/** * `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, findOcPaths, formatOcPath, inferKind, parseJsonc, parseJsonl, parseMd, parseOcPath, 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 cwd?: string; readonly file?: string; readonly dryRun?: 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;} 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); } 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}`; } // ---------- 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;} const ocPath = tryParse(pathStr, runtime, mode); if (ocPath === null) {return;} const fsPath = resolveFsPath(ocPath, options); const ast = await loadAst(fsPath, ocPath.file); const result = catchSentinel("set", runtime, mode, () => setOcPath(ast, ocPath, value)); 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) { emit( runtime, mode, { ok: true, dryRun: true, bytes: newBytes }, () => `--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("--dry-run", "Print bytes without writing"), ).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; }); }