diff --git a/docs/cli/approvals.md b/docs/cli/approvals.md index 2681a04fd4f9..e216cb49bcbc 100644 --- a/docs/cli/approvals.md +++ b/docs/cli/approvals.md @@ -26,11 +26,14 @@ openclaw exec-policy show --json openclaw exec-policy preset yolo openclaw exec-policy preset cautious --json -openclaw exec-policy set --host gateway --security full --ask off --ask-fallback full +openclaw exec-policy set --host gateway --security full --ask off --ask-fallback full --json ``` Presets (`yolo`, `cautious`, `deny-all`) apply `host`, `security`, `ask`, and `askFallback` together. `set` applies only the flags you pass; each accepted value is validated (`--host auto|sandbox|gateway|node`, `--security deny|allowlist|full`, `--ask off|on-miss|always`, `--ask-fallback deny|allowlist|full`). +`show`, `preset`, and `set` accept `--json` and return the same requested, +host, and effective policy facts as one JSON object. + Scope: - Updates the local config file and local approvals document together; does not push policy to the gateway or a node host. diff --git a/docs/cli/backup.md b/docs/cli/backup.md index 4cc142daa8c4..f6c58331b21f 100644 --- a/docs/cli/backup.md +++ b/docs/cli/backup.md @@ -27,6 +27,9 @@ openclaw backup sqlite verify ~/Backups/openclaw-sqlite/ --scratch openclaw backup sqlite restore ~/Backups/openclaw-sqlite/ --target ./restored/openclaw.sqlite ``` +Archive `create` and `verify`, plus SQLite `create`, `list`, `verify`, and +`restore`, accept `--json` for one machine-readable result on stdout. + ## Notes - The archive embeds a `manifest.json` with the resolved source paths and archive layout. diff --git a/docs/cli/config.md b/docs/cli/config.md index 2d45f205d014..779688d9a956 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -24,9 +24,11 @@ Guided sections: `workspace`, `model`, `web`, `gateway`, `daemon`, `channels`, ` ```bash openclaw config file +openclaw config file --json openclaw config --section model openclaw config --section gateway --section daemon openclaw config schema +openclaw config schema --json openclaw config get browser.executablePath openclaw config set browser.executablePath "/usr/bin/google-chrome" openclaw config set browser.profiles.work.executablePath "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" @@ -68,6 +70,8 @@ openclaw config get agents.defaults.model --json Prints the active config file path, resolved from `OPENCLAW_CONFIG_PATH` or the default location. The path names a regular file, not a symlink; see [Write safety](#write-safety). +With `--json`, stdout contains an object with the resolved path under `path`. + ### `config schema` Prints the generated JSON schema for `openclaw.json` to stdout. @@ -89,9 +93,13 @@ Prints the generated JSON schema for `openclaw.json` to stdout. ```bash openclaw config schema +openclaw config schema --json openclaw config schema > openclaw.schema.json ``` +The schema is JSON in both modes. `--json` is accepted as the explicit +machine-output spelling and keeps stdout reserved for the schema document. + ### `config validate` Validates the current config against the active schema without starting the gateway. diff --git a/docs/cli/cron.md b/docs/cli/cron.md index e513ba13ffab..69c656c17f8e 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -293,18 +293,20 @@ Manual run and inspection: openclaw automations list openclaw automations list --agent ops openclaw automations get +openclaw automations get --json openclaw automations show openclaw automations run openclaw automations run --due openclaw automations run --wait --wait-timeout 10m openclaw automations run --wait --wait-timeout 10m --poll-interval 2s openclaw automations runs --id --limit 50 +openclaw automations runs --id --limit 50 --json openclaw automations runs --id --run-id ``` `openclaw automations list` shows enabled jobs by default. Pass `--all` to include disabled jobs, or `--agent ` to show only jobs whose effective normalized agent id matches; jobs without a stored agent id count as the configured default agent. -`openclaw automations get ` returns the stored job JSON directly. Use `automations show ` when you want the human-readable view with delivery-route preview. +`openclaw automations get ` returns the stored job JSON directly. `get` and `runs` accept `--json` as the explicit machine-output spelling. Use `automations show ` when you want the human-readable view with delivery-route preview. `automations list --json` and `automations show --json` include a top-level `status` field on each job, computed from `enabled`, `state.runningAtMs`, and `state.lastRunStatus`. Values: `disabled`, `running`, `ok`, `error`, `skipped`, or `idle`. JSON status stays canonical and undecorated so external tooling can read job state without re-deriving it; human output may decorate repeated `error` statuses with a failure count. diff --git a/docs/cli/docs.md b/docs/cli/docs.md index 769007512a09..6fcccd7532e5 100644 --- a/docs/cli/docs.md +++ b/docs/cli/docs.md @@ -13,13 +13,15 @@ Search the live OpenClaw docs index from the terminal. ## Usage ```bash -openclaw docs # print docs entrypoint and example search -openclaw docs # search the live docs index +openclaw docs # print docs entrypoint and example search +openclaw docs --json # print the same guidance as JSON +openclaw docs [--json] # search the live docs index ``` -| Argument | Description | -| ------------ | ---------------------------------------------------------------------------------- | -| `[query...]` | Free-form search query. Multi-word queries are joined with spaces and sent as one. | +| Argument/option | Description | +| --------------- | ---------------------------------------------------------------------------------- | +| `[query...]` | Free-form search query. Multi-word queries are joined with spaces and sent as one. | +| `--json` | Emit one machine-readable JSON object on stdout. | With no query, `openclaw docs` prints the docs entrypoint URL and a sample search command instead of running a search. @@ -27,6 +29,7 @@ With no query, `openclaw docs` prints the docs entrypoint URL and a sample searc ```bash openclaw docs browser existing-session +openclaw docs browser existing-session --json openclaw docs sandbox allowHostControl openclaw docs gateway token secretref ``` @@ -48,6 +51,11 @@ In non-rich output (piped, `--no-color`, scripts), the same data renders as Mark - [Title](https://docs.openclaw.ai/...) - snippet ``` +With `--json`, stdout contains one object with the normalized query and result +list. With no query, `query` is `null`, `url` is the docs entrypoint, and +`results` is empty. Styling and headings are suppressed; request diagnostics +stay on stderr so stdout can be piped directly to a JSON parser. + ## Exit codes | Code | Meaning | diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index 25d54698d89b..6354416b2883 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -15,10 +15,13 @@ Related: [Hooks](/automation/hooks) - [Plugin hooks](/plugins/hooks) ## List hooks ```bash +openclaw hooks --json openclaw hooks list [--eligible] [--json] [-v|--verbose] ``` -Lists hooks discovered from workspace, managed, extra, and bundled directories. +Bare `openclaw hooks` and `openclaw hooks --json` use the same list operation as +`openclaw hooks list`. The command discovers hooks from workspace, managed, +extra, and bundled directories. - `--eligible`: only hooks whose requirements are met. - `--json`: structured output. diff --git a/docs/cli/index.md b/docs/cli/index.md index 28ba5a547648..0eab1b88331d 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -57,7 +57,12 @@ state directories and config paths remain unchanged. - ANSI colors and progress indicators render only in TTY sessions. - OSC-8 hyperlinks render as clickable links where supported; otherwise the CLI falls back to plain URLs. -- `--json` (and `--plain` where supported) disables styling for clean output. +- On bounded reporting commands, `--json` reserves stdout for one JSON document; + styling and progress output are suppressed, and warnings and diagnostics stay on + stderr. +- Interactive UIs and wizards, long-running servers and streams, shell integration, + and pure side-effect commands may omit `--json` when they have no meaningful + report to return. - Long-running commands show a progress indicator (OSC 9;4 when supported). ## Color palette diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md index f9e3b19be5ad..c455665f7862 100644 --- a/docs/cli/mcp.md +++ b/docs/cli/mcp.md @@ -398,11 +398,11 @@ config to Codex. Commands: -- `openclaw mcp list` -- `openclaw mcp show [name]` -- `openclaw mcp status [--verbose]` -- `openclaw mcp doctor [name] [--probe]` -- `openclaw mcp probe [name]` +- `openclaw mcp list [--json]` +- `openclaw mcp show [name] [--json]` +- `openclaw mcp status [--verbose] [--json]` +- `openclaw mcp doctor [name] [--probe] [--json]` +- `openclaw mcp probe [name] [--json]` - `openclaw mcp add [flags]` - `openclaw mcp set ` - `openclaw mcp configure [flags]` diff --git a/docs/cli/models.md b/docs/cli/models.md index e42285d6a01e..0d93ef419db6 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -19,6 +19,7 @@ Related: ## Common commands ```bash +openclaw models --json openclaw models status openclaw models list openclaw models refresh @@ -31,6 +32,9 @@ openclaw models scan ### Status +Bare `openclaw models` is equivalent to `openclaw models status`. +`openclaw models --json` returns the same object as `openclaw models status --json`. + `openclaw models status` shows the resolved default/fallbacks plus an auth overview. For plugin-owned agent runtimes such as Codex, it also checks whether the owning plugin is enabled and passed startup payload verification. A route with valid credentials but an unavailable runtime reports `status: unavailable` instead of `usable`; JSON output includes separate `authStatus`, `runtimeStatus`, and bounded runtime diagnostics. When provider usage snapshots are available, the OAuth/API-key status section includes provider usage windows and quota snapshots. Current usage-window providers: Anthropic, GitHub Copilot, Gemini CLI, OpenAI, MiniMax, Xiaomi, and z.ai. Usage auth comes from provider-specific hooks when available; otherwise OpenClaw falls back to matching OAuth/API-key credentials from auth profiles, env, or config. In `--json` output, `auth.providers` is the env/config/store-aware provider overview, while `auth.oauth` is auth-store profile health only. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 099266347bf4..0e6d60f27ab5 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -42,10 +42,10 @@ openclaw plugins disable openclaw plugins uninstall [--dry-run] [--keep-files] [--force] openclaw plugins update | --all [--dry-run] openclaw plugins registry [--refresh] [--json] -openclaw plugins doctor +openclaw plugins doctor [--json] openclaw plugins init [--name ] [--type tool|provider] [--directory ] openclaw plugins build [--entry ] [--check] -openclaw plugins validate [--entry ] +openclaw plugins validate [--entry ] [--json] openclaw plugins marketplace entries [--offline] [--feed-profile ] [--json] openclaw plugins marketplace list [--json] openclaw plugins marketplace refresh [--feed-profile ] [--expected-sha256 ] [--json] @@ -85,8 +85,9 @@ id for the default output directory and package naming. Tool scaffolds use `plugins build` imports the built entry, reads its static tool metadata, writes `openclaw.plugin.json`, and keeps `package.json`'s `openclaw.extensions` aligned. `plugins validate` checks that the generated manifest, package metadata, and -current entry export still agree. See [Tool Plugins](/plugins/tool-plugins) for -the full authoring workflow. +current entry export still agree. Pass `--json` for a machine-readable +validation result. See [Tool Plugins](/plugins/tool-plugins) for the full +authoring workflow. The scaffold writes TypeScript source but generates metadata from the built `./dist/index.js` entry, so the workflow also works with the published CLI. Use @@ -506,10 +507,14 @@ The `--json` flag outputs a machine-readable report suitable for scripting and a ```bash openclaw plugins doctor +openclaw plugins doctor --json ``` `doctor` reports plugin load errors, manifest/discovery diagnostics, compatibility notices, and stale plugin config references such as missing plugin slots. It loads plugin modules without activating plugins and does not query the running Gateway. When these local checks pass, it prints `Plugin discovery, module loading, compatibility, and configuration checks passed. Run "openclaw health" to check the running Gateway, including runtime quarantines and fallbacks.` The [health command](/cli/health) reads current runtime quarantine and fallback state from the Gateway. If stale config remains but the install tree is otherwise healthy, the summary says so instead of implying full plugin health. +With `--json`, the same discovery, compatibility, and configuration diagnostics +are returned as one machine-readable object. + If a configured plugin is present on disk but blocked by the loader's path-safety checks, config validation keeps the plugin entry and reports it as `present but blocked`. Fix the preceding blocked-plugin diagnostic, such as path ownership or world-writable permissions, instead of removing the `plugins.entries.` or `plugins.allow` config. For module-shape failures such as missing `register`/`activate` exports, rerun with `OPENCLAW_PLUGIN_LOAD_DEBUG=1` to include a compact export-shape summary in the diagnostic output. diff --git a/docs/cli/proxy.md b/docs/cli/proxy.md index 8aca396f270e..ff0f4cba8e9e 100644 --- a/docs/cli/proxy.md +++ b/docs/cli/proxy.md @@ -15,9 +15,9 @@ Validate operator-managed proxy routing, or run the local explicit debug proxy a openclaw proxy validate [--json] [--proxy-url ] [--proxy-ca-file ] [--allowed-url ] [--denied-url ] [--apns-reachable] [--apns-authority ] [--timeout-ms ] openclaw proxy start [--host ] [--port ] openclaw proxy run [--host ] [--port ] -- -openclaw proxy coverage -openclaw proxy sessions [--limit ] -openclaw proxy query --preset [--session ] +openclaw proxy coverage [--json] +openclaw proxy sessions [--limit ] [--json] +openclaw proxy query --preset [--session ] [--json] openclaw proxy blob --id openclaw proxy purge ``` @@ -77,6 +77,11 @@ The debug proxy's direct upstream forwarding opens upstream sockets for diagnost - `missing-ack` - `error-bursts` +`coverage`, `sessions`, and `query` already return JSON by default. They also +accept `--json` as an explicit machine-output spelling for consistent scripts. +In that mode, `coverage` keeps its report object, while `sessions` and `query` +wrap their rows under `sessions` and `rows`, respectively. + `blob --id ` prints a captured payload blob's raw content. `purge` deletes all captured traffic metadata and blobs. Captures are local debugging data; purge when finished. diff --git a/docs/cli/skills.md b/docs/cli/skills.md index e564f4a7a873..a219703e6214 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -44,6 +44,7 @@ openclaw skills update --all openclaw skills update --all --agent openclaw skills update --all --global openclaw skills verify @owner/ +openclaw skills verify @owner/ --json openclaw skills verify @owner/ --version openclaw skills verify @owner/ --tag openclaw skills verify @owner/ --card @@ -59,6 +60,8 @@ openclaw skills info --agent openclaw skills check openclaw skills check --agent openclaw skills check --json +openclaw skills curator status +openclaw skills curator status --json openclaw skills workshop propose-create --name "qa-check" --description "QA checklist" --proposal ./PROPOSAL.md openclaw skills workshop propose-update qa-check --proposal ./PROPOSAL.md openclaw skills workshop list @@ -96,28 +99,29 @@ settings use the separate `skills.install` request path instead. Notes: -| Flag/behavior | Description | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `search [query...]` | Optional query; omit it to browse the default ClawHub search feed. | -| `search --limit ` | Caps returned results. | -| `install git:owner/repo[@ref]` | Installs a Git skill. Branch refs may contain slashes, such as `git:owner/repo@feature/foo`. | -| `install ./path/to/skill` | Installs a local directory whose root contains `SKILL.md`. | -| `install --as ` | Overrides the inferred slug for Git and local directory installs. | -| `install --version ` | Applies to native ClawHub skill refs, not `skills-sh:` refs; the mirrored reference already identifies the exact synchronized commit. | -| `install --force` | Overwrites an existing workspace skill folder for the same slug. | -| `install/update --force-install` | Installs a pending GitHub-backed ClawHub skill before ClawHub's scan completes. | -| `--global` | Targets the shared managed skills directory; cannot combine with `--agent `. | -| `--agent ` | Targets one configured agent workspace; overrides current working directory inference. | -| `update @owner/` | Updates a single tracked skill. Add `--global` to target the shared managed skills directory instead of the workspace. | -| `update --all` | Updates tracked ClawHub installs in the selected workspace, or the shared managed skills directory with `--global`. | -| `verify @owner/` | Prints ClawHub's `clawhub.skill.verify.v1` JSON envelope by default. There is no `--json` flag because JSON is already the default. Bare slugs are accepted for compatibility when the skill is already installed or unambiguous; owner-qualified refs avoid publisher ambiguity. | -| `verify` provenance | When ClawHub returns server-resolved source provenance, verify JSON also includes a commit-pinned `openclaw.verifiedSourceUrl`. Unavailable or self-declared source URLs stay only in the raw provenance envelope and are not promoted. | -| `verify` version selector | `verify` uses `.clawhub/origin.json` for installed ClawHub skills, so it verifies the installed version against the registry it came from. `--version` and `--tag` override the version selector but keep that installed registry when origin metadata exists. | -| `verify --card` | Prints the generated Skill Card Markdown instead of JSON. Exits non-zero when ClawHub returns `ok: false` or `decision: "fail"`; unsigned signatures are informational unless ClawHub policy changes. | -| Skill Card fingerprint | Installed ClawHub bundles can include a generated `skill-card.md`. OpenClaw treats verification as a ClawHub server decision and does not reject an installed skill just because that generated card changes the bundle fingerprint. | -| `check --agent ` | Checks the selected agent's workspace and reports which ready skills are actually visible to that agent's prompt or command surface. | -| `list` | Default action when no subcommand is provided. | -| `list`/`info`/`check` output | Rendered output goes to stdout. With `--json`, the machine-readable payload stays on stdout for pipes and scripts. | +| Flag/behavior | Description | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `search [query...]` | Optional query; omit it to browse the default ClawHub search feed. | +| `search --limit ` | Caps returned results. | +| `install git:owner/repo[@ref]` | Installs a Git skill. Branch refs may contain slashes, such as `git:owner/repo@feature/foo`. | +| `install ./path/to/skill` | Installs a local directory whose root contains `SKILL.md`. | +| `install --as ` | Overrides the inferred slug for Git and local directory installs. | +| `install --version ` | Applies to native ClawHub skill refs, not `skills-sh:` refs; the mirrored reference already identifies the exact synchronized commit. | +| `install --force` | Overwrites an existing workspace skill folder for the same slug. | +| `install/update --force-install` | Installs a pending GitHub-backed ClawHub skill before ClawHub's scan completes. | +| `--global` | Targets the shared managed skills directory; cannot combine with `--agent `. | +| `--agent ` | Targets one configured agent workspace; overrides current working directory inference. | +| `update @owner/` | Updates a single tracked skill. Add `--global` to target the shared managed skills directory instead of the workspace. | +| `update --all` | Updates tracked ClawHub installs in the selected workspace, or the shared managed skills directory with `--global`. | +| `verify @owner/` | Prints ClawHub's `clawhub.skill.verify.v1` JSON envelope by default. `--json` is accepted as the explicit machine-output spelling. Bare slugs are accepted for compatibility when the skill is already installed or unambiguous; owner-qualified refs avoid publisher ambiguity. | +| `verify` provenance | When ClawHub returns server-resolved source provenance, verify JSON also includes a commit-pinned `openclaw.verifiedSourceUrl`. Unavailable or self-declared source URLs stay only in the raw provenance envelope and are not promoted. | +| `verify` version selector | `verify` uses `.clawhub/origin.json` for installed ClawHub skills, so it verifies the installed version against the registry it came from. `--version` and `--tag` override the version selector but keep that installed registry when origin metadata exists. | +| `verify --card` | Prints the generated Skill Card Markdown instead of JSON. Exits non-zero when ClawHub returns `ok: false` or `decision: "fail"`; unsigned signatures are informational unless ClawHub policy changes. | +| Skill Card fingerprint | Installed ClawHub bundles can include a generated `skill-card.md`. OpenClaw treats verification as a ClawHub server decision and does not reject an installed skill just because that generated card changes the bundle fingerprint. | +| `check --agent ` | Checks the selected agent's workspace and reports which ready skills are actually visible to that agent's prompt or command surface. | +| `list` | Default action when no subcommand is provided. | +| `list`/`info`/`check` output | Rendered output goes to stdout. With `--json`, the machine-readable payload stays on stdout for pipes and scripts. | +| `curator status --json` | Returns the current curator run and skill lifecycle status as one JSON object. | Community ClawHub skill installs and updates check trust before downloading. Versioned community archive releases use exact-release trust metadata. diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 56618be78390..32b5f0c3da2c 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -1573,7 +1573,7 @@ describe("config cli", () => { }), ); - await runConfigCommand(["config", "schema"]); + await runConfigCommand(["config", "schema", "--json"]); expect(mockExit).not.toHaveBeenCalled(); expect(mockError).not.toHaveBeenCalled(); @@ -4421,6 +4421,22 @@ describe("config cli", () => { fs.rmSync(home, { recursive: true, force: true }); } }); + + it("emits the active path as a JSON object", async () => { + const configPath = path.join(os.tmpdir(), "openclaw-json-config", "openclaw.json"); + vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath); + + try { + await runConfigCommand(["config", "file", "--json"]); + + expect(defaultRuntime.writeJson).toHaveBeenCalledWith({ path: configPath }, 2); + expect(structuredClone(lastMockArg(defaultRuntime.writeJson))).toEqual({ + path: configPath, + }); + } finally { + vi.unstubAllEnvs(); + } + }); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 3495ba4e561d..da66dec72ca0 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -278,10 +278,15 @@ export async function runConfigUnset(opts: { } } -async function runConfigFile(opts: { runtime?: RuntimeEnv }) { +async function runConfigFile(opts: { json?: boolean; runtime?: RuntimeEnv }) { const runtime = opts.runtime ?? defaultRuntime; try { - writeRuntimeStdout(runtime, `${resolveConfigPath()}\n`); + const path = resolveConfigPath(); + if (opts.json) { + writeRuntimeJson(runtime, { path }); + return; + } + writeRuntimeStdout(runtime, `${path}\n`); } catch (err) { runtime.error(danger(String(err))); runtime.exit(1); @@ -524,10 +529,15 @@ export function registerConfigCli(program: Command) { await runConfigUnset({ path, cliOptions: options }); }); - cmd.command("file").description("Print the active config file path").action(runConfigFile); + cmd + .command("file") + .description("Print the active config file path") + .option("--json", "Output JSON", false) + .action((opts: { json?: boolean }) => runConfigFile(opts)); cmd .command("schema") .description("Print the JSON schema for openclaw.json") + .option("--json", "Output JSON", false) .action(runConfigSchema); cmd .command("validate") diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index c3275f0db532..f5f4fb2dab00 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -1004,6 +1004,33 @@ describe("cron cli", () => { expect(stdoutText()).toContain('"id": "job-1"'); }); + it.each([ + { + name: "get", + args: ["cron", "get", "job-1", "--json"], + method: "cron.get", + params: { id: "job-1" }, + }, + { + name: "runs", + args: ["cron", "runs", "--id", "job-1", "--json"], + method: "cron.runs", + params: { id: "job-1", limit: 50 }, + }, + ])( + "accepts --json for cron $name and prints one JSON object", + async ({ args, method, params }) => { + await runCronCommand(args); + + const gatewayCall = callGatewayFromCli.mock.calls.find( + ([calledMethod]) => calledMethod === method, + ); + expect(gatewayCall?.[2]).toEqual(params); + expect(defaultRuntime.writeJson).toHaveBeenCalledOnce(); + expect(() => JSON.parse(stdoutText())).not.toThrow(); + }, + ); + it("rejects partial cron runs limit", async () => { await expectCronCommandExit(["cron", "runs", "--id", "job-1", "--limit", "10x"]); expectRuntimeErrorContaining("Invalid --limit"); diff --git a/src/cli/cron-cli/register.cron-simple.ts b/src/cli/cron-cli/register.cron-simple.ts index 19301dbb8b0d..e339259c0d56 100644 --- a/src/cli/cron-cli/register.cron-simple.ts +++ b/src/cli/cron-cli/register.cron-simple.ts @@ -162,6 +162,7 @@ export function registerCronSimpleCommands(cron: Command) { .command("get") .description("Get an automation as JSON") .argument("", "Job id") + .option("--json", "Output JSON", false) .action(async (id, opts) => { try { const res = await callGatewayFromCli("cron.get", opts, { id: String(id) }); @@ -202,6 +203,7 @@ export function registerCronSimpleCommands(cron: Command) { .command("runs") .description("Show automation run history") .requiredOption("--id ", "Job id") + .option("--json", "Output JSON", false) .option("--run-id ", "Filter by cron run id") .option("--limit ", "Max entries (default 50)", "50") .action(async (opts) => { diff --git a/src/cli/docs-cli.ts b/src/cli/docs-cli.ts index e4f4671ed7aa..03c4695f0772 100644 --- a/src/cli/docs-cli.ts +++ b/src/cli/docs-cli.ts @@ -11,14 +11,15 @@ export function registerDocsCli(program: Command) { .command("docs") .description("Search the live OpenClaw docs") .argument("[query...]", "Search query") + .option("--json", "Output JSON", false) .addHelpText( "after", () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/docs", "docs.openclaw.ai/cli/docs")}\n`, ) - .action(async (queryParts: string[]) => { + .action(async (queryParts: string[], opts: { json?: boolean }) => { await runCommandWithRuntime(defaultRuntime, async () => { - await docsSearchCommand(queryParts, defaultRuntime); + await docsSearchCommand(queryParts, defaultRuntime, { json: Boolean(opts.json) }); }); }); } diff --git a/src/cli/hooks-cli.toggle.test.ts b/src/cli/hooks-cli.toggle.test.ts index 9a315cc099ec..278e31cbad64 100644 --- a/src/cli/hooks-cli.toggle.test.ts +++ b/src/cli/hooks-cli.toggle.test.ts @@ -231,4 +231,15 @@ describe("hooks CLI metadata config keys", () => { expect(mocks.requestExitAfterOneShotOutput).toHaveBeenCalledWith(capture.defaultRuntime, 1); expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); }); + + it("emits the default hooks report as JSON", async () => { + await createHooksProgram().parseAsync(["hooks", "--json"], { from: "user" }); + + const payload = JSON.parse(String(capture.runtimeLogs.at(-1))) as { + hooks?: Array<{ name?: string }>; + }; + expect(payload.hooks).toEqual([expect.objectContaining({ name: "display-name" })]); + expect(capture.runtimeLogs).toHaveLength(1); + expect(mocks.requestExitAfterOneShotOutput).toHaveBeenCalledWith(capture.defaultRuntime, 0); + }); }); diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index b75f7583ba19..48115a60aa91 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -507,11 +507,14 @@ export function registerHooksCli(program: Command): void { const hooks = program .command("hooks") .description("Manage internal agent hooks") + .option("--json", "Output as JSON", false) .addHelpText( "after", () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/hooks", "docs.openclaw.ai/cli/hooks")}\n`, ); + const hasJsonOutput = (opts: { json?: boolean } | undefined): boolean => + Boolean(opts?.json || hooks.opts<{ json?: boolean }>().json); hooks .command("list") @@ -519,11 +522,12 @@ export function registerHooksCli(program: Command): void { .option("--eligible", "Show only eligible hooks", false) .option("--json", "Output as JSON", false) .option("-v, --verbose", "Show more details including missing requirements", false) - .action(async (opts) => + .action(async (opts: HooksListOptions) => runOneShotHooksCliAction(async () => { const config = getRuntimeConfig(); const report = buildHooksReport(config); - writeHooksOutput(formatHooksList(report, opts), opts.json); + const json = hasJsonOutput(opts); + writeHooksOutput(formatHooksList(report, { ...opts, json }), json); }), ); @@ -531,11 +535,12 @@ export function registerHooksCli(program: Command): void { .command("info ") .description("Show detailed information about a hook") .option("--json", "Output as JSON", false) - .action(async (name, opts) => + .action(async (name, opts: HookInfoOptions) => runOneShotHooksCliAction(async () => { const config = getRuntimeConfig(); const report = buildHooksReport(config); - writeHooksOutput(formatHookInfo(report, name, opts), opts.json); + const json = hasJsonOutput(opts); + writeHooksOutput(formatHookInfo(report, name, { ...opts, json }), json); return report.hooks.some((hook) => hook.name === name || hook.hookKey === name) ? 0 : 1; }), ); @@ -544,11 +549,12 @@ export function registerHooksCli(program: Command): void { .command("check") .description("Check hooks eligibility status") .option("--json", "Output as JSON", false) - .action(async (opts) => + .action(async (opts: HooksCheckOptions) => runOneShotHooksCliAction(async () => { const config = getRuntimeConfig(); const report = buildHooksReport(config); - writeHooksOutput(formatHooksCheck(report, opts), opts.json); + const json = hasJsonOutput(opts); + writeHooksOutput(formatHooksCheck(report, { ...opts, json }), json); }), ); @@ -614,11 +620,12 @@ export function registerHooksCli(program: Command): void { await runPluginUpdateCommand({ id, opts }); }); - hooks.action(async () => + hooks.action(async (opts: HooksListOptions) => runOneShotHooksCliAction(async () => { const config = getRuntimeConfig(); const report = buildHooksReport(config); - defaultRuntime.log(formatHooksList(report, {})); + const json = hasJsonOutput(opts); + writeHooksOutput(formatHooksList(report, { ...opts, json }), json); }), ); } diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index cc6976e33f44..7f85b864bb2b 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -133,7 +133,7 @@ describe("models cli", () => { } } - it("declares --status-json as machine output", async () => { + it.each(["--json", "--status-json"])("declares %s as machine output", async (flag) => { const program = createProgram(); let detected = false; program.hook("preAction", (_command, actionCommand) => { @@ -141,9 +141,9 @@ describe("models cli", () => { }); const originalArgv = process.argv; - process.argv = ["node", "openclaw", "models", "--status-json"]; + process.argv = ["node", "openclaw", "models", flag]; try { - await program.parseAsync(["models", "--status-json"], { from: "user" }); + await program.parseAsync(["models", flag], { from: "user" }); } finally { process.argv = originalArgv; } @@ -151,6 +151,12 @@ describe("models cli", () => { expect(detected).toBe(true); }); + it("forwards bare --json to the default status report", async () => { + await runModelsCommand(["models", "--json"]); + + expectCommandOptions(modelsStatusCommand, { json: true }); + }); + it("registers github-copilot login command", async () => { const program = createProgram(); const models = requireCommand(program, "models"); diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index a0ea6327c550..fcad800ccdae 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -42,6 +42,7 @@ export function registerModelsCli(program: Command) { const models = program .command("models") .description("Model discovery, scanning, and configuration") + .option("--json", "Output JSON (alias for `models status --json`)", false) .option("--status-json", "Output JSON (alias for `models status --json`)", false) .option("--status-plain", "Plain output (alias for `models status --plain`)", false) .option("--agent ", "Agent id to inspect (overrides OPENCLAW_AGENT_DIR)") @@ -50,6 +51,8 @@ export function registerModelsCli(program: Command) { () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/models", "docs.openclaw.ai/cli/models")}\n`, ); + const hasJsonOutput = (opts?: { json?: boolean }): boolean => + Boolean(opts?.json || models.opts<{ json?: boolean }>().json); setCommandJsonMode(models, "output", ({ argv }) => isModelsStatusJsonOutput(argv)); models @@ -63,7 +66,7 @@ export function registerModelsCli(program: Command) { .action(async (opts) => { await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsListCommand } = await import("../commands/models/list.list-command.js"); - await modelsListCommand(opts, defaultRuntime); + await modelsListCommand({ ...opts, json: hasJsonOutput(opts) }, defaultRuntime); }); }); @@ -98,7 +101,7 @@ export function registerModelsCli(program: Command) { const { modelsStatusCommand } = await loadModelsStatusCommands(); await modelsStatusCommand( { - json: Boolean(opts.json), + json: hasJsonOutput(opts), plain: Boolean(opts.plain), check: Boolean(opts.check), probe: Boolean(opts.probe), @@ -121,7 +124,7 @@ export function registerModelsCli(program: Command) { .action(async (opts) => { await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsRefreshCommand } = await import("../commands/models/refresh.js"); - await modelsRefreshCommand({ json: Boolean(opts.json) }, defaultRuntime); + await modelsRefreshCommand({ json: hasJsonOutput(opts) }, defaultRuntime); }); }); @@ -161,7 +164,7 @@ export function registerModelsCli(program: Command) { .action(async (opts) => { await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsAliasesListCommand } = await loadModelsAliasesCommands(); - await modelsAliasesListCommand(opts, defaultRuntime); + await modelsAliasesListCommand({ ...opts, json: hasJsonOutput(opts) }, defaultRuntime); }); }); @@ -198,7 +201,7 @@ export function registerModelsCli(program: Command) { .action(async (opts) => { await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsFallbacksListCommand } = await loadModelsFallbacksCommands(); - await modelsFallbacksListCommand(opts, defaultRuntime); + await modelsFallbacksListCommand({ ...opts, json: hasJsonOutput(opts) }, defaultRuntime); }); }); @@ -246,7 +249,10 @@ export function registerModelsCli(program: Command) { .action(async (opts) => { await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsImageFallbacksListCommand } = await loadModelsImageFallbacksCommands(); - await modelsImageFallbacksListCommand(opts, defaultRuntime); + await modelsImageFallbacksListCommand( + { ...opts, json: hasJsonOutput(opts) }, + defaultRuntime, + ); }); }); @@ -300,7 +306,7 @@ export function registerModelsCli(program: Command) { .action(async (opts) => { await withModelsRuntime(async ({ defaultRuntime }) => { const { modelsScanCommand } = await import("../commands/models/scan.js"); - await modelsScanCommand(opts, defaultRuntime); + await modelsScanCommand({ ...opts, json: hasJsonOutput(opts) }, defaultRuntime); }); }); @@ -309,7 +315,7 @@ export function registerModelsCli(program: Command) { const { modelsStatusCommand } = await loadModelsStatusCommands(); await modelsStatusCommand( { - json: Boolean(opts?.statusJson), + json: Boolean(opts?.json || opts?.statusJson), plain: Boolean(opts?.statusPlain), agent: opts?.agent as string | undefined, }, @@ -338,7 +344,7 @@ export function registerModelsCli(program: Command) { { provider: opts.provider as string | undefined, agent, - json: Boolean(opts.json), + json: hasJsonOutput(opts), }, defaultRuntime, ); @@ -514,7 +520,7 @@ export function registerModelsCli(program: Command) { { provider: opts.provider as string, agent, - json: Boolean(opts.json), + json: hasJsonOutput(opts), }, defaultRuntime, ); diff --git a/src/cli/models-output-mode.ts b/src/cli/models-output-mode.ts index f4004fb28979..8e8da37dff41 100644 --- a/src/cli/models-output-mode.ts +++ b/src/cli/models-output-mode.ts @@ -2,5 +2,5 @@ import { hasMachineOutputOption } from "./machine-output-argv.js"; /** Resolve the parent-command alias for `models status --json`. */ export function isModelsStatusJsonOutput(argv: readonly string[]): boolean { - return hasMachineOutputOption(argv, "--status-json"); + return hasMachineOutputOption(argv, "--json") || hasMachineOutputOption(argv, "--status-json"); } diff --git a/src/cli/plugins-authoring-command.test.ts b/src/cli/plugins-authoring-command.test.ts index 7fc645ec9854..d465364a4eb0 100644 --- a/src/cli/plugins-authoring-command.test.ts +++ b/src/cli/plugins-authoring-command.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { defineToolPlugin, getToolPluginMetadata } from "../plugin-sdk/tool-plugin.js"; import { defaultRuntime } from "../runtime.js"; +import { withEnvAsync } from "../test-utils/env.js"; import { VERSION } from "../version.js"; import { buildToolPluginManifest, @@ -274,6 +275,71 @@ describe("plugin authoring commands", () => { ).toEqual([]); }); + it("emits a stable JSON validation result without human output", async () => { + const tmpDir = tempDirs.make("openclaw-plugin-valid-json-"); + const entryPath = writeSourceToolPluginProject({ + tmpDir, + packageName: "openclaw-plugin-valid-json", + pluginId: "valid-json", + toolName: "valid_json_echo", + }); + await runPluginsBuildCommand({ root: tmpDir, entry: entryPath }); + const writeJson = vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => {}); + const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {}); + const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); + + try { + await runPluginsValidateCommand({ root: tmpDir, entry: entryPath, json: true }); + + expect(writeJson).toHaveBeenCalledOnce(); + expect(writeJson).toHaveBeenCalledWith({ valid: true, pluginId: "valid-json", errors: [] }); + expect(log).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + } finally { + writeJson.mockRestore(); + log.mockRestore(); + error.mockRestore(); + } + }); + + it("keeps validation errors on stderr and sanitizes JSON paths", async () => { + const homeDir = tempDirs.make("openclaw-plugin-invalid-json-home-"); + const rootDir = path.join(homeDir, "plugins", "invalid-json"); + fs.mkdirSync(rootDir, { recursive: true }); + fs.writeFileSync(path.join(rootDir, "package.json"), "{}\n"); + const writeJson = vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => {}); + const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {}); + const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); + const exit = vi.spyOn(defaultRuntime, "exit").mockImplementation((code) => { + throw new Error(`expected runtime exit ${code}`); + }); + + try { + await expect( + withEnvAsync({ OPENCLAW_HOME: homeDir }, async () => { + await runPluginsValidateCommand({ root: rootDir, json: true }); + }), + ).rejects.toThrow("expected runtime exit 1"); + + expect(writeJson).toHaveBeenCalledWith({ + valid: false, + errors: [ + "plugin manifest not found: $OPENCLAW_HOME/plugins/invalid-json/openclaw.plugin.json", + ], + }); + expect(error).toHaveBeenCalledWith( + `plugin manifest not found: ${rootDir}/openclaw.plugin.json`, + ); + expect(log).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(1, { resetStream: process.stderr }); + } finally { + writeJson.mockRestore(); + log.mockRestore(); + error.mockRestore(); + exit.mockRestore(); + } + }); + it.each(["validate", "build --check"] as const)( "accepts reordered JSON object keys without rewriting files in %s", async (command) => { diff --git a/src/cli/plugins-authoring-command.ts b/src/cli/plugins-authoring-command.ts index c2b022d71205..c4a7bff0c8a0 100644 --- a/src/cli/plugins-authoring-command.ts +++ b/src/cli/plugins-authoring-command.ts @@ -18,7 +18,7 @@ import { import { buildPluginLoaderAliasMap } from "../plugins/sdk-alias.js"; import { defaultRuntime } from "../runtime.js"; import { toSafeImportPath } from "../shared/import-specifier.js"; -import { isRecord } from "../utils.js"; +import { isRecord, shortenHomeInString } from "../utils.js"; import { VERSION } from "../version.js"; type JsonObject = Record; @@ -32,8 +32,13 @@ export type PluginsBuildOptions = { export type PluginsValidateOptions = { root?: string; entry?: string; + json?: boolean; }; +type PluginsValidationResult = + | { valid: true; pluginId: string; errors: [] } + | { valid: false; pluginId?: string; errors: string[] }; + export type PluginsInitOptions = { directory?: string; force?: boolean; @@ -321,15 +326,16 @@ export async function runPluginsBuildCommand(opts: PluginsBuildOptions): Promise defaultRuntime.log(`Updated ${formatOutputPath(packagePath, "package.json")}`); } -export async function runPluginsValidateCommand(opts: PluginsValidateOptions): Promise { +async function collectPluginsValidationResult( + opts: PluginsValidateOptions, +): Promise { const rootDir = resolveRootDir(opts.root); const entryPath = resolveEntryPath(rootDir, opts.entry); const entryRelative = normalizeRelativePath(rootDir, entryPath); const packageManifest = readPackageManifest(rootDir); const manifestResult = loadPluginManifest(rootDir, false); if (!manifestResult.ok) { - defaultRuntime.error(manifestResult.error); - return defaultRuntime.exit(1); + return { valid: false, errors: [manifestResult.error] }; } const manifest = readJsonFile(path.join(rootDir, PLUGIN_MANIFEST_FILENAME)); const { metadata } = await loadToolPlugin({ rootDir, entryPath }); @@ -340,12 +346,43 @@ export async function runPluginsValidateCommand(opts: PluginsValidateOptions): P entry: entryRelative, }); if (errors.length > 0) { - for (const error of errors) { + return { valid: false, pluginId: metadata.id, errors }; + } + + return { valid: true, pluginId: metadata.id, errors: [] }; +} + +export async function runPluginsValidateCommand(opts: PluginsValidateOptions): Promise { + let result: PluginsValidationResult; + try { + result = await collectPluginsValidationResult(opts); + } catch (err) { + if (!opts.json) { + throw err; + } + result = { + valid: false, + errors: [err instanceof Error ? err.message : String(err)], + }; + } + + if (!result.valid) { + for (const error of result.errors) { defaultRuntime.error(error); } - return defaultRuntime.exit(1); + if (opts.json) { + defaultRuntime.writeJson({ + ...result, + errors: result.errors.map(shortenHomeInString), + }); + } + return defaultRuntime.exit(1, opts.json ? { resetStream: process.stderr } : undefined); } - defaultRuntime.log(`Plugin ${metadata.id} is valid.`); + if (opts.json) { + defaultRuntime.writeJson(result); + return; + } + defaultRuntime.log(`Plugin ${result.pluginId} is valid.`); } function assertCanCreate(filePath: string, force: boolean): void { diff --git a/src/cli/plugins-cli.lazy.test.ts b/src/cli/plugins-cli.lazy.test.ts index 0fb915c0a516..c4c228bcbc2e 100644 --- a/src/cli/plugins-cli.lazy.test.ts +++ b/src/cli/plugins-cli.lazy.test.ts @@ -9,6 +9,7 @@ describe("plugins cli lazy runtime boundary", () => { afterEach(() => { vi.doUnmock("./plugins-cli.runtime.js"); + vi.doUnmock("./plugins-authoring-command.js"); vi.resetModules(); }); @@ -113,6 +114,25 @@ describe("plugins cli lazy runtime boundary", () => { expect(runPluginsRegistryCommand).toHaveBeenCalledWith(expect.objectContaining({ json: true })); }); + it("forwards JSON mode to plugin doctor and validation actions", async () => { + const runPluginsDoctorCommand = vi.fn().mockResolvedValue(undefined); + const runPluginsValidateCommand = vi.fn().mockResolvedValue(undefined); + vi.doMock("./plugins-cli.runtime.js", () => ({ runPluginsDoctorCommand })); + vi.doMock("./plugins-authoring-command.js", () => ({ runPluginsValidateCommand })); + + const { registerPluginsCli } = await import("./plugins-cli.js"); + const doctorProgram = new Command(); + registerPluginsCli(doctorProgram); + await doctorProgram.parseAsync(["plugins", "doctor", "--json"], { from: "user" }); + + const validateProgram = new Command(); + registerPluginsCli(validateProgram); + await validateProgram.parseAsync(["plugins", "validate", "--json"], { from: "user" }); + + expect(runPluginsDoctorCommand).toHaveBeenCalledWith(expect.objectContaining({ json: true })); + expect(runPluginsValidateCommand).toHaveBeenCalledWith(expect.objectContaining({ json: true })); + }); + it("loads the plugins runtime for marketplace entries", async () => { const runPluginMarketplaceEntriesCommand = vi.fn().mockResolvedValue(undefined); vi.doMock("./plugins-cli.runtime.js", () => ({ diff --git a/src/cli/plugins-cli.list.test.ts b/src/cli/plugins-cli.list.test.ts index b145fcafa7eb..834de3dec9fd 100644 --- a/src/cli/plugins-cli.list.test.ts +++ b/src/cli/plugins-cli.list.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createPluginRecord } from "../plugins/status.test-fixtures.js"; +import { withEnvAsync } from "../test-utils/env.js"; import { buildPluginDiagnosticsReport, buildPluginInspectReport, @@ -100,6 +101,84 @@ describe("plugins cli list", () => { expect(runtimeLogs).toContain(cleanDoctorMessage); }); + it("emits one sanitized JSON doctor report without human decoration", async () => { + const homeDir = "/tmp/openclaw-plugin-doctor-home"; + buildPluginDiagnosticsReport.mockReturnValue({ + plugins: [ + createPluginRecord({ + id: "broken", + origin: "config", + source: `${homeDir}/plugins/broken/index.ts`, + status: "error", + error: `failed to load ${homeDir}/plugins/broken/runtime.ts`, + }), + ], + diagnostics: [ + { + level: "warn", + pluginId: "broken", + source: `${homeDir}/plugins/shadowed/index.ts`, + message: + "duplicate plugin id resolved by explicit config-selected plugin; " + + `global plugin will be overridden by config plugin (${homeDir}/plugins/broken/index.ts)`, + }, + { + level: "warn", + message: `failed to inspect ${homeDir}/plugins/unreadable`, + }, + ], + }); + + await withEnvAsync({ OPENCLAW_HOME: homeDir }, async () => { + await runPluginsCommand(["plugins", "doctor", "--json"]); + }); + + expect(runtimeLogs).toHaveLength(1); + expect(runtimeErrors).toEqual([]); + expect(runtimeLogs[0]).not.toContain(homeDir); + expect(runtimeLogs[0]).not.toContain("Plugin errors:"); + expect(runtimeLogs[0]).not.toContain("Docs:"); + expect(JSON.parse(runtimeLogs[0] ?? "null")).toEqual({ + ok: false, + pluginErrors: [ + { + id: "broken", + error: "failed to load $OPENCLAW_HOME/plugins/broken/runtime.ts", + source: "$OPENCLAW_HOME/plugins/broken/index.ts", + }, + ], + diagnostics: [ + { + level: "warn", + message: "failed to inspect $OPENCLAW_HOME/plugins/unreadable", + }, + ], + sourceShadowing: [ + { + pluginId: "broken", + message: + "duplicate plugin id resolved by explicit config-selected plugin; " + + "global plugin will be overridden by config plugin ($OPENCLAW_HOME/plugins/broken/index.ts)", + active: { + source: "$OPENCLAW_HOME/plugins/broken/index.ts", + origin: "config", + status: "error", + error: "failed to load $OPENCLAW_HOME/plugins/broken/runtime.ts", + }, + shadowedSource: "$OPENCLAW_HOME/plugins/shadowed/index.ts", + repair: [ + "openclaw plugins inspect broken", + "edit or remove the config-selected plugin source", + "openclaw plugins registry --refresh", + "openclaw gateway restart --force", + ], + }, + ], + compatibility: [], + configurationWarnings: [], + }); + }); + it.each([ { description: "a required plugin is missing", diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index 917d9807ba3c..1132d4162ed0 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -20,6 +20,7 @@ import { defaultRuntime } from "../runtime.js"; import { shortenHomeInString } from "../utils.js"; import { formatMissingPluginMessage } from "./error-format.js"; import type { + PluginDoctorOptions, PluginMarketplaceEntriesOptions, PluginMarketplaceListOptions, PluginMarketplaceRefreshOptions, @@ -355,7 +356,7 @@ export async function runPluginsRegistryCommand(opts: PluginRegistryOptions): Pr } /** Print plugin install-tree, compatibility, and plugin-owned config diagnostics. */ -export async function runPluginsDoctorCommand(): Promise { +export async function runPluginsDoctorCommand(opts: PluginDoctorOptions = {}): Promise { const { buildPluginCompatibilityNotices, buildPluginDiagnosticsReport, @@ -393,6 +394,54 @@ export async function runPluginsDoctorCommand(): Promise { errors.length > 0 || diags.length > 0 || shadowed.length > 0 || compatibility.length > 0; const pluginConfigWarnings = [...stalePluginConfigWarnings, ...configuredRuntimePluginWarnings]; + if (opts.json) { + defaultRuntime.writeJson({ + ok: !hasInstallTreeIssues && pluginConfigWarnings.length === 0, + pluginErrors: errors.map((entry) => ({ + id: entry.id, + ...(entry.failurePhase ? { failurePhase: entry.failurePhase } : {}), + error: shortenHomeInString(entry.error ?? "failed to load"), + source: shortenHomeInString(entry.source), + })), + diagnostics: diags.map((entry) => ({ + level: entry.level, + ...(entry.pluginId ? { pluginId: entry.pluginId } : {}), + message: shortenHomeInString(entry.message), + ...(entry.source ? { source: shortenHomeInString(entry.source) } : {}), + })), + sourceShadowing: shadowed.map((entry) => { + const active = report.plugins.find((plugin) => plugin.id === entry.pluginId); + return { + ...(entry.pluginId ? { pluginId: entry.pluginId } : {}), + message: shortenHomeInString(entry.message), + ...(active + ? { + active: { + source: shortenHomeInString(active.source), + origin: active.origin, + status: active.status, + ...(active.error ? { error: shortenHomeInString(active.error) } : {}), + }, + } + : {}), + ...(entry.source ? { shadowedSource: shortenHomeInString(entry.source) } : {}), + repair: [ + `openclaw plugins inspect ${entry.pluginId ?? ""}`, + "edit or remove the config-selected plugin source", + "openclaw plugins registry --refresh", + "openclaw gateway restart --force", + ], + }; + }), + compatibility: compatibility.map((notice) => ({ + ...notice, + message: shortenHomeInString(notice.message), + })), + configurationWarnings: pluginConfigWarnings.map(shortenHomeInString), + }); + return; + } + if (!hasInstallTreeIssues && pluginConfigWarnings.length === 0) { defaultRuntime.log( "Plugin discovery, module loading, compatibility, and configuration checks passed. " + diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index c58f547483c0..c5fb3a12dd6d 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -68,6 +68,11 @@ type PluginAuthoringBuildOptions = { type PluginAuthoringValidateOptions = { root?: string; entry?: string; + json?: boolean; +}; + +export type PluginDoctorOptions = { + json?: boolean; }; type PluginAuthoringInitOptions = { @@ -252,9 +257,10 @@ export function registerPluginsCli(program: Command) { plugins .command("doctor") .description("Report plugin load issues") - .action(async () => { + .option("--json", "Print JSON") + .action(async (opts: PluginDoctorOptions) => { const { runPluginsDoctorCommand } = await loadPluginsRuntime(); - await runPluginsDoctorCommand(); + await runPluginsDoctorCommand(opts); }); plugins @@ -273,6 +279,7 @@ export function registerPluginsCli(program: Command) { .description("Validate simple tool plugin metadata") .option("--root ", "Plugin package root") .option("--entry ", "Plugin entry module relative to --root") + .option("--json", "Print JSON") .action(async (opts: PluginAuthoringValidateOptions) => { const { runPluginsValidateCommand } = await loadPluginsAuthoringCommands(); await runPluginsValidateCommand(opts); diff --git a/src/cli/program/root-command-descriptions.test.ts b/src/cli/program/root-command-descriptions.test.ts index edfc0c8053c7..96d50e85b227 100644 --- a/src/cli/program/root-command-descriptions.test.ts +++ b/src/cli/program/root-command-descriptions.test.ts @@ -2,12 +2,245 @@ // registered Commander commands. Keep those user-facing descriptions aligned. import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { collectShellCompletionCommandTree } from "../completion-command-tree.js"; import { getCoreCliCommandNames, registerCoreCliByName } from "./command-registry-core.js"; import { createProgramContext } from "./context.js"; import { getCoreCliCommandDescriptors } from "./core-command-descriptors.js"; import { registerSubCliByName } from "./register.subclis.js"; import { getSubCliEntries } from "./subcli-descriptors.js"; +const JSON_NOT_APPLICABLE = { + namespaces: { + reason: "command group only; reporting subcommands declare JSON output individually", + commands: [ + "backup", + "backup sqlite", + "message", + "message thread", + "message emoji", + "message sticker", + "message role", + "message channel", + "message member", + "message voice", + "message event", + "mcp", + "transcripts", + "gateway restart-handoff", + "gateway diagnostics", + "daemon", + "system", + "system heartbeat", + "promos", + "infer", + "infer model", + "infer model auth", + "infer image", + "infer audio", + "infer tts", + "infer video", + "infer web", + "infer embedding", + "approvals", + "approvals allowlist", + "exec-policy", + "nodes", + "nodes camera", + "nodes screen", + "nodes location", + "devices", + "users", + "node", + "sandbox", + "fleet", + "worktrees", + "cron", + "dns", + "proxy", + "webhooks", + "webhooks gmail", + "clawbot", + "pairing", + "plugins", + "plugins marketplace", + "channels", + "channels dead-letters", + "directory", + "directory peers", + "directory groups", + "security", + "secrets", + "models aliases", + "models fallbacks", + "models image-fallbacks", + "models auth", + "models auth order", + "tasks flow", + "skills workshop", + ], + }, + interactive: { + reason: "interactive wizard, login, or terminal session has no single report document", + commands: [ + "configure", + "config", + "acp client", + "promos claim", + "infer model auth login", + "models auth add", + "models auth login", + "models auth setup-token", + "models auth paste-token", + "models auth paste-api-key", + "models auth login-github-copilot", + "mcp configure", + "mcp login", + "attach", + "tui", + "update wizard", + ], + }, + longRunning: { + reason: "long-running server, worker, or live stream does not terminate with one JSON document", + commands: [ + "acp", + "gateway", + "gateway run", + "mcp serve", + "node worker", + "node run", + "worker", + "fleet logs", + "proxy start", + "proxy run", + "webhooks gmail run", + "hooks relay", + "sessions tail", + ], + }, + mutations: { + reason: "pure side-effect command has no meaningful report payload", + commands: [ + "reset", + "uninstall", + "config set", + "mcp add", + "mcp set", + "mcp tools", + "mcp logout", + "mcp reload", + "mcp unset", + "onboard recommendations acknowledge", + "onboard recommendations refresh", + "tasks notify", + "tasks cancel", + "tasks flow cancel", + "models set", + "models set-image", + "models aliases add", + "models aliases remove", + "models fallbacks add", + "models fallbacks remove", + "models fallbacks clear", + "models image-fallbacks add", + "models image-fallbacks remove", + "models image-fallbacks clear", + "models auth logout", + "models auth order set", + "models auth order clear", + "hooks enable", + "hooks disable", + "hooks install", + "hooks update", + "skills install", + "skills update", + "sandbox recreate", + "fleet start", + "fleet stop", + "fleet restart", + "fleet upgrade", + "fleet rm", + "cron enable", + "cron disable", + "cron run", + "cron edit", + "dns setup", + "proxy purge", + "pairing approve", + "plugins enable", + "plugins disable", + "plugins uninstall", + "plugins install", + "plugins update", + "plugins build", + "plugins init", + "channels add", + "channels remove", + "channels login", + "channels logout", + ], + }, + rawArtifacts: { + reason: "command streams a raw artifact whose bytes are already the machine contract", + commands: ["proxy blob"], + }, + shellIntegration: { + reason: "shell integration output must remain executable shell source", + commands: ["completion"], + }, +} as const; + +// These subcommands intentionally consume --json from their parent and emit JSON. +const JSON_OUTPUT_INHERITED_FROM_PARENT = new Set([ + "skills curator status", + "skills curator pin", + "skills curator unpin", + "skills curator restore", +]); + +// Route-first parsing accepts JSON before Commander registration is reached. +const JSON_OUTPUT_ROUTE_FIRST = new Set(["agents"]); + +async function registerAllBuiltInCommands(): Promise { + const program = new Command().name("openclaw"); + const ctx = createProgramContext(); + const argv = ["node", "openclaw", "completion"]; + + for (const name of getCoreCliCommandNames()) { + await registerCoreCliByName(program, ctx, name, argv); + } + for (const entry of getSubCliEntries()) { + await registerSubCliByName(program, entry.name, argv, { purpose: "completion" }); + } + return program; +} + +function hasOwnJsonOption(command: Command): boolean { + return command.options.some((option) => option.long === "--json"); +} + +function hasAncestorJsonOption(command: Command): boolean { + for (let parent = command.parent; parent; parent = parent.parent) { + if (hasOwnJsonOption(parent)) { + return true; + } + } + return false; +} + +function supportsJsonOutput(path: string, command: Command): boolean { + // `config set --json` is a legacy strict-input parser alias. Only its + // `--dry-run --json` combination reports JSON, so the mutation stays N/A. + if (path === "config set") { + return false; + } + return ( + hasOwnJsonOption(command) || + (JSON_OUTPUT_INHERITED_FROM_PARENT.has(path) && hasAncestorJsonOption(command)) || + JSON_OUTPUT_ROUTE_FIRST.has(path) + ); +} + describe("root command descriptions", () => { beforeEach(() => { vi.stubEnv("OPENCLAW_ENABLE_PRIVATE_QA_CLI", ""); @@ -18,16 +251,7 @@ describe("root command descriptions", () => { }); it("keeps catalog placeholders and registered commands in sync", async () => { - const program = new Command().name("openclaw"); - const ctx = createProgramContext(); - const argv = ["node", "openclaw", "completion"]; - - for (const name of getCoreCliCommandNames()) { - await registerCoreCliByName(program, ctx, name, argv); - } - for (const entry of getSubCliEntries()) { - await registerSubCliByName(program, entry.name, argv, { purpose: "completion" }); - } + const program = await registerAllBuiltInCommands(); const registeredCommands = new Map(); for (const command of program.commands) { @@ -59,4 +283,65 @@ describe("root command descriptions", () => { expect(missing, "catalog entries with no registered command or alias").toEqual([]); expect(mismatches, "root help vs registered command description drift").toEqual([]); }); + + it("classifies every built-in command as JSON output or explicitly not applicable", async () => { + const program = await registerAllBuiltInCommands(); + const contexts = collectShellCompletionCommandTree(program).descendants; + const registered = new Map( + contexts.map((context) => [context.pathVariants[0]?.join(" ") ?? "", context.command]), + ); + const notApplicableEntries = Object.values(JSON_NOT_APPLICABLE).flatMap((category) => + category.commands.map((command) => ({ command, reason: category.reason })), + ); + const notApplicable = new Map( + notApplicableEntries.map(({ command, reason }) => [command, reason]), + ); + + expect(notApplicable.size, "a command must not appear in more than one JSON N/A category").toBe( + notApplicableEntries.length, + ); + expect( + notApplicableEntries.filter(({ reason }) => reason.trim().length === 0), + "every JSON N/A category must document why JSON is meaningless", + ).toEqual([]); + + const unclassified = [...registered] + .filter(([path, command]) => !supportsJsonOutput(path, command) && !notApplicable.has(path)) + .map(([path]) => path); + expect(unclassified, "commands missing a deliberate JSON output decision").toEqual([]); + + const staleExceptions = [...notApplicable] + .filter(([path]) => !registered.has(path)) + .map(([path]) => path); + expect(staleExceptions, "JSON N/A entries for commands that no longer exist").toEqual([]); + + const exceptionsThatNowSupportJson = [...notApplicable] + .filter(([path]) => { + const command = registered.get(path); + return command ? supportsJsonOutput(path, command) : false; + }) + .map(([path]) => path); + expect( + exceptionsThatNowSupportJson, + "remove stale JSON N/A entries after adding output support", + ).toEqual([]); + + const staleInheritedSupport = [...JSON_OUTPUT_INHERITED_FROM_PARENT].filter((path) => { + const command = registered.get(path); + return !command || hasOwnJsonOption(command) || !hasAncestorJsonOption(command); + }); + expect( + staleInheritedSupport, + "inherited JSON entries must exist, lack their own flag, and inherit a parent flag", + ).toEqual([]); + + const staleRouteFirstSupport = [...JSON_OUTPUT_ROUTE_FIRST].filter((path) => { + const command = registered.get(path); + return !command || hasOwnJsonOption(command); + }); + expect( + staleRouteFirstSupport, + "route-first JSON entries must exist and remain absent from Commander options", + ).toEqual([]); + }); }); diff --git a/src/cli/proxy-cli.runtime.test.ts b/src/cli/proxy-cli.runtime.test.ts index 383cb37eae16..dc0f192cf768 100644 --- a/src/cli/proxy-cli.runtime.test.ts +++ b/src/cli/proxy-cli.runtime.test.ts @@ -482,6 +482,60 @@ describe("proxy cli runtime", () => { expect(process.exitCode).toBe(1); }); + it.each([ + { + name: "coverage", + run: async (runtime: typeof import("./proxy-cli.runtime.js")) => + await runtime.runDebugProxyCoverageCommand(), + assertShape: (value: unknown) => + expect(value).toEqual({ + summary: expect.objectContaining({ total: expect.any(Number) }), + entries: expect.any(Array), + }), + }, + { + name: "sessions", + run: async (runtime: typeof import("./proxy-cli.runtime.js")) => + await runtime.runDebugProxySessionsCommand({ json: true }), + assertShape: (value: unknown) => expect(value).toEqual({ sessions: [] }), + }, + { + name: "query", + run: async (runtime: typeof import("./proxy-cli.runtime.js")) => + await runtime.runDebugProxyQueryCommand({ json: true, preset: "double-sends" }), + assertShape: (value: unknown) => expect(value).toEqual({ rows: [] }), + }, + ])("prints one undecorated JSON object for proxy $name --json", async ({ run, assertShape }) => { + const runtime = await import("./proxy-cli.runtime.js"); + + await run(runtime); + + expect(process.stdout["write"]).toHaveBeenCalledOnce(); + const output = String(vi.mocked(process.stdout["write"]).mock.calls[0]?.[0] ?? ""); + const parsed = JSON.parse(output) as unknown; + assertShape(parsed); + }); + + it.each([ + { + name: "sessions", + run: async (runtime: typeof import("./proxy-cli.runtime.js")) => + await runtime.runDebugProxySessionsCommand({}), + }, + { + name: "query", + run: async (runtime: typeof import("./proxy-cli.runtime.js")) => + await runtime.runDebugProxyQueryCommand({ preset: "double-sends" }), + }, + ])("preserves the legacy bare-array proxy $name output without --json", async ({ run }) => { + const runtime = await import("./proxy-cli.runtime.js"); + + await run(runtime); + + const output = String(vi.mocked(process.stdout["write"]).mock.calls[0]?.[0] ?? ""); + expect(JSON.parse(output)).toEqual([]); + }); + it.each([ { signal: "SIGINT" as const, exitCode: 130 }, { signal: "SIGTERM" as const, exitCode: 143 }, diff --git a/src/cli/proxy-cli.runtime.ts b/src/cli/proxy-cli.runtime.ts index 16e2537b94f5..c2ce56a38836 100644 --- a/src/cli/proxy-cli.runtime.ts +++ b/src/cli/proxy-cli.runtime.ts @@ -22,6 +22,7 @@ import { getDebugProxyCaptureStore, } from "../proxy-capture/store.sqlite.js"; import type { CaptureQueryPreset } from "../proxy-capture/types.js"; +import { defaultRuntime, writeRuntimeJson } from "../runtime.js"; import { resolveSubprocessExitCode } from "./subprocess-exit-code.js"; export async function runDebugProxyStartCommand(opts: { host?: string; port?: number }) { @@ -282,23 +283,25 @@ export async function runProxyValidateCommand(opts: { } } -export async function runDebugProxySessionsCommand(opts: { limit?: number }) { +export async function runDebugProxySessionsCommand(opts: { json?: boolean; limit?: number }) { const sessions = getDebugProxyCaptureStore().listSessions(opts.limit ?? 20); - process.stdout.write(`${JSON.stringify(sessions, null, 2)}\n`); + writeRuntimeJson(defaultRuntime, opts.json ? { sessions } : sessions); closeDebugProxyCaptureStore(); } export async function runDebugProxyQueryCommand(opts: { + json?: boolean; preset: CaptureQueryPreset; sessionId?: string; }) { const rows = getDebugProxyCaptureStore().queryPreset(opts.preset, opts.sessionId); - process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`); + writeRuntimeJson(defaultRuntime, opts.json ? { rows } : rows); closeDebugProxyCaptureStore(); } export async function runDebugProxyCoverageCommand() { - process.stdout.write(`${JSON.stringify(buildDebugProxyCoverageReport(), null, 2)}\n`); + const report = buildDebugProxyCoverageReport(); + writeRuntimeJson(defaultRuntime, report); closeDebugProxyCaptureStore(); } diff --git a/src/cli/proxy-cli.test.ts b/src/cli/proxy-cli.test.ts index 9249d71b28d3..be94e262d95a 100644 --- a/src/cli/proxy-cli.test.ts +++ b/src/cli/proxy-cli.test.ts @@ -3,17 +3,24 @@ import { Command } from "commander"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { registerProxyCli } from "./proxy-cli.js"; -const { runDebugProxySessionsCommand, runDebugProxyStartCommand, runProxyValidateCommand } = - vi.hoisted(() => ({ - runDebugProxySessionsCommand: vi.fn(), - runDebugProxyStartCommand: vi.fn(), - runProxyValidateCommand: vi.fn(), - })); +const { + runDebugProxyCoverageCommand, + runDebugProxyQueryCommand, + runDebugProxySessionsCommand, + runDebugProxyStartCommand, + runProxyValidateCommand, +} = vi.hoisted(() => ({ + runDebugProxyCoverageCommand: vi.fn(), + runDebugProxyQueryCommand: vi.fn(), + runDebugProxySessionsCommand: vi.fn(), + runDebugProxyStartCommand: vi.fn(), + runProxyValidateCommand: vi.fn(), +})); vi.mock("./proxy-cli.runtime.js", () => ({ - runDebugProxyCoverageCommand: vi.fn(), + runDebugProxyCoverageCommand, runDebugProxyPurgeCommand: vi.fn(), - runDebugProxyQueryCommand: vi.fn(), + runDebugProxyQueryCommand, runDebugProxyRunCommand: vi.fn(), runDebugProxySessionsCommand, runDebugProxyStartCommand, @@ -34,6 +41,8 @@ describe("proxy cli", () => { } beforeEach(() => { + runDebugProxyCoverageCommand.mockReset(); + runDebugProxyQueryCommand.mockReset(); runDebugProxySessionsCommand.mockReset(); runDebugProxyStartCommand.mockReset(); runProxyValidateCommand.mockReset(); @@ -67,6 +76,47 @@ describe("proxy cli", () => { "--apns-authority", "--timeout-ms", ]); + + expect( + Object.fromEntries( + ["coverage", "sessions", "query"].map((name) => { + const command = proxy?.commands.find((candidate) => candidate.name() === name); + return [name, command?.options.map((option) => option.long)]; + }), + ), + ).toEqual({ + coverage: ["--json"], + sessions: ["--json", "--limit"], + query: ["--preset", "--json", "--session"], + }); + }); + + it.each([ + { + args: ["proxy", "coverage", "--json"], + invoke: runDebugProxyCoverageCommand, + expected: undefined, + }, + { + args: ["proxy", "sessions", "--json", "--limit", "5"], + invoke: runDebugProxySessionsCommand, + expected: { json: true, limit: 5 }, + }, + { + args: ["proxy", "query", "--json", "--preset", "double-sends", "--session", "capture-1"], + invoke: runDebugProxyQueryCommand, + expected: { json: true, preset: "double-sends", sessionId: "capture-1" }, + }, + ])("passes --json through proxy reporting command $args", async ({ args, invoke, expected }) => { + const program = createProgram(); + + await program.parseAsync(["node", "openclaw", ...args]); + + if (expected === undefined) { + expect(invoke).toHaveBeenCalledWith(); + } else { + expect(invoke).toHaveBeenCalledWith(expected); + } }); it.each([ diff --git a/src/cli/proxy-cli.ts b/src/cli/proxy-cli.ts index 97fbfd4da924..07dc408f2f06 100644 --- a/src/cli/proxy-cli.ts +++ b/src/cli/proxy-cli.ts @@ -123,6 +123,7 @@ export function registerProxyCli(program: Command) { proxy .command("coverage") .description("Report current debug proxy transport coverage and remaining gaps") + .option("--json", "Print machine-readable JSON") .action(async () => { const runtime = await loadProxyCliRuntime(); await runtime.runDebugProxyCoverageCommand(); @@ -131,10 +132,11 @@ export function registerProxyCli(program: Command) { proxy .command("sessions") .description("List recent capture sessions") + .option("--json", "Print machine-readable JSON") .option("--limit ", "Maximum sessions to show", (value) => parsePositiveIntegerOption(value, "--limit"), ) - .action(async (opts: { limit?: number }) => { + .action(async (opts: { json?: boolean; limit?: number }) => { const runtime = await loadProxyCliRuntime(); await runtime.runDebugProxySessionsCommand(opts); }); @@ -146,10 +148,12 @@ export function registerProxyCli(program: Command) { "--preset ", "Query preset: double-sends, retry-storms, cache-busting, ws-duplicate-frames, missing-ack, error-bursts", ) + .option("--json", "Print machine-readable JSON") .option("--session ", "Restrict to a capture session id") - .action(async (opts: { preset: CaptureQueryPreset; session?: string }) => { + .action(async (opts: { json?: boolean; preset: CaptureQueryPreset; session?: string }) => { const runtime = await loadProxyCliRuntime(); await runtime.runDebugProxyQueryCommand({ + json: opts.json, preset: opts.preset, sessionId: opts.session, }); diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index f63eb0c7767f..ca1f1e05afea 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -1282,7 +1282,7 @@ describe("skills cli commands", () => { expect(fetchClawHubSkillCardMock).not.toHaveBeenCalled(); }); - it("does not register a redundant --json option for verify", () => { + it("registers explicit --json output for verify", () => { const skills = createProgram().commands.find((command) => command.name() === "skills"); const verify = skills?.commands.find((command) => command.name() === "verify"); @@ -1290,12 +1290,22 @@ describe("skills cli commands", () => { "--version", "--tag", "--card", + "--json", "--global", "--agent", ]); }); it.each([ + { + label: "default list", + argv: ["skills", "--json"], + assert: (payload: Record) => { + const skills = payload.skills as Array>; + expect(skills).toHaveLength(1); + expect(skills[0]?.name).toBe("calendar"); + }, + }, { label: "list", argv: ["skills", "list", "--json"], diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index ffdec93e9c4e..1c02251e880c 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -556,11 +556,14 @@ export function registerSkillsCli(program: Command) { .command("skills") .description("List and inspect available skills") .option("--agent ", "Target agent workspace (defaults to cwd-inferred, then default agent)") + .option("--json", "Output as JSON", false) .addHelpText( "after", () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/skills", "docs.openclaw.ai/cli/skills")}\n`, ); + const hasJsonOutput = (opts?: { json?: boolean }): boolean => + Boolean(opts?.json || skills.opts<{ json?: boolean }>().json); setCommandJsonMode(skills, "output", ({ argv }) => isSkillsMachineOutput(argv)); skills @@ -575,7 +578,7 @@ export function registerSkillsCli(program: Command) { query: normalizeOptionalString(queryParts.join(" ")), limit: opts.limit, }); - if (opts.json) { + if (hasJsonOutput(opts)) { defaultRuntime.writeJson({ results }); return; } @@ -826,6 +829,7 @@ export function registerSkillsCli(program: Command) { .option("--version ", "Verify a specific version") .option("--tag ", "Verify a dist tag") .option("--card", "Print the generated Skill Card Markdown", false) + .option("--json", "Output as JSON", false) .option( "--global", "Resolve installed skill metadata from the shared managed skills directory", @@ -836,7 +840,14 @@ export function registerSkillsCli(program: Command) { .action( async ( slug: string, - opts: { version?: string; tag?: string; card?: boolean; global?: boolean; agent?: string }, + opts: { + version?: string; + tag?: string; + card?: boolean; + json?: boolean; + global?: boolean; + agent?: string; + }, command: Command, ) => { let exitCode: number | undefined; @@ -865,7 +876,7 @@ export function registerSkillsCli(program: Command) { tag: target.tag, baseUrl: target.baseUrl, }); - if (opts.card) { + if (opts.card && !hasJsonOutput(opts)) { const cardUrl = readVerifiedSkillCardUrl(verification); if (!cardUrl.ok) { defaultRuntime.error(cardUrl.error); @@ -902,7 +913,7 @@ export function registerSkillsCli(program: Command) { const showCuratorStatus = async () => { try { const status = await loadSkillCuratorStatus(); - if (curator.opts<{ json?: boolean }>().json) { + if (hasJsonOutput(curator.opts<{ json?: boolean }>())) { defaultRuntime.writeJson(status); return; } @@ -926,7 +937,7 @@ export function registerSkillsCli(program: Command) { .action(async (skill: string) => { try { const result = await runSkillCuratorMutation(action, skill); - if (curator.opts<{ json?: boolean }>().json) { + if (hasJsonOutput(curator.opts<{ json?: boolean }>())) { defaultRuntime.writeJson(result); return; } @@ -958,7 +969,7 @@ export function registerSkillsCli(program: Command) { try { const { agentId, workspaceDir } = resolveSkillsWorkspaceForCommand(workshop, opts); const manifest = await listSkillProposals({ agentId, workspaceDir }); - if (opts.json) { + if (hasJsonOutput(opts)) { defaultRuntime.writeJson(manifest); return; } @@ -983,7 +994,7 @@ export function registerSkillsCli(program: Command) { defaultRuntime.exit(1); return; } - if (opts.json) { + if (hasJsonOutput(opts)) { defaultRuntime.writeJson(proposal); return; } @@ -1040,7 +1051,7 @@ export function registerSkillsCli(program: Command) { goal: opts.goal, evidence: opts.evidence, }); - if (opts.json) { + if (hasJsonOutput(opts)) { defaultRuntime.writeJson(proposal); return; } @@ -1098,7 +1109,7 @@ export function registerSkillsCli(program: Command) { goal: opts.goal, evidence: opts.evidence, }); - if (opts.json) { + if (hasJsonOutput(opts)) { defaultRuntime.writeJson(proposal); return; } @@ -1155,7 +1166,7 @@ export function registerSkillsCli(program: Command) { goal: opts.goal, evidence: opts.evidence, }); - if (opts.json) { + if (hasJsonOutput(opts)) { defaultRuntime.writeJson(proposal); return; } @@ -1188,7 +1199,7 @@ export function registerSkillsCli(program: Command) { proposalId, normalizeOptionalString(opts.correlationId), ); - if (opts.json) { + if (hasJsonOutput(opts)) { defaultRuntime.writeJson(evaluated); return; } @@ -1210,7 +1221,7 @@ export function registerSkillsCli(program: Command) { try { const resolved = resolveSkillsWorkspaceForCommand(command.parent, opts); const applied = await runSkillProposalApply(resolved, proposalId); - if (opts.json) { + if (hasJsonOutput(opts)) { defaultRuntime.writeJson(applied); return; } @@ -1245,7 +1256,7 @@ export function registerSkillsCli(program: Command) { proposalId, reason: opts.reason, }); - if (opts.json) { + if (hasJsonOutput(opts)) { defaultRuntime.writeJson(record); return; } @@ -1278,7 +1289,7 @@ export function registerSkillsCli(program: Command) { proposalId, reason: opts.reason, }); - if (opts.json) { + if (hasJsonOutput(opts)) { defaultRuntime.writeJson(record); return; } @@ -1304,9 +1315,16 @@ export function registerSkillsCli(program: Command) { opts: { json?: boolean; eligible?: boolean; verbose?: boolean; agent?: string }, command: Command, ) => { - await runSkillsAction((report) => formatSkillsList(report, opts), { - agentId: resolveAgentOption(command, opts), - }); + await runSkillsAction( + (report) => + formatSkillsList(report, { + ...opts, + json: hasJsonOutput(opts), + }), + { + agentId: resolveAgentOption(command, opts), + }, + ); }, ); @@ -1317,9 +1335,16 @@ export function registerSkillsCli(program: Command) { .option("--json", "Output as JSON", false) .option("--agent ", "Target agent workspace (defaults to cwd-inferred, then default agent)") .action(async (name: string, opts: { json?: boolean; agent?: string }, command: Command) => { - await runSkillsAction((report) => formatSkillInfo(report, name, opts), { - agentId: resolveAgentOption(command, opts), - }); + await runSkillsAction( + (report) => + formatSkillInfo(report, name, { + ...opts, + json: hasJsonOutput(opts), + }), + { + agentId: resolveAgentOption(command, opts), + }, + ); }); skills @@ -1328,14 +1353,21 @@ export function registerSkillsCli(program: Command) { .option("--agent ", "Target agent workspace (defaults to cwd-inferred, then default agent)") .option("--json", "Output as JSON", false) .action(async (opts: { json?: boolean; agent?: string }, command: Command) => { - await runSkillsAction((report) => formatSkillsCheck(report, opts), { - agentId: resolveAgentOption(command, opts), - }); + await runSkillsAction( + (report) => + formatSkillsCheck(report, { + ...opts, + json: hasJsonOutput(opts), + }), + { + agentId: resolveAgentOption(command, opts), + }, + ); }); // Default action (no subcommand) - show list - skills.action(async (opts: { agent?: string }, command: Command) => { - await runSkillsAction((report) => formatSkillsList(report, {}), { + skills.action(async (opts: { agent?: string; json?: boolean }, command: Command) => { + await runSkillsAction((report) => formatSkillsList(report, { json: hasJsonOutput(opts) }), { agentId: resolveAgentOption(command, opts), }); }); diff --git a/src/cli/skills-cli.verify.test.ts b/src/cli/skills-cli.verify.test.ts index 18ac54308c7d..cbfaf1b16508 100644 --- a/src/cli/skills-cli.verify.test.ts +++ b/src/cli/skills-cli.verify.test.ts @@ -297,7 +297,7 @@ describe("skills verify CLI", () => { signature: { status: "unsigned" }, }); - await runCommand(["skills", "verify", "agentreceipt"]); + await runCommand(["skills", "verify", "agentreceipt", "--json"]); expect(mocks.fetchClawHubSkillVerificationMock).toHaveBeenCalledWith({ slug: "agentreceipt", diff --git a/src/commands/docs.test.ts b/src/commands/docs.test.ts index c240d46f85ae..50f01eeede6c 100644 --- a/src/commands/docs.test.ts +++ b/src/commands/docs.test.ts @@ -65,6 +65,50 @@ describe("docsSearchCommand", () => { expect(init).toMatchObject({ headers: { Accept: "application/json" } }); }); + it("emits one JSON object for search results", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + results: [ + { + title: "CLI reference", + link: "https://docs.openclaw.ai/cli", + snippet: "Command-line usage", + }, + ], + }), + ), + ); + const runtime = makeRuntime(); + + await docsSearchCommand(["cli"], runtime, { json: true }); + + expect(runtime.log).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(runtime.log.mock.calls[0]?.[0]))).toEqual({ + query: "cli", + results: [ + { + title: "CLI reference", + link: "https://docs.openclaw.ai/cli", + snippet: "Command-line usage", + }, + ], + }); + }); + + it("emits one JSON object for the docs homepage", async () => { + const runtime = makeRuntime(); + + await docsSearchCommand([], runtime, { json: true }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(JSON.parse(String(runtime.log.mock.calls[0]?.[0]))).toEqual({ + query: null, + url: "https://docs.openclaw.ai/", + results: [], + }); + }); + it("cancels non-OK docs search response bodies and fails loudly", async () => { let cancelled = false; const response = new Response( diff --git a/src/commands/docs.ts b/src/commands/docs.ts index cf098b504c5a..03daf31b5bad 100644 --- a/src/commands/docs.ts +++ b/src/commands/docs.ts @@ -3,7 +3,7 @@ import { isRich, theme } from "../../packages/terminal-core/src/theme.js"; import { formatCliCommand } from "../cli/command-format.js"; // Implements docs link/search output for `openclaw docs`. import { readResponseWithLimit } from "../infra/http-body.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; const SEARCH_API = "https://docs.openclaw.ai/api/search"; const SEARCH_TIMEOUT_MS = 30_000; @@ -119,9 +119,21 @@ function parseDocsSearchResults(raw: unknown): DocResult[] { } /** Search hosted docs, or print the docs homepage when no query is provided. */ -export async function docsSearchCommand(queryParts: string[], runtime: RuntimeEnv) { +export async function docsSearchCommand( + queryParts: string[], + runtime: RuntimeEnv, + options: { json?: boolean } = {}, +) { const query = queryParts.join(" ").trim(); if (!query) { + if (options.json) { + writeRuntimeJson(runtime, { + query: null, + url: "https://docs.openclaw.ai/", + results: [], + }); + return; + } const docs = formatDocsLink("/", "docs.openclaw.ai"); if (isRich()) { runtime.log(`${theme.muted("Docs:")} ${docs}`); @@ -143,6 +155,11 @@ export async function docsSearchCommand(queryParts: string[], runtime: RuntimeEn return; } + if (options.json) { + writeRuntimeJson(runtime, { query, results }); + return; + } + if (isRich()) { renderRichResults(query, results, runtime); return; diff --git a/src/security/audit-probe-failure.test.ts b/src/security/audit-probe-failure.test.ts index 6a88c54c6de4..825fd2222536 100644 --- a/src/security/audit-probe-failure.test.ts +++ b/src/security/audit-probe-failure.test.ts @@ -1,6 +1,8 @@ // Verifies probe failure audit reporting. import { describe, expect, it } from "vitest"; +import { withEnvAsync } from "../test-utils/env.js"; import { collectDeepProbeFindings } from "./audit-deep-probe-findings.js"; +import { runSecurityAudit } from "./audit.js"; function requireProbeFailure(findings: ReturnType) { const finding = findings.find((entry) => entry.checkId === "gateway.probe_failed"); @@ -11,6 +13,49 @@ function requireProbeFailure(findings: ReturnType { + it("redacts gateway URL credentials from the deep audit report", async () => { + const user = "audit-user-sentinel"; + const password = "audit-password-sentinel"; + const querySecret = "audit-query-sentinel"; + const url = `wss://${user}:${password}@gateway.example.test/socket?client_secret=${querySecret}`; + + const report = await withEnvAsync({ OPENCLAW_GATEWAY_URL: undefined }, async () => + runSecurityAudit({ + config: { gateway: { mode: "remote", remote: { url } } }, + sourceConfig: { gateway: { mode: "remote", remote: { url } } }, + env: {}, + deep: true, + includeFilesystem: false, + includeChannelSecurity: false, + loadPluginSecurityCollectors: false, + probeGatewayFn: async ({ url: probeUrl }) => ({ + ok: false, + url: probeUrl, + connectLatencyMs: null, + error: `failed to connect to ${probeUrl}`, + close: { code: 1006, reason: `connection closed at ${probeUrl}` }, + auth: { role: null, scopes: [], capability: "unknown" }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }), + }), + ); + + expect(report.deep?.gateway).toMatchObject({ + url: "wss://***:***@gateway.example.test/socket?client_secret=***", + error: "failed to connect to wss://***:***@gateway.example.test/socket?client_secret=***", + close: { + reason: "connection closed at wss://***:***@gateway.example.test/socket?client_secret=***", + }, + }); + const serialized = JSON.stringify(report); + expect(serialized).not.toContain(user); + expect(serialized).not.toContain(password); + expect(serialized).not.toContain(querySecret); + }); + it("adds probe_failed warnings for deep probe failure modes", () => { const cases: Array<{ name: string; diff --git a/src/security/audit.ts b/src/security/audit.ts index d108db5eddd6..c5058c6f2f07 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1,5 +1,6 @@ // Orchestrates security audit collection and report formatting. import path from "node:path"; +import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; @@ -1268,10 +1269,15 @@ async function maybeProbeGateway(params: { deep: { gateway: { attempted: true, - url, + url: redactSensitiveUrlLikeString(url), ok: res.ok, - error: res.ok ? null : res.error, - close: res.close ? { code: res.close.code, reason: res.close.reason } : null, + error: res.ok || res.error === null ? null : redactSensitiveUrlLikeString(res.error), + close: res.close + ? { + code: res.close.code, + reason: redactSensitiveUrlLikeString(res.close.reason), + } + : null, }, }, authWarning: authResolution.warning,