/** * `openclaw path` — shell access to the OcPath substrate verbs. * * Subcommands: `resolve` / `set` / `find` / `validate` / `emit`. * TTY-aware output: human when interactive, JSON when piped; `--json` * / `--human` override. */ import { promises as fs } from "node:fs"; import { resolve as resolvePath } from "node:path"; import type { Command } from "commander"; import { OcEmitSentinelError, OcPathError, REDACTED_SENTINEL, emitJsonc, emitJsonl, emitMd, emitYaml, findOcPaths, formatOcPath, inferKind, parseJsonc, parseJsonl, parseMd, parseOcPath, parseYaml, resolveOcPath, setOcPath, type OcAst, type OcMatch, type OcPath, } from "./oc-path/index.js"; export type OutputRuntimeEnv = { writeStdout(value: string): void; error(value: string): void; exit(code: number): void; }; export interface PathCommandOptions { readonly json?: boolean; readonly human?: boolean; readonly valueJson?: boolean; readonly cwd?: string; readonly file?: string; readonly dryRun?: boolean; readonly diff?: boolean; } type OutputMode = "human" | "json"; const SCRUB_PLACEHOLDER = "[REDACTED]"; const defaultRuntime: OutputRuntimeEnv = { writeStdout(value) { process.stdout.write(value); }, error(value) { process.stderr.write(`${value}\n`); }, exit(code) { process.exitCode = code; }, }; // 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; } 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"; } return process.stdout.isTTY ? "human" : "json"; } function emit( runtime: OutputRuntimeEnv, mode: OutputMode, value: unknown, humanFallback: () => string, ): void { if (mode === "json") { runtime.writeStdout(scrubSentinel(JSON.stringify(value, null, 2))); return; } runtime.writeStdout(scrubSentinel(humanFallback())); } function emitError( runtime: OutputRuntimeEnv, mode: OutputMode, message: string, code = "ERR", ): void { const scrubbed = scrubSentinel(message); if (mode === "json") { runtime.error(JSON.stringify({ error: { code, message: scrubbed } })); return; } runtime.error(`${code}: ${scrubbed}`); } /** Bail with usage error if a required arg is missing. */ function requireArg( value: T | undefined, usage: string, runtime: OutputRuntimeEnv, mode: OutputMode, ): value is T extends undefined ? never : T { if (value === undefined) { emitError(runtime, mode, usage); runtime.exit(2); return false; } return true; } /** Parse an oc-path string; emit structured error and return null on failure. */ function tryParse(pathStr: string, runtime: OutputRuntimeEnv, mode: OutputMode): OcPath | null { try { return parseOcPath(pathStr); } catch (err) { if (err instanceof OcPathError) { emitError(runtime, mode, `parse failed: ${err.message}`, err.code); runtime.exit(2); return null; } throw err; } } // Catch OcEmitSentinelError so it goes through the structured error // path; otherwise commander prints `String(err)` raw and bypasses the // `--json` scrubbed-error boundary. function catchSentinel( label: string, runtime: OutputRuntimeEnv, mode: OutputMode, fn: () => T, ): T | null { try { return fn(); } catch (err) { if (err instanceof OcEmitSentinelError) { emitError(runtime, mode, `${label} refused: ${err.message}`, "OC_EMIT_SENTINEL"); runtime.exit(1); return null; } throw err; } } 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 === "yaml") { return parseYaml(raw).ast; } return parseMd(raw).ast; } function emitForKind(ast: OcAst, fileName?: string): string { // Plumb fileName so sentinel errors carry file context. const opts = fileName !== undefined ? { fileNameForGuard: fileName } : {}; switch (ast.kind) { case "jsonc": return emitJsonc(ast, opts); case "jsonl": return emitJsonl(ast, opts); case "md": return emitMd(ast, opts); case "yaml": return emitYaml(ast, opts); } return ""; } function resolveFsPath(path: OcPath, options: PathCommandOptions): string { if (options.file !== undefined) { return resolvePath(options.file); } return resolvePath(options.cwd ?? process.cwd(), path.file); } 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 === "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( pathStr: string | undefined, options: PathCommandOptions, runtime: OutputRuntimeEnv, ): Promise { const mode = detectMode(options); if (!requireArg(pathStr, "resolve: missing argument", runtime, mode)) { return; } const ocPath = tryParse(pathStr, runtime, mode); if (ocPath === null) { return; } const ast = await loadAst(resolveFsPath(ocPath, options), ocPath.file); let match: OcMatch | null; try { match = resolveOcPath(ast, ocPath); } catch (err) { if (err instanceof OcPathError) { // resolveOcPath throws on wildcard patterns — point at find. emitError(runtime, mode, `resolve refused: ${err.message}`, err.code); runtime.exit(2); return; } throw err; } if (match === null) { emit(runtime, mode, { resolved: false, ocPath: pathStr }, () => `not found: ${pathStr}`); runtime.exit(1); return; } emit(runtime, mode, { resolved: true, ocPath: pathStr, match }, () => formatMatchHuman(match)); } export async function pathSetCommand( pathStr: string | undefined, value: string | undefined, options: PathCommandOptions, runtime: OutputRuntimeEnv, ): Promise { const mode = detectMode(options); 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; } 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, { valueJson: options.valueJson === true }), ); if (result === null) { return; } if (!result.ok) { const detail = "detail" in result ? result.detail : undefined; emit( runtime, mode, { ok: false, reason: result.reason, detail }, () => `set failed: ${result.reason}${detail !== undefined ? ` — ${detail}` : ""}`, ); runtime.exit(1); 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; } if (options.dryRun === true) { const diff = options.diff === true ? formatUnifiedDiff(oldBytes, newBytes, fsPath) : undefined; emit( runtime, mode, { 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; } await fs.writeFile(fsPath, newBytes, "utf-8"); emit( runtime, mode, { ok: true, dryRun: false, bytesWritten: newBytes.length, fsPath }, () => `wrote ${newBytes.length} bytes to ${fsPath}`, ); } export async function pathFindCommand( patternStr: string | undefined, options: PathCommandOptions, runtime: OutputRuntimeEnv, ): Promise { const mode = detectMode(options); if (!requireArg(patternStr, "find: missing argument", runtime, mode)) { return; } const pattern = tryParse(patternStr, runtime, mode); if (pattern === null) { return; } // File-slot wildcards would silently ENOENT during readFile; reject. if (/[*?]/.test(pattern.file)) { emitError( runtime, mode, `find: file-slot wildcards are not supported (got "${pattern.file}"). ` + `Pass a concrete file path; multi-file globbing is a follow-up feature.`, "OC_PATH_FILE_WILDCARD_UNSUPPORTED", ); runtime.exit(2); return; } const ast = await loadAst(resolveFsPath(pattern, options), pattern.file); const matches = findOcPaths(ast, pattern); emit( runtime, mode, { pattern: patternStr, count: matches.length, matches: matches.map((m) => ({ path: formatOcPath(m.path), match: m.match })), }, () => { 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) { lines.push(` ${formatOcPath(m.path)} → ${formatMatchHuman(m.match)}`); } return lines.join("\n"); }, ); if (matches.length === 0) { runtime.exit(1); } } export function pathValidateCommand( pathStr: string | undefined, options: PathCommandOptions, runtime: OutputRuntimeEnv, ): void { const mode = detectMode(options); if (!requireArg(pathStr, "validate: missing argument", runtime, mode)) { return; } try { const ocPath = parseOcPath(pathStr); emit( runtime, mode, { valid: true, ocPath: pathStr, formatted: formatOcPath(ocPath), structure: { file: ocPath.file, section: ocPath.section, item: ocPath.item, field: ocPath.field, session: ocPath.session, }, }, () => { 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}`); } return lines.join("\n"); }, ); } catch (err) { if (err instanceof OcPathError) { emit( runtime, mode, { valid: false, code: err.code, message: err.message }, () => `INVALID: ${err.code}: ${err.message}`, ); runtime.exit(1); return; } throw err; } } export async function pathEmitCommand( fileArg: string | undefined, options: PathCommandOptions, runtime: OutputRuntimeEnv, ): Promise { const mode = detectMode(options); if (!requireArg(fileArg, "emit: missing argument", runtime, mode)) { return; } const fsPath = options.file !== undefined ? resolvePath(options.file) : resolvePath(options.cwd ?? process.cwd(), fileArg); 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 (mode === "json") { runtime.writeStdout(scrubSentinel(JSON.stringify({ ok: true, kind: ast.kind, bytes }))); return; } runtime.writeStdout(bytes); } // ---------- Commander wiring --------------------------------------------- function withCommonOpts(cmd: Command): Command { return cmd .option("--json", "Force JSON output") .option("--human", "Force human output") .option("--cwd ", "Resolve file slot against this directory") .option("--file ", "Override the file slot's resolved path"); } export function registerPathCli(program: Command): void { const path = program .command("path") .description("Inspect and edit workspace files via the oc:// addressing scheme") .addHelpText("after", "\nDocs: https://docs.openclaw.ai/cli/path\n"); withCommonOpts( path .command("resolve") .description("Print the match at an oc:// path") .argument("", "oc:// path to resolve"), ).action(async (pathStr: string, opts: PathCommandOptions) => { await pathResolveCommand(pathStr, opts, defaultRuntime); }); withCommonOpts( path .command("find") .description("Enumerate matches for a wildcard / predicate oc:// pattern") .argument("", "oc:// pattern"), ).action(async (patternStr: string, opts: PathCommandOptions) => { await pathFindCommand(patternStr, opts, defaultRuntime); }); withCommonOpts( path .command("set") .description("Write a leaf value at an oc:// path") .argument("", "oc:// path to write") .argument("", "string value to write") .option("--value-json", "Parse as JSON for JSON/JSONC/JSONL leaf replacement") .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); }); path .command("validate") .description("Parse an oc:// path and print its slot structure") .argument("", "oc:// path to validate") .option("--json", "Force JSON output") .option("--human", "Force human output") .action((pathStr: string, opts: PathCommandOptions) => { pathValidateCommand(pathStr, opts, defaultRuntime); }); withCommonOpts( path .command("emit") .description("Round-trip a file through parse + emit") .argument("", "Path to a workspace file"), ).action(async (fileArg: string, opts: PathCommandOptions) => { await pathEmitCommand(fileArg, opts, defaultRuntime); }); // Bare `openclaw path` prints help and exits 0 (matches the core // applyParentDefaultHelpAction contract — see openclaw#73077). path.action(() => { path.outputHelp(); process.exitCode = 0; }); }