Files
openclaw/extensions/oc-path/src/cli.ts

463 lines
14 KiB
TypeScript

/**
* `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<T>(
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<T>(
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<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;}
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<void> {
const mode = detectMode(options);
if (!requireArg(pathStr, "resolve: missing <oc-path> 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<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;}
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<void> {
const mode = detectMode(options);
if (!requireArg(patternStr, "find: missing <pattern> 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 <oc-path> 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<void> {
const mode = detectMode(options);
if (!requireArg(fileArg, "emit: missing <file> 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 <dir>", "Resolve file slot against this directory")
.option("--file <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>", "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("<pattern>", "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>", "oc:// path to write")
.argument("<value>", "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>", "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("<file>", "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;
});
}