diff --git a/CHANGELOG.md b/CHANGELOG.md index 459145bf09ed..20481a145fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/cli/path.md b/docs/cli/path.md index 1688c30c7963..8772e16dc7a0 100644 --- a/docs/cli/path.md +++ b/docs/cli/path.md @@ -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 ` 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' ``` diff --git a/extensions/oc-path/src/cli.test.ts b/extensions/oc-path/src/cli.test.ts index 0b3a9fca018d..3cd8f46287a8 100644 --- a/extensions/oc-path/src/cli.test.ts +++ b/extensions/oc-path/src/cli.test.ts @@ -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"); diff --git a/extensions/oc-path/src/cli.ts b/extensions/oc-path/src/cli.ts index a0b8a99e7eaf..8c43b3572f5e 100644 --- a/extensions/oc-path/src/cli.ts +++ b/extensions/oc-path/src/cli.ts @@ -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( } /** 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( async function loadAst(absPath: string, fileName: string): Promise { 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 { const mode = detectMode(options); - if (!requireArg(pathStr, "resolve: missing argument", runtime, mode)) {return;} + if (!requireArg(pathStr, "resolve: missing 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 { const mode = detectMode(options); - if (!requireArg(pathStr, "set: requires ", runtime, mode)) {return;} - if (!requireArg(value, "set: requires ", runtime, mode)) {return;} + if (!requireArg(pathStr, "set: requires ", runtime, mode)) { + return; + } + if (!requireArg(value, "set: requires ", 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 { const mode = detectMode(options); - if (!requireArg(patternStr, "find: missing argument", runtime, mode)) {return;} + if (!requireArg(patternStr, "find: missing 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 argument", runtime, mode)) {return;} + if (!requireArg(pathStr, "validate: missing 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 { const mode = detectMode(options); - if (!requireArg(fileArg, "emit: missing argument", runtime, mode)) {return;} + if (!requireArg(fileArg, "emit: missing 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 to write") .argument("", "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); });