diff --git a/AGENTS.md b/AGENTS.md index 35b00b4..c5142bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,20 +7,20 @@ This repository ships a CLI-first interface for agents. Do not rely on MCP, brow Run these first: ```bash -pcl --llms -pcl doctor -pcl auth ensure --format toon -pcl whoami -pcl api manifest --format toon +pcl --toon --llms +pcl doctor --toon +pcl auth ensure --toon +pcl whoami --toon +pcl api manifest --toon ``` When changing this repository, run `make ci` before handing work back. It sets `PCL_AUTH_NO_BROWSER=1` for tests so auth flows do not open a browser, and it runs `make agent-smoke` to verify the documented agent discovery path. -Use TOON as the normal machine interface; it is the default compact envelope and is cheaper for agents to consume. Examples in this file prefer default TOON or `--format toon` when the contract should be explicit. Use `--json` or `--format json` only when a downstream tool needs strict JSON. +Use TOON as the normal machine interface. Default CLI output is optimized for humans, so agent examples must pass `--toon`. Use `--json` only when a downstream tool needs strict JSON. ## Output Contract -Every agent-facing command should be treated as an envelope. In default TOON this is shaped like: +Every agent-facing command should be treated as an envelope. With `--toon` this is shaped like: ```toon status: ok @@ -34,9 +34,9 @@ Errors use the same shape with `status: "error"` and an `error` object. Do not p Output mode rules: -- default: TOON envelope -- explicit TOON: `--format toon` -- JSON: `--json` or `--format json` +- default: human-readable output for people +- TOON: `--toon` +- JSON: `--json` - JSONL exception: fresh `pcl auth login --json` streams events and marks the final event with `terminal: true` Fresh `pcl auth login --json` emits JSONL progress events: first `event: auth.login_instructions`, then a terminal envelope with `terminal: true`. Treat only the terminal event as the final login result. Existing valid auth still returns a single JSON envelope. @@ -45,9 +45,9 @@ Fresh `pcl auth login --json` emits JSONL progress events: first `event: auth.lo Prefer the surfaces in this order: -1. `pcl --llms` or `pcl llms` for the current agent guide. -2. `pcl workflows` for task recipes. -3. `pcl schema list` and `pcl schema get --action ` for workflow action contracts. +1. `pcl --toon --llms` or `pcl llms --toon` for the current agent guide. +2. `pcl workflows --toon` for task recipes. +3. `pcl schema list --toon` and `pcl schema get --action --toon` for workflow action contracts. 4. Top-level workflow commands like `pcl incidents`, `pcl projects`, `pcl assertions`, `pcl account`, `pcl releases`, and `pcl protocol-manager`. 5. `pcl api list`, `pcl api inspect`, `pcl api call`, and `pcl api coverage` only for debugging, API parity checks, internal/service endpoints, or endpoints without `workflow_alternatives`. @@ -70,19 +70,19 @@ Raw calls are not the normal product path. Use them for debugging, API parity ch Both query forms are valid: ```bash -pcl api call get '/views/public/incidents?limit=5' --allow-unauthenticated --format toon -pcl api call get /views/public/incidents --query limit=5 --allow-unauthenticated --format toon +pcl api call get '/views/public/incidents?limit=5' --allow-unauthenticated --toon +pcl api call get /views/public/incidents --query limit=5 --allow-unauthenticated --toon ``` For simple raw request bodies, `pcl api call` accepts repeated `--field key=value` and merges those fields into a JSON object, matching workflow command behavior. Use `--body-file` for nested payloads. -Use `pcl api inspect --format toon` before calling unfamiliar endpoints. Inspect includes `workflow_alternatives`, `raw_api_use`, auth metadata, and required header placeholders; preserve required `--header` values in generated examples. For required request bodies, inspect the operation and prefer `--body-file`. +Use `pcl api inspect --toon` before calling unfamiliar endpoints. Inspect includes `workflow_alternatives`, `raw_api_use`, auth metadata, and required header placeholders; preserve required `--header` values in generated examples. For required request bodies, inspect the operation and prefer `--body-file`. Raw API calls persist `operation_id` in request history when the live OpenAPI manifest can resolve the method/path. After exploratory testing, run: ```bash -pcl api coverage --format toon -pcl api coverage --markdown api-coverage.md --format toon +pcl api coverage --toon +pcl api coverage --markdown api-coverage.md --toon ``` Use `no_hit`, `no_2xx`, `write_no_2xx`, and `unmatched_records` to decide what still needs manual reconciliation. @@ -92,11 +92,11 @@ Use `no_hit`, `no_2xx`, `write_no_2xx`, and `unmatched_records` to decide what s For investigations, prefer JSONL exports and local job records: ```bash -pcl export incidents --project-id --environment production --out incidents.jsonl --errors errors.jsonl --checkpoint checkpoint.json --resume --continue-on-error --format toon -pcl jobs list --format toon -pcl jobs status --format toon -pcl jobs resume --format toon -pcl artifacts list --format toon +pcl export incidents --project-id --environment production --out incidents.jsonl --errors errors.jsonl --checkpoint checkpoint.json --resume --continue-on-error --toon +pcl jobs list --toon +pcl jobs status --toon +pcl jobs resume --toon +pcl artifacts list --toon ``` Export commands record `job_id`, `resume_command`, checkpoint path, output path, and error path. Use those fields instead of rebuilding pagination state manually. @@ -106,16 +106,16 @@ Export commands record `job_id`, `resume_command`, checkpoint path, output path, Use: ```bash -pcl auth status --format toon -pcl auth ensure --format toon -pcl whoami --format toon +pcl auth status --toon +pcl auth ensure --toon +pcl whoami --toon ``` -Do not treat a stored token as valid unless `token_valid` is true and `expired` is false. `pcl doctor --format toon` also checks whether the target API advertises CLI login, refresh, and remote logout/revocation endpoints. Public endpoints should be called with `--allow-unauthenticated` when using raw `pcl api call`. +Do not treat a stored token as valid unless `token_valid` is true and `expired` is false. `pcl doctor --toon` also checks whether the target API advertises CLI login, refresh, and remote logout/revocation endpoints. Public endpoints should be called with `--allow-unauthenticated` when using raw `pcl api call`. -Use `pcl auth ensure --format toon` before long workflows. It returns `status: ok` when auth is usable, or one `status: action_required` envelope with `device_url`, `code`, `device_secret`, and `poll_command` when user login is needed. Run `poll_command` until it returns `status: ok` or `status: error`. +Use `pcl auth ensure --toon` before long workflows. It returns `status: ok` when auth is usable, or one `status: action_required` envelope with `device_url`, `code`, `device_secret`, and `poll_command` when user login is needed. Run `poll_command` until it returns `status: ok` or `status: error`. -`expires_soon: true` means the access token has five minutes or less remaining. `pcl auth refresh --format toon` is safe to call and rotates the stored CLI refresh token when available; if the refresh token is missing or rejected, it returns the same login challenge shape. `pcl auth login --no-wait --format toon` also returns a single challenge envelope. Use `pcl auth login --json` only when you specifically want the JSONL streaming login contract. `pcl auth logout` attempts remote logout first, then clears local credentials; use `pcl auth logout --local` only when you explicitly want local cleanup. +`expires_soon: true` means the access token has five minutes or less remaining. `pcl auth refresh --toon` is safe to call and rotates the stored CLI refresh token when available; if the refresh token is missing or rejected, it returns the same login challenge shape. `pcl auth login --no-wait --toon` also returns a single challenge envelope. Use `pcl auth login --json` only when you specifically want the JSONL streaming login contract. `pcl auth logout` attempts remote logout first, then clears local credentials; use `pcl auth logout --local` only when you explicitly want local cleanup. Auth commands use `--auth-url`/`PCL_AUTH_URL` when set, otherwise they follow `PCL_API_URL` before falling back to the production app URL. @@ -127,7 +127,7 @@ When reporting results, preserve: - Incident IDs, transaction hashes, trace IDs, project IDs, and artifact paths. - The exact command used, especially for exports and mutations. -Use `pcl requests list --format toon` to recover recent request metadata. +Use `pcl requests list --toon` to recover recent request metadata. ## Shell Completions @@ -139,4 +139,4 @@ pcl completions zsh pcl completions fish ``` -Use `--format json` for completions only when a downstream installer expects the script inside a JSON envelope. +Use `--json` for completions only when a downstream installer expects the script inside a JSON envelope. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d3a586..35bac34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable user-facing changes should be recorded here. ## Unreleased +## 1.4.3 - 2026-05-10 + +- Made default CLI output human-first across command surfaces, including auth, config, workflow, schema, API discovery, dry-run, export, job, artifact, request log, collection, incident, and raw API response views. +- Added `--toon` as the compact agent-readable output mode while preserving `--json` and hidden legacy `--format` compatibility. +- Updated agent-facing docs and smoke checks to use `--toon`. + ## 1.4.2 - 2026-05-09 - Updated Credible SDK assertion dependencies to the latest 1.4 assertion runtime graph. diff --git a/Cargo.lock b/Cargo.lock index 78c3dec..92c6434 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2627,7 +2627,7 @@ dependencies = [ [[package]] name = "dapp-api-client" -version = "1.4.2" +version = "1.4.3" dependencies = [ "anyhow", "assert_matches", @@ -6672,7 +6672,7 @@ dependencies = [ [[package]] name = "pcl" -version = "1.4.2" +version = "1.4.3" dependencies = [ "chrono", "clap", @@ -6689,7 +6689,7 @@ dependencies = [ [[package]] name = "pcl-common" -version = "1.4.2" +version = "1.4.3" dependencies = [ "clap", "serde_json", @@ -6697,7 +6697,7 @@ dependencies = [ [[package]] name = "pcl-core" -version = "1.4.2" +version = "1.4.3" dependencies = [ "alloy-dyn-abi", "alloy-json-abi", @@ -6731,7 +6731,7 @@ dependencies = [ [[package]] name = "pcl-phoundry" -version = "1.4.2" +version = "1.4.3" dependencies = [ "alloy-json-abi", "clap", diff --git a/Cargo.toml b/Cargo.toml index fd3f66e..1d06e4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ resolver = "2" [workspace.package] -version = "1.4.2" +version = "1.4.3" edition = "2024" authors = ["Phylax Systems"] license = "BSL 1.1" diff --git a/README.md b/README.md index 42e7b32..aef1e7a 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ If `pcl api list` or `pcl api inspect` returns `workflow_alternatives`, use that pcl api list --filter incidents pcl api inspect get_views_projects_project_id_incidents pcl incidents --project --environment production -pcl api coverage --format toon +pcl api coverage --toon ``` ### Command Map @@ -76,8 +76,8 @@ pcl api coverage --format toon | `pcl account`, `pcl contracts`, `pcl releases`, `pcl deployments` | Account, contract, release, and deployment workflows | | `pcl access`, `pcl integrations`, `pcl protocol-manager`, `pcl transfers`, `pcl events`, `pcl search` | Access control, integrations, protocol manager, transfer, audit, and search workflows | | `pcl doctor`, `pcl whoami` | Diagnose local/API readiness, target auth capability, and identity state | -| `pcl workflows`, `pcl schema` | Agent-facing workflow recipes and command/action schemas | -| `pcl --llms`, `pcl llms` | Print the CLI-native LLM usage guide | +| `pcl workflows`, `pcl schema` | Workflow recipes and command/action schemas; agents should add `--toon` | +| `pcl --toon --llms`, `pcl llms --toon` | Print the CLI-native LLM usage guide for agents | | `pcl export`, `pcl jobs`, `pcl artifacts`, `pcl requests` | Export JSONL artifacts and inspect local jobs, artifacts, and request logs | | `pcl completions` | Generate shell completion scripts | | `pcl api` | Discover, inspect, call, and audit raw platform API endpoints | @@ -122,21 +122,21 @@ Top-level workflow commands expose the platform API as structured CLI operations `pcl api` remains the raw discovery and escape-hatch surface for uncovered endpoints. The CLI is designed around the platform workflows documented in the [Phylax docs](https://docs.phylax.systems): projects, assertions, transparency views, deployment state, integrations, and incidents. -API commands default to compact TOON-style envelopes with `status`, `data`, and `next_actions`; -use `--format toon` to pin that default explicitly. Pass `--json` / `--format json` +API commands default to human-readable output for people. Agents should pass `--toon` to get compact +TOON-style envelopes with `status`, `data`, and `next_actions`. Pass `--json` only when a downstream tool needs the same envelope as strict JSON. Successes and errors use the same shape, so agents can recover from auth, validation, and parser failures without scraping prose diagnostics. `pcl auth status` also reports token validity, expiry, and platform URL; expired stored tokens return a nonzero structured error so agents do not mistake stale credentials for a working login. -For preflight checks, prefer `pcl auth ensure --format toon`: it returns `status: ok` when auth is usable, +For preflight checks, prefer `pcl auth ensure --toon`: it returns `status: ok` when auth is usable, or one `status: action_required` envelope with `device_url`, `code`, `device_secret`, and `poll_command` -when user login is needed. `pcl auth refresh --format toon` is safe to call and rotates the stored CLI +when user login is needed. `pcl auth refresh --toon` is safe to call and rotates the stored CLI refresh token when available; if the refresh token is missing or rejected, it returns the same login challenge shape. Auth commands use `--auth-url`/`PCL_AUTH_URL` when set, otherwise they follow `PCL_API_URL` before falling back to the production app URL. -When `expires_soon` is true, renew before long-running work with `pcl auth ensure --force --format toon` -or `pcl auth login --no-wait --format toon`. +When `expires_soon` is true, renew before long-running work with `pcl auth ensure --force --toon` +or `pcl auth login --no-wait --toon`. `pcl auth logout` attempts remote logout first, then clears local credentials. Use `pcl auth logout --local` only when you explicitly want local cleanup. Repository-local agent instructions also live in [AGENTS.md](AGENTS.md). @@ -147,15 +147,15 @@ The core discovery commands in this section are exercised by `make agent-smoke`, Start with CLI-native discovery. Do not scrape human help text unless the structured surfaces are missing the field you need. -1. `pcl --llms` for the current CLI-native agent guide. -2. `pcl doctor` and `pcl whoami` for readiness and token truthfulness. -3. `pcl workflows`, `pcl schema list`, and `pcl api manifest --format toon` for discovery. +1. `pcl --toon --llms` for the current CLI-native agent guide. +2. `pcl doctor --toon` and `pcl whoami --toon` for readiness and token truthfulness. +3. `pcl workflows --toon`, `pcl schema list --toon`, and `pcl api manifest --toon` for discovery. 4. Top-level workflow commands for normal work. 5. `pcl api list`, `pcl api inspect`, `pcl api call`, and `pcl api coverage` only for debugging, API parity checks, internal/service endpoints, or endpoints without `workflow_alternatives`. ### Output Contract -Every machine-facing command is an envelope. Default TOON output looks like: +Every agent-facing command should be treated as an envelope. Explicit TOON output looks like: ```toon status: ok @@ -174,55 +174,53 @@ Errors use `status: "error"` with: - optional `request_id` - `next_actions` -Default output is TOON for compact agent consumption. Use `--format toon` when a script needs -to make that contract explicit. Use `--json` or `--format json` only when you need strict JSON parsing. -Do not parse colored or human prose output as a control plane. +Default output is for humans. Use `--toon` for compact agent consumption. Use `--json` +only when you need strict JSON parsing. Do not parse colored or human prose output as a control plane. -`pcl auth login --json` is the one streaming exception: a fresh login emits JSONL events because the command must print device-login instructions and then wait for verification. Read each line as an envelope and trust only the event with `terminal: true` as the final result. If credentials are already valid, `pcl auth login --json` returns a single normal envelope. For normal agent flows, prefer a single TOON envelope from `pcl auth ensure --format toon` or `pcl auth login --no-wait --format toon`, then run `data.poll_command`. +`pcl auth login --json` is the one streaming exception: a fresh login emits JSONL events because the command must print device-login instructions and then wait for verification. Read each line as an envelope and trust only the event with `terminal: true` as the final result. If credentials are already valid, `pcl auth login --json` returns a single normal envelope. For normal agent flows, prefer a single TOON envelope from `pcl auth ensure --toon` or `pcl auth login --no-wait --toon`, then run `data.poll_command`. ### Discovery Commands ```bash -pcl --llms -pcl --format toon --llms -pcl doctor --format toon -pcl auth ensure --format toon -pcl whoami --format toon -pcl workflows --format toon -pcl workflows show incident-investigation --format toon -pcl schema list --format toon -pcl schema get incidents --action list_public --format toon -pcl api manifest --format toon +pcl --toon --llms +pcl doctor --toon +pcl auth ensure --toon +pcl whoami --toon +pcl workflows --toon +pcl workflows show incident-investigation --toon +pcl schema list --toon +pcl schema get incidents --action list_public --toon +pcl api manifest --toon ``` -Use `--format json` for these same commands only when strict JSON parsing is required. +Use `--json` for these same commands only when strict JSON parsing is required. ### Workflow Commands Prefer top-level commands before raw API calls: ```bash -pcl incidents --limit 5 --format toon -pcl incidents --project-id --environment production --format toon -pcl incidents --project-id --all --limit 50 --output incidents.json --format toon -pcl incidents --incident-id --format toon -pcl incidents --incident-id --tx-id --retry-trace --format toon -pcl projects --limit 10 --format toon -pcl projects --project-id --format toon -pcl projects --create --project-name demo --chain-id 1 --dry-run --format toon -pcl projects --project-id --update --field github_url=https://github.com/org/repo --dry-run --format toon -pcl assertions --project-id --format toon -pcl assertions --adopter-address 0x... --network 1 --format toon -pcl account --format toon -pcl contracts --project --format toon -pcl releases --project --format toon -pcl deployments --project --format toon -pcl access --project --members --format toon -pcl integrations --project --provider slack --format toon -pcl protocol-manager --project --pending-transfer --format toon -pcl transfers --pending --format toon -pcl events --project --audit-log --format toon -pcl search --query settler --format toon +pcl incidents --limit 5 --toon +pcl incidents --project-id --environment production --toon +pcl incidents --project-id --all --limit 50 --output incidents.json --toon +pcl incidents --incident-id --toon +pcl incidents --incident-id --tx-id --retry-trace --toon +pcl projects --limit 10 --toon +pcl projects --project-id --toon +pcl projects --create --project-name demo --chain-id 1 --dry-run --toon +pcl projects --project-id --update --field github_url=https://github.com/org/repo --dry-run --toon +pcl assertions --project-id --toon +pcl assertions --adopter-address 0x... --network 1 --toon +pcl account --toon +pcl contracts --project --toon +pcl releases --project --toon +pcl deployments --project --toon +pcl access --project --members --toon +pcl integrations --project --provider slack --toon +pcl protocol-manager --project --pending-transfer --toon +pcl transfers --pending --toon +pcl events --project --audit-log --toon +pcl search --query settler --toon ``` ### Mutation Rules @@ -232,19 +230,19 @@ Use `--dry-run` before writes and `--body-template` before constructing mutation Prefer typed flags, then `--field key=value`, then `--body-file` for nested payloads. ```bash -pcl projects --body-template --format toon -pcl assertions --project-id --body-template --format toon -pcl releases --project --body-template --format toon -pcl access --project --member-user-id --update-role --body-template --format toon -pcl protocol-manager --project --confirm-transfer --body-template --format toon -pcl api inspect post_projects --format toon +pcl projects --body-template --toon +pcl assertions --project-id --body-template --toon +pcl releases --project --body-template --toon +pcl access --project --member-user-id --update-role --body-template --toon +pcl protocol-manager --project --confirm-transfer --body-template --toon +pcl api inspect post_projects --toon ``` For complex bodies: -1. Get the template with `--body-template --format toon`. +1. Get the template with `--body-template --toon`. 2. Fill the returned body into a file. -3. Run the write with `--dry-run --body-file --format toon`. +3. Run the write with `--dry-run --body-file --toon`. 4. Execute without `--dry-run` only after the request plan is correct. ### Raw API Fallback @@ -256,8 +254,8 @@ Known public raw paths do not attach stored tokens by default; pass `--allow-una For simple JSON object bodies, repeated `--field key=value` works on raw `pcl api call` the same way it works on workflow commands. ```bash -pcl api list --filter integrations --format toon -pcl api inspect get_views_projects_project_id_incidents --format toon +pcl api list --filter integrations --toon +pcl api inspect get_views_projects_project_id_incidents --toon pcl incidents --limit 5 pcl projects --create --field project_name=demo --field chain_id=1 pcl incidents --project --environment production @@ -267,24 +265,24 @@ pcl assertions --project pcl account --logout ``` -`pcl api inspect` reports `workflow_alternatives`, `raw_api_use`, auth metadata, and required header placeholders so agents can avoid manual raw calls unless they are intentionally debugging. Raw API calls record `operation_id` in request history when it can be resolved from OpenAPI; use `pcl api coverage --format toon` or `pcl api coverage --markdown api-coverage.md --format toon` after an exploration run to find untested endpoints, endpoints hit without a 2xx, and side-effecting operations that need reconciliation. +`pcl api inspect` reports `workflow_alternatives`, `raw_api_use`, auth metadata, and required header placeholders so agents can avoid manual raw calls unless they are intentionally debugging. Raw API calls record `operation_id` in request history when it can be resolved from OpenAPI; use `pcl api coverage --toon` or `pcl api coverage --markdown api-coverage.md --toon` after an exploration run to find untested endpoints, endpoints hit without a 2xx, and side-effecting operations that need reconciliation. ### Jobs, Artifacts, And Provenance For long investigations, use JSONL exports, checkpoint files, and `pcl jobs` instead of rebuilding pagination or retry state manually. ```bash -pcl export incidents --project-id --environment production --out incidents.jsonl --errors errors.jsonl --checkpoint checkpoint.json --resume --continue-on-error --format toon -pcl jobs path --format toon -pcl jobs list --format toon -pcl jobs status --format toon -pcl jobs resume --format toon -pcl artifacts list --format toon -pcl requests list --limit 20 --format toon +pcl export incidents --project-id --environment production --out incidents.jsonl --errors errors.jsonl --checkpoint checkpoint.json --resume --continue-on-error --toon +pcl jobs path --toon +pcl jobs list --toon +pcl jobs status --toon +pcl jobs resume --toon +pcl artifacts list --toon +pcl requests list --limit 20 --toon ``` When an agent reports a derived result, preserve the command, artifact path, request ID, project ID, -incident ID, transaction hash, and trace context that produced it. `pcl requests list --format toon` recovers +incident ID, transaction hash, and trace context that produced it. `pcl requests list --toon` recovers recent request IDs and HTTP statuses; export outputs include `job_id`, `resume_command`, checkpoint, output, and error file paths. diff --git a/crates/pcl/cli/src/cli.rs b/crates/pcl/cli/src/cli.rs index 6f869ea..5b9507a 100644 --- a/crates/pcl/cli/src/cli.rs +++ b/crates/pcl/cli/src/cli.rs @@ -3,7 +3,11 @@ use clap::{ Parser, }; use clap_complete::Shell; -use pcl_common::args::CliArgs; +use pcl_common::args::{ + CliArgs, + OutputMode, + current_output_mode, +}; #[cfg(feature = "credible")] use pcl_core::verify::VerifyArgs; use pcl_core::{ @@ -186,7 +190,14 @@ pub struct CompletionsArgs { impl CompletionsArgs { pub fn run(&self, json_output: bool) -> Result<(), serde_json::Error> { let script = completion_script(self.shell); - if json_output { + let output_mode = if json_output { + OutputMode::Json + } else { + current_output_mode() + }; + if output_mode == OutputMode::Human { + print!("{script}"); + } else { let envelope = with_envelope_metadata(json!({ "status": "ok", "data": { @@ -198,9 +209,10 @@ impl CompletionsArgs { format!("pcl completions {}", self.shell), ], })); - println!("{}", serde_json::to_string_pretty(&envelope)?); - } else { - print!("{script}"); + print!( + "{}", + pcl_core::api::envelope_output_string(&envelope, json_output)? + ); } Ok(()) } diff --git a/crates/pcl/cli/src/main.rs b/crates/pcl/cli/src/main.rs index 06c6335..17923c8 100644 --- a/crates/pcl/cli/src/main.rs +++ b/crates/pcl/cli/src/main.rs @@ -12,11 +12,15 @@ use color_eyre::{ Result, eyre::Report, }; -use pcl_common::args::CliArgs; +use pcl_common::args::{ + CliArgs, + OutputMode, + set_current_output_mode, +}; use pcl_core::{ api::{ ApiCommandError, - toon_string, + envelope_output_string, with_envelope_metadata, }, config::CliConfig, @@ -48,14 +52,18 @@ async fn main() -> Result<()> { .install()?; if wants_llms_output(env::args_os()) { - pcl_core::surface::print_llms_guide(wants_json_output(env::args_os()))?; + let output_mode = wants_output_mode(env::args_os()); + set_current_output_mode(output_mode); + pcl_core::surface::print_llms_guide(output_mode.is_json())?; return Ok(()); } let cli = match Cli::try_parse() { Ok(cli) => cli, Err(err) => { - if wants_json_output(env::args_os()) { + let output_mode = wants_output_mode(env::args_os()); + set_current_output_mode(output_mode); + if output_mode.is_json() { let exit_code = err.exit_code(); let envelope = with_envelope_metadata(clap_error_envelope(&err)); eprintln!("{}", serde_json::to_string_pretty(&envelope)?); @@ -69,11 +77,12 @@ async fn main() -> Result<()> { } eprint!( "{}", - toon_string(&with_envelope_metadata(clap_error_envelope(&err))) + envelope_output_string(&with_envelope_metadata(clap_error_envelope(&err)), false)? ); std::process::exit(err.exit_code()); } }; + set_current_output_mode(cli.args.output_mode()); let mut read_valid_config = true; let mut config = match CliConfig::read_from_file(&cli.args) { Ok(config) => config, @@ -86,7 +95,7 @@ async fn main() -> Result<()> { if cli.args.json_output() { eprintln!("{}", serde_json::to_string_pretty(&envelope)?); } else { - eprint!("{}", toon_string(&envelope)); + eprint!("{}", envelope_output_string(&envelope, false)?); } std::process::exit(1); } @@ -122,7 +131,7 @@ async fn main() -> Result<()> { if cli.args.json_output() { eprintln!("{}", serde_json::to_string_pretty(&envelope)?); } else { - eprint!("{}", toon_string(&envelope)); + eprint!("{}", envelope_output_string(&envelope, false)?); } std::process::exit(1); } @@ -473,6 +482,52 @@ where false } +fn wants_toon_output(args: I) -> bool +where + I: IntoIterator, + S: AsRef, +{ + let mut saw_output_flag = false; + for arg in args { + let arg = arg.as_ref(); + if arg == OsStr::new("--toon") { + return true; + } + if saw_output_flag { + saw_output_flag = false; + if arg == OsStr::new("toon") { + return true; + } + continue; + } + if arg == OsStr::new("--format") { + saw_output_flag = true; + continue; + } + if let Some(value) = arg.to_str().and_then(|arg| arg.strip_prefix("--format=")) + && value == "toon" + { + return true; + } + } + false +} + +fn wants_output_mode(args: I) -> OutputMode +where + I: IntoIterator, + S: AsRef, +{ + let args: Vec = args.into_iter().collect(); + if wants_json_output(&args) { + OutputMode::Json + } else if wants_toon_output(&args) { + OutputMode::Toon + } else { + OutputMode::Human + } +} + fn wants_llms_output(args: I) -> bool where I: IntoIterator, @@ -492,7 +547,7 @@ fn clap_error_envelope(err: &clap::Error) -> Value { }, "next_actions": [ "pcl --help", - "pcl api manifest --json" + "pcl api manifest --toon" ], })) } @@ -517,6 +572,7 @@ fn clap_error_code(kind: ErrorKind) -> &'static str { mod tests { use super::*; use clap::CommandFactory; + use pcl_core::api::toon_string; #[test] fn detects_json_flag_before_successful_parse() { @@ -528,6 +584,23 @@ mod tests { assert!(!wants_json_output(["pcl", "api", "projects"])); } + #[test] + fn detects_output_mode_before_successful_parse() { + assert_eq!( + wants_output_mode(["pcl", "--json", "api"]), + OutputMode::Json + ); + assert_eq!( + wants_output_mode(["pcl", "--toon", "api"]), + OutputMode::Toon + ); + assert_eq!( + wants_output_mode(["pcl", "--format", "toon", "api"]), + OutputMode::Toon + ); + assert_eq!(wants_output_mode(["pcl", "api"]), OutputMode::Human); + } + #[test] fn detects_llms_flag_before_successful_parse() { assert!(wants_llms_output(["pcl", "--llms"])); diff --git a/crates/pcl/cli/tests/auth_output.rs b/crates/pcl/cli/tests/auth_output.rs index 1f9c4f8..757a9e0 100644 --- a/crates/pcl/cli/tests/auth_output.rs +++ b/crates/pcl/cli/tests/auth_output.rs @@ -108,7 +108,7 @@ fn auth_ensure_json_with_existing_auth_outputs_single_ok_envelope() { } #[test] -fn auth_ensure_default_output_is_toon_envelope() { +fn auth_ensure_toon_output_is_toon_envelope() { let temp_dir = tempfile::tempdir().expect("create temp config dir"); write_valid_auth_config(temp_dir.path()); @@ -116,6 +116,7 @@ fn auth_ensure_default_output_is_toon_envelope() { .args([ "--config-dir", temp_dir.path().to_str().expect("utf-8 temp path"), + "--toon", "auth", "ensure", ]) @@ -133,6 +134,36 @@ fn auth_ensure_default_output_is_toon_envelope() { assert!(stdout.contains("schema_version: pcl.envelope.v1")); } +#[test] +fn auth_ensure_default_output_is_human() { + let temp_dir = tempfile::tempdir().expect("create temp config dir"); + write_valid_auth_config(temp_dir.path()); + + let output = Command::new(env!("CARGO_BIN_EXE_pcl")) + .args([ + "--config-dir", + temp_dir.path().to_str().expect("utf-8 temp path"), + "auth", + "ensure", + ]) + .output() + .expect("run pcl auth ensure"); + + assert!( + output.status.success(), + "command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); + assert!(stdout.starts_with("OK\n"), "{stdout}"); + assert!(stdout.contains("Authentication"), "{stdout}"); + assert!(stdout.contains("Status: authenticated"), "{stdout}"); + assert!(stdout.contains("Time remaining:"), "{stdout}"); + assert!(stdout.contains("Next:"), "{stdout}"); + assert!(!stdout.contains("Details:"), "{stdout}"); + assert!(!stdout.contains("Schema: pcl.envelope.v1"), "{stdout}"); +} + #[test] fn auth_ensure_json_without_auth_outputs_login_challenge() { let temp_dir = tempfile::tempdir().expect("create temp config dir"); @@ -897,7 +928,7 @@ fn global_llms_flag_outputs_json_without_config_read() { "--llms", ]) .output() - .expect("run pcl --llms"); + .expect("run pcl --json --llms"); assert!( output.status.success(), @@ -907,7 +938,7 @@ fn global_llms_flag_outputs_json_without_config_read() { let envelope: serde_json::Value = serde_json::from_slice(&output.stdout).expect("json envelope"); assert_eq!(envelope["schema_version"], "pcl.envelope.v1"); - assert_eq!(envelope["data"]["default_output"], "toon"); + assert_eq!(envelope["data"]["default_output"], "human"); assert_eq!(envelope["data"]["no_mcp_required"], true); assert_eq!( fs::read_to_string(config_path).expect("read invalid config"), @@ -1299,7 +1330,7 @@ fn api_request_logs_respect_config_dir() { } #[test] -fn default_error_output_is_structured_toon_envelope() { +fn default_error_output_is_human_readable() { let output = Command::new(env!("CARGO_BIN_EXE_pcl")) .args(["api", "call", "get", "health"]) .output() @@ -1312,13 +1343,10 @@ fn default_error_output_is_structured_toon_envelope() { String::from_utf8_lossy(&output.stdout) ); let stderr = String::from_utf8(output.stderr).expect("utf-8 stderr"); - assert!(stderr.contains("status: error"), "{stderr}"); - assert!(stderr.contains("code: input.invalid_path"), "{stderr}"); - assert!(stderr.contains("next_actions[2]:"), "{stderr}"); - assert!( - stderr.contains("schema_version: pcl.envelope.v1"), - "{stderr}" - ); + assert!(stderr.starts_with("Error\n"), "{stderr}"); + assert!(stderr.contains("Code: input.invalid_path"), "{stderr}"); + assert!(stderr.contains("Next:"), "{stderr}"); + assert!(!stderr.contains("Schema: pcl.envelope.v1"), "{stderr}"); } #[test] diff --git a/crates/pcl/cli/tests/workflow_cli.rs b/crates/pcl/cli/tests/workflow_cli.rs index 4054671..3d8dad3 100644 --- a/crates/pcl/cli/tests/workflow_cli.rs +++ b/crates/pcl/cli/tests/workflow_cli.rs @@ -193,13 +193,14 @@ fn workflow_body_templates_cover_access_manager_families() { } #[test] -fn workflow_body_template_defaults_to_toon_envelope() { +fn workflow_body_template_toon_flag_emits_toon_envelope() { let temp_dir = tempfile::tempdir().expect("create temp config dir"); write_valid_auth_config(temp_dir.path()); let output = Command::new(env!("CARGO_BIN_EXE_pcl")) .arg("--config-dir") .arg(temp_dir.path()) + .arg("--toon") .args([ "projects", "--api-url", diff --git a/crates/pcl/common/src/args.rs b/crates/pcl/common/src/args.rs index 48fa063..4bcb027 100644 --- a/crates/pcl/common/src/args.rs +++ b/crates/pcl/common/src/args.rs @@ -5,16 +5,65 @@ use clap::{ use std::{ fmt, path::PathBuf, + sync::atomic::{ + AtomicU8, + Ordering, + }, }; -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] +static CURRENT_OUTPUT_MODE: AtomicU8 = AtomicU8::new(OutputMode::Human as u8); + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum OutputMode { #[default] + Human, Toon, Json, } +pub fn set_current_output_mode(mode: OutputMode) { + CURRENT_OUTPUT_MODE.store(mode as u8, Ordering::Relaxed); +} + +pub fn current_output_mode() -> OutputMode { + match CURRENT_OUTPUT_MODE.load(Ordering::Relaxed) { + value if value == OutputMode::Toon as u8 => OutputMode::Toon, + value if value == OutputMode::Json as u8 => OutputMode::Json, + _ => OutputMode::Human, + } +} + +impl OutputMode { + pub fn is_json(self) -> bool { + self == Self::Json + } + + pub fn is_toon(self) -> bool { + self == Self::Toon + } + + pub fn is_human(self) -> bool { + self == Self::Human + } +} + impl fmt::Display for OutputMode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Human => "human", + Self::Toon => "toon", + Self::Json => "json", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum MachineOutputMode { + Toon, + Json, +} + +impl fmt::Display for MachineOutputMode { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { Self::Toon => "toon", @@ -29,17 +78,25 @@ pub struct CliArgs { short, long, global = true, - help = "Alias for --format json; default output is TOON" + conflicts_with = "toon", + help = "Emit strict JSON output for agents and programmatic consumers" )] pub json: bool, + #[clap( + long, + global = true, + conflicts_with = "json", + help = "Emit compact TOON output for agents" + )] + pub toon: bool, #[clap( long = "format", global = true, value_enum, - default_value_t = OutputMode::Toon, - help = "Select machine-readable envelope format" + hide = true, + help = "Deprecated alias for --toon or --json" )] - pub format: OutputMode, + pub format: Option, #[clap(long = "config-dir", hide = true, global = true)] pub config_dir: Option, #[clap( @@ -51,8 +108,26 @@ pub struct CliArgs { } impl CliArgs { + pub fn output_mode(&self) -> OutputMode { + if self.json || self.format == Some(MachineOutputMode::Json) { + OutputMode::Json + } else if self.toon || self.format == Some(MachineOutputMode::Toon) { + OutputMode::Toon + } else { + OutputMode::Human + } + } + pub fn json_output(&self) -> bool { - self.json || self.format == OutputMode::Json + self.output_mode().is_json() + } + + pub fn toon_output(&self) -> bool { + self.output_mode().is_toon() + } + + pub fn human_output(&self) -> bool { + self.output_mode().is_human() } } @@ -68,20 +143,38 @@ mod tests { fn parses_json_flag() { let args = CliArgs::try_parse_from(["cli", "--json"]).expect("should parse"); assert!(args.json_output()); + assert_eq!(args.output_mode(), OutputMode::Json); + } + + #[test] + fn parses_toon_flag() { + let args = CliArgs::try_parse_from(["cli", "--toon"]).expect("should parse"); + assert!(args.toon_output()); + assert_eq!(args.output_mode(), OutputMode::Toon); } #[test] - fn parses_output_json_flag() { + fn parses_legacy_format_json_flag() { let args = CliArgs::try_parse_from(["cli", "--format", "json"]).expect("should parse"); assert!(args.json_output()); - assert_eq!(args.format, OutputMode::Json); + assert_eq!(args.format, Some(MachineOutputMode::Json)); + assert_eq!(args.output_mode(), OutputMode::Json); } #[test] - fn parses_output_toon_as_default_machine_output() { + fn parses_legacy_format_toon_flag() { let args = CliArgs::try_parse_from(["cli", "--format", "toon"]).expect("should parse"); + assert!(args.toon_output()); + assert_eq!(args.format, Some(MachineOutputMode::Toon)); + assert_eq!(args.output_mode(), OutputMode::Toon); + } + + #[test] + fn defaults_to_human_output() { + let args = CliArgs::try_parse_from(["cli"]).expect("should parse"); + assert!(args.human_output()); assert!(!args.json_output()); - assert_eq!(args.format, OutputMode::Toon); + assert!(!args.toon_output()); } #[test] diff --git a/crates/pcl/core/src/api.rs b/crates/pcl/core/src/api.rs index a51c36b..5bf9bd0 100644 --- a/crates/pcl/core/src/api.rs +++ b/crates/pcl/core/src/api.rs @@ -17,7 +17,11 @@ use clap::{ ArgGroup, ValueEnum, }; -use pcl_common::args::CliArgs; +use pcl_common::args::{ + CliArgs, + OutputMode, + current_output_mode, +}; use reqwest::header::{ HeaderMap, HeaderName, @@ -62,7 +66,7 @@ pub enum ApiCommandError { NoAuthToken, #[error( - "Stored auth token expired at {0}. Run `pcl auth refresh --json` or `pcl auth login` again, or pass `--allow-unauthenticated` for public endpoints." + "Stored auth token expired at {0}. Run `pcl auth refresh --toon` or `pcl auth login` again, or pass `--allow-unauthenticated` for public endpoints." )] ExpiredAuthToken(chrono::DateTime), @@ -189,21 +193,21 @@ impl ApiCommandError { match self { Self::NoAuthToken | Self::ExpiredAuthToken(_) | Self::AuthRefresh(_) => { vec![ - "pcl auth refresh --json".to_string(), + "pcl auth refresh --toon".to_string(), "pcl auth login".to_string(), - "pcl api list --allow-unauthenticated --json".to_string(), + "pcl api list --allow-unauthenticated --toon".to_string(), ] } Self::InvalidPath(_) => { vec![ - "pcl api list --json".to_string(), - "pcl api call get /views/public/incidents --allow-unauthenticated --json" + "pcl api list --toon".to_string(), + "pcl api call get /views/public/incidents --allow-unauthenticated --toon" .to_string(), ] } Self::InvalidKeyValue { kind, .. } => { vec![format!( - "Use --{kind} key=value, for example: pcl api call get /views/public/incidents --{kind} limit=5 --json" + "Use --{kind} key=value, for example: pcl api call get /views/public/incidents --{kind} limit=5 --toon" )] } Self::InvalidHeaderName { .. } | Self::InvalidHeaderValue { .. } => { @@ -219,8 +223,8 @@ impl ApiCommandError { } Self::OperationNotFound(_) => { vec![ - "pcl api list --json".to_string(), - "pcl api inspect get /views/public/incidents --json".to_string(), + "pcl api list --toon".to_string(), + "pcl api inspect get /views/public/incidents --toon".to_string(), ] } Self::InvalidWorkflow { .. } => { @@ -243,7 +247,7 @@ impl ApiCommandError { } Self::HttpStatus { status: 401, .. } => { vec![ - "pcl auth refresh --json".to_string(), + "pcl auth refresh --toon".to_string(), "pcl auth login".to_string(), "Use --allow-unauthenticated only for public endpoints".to_string(), ] @@ -265,17 +269,17 @@ impl ApiCommandError { } => { vec![ format!( - "pcl api inspect {} {} --json", + "pcl api inspect {} {} --toon", method.to_ascii_lowercase(), path ), - "pcl api manifest --json".to_string(), + "pcl api manifest --toon".to_string(), "Read error.http.body for the rejected field details".to_string(), ] } Self::HttpStatus { status: 404, .. } => { vec![ - "pcl api list --json".to_string(), + "pcl api list --toon".to_string(), "Check identifiers and required path/query parameters".to_string(), ] } @@ -293,13 +297,13 @@ impl ApiCommandError { method.to_ascii_lowercase(), path ), - "pcl requests list --json".to_string(), + "pcl requests list --toon".to_string(), "Read error.http.body for API-provided failure details".to_string(), ] } else { vec![ "Retry the same command once; server errors can be transient".to_string(), - "pcl api manifest --json".to_string(), + "pcl api manifest --toon".to_string(), "Read error.http.body for API-provided failure details".to_string(), ] }; @@ -312,7 +316,7 @@ impl ApiCommandError { } Self::HttpStatus { .. } => { vec![ - "pcl api manifest --json".to_string(), + "pcl api manifest --toon".to_string(), "Read error.http.body for API-provided failure details".to_string(), ] } @@ -322,7 +326,7 @@ impl ApiCommandError { } Self::RequestLog { .. } => { vec![ - "pcl requests path --json".to_string(), + "pcl requests path --toon".to_string(), "Check request log permissions or move the PCL state directory".to_string(), ] } @@ -452,7 +456,7 @@ impl ApiCommandError { #[derive(clap::Parser, Debug)] #[command( about = "Discover and call the platform API", - long_about = "Discover and call the Credible Layer platform API. Commands return compact structured TOON by default, including error envelopes and next actions. Pass --json for full JSON envelopes." + long_about = "Discover and call the Credible Layer platform API. Commands use human-readable output by default. Pass --toon for compact agent envelopes or --json for strict JSON envelopes." )] pub struct ApiArgs { #[command(subcommand)] @@ -638,13 +642,13 @@ enum ApiCommand { #[command( about = "Print an agent-readable command manifest", - after_help = "Examples:\n pcl api manifest\n pcl api manifest --json" + after_help = "Examples:\n pcl api manifest\n pcl api manifest --toon\n pcl api manifest --json" )] Manifest, #[command( about = "List OpenAPI operations", - after_help = "Examples:\n pcl api list\n pcl api list --filter incidents\n pcl api list --method get\n pcl api list --json" + after_help = "Examples:\n pcl api list\n pcl api list --filter incidents\n pcl api list --method get\n pcl api list --toon\n pcl api list --json" )] List { #[arg(long, help = "Filter operation id, summary, tags, or path")] @@ -655,7 +659,7 @@ enum ApiCommand { #[command( about = "Inspect one OpenAPI operation", - after_help = "Examples:\n pcl api inspect get_views_projects_project_id_incidents\n pcl api inspect get /views/public/incidents\n pcl api inspect get_views_projects_project_id_incidents --json" + after_help = "Examples:\n pcl api inspect get_views_projects_project_id_incidents\n pcl api inspect get /views/public/incidents\n pcl api inspect get_views_projects_project_id_incidents --toon\n pcl api inspect get_views_projects_project_id_incidents --json" )] Inspect { #[arg(help = "Operation id, or HTTP method when PATH is also provided")] @@ -670,7 +674,7 @@ enum ApiCommand { name = "coverage", alias = "audit", about = "Compare the local request log against the live OpenAPI surface", - after_help = "Examples:\n pcl api coverage --json\n pcl api coverage --records 5000 --markdown /tmp/pcl-api-coverage.md" + after_help = "Examples:\n pcl api coverage --toon\n pcl api coverage --json\n pcl api coverage --records 5000 --markdown /tmp/pcl-api-coverage.md" )] Coverage { #[arg( @@ -685,7 +689,7 @@ enum ApiCommand { #[command( about = "Call any platform API endpoint", - after_help = "Examples:\n pcl api call get '/views/public/incidents?limit=5' --allow-unauthenticated\n pcl api call get /views/projects//incidents --query environment=production\n pcl api call get /views/public/incidents --paginate incidents --limit 50 --allow-unauthenticated --output incidents.json\n pcl api call get /views/public/incidents --paginate incidents --limit 50 --allow-unauthenticated --jsonl --output incidents.jsonl\n pcl api call get /views/public/incidents --query limit=5 --allow-unauthenticated --output incidents.json\n pcl api call post /web/auth/logout --body '{}'\n pcl api call get /views/public/incidents --query limit=5 --allow-unauthenticated --json" + after_help = "Examples:\n pcl api call get '/views/public/incidents?limit=5' --allow-unauthenticated\n pcl api call get /views/projects//incidents --query environment=production\n pcl api call get /views/public/incidents --paginate incidents --limit 50 --allow-unauthenticated --output incidents.json\n pcl api call get /views/public/incidents --paginate incidents --limit 50 --allow-unauthenticated --jsonl --output incidents.jsonl\n pcl api call get /views/public/incidents --query limit=5 --allow-unauthenticated --output incidents.json\n pcl api call post /web/auth/logout --body '{}'\n pcl api call get /views/public/incidents --query limit=5 --allow-unauthenticated --toon" )] Call { #[arg(value_enum, help = "HTTP method")] @@ -1828,8 +1832,8 @@ impl ApiArgs { "status": "ok", "data": coverage, "next_actions": [ - "pcl requests list --json", - "pcl api list --json", + "pcl requests list --toon", + "pcl api list --toon", "pcl api coverage --markdown api-coverage.md", ], }); @@ -1893,7 +1897,7 @@ impl ApiArgs { "Adjust --limit or --max-pages if the result set was truncated" .to_string(), "Use --output results.json to save paginated data".to_string(), - "pcl api manifest --json".to_string(), + "pcl api manifest --toon".to_string(), ], ) } else { @@ -1903,8 +1907,8 @@ impl ApiArgs { ( response, vec![ - "pcl api list --json".to_string(), - "pcl api manifest --json".to_string(), + "pcl api list --toon".to_string(), + "pcl api manifest --toon".to_string(), ], ) }; @@ -2938,56 +2942,1640 @@ fn response_body_value(content_type: &str, bytes: &[u8]) -> Value { } fn print_output(value: &Value, json_output: bool) -> Result<(), ApiCommandError> { - print!("{}", output_string(value, json_output)?); + print!("{}", envelope_output_string(value, json_output)?); Ok(()) } -fn output_string(value: &Value, json_output: bool) -> Result { +pub fn envelope_output_string( + value: &Value, + json_output: bool, +) -> Result { let value = with_envelope_metadata(value.clone()); - if json_output { - Ok(format!("{}\n", serde_json::to_string_pretty(&value)?)) + let output_mode = if json_output { + OutputMode::Json } else { - Ok(toon_string(&value)) + current_output_mode() + }; + match output_mode { + OutputMode::Json => Ok(format!("{}\n", serde_json::to_string_pretty(&value)?)), + OutputMode::Toon => Ok(toon_string(&value)), + OutputMode::Human => Ok(human_string(&value)), } } -fn ok_envelope(data: Value) -> Value { - with_envelope_metadata(json!({ - "status": "ok", - "data": data, - "next_actions": [ - "pcl api list", - "pcl api inspect get_views_public_incidents", - "pcl api call get /views/public/incidents --query limit=5 --allow-unauthenticated", - ], - })) +/// Render an envelope for interactive humans. +pub fn human_string(value: &Value) -> String { + let value = with_envelope_metadata(value.clone()); + let status = value.get("status").and_then(Value::as_str).unwrap_or("ok"); + let mut output = String::new(); + output.push_str(match status { + "ok" => "OK", + "error" => "Error", + "action_required" => "Action required", + "pending" => "Pending", + other => other, + }); + output.push('\n'); + + if let Some(error) = value.get("error") { + render_human_error(&mut output, error); + } else if !render_human_special(&mut output, &value) + && !render_human_collection(&mut output, &value) + && let Some(data) = value.get("data") + { + render_human_summary(&mut output, data); + } + + if let Some(actions) = value.get("next_actions").and_then(Value::as_array) + && !actions.is_empty() + { + output.push_str("\nNext:\n"); + for (index, action) in actions.iter().enumerate() { + output.push_str(" "); + output.push_str(&(index + 1).to_string()); + output.push_str(". "); + output.push_str(&human_action(action)); + output.push('\n'); + } + } + render_human_request_id(&mut output, &value); + if !output.ends_with('\n') { + output.push('\n'); + } + output } -fn dry_run_envelope(data: Value) -> Value { - let auth_required = data - .pointer("/request/auth/required") - .and_then(Value::as_bool) - .unwrap_or(false); - let allow_unauthenticated = data - .pointer("/request/auth/allow_unauthenticated") +struct HumanCollection<'a> { + field: String, + name: String, + items: &'a [Value], + pagination: Option<&'a Value>, + meta: Option<&'a Value>, +} + +fn render_human_error(output: &mut String, error: &Value) { + output.push('\n'); + if let Some(message) = error.get("message").and_then(Value::as_str) { + output.push_str(message); + output.push('\n'); + } else if let Some(error) = error.as_str() { + output.push_str(error); + output.push('\n'); + } else { + render_human_value(output, error, 0); + } + + if let Some(code) = error.get("code").and_then(Value::as_str) { + output.push_str("Code: "); + output.push_str(code); + output.push('\n'); + } +} + +fn render_human_special(output: &mut String, envelope: &Value) -> bool { + let Some(data) = envelope.get("data") else { + return false; + }; + let display_data = data.get("data").unwrap_or(data); + + if render_login_challenge(output, display_data) { + return true; + } + if render_request_plan(output, display_data) { + return true; + } + if render_auth_status(output, display_data) { + return true; + } + if render_identity_status(output, display_data) { + return true; + } + if render_doctor(output, display_data) { + return true; + } + if render_api_manifest(output, display_data) { + return true; + } + if render_llms_guide(output, display_data) { + return true; + } + if render_workflow_detail(output, display_data) { + return true; + } + if render_schema_detail(output, display_data) { + return true; + } + if render_operation_detail(output, display_data) { + return true; + } + if render_api_coverage(output, display_data) { + return true; + } + if render_raw_api_response(output, display_data) { + return true; + } + if render_export_result(output, display_data) { + return true; + } + if render_job_detail(output, display_data) { + return true; + } + if render_path_or_toggle_result(output, display_data) { + return true; + } + if render_body_template(output, envelope, display_data) { + return true; + } + + false +} + +fn render_login_challenge(output: &mut String, data: &Value) -> bool { + if data.get("state").and_then(Value::as_str) != Some("login_required") { + return false; + } + output.push_str("\nLogin required\n"); + if let Some(reason) = data.get("reason").and_then(Value::as_str) { + writeln!(output, "Reason: {}", title_case(reason)).expect("write to string"); + } + if let Some(url) = data.get("device_url").and_then(Value::as_str) { + writeln!(output, "Open: {url}").expect("write to string"); + } + if let Some(code) = data.get("code").and_then(Value::as_str) { + writeln!(output, "Code: {code}").expect("write to string"); + } + if let Some(expires_at) = data.get("expires_at").and_then(Value::as_str) { + writeln!(output, "Expires: {}", format_timestamp(expires_at)).expect("write to string"); + } + if let Some(command) = data.get("poll_command").and_then(Value::as_str) { + writeln!(output, "Poll: {}", humanize_command(command)).expect("write to string"); + } + true +} + +fn render_request_plan(output: &mut String, data: &Value) -> bool { + if data.get("dry_run").and_then(Value::as_bool) != Some(true) { + return false; + } + + output.push_str("\nDry run\n"); + if data.get("valid").and_then(Value::as_bool) == Some(false) { + output.push_str("Request is not valid.\n"); + if let Some(error) = data.get("error") { + render_human_error(output, error); + } + return true; + } + + let request = data.get("request").unwrap_or(data); + let method = request.get("method").and_then(Value::as_str).unwrap_or("-"); + let path = request.get("path").and_then(Value::as_str).unwrap_or("-"); + writeln!(output, "{method} {path}").expect("write to string"); + if let Some(query) = request.get("query").and_then(Value::as_array) + && !query.is_empty() + { + output.push_str("Query: "); + output.push_str(&name_value_pairs(query)); + output.push('\n'); + } + if let Some(auth) = request.get("auth") { + let required = auth + .get("required") + .and_then(Value::as_bool) + .unwrap_or(false); + let attached = auth + .get("will_attach_stored_token") + .and_then(Value::as_bool) + .unwrap_or(false); + writeln!( + output, + "Auth: {}{}", + if required { "required" } else { "not required" }, + if attached { + ", stored token will be attached" + } else { + "" + } + ) + .expect("write to string"); + } + if let Some(body) = request.get("body") + && !body.is_null() + { + output.push_str("Body: "); + output.push_str(&human_compact_summary(body)); + output.push('\n'); + } + if let Some(pagination) = data.get("pagination") + && !pagination.is_null() + { + output.push_str("Pagination: "); + output.push_str(&human_compact_summary(pagination)); + output.push('\n'); + } + true +} + +fn render_auth_status(output: &mut String, data: &Value) -> bool { + if !data.get("authenticated").is_some_and(Value::is_boolean) + || data.get("auth").is_some() + || data.get("config_path").is_some() + { + return false; + } + + output.push_str("\nAuthentication\n"); + let authenticated = data + .get("authenticated") .and_then(Value::as_bool) .unwrap_or(false); - let stored_token_valid = data - .pointer("/request/auth/stored_token_valid") + writeln!( + output, + "Status: {}", + if authenticated { + "authenticated" + } else { + "not logged in" + } + ) + .expect("write to string"); + if let Some(user) = data.get("user").and_then(Value::as_str) { + writeln!(output, "User: {user}").expect("write to string"); + } + if let Some(email) = data.get("email").and_then(Value::as_str) + && data.get("user").and_then(Value::as_str) != Some(email) + { + writeln!(output, "Email: {email}").expect("write to string"); + } + if let Some(wallet) = data.get("wallet_address").and_then(Value::as_str) { + writeln!(output, "Wallet: {wallet}").expect("write to string"); + } + if let Some(expires_at) = data.get("expires_at").and_then(Value::as_str) { + writeln!(output, "Token expires: {}", format_timestamp(expires_at)) + .expect("write to string"); + } + if let Some(seconds) = data.get("seconds_remaining").and_then(Value::as_i64) { + writeln!(output, "Time remaining: {}", format_duration(seconds)).expect("write to string"); + } + if data.get("refreshed").and_then(Value::as_bool) == Some(true) { + output.push_str("Token refreshed.\n"); + } + if let Some(request_id) = data.get("request_id").and_then(Value::as_str) { + writeln!(output, "Request ID: {request_id}").expect("write to string"); + } + true +} + +fn render_identity_status(output: &mut String, data: &Value) -> bool { + let Some(auth) = data.get("auth") else { + return false; + }; + if !auth.get("authenticated").is_some_and(Value::is_boolean) { + return false; + } + output.push_str("\nIdentity\n"); + let authenticated = auth + .get("authenticated") .and_then(Value::as_bool) .unwrap_or(false); - let next_actions = if auth_required && !allow_unauthenticated && !stored_token_valid { - vec![ - "pcl auth ensure --json", - "Authenticate before removing --dry-run", - "Use --body-template when constructing mutation bodies", - ] - } else { - vec![ - "Remove --dry-run to execute this request", - "Use --json to consume this plan programmatically", - "Use --body-template when constructing mutation bodies", - ] + writeln!( + output, + "Status: {}", + if authenticated { + "authenticated" + } else { + "not logged in" + } + ) + .expect("write to string"); + if let Some(user) = auth.get("user").and_then(Value::as_str) { + writeln!(output, "User: {user}").expect("write to string"); + } + if let Some(user_id) = auth.get("user_id").and_then(Value::as_str) { + writeln!(output, "User ID: {user_id}").expect("write to string"); + } + if let Some(expires_at) = auth.get("expires_at").and_then(Value::as_str) { + writeln!(output, "Token expires: {}", format_timestamp(expires_at)) + .expect("write to string"); + } + if let Some(config_path) = data.get("config_path").and_then(Value::as_str) { + writeln!(output, "Config: {config_path}").expect("write to string"); + } + if data.get("offline").and_then(Value::as_bool) == Some(true) { + output.push_str("Network checks skipped.\n"); + } + true +} + +fn render_doctor(output: &mut String, data: &Value) -> bool { + let Some(checks) = data.get("checks").and_then(Value::as_array) else { + return false; + }; + output.push_str("\nDoctor\n"); + render_checks_table(output, checks); + if let Some(api_url) = data.get("api_url").and_then(Value::as_str) { + writeln!(output, "\nAPI: {api_url}").expect("write to string"); + } + output.push_str("Default output: human. Agents should pass --toon; scripts can pass --json.\n"); + true +} + +fn render_body_template(output: &mut String, envelope: &Value, data: &Value) -> bool { + if !is_body_template_envelope(envelope) { + return false; + } + if let Some(variants) = data.get("body_variants").and_then(Value::as_array) { + output.push_str("\nBody variants\n"); + for variant in variants { + let name = variant + .get("name") + .and_then(Value::as_str) + .unwrap_or("variant"); + writeln!(output, "- {name}").expect("write to string"); + if let Some(body) = variant.get("body") { + render_human_value(output, body, 4); + } + } + return true; + } + + let Some(object) = data.as_object() else { + return false; + }; + if object.is_empty() + || !object + .values() + .all(|value| is_scalar(value) || value.is_object() || value.is_array()) + { + return false; + } + if !object.keys().any(|key| is_body_template_key(key)) { + return false; + } + output.push_str("\nBody template\n"); + render_human_value(output, data, 2); + true +} + +fn is_body_template_envelope(envelope: &Value) -> bool { + envelope + .get("next_actions") + .and_then(Value::as_array) + .is_some_and(|actions| { + actions.iter().filter_map(Value::as_str).any(|action| { + action.starts_with("Pass the template") + || action.starts_with("Choose one entry from data.body_variants") + }) + }) +} + +fn render_api_manifest(output: &mut String, data: &Value) -> bool { + if data.get("name").and_then(Value::as_str) != Some("pcl") || data.get("commands").is_none() { + return false; + } + output.push_str("\nPCL command surface\n"); + if let Some(description) = data.get("description").and_then(Value::as_str) { + writeln!(output, "{description}").expect("write to string"); + } + output.push_str("\nStart here:\n"); + for command in ["pcl --llms", "pcl workflows", "pcl schema list"] { + writeln!(output, " - {command}").expect("write to string"); + } + if let Some(commands) = data.get("commands").and_then(Value::as_array) { + writeln!( + output, + "\n{} workflow/API command groups available.", + commands.len() + ) + .expect("write to string"); + } + true +} + +fn render_llms_guide(output: &mut String, data: &Value) -> bool { + if data.get("purpose").is_none() || data.get("consumption_order").is_none() { + return false; + } + output.push_str("\nLLM guide\n"); + if let Some(purpose) = data.get("purpose").and_then(Value::as_str) { + writeln!(output, "{purpose}").expect("write to string"); + } + if let Some(order) = data.get("consumption_order").and_then(Value::as_array) { + output.push_str("\nRecommended order:\n"); + for command in order.iter().filter_map(Value::as_str).take(8) { + writeln!(output, " - {}", humanize_command(command)).expect("write to string"); + } + } + true +} + +fn render_workflow_detail(output: &mut String, data: &Value) -> bool { + if data.get("steps").is_none() || data.get("name").is_none() { + return false; + } + output.push('\n'); + if let Some(name) = data.get("name").and_then(Value::as_str) { + writeln!(output, "Workflow: {name}").expect("write to string"); + } + if let Some(description) = data.get("description").and_then(Value::as_str) { + writeln!(output, "{description}").expect("write to string"); + } + if let Some(steps) = data.get("steps").and_then(Value::as_array) { + output.push_str("\nSteps:\n"); + for (index, step) in steps.iter().enumerate() { + let command = step.get("command").and_then(Value::as_str).unwrap_or("-"); + let description = step.get("output").and_then(Value::as_str).unwrap_or(""); + writeln!( + output, + " {}. {}{}", + index + 1, + humanize_command(command), + if description.is_empty() { + String::new() + } else { + format!(" -> {description}") + } + ) + .expect("write to string"); + } + } + true +} + +fn render_schema_detail(output: &mut String, data: &Value) -> bool { + if data.get("workflow").is_none() + || !(data.get("actions").is_some() || data.get("action").is_some()) + { + return false; + } + output.push('\n'); + if let Some(workflow) = data.get("workflow").and_then(Value::as_str) { + writeln!(output, "Schema: {workflow}").expect("write to string"); + } + if let Some(command) = data.get("command").and_then(Value::as_str) { + writeln!(output, "Command: {}", humanize_command(command)).expect("write to string"); + } + if let Some(actions) = data.get("actions").and_then(Value::as_array) { + render_actions_table(output, actions); + } else if let Some(action) = data.get("action") { + render_action_detail(output, action); + } + true +} + +fn render_operation_detail(output: &mut String, data: &Value) -> bool { + if data.get("operation_id").is_none() + || data.get("method").is_none() + || data.get("path").is_none() + { + return false; + } + output.push_str("\nAPI operation\n"); + let method = data.get("method").and_then(Value::as_str).unwrap_or("-"); + let path = data.get("path").and_then(Value::as_str).unwrap_or("-"); + writeln!(output, "{method} {path}").expect("write to string"); + if let Some(operation_id) = data.get("operation_id").and_then(Value::as_str) { + writeln!(output, "Operation: {operation_id}").expect("write to string"); + } + if let Some(summary) = data.get("summary").and_then(Value::as_str) { + writeln!(output, "Summary: {summary}").expect("write to string"); + } + if let Some(policy) = data.pointer("/raw_api_use/policy").and_then(Value::as_str) { + writeln!(output, "Raw API policy: {}", title_case(policy)).expect("write to string"); + } + if let Some(alternatives) = data.get("workflow_alternatives").and_then(Value::as_array) + && !alternatives.is_empty() + { + output.push_str("Prefer:\n"); + for alternative in alternatives { + if let Some(example) = alternative.get("example").and_then(Value::as_str) { + writeln!(output, " - {}", humanize_command(example)).expect("write to string"); + } + } + } + if let Some(command) = data.get("call_command").and_then(Value::as_str) { + writeln!(output, "Raw call: {}", humanize_command(command)).expect("write to string"); + } + true +} + +fn render_api_coverage(output: &mut String, data: &Value) -> bool { + let Some(total) = data.get("total_operations").and_then(Value::as_u64) else { + return false; + }; + output.push_str("\nAPI coverage\n"); + writeln!(output, "Operations: {total}").expect("write to string"); + for (label, field) in [ + ("No request-log hit", "no_hit_count"), + ("Hit without 2xx", "no_2xx_count"), + ("Write hit without 2xx", "write_no_2xx_count"), + ("Unmatched records", "unmatched_record_count"), + ] { + if let Some(count) = data.get(field).and_then(Value::as_u64) { + writeln!(output, "{label}: {count}").expect("write to string"); + } + } + if let Some(by_method) = data.get("by_method").and_then(Value::as_object) { + output.push_str("\nBy method:\n"); + for (method, stats) in by_method { + let total = stats.get("total").and_then(Value::as_u64).unwrap_or(0); + let hit = stats.get("hit").and_then(Value::as_u64).unwrap_or(0); + let ok = stats.get("ok").and_then(Value::as_u64).unwrap_or(0); + writeln!(output, " {method}: {ok}/{total} 2xx, {hit} hit").expect("write to string"); + } + } + true +} + +fn render_raw_api_response(output: &mut String, data: &Value) -> bool { + if data.get("request").is_none() || data.get("response").is_none() { + return false; + } + let request = data.get("request").unwrap_or(&Value::Null); + let response = data.get("response").unwrap_or(&Value::Null); + output.push_str("\nAPI response\n"); + if let (Some(method), Some(path)) = ( + request.get("method").and_then(Value::as_str), + request.get("path").and_then(Value::as_str), + ) { + writeln!(output, "{method} {path}").expect("write to string"); + } + if let Some(status) = response.get("status").and_then(Value::as_u64) { + writeln!(output, "HTTP {status}").expect("write to string"); + } + if let Some(request_id) = response.get("request_id").and_then(Value::as_str) { + writeln!(output, "Request ID: {request_id}").expect("write to string"); + } + if let Some(body) = response.get("body") { + if let Some(collection) = find_collection_in_value(body, "") { + output.push('\n'); + output.push_str(&collection.name); + output.push('\n'); + output.push_str(&collection_summary(&collection)); + output.push_str("\n\n"); + render_collection_items(output, &collection); + } else { + output.push_str("Body: "); + output.push_str(&human_compact_summary(body)); + output.push('\n'); + } + } + if let Some(path) = data.get("output_path").and_then(Value::as_str) { + writeln!(output, "Wrote: {path}").expect("write to string"); + } + true +} + +fn render_export_result(output: &mut String, data: &Value) -> bool { + if data.get("export").and_then(Value::as_str) != Some("incidents") + && !(data.get("plan").is_some() && data.get("job_id").is_some()) + { + return false; + } + output.push_str("\nIncident export\n"); + if let Some(job_id) = data.get("job_id").and_then(Value::as_str) { + writeln!(output, "Job: {job_id}").expect("write to string"); + } + let source = data.get("plan").unwrap_or(data); + for (label, field) in [ + ("Output", "out"), + ("Errors", "errors"), + ("Checkpoint", "checkpoint"), + ] { + if let Some(path) = source.get(field).and_then(Value::as_str) { + writeln!(output, "{label}: {path}").expect("write to string"); + } + } + for (label, field) in [ + ("Pages fetched", "pages_fetched"), + ("Incidents written", "incidents_written"), + ("Errors written", "errors_written"), + ("Retries", "retries_attempted"), + ] { + if let Some(count) = data.get(field).and_then(Value::as_u64) { + writeln!(output, "{label}: {count}").expect("write to string"); + } + } + if let Some(command) = data.get("resume_command").and_then(Value::as_str) { + writeln!(output, "Resume: {}", humanize_command(command)).expect("write to string"); + } + true +} + +fn render_job_detail(output: &mut String, data: &Value) -> bool { + let job = data.get("job").unwrap_or(data); + if job.get("job_id").is_none() { + return false; + } + output.push_str("\nJob\n"); + for (label, field) in [ + ("ID", "job_id"), + ("Kind", "kind"), + ("Status", "status"), + ("Updated", "updated_at"), + ] { + if let Some(value) = job.get(field) { + writeln!(output, "{label}: {}", human_cell(value)).expect("write to string"); + } + } + if let Some(stats) = job.get("stats") { + output.push_str("Stats: "); + output.push_str(&human_compact_summary(stats)); + output.push('\n'); + } + if let Some(command) = data + .get("resume_command") + .or_else(|| job.get("resume_command")) + .and_then(Value::as_str) + { + writeln!(output, "Resume: {}", humanize_command(command)).expect("write to string"); + } + true +} + +fn render_path_or_toggle_result(output: &mut String, data: &Value) -> bool { + if data + .as_object() + .is_some_and(|object| object.values().any(Value::is_array)) + { + return false; + } + let path_fields = [ + ("Config", "config_path"), + ("Artifacts", "artifact_dir"), + ("Request log", "request_log"), + ("Jobs", "jobs_path"), + ]; + let mut rendered = false; + for (label, field) in path_fields { + if let Some(path) = data.get(field).and_then(Value::as_str) { + if !rendered { + output.push('\n'); + rendered = true; + } + writeln!(output, "{label}: {path}").expect("write to string"); + } + } + for (label, field) in [("Created", "created"), ("Deleted", "deleted")] { + if let Some(value) = data.get(field).and_then(Value::as_bool) { + if !rendered { + output.push('\n'); + rendered = true; + } + writeln!(output, "{label}: {}", yes_no(value)).expect("write to string"); + } + } + rendered +} + +fn render_human_collection(output: &mut String, envelope: &Value) -> bool { + let Some(collection) = find_human_collection(envelope) else { + return false; + }; + + output.push('\n'); + output.push_str(&collection.name); + output.push('\n'); + output.push_str(&collection_summary(&collection)); + output.push('\n'); + if let Some(meta) = collection.meta { + render_collection_meta(output, meta); + } + output.push('\n'); + + if collection.items.is_empty() { + output.push_str("No results.\n"); + return true; + } + + render_collection_items(output, &collection); + + if let Some(pagination) = collection.pagination + && pagination + .get("hasMore") + .or_else(|| pagination.get("has_more")) + .and_then(Value::as_bool) + .unwrap_or(false) + { + let next_page = pagination + .get("page") + .and_then(Value::as_u64) + .map_or(2, |page| page.saturating_add(1)); + let limit = pagination + .get("limit") + .and_then(Value::as_u64) + .unwrap_or(collection.items.len() as u64); + output.push('\n'); + writeln!( + output, + "More results available. Try --page {next_page} --limit {limit}." + ) + .expect("write to string"); + } + + true +} + +fn find_human_collection(envelope: &Value) -> Option> { + let data = envelope.get("data")?; + let request_path = envelope + .pointer("/request/path") + .and_then(Value::as_str) + .unwrap_or_default(); + + find_collection_in_value(data, request_path) +} + +fn find_collection_in_value<'a>( + data: &'a Value, + request_path: &str, +) -> Option> { + if let Some(inner) = data.get("data") + && let Some(collection) = find_collection_in_value(inner, request_path) + { + return Some(HumanCollection { + meta: data.get("_meta").or(collection.meta), + ..collection + }); + } + + if let Some(items) = data.get("items").and_then(Value::as_array) { + return Some(HumanCollection { + field: "items".to_string(), + name: infer_collection_name("items", request_path, items), + items, + pagination: data.get("pagination"), + meta: data.get("_meta"), + }); + } + + for field in [ + "incidents", + "projects", + "assertions", + "contracts", + "releases", + "deployments", + "events", + "operations", + "workflows", + "schemas", + "checks", + "records", + "jobs", + "artifacts", + "members", + "invitations", + "integrations", + "transfers", + "requests", + "no_hit", + "no_2xx", + "write_no_2xx", + "unmatched_records", + "body_variants", + "examples", + "product_surfaces", + "requests", + ] { + if let Some(items) = data.get(field).and_then(Value::as_array) { + return Some(HumanCollection { + field: field.to_string(), + name: title_case(field), + items, + pagination: data.get("pagination"), + meta: data.get("_meta"), + }); + } + } + + None +} + +fn infer_collection_name(field: &str, request_path: &str, items: &[Value]) -> String { + for name in [ + "incidents", + "projects", + "assertions", + "contracts", + "releases", + "deployments", + "events", + "operations", + "workflows", + "schemas", + "records", + "jobs", + "artifacts", + "requests", + ] { + if request_path.contains(name) { + return title_case(name); + } + } + if items.iter().any(has_incident_shape) { + return "Incidents".to_string(); + } + title_case(field) +} + +fn collection_summary(collection: &HumanCollection<'_>) -> String { + let shown = collection.items.len(); + let item_name = collection.name.to_ascii_lowercase(); + if let Some(pagination) = collection.pagination { + let total = pagination + .get("total") + .and_then(Value::as_u64) + .unwrap_or(shown as u64); + let page = pagination.get("page").and_then(Value::as_u64); + let limit = pagination.get("limit").and_then(Value::as_u64); + let mut summary = if total > shown as u64 { + format!("Showing {shown} of {total} {item_name}") + } else { + format!("Showing {shown} {item_name}") + }; + if let Some(page) = page { + write!(summary, " on page {page}").expect("write to string"); + } + if let Some(limit) = limit { + write!(summary, " (limit {limit})").expect("write to string"); + } + return summary; + } + format!("Showing {shown} {item_name}") +} + +fn render_collection_items(output: &mut String, collection: &HumanCollection<'_>) { + match collection.field.as_str() { + "checks" => render_checks_table(output, collection.items), + "operations" => render_operations_table(output, collection.items), + "workflows" => render_workflows_table(output, collection.items), + "schemas" => render_schemas_table(output, collection.items), + "records" | "requests" | "unmatched_records" => { + render_request_records_table(output, collection.items); + } + "jobs" => render_jobs_table(output, collection.items), + "artifacts" => render_artifacts_table(output, collection.items), + "no_hit" | "no_2xx" | "write_no_2xx" => render_coverage_table(output, collection.items), + "body_variants" => render_body_variant_table(output, collection.items), + _ if is_incident_collection(collection) => render_incident_table(output, collection.items), + _ => render_generic_table(output, collection.items), + } +} + +fn render_checks_table(output: &mut String, items: &[Value]) { + writeln!(output, "{:<20} {:<10} Details", "Check", "Status").expect("write to string"); + for item in items { + let name = item.get("name").and_then(Value::as_str).unwrap_or("-"); + let status = item.get("status").and_then(Value::as_str).unwrap_or("-"); + let details = item + .get("details") + .or_else(|| item.get("path")) + .map_or_else(String::new, human_compact_summary); + writeln!( + output, + "{:<20} {:<10} {}", + pad(name, 20), + pad(status, 10), + details + ) + .expect("write to string"); + } +} + +fn render_operations_table(output: &mut String, items: &[Value]) { + writeln!( + output, + "{:<7} {:<45} {:<36} Policy", + "Method", "Path", "Operation" + ) + .expect("write to string"); + for item in items { + let method = item.get("method").and_then(Value::as_str).unwrap_or("-"); + let path = item.get("path").and_then(Value::as_str).unwrap_or("-"); + let operation = item + .get("operation_id") + .and_then(Value::as_str) + .unwrap_or("-"); + let policy = item + .pointer("/raw_api_use/policy") + .and_then(Value::as_str) + .map_or("-", |value| value); + writeln!( + output, + "{:<7} {:<45} {:<36} {}", + method, + pad(path, 45), + pad(operation, 36), + title_case(policy) + ) + .expect("write to string"); + } +} + +fn render_workflows_table(output: &mut String, items: &[Value]) { + writeln!(output, "{:<28} Steps Description", "Workflow").expect("write to string"); + for item in items { + let name = item.get("name").and_then(Value::as_str).unwrap_or("-"); + let steps = item + .get("steps") + .and_then(Value::as_array) + .map_or(0, Vec::len); + let description = item + .get("description") + .and_then(Value::as_str) + .unwrap_or_default(); + writeln!( + output, + "{:<28} {:<5} {}", + pad(name, 28), + steps, + truncate(description, 72) + ) + .expect("write to string"); + } +} + +fn render_schemas_table(output: &mut String, items: &[Value]) { + writeln!(output, "{:<24} {:<7} Command", "Workflow", "Actions").expect("write to string"); + for item in items { + let workflow = item.get("workflow").and_then(Value::as_str).unwrap_or("-"); + let actions = item.get("actions").and_then(Value::as_u64).unwrap_or(0); + let command = item.get("command").and_then(Value::as_str).unwrap_or("-"); + writeln!( + output, + "{:<24} {:<7} {}", + pad(workflow, 24), + actions, + truncate(&humanize_command(command), 96) + ) + .expect("write to string"); + } +} + +fn render_request_records_table(output: &mut String, items: &[Value]) { + writeln!( + output, + "{:<16} {:<7} {:<45} {:<6} Request ID", + "Time", "Method", "Path", "HTTP" + ) + .expect("write to string"); + for item in items { + let time = item + .get("timestamp") + .and_then(Value::as_str) + .map_or_else(String::new, format_timestamp); + let method = item.get("method").and_then(Value::as_str).unwrap_or("-"); + let path = item.get("path").and_then(Value::as_str).unwrap_or("-"); + let status = item + .get("status") + .and_then(Value::as_u64) + .map_or_else(|| "-".to_string(), |value| value.to_string()); + let request_id = item + .get("request_id") + .and_then(Value::as_str) + .unwrap_or("-"); + writeln!( + output, + "{:<16} {:<7} {:<45} {:<6} {}", + pad(&time, 16), + method, + pad(path, 45), + status, + request_id + ) + .expect("write to string"); + } +} + +fn render_jobs_table(output: &mut String, items: &[Value]) { + writeln!( + output, + "{:<38} {:<16} {:<12} Updated", + "Job", "Kind", "Status" + ) + .expect("write to string"); + for item in items { + let job_id = item.get("job_id").and_then(Value::as_str).unwrap_or("-"); + let kind = item.get("kind").and_then(Value::as_str).unwrap_or("-"); + let status = item.get("status").and_then(Value::as_str).unwrap_or("-"); + let updated = item + .get("updated_at") + .and_then(Value::as_str) + .map_or_else(String::new, format_timestamp); + writeln!( + output, + "{:<38} {:<16} {:<12} {}", + pad(job_id, 38), + pad(kind, 16), + pad(status, 12), + updated + ) + .expect("write to string"); + } +} + +fn render_artifacts_table(output: &mut String, items: &[Value]) { + writeln!(output, "{:<58} {:>10} Modified", "Path", "Bytes").expect("write to string"); + for item in items { + let path = item.get("path").and_then(Value::as_str).unwrap_or("-"); + let bytes = item + .get("bytes") + .and_then(Value::as_u64) + .map_or_else(|| "-".to_string(), |value| value.to_string()); + let modified = item + .get("modified") + .and_then(Value::as_u64) + .map_or_else(String::new, format_unix_timestamp); + writeln!(output, "{:<58} {:>10} {}", pad(path, 58), bytes, modified) + .expect("write to string"); + } +} + +fn render_coverage_table(output: &mut String, items: &[Value]) { + writeln!( + output, + "{:<7} {:<45} {:<7} {:<7} Request ID", + "Method", "Path", "Hits", "2xx" + ) + .expect("write to string"); + for item in items.iter().take(20) { + let method = item.get("method").and_then(Value::as_str).unwrap_or("-"); + let path = item.get("path").and_then(Value::as_str).unwrap_or("-"); + let hits = item.get("hits").and_then(Value::as_u64).unwrap_or(0); + let ok = item.get("ok").and_then(Value::as_u64).unwrap_or(0); + let request_id = item + .get("latest_request_id") + .and_then(Value::as_str) + .unwrap_or("-"); + writeln!( + output, + "{:<7} {:<45} {:<7} {:<7} {}", + method, + pad(path, 45), + hits, + ok, + request_id + ) + .expect("write to string"); + } + if items.len() > 20 { + writeln!(output, "... {} more", items.len() - 20).expect("write to string"); + } +} + +fn render_body_variant_table(output: &mut String, items: &[Value]) { + for item in items { + let name = item + .get("name") + .and_then(Value::as_str) + .unwrap_or("variant"); + writeln!(output, "- {name}").expect("write to string"); + if let Some(body) = item.get("body") { + render_human_value(output, body, 4); + } + } +} + +fn render_collection_meta(output: &mut String, meta: &Value) { + let fetched_at = meta + .get("fetchedAt") + .or_else(|| meta.get("fetched_at")) + .and_then(Value::as_str); + let sources = meta.get("sources").and_then(Value::as_array); + if fetched_at.is_none() && sources.is_none_or(Vec::is_empty) { + return; + } + + output.push_str("Fetched"); + if let Some(fetched_at) = fetched_at { + output.push(' '); + output.push_str(&format_timestamp(fetched_at)); + } + if let Some(sources) = sources { + let source_names = sources + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(", "); + if !source_names.is_empty() { + output.push_str(" from "); + output.push_str(&source_names); + } + } + output.push('\n'); +} + +fn is_incident_collection(collection: &HumanCollection<'_>) -> bool { + collection.name == "Incidents" || collection.items.iter().any(has_incident_shape) +} + +fn has_incident_shape(value: &Value) -> bool { + value.get("referenceId").is_some() + || value.get("reference_id").is_some() + || (value.get("timestamp").is_some() + && value.get("network").is_some() + && value.get("title").is_some()) +} + +fn render_incident_table(output: &mut String, items: &[Value]) { + writeln!( + output, + "{:<3} {:<16} {:<24} {:<29} ID", + "#", "Time", "Network", "Title" + ) + .expect("write to string"); + for (index, item) in items.iter().enumerate() { + let timestamp = item + .get("timestamp") + .and_then(Value::as_str) + .map_or_else(String::new, format_timestamp); + let network = format_network(item.get("network")); + let title = item + .get("title") + .and_then(Value::as_str) + .unwrap_or("Untitled"); + let id = item.get("id").and_then(Value::as_str).unwrap_or("-"); + writeln!( + output, + "{:<3} {:<16} {:<24} {:<29} {}", + index + 1, + pad(×tamp, 16), + pad(&network, 24), + pad(title, 29), + id + ) + .expect("write to string"); + } +} + +fn render_generic_table(output: &mut String, items: &[Value]) { + let columns = generic_columns(items); + if columns.is_empty() { + render_human_value(output, &Value::Array(items.to_vec()), 0); + return; + } + + write!(output, "{:<3}", "#").expect("write to string"); + for column in &columns { + write!(output, " {:<22}", title_case(column)).expect("write to string"); + } + output.push('\n'); + + for (index, item) in items.iter().enumerate() { + write!(output, "{:<3}", index + 1).expect("write to string"); + for column in &columns { + let value = item.get(column).map_or_else(String::new, human_cell); + write!(output, " {:<22}", pad(&value, 22)).expect("write to string"); + } + output.push('\n'); + } +} + +fn generic_columns(items: &[Value]) -> Vec { + let mut columns = Vec::new(); + for preferred in [ + "name", + "title", + "id", + "status", + "environment", + "network", + "timestamp", + "createdAt", + "updatedAt", + ] { + if items.iter().any(|item| item.get(preferred).is_some()) { + columns.push(preferred.to_string()); + } + if columns.len() == 4 { + return columns; + } + } + + if columns.is_empty() + && let Some(object) = items.first().and_then(Value::as_object) + { + columns.extend(object.keys().take(4).cloned()); + } + columns +} + +fn human_cell(value: &Value) -> String { + match value { + Value::Object(object) if object.contains_key("name") => { + object + .get("name") + .and_then(Value::as_str) + .map_or_else(|| compact_json(value), ToString::to_string) + } + Value::Object(_) | Value::Array(_) => compact_json(value), + _ => scalar_string(value), + } +} + +fn human_action(action: &Value) -> String { + action.as_str().map_or_else( + || compact_json(action), + |value| { + if value.trim_start().starts_with("pcl ") { + humanize_command(value) + } else if value == "Use --toon for agent consumption or --json for strict JSON parsing" + { + "Use --json for strict JSON parsing".to_string() + } else { + value.to_string() + } + }, + ) +} + +fn humanize_command(command: &str) -> String { + command + .replace(" --format toon", "") + .replace(" --toon", "") + .replace("--toon ", "") +} + +fn is_body_template_key(key: &str) -> bool { + matches!( + key, + "project_name" + | "project_description" + | "profile_image_url" + | "github_url" + | "chain_id" + | "is_private" + | "is_dev" + | "project_id" + | "identifier" + | "identifier_type" + | "role" + | "provider" + | "webhook_url" + | "routing_key" + | "enabled" + | "address" + | "signature" + | "nonce" + | "tx_hash" + | "contract_name" + | "assertions" + | "assertionsDir" + | "contracts" + | "environment" + | "mode" + | "new_manager_address" + | "ponder_transfer_id" + | "reason" + | "notify" + ) +} + +fn name_value_pairs(values: &[Value]) -> String { + values + .iter() + .map(|value| { + let name = value.get("name").and_then(Value::as_str).unwrap_or("?"); + let rendered = value + .get("value") + .map_or_else(|| "none".to_string(), scalar_string); + format!("{name}={rendered}") + }) + .collect::>() + .join(", ") +} + +fn render_actions_table(output: &mut String, actions: &[Value]) { + writeln!( + output, + "{:<24} {:<7} {:<8} Path", + "Action", "Auth", "Method" + ) + .expect("write to string"); + for action in actions { + let name = action.get("name").and_then(Value::as_str).unwrap_or("-"); + let auth = action + .get("auth") + .and_then(Value::as_bool) + .map_or("-", |value| if value { "yes" } else { "no" }); + let method = action.get("method").and_then(Value::as_str).unwrap_or("-"); + let path = action.get("path").and_then(Value::as_str).unwrap_or("-"); + writeln!( + output, + "{:<24} {:<7} {:<8} {}", + pad(name, 24), + auth, + method, + path + ) + .expect("write to string"); + } +} + +fn render_action_detail(output: &mut String, action: &Value) { + let name = action.get("name").and_then(Value::as_str).unwrap_or("-"); + writeln!(output, "Action: {name}").expect("write to string"); + if let (Some(method), Some(path)) = ( + action.get("method").and_then(Value::as_str), + action.get("path").and_then(Value::as_str), + ) { + writeln!(output, "Request: {method} {path}").expect("write to string"); + } + if let Some(auth) = action.get("auth").and_then(Value::as_bool) { + writeln!( + output, + "Auth: {}", + if auth { "required" } else { "not required" } + ) + .expect("write to string"); + } + if let Some(example) = action.get("example").and_then(Value::as_str) { + writeln!(output, "Example: {}", humanize_command(example)).expect("write to string"); + } + if let Some(flags) = action.get("required_flags").and_then(Value::as_array) + && !flags.is_empty() + { + writeln!(output, "Required flags: {}", string_list(flags)).expect("write to string"); + } + if let Some(flags) = action.get("optional_flags").and_then(Value::as_array) + && !flags.is_empty() + { + writeln!(output, "Optional flags: {}", string_list(flags)).expect("write to string"); + } +} + +fn string_list(values: &[Value]) -> String { + values + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(", ") +} + +fn yes_no(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} + +fn format_duration(seconds: i64) -> String { + if seconds < 0 { + return "expired".to_string(); + } + let days = seconds / 86_400; + let hours = (seconds % 86_400) / 3_600; + let minutes = (seconds % 3_600) / 60; + if days > 0 { + format!("{days}d {hours}h") + } else if hours > 0 { + format!("{hours}h {minutes}m") + } else { + format!("{minutes}m") + } +} + +fn render_human_summary(output: &mut String, data: &Value) { + let display_data = data.get("data").unwrap_or(data); + output.push('\n'); + if let Some(object) = display_data.as_object() { + for (key, value) in object { + if key.starts_with('_') { + continue; + } + output.push_str(&title_case(key)); + output.push_str(": "); + if is_scalar(value) { + output.push_str(&scalar_string(value)); + output.push('\n'); + } else { + output.push_str(&human_compact_summary(value)); + output.push('\n'); + } + } + } else { + render_human_value(output, display_data, 0); + } +} + +fn render_human_request_id(output: &mut String, envelope: &Value) { + let request_id = envelope + .pointer("/response/request_id") + .and_then(Value::as_str); + let status = envelope.pointer("/response/status").and_then(Value::as_u64); + if request_id.is_none() && status.is_none() { + return; + } + + output.push('\n'); + if let Some(request_id) = request_id { + output.push_str("Request ID: "); + output.push_str(request_id); + if let Some(status) = status { + write!(output, " (HTTP {status})").expect("write to string"); + } + output.push('\n'); + } else if let Some(status) = status { + writeln!(output, "HTTP status: {status}").expect("write to string"); + } +} + +fn human_compact_summary(value: &Value) -> String { + match value { + Value::Array(values) => format!("{} item(s)", values.len()), + Value::Object(object) => { + object + .iter() + .filter(|(key, _)| !key.starts_with('_')) + .take(3) + .map(|(key, value)| { + if is_scalar(value) { + format!("{key}={}", scalar_string(value)) + } else { + format!("{key}={}", compact_json(value)) + } + }) + .collect::>() + .join(", ") + } + _ => scalar_string(value), + } +} + +fn format_network(value: Option<&Value>) -> String { + let Some(value) = value else { + return "-".to_string(); + }; + if let Some(name) = value.as_str() { + return name.to_string(); + } + let name = value + .get("name") + .and_then(Value::as_str) + .unwrap_or("Unknown network"); + if let Some(chain_id) = value.get("chainId").and_then(Value::as_u64) { + return format!("{name} ({chain_id})"); + } + if let Some(chain_id) = value.get("chain_id").and_then(Value::as_u64) { + return format!("{name} ({chain_id})"); + } + name.to_string() +} + +fn format_timestamp(value: &str) -> String { + if value.len() >= 16 && value.as_bytes().get(10) == Some(&b'T') { + return value[..16].replace('T', " "); + } + value.to_string() +} + +fn format_unix_timestamp(value: u64) -> String { + let Ok(seconds) = i64::try_from(value) else { + return value.to_string(); + }; + chrono::DateTime::from_timestamp(seconds, 0).map_or_else( + || value.to_string(), + |timestamp| timestamp.format("%Y-%m-%d %H:%M").to_string(), + ) +} + +fn title_case(value: &str) -> String { + value + .replace('_', " ") + .split_whitespace() + .map(|word| { + let mut chars = word.chars(); + chars.next().map_or_else(String::new, |first| { + first.to_uppercase().collect::() + chars.as_str() + }) + }) + .collect::>() + .join(" ") +} + +fn pad(value: &str, width: usize) -> String { + let value = truncate(value, width); + format!("{value: String { + let char_count = value.chars().count(); + if char_count <= max_chars { + return value.to_string(); + } + if max_chars <= 3 { + return value.chars().take(max_chars).collect(); + } + let prefix: String = value.chars().take(max_chars - 3).collect(); + format!("{prefix}...") +} + +fn render_human_value(output: &mut String, value: &Value, indent: usize) { + match value { + Value::Object(object) => { + for (key, value) in object { + write_indent(output, indent); + output.push_str(key); + output.push_str(": "); + if is_scalar(value) { + output.push_str(&scalar_string(value)); + output.push('\n'); + } else { + output.push('\n'); + render_human_value(output, value, indent + 2); + } + } + } + Value::Array(values) => { + for value in values { + write_indent(output, indent); + output.push_str("- "); + if is_scalar(value) { + output.push_str(&scalar_string(value)); + output.push('\n'); + } else { + output.push('\n'); + render_human_value(output, value, indent + 2); + } + } + } + _ => { + write_indent(output, indent); + output.push_str(&scalar_string(value)); + output.push('\n'); + } + } +} + +fn write_indent(output: &mut String, indent: usize) { + for _ in 0..indent { + output.push(' '); + } +} + +fn is_scalar(value: &Value) -> bool { + matches!( + value, + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) + ) +} + +fn scalar_string(value: &Value) -> String { + match value { + Value::Null => "none".to_string(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => value.clone(), + Value::Array(_) | Value::Object(_) => compact_json(value), + } +} + +fn compact_json(value: &Value) -> String { + serde_json::to_string(value).unwrap_or_else(|_| value.to_string()) +} + +fn ok_envelope(data: Value) -> Value { + with_envelope_metadata(json!({ + "status": "ok", + "data": data, + "next_actions": [ + "pcl api list", + "pcl api inspect get_views_public_incidents", + "pcl api call get /views/public/incidents --query limit=5 --allow-unauthenticated", + ], + })) +} + +fn dry_run_envelope(data: Value) -> Value { + let auth_required = data + .pointer("/request/auth/required") + .and_then(Value::as_bool) + .unwrap_or(false); + let allow_unauthenticated = data + .pointer("/request/auth/allow_unauthenticated") + .and_then(Value::as_bool) + .unwrap_or(false); + let stored_token_valid = data + .pointer("/request/auth/stored_token_valid") + .and_then(Value::as_bool) + .unwrap_or(false); + let next_actions = if auth_required && !allow_unauthenticated && !stored_token_valid { + vec![ + "pcl auth ensure --toon", + "Authenticate before removing --dry-run", + "Use --body-template when constructing mutation bodies", + ] + } else { + let mut actions = vec![ + "Remove --dry-run to execute this request", + "Use --toon for agent consumption or --json for strict JSON parsing", + ]; + let method = data + .pointer("/request/method") + .and_then(Value::as_str) + .unwrap_or_default(); + if method_side_effecting(method) { + actions.push("Use --body-template when constructing mutation bodies"); + } + actions }; with_envelope_metadata(json!({ "status": "ok", @@ -3024,12 +4612,12 @@ pub fn api_manifest() -> Value { "allowed_uses": ["debugging", "OpenAPI parity checks", "service/internal endpoint investigation", "browser-session bridge investigation", "new endpoint exploration before promotion"], "not_normal_path": "Agents should not call raw endpoints for incidents, projects, assertions, releases, integrations, access, protocol-manager, transfers, events, search, or auth when a workflow alternative is advertised." }, - "llms": "pcl --llms | pcl llms", - "default_output": "toon", + "llms": "pcl --toon --llms | pcl llms --toon", + "default_output": "human", "output_modes": { - "default": "toon", - "toon": "Default compact machine-readable envelope; explicit form is --format toon.", - "json": "Pass --json or --format json for the same {status,data,error,next_actions} envelope as JSON." + "default": "Human-readable output optimized for people.", + "toon": "Pass --toon for compact machine-readable envelopes.", + "json": "Pass --json for the same {status,data,error,next_actions} envelope as JSON." }, "body_input": { "preferred": "Use typed flags when available, then --field key=value, then --body-file for nested payloads.", @@ -3051,17 +4639,17 @@ pub fn api_manifest() -> Value { "destructive_detection": "Request plans flag likely destructive paths, but raw api call does not enforce a confirmation gate." }, "product_surfaces": [ - {"command": "pcl --llms | pcl llms", "description": "Print the CLI-native LLM usage guide; use --json for JSON."}, - {"command": "pcl doctor", "description": "Diagnose config, auth, request-log, artifact, and API health state."}, - {"command": "pcl whoami", "description": "Print local identity, token validity, and expiry."}, - {"command": "pcl workflows [show ]", "description": "List agent-friendly workflow recipes with concrete command steps."}, - {"command": "pcl export incidents", "description": "Export incident list data as resumable JSONL artifacts with checkpoint and error files."}, - {"command": "pcl artifacts [path|init|list]", "description": "Find and inspect generated artifacts."}, - {"command": "pcl jobs [path|list|status|resume|cancel]", "description": "Inspect resumable local job records from export workflows."}, - {"command": "pcl requests|logs [path|list|clear]", "description": "Inspect the local API request log with status and request IDs."}, - {"command": "pcl api coverage [--records ] [--markdown ]", "description": "Compare the local request log with the live OpenAPI manifest and report hit/no-hit/no-2xx coverage."}, - {"command": "pcl schema [list|get ]", "description": "Inspect workflow/action schemas from the command manifest."}, - {"command": "pcl completions ", "description": "Generate shell completion scripts for bash, zsh, fish, powershell, and elvish."} + {"command": "pcl --toon --llms | pcl llms --toon", "description": "Print the CLI-native LLM usage guide for agents."}, + {"command": "pcl doctor --toon", "description": "Diagnose config, auth, request-log, artifact, and API health state."}, + {"command": "pcl whoami --toon", "description": "Print local identity, token validity, and expiry."}, + {"command": "pcl workflows [show ] --toon", "description": "List agent-friendly workflow recipes with concrete command steps."}, + {"command": "pcl export incidents --toon", "description": "Export incident list data as resumable JSONL artifacts with checkpoint and error files."}, + {"command": "pcl artifacts [path|init|list] --toon", "description": "Find and inspect generated artifacts."}, + {"command": "pcl jobs [path|list|status|resume|cancel] --toon", "description": "Inspect resumable local job records from export workflows."}, + {"command": "pcl requests|logs [path|list|clear] --toon", "description": "Inspect the local API request log with status and request IDs."}, + {"command": "pcl api coverage [--records ] [--markdown ] --toon", "description": "Compare the local request log with the live OpenAPI manifest and report hit/no-hit/no-2xx coverage."}, + {"command": "pcl schema [list|get ] --toon", "description": "Inspect workflow/action schemas from the command manifest."}, + {"command": "pcl completions --toon", "description": "Generate shell completion scripts for bash, zsh, fish, powershell, and elvish."} ], "commands": [ { @@ -5560,7 +7148,7 @@ fn special_workflow_alternatives(method: HttpMethod, path: &str) -> Vec { single_special_workflow( "auth", "login_challenge", - "pcl auth login --no-wait --force --json", + "pcl auth login --no-wait --force --toon", "Device-login challenge is exposed as a structured auth command.", ) } @@ -5568,7 +7156,7 @@ fn special_workflow_alternatives(method: HttpMethod, path: &str) -> Vec { single_special_workflow( "auth", "poll", - "pcl auth poll --session-id --device-secret --expires-at --json", + "pcl auth poll --session-id --device-secret --expires-at --toon", "Polling is handled by the auth command returned in data.poll_command.", ) } @@ -5576,7 +7164,7 @@ fn special_workflow_alternatives(method: HttpMethod, path: &str) -> Vec { single_special_workflow( "auth", "verify", - "pcl auth login --force --json", + "pcl auth login --force --toon", "The login command owns verification and stores the resulting credentials.", ) } @@ -5584,7 +7172,7 @@ fn special_workflow_alternatives(method: HttpMethod, path: &str) -> Vec { single_special_workflow( "auth", "refresh", - "pcl auth refresh --json", + "pcl auth refresh --toon", "Refresh rotation is exposed as a structured auth command.", ) } @@ -5592,7 +7180,7 @@ fn special_workflow_alternatives(method: HttpMethod, path: &str) -> Vec { single_special_workflow( "api", "manifest", - "pcl api manifest --json", + "pcl api manifest --toon", "Use the CLI manifest/list/inspect surfaces for discovery instead of raw OpenAPI retrieval.", ) } @@ -6199,12 +7787,12 @@ fn next_actions_for_operations(operations: &[OperationSummary]) -> Vec { { return vec![ example.to_string(), - format!("{} --json", operation.inspect_command), + format!("{} --toon", operation.inspect_command), ]; } if operation.requires_input { vec![ - format!("{} --json", operation.inspect_command), + format!("{} --toon", operation.inspect_command), "Use data.example_call after filling placeholders".to_string(), ] } else { diff --git a/crates/pcl/core/src/api/tests.rs b/crates/pcl/core/src/api/tests.rs index 14bc829..40a244a 100644 --- a/crates/pcl/core/src/api/tests.rs +++ b/crates/pcl/core/src/api/tests.rs @@ -279,7 +279,7 @@ fn openapi_call_commands_include_required_inputs() { assert_eq!( next_actions_for_operations(&operations), vec![ - "pcl api inspect post_project_widgets --json".to_string(), + "pcl api inspect post_project_widgets --toon".to_string(), "Use data.example_call after filling placeholders".to_string() ] ); @@ -1219,7 +1219,7 @@ async fn dry_run_projects_and_assertions_do_not_execute_requests() { project_output["data"]["request"]["auth"]["will_attach_stored_token"], false ); - assert_eq!(project_output["next_actions"][0], "pcl auth ensure --json"); + assert_eq!(project_output["next_actions"][0], "pcl auth ensure --toon"); let assertion_output = api .run_assertions( @@ -2340,7 +2340,7 @@ fn forbidden_errors_preserve_permission_context() { !error .next_actions() .iter() - .any(|action| action == "pcl auth refresh --json") + .any(|action| action == "pcl auth refresh --toon") ); } @@ -2490,8 +2490,8 @@ fn body_templates_are_action_specific() { } #[test] -fn default_api_output_is_full_toon_envelope() { - let output = output_string( +fn default_api_output_is_human_readable() { + let output = envelope_output_string( &json!({ "status": "ok", "data": {"healthy": true}, @@ -2501,12 +2501,176 @@ fn default_api_output_is_full_toon_envelope() { ) .unwrap(); - assert!(output.contains("status: ok")); - assert!(output.contains("schema_version: pcl.envelope.v1")); - assert!(output.contains("pcl_version:")); - assert!(output.contains("data:")); - assert!(output.contains("healthy: true")); - assert!(output.contains("next_actions[1]:")); + assert!(output.starts_with("OK\n")); + assert!(output.contains("Healthy: true")); + assert!(output.contains("Next:")); + assert!(!output.contains("Schema: pcl.envelope.v1")); + assert!(!output.contains("Details:")); +} + +#[test] +fn human_api_output_formats_incident_lists_for_people() { + let output = envelope_output_string( + &json!({ + "status": "ok", + "data": { + "data": { + "items": [ + { + "id": "7dfe71ee-9d69-41bb-b33c-992c0fbd684f", + "title": "Removed invalid transaction", + "network": {"chainId": 59144, "name": "Linea Mainnet"}, + "timestamp": "2026-05-06T14:01:54+00:00", + "referenceId": "c4f250" + } + ], + "pagination": { + "page": 1, + "limit": 20, + "total": 332, + "hasMore": true + } + }, + "_meta": { + "sources": ["offchain"], + "fetchedAt": "2026-05-09T23:30:09.618Z" + } + }, + "request": {"method": "GET", "path": "/views/public/incidents"}, + "response": {"status": 200, "request_id": "req_123"}, + "next_actions": ["pcl incidents --incident-id 7dfe71ee-9d69-41bb-b33c-992c0fbd684f"], + }), + false, + ) + .unwrap(); + + assert!(output.contains("Incidents\n")); + assert!(output.contains("Showing 1 of 332 incidents on page 1 (limit 20)")); + assert!(output.contains("Fetched 2026-05-09 23:30 from offchain")); + assert!(output.contains("Linea Mainnet (59144)")); + assert!(output.contains("Removed invalid transaction")); + assert!(output.contains("7dfe71ee-9d69-41bb-b33c-992c0fbd684f")); + assert!(output.contains("More results available. Try --page 2 --limit 20.")); + assert!(output.contains("Request ID: req_123 (HTTP 200)")); + assert!(!output.contains("Details:")); + assert!(!output.contains("Request:\n")); + assert!(!output.contains("Schema:")); +} + +#[test] +fn human_output_formats_surface_lists_for_people() { + let output = envelope_output_string( + &json!({ + "status": "ok", + "data": { + "workflows": [ + { + "name": "incident-investigation", + "description": "Export incidents and inspect traces.", + "steps": [ + {"command": "pcl doctor --toon", "output": "environment readiness"} + ] + } + ] + }, + "next_actions": ["pcl schema list --toon"], + }), + false, + ) + .unwrap(); + + assert!(output.contains("Workflows\n")); + assert!(output.contains("incident-investigation")); + assert!(output.contains("Export incidents and inspect traces.")); + assert!(output.contains("pcl schema list")); + assert!(!output.contains("--toon")); + assert!(!output.contains("Details:")); +} + +#[test] +fn human_output_formats_schema_action_for_people() { + let output = envelope_output_string( + &json!({ + "status": "ok", + "data": { + "workflow": "incidents", + "action": { + "name": "list_public", + "auth": false, + "method": "GET", + "path": "/views/public/incidents", + "optional_flags": ["--page", "--limit"], + "example": "pcl incidents --limit 5 --toon" + } + }, + "next_actions": ["pcl workflows --toon"], + }), + false, + ) + .unwrap(); + + assert!(output.contains("Schema: incidents")); + assert!(output.contains("Action: list_public")); + assert!(output.contains("Request: GET /views/public/incidents")); + assert!(output.contains("Example: pcl incidents --limit 5")); + assert!(!output.contains("--toon")); +} + +#[test] +fn human_output_formats_dry_run_request_plan_for_people() { + let output = envelope_output_string( + &dry_run_envelope(json!({ + "dry_run": true, + "valid": true, + "request": { + "method": "GET", + "path": "/views/projects", + "query": [{"name": "limit", "value": "2"}], + "auth": { + "required": false, + "will_attach_stored_token": false, + } + }, + "pagination": null, + })), + false, + ) + .unwrap(); + + assert!(output.contains("Dry run")); + assert!(output.contains("GET /views/projects")); + assert!(output.contains("Query: limit=2")); + assert!(output.contains("Auth: not required")); + assert!(!output.contains("Use --body-template when constructing mutation bodies")); + assert!(!output.contains("Details:")); +} + +#[test] +fn human_output_formats_api_discovery_for_people() { + let output = envelope_output_string( + &json!({ + "status": "ok", + "data": { + "operations": [ + { + "operation_id": "get_views_public_incidents", + "method": "GET", + "path": "/views/public/incidents", + "raw_api_use": {"policy": "prefer_workflow"} + } + ] + }, + "next_actions": ["pcl api inspect get_views_public_incidents --toon"], + }), + false, + ) + .unwrap(); + + assert!(output.contains("Operations\n")); + assert!(output.contains("GET")); + assert!(output.contains("/views/public/incidents")); + assert!(output.contains("Prefer Workflow")); + assert!(!output.contains("--toon")); } #[test] @@ -2578,15 +2742,15 @@ fn variant_body_templates_return_variant_specific_next_actions() { #[test] fn manifest_lists_structured_actions_for_every_workflow() { let manifest = api_manifest(); - assert_eq!(manifest["default_output"], "toon"); + assert_eq!(manifest["default_output"], "human"); assert_eq!( manifest["output_modes"]["toon"], - "Default compact machine-readable envelope; explicit form is --format toon." + "Pass --toon for compact machine-readable envelopes." ); assert!( manifest["output_modes"]["json"] .as_str() - .is_some_and(|value| value.contains("--format json")) + .is_some_and(|value| value.contains("--json")) ); let commands = manifest["commands"].as_array().unwrap(); for command_name in [ diff --git a/crates/pcl/core/src/auth.rs b/crates/pcl/core/src/auth.rs index 9b2d72a..a5a3669 100644 --- a/crates/pcl/core/src/auth.rs +++ b/crates/pcl/core/src/auth.rs @@ -1,7 +1,7 @@ use crate::{ DEFAULT_PLATFORM_URL, api::{ - toon_string, + envelope_output_string, with_envelope_metadata, }, config::{ @@ -112,7 +112,7 @@ pub enum AuthSubcommands { /// Ensure auth is usable, or return a one-envelope login challenge #[command( long_about = "Checks whether auth is usable. If not, returns a structured device-login challenge without waiting.", - after_help = "Examples:\n pcl auth ensure\n pcl auth ensure --format toon\n pcl auth ensure --force --format toon" + after_help = "Examples:\n pcl auth ensure\n pcl auth ensure --toon\n pcl auth ensure --force --toon" )] Ensure { #[arg(long, help = "Return a fresh login challenge even when auth is usable")] @@ -122,7 +122,7 @@ pub enum AuthSubcommands { /// Login to PCL #[command( long_about = "Initiates the login process. Opens a browser window for authentication.", - after_help = "Examples:\n pcl auth login\n pcl auth login --force\n pcl auth login --no-wait --format toon" + after_help = "Examples:\n pcl auth login\n pcl auth login --force\n pcl auth login --no-wait --toon" )] Login { #[arg( @@ -140,7 +140,7 @@ pub enum AuthSubcommands { /// Poll a pending device-login session once #[command( long_about = "Checks a device-login session once and stores credentials if verification completed.", - after_help = "Example: pcl auth poll --session-id --device-secret --expires-at --format toon" + after_help = "Example: pcl auth poll --session-id --device-secret --expires-at --toon" )] Poll { #[arg( @@ -157,7 +157,7 @@ pub enum AuthSubcommands { /// Refresh auth when possible, or return a login challenge when refresh is unavailable #[command( long_about = "Refreshes auth non-interactively by rotating the stored CLI refresh token; returns a structured login challenge when no refreshable session exists.", - after_help = "Example: pcl auth refresh --format toon" + after_help = "Example: pcl auth refresh --toon" )] Refresh { #[arg( @@ -546,7 +546,7 @@ impl AuthCommand { "wait_command": if json_output { "pcl auth login --force --json" } else { - "pcl auth login --force --format toon" + "pcl auth login --force --toon" }, }, "next_actions": [ @@ -602,11 +602,7 @@ impl AuthCommand { } fn poll_command(&self, auth_response: &GetCliAuthCodeResponse, json_output: bool) -> String { - let output_flag = if json_output { - " --json" - } else { - " --format toon" - }; + let output_flag = if json_output { " --json" } else { " --toon" }; let auth_url = self.effective_auth_url(); format!( "pcl auth --auth-url={} poll --session-id={} --device-secret={} --expires-at={}{}", @@ -956,9 +952,9 @@ impl AuthCommand { "platform_url": self.effective_auth_url().as_str(), }, "next_actions": if token_expired { - json!(["pcl auth refresh --json", "pcl auth login --force", "pcl auth logout"]) + json!(["pcl auth refresh --toon", "pcl auth login --force", "pcl auth logout"]) } else if expires_soon { - json!(["pcl auth refresh --json", "pcl account"]) + json!(["pcl auth refresh --toon", "pcl account"]) } else { json!(["pcl account", "pcl projects --limit 10"]) }, @@ -966,16 +962,11 @@ impl AuthCommand { } fn print_output(value: &Value, json_output: bool) -> Result<(), AuthError> { - let value = with_envelope_metadata(value.clone()); - if json_output { - println!( - "{}", - serde_json::to_string_pretty(&value) - .map_err(|error| AuthError::InvalidAuthData(error.to_string()))? - ); - } else { - print!("{}", toon_string(&value)); - } + print!( + "{}", + envelope_output_string(value, json_output) + .map_err(|error| AuthError::InvalidAuthData(error.to_string()))? + ); Ok(()) } @@ -1473,11 +1464,11 @@ mod tests { assert!( toon["data"]["poll_command"] .as_str() - .is_some_and(|command| command.ends_with("--format toon")) + .is_some_and(|command| command.ends_with("--toon")) ); assert_eq!( toon["data"]["wait_command"], - "pcl auth login --force --format toon" + "pcl auth login --force --toon" ); let json = cmd.login_challenge_envelope(&auth_response, AuthChallengeReason::Missing, true); diff --git a/crates/pcl/core/src/config.rs b/crates/pcl/core/src/config.rs index bbda8e5..715af74 100644 --- a/crates/pcl/core/src/config.rs +++ b/crates/pcl/core/src/config.rs @@ -1,6 +1,6 @@ use crate::{ api::{ - toon_string, + envelope_output_string, with_envelope_metadata, }, error::ConfigError, @@ -469,15 +469,10 @@ fn config_auth_value(config: &CliConfig) -> Value { } fn print_config_output(value: &Value, json_output: bool) -> Result<(), ConfigError> { - let value = with_envelope_metadata(value.clone()); - if json_output { - println!( - "{}", - serde_json::to_string_pretty(&value).map_err(ConfigError::JsonError)? - ); - } else { - print!("{}", toon_string(&value)); - } + print!( + "{}", + envelope_output_string(value, json_output).map_err(ConfigError::JsonError)? + ); Ok(()) } diff --git a/crates/pcl/core/src/surface.rs b/crates/pcl/core/src/surface.rs index 18d71bb..e7717a9 100644 --- a/crates/pcl/core/src/surface.rs +++ b/crates/pcl/core/src/surface.rs @@ -8,7 +8,7 @@ use crate::{ DEFAULT_PLATFORM_URL, api::{ api_manifest, - toon_string, + envelope_output_string, with_envelope_metadata, }, config::{ @@ -472,9 +472,10 @@ impl DoctorArgs { "status": status, "data": { "checks": checks, - "default_output": "toon", + "default_output": "human", + "toon_output_flag": "--toon", "json_output_flag": "--json", - "format_flag": "--format toon|json", + "legacy_format_flag": "--format toon|json", "api_url": self.api_url.as_str(), }, "next_actions": [ @@ -1060,12 +1061,7 @@ async fn export_incidents( } fn print_output(value: &Value, json_output: bool) -> Result<(), ProductSurfaceError> { - let value = with_envelope_metadata(value.clone()); - if json_output { - println!("{}", serde_json::to_string_pretty(&value)?); - } else { - print!("{}", toon_string(&value)); - } + print!("{}", envelope_output_string(value, json_output)?); Ok(()) } @@ -1317,10 +1313,10 @@ pub fn print_llms_guide(json_output: bool) -> Result<(), ProductSurfaceError> { "status": "ok", "data": llms_guide(), "next_actions": [ - "pcl doctor", - "pcl api manifest --json", + "pcl doctor --toon", + "pcl api manifest --toon", "pcl completions bash > ~/.local/share/bash-completion/completions/pcl", - "pcl jobs list", + "pcl jobs list --toon", ], }), json_output, @@ -1331,12 +1327,14 @@ fn llms_guide() -> Value { json!({ "name": "pcl", "purpose": "CLI-native control surface for Credible Layer API investigation and assertion workflows.", - "default_output": "toon", + "default_output": "human", + "toon_flag": "--toon", "json_flag": "--json", - "format_flag": "--format toon|json", + "legacy_format_flag": "--format toon|json", "output_modes": { - "toon": "Default compact machine-readable envelope; preferred for agent context efficiency.", - "json": "Use --json or --format json when strict JSON tooling is required." + "default": "Human-readable output optimized for people using the CLI directly.", + "toon": "Use --toon for compact machine-readable envelopes; preferred for agent context efficiency.", + "json": "Use --json when strict JSON tooling is required." }, "no_mcp_required": true, "principles": [ @@ -1348,52 +1346,52 @@ fn llms_guide() -> Value { "Prefer CLI contracts over MCP, browser automation, or scraped help text." ], "consumption_order": [ - "pcl --llms", - "pcl doctor", - "pcl whoami", - "pcl workflows", - "pcl schema list", - "pcl api manifest --json", + "pcl --toon --llms", + "pcl doctor --toon", + "pcl whoami --toon", + "pcl workflows --toon", + "pcl schema list --toon", + "pcl api manifest --toon", "top-level workflow commands", - "pcl api inspect --json when debugging", - "pcl api call --json only after checking workflow_alternatives", - "pcl api coverage --json" + "pcl api inspect --toon when debugging", + "pcl api call --toon only after checking workflow_alternatives", + "pcl api coverage --toon" ], "orientation": [ { "goal": "Check local readiness and auth truthfulness", - "commands": ["pcl doctor", "pcl auth ensure --json", "pcl whoami", "pcl auth status --json"] + "commands": ["pcl doctor --toon", "pcl auth ensure --toon", "pcl whoami --toon", "pcl auth status --toon"] }, { "goal": "Discover available workflows", - "commands": ["pcl workflows", "pcl schema list", "pcl api manifest --json"] + "commands": ["pcl workflows --toon", "pcl schema list --toon", "pcl api manifest --toon"] }, { "goal": "Debug raw API shape", - "commands": ["pcl api list --filter incidents --json", "pcl api inspect --json"] + "commands": ["pcl api list --filter incidents --toon", "pcl api inspect --toon"] }, { "goal": "Run raw calls only for debugging or unsupported/internal endpoints", - "commands": ["pcl api call get /health --allow-unauthenticated", "pcl api call get '/views/public/incidents?limit=5' --allow-unauthenticated"] + "commands": ["pcl api call get /health --allow-unauthenticated --toon", "pcl api call get '/views/public/incidents?limit=5' --allow-unauthenticated --toon"] }, { "goal": "Export resumable incident data", - "commands": ["pcl export incidents --project-id --environment production --out incidents.jsonl --errors errors.jsonl --resume", "pcl jobs list"] + "commands": ["pcl export incidents --project-id --environment production --out incidents.jsonl --errors errors.jsonl --resume --toon", "pcl jobs list --toon"] } ], "command_surfaces": { "workflows": ["pcl incidents", "pcl projects", "pcl assertions", "pcl account", "pcl contracts", "pcl releases", "pcl deployments", "pcl access", "pcl integrations", "pcl protocol-manager", "pcl transfers", "pcl events", "pcl search"], - "discovery": ["pcl --llms", "pcl llms", "pcl workflows", "pcl schema", "pcl api manifest", "pcl api list", "pcl api inspect"], + "discovery": ["pcl --toon --llms", "pcl llms --toon", "pcl workflows --toon", "pcl schema --toon", "pcl api manifest --toon", "pcl api list --toon", "pcl api inspect --toon"], "execution": ["pcl api call", "pcl export incidents"], "state": ["pcl artifacts", "pcl requests", "pcl jobs"], "shell": ["pcl completions bash", "pcl completions zsh", "pcl completions fish"] }, "output_contract": { - "default": "TOON envelope", - "toon": "Pass --format toon explicitly when you need to pin the default contract.", - "json": "Pass --json or --format json for pretty JSON envelopes.", + "default": "Human-readable output for people.", + "toon": "Pass --toon for compact agent envelopes.", + "json": "Pass --json for pretty JSON envelopes.", "jsonl_exceptions": { - "pcl auth login --json": "Fresh login emits JSONL progress events and a final event with terminal=true. Already-authenticated login, including --no-wait, returns one auth-status envelope instead of a challenge. Use pcl auth ensure --json or pcl auth login --no-wait --force --json when a fresh challenge is required." + "pcl auth login --json": "Fresh login emits JSONL progress events and a final event with terminal=true. Already-authenticated login, including --no-wait, returns one auth-status envelope instead of a challenge. Use pcl auth ensure --toon or pcl auth login --no-wait --force --toon for normal agent flows; use --json only when JSONL is required." }, "envelope_fields": ["status", "data", "error", "next_actions", "schema_version", "pcl_version"], "errors": "Parser, auth, config, validation, network, and API failures return structured envelopes and nonzero exit codes.", @@ -1402,12 +1400,12 @@ fn llms_guide() -> Value { }, "auth_behavior": { "expiry_source": "Stored token expiry is normalized from the access-token JWT exp claim when available.", - "ensure_command": "pcl auth ensure --json", + "ensure_command": "pcl auth ensure --toon", "expires_soon": "true when five minutes or less remain; renew before long-running work.", - "renew_command": "pcl auth ensure --force --json", - "single_envelope_login": "pcl auth login --no-wait --force --json returns status=action_required with device_url, code, device_secret, expires_at, and poll_command.", - "poll_command": "pcl auth poll --session-id --device-secret --expires-at --json", - "refresh_command": "pcl auth refresh --json rotates the stored refresh token when available; if the refresh token is missing or rejected, it returns a login challenge.", + "renew_command": "pcl auth ensure --force --toon", + "single_envelope_login": "pcl auth login --no-wait --force --toon returns status=action_required with device_url, code, device_secret, expires_at, and poll_command.", + "poll_command": "pcl auth poll --session-id --device-secret --expires-at --toon", + "refresh_command": "pcl auth refresh --toon rotates the stored refresh token when available; if the refresh token is missing or rejected, it returns a login challenge.", "logout": "pcl auth logout attempts remote logout first, then clears local credentials; pass --local to skip the remote request." }, "mutation_safety": { @@ -1417,21 +1415,21 @@ fn llms_guide() -> Value { }, "raw_api": { "policy": "For normal product work, use workflow_alternatives from pcl api list/inspect or a top-level workflow command. Raw api call is for debugging, OpenAPI parity checks, internal/service endpoints, browser-session bridge investigation, or new endpoint exploration before promotion.", - "inspect_first": "Use pcl api inspect --json before unfamiliar raw calls and check data.workflow_alternatives first.", + "inspect_first": "Use pcl api inspect --toon before unfamiliar raw calls and check data.workflow_alternatives first.", "query_strings": "pcl api call accepts both /path?key=value and repeated --query key=value.", "fields": "pcl api call accepts repeated --field key=value for simple JSON object bodies; use --body-file for nested payloads.", "public_endpoints": "Known public raw calls do not attach stored tokens; --allow-unauthenticated remains the explicit opt-out for other public endpoints.", "pagination": "Use --paginate --limit --max-pages and optionally --jsonl --output for generic GET pagination.", - "coverage": "Use pcl api coverage --json after exploration to find no-hit, hit-without-2xx, side-effecting-without-2xx, and unmatched request-log records." + "coverage": "Use pcl api coverage --toon after exploration to find no-hit, hit-without-2xx, side-effecting-without-2xx, and unmatched request-log records." }, "jobs_and_artifacts": { - "export": "pcl export incidents --project-id --environment production --out incidents.jsonl --errors errors.jsonl --checkpoint checkpoint.json --resume --continue-on-error --json", - "inspect": ["pcl jobs list --json", "pcl jobs status --json", "pcl jobs resume --json", "pcl artifacts list --json"], + "export": "pcl export incidents --project-id --environment production --out incidents.jsonl --errors errors.jsonl --checkpoint checkpoint.json --resume --continue-on-error --toon", + "inspect": ["pcl jobs list --toon", "pcl jobs status --toon", "pcl jobs resume --toon", "pcl artifacts list --toon"], "state_fields": ["job_id", "resume_command", "artifacts.out", "artifacts.errors", "artifacts.checkpoint"] }, "provenance": { "preserve": ["request_id", "project_id", "incident_id", "transaction_hash", "trace_id", "artifact_path", "command"], - "request_log": "pcl requests list --json" + "request_log": "pcl requests list --toon" }, "agent_files": { "repo_instructions": "AGENTS.md", @@ -1630,9 +1628,9 @@ async fn auth_capability_check(api_url: &url::Url) -> Value { "verify": "/api/v1/cli/auth/verify", }, "commands": [ - "pcl auth refresh --json", - "pcl auth login --no-wait --json", - "pcl auth status --json", + "pcl auth refresh --toon", + "pcl auth login --no-wait --toon", + "pcl auth status --toon", ], }) } @@ -1651,40 +1649,40 @@ fn workflow_recipes() -> Vec { "name": "incident-investigation", "description": "Export incidents, inspect failing detail/trace records, and preserve request IDs.", "steps": [ - {"command": "pcl doctor", "output": "environment readiness"}, - {"command": "pcl export incidents --project-id --environment production --out incidents.jsonl --errors errors.jsonl --resume", "output": "incident JSONL artifact"}, - {"command": "pcl incidents --incident-id ", "output": "incident detail"}, - {"command": "pcl incidents --incident-id --tx-id ", "output": "transaction trace"}, - {"command": "pcl requests list --limit 20", "output": "API request IDs and status history"} + {"command": "pcl doctor --toon", "output": "environment readiness"}, + {"command": "pcl export incidents --project-id --environment production --out incidents.jsonl --errors errors.jsonl --resume --toon", "output": "incident JSONL artifact"}, + {"command": "pcl incidents --incident-id --toon", "output": "incident detail"}, + {"command": "pcl incidents --incident-id --tx-id --toon", "output": "transaction trace"}, + {"command": "pcl requests list --limit 20 --toon", "output": "API request IDs and status history"} ], }), json!({ "name": "deploy-release", "description": "Create release payloads, preview, create, and fetch deploy calldata.", "steps": [ - {"command": "pcl releases --project --body-template", "output": "release body contract"}, - {"command": "pcl releases --project --preview --body-file release.json", "output": "release preview"}, - {"command": "pcl releases --project --create --body-file release.json", "output": "created release"}, - {"command": "pcl releases --project --release-id --deploy-calldata --signer-address
", "output": "deployment calldata"} + {"command": "pcl releases --project --body-template --toon", "output": "release body contract"}, + {"command": "pcl releases --project --preview --body-file release.json --toon", "output": "release preview"}, + {"command": "pcl releases --project --create --body-file release.json --toon", "output": "created release"}, + {"command": "pcl releases --project --release-id --deploy-calldata --signer-address
--toon", "output": "deployment calldata"} ], }), json!({ "name": "invite-member", "description": "Invite a project member and inspect pending invitations.", "steps": [ - {"command": "pcl access --project --invite --body-template", "output": "invite body contract"}, - {"command": "pcl access --project --invite --body-file invite.json", "output": "invitation result"}, - {"command": "pcl access --project --invitations", "output": "project invitations"} + {"command": "pcl access --project --invite --body-template --toon", "output": "invite body contract"}, + {"command": "pcl access --project --invite --body-file invite.json --toon", "output": "invitation result"}, + {"command": "pcl access --project --invitations --toon", "output": "project invitations"} ], }), json!({ "name": "protocol-manager-transfer", "description": "Inspect manager state, produce transfer calldata, and confirm transfer variants.", "steps": [ - {"command": "pcl protocol-manager --project --pending-transfer", "output": "pending transfer"}, - {"command": "pcl protocol-manager --project --nonce --address ", "output": "manager nonce"}, - {"command": "pcl protocol-manager --project --transfer-calldata --new-manager
", "output": "transfer calldata"}, - {"command": "pcl protocol-manager --confirm-transfer --body-template", "output": "direct/onchain confirm variants"} + {"command": "pcl protocol-manager --project --pending-transfer --toon", "output": "pending transfer"}, + {"command": "pcl protocol-manager --project --nonce --address --toon", "output": "manager nonce"}, + {"command": "pcl protocol-manager --project --transfer-calldata --new-manager
--toon", "output": "transfer calldata"}, + {"command": "pcl protocol-manager --confirm-transfer --body-template --toon", "output": "direct/onchain confirm variants"} ], }), ] @@ -2026,8 +2024,8 @@ mod tests { fn llms_guide_advertises_cli_native_surfaces() { let guide = llms_guide(); - assert_eq!(guide["default_output"], "toon"); - assert_eq!(guide["format_flag"], "--format toon|json"); + assert_eq!(guide["default_output"], "human"); + assert_eq!(guide["toon_flag"], "--toon"); assert_eq!(guide["no_mcp_required"], true); assert_eq!(guide["agent_files"]["repo_instructions"], "AGENTS.md"); assert!( @@ -2035,14 +2033,14 @@ mod tests { .as_array() .unwrap() .iter() - .any(|command| command == "pcl --llms") + .any(|command| command == "pcl --toon --llms") ); assert!( guide["consumption_order"] .as_array() .unwrap() .iter() - .any(|command| command == "pcl api manifest --json") + .any(|command| command == "pcl api manifest --toon") ); assert!( guide["command_surfaces"]["state"] diff --git a/scripts/agent-smoke.sh b/scripts/agent-smoke.sh index fd5d13d..ee9792f 100755 --- a/scripts/agent-smoke.sh +++ b/scripts/agent-smoke.sh @@ -18,7 +18,7 @@ email = "agent-smoke@example.com" CONFIG json_envelope() { - "$bin" --config-dir "$config_dir" --format json "$@" | python3 -c 'import json, sys + "$bin" --config-dir "$config_dir" --json "$@" | python3 -c 'import json, sys doc = json.load(sys.stdin) assert doc.get("schema_version") == "pcl.envelope.v1", doc assert doc.get("status") in {"ok", "warning", "pending", "action_required"}, doc @@ -26,26 +26,22 @@ assert doc.get("status") in {"ok", "warning", "pending", "action_required"}, doc } toon_envelope() { - "$bin" --config-dir "$config_dir" --format toon "$@" | grep -q "schema_version: pcl.envelope.v1" + "$bin" --config-dir "$config_dir" --toon "$@" | grep -q "schema_version: pcl.envelope.v1" } -"$bin" --config-dir "$config_dir" --format json --llms | python3 -c 'import json, sys -doc = json.load(sys.stdin) -assert doc.get("schema_version") == "pcl.envelope.v1", doc -assert doc.get("status") == "ok", doc -' >/dev/null -json_envelope llms -json_envelope doctor --offline -json_envelope auth ensure -json_envelope whoami -json_envelope workflows -json_envelope workflows show incident-investigation -json_envelope schema list -json_envelope schema get incidents --action list_public -json_envelope api manifest -json_envelope api --dry-run --allow-unauthenticated call get '/health?limit=5' -json_envelope completions bash - +toon_envelope --llms +toon_envelope llms toon_envelope doctor --offline toon_envelope auth ensure +toon_envelope whoami +toon_envelope workflows +toon_envelope workflows show incident-investigation +toon_envelope schema list +toon_envelope schema get incidents --action list_public +toon_envelope api manifest toon_envelope api --dry-run --allow-unauthenticated call get '/health?limit=5' +toon_envelope completions bash + +json_envelope llms +json_envelope api manifest +json_envelope completions bash