OC Path: add dry-run diff output (#81437)

* OC Path: add dry-run diff output

* fix(oc-path): require dry-run for diff output

* fix(oc-path): show final newline diff

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(oc-path): show line-ending-only dry-run diffs

---------

Co-authored-by: Gio Della-Libera <giodl@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Gio Della-Libera
2026-05-15 21:54:06 -07:00
committed by GitHub
parent f436b4310a
commit c8bec51869
4 changed files with 253 additions and 39 deletions
+2
View File
@@ -85,6 +85,8 @@ Docs: https://docs.openclaw.ai
- Providers/OpenRouter: stop adding empty DeepSeek V4 `reasoning_content` placeholders to assistant tool-call replay messages and strip empty replay artifacts before follow-up Chat Completions requests, so `openrouter/deepseek/deepseek-v4-pro` no longer fails after tool use. Fixes #82150. (#82158) Thanks @luyao618 and @Suquir0.
- OpenAI-compatible providers: honor streaming-usage compatibility metadata when deciding whether to send `stream_options.include_usage`, while keeping bundled Volcengine routes opted in to Ark streaming usage. Refs #44845. (#82181) Thanks @xuruiray.
- Gateway/approvals: treat `turnSourceTo` as optional in `canBridgeNoDeviceChatApprovalFromBackend`, matching the existing optional handling of `turnSourceAccountId` and `turnSourceThreadId`. Channels without a recipient concept (webchat, control-ui) leave `turnSourceTo` null on both the approval snapshot and the replay params, so the prior required-string check rejected every backend replay with `APPROVAL_CLIENT_MISMATCH`. Cross-channel replay is still gated by the required `turnSourceChannel` and `sessionKey` checks. Fixes #82132. (#82136) Thanks @ottodeng.
- OC Path: add `openclaw path set --dry-run --diff` so addressed edits can be reviewed as a unified diff before writing.
- Cron: load runtime plugins before isolated cron model and delivery resolution so external channels can be selected for scheduled runs. (#82111) Thanks @medns.
- Cron: mirror successful direct scheduled deliveries into the resolved destination session transcript while preserving isolated-delivery awareness policy. (#80786) Thanks @cavit99.
- Cron: preserve rotated transcript identity after session-bound scheduled runs compact, so `sessionTarget: "current"` keeps the next user message on the same conversation. Fixes #82164. Thanks @weissfl.
+10 -3
View File
@@ -135,6 +135,7 @@ matches you can inspect before choosing one to write.
| `--json` | Force JSON output (default when stdout is not a TTY). |
| `--human` | Force human output (default when stdout is a TTY). |
| `--dry-run` | (only on `set`) print the bytes that would be written without writing. |
| `--diff` | (with `set --dry-run`) print a unified diff instead of the full bytes. |
## `oc://` syntax
@@ -202,6 +203,8 @@ the per-kind AST shape.
Use `--dry-run` before user-visible writes when the exact bytes matter. The
substrate preserves byte-identical output for parse/emit round-trips, but a
mutation can canonicalize the edited region or file depending on kind.
Add `--diff` when you want the preview as a focused before/after patch instead
of the full rendered file.
## Examples
@@ -218,6 +221,9 @@ openclaw path find 'oc://session.jsonl/*/event' --file ./logs/session.jsonl
# Dry-run a write
openclaw path set 'oc://gateway.jsonc/version' '2.0' --dry-run
# Dry-run a write as a unified diff
openclaw path set 'oc://gateway.jsonc/version' '2.0' --dry-run --diff
# Apply the write
openclaw path set 'oc://gateway.jsonc/version' '2.0'
@@ -375,12 +381,13 @@ openclaw path find 'oc://config.jsonc/plugins/{github,slack}/enabled'
### `set <oc-path> <value>`
Write a leaf. Pair with `--dry-run` to preview the bytes that would be
written without touching the file. Exits `0` on a successful write, `1` if
the substrate refuses (for example, a sentinel guard hit), `2` on parse
errors.
written without touching the file. Add `--diff` for a unified diff preview.
Exits `0` on a successful write, `1` if the substrate refuses (for example, a
sentinel guard hit), `2` on parse errors.
```bash
openclaw path set 'oc://gateway.jsonc/version' '2.0' --dry-run
openclaw path set 'oc://gateway.jsonc/version' '2.0' --dry-run --diff
openclaw path set 'oc://gateway.jsonc/version' '2.0'
openclaw path set 'oc://AGENTS.md/Tools/+gh/risk' 'low'
```
+87
View File
@@ -12,6 +12,7 @@ import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
type OutputRuntimeEnv,
formatUnifiedDiff,
pathEmitCommand,
pathFindCommand,
pathResolveCommand,
@@ -152,6 +153,92 @@ describe("openclaw path CLI", () => {
expect(readFileSync(filePath, "utf-8")).toBe(before);
});
it("CLI-S05 --dry-run --diff prints a unified diff", async () => {
const filePath = join(workspaceDir, "gateway.jsonc");
const before = '{\n "version": "1.0",\n "enabled": true\n}\n';
writeFileSync(filePath, before, "utf-8");
const rt = createTestRuntime();
await pathSetCommand(
"oc://gateway.jsonc/version",
"2.0",
{ cwd: workspaceDir, human: true, dryRun: true, diff: true },
rt,
);
expect(rt.exitCode).toBe(0);
const out = stdoutText(rt);
expect(out).toContain("--- ");
expect(out).toContain("+++ ");
expect(out).toContain('- "version": "1.0",');
expect(out).toContain('+ "version": "2.0",');
expect(readFileSync(filePath, "utf-8")).toBe(before);
});
it("CLI-S05b --dry-run --diff shows final newline-only byte changes", () => {
const out = formatUnifiedDiff(
"## Boundaries\n\n- timeout: 5\n",
"## Boundaries\n\n- timeout: 5",
"AGENTS.md",
);
expect(out).toContain("--- AGENTS.md");
expect(out).toContain("@@ -1,4 +1,3 @@");
expect(out).toContain("\n-\n");
});
it("CLI-S05c --dry-run --diff shows line-ending-only byte changes", async () => {
const filePath = join(workspaceDir, "AGENTS.md");
const before = "---\r\nname: x\r\n---\r\n";
writeFileSync(filePath, before, "utf-8");
const rt = createTestRuntime();
await pathSetCommand(
"oc://AGENTS.md/[frontmatter]/name",
"x",
{ cwd: workspaceDir, json: true, dryRun: true, diff: true },
rt,
);
expect(rt.exitCode).toBe(0);
const out = JSON.parse(stdoutText(rt));
expect(out.diff).toContain("-name: x\r");
expect(out.diff).toContain("+name: x");
expect(readFileSync(filePath, "utf-8")).toBe(before);
});
it("CLI-S06 --dry-run --diff includes diff in JSON output", async () => {
const filePath = join(workspaceDir, "gateway.jsonc");
writeFileSync(filePath, '{ "version": "1.0" }', "utf-8");
const rt = createTestRuntime();
await pathSetCommand(
"oc://gateway.jsonc/version",
"2.0",
{ cwd: workspaceDir, json: true, dryRun: true, diff: true },
rt,
);
expect(rt.exitCode).toBe(0);
const out = JSON.parse(stdoutText(rt));
expect(out.dryRun).toBe(true);
expect(out.bytes).toContain('"2.0"');
expect(out.diff).toContain('-{ "version": "1.0" }');
expect(out.diff).toContain('+{ "version": "2.0" }');
});
it("CLI-S07 rejects --diff without --dry-run", async () => {
const filePath = join(workspaceDir, "gateway.jsonc");
const before = '{ "version": "1.0" }';
writeFileSync(filePath, before, "utf-8");
const rt = createTestRuntime();
await pathSetCommand(
"oc://gateway.jsonc/version",
"2.0",
{ cwd: workspaceDir, json: true, diff: true },
rt,
);
expect(rt.exitCode).toBe(1);
expect(JSON.parse(stdoutText(rt))).toMatchObject({
ok: false,
reason: "--diff requires --dry-run",
});
expect(readFileSync(filePath, "utf-8")).toBe(before);
});
it("CLI-S03 sentinel-bearing value is refused at emit", async () => {
const filePath = join(workspaceDir, "gateway.jsonc");
writeFileSync(filePath, '{ "token": "x" }', "utf-8");
+154 -36
View File
@@ -42,6 +42,7 @@ export interface PathCommandOptions {
readonly cwd?: string;
readonly file?: string;
readonly dryRun?: boolean;
readonly diff?: boolean;
}
type OutputMode = "human" | "json";
@@ -63,13 +64,19 @@ const defaultRuntime: OutputRuntimeEnv = {
// Defense-in-depth: replace the redaction sentinel with `[REDACTED]`
// before writing, even if upstream emits it.
export function scrubSentinel(s: string): string {
if (!s.includes(REDACTED_SENTINEL)) {return s;}
if (!s.includes(REDACTED_SENTINEL)) {
return s;
}
return s.split(REDACTED_SENTINEL).join(SCRUB_PLACEHOLDER);
}
function detectMode(options: PathCommandOptions): OutputMode {
if (options.json === true) {return "json";}
if (options.human === true) {return "human";}
if (options.json === true) {
return "json";
}
if (options.human === true) {
return "human";
}
return process.stdout.isTTY ? "human" : "json";
}
@@ -116,11 +123,7 @@ function requireArg<T>(
}
/** Parse an oc-path string; emit structured error and return null on failure. */
function tryParse(
pathStr: string,
runtime: OutputRuntimeEnv,
mode: OutputMode,
): OcPath | null {
function tryParse(pathStr: string, runtime: OutputRuntimeEnv, mode: OutputMode): OcPath | null {
try {
return parseOcPath(pathStr);
} catch (err) {
@@ -157,8 +160,12 @@ function catchSentinel<T>(
async function loadAst(absPath: string, fileName: string): Promise<OcAst> {
const raw = await fs.readFile(absPath, "utf-8");
const kind = inferKind(fileName);
if (kind === "jsonc") {return parseJsonc(raw).ast;}
if (kind === "jsonl") {return parseJsonl(raw).ast;}
if (kind === "jsonc") {
return parseJsonc(raw).ast;
}
if (kind === "jsonl") {
return parseJsonl(raw).ast;
}
return parseMd(raw).ast;
}
@@ -177,7 +184,9 @@ function emitForKind(ast: OcAst, fileName?: string): string {
}
function resolveFsPath(path: OcPath, options: PathCommandOptions): string {
if (options.file !== undefined) {return resolvePath(options.file);}
if (options.file !== undefined) {
return resolvePath(options.file);
}
return resolvePath(options.cwd ?? process.cwd(), path.file);
}
@@ -185,13 +194,72 @@ function formatMatchHuman(match: OcMatch): string {
if (match.kind === "leaf") {
return `leaf @ L${match.line}: ${JSON.stringify(match.valueText)} (${match.leafType})`;
}
if (match.kind === "node") {return `node @ L${match.line} [${match.descriptor}]`;}
if (match.kind === "node") {
return `node @ L${match.line} [${match.descriptor}]`;
}
if (match.kind === "insertion-point") {
return `insertion-point @ L${match.line} [${match.container}]`;
}
return `root @ L${match.line}`;
}
function splitDiffLines(s: string): readonly string[] {
return s === "" ? [] : s.split("\n");
}
export function formatUnifiedDiff(oldBytes: string, newBytes: string, fsPath: string): string {
if (oldBytes === newBytes) {
return "";
}
const oldLines = splitDiffLines(oldBytes);
const newLines = splitDiffLines(newBytes);
let prefix = 0;
while (
prefix < oldLines.length &&
prefix < newLines.length &&
oldLines[prefix] === newLines[prefix]
) {
prefix++;
}
let oldSuffix = oldLines.length - 1;
let newSuffix = newLines.length - 1;
while (
oldSuffix >= prefix &&
newSuffix >= prefix &&
oldLines[oldSuffix] === newLines[newSuffix]
) {
oldSuffix--;
newSuffix--;
}
const context = 3;
const hunkStart = Math.max(0, prefix - context);
const hunkOldEnd = Math.min(oldLines.length - 1, oldSuffix + context);
const hunkNewEnd = Math.min(newLines.length - 1, newSuffix + context);
const oldCount = Math.max(0, hunkOldEnd - hunkStart + 1);
const newCount = Math.max(0, hunkNewEnd - hunkStart + 1);
const lines = [
`--- ${fsPath}`,
`+++ ${fsPath}`,
`@@ -${hunkStart + 1},${oldCount} +${hunkStart + 1},${newCount} @@`,
];
for (let i = hunkStart; i < prefix; i++) {
lines.push(` ${oldLines[i] ?? ""}`);
}
for (let i = prefix; i <= oldSuffix; i++) {
lines.push(`-${oldLines[i] ?? ""}`);
}
for (let i = prefix; i <= newSuffix; i++) {
lines.push(`+${newLines[i] ?? ""}`);
}
for (let i = Math.max(oldSuffix + 1, prefix); i <= hunkOldEnd; i++) {
lines.push(` ${oldLines[i] ?? ""}`);
}
return `${lines.join("\n")}\n`;
}
// ---------- Commands -----------------------------------------------------
export async function pathResolveCommand(
@@ -200,9 +268,13 @@ export async function pathResolveCommand(
runtime: OutputRuntimeEnv,
): Promise<void> {
const mode = detectMode(options);
if (!requireArg(pathStr, "resolve: missing <oc-path> argument", runtime, mode)) {return;}
if (!requireArg(pathStr, "resolve: missing <oc-path> argument", runtime, mode)) {
return;
}
const ocPath = tryParse(pathStr, runtime, mode);
if (ocPath === null) {return;}
if (ocPath === null) {
return;
}
const ast = await loadAst(resolveFsPath(ocPath, options), ocPath.file);
let match: OcMatch | null;
try {
@@ -231,15 +303,34 @@ export async function pathSetCommand(
runtime: OutputRuntimeEnv,
): Promise<void> {
const mode = detectMode(options);
if (!requireArg(pathStr, "set: requires <oc-path> <value>", runtime, mode)) {return;}
if (!requireArg(value, "set: requires <oc-path> <value>", runtime, mode)) {return;}
if (!requireArg(pathStr, "set: requires <oc-path> <value>", runtime, mode)) {
return;
}
if (!requireArg(value, "set: requires <oc-path> <value>", runtime, mode)) {
return;
}
if (options.diff === true && options.dryRun !== true) {
emit(
runtime,
mode,
{ ok: false, reason: "--diff requires --dry-run" },
() => "set failed: --diff requires --dry-run",
);
runtime.exit(1);
return;
}
const ocPath = tryParse(pathStr, runtime, mode);
if (ocPath === null) {return;}
if (ocPath === null) {
return;
}
const fsPath = resolveFsPath(ocPath, options);
const oldBytes = await fs.readFile(fsPath, "utf-8");
const ast = await loadAst(fsPath, ocPath.file);
const result = catchSentinel("set", runtime, mode, () => setOcPath(ast, ocPath, value));
if (result === null) {return;}
if (result === null) {
return;
}
if (!result.ok) {
const detail = "detail" in result ? result.detail : undefined;
emit(
@@ -252,17 +343,21 @@ export async function pathSetCommand(
return;
}
// Per-kind emit can still refuse the sentinel even after set succeeds.
const newBytes = catchSentinel("emit", runtime, mode, () =>
emitForKind(result.ast, ocPath.file),
);
if (newBytes === null) {return;}
const newBytes = catchSentinel("emit", runtime, mode, () => emitForKind(result.ast, ocPath.file));
if (newBytes === null) {
return;
}
if (options.dryRun === true) {
const diff = options.diff === true ? formatUnifiedDiff(oldBytes, newBytes, fsPath) : undefined;
emit(
runtime,
mode,
{ ok: true, dryRun: true, bytes: newBytes },
() => `--dry-run: would write ${newBytes.length} bytes to ${fsPath}\n${newBytes}`,
{ ok: true, dryRun: true, bytes: newBytes, ...(diff !== undefined ? { diff } : {}) },
() =>
diff !== undefined
? diff || `--dry-run: no byte changes for ${fsPath}`
: `--dry-run: would write ${newBytes.length} bytes to ${fsPath}\n${newBytes}`,
);
return;
}
@@ -281,9 +376,13 @@ export async function pathFindCommand(
runtime: OutputRuntimeEnv,
): Promise<void> {
const mode = detectMode(options);
if (!requireArg(patternStr, "find: missing <pattern> argument", runtime, mode)) {return;}
if (!requireArg(patternStr, "find: missing <pattern> argument", runtime, mode)) {
return;
}
const pattern = tryParse(patternStr, runtime, mode);
if (pattern === null) {return;}
if (pattern === null) {
return;
}
// File-slot wildcards would silently ENOENT during readFile; reject.
if (/[*?]/.test(pattern.file)) {
emitError(
@@ -307,7 +406,9 @@ export async function pathFindCommand(
matches: matches.map((m) => ({ path: formatOcPath(m.path), match: m.match })),
},
() => {
if (matches.length === 0) {return `0 matches for ${patternStr}`;}
if (matches.length === 0) {
return `0 matches for ${patternStr}`;
}
const plural = matches.length === 1 ? "" : "es";
const lines = [`${matches.length} match${plural} for ${patternStr}:`];
for (const m of matches) {
@@ -316,7 +417,9 @@ export async function pathFindCommand(
return lines.join("\n");
},
);
if (matches.length === 0) {runtime.exit(1);}
if (matches.length === 0) {
runtime.exit(1);
}
}
export function pathValidateCommand(
@@ -325,7 +428,9 @@ export function pathValidateCommand(
runtime: OutputRuntimeEnv,
): void {
const mode = detectMode(options);
if (!requireArg(pathStr, "validate: missing <oc-path> argument", runtime, mode)) {return;}
if (!requireArg(pathStr, "validate: missing <oc-path> argument", runtime, mode)) {
return;
}
try {
const ocPath = parseOcPath(pathStr);
emit(
@@ -345,10 +450,18 @@ export function pathValidateCommand(
},
() => {
const lines = [`valid: ${pathStr}`, ` file: ${ocPath.file}`];
if (ocPath.section !== undefined) {lines.push(` section: ${ocPath.section}`);}
if (ocPath.item !== undefined) {lines.push(` item: ${ocPath.item}`);}
if (ocPath.field !== undefined) {lines.push(` field: ${ocPath.field}`);}
if (ocPath.session !== undefined) {lines.push(` session: ${ocPath.session}`);}
if (ocPath.section !== undefined) {
lines.push(` section: ${ocPath.section}`);
}
if (ocPath.item !== undefined) {
lines.push(` item: ${ocPath.item}`);
}
if (ocPath.field !== undefined) {
lines.push(` field: ${ocPath.field}`);
}
if (ocPath.session !== undefined) {
lines.push(` session: ${ocPath.session}`);
}
return lines.join("\n");
},
);
@@ -373,7 +486,9 @@ export async function pathEmitCommand(
runtime: OutputRuntimeEnv,
): Promise<void> {
const mode = detectMode(options);
if (!requireArg(fileArg, "emit: missing <file> argument", runtime, mode)) {return;}
if (!requireArg(fileArg, "emit: missing <file> argument", runtime, mode)) {
return;
}
const fsPath =
options.file !== undefined
? resolvePath(options.file)
@@ -381,7 +496,9 @@ export async function pathEmitCommand(
const fileName = fsPath.split(/[\\/]/).pop() ?? fileArg;
const ast = await loadAst(fsPath, fileName);
const bytes = catchSentinel("emit", runtime, mode, () => emitForKind(ast, fileName));
if (bytes === null) {return;}
if (bytes === null) {
return;
}
if (mode === "json") {
runtime.writeStdout(scrubSentinel(JSON.stringify({ ok: true, kind: ast.kind, bytes })));
return;
@@ -429,7 +546,8 @@ export function registerPathCli(program: Command): void {
.description("Write a leaf value at an oc:// path")
.argument("<oc-path>", "oc:// path to write")
.argument("<value>", "string value to write")
.option("--dry-run", "Print bytes without writing"),
.option("--dry-run", "Print bytes without writing")
.option("--diff", "With --dry-run, print a unified diff instead of full bytes"),
).action(async (pathStr: string, value: string, opts: PathCommandOptions) => {
await pathSetCommand(pathStr, value, opts, defaultRuntime);
});