From ebf722967488fc3858d3a7f5591a5defc46ef914 Mon Sep 17 00:00:00 2001 From: "Odysseas.eth" Date: Thu, 14 May 2026 19:15:19 -0400 Subject: [PATCH 1/4] Format review fixups --- crates/pcl/cli/src/cli.rs | 2 +- crates/pcl/cli/src/main.rs | 10 +++++----- crates/pcl/cli/tests/verify_cli.rs | 4 +--- crates/pcl/core/src/api/tests.rs | 15 ++++++++++++--- crates/pcl/core/src/verify.rs | 1 - 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/crates/pcl/cli/src/cli.rs b/crates/pcl/cli/src/cli.rs index 8b96158..e3c4ca8 100644 --- a/crates/pcl/cli/src/cli.rs +++ b/crates/pcl/cli/src/cli.rs @@ -292,8 +292,8 @@ mod tests { args.config, std::path::PathBuf::from("assertions/credible.toml") ); - assert!(!args.json); assert!(!args.yes); + assert!(cli.args.human_output()); } _ => panic!("expected apply command"), } diff --git a/crates/pcl/cli/src/main.rs b/crates/pcl/cli/src/main.rs index 99937ad..1239b46 100644 --- a/crates/pcl/cli/src/main.rs +++ b/crates/pcl/cli/src/main.rs @@ -19,11 +19,6 @@ use pcl_common::args::{ current_output_mode, set_current_output_mode, }; -#[cfg(feature = "credible")] -use pcl_core::{ - error::VerifyError, - verify::VerificationSummary, -}; use pcl_core::{ api::{ ApiCommandError, @@ -40,6 +35,11 @@ use pcl_core::{ output::command_for_mode, surface::ProductSurfaceError, }; +#[cfg(feature = "credible")] +use pcl_core::{ + error::VerifyError, + verify::VerificationSummary, +}; use pcl_phoundry::error::PhoundryError; use serde_json::{ Value, diff --git a/crates/pcl/cli/tests/verify_cli.rs b/crates/pcl/cli/tests/verify_cli.rs index 33c2164..bb32147 100644 --- a/crates/pcl/cli/tests/verify_cli.rs +++ b/crates/pcl/cli/tests/verify_cli.rs @@ -146,9 +146,7 @@ fn apply_dry_run_builds_and_verifies_fixture_payload_without_api() { fn apply_dry_run_json_preserves_failed_assertion_summary() { let project = fixture_project(); fs::write( - project - .path() - .join("assertions/src/NoArgsAssertion.a.sol"), + project.path().join("assertions/src/NoArgsAssertion.a.sol"), r#"// SPDX-License-Identifier: MIT pragma solidity ^0.8.28; diff --git a/crates/pcl/core/src/api/tests.rs b/crates/pcl/core/src/api/tests.rs index 47a4f46..4eff0bd 100644 --- a/crates/pcl/core/src/api/tests.rs +++ b/crates/pcl/core/src/api/tests.rs @@ -1446,13 +1446,19 @@ fn raw_operations_advertise_workflow_alternatives_when_available() { assert_eq!(project_detail.len(), 1); assert_eq!(project_detail[0]["workflow"], "projects"); assert_eq!(project_detail[0]["action"], "detail"); - assert_eq!(project_detail[0]["example"], "pcl projects show "); + assert_eq!( + project_detail[0]["example"], + "pcl projects show " + ); let saved_delete = workflow_alternatives(HttpMethod::Delete, "/projects/saved"); assert_eq!(saved_delete.len(), 1); assert_eq!(saved_delete[0]["workflow"], "projects"); assert_eq!(saved_delete[0]["action"], "unsave"); - assert_eq!(saved_delete[0]["example"], "pcl projects unsave "); + assert_eq!( + saved_delete[0]["example"], + "pcl projects unsave " + ); let project_literal = workflow_alternatives(HttpMethod::Get, "/projects/project-1"); assert_eq!(project_literal.len(), 1); @@ -2931,7 +2937,10 @@ fn dry_run_auth_recovery_only_suggests_body_templates_for_mutations() { })); assert_eq!( get["next_actions"], - json!(["pcl auth ensure --toon", "Authenticate before removing --dry-run"]) + json!([ + "pcl auth ensure --toon", + "Authenticate before removing --dry-run" + ]) ); let post = dry_run_envelope(json!({ diff --git a/crates/pcl/core/src/verify.rs b/crates/pcl/core/src/verify.rs index e062912..2434932 100644 --- a/crates/pcl/core/src/verify.rs +++ b/crates/pcl/core/src/verify.rs @@ -66,7 +66,6 @@ pub struct VerifyArgs { #[arg(long, num_args = 1.., help = "Constructor arguments for the assertion")] pub args: Vec, - } struct VerifyInput { From 51004bd16f5d820b37c4dea28e203045943d239b Mon Sep 17 00:00:00 2001 From: "Odysseas.eth" Date: Fri, 15 May 2026 14:58:06 -0400 Subject: [PATCH 2/4] Resolve main rebase conflicts --- crates/pcl/cli/src/main.rs | 2 +- crates/pcl/core/src/error.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/pcl/cli/src/main.rs b/crates/pcl/cli/src/main.rs index 1239b46..92886e6 100644 --- a/crates/pcl/cli/src/main.rs +++ b/crates/pcl/cli/src/main.rs @@ -405,7 +405,7 @@ fn verify_error_envelope(err: &VerifyError) -> Value { &["pcl build --help", "pcl verify --help"], ) } - VerifyError::AbiEncode(_) => { + VerifyError::BytecodeHex(_) | VerifyError::ConstructorAbi(_) => { ( "verify.invalid_constructor_args", err.to_string(), diff --git a/crates/pcl/core/src/error.rs b/crates/pcl/core/src/error.rs index 346c6ec..3861698 100644 --- a/crates/pcl/core/src/error.rs +++ b/crates/pcl/core/src/error.rs @@ -1,9 +1,9 @@ +#[cfg(feature = "credible")] +use crate::verify::VerificationSummary; use crate::{ abi::ConstructorAbiError, credible_config::CredibleConfigError, }; -#[cfg(feature = "credible")] -use crate::verify::VerificationSummary; use chrono::{ DateTime, Utc, From 2a61d05a1defcb415f7fd3267b6126cba86b77ce Mon Sep 17 00:00:00 2001 From: "Odysseas.eth" Date: Tue, 19 May 2026 20:05:21 -0400 Subject: [PATCH 3/4] Refactor API workflow contracts --- README.md | 2 +- crates/pcl/cli/Cargo.toml | 2 +- crates/pcl/cli/src/cli.rs | 24 +- crates/pcl/cli/src/main.rs | 110 +- crates/pcl/cli/tests/auth_output.rs | 94 ++ crates/pcl/cli/tests/parse_output.rs | 105 +- crates/pcl/cli/tests/verify_cli.rs | 42 +- crates/pcl/core/src/api.rs | 499 +++++-- crates/pcl/core/src/api/definitions.rs | 266 ++++ crates/pcl/core/src/api/manifest.rs | 229 +--- crates/pcl/core/src/api/openapi.rs | 164 ++- crates/pcl/core/src/api/tests.rs | 670 +++++++++- crates/pcl/core/src/api/workflows.rs | 1173 ++--------------- crates/pcl/core/src/api/workflows/access.rs | 157 +++ crates/pcl/core/src/api/workflows/account.rs | 59 + .../pcl/core/src/api/workflows/assertions.rs | 145 ++ .../pcl/core/src/api/workflows/contracts.rs | 169 +++ .../pcl/core/src/api/workflows/deployments.rs | 56 + crates/pcl/core/src/api/workflows/events.rs | 54 + .../pcl/core/src/api/workflows/incidents.rs | 172 +++ .../core/src/api/workflows/integrations.rs | 94 ++ crates/pcl/core/src/api/workflows/projects.rs | 186 +++ .../src/api/workflows/protocol_manager.rs | 172 +++ crates/pcl/core/src/api/workflows/releases.rs | 183 +++ crates/pcl/core/src/api/workflows/search.rs | 159 +++ .../pcl/core/src/api/workflows/transfers.rs | 77 ++ crates/pcl/core/src/apply.rs | 91 +- crates/pcl/core/src/auth.rs | 34 +- crates/pcl/core/src/client.rs | 135 +- crates/pcl/core/src/config.rs | 20 +- crates/pcl/core/src/download.rs | 373 ++++-- crates/pcl/core/src/error.rs | 8 + crates/pcl/core/src/output/actions.rs | 65 +- crates/pcl/core/src/output/human.rs | 85 +- crates/pcl/core/src/output/mod.rs | 1 + crates/pcl/core/src/surface.rs | 322 ++++- crates/pcl/core/src/verify.rs | 17 +- scripts/agent-smoke.sh | 45 +- 38 files changed, 4568 insertions(+), 1691 deletions(-) create mode 100644 crates/pcl/core/src/api/definitions.rs create mode 100644 crates/pcl/core/src/api/workflows/access.rs create mode 100644 crates/pcl/core/src/api/workflows/account.rs create mode 100644 crates/pcl/core/src/api/workflows/assertions.rs create mode 100644 crates/pcl/core/src/api/workflows/contracts.rs create mode 100644 crates/pcl/core/src/api/workflows/deployments.rs create mode 100644 crates/pcl/core/src/api/workflows/events.rs create mode 100644 crates/pcl/core/src/api/workflows/incidents.rs create mode 100644 crates/pcl/core/src/api/workflows/integrations.rs create mode 100644 crates/pcl/core/src/api/workflows/projects.rs create mode 100644 crates/pcl/core/src/api/workflows/protocol_manager.rs create mode 100644 crates/pcl/core/src/api/workflows/releases.rs create mode 100644 crates/pcl/core/src/api/workflows/search.rs create mode 100644 crates/pcl/core/src/api/workflows/transfers.rs diff --git a/README.md b/README.md index c9bf69a..8840447 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ 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 --toon --llms` for the current CLI-native agent guide. -2. `pcl doctor --toon` and `pcl whoami --toon` for readiness and token truthfulness. +2. `pcl doctor --toon`, `pcl auth ensure --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`. diff --git a/crates/pcl/cli/Cargo.toml b/crates/pcl/cli/Cargo.toml index bb34f76..b207239 100644 --- a/crates/pcl/cli/Cargo.toml +++ b/crates/pcl/cli/Cargo.toml @@ -25,7 +25,7 @@ mockito = "1.2" tempfile = { workspace = true } [features] -default = [] +default = ["credible"] credible = ["pcl-phoundry/credible", "pcl-core/credible"] full = ["credible"] diff --git a/crates/pcl/cli/src/cli.rs b/crates/pcl/cli/src/cli.rs index e3c4ca8..b71f5a6 100644 --- a/crates/pcl/cli/src/cli.rs +++ b/crates/pcl/cli/src/cli.rs @@ -9,6 +9,8 @@ use pcl_common::args::{ current_output_mode, }; #[cfg(feature = "credible")] +use pcl_core::apply::ApplyArgs; +#[cfg(feature = "credible")] use pcl_core::verify::VerifyArgs; use pcl_core::{ DEFAULT_PLATFORM_URL, @@ -29,7 +31,6 @@ use pcl_core::{ TransfersCommand, with_envelope_metadata, }, - apply::ApplyArgs, auth::AuthCommand, config::ConfigArgs, download::DownloadArgs, @@ -135,6 +136,7 @@ pub enum Commands { Completions(CompletionsArgs), #[command(about = "Manage configuration")] Config(ConfigArgs), + #[cfg(feature = "credible")] #[command(name = "apply")] Apply(ApplyArgs), #[cfg(feature = "credible")] @@ -199,13 +201,23 @@ impl CompletionsArgs { if output_mode == OutputMode::Human { print!("{script}"); } else { - let envelope = with_envelope_metadata(json!({ - "status": "ok", - "data": { + let data = if output_mode == OutputMode::Json { + json!({ "shell": self.shell.to_string(), "script": script, "install_note": "Run without --toon/--json and redirect stdout into your shell completion directory.", - }, + }) + } else { + json!({ + "shell": self.shell.to_string(), + "script_omitted": true, + "script_bytes": script.len(), + "install_note": "Run without --toon/--json and redirect stdout into your shell completion directory, or use --json only when an installer expects the script inside an envelope.", + }) + }; + let envelope = with_envelope_metadata(json!({ + "status": "ok", + "data": data, "next_actions": [ format!("pcl completions {} > ", self.shell), ], @@ -278,6 +290,7 @@ mod tests { assert!(matches!(cli.command, Commands::Config(_))); } + #[cfg(feature = "credible")] #[test] fn parses_apply_command() { let cli = @@ -433,6 +446,7 @@ mod tests { )); } + #[cfg(feature = "credible")] #[test] fn parses_apply_command_with_custom_config() { let cli = Cli::try_parse_from([ diff --git a/crates/pcl/cli/src/main.rs b/crates/pcl/cli/src/main.rs index 92886e6..20a61a2 100644 --- a/crates/pcl/cli/src/main.rs +++ b/crates/pcl/cli/src/main.rs @@ -87,26 +87,19 @@ async fn main() -> Result<()> { } err.exit(); } - if matches!( + let is_success_display = matches!( err.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion - ) && output_mode != OutputMode::Json - { - err.exit(); - } - if output_mode == OutputMode::Json { - let exit_code = err.exit_code(); - eprintln!( - "{}", - serde_json::to_string_pretty(&clap_error_envelope(&err, &raw_args))? - ); - std::process::exit(exit_code); - } - eprint!( - "{}", - envelope_output_string(&clap_error_envelope(&err, &raw_args), false)? ); - std::process::exit(err.exit_code()); + let exit_code = err.exit_code(); + let envelope = clap_error_envelope(&err, &raw_args); + let output = envelope_output_string(&envelope, output_mode == OutputMode::Json)?; + if is_success_display { + print!("{output}"); + } else { + eprint!("{output}"); + } + std::process::exit(exit_code); } }; set_current_output_mode(cli.args.output_mode()); @@ -170,6 +163,7 @@ async fn run_command( ensure_human_pass_through(cli_args, "pcl test")?; phorge.run().await?; } + #[cfg(feature = "credible")] Commands::Apply(apply) => apply.run(cli_args, config).await?, Commands::Api(api) => api.run(config, cli_args, json_output).await?, Commands::Incidents(command) => command.run(config, cli_args, json_output).await?, @@ -216,7 +210,7 @@ fn ensure_human_pass_through( return Ok(()); } Err(ProductSurfaceError::InvalidInput(format!( - "{command} is a developer pass-through command and does not support --toon/--json yet. Use human output, or use pcl verify/apply for structured assertion workflows." + "{command} is a developer pass-through command and does not support --toon/--json yet. Use human output, or use pcl verify/apply from a credible-enabled build for structured assertion workflows." ))) } @@ -288,6 +282,20 @@ fn apply_error_envelope(err: &ApplyError) -> Value { &["pcl auth login", "pcl auth status"], ) } + ApplyError::ExpiredAuthToken(_) => { + ( + "auth.expired_token", + err.to_string(), + &["pcl auth refresh --toon", "pcl auth login --force"], + ) + } + ApplyError::AuthRefresh(_) => { + ( + "auth.refresh_failed", + err.to_string(), + &["pcl auth refresh --toon", "pcl auth login --force"], + ) + } ApplyError::InvalidConfig(message) if message.contains("credible.toml not found") => { ( "config.credible_toml_not_found", @@ -330,6 +338,32 @@ fn apply_error_envelope(err: &ApplyError) -> Value { } fn download_error_envelope(err: &DownloadError) -> Value { + if let DownloadError::Api { + endpoint, + status, + request_id, + body, + } = err + { + return json!({ + "status": "error", + "error": { + "code": "download.api_failed", + "message": err.to_string(), + "recoverable": true, + "request_id": request_id, + "http": { + "method": "GET", + "path": endpoint, + "status": status, + "request_id": request_id, + "body": body, + }, + }, + "next_actions": ["pcl download --help", "pcl doctor"], + }); + } + let (code, message, next_actions): (&str, String, &[&str]) = match err { DownloadError::NoAuthToken => { ( @@ -338,6 +372,20 @@ fn download_error_envelope(err: &DownloadError) -> Value { &["pcl auth login", "pcl auth status"], ) } + DownloadError::ExpiredAuthToken(_) => { + ( + "auth.expired_token", + err.to_string(), + &["pcl auth refresh --toon", "pcl auth login --force"], + ) + } + DownloadError::AuthRefresh(_) => { + ( + "auth.refresh_failed", + err.to_string(), + &["pcl auth refresh --toon", "pcl auth login --force"], + ) + } DownloadError::MissingIdentifier => { ( "download.missing_project_id", @@ -796,6 +844,19 @@ fn clap_error_envelope(err: &clap::Error, args: &[OsString]) -> Value { let command = parsed_command_name(args); let message = clap_error_message(err, command.as_deref()); let next_actions = clap_error_next_actions(err.kind(), command.as_deref()); + if matches!( + err.kind(), + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion + ) { + return with_envelope_metadata(json!({ + "status": "ok", + "data": { + "kind": clap_error_code(err.kind()), + "message": message, + }, + "next_actions": next_actions, + })); + } with_envelope_metadata(json!({ "status": "error", "error": { @@ -1039,6 +1100,19 @@ mod tests { assert!(!output.contains('\u{1b}')); } + #[test] + fn wraps_clap_help_as_success_envelope() { + let err = Cli::command() + .try_get_matches_from(["pcl", "--help"]) + .unwrap_err(); + let args = vec![OsString::from("pcl"), OsString::from("--help")]; + let envelope = clap_error_envelope(&err, &args); + + assert_eq!(envelope["status"], "ok"); + assert_eq!(envelope["data"]["kind"], "cli.help"); + assert!(envelope["error"].is_null()); + } + #[test] fn wraps_runtime_errors_as_toon_errors() { let err = Report::new(ApiCommandError::NoAuthToken); diff --git a/crates/pcl/cli/tests/auth_output.rs b/crates/pcl/cli/tests/auth_output.rs index d43261a..28f70aa 100644 --- a/crates/pcl/cli/tests/auth_output.rs +++ b/crates/pcl/cli/tests/auth_output.rs @@ -595,6 +595,100 @@ fn auth_login_json_fresh_flow_outputs_pending_and_terminal_events() { auth_status.assert(); } +#[test] +fn auth_login_toon_fresh_flow_returns_challenge_without_polling() { + let temp_dir = tempfile::tempdir().expect("create temp config dir"); + let mut server = mockito::Server::new(); + let auth_code = server + .mock("GET", "/api/v1/cli/auth/code") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"code":"123456","sessionId":"550e8400-e29b-41d4-a716-446655440000","deviceSecret":"test_secret","expiresAt":"2099-12-31T00:00:00Z"}"#, + ) + .expect(1) + .create(); + + let output = Command::new(env!("CARGO_BIN_EXE_pcl")) + .env("PCL_AUTH_NO_BROWSER", "1") + .args([ + "--config-dir", + temp_dir.path().to_str().expect("utf-8 temp path"), + "--toon", + "auth", + "--auth-url", + &server.url(), + "login", + ]) + .output() + .expect("run pcl auth login --toon"); + + assert!( + output.status.success(), + "command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.stderr.is_empty(), + "unexpected stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); + assert!(stdout.contains("status: action_required"), "{stdout}"); + assert!(stdout.contains("poll_command:"), "{stdout}"); + assert!(stdout.contains("--toon"), "{stdout}"); + assert!(!stdout.contains("auth.login_instructions"), "{stdout}"); + auth_code.assert(); +} + +#[test] +fn auth_login_toon_with_expired_auth_outputs_only_envelope() { + let temp_dir = tempfile::tempdir().expect("create temp config dir"); + write_expired_refreshable_auth_config(temp_dir.path()); + let mut server = mockito::Server::new(); + let auth_code = server + .mock("GET", "/api/v1/cli/auth/code") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"code":"123456","sessionId":"550e8400-e29b-41d4-a716-446655440000","deviceSecret":"test_secret","expiresAt":"2099-12-31T00:00:00Z"}"#, + ) + .expect(1) + .create(); + + let output = Command::new(env!("CARGO_BIN_EXE_pcl")) + .env("PCL_AUTH_NO_BROWSER", "1") + .args([ + "--config-dir", + temp_dir.path().to_str().expect("utf-8 temp path"), + "--toon", + "auth", + "--auth-url", + &server.url(), + "login", + ]) + .output() + .expect("run pcl auth login --toon"); + + assert!( + output.status.success(), + "command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.stderr.is_empty(), + "unexpected stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); + assert!(stdout.starts_with("status: action_required\n"), "{stdout}"); + assert!( + !stdout.contains("Stored auth token expired"), + "machine output was polluted: {stdout}" + ); + auth_code.assert(); +} + #[test] fn auth_login_force_starts_fresh_flow_even_with_existing_auth() { let temp_dir = tempfile::tempdir().expect("create temp config dir"); diff --git a/crates/pcl/cli/tests/parse_output.rs b/crates/pcl/cli/tests/parse_output.rs index 90360c9..9569476 100644 --- a/crates/pcl/cli/tests/parse_output.rs +++ b/crates/pcl/cli/tests/parse_output.rs @@ -143,6 +143,39 @@ fn subcommand_help_advertises_global_json_mode() { assert_help("verify"); } +#[test] +fn machine_help_requests_stay_structured() { + let toon = run_pcl(&["projects", "--help", "--toon"]); + toon.assert_success(); + assert!(toon.stderr.is_empty(), "{}", toon.stderr); + assert!(toon.stdout.starts_with("status: ok\n"), "{}", toon.stdout); + assert!(toon.stdout.contains("kind: cli.help"), "{}", toon.stdout); + assert!( + toon.stdout.contains("schema_version: pcl.envelope.v1"), + "{}", + toon.stdout + ); + + let root_toon = run_pcl(&["--help", "--toon"]); + root_toon.assert_success(); + assert!(root_toon.stderr.is_empty(), "{}", root_toon.stderr); + assert!( + root_toon.stdout.starts_with("status: ok\n"), + "{}", + root_toon.stdout + ); + assert!( + root_toon.stdout.contains("kind: cli.help"), + "{}", + root_toon.stdout + ); + assert!( + root_toon.stdout.contains("schema_version: pcl.envelope.v1"), + "{}", + root_toon.stdout + ); +} + #[test] fn new_workflow_subcommands_parse_and_emit_structured_dry_runs() { for args in [ @@ -185,6 +218,8 @@ fn new_workflow_subcommands_parse_and_emit_structured_dry_runs() { "--dry-run", ] .as_slice(), + ["--json", "releases", "deploy", "--body-template"].as_slice(), + ["--json", "access", "invite", "--body-template"].as_slice(), ] { let output = run_pcl(args); @@ -242,6 +277,25 @@ fn machine_parse_errors_stay_structured() { assert_eq!(envelope["status"], "error"); assert_eq!(envelope["error"]["code"], "cli.argument_conflict"); assert_eq!(envelope["schema_version"], "pcl.envelope.v1"); + let next_actions = envelope["next_actions"] + .as_array() + .expect("next actions array"); + assert!( + next_actions.iter().all(|action| { + action + .as_str() + .is_none_or(|action| !action.contains("--toon")) + }), + "{envelope}" + ); + assert!( + next_actions.iter().any(|action| { + action + .as_str() + .is_some_and(|action| action.contains(" --json")) + }), + "{envelope}" + ); let toon_at_end = run_pcl(&["projects", "--mine", "--saved", "--toon"]); assert_toon_error(&toon_at_end, "cli.argument_conflict"); @@ -308,6 +362,43 @@ fn documented_agent_leaf_commands_accept_toon_after_subcommands() { } } +#[test] +fn schema_list_exposes_output_contract_summary() { + let output = run_pcl(&["schema", "list", "--json"]); + + output.assert_success(); + assert!( + output.stderr.is_empty(), + "schema list should write JSON to stdout: {}", + output.stderr + ); + + let envelope: serde_json::Value = serde_json::from_str(&output.stdout).expect("json envelope"); + let schemas = envelope["data"]["schemas"] + .as_array() + .expect("schemas array"); + assert_eq!(schemas.len(), 13); + assert!( + schemas.iter().all(|schema| schema["workflow"] != "api"), + "{schemas:?}" + ); + let deployments = schemas + .iter() + .find(|schema| schema["workflow"] == "deployments") + .expect("deployments schema"); + + assert_eq!( + deployments["output_policy"], + "machine_raw_human_compact_artifacts" + ); + assert!( + deployments["output"] + .as_str() + .is_some_and(|output| output.contains("deployment")), + "{deployments:?}" + ); +} + #[test] fn llms_machine_next_actions_leave_completion_redirect_raw() { let completion_install = @@ -349,7 +440,13 @@ fn llms_machine_next_actions_leave_completion_redirect_raw() { fn completions_machine_next_action_is_raw_redirect() { let output = run_pcl(&["--toon", "completions", "bash"]); output.assert_success(); - assert!(output.stdout.contains("script:"), "{}", output.stdout); + assert!( + output.stdout.contains("script_omitted: true"), + "{}", + output.stdout + ); + assert!(output.stdout.contains("script_bytes:"), "{}", output.stdout); + assert!(!output.stdout.contains("_pcl()"), "{}", output.stdout); assert!( output .stdout @@ -366,6 +463,12 @@ fn completions_machine_next_action_is_raw_redirect() { let json = run_pcl(&["--json", "completions", "bash"]); json.assert_success(); let envelope: serde_json::Value = serde_json::from_str(&json.stdout).expect("json envelope"); + assert!( + envelope["data"]["script"] + .as_str() + .is_some_and(|script| script.contains("_pcl()")), + "{envelope}" + ); assert_eq!( envelope["next_actions"], serde_json::json!(["pcl completions bash > "]) diff --git a/crates/pcl/cli/tests/verify_cli.rs b/crates/pcl/cli/tests/verify_cli.rs index bb32147..8b7b88f 100644 --- a/crates/pcl/cli/tests/verify_cli.rs +++ b/crates/pcl/cli/tests/verify_cli.rs @@ -1,11 +1,11 @@ -#[cfg(feature = "full")] +#[cfg(feature = "credible")] use std::{ fs, path::Path, process::Command, }; -#[cfg(feature = "full")] +#[cfg(feature = "credible")] fn copy_dir(from: &Path, to: &Path) { fs::create_dir_all(to).expect("create fixture destination"); for entry in fs::read_dir(from).expect("read fixture directory") { @@ -20,7 +20,7 @@ fn copy_dir(from: &Path, to: &Path) { } } -#[cfg(feature = "full")] +#[cfg(feature = "credible")] fn fixture_project() -> tempfile::TempDir { let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/verify-project"); let temp_dir = tempfile::tempdir().expect("create temp project"); @@ -28,7 +28,7 @@ fn fixture_project() -> tempfile::TempDir { temp_dir } -#[cfg(feature = "full")] +#[cfg(feature = "credible")] fn assert_verify_success(output: std::process::Output) { assert!( output.status.success(), @@ -52,9 +52,17 @@ fn assert_verify_success(output: std::process::Output) { summary["assertions"][0]["triggers"]["0x0f04ec21"], "allCall" ); + let next_action = envelope["next_actions"][0] + .as_str() + .expect("next action string"); + assert!(next_action.contains("pcl apply --root "), "{next_action}"); + assert!( + next_action.contains("--config assertions/credible.toml"), + "{next_action}" + ); } -#[cfg(feature = "full")] +#[cfg(feature = "credible")] fn assert_command_success(output: &std::process::Output, command: &str) { assert!( output.status.success(), @@ -64,7 +72,7 @@ fn assert_command_success(output: &std::process::Output, command: &str) { ); } -#[cfg(feature = "full")] +#[cfg(feature = "credible")] #[test] fn build_cli_succeeds_for_fixture_project() { let project = fixture_project(); @@ -81,7 +89,7 @@ fn build_cli_succeeds_for_fixture_project() { assert_command_success(&output, "pcl build"); } -#[cfg(feature = "full")] +#[cfg(feature = "credible")] #[test] fn test_cli_succeeds_for_fixture_project() { let project = fixture_project(); @@ -98,7 +106,7 @@ fn test_cli_succeeds_for_fixture_project() { assert_command_success(&output, "pcl test"); } -#[cfg(feature = "full")] +#[cfg(feature = "credible")] #[test] fn apply_dry_run_builds_and_verifies_fixture_payload_without_api() { let project = fixture_project(); @@ -139,15 +147,23 @@ fn apply_dry_run_builds_and_verifies_fixture_payload_without_api() { .as_str() .is_some_and(|bytecode| bytecode.starts_with("0x")) ); + let root = fs::canonicalize(project.path()).expect("canonical fixture root"); + assert_eq!( + envelope["next_actions"][0], + format!( + "pcl apply --root {} --config assertions/credible.toml --yes --json", + root.display() + ) + ); } -#[cfg(feature = "full")] +#[cfg(feature = "credible")] #[test] fn apply_dry_run_json_preserves_failed_assertion_summary() { let project = fixture_project(); fs::write( project.path().join("assertions/src/NoArgsAssertion.a.sol"), - r#"// SPDX-License-Identifier: MIT + r"// SPDX-License-Identifier: MIT pragma solidity ^0.8.28; abstract contract Assertion { @@ -161,7 +177,7 @@ contract NoArgsAssertion is Assertion { return true; } } -"#, +", ) .expect("write failing assertion fixture"); @@ -197,7 +213,7 @@ contract NoArgsAssertion is Assertion { ); } -#[cfg(feature = "full")] +#[cfg(feature = "credible")] #[test] fn verify_cli_succeeds_for_explicit_fixture_assertion() { let project = fixture_project(); @@ -216,7 +232,7 @@ fn verify_cli_succeeds_for_explicit_fixture_assertion() { assert_verify_success(output); } -#[cfg(feature = "full")] +#[cfg(feature = "credible")] #[test] fn verify_cli_succeeds_for_credible_toml_fixture() { let project = fixture_project(); diff --git a/crates/pcl/core/src/api.rs b/crates/pcl/core/src/api.rs index 52361e1..0a175db 100644 --- a/crates/pcl/core/src/api.rs +++ b/crates/pcl/core/src/api.rs @@ -17,7 +17,10 @@ use clap::{ ArgGroup, ValueEnum, }; -use pcl_common::args::CliArgs; +use pcl_common::args::{ + CliArgs, + OutputMode, +}; use reqwest::header::{ HeaderMap, HeaderName, @@ -39,6 +42,7 @@ use std::{ str::FromStr, }; +mod definitions; mod manifest; mod openapi; mod render; @@ -57,6 +61,10 @@ pub use render::{ toon_string, }; +use definitions::{ + WorkflowOutputPolicy, + workflow_output_policy, +}; use openapi::{ api_coverage, command_next_actions, @@ -98,6 +106,8 @@ use workflows::{ account_request, assertions_next_actions, assertions_request, + compact_deployment_data, + contracts_next_actions, contracts_request, deployments_request, events_request, @@ -107,11 +117,14 @@ use workflows::{ project_segment, projects_next_actions, projects_request, + protocol_manager_next_actions, protocol_manager_request, + releases_next_actions, releases_request, request_body, search_next_actions, search_request, + transfers_next_actions, transfers_request, }; @@ -505,7 +518,6 @@ impl ApiCommandError { let mut envelope = json!({ "status": "error", "error": error, - "recoverable": self.recoverable(), "suggested_next_actions": self.suggested_next_actions(), "next_actions": self.next_actions(), }); @@ -949,6 +961,7 @@ struct WorkflowRequest { query: Vec<(String, String)>, body: Option, require_auth: bool, + attach_auth: bool, next_actions: Vec, } @@ -973,9 +986,15 @@ impl WorkflowRequest { query, body: None, require_auth, + attach_auth: require_auth, next_actions: next_actions.into_iter().map(Into::into).collect(), } } + + fn with_optional_auth(mut self) -> Self { + self.attach_auth = true; + self + } } #[derive(clap::Args, Debug)] @@ -1688,7 +1707,7 @@ struct ReleaseRefArgs { #[derive(clap::Args, Debug)] struct ReleaseProjectBodyArgs { #[arg(value_name = "PROJECT")] - project: String, + project: Option, #[command(flatten)] body: WorkflowBodyArgs, } @@ -1696,9 +1715,9 @@ struct ReleaseProjectBodyArgs { #[derive(clap::Args, Debug)] struct ReleaseBodyArgs { #[arg(value_name = "PROJECT")] - project: String, + project: Option, #[arg(value_name = "RELEASE_ID")] - release_id: String, + release_id: Option, #[command(flatten)] body: WorkflowBodyArgs, } @@ -1706,11 +1725,11 @@ struct ReleaseBodyArgs { #[derive(clap::Args, Debug)] struct ReleaseRetryCheckArgs { #[arg(value_name = "PROJECT")] - project: String, + project: Option, #[arg(value_name = "RELEASE_ID")] - release_id: String, + release_id: Option, #[arg(value_name = "CHECK_ID")] - check_id: String, + check_id: Option, #[command(flatten)] body: WorkflowBodyArgs, } @@ -1759,7 +1778,7 @@ impl ReleasesCommand { impl ReleasesSubcommand { fn into_args(self) -> ReleasesArgs { match self { - Self::List(args) => release_project_args(args.project), + Self::List(args) => release_project_args(Some(args.project)), Self::Show(args) => release_ref_args(args.project, args.release_id), Self::Create(args) => { let mut release_args = release_project_args(args.project); @@ -1774,13 +1793,13 @@ impl ReleasesSubcommand { release_args } Self::Deploy(args) => { - let mut release_args = release_ref_args(args.project, args.release_id); + let mut release_args = release_ref_args_optional(args.project, args.release_id); release_args.deploy = true; args.body.apply_to_release(&mut release_args); release_args } Self::Remove(args) => { - let mut release_args = release_ref_args(args.project, args.release_id); + let mut release_args = release_ref_args_optional(args.project, args.release_id); release_args.remove = true; args.body.apply_to_release(&mut release_args); release_args @@ -1806,9 +1825,9 @@ impl ReleasesSubcommand { release_args } Self::RetryCheck(args) => { - let mut release_args = release_ref_args(args.project, args.release_id); + let mut release_args = release_ref_args_optional(args.project, args.release_id); release_args.retry_check = true; - release_args.check_id = Some(args.check_id); + release_args.check_id = args.check_id; args.body.apply_to_release(&mut release_args); release_args } @@ -1816,17 +1835,25 @@ impl ReleasesSubcommand { } } -fn release_project_args(project: String) -> ReleasesArgs { +fn release_project_args(project: Option) -> ReleasesArgs { + ReleasesArgs { + project, + ..ReleasesArgs::default() + } +} + +fn release_ref_args(project: impl Into, release_id: impl Into) -> ReleasesArgs { ReleasesArgs { - project: Some(project), + project: Some(project.into()), + release_id: Some(release_id.into()), ..ReleasesArgs::default() } } -fn release_ref_args(project: String, release_id: String) -> ReleasesArgs { +fn release_ref_args_optional(project: Option, release_id: Option) -> ReleasesArgs { ReleasesArgs { - project: Some(project), - release_id: Some(release_id), + project, + release_id, ..ReleasesArgs::default() } } @@ -1972,7 +1999,7 @@ struct AccessTokenArgs { #[derive(clap::Args, Debug)] struct AccessTokenBodyArgs { #[arg(value_name = "TOKEN")] - token: String, + token: Option, #[command(flatten)] body: WorkflowBodyArgs, } @@ -1980,7 +2007,7 @@ struct AccessTokenBodyArgs { #[derive(clap::Args, Debug)] struct AccessProjectBodyArgs { #[arg(value_name = "PROJECT")] - project: String, + project: Option, #[command(flatten)] body: WorkflowBodyArgs, } @@ -1988,9 +2015,9 @@ struct AccessProjectBodyArgs { #[derive(clap::Args, Debug)] struct AccessInvitationArgs { #[arg(value_name = "PROJECT")] - project: String, + project: Option, #[arg(value_name = "INVITATION_ID")] - invitation_id: String, + invitation_id: Option, #[command(flatten)] body: WorkflowBodyArgs, } @@ -2022,9 +2049,9 @@ enum AccessMemberSubcommand { #[derive(clap::Args, Debug)] struct AccessMemberBodyArgs { #[arg(value_name = "PROJECT")] - project: String, + project: Option, #[arg(value_name = "MEMBER_USER_ID")] - member_user_id: String, + member_user_id: Option, #[command(flatten)] body: WorkflowBodyArgs, } @@ -2078,7 +2105,7 @@ impl AccessSubcommand { } Self::Accept(args) => { let mut access_args = AccessArgs { - token: Some(args.token), + token: args.token, accept: true, ..AccessArgs::default() }; @@ -2087,7 +2114,7 @@ impl AccessSubcommand { } Self::Invite(args) => { let mut access_args = AccessArgs { - project: Some(args.project), + project: args.project, invite: true, ..AccessArgs::default() }; @@ -2137,18 +2164,18 @@ impl AccessSubcommand { } } -fn access_invitation_args(project: String, invitation_id: String) -> AccessArgs { +fn access_invitation_args(project: Option, invitation_id: Option) -> AccessArgs { AccessArgs { - project: Some(project), - invitation_id: Some(invitation_id), + project, + invitation_id, ..AccessArgs::default() } } -fn access_member_args(project: String, member_user_id: String) -> AccessArgs { +fn access_member_args(project: Option, member_user_id: Option) -> AccessArgs { AccessArgs { - project: Some(project), - member_user_id: Some(member_user_id), + project, + member_user_id, ..AccessArgs::default() } } @@ -2426,7 +2453,13 @@ impl ApiArgs { return Ok(()); } let output = self - .run_workflow(config, cli_args, account_request(args)?, &request_log_path) + .run_workflow( + config, + cli_args, + "account", + account_request(args)?, + &request_log_path, + ) .await?; print_output(&output, json_output)?; } @@ -2437,12 +2470,7 @@ impl ApiArgs { return Ok(()); } let output = self - .run_workflow( - config, - cli_args, - contracts_request(args)?, - &request_log_path, - ) + .run_contracts(config, cli_args, args, &request_log_path) .await?; print_output(&output, json_output)?; } @@ -2453,7 +2481,7 @@ impl ApiArgs { return Ok(()); } let output = self - .run_workflow(config, cli_args, releases_request(args)?, &request_log_path) + .run_releases(config, cli_args, args, &request_log_path) .await?; print_output(&output, json_output)?; } @@ -2464,12 +2492,7 @@ impl ApiArgs { return Ok(()); } let output = self - .run_workflow( - config, - cli_args, - deployments_request(args)?, - &request_log_path, - ) + .run_deployments(config, cli_args, args, &request_log_path) .await?; print_output(&output, json_output)?; } @@ -2480,7 +2503,13 @@ impl ApiArgs { return Ok(()); } let output = self - .run_workflow(config, cli_args, access_request(args)?, &request_log_path) + .run_workflow( + config, + cli_args, + "access", + access_request(args)?, + &request_log_path, + ) .await?; print_output(&output, json_output)?; } @@ -2494,6 +2523,7 @@ impl ApiArgs { .run_workflow( config, cli_args, + "integrations", integrations_request(args)?, &request_log_path, ) @@ -2507,12 +2537,7 @@ impl ApiArgs { return Ok(()); } let output = self - .run_workflow( - config, - cli_args, - protocol_manager_request(args)?, - &request_log_path, - ) + .run_protocol_manager(config, cli_args, args, &request_log_path) .await?; print_output(&output, json_output)?; } @@ -2523,18 +2548,19 @@ impl ApiArgs { return Ok(()); } let output = self - .run_workflow( - config, - cli_args, - transfers_request(args)?, - &request_log_path, - ) + .run_transfers(config, cli_args, args, &request_log_path) .await?; print_output(&output, json_output)?; } ApiCommand::Events(args) => { let output = self - .run_workflow(config, cli_args, events_request(args)?, &request_log_path) + .run_workflow( + config, + cli_args, + "events", + events_request(args)?, + &request_log_path, + ) .await?; print_output(&output, json_output)?; } @@ -2901,16 +2927,15 @@ impl ApiArgs { return Ok(template_envelope(project_body_template(args))); } let request = projects_request(args)?; - if self.dry_run { - return Ok(dry_run_envelope( - self.workflow_request_plan(&request, None, config), - )); - } - let result = self - .call_workflow_result(config, cli_args, &request, request_log_path) - .await?; - let next_actions = projects_next_actions(&result.body, request.next_actions); - Ok(workflow_success_envelope(result, next_actions)) + self.run_prepared_workflow( + config, + cli_args, + "projects", + request, + request_log_path, + projects_next_actions, + ) + .await } async fn run_assertions( @@ -2924,16 +2949,15 @@ impl ApiArgs { return Ok(template_envelope(body_template("empty_object"))); } let request = assertions_request(args)?; - if self.dry_run { - return Ok(dry_run_envelope( - self.workflow_request_plan(&request, None, config), - )); - } - let result = self - .call_workflow_result(config, cli_args, &request, request_log_path) - .await?; - let next_actions = assertions_next_actions(&result.body, args, request.next_actions); - Ok(workflow_success_envelope(result, next_actions)) + self.run_prepared_workflow( + config, + cli_args, + "assertions", + request, + request_log_path, + |data, fallback| assertions_next_actions(data, args, fallback), + ) + .await } async fn run_search( @@ -2944,25 +2968,143 @@ impl ApiArgs { request_log_path: &Path, ) -> Result { let request = search_request(args)?; - if self.dry_run { - return Ok(dry_run_envelope( - self.workflow_request_plan(&request, None, config), - )); - } - let result = self - .call_workflow_result(config, cli_args, &request, request_log_path) - .await?; - let next_actions = search_next_actions(&result.body, request.next_actions); - Ok(workflow_success_envelope(result, next_actions)) + self.run_prepared_workflow( + config, + cli_args, + "search", + request, + request_log_path, + search_next_actions, + ) + .await + } + + async fn run_contracts( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + args: &ContractsArgs, + request_log_path: &Path, + ) -> Result { + let request = contracts_request(args)?; + self.run_prepared_workflow( + config, + cli_args, + "contracts", + request, + request_log_path, + |data, fallback| contracts_next_actions(data, args, fallback), + ) + .await + } + + async fn run_releases( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + args: &ReleasesArgs, + request_log_path: &Path, + ) -> Result { + let request = releases_request(args)?; + self.run_prepared_workflow( + config, + cli_args, + "releases", + request, + request_log_path, + |data, fallback| releases_next_actions(data, args, fallback), + ) + .await + } + + async fn run_deployments( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + args: &DeploymentsArgs, + request_log_path: &Path, + ) -> Result { + let request = deployments_request(args)?; + self.run_prepared_workflow( + config, + cli_args, + "deployments", + request, + request_log_path, + |_data, fallback| fallback, + ) + .await + } + + async fn run_transfers( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + args: &TransfersArgs, + request_log_path: &Path, + ) -> Result { + let request = transfers_request(args)?; + self.run_prepared_workflow( + config, + cli_args, + "transfers", + request, + request_log_path, + |data, fallback| transfers_next_actions(data, args, fallback), + ) + .await + } + + async fn run_protocol_manager( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + args: &ProtocolManagerArgs, + request_log_path: &Path, + ) -> Result { + let request = protocol_manager_request(args)?; + self.run_prepared_workflow( + config, + cli_args, + "protocol-manager", + request, + request_log_path, + |data, fallback| protocol_manager_next_actions(data, args, fallback), + ) + .await } async fn run_workflow( &self, config: &mut CliConfig, cli_args: &CliArgs, + workflow: &'static str, request: WorkflowRequest, request_log_path: &Path, ) -> Result { + self.run_prepared_workflow( + config, + cli_args, + workflow, + request, + request_log_path, + |_data, fallback| fallback, + ) + .await + } + + async fn run_prepared_workflow( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + workflow: &'static str, + request: WorkflowRequest, + request_log_path: &Path, + next_actions_for: F, + ) -> Result + where + F: FnOnce(&Value, Vec) -> Vec, + { if self.dry_run { return Ok(dry_run_envelope( self.workflow_request_plan(&request, None, config), @@ -2971,7 +3113,13 @@ impl ApiArgs { let result = self .call_workflow_result(config, cli_args, &request, request_log_path) .await?; - Ok(workflow_success_envelope(result, request.next_actions)) + let next_actions = next_actions_for(&result.body, request.next_actions); + let data = workflow_data_for_output_mode(workflow, &result.body, cli_args.output_mode()); + Ok(workflow_success_envelope_with_data( + result, + data, + next_actions, + )) } fn workflow_request_plan( @@ -3006,7 +3154,7 @@ impl ApiArgs { "path": request.path.as_str(), "query": query_pairs_value(&request.query), "body": body, - "auth": self.auth_plan(request.require_auth, config), + "auth": self.auth_plan(request.require_auth, request.attach_auth, config), "side_effecting": request.method != HttpMethod::Get, "destructive": destructive, "project_resolution": "not_performed", @@ -3038,7 +3186,7 @@ impl ApiArgs { "query": query_pairs_value(&query), "headers": query_pairs_value(&header), "body": body.unwrap_or(Value::Null), - "auth": self.auth_plan(input.require_auth, config), + "auth": self.auth_plan(input.require_auth, input.require_auth, config), "side_effecting": input.method != HttpMethod::Get, "destructive": destructive, }, @@ -3054,7 +3202,7 @@ impl ApiArgs { })) } - fn auth_plan(&self, require_auth: bool, config: &CliConfig) -> Value { + fn auth_plan(&self, require_auth: bool, attach_auth: bool, config: &CliConfig) -> Value { let now = chrono::Utc::now(); let stored_token_present = config .auth @@ -3065,7 +3213,7 @@ impl ApiArgs { .as_ref() .is_some_and(|auth| !auth.access_token.trim().is_empty() && auth.expires_at > now); let will_attach_stored_token = - require_auth && !self.allow_unauthenticated && stored_token_valid; + attach_auth && !self.allow_unauthenticated && stored_token_valid; json!({ "required": require_auth, "will_attach_stored_token": will_attach_stored_token, @@ -3090,8 +3238,27 @@ impl ApiArgs { async fn fetch_openapi(&self, config: &CliConfig) -> Result { let url = self.api_url("/openapi")?; let request = self.http_client(config, false, false)?.get(url); - let response = request.send().await?.error_for_status()?; - Ok(response.json().await?) + let response = request.send().await?; + let status = response.status(); + let request_id = request_id_from_headers(response.headers()); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let bytes = response.bytes().await?; + let body = response_body_value(&content_type, &bytes); + if !status.is_success() { + return Err(ApiCommandError::HttpStatus { + method: "GET", + path: "/openapi".to_string(), + status: status.as_u16(), + request_id, + body: Box::new(body), + }); + } + Ok(body) } async fn try_refresh_after_401( @@ -3282,13 +3449,32 @@ impl ApiArgs { request: &WorkflowRequest, request_log_path: &Path, ) -> Result { - let path = self.normalize_project_path(config, &request.path).await?; - let url = self.api_url(&path)?; let requires_auth = request.require_auth && !self.allow_unauthenticated; self.ensure_request_auth(config, cli_args, request.require_auth) .await?; + let attach_auth = self.workflow_attach_auth(request, config); + let path = self + .normalize_project_path( + config, + &request.path, + attach_auth, + requires_auth, + request_log_path, + ) + .await?; + let url = self.api_url(&path)?; let json_body = if let Some(body) = &request.body { - Some(self.normalize_request_body(config, &path, body).await?) + Some( + self.normalize_request_body( + config, + &path, + body, + attach_auth, + requires_auth, + request_log_path, + ) + .await?, + ) } else { None }; @@ -3358,7 +3544,7 @@ impl ApiArgs { "method": request.method.as_str(), "path": path, "query": query_pairs_value(&request.query), - "auth": self.auth_plan(request.require_auth, config), + "auth": self.auth_plan(request.require_auth, request.attach_auth, config), "side_effecting": request.method != HttpMethod::Get, "destructive": request_is_destructive(request.method, &request.path), "retried_after_refresh": retried_after_refresh, @@ -3448,13 +3634,24 @@ impl ApiArgs { config: &CliConfig, path: &str, body: &str, + attach_auth: bool, + require_auth: bool, + request_log_path: &Path, ) -> Result { let mut json_body: Value = serde_json::from_str(body)?; if path == "/projects/saved" && let Some(project_ref) = json_body.get("project_id").and_then(Value::as_str) && project_ref.parse::().is_err() { - let project_id = self.resolve_project_id(config, project_ref).await?; + let project_id = self + .resolve_project_id( + config, + project_ref, + attach_auth, + require_auth, + request_log_path, + ) + .await?; if let Some(object) = json_body.as_object_mut() { object.insert("project_id".to_string(), Value::String(project_id)); } @@ -3466,6 +3663,9 @@ impl ApiArgs { &self, config: &CliConfig, path: &str, + attach_auth: bool, + require_auth: bool, + request_log_path: &Path, ) -> Result { let Some((prefix, project_ref, suffix)) = project_segment(path) else { return Ok(path.to_string()); @@ -3473,7 +3673,15 @@ impl ApiArgs { if project_ref.parse::().is_ok() { return Ok(path.to_string()); } - let project_id = self.resolve_project_id(config, project_ref).await?; + let project_id = self + .resolve_project_id( + config, + project_ref, + attach_auth, + require_auth, + request_log_path, + ) + .await?; Ok(format!("{prefix}{project_id}{suffix}")) } @@ -3481,16 +3689,42 @@ impl ApiArgs { &self, config: &CliConfig, project_ref: &str, + attach_auth: bool, + require_auth: bool, + request_log_path: &Path, ) -> Result { - let url = self.api_url(&format!("/projects/resolve/{project_ref}"))?; - let client = self.http_client(config, false, false)?; - let response: Value = client - .get(url) - .send() - .await? - .error_for_status()? - .json() - .await?; + let path = format!("/projects/resolve/{project_ref}"); + let url = self.api_url(&path)?; + let client = self.http_client(config, attach_auth, require_auth)?; + let response = client.get(url).send().await?; + let status = response.status(); + let request_id = request_id_from_headers(response.headers()); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let bytes = response.bytes().await?; + let response = response_body_value(&content_type, &bytes); + write_request_log( + request_log_path, + "workflow_project_resolution", + "GET", + &path, + status.as_u16(), + request_id.as_deref(), + Some("get_projects_resolve_project_ref"), + ); + if !status.is_success() { + return Err(ApiCommandError::HttpStatus { + method: "GET", + path, + status: status.as_u16(), + request_id, + body: Box::new(response), + }); + } response .get("project_id") .or_else(|| response.get("projectId")) @@ -3552,7 +3786,8 @@ impl ApiArgs { body: Option<&Value>, ) -> Result { let requires_auth = request.require_auth && !self.allow_unauthenticated; - let client = self.http_client(config, requires_auth, requires_auth)?; + let attach_auth = self.workflow_attach_auth(request, config); + let client = self.http_client(config, attach_auth, requires_auth)?; let mut builder = client.request(request.method.reqwest(), url.clone()); if !request.query.is_empty() { builder = builder.query(&request.query); @@ -3563,6 +3798,19 @@ impl ApiArgs { Ok(builder.send().await?) } + fn workflow_attach_auth(&self, request: &WorkflowRequest, config: &CliConfig) -> bool { + if self.allow_unauthenticated { + return false; + } + if request.require_auth { + return true; + } + request.attach_auth + && config.auth.as_ref().is_some_and(|auth| { + !auth.access_token.trim().is_empty() && auth.expires_at > chrono::Utc::now() + }) + } + fn http_client( &self, config: &CliConfig, @@ -3641,7 +3889,7 @@ fn split_path_and_inline_query( Ok((path.to_string(), query)) } -fn request_id_from_headers(headers: &HeaderMap) -> Option { +pub(crate) fn request_id_from_headers(headers: &HeaderMap) -> Option { [ "x-request-id", "x-correlation-id", @@ -3770,6 +4018,29 @@ fn workflow_success_envelope(result: WorkflowCallResult, next_actions: Vec, +) -> Value { + with_envelope_metadata(json!({ + "status": "ok", + "data": data, + "request": result.request, + "response": result.response, + "next_actions": next_actions, + })) +} + +fn workflow_data_for_output_mode(workflow: &str, data: &Value, output_mode: OutputMode) -> Value { + match (workflow_output_policy(workflow), output_mode) { + (WorkflowOutputPolicy::MachineRawHumanCompactArtifacts, OutputMode::Human) => { + compact_deployment_data(data) + } + _ => data.clone(), + } +} + fn request_is_destructive(method: HttpMethod, path: &str) -> bool { method == HttpMethod::Delete || path.contains("/delete") diff --git a/crates/pcl/core/src/api/definitions.rs b/crates/pcl/core/src/api/definitions.rs new file mode 100644 index 0000000..ad953dd --- /dev/null +++ b/crates/pcl/core/src/api/definitions.rs @@ -0,0 +1,266 @@ +use crate::output::command_for_mode; +use pcl_common::args::OutputMode; +use serde_json::{ + Map, + Value, + json, +}; + +use super::workflows; + +// Workflow metadata lives here so schema, manifest, OpenAPI alternatives, and +// output policies cannot silently diverge as new API functionality is added. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum WorkflowOutputPolicy { + MachineRaw, + MachineRawHumanCompactArtifacts, +} + +impl WorkflowOutputPolicy { + pub(super) const fn as_str(self) -> &'static str { + match self { + Self::MachineRaw => "machine_raw", + Self::MachineRawHumanCompactArtifacts => "machine_raw_human_compact_artifacts", + } + } +} + +#[derive(Debug)] +pub(super) struct WorkflowDefinition { + pub(super) name: &'static str, + pub(super) command: &'static str, + pub(in crate::api) description: &'static str, + pub(in crate::api) output: &'static str, + pub(super) output_policy: WorkflowOutputPolicy, + pub(in crate::api) legacy_examples: &'static [&'static str], + pub(super) actions: &'static [WorkflowActionDefinition], +} + +#[derive(Debug)] +pub(super) struct WorkflowActionDefinition { + pub(super) name: &'static str, + pub(super) auth: bool, + pub(super) method: &'static str, + pub(super) path: &'static str, + pub(in crate::api) required_flags: &'static [&'static str], + pub(in crate::api) optional_flags: &'static [&'static str], + pub(in crate::api) required_body_fields: &'static [&'static str], + pub(in crate::api) body_template: Option<&'static str>, + pub(in crate::api) query: &'static [(&'static str, &'static str)], + pub(in crate::api) legacy_aliases: &'static [&'static str], + pub(in crate::api) example: &'static str, +} + +impl WorkflowDefinition { + fn manifest_value(&self) -> Value { + let mut object = Map::new(); + object.insert("command".to_string(), json!(self.command)); + object.insert("description".to_string(), json!(self.description)); + object.insert("output".to_string(), json!(self.output)); + object.insert( + "output_policy".to_string(), + json!(self.output_policy.as_str()), + ); + if !self.legacy_examples.is_empty() { + object.insert( + "legacy_examples".to_string(), + string_array(self.legacy_examples, true), + ); + } + object.insert( + "actions".to_string(), + Value::Array( + self.actions + .iter() + .map(WorkflowActionDefinition::manifest_value) + .collect(), + ), + ); + Value::Object(object) + } +} + +impl WorkflowActionDefinition { + fn manifest_value(&self) -> Value { + let mut object = Map::new(); + object.insert("name".to_string(), json!(self.name)); + object.insert("auth".to_string(), json!(self.auth)); + object.insert("method".to_string(), json!(self.method)); + object.insert("path".to_string(), json!(self.path)); + if !self.required_flags.is_empty() { + object.insert( + "required_flags".to_string(), + string_array(self.required_flags, false), + ); + } + if !self.optional_flags.is_empty() { + object.insert( + "optional_flags".to_string(), + string_array(self.optional_flags, false), + ); + } + if !self.required_body_fields.is_empty() { + object.insert( + "required_body_fields".to_string(), + string_array(self.required_body_fields, false), + ); + } + if let Some(body_template) = self.body_template { + object.insert("body_template".to_string(), json!(body_template)); + } + if !self.query.is_empty() { + object.insert("query".to_string(), query_object(self.query)); + } + if !self.legacy_aliases.is_empty() { + object.insert( + "legacy_aliases".to_string(), + string_array(self.legacy_aliases, true), + ); + } + object.insert("example".to_string(), agent_command(self.example)); + Value::Object(object) + } + + pub(super) fn required_flags_value(&self) -> Value { + if self.required_flags.is_empty() { + Value::Null + } else { + string_array(self.required_flags, false) + } + } + + pub(super) fn body_template_value(&self) -> Value { + self.body_template.map_or(Value::Null, |value| json!(value)) + } + + pub(super) fn example_for_operation(&self, workflow: &str, operation_path: &str) -> String { + let mut example = agent_command(self.example) + .as_str() + .unwrap_or_default() + .to_string(); + if workflow == "integrations" { + if operation_path.contains("/integrations/pagerduty") { + example = example.replace("--provider slack", "--provider pagerduty"); + } else if operation_path.contains("/integrations/slack") { + example = example.replace("--provider pagerduty", "--provider slack"); + } + } + example + } +} + +pub(super) fn workflow_definitions() -> &'static [WorkflowDefinition] { + WORKFLOW_DEFINITIONS +} + +pub(super) fn workflow_definition(name: &str) -> Option<&'static WorkflowDefinition> { + WORKFLOW_DEFINITIONS + .iter() + .find(|definition| definition.name == name) +} + +pub(super) fn workflow_output_policy(name: &str) -> WorkflowOutputPolicy { + workflow_definition(name).map_or(WorkflowOutputPolicy::MachineRaw, |definition| { + definition.output_policy + }) +} + +pub(super) fn command_manifests() -> Vec { + WORKFLOW_DEFINITIONS + .iter() + .map(WorkflowDefinition::manifest_value) + .chain(raw_api_command_manifests()) + .collect() +} + +pub(super) fn agent_examples() -> Vec { + [ + "pcl incidents --limit 5", + "pcl search --query settler", + "pcl releases list ", + "pcl access members ", + "pcl integrations --project --provider slack", + "pcl api list --filter incidents", + ] + .into_iter() + .map(agent_command) + .collect() +} + +fn raw_api_command_manifests() -> Vec { + vec![ + json!({ + "command": "pcl api manifest", + "description": "Print this agent-readable command manifest.", + }), + json!({ + "command": "pcl api list [--filter ] [--method ]", + "description": "List OpenAPI operations with executable inspect and call commands.", + "output": "operations[] with operation_id, method, path, summary, tags, workflow_alternatives, raw_api_use, inspect_command, call_command", + }), + json!({ + "command": "pcl api inspect | [--full]", + "description": "Inspect a compact operation manifest. Use --full for raw OpenAPI.", + "output": "operation_id, method, path, auth metadata, workflow_alternatives, raw_api_use, path_params, required_query, body_fields, required_body_fields, body_template, response_statuses, example_call", + }), + json!({ + "command": "pcl api call [--query key=value] [--field key=value] [--body-file body.json] [--paginate ] [--page-param page] [--limit-param limit] [--jsonl] [--output ] [--dry-run]", + "description": "Execute any endpoint below /api/v1. Query strings in PATH and repeated --query flags are both accepted; --field merges simple JSON object body fields; GET calls can paginate any array response with --paginate. Add --dry-run to print the request plan without sending it.", + "output": "request and response status/body; non-2xx responses return structured error envelopes with request_id when the API provides one. Raw calls log operation_id when the live OpenAPI manifest can resolve the method/path.", + "actions": [ + {"name": "execute", "method": "*", "path": "", "auth": "default", "optional_flags": ["--dry-run"], "example": agent_command("pcl api call get /views/public/incidents --query limit=5 --allow-unauthenticated")}, + {"name": "paginate", "method": "GET", "path": "", "auth": "default", "required_flags": ["--paginate"], "optional_flags": ["--all", "--page", "--limit", "--page-param", "--limit-param", "--max-pages", "--jsonl", "--output"], "example": agent_command("pcl api call get /views/public/incidents --paginate incidents --limit 50 --allow-unauthenticated --output incidents.json")}, + {"name": "export_jsonl", "method": "GET", "path": "", "auth": "default", "required_flags": ["--paginate", "--jsonl", "--output"], "example": agent_command("pcl api call get /views/public/incidents --paginate incidents --limit 50 --allow-unauthenticated --jsonl --output incidents.jsonl")} + ], + }), + json!({ + "command": "pcl api coverage [--records ] [--markdown ]", + "description": "Audit local request history against the live OpenAPI surface. Old records are matched by method/path; new raw api calls also persist operation_id.", + "output": "total operations, by-method coverage, no-hit operations, hit-but-no-2xx operations, side-effecting no-2xx operations, unmatched records", + }), + ] +} + +fn string_array(values: &[&str], normalize_commands: bool) -> Value { + Value::Array( + values + .iter() + .map(|value| { + if normalize_commands && value.trim_start().starts_with("pcl ") { + agent_command(value) + } else { + json!(value) + } + }) + .collect(), + ) +} + +fn query_object(values: &[(&str, &str)]) -> Value { + Value::Object( + values + .iter() + .map(|(key, value)| ((*key).to_string(), json!(value))) + .collect(), + ) +} + +fn agent_command(command: &str) -> Value { + json!(command_for_mode(command, OutputMode::Toon)) +} + +const WORKFLOW_DEFINITIONS: &[WorkflowDefinition] = &[ + workflows::incidents::DEFINITION, + workflows::projects::DEFINITION, + workflows::assertions::DEFINITION, + workflows::search::DEFINITION, + workflows::account::DEFINITION, + workflows::contracts::DEFINITION, + workflows::releases::DEFINITION, + workflows::deployments::DEFINITION, + workflows::access::DEFINITION, + workflows::integrations::DEFINITION, + workflows::protocol_manager::DEFINITION, + workflows::transfers::DEFINITION, + workflows::events::DEFINITION, +]; diff --git a/crates/pcl/core/src/api/manifest.rs b/crates/pcl/core/src/api/manifest.rs index c93f77c..b74c333 100644 --- a/crates/pcl/core/src/api/manifest.rs +++ b/crates/pcl/core/src/api/manifest.rs @@ -1,11 +1,14 @@ -use serde_json::{ - Value, - json, -}; +use serde_json::json; -use super::spec::workflow_spec_summary; +use super::{ + definitions::{ + agent_examples, + command_manifests, + }, + spec::workflow_spec_summary, +}; -pub fn api_manifest() -> Value { +pub fn api_manifest() -> serde_json::Value { json!({ "name": "pcl", "description": "Use top-level workflow commands for product workflows; use pcl api list/inspect/call only for debugging, API parity checks, internal/service endpoints, or endpoints not yet promoted to a workflow.", @@ -55,217 +58,7 @@ pub fn api_manifest() -> Value { {"command": "pcl schema [list|get ] --toon", "description": "Inspect workflow/action schemas from the command manifest."}, {"command": "pcl completions ", "description": "Print raw shell completion scripts for bash, zsh, fish, powershell, and elvish. Use --json only when an installer expects the script inside an envelope."} ], - "commands": [ - { - "command": "pcl incidents [--project-id ] [--incident-id ] [--stats] [--limit ] [--all --output ]", - "description": "List public incidents, project incidents, fetch all incident pages, inspect incident detail, incident stats, or incident trace.", - "output": "incident data from /views/public/incidents, /views/projects/{projectId}/incidents, /views/incidents/{incidentId}, or /projects/{project_id}/incidents/stats", - "actions": [ - {"name": "list_public", "auth": false, "method": "GET", "path": "/views/public/incidents", "optional_flags": ["--page", "--limit", "--network", "--sort", "--dev-mode", "--all", "--max-pages", "--output"], "example": "pcl incidents --limit 5"}, - {"name": "list_project", "auth": true, "method": "GET", "path": "/views/projects/{projectId}/incidents", "required_flags": ["--project"], "optional_flags": ["--page", "--limit", "--assertion-id", "--adopter-id", "--environment", "--from", "--to", "--all", "--max-pages", "--output"], "example": "pcl incidents --project --all --limit 50 --output incidents.json"}, - {"name": "stats", "auth": true, "method": "GET", "path": "/projects/{project_id}/incidents/stats", "required_flags": ["--project"], "example": "pcl incidents --project --stats"}, - {"name": "detail", "auth": true, "method": "GET", "path": "/views/incidents/{incidentId}", "required_flags": ["--incident-id"], "example": "pcl incidents --incident-id "}, - {"name": "trace", "auth": true, "method": "GET", "path": "/views/incidents/{incidentId}/transactions/{txId}/trace", "required_flags": ["--incident-id", "--tx-id"], "example": "pcl incidents --incident-id --tx-id "}, - {"name": "retry_trace", "auth": true, "method": "POST", "path": "/incidents/{incident_id}/transactions/{tx_id}/trace/retry", "required_flags": ["--incident-id", "--tx-id"], "body_template": "empty_object", "example": "pcl incidents --incident-id --tx-id --retry-trace"} - ] - }, - { - "command": "pcl projects ", - "description": "List, inspect, create, update, save, unsave, resolve, widget, and delete projects.", - "output": "project explorer, your projects, project detail, saved projects, widget, or mutation result", - "legacy_examples": ["pcl projects --mine", "pcl projects --project ", "pcl projects --create --project-name demo --chain-id 1"], - "actions": [ - {"name": "explorer", "auth": false, "method": "GET", "path": "/views/projects", "example": "pcl projects list --limit 10"}, - {"name": "mine", "auth": true, "method": "GET", "path": "/views/projects/home", "example": "pcl projects mine", "legacy_aliases": ["pcl projects --mine", "pcl projects --home"]}, - {"name": "saved", "auth": true, "method": "GET", "path": "/projects/saved", "required_flags": ["--user-id"], "query": {"user_id": ""}, "example": "pcl projects saved --user-id "}, - {"name": "detail", "auth": true, "method": "GET", "path": "/projects/{project_id}", "required_flags": [""], "example": "pcl projects show "}, - {"name": "create", "auth": true, "method": "POST", "path": "/projects", "body_template": "project_create", "required_body_fields": ["project_name", "chain_id"], "example": "pcl projects create --project-name demo --chain-id 1"}, - {"name": "update", "auth": true, "method": "PUT", "path": "/projects/{project_id}", "required_flags": [""], "body_template": "project_update", "example": "pcl projects update --field github_url=https://github.com/org/repo"}, - {"name": "delete", "auth": true, "method": "DELETE", "path": "/projects/{project_id}", "required_flags": [""], "example": "pcl projects delete "}, - {"name": "save", "auth": true, "method": "POST", "path": "/projects/saved", "required_flags": [""], "body_template": "project_saved", "example": "pcl projects save "}, - {"name": "unsave", "auth": true, "method": "DELETE", "path": "/projects/saved", "required_flags": [""], "body_template": "project_saved", "example": "pcl projects unsave "}, - {"name": "resolve", "auth": false, "method": "GET", "path": "/projects/resolve/{project_ref}", "required_flags": [""], "example": "pcl projects resolve "}, - {"name": "widget", "auth": true, "method": "GET", "path": "/projects/{project_id}/widget", "required_flags": [""], "example": "pcl projects widget "} - ] - }, - { - "command": "pcl assertions --project [--assertion-id |--registered|--remove-info|--remove-calldata]", - "description": "List, inspect, and manage project assertion lifecycle state.", - "output": "assertion index/detail, registered assertions, or removal info/calldata", - "actions": [ - {"name": "index", "auth": true, "method": "GET", "path": "/views/projects/{projectId}/assertions", "required_flags": ["--project"], "example": "pcl assertions --project "}, - {"name": "detail", "auth": true, "method": "GET", "path": "/views/projects/{projectId}/assertions/{assertionId}", "required_flags": ["--project", "--assertion-id"], "example": "pcl assertions --project --assertion-id "}, - {"name": "adopter_lookup", "auth": false, "method": "GET", "path": "/assertions", "required_flags": ["--adopter-address"], "optional_flags": ["--network", "--environment", "--include-onchain-only"], "example": "pcl assertions --adopter-address 0x... --network 1"}, - {"name": "registered", "auth": true, "method": "GET", "path": "/projects/{project_id}/registered-assertions", "required_flags": ["--project"], "example": "pcl assertions --project --registered"}, - {"name": "remove_info", "auth": true, "method": "GET", "path": "/projects/{project_id}/remove-assertions-info", "required_flags": ["--project"], "example": "pcl assertions --project --remove-info"}, - {"name": "remove_calldata", "auth": true, "method": "GET", "path": "/projects/{project_id}/remove-assertions-calldata", "required_flags": ["--project"], "example": "pcl assertions --project --remove-calldata"} - ] - }, - { - "command": "pcl search [--query ] [--stats] [--system-status] [--verified-contract --address --chain-id ]", - "description": "Search projects/contracts and inspect platform metadata.", - "output": "search results, stats, system status, health, whitelist, or verified contract data", - "actions": [ - {"name": "query", "auth": false, "method": "GET", "path": "/search", "optional_flags": ["--query"], "example": "pcl search --query settler"}, - {"name": "stats", "auth": false, "method": "GET", "path": "/stats", "example": "pcl search --stats"}, - {"name": "system_status", "auth": false, "method": "GET", "path": "/system-status", "example": "pcl search --system-status"}, - {"name": "health", "auth": false, "method": "GET", "path": "/health", "example": "pcl search --health"}, - {"name": "whitelist", "auth": true, "method": "GET", "path": "/whitelist", "example": "pcl search --whitelist"}, - {"name": "verified_contract", "auth": false, "method": "GET", "path": "/web/verified-contract", "required_flags": ["--address", "--chain-id"], "example": "pcl search --verified-contract --address 0x... --chain-id 1"} - ] - }, - { - "command": "pcl account [--me|--accept-terms|--logout]", - "description": "Inspect authenticated web user state and perform onboarding actions.", - "output": "current user account state, terms acceptance result, or logout result", - "actions": [ - {"name": "me", "auth": true, "method": "GET", "path": "/web/auth/me", "example": "pcl account"}, - {"name": "accept_terms", "auth": true, "method": "POST", "path": "/web/auth/accept-terms", "body_template": "empty_object", "example": "pcl account --accept-terms"}, - {"name": "logout", "auth": true, "method": "POST", "path": "/web/auth/logout", "body_template": "empty_object", "example": "pcl account --logout"} - ] - }, - { - "command": "pcl contracts [--project ] [--adopter-id ] [--unassigned --manager
] [--create --body-template]", - "description": "List and manage project contracts and assertion adopters.", - "output": "contract views, adopter records, assignment results, or remove calldata", - "actions": [ - {"name": "list_all", "auth": true, "method": "GET", "path": "/assertion_adopters", "example": "pcl contracts"}, - {"name": "list_project", "auth": true, "method": "GET", "path": "/views/projects/{project}/contracts", "required_flags": ["--project"], "example": "pcl contracts --project "}, - {"name": "detail", "auth": true, "method": "GET", "path": "/views/projects/{project}/contracts/{adopter_id}", "required_flags": ["--project", "--adopter-id"], "example": "pcl contracts --project --adopter-id "}, - {"name": "unassigned", "auth": true, "method": "GET", "path": "/assertion_adopters/no-project", "required_flags": ["--manager"], "query": {"manager": ""}, "example": "pcl contracts --unassigned --manager 0x..."}, - {"name": "create", "auth": true, "method": "POST", "path": "/assertion_adopters", "body_template": "contracts", "example": "pcl contracts --create --body-template"}, - {"name": "assign_project", "auth": true, "method": "POST", "path": "/assertion_adopters/assign-project", "body_template": "contracts_assign_project", "example": "pcl contracts --assign-project --body-template"}, - {"name": "remove", "auth": true, "method": "DELETE", "path": "/projects/{project}/{aa_address}", "required_flags": ["--project", "--aa-address"], "example": "pcl contracts --project --aa-address 0x... --remove"}, - {"name": "remove_calldata", "auth": true, "method": "GET", "path": "/assertion_adopters/{aa_address}/remove-assertions-calldata", "required_flags": ["--aa-address", "--assertion-id"], "optional_flags": ["--network", "--environment"], "query": {"assertion_ids": "", "network": "", "environment": "production|staging"}, "example": "pcl contracts --aa-address 0x... --remove-calldata --network 1 --assertion-id 0x..."} - ] - }, - { - "command": "pcl releases ", - "description": "List, inspect, create, preview, deploy, check progress, retry failed checks, and remove releases.", - "output": "release data, diffs, check progress, deployment confirmations, or calldata", - "legacy_examples": ["pcl releases --project ", "pcl releases --project --release-id ", "pcl releases --project --preview --body-file release.json"], - "actions": [ - {"name": "list", "auth": true, "method": "GET", "path": "/projects/{project}/releases", "required_flags": [""], "example": "pcl releases list "}, - {"name": "detail", "auth": true, "method": "GET", "path": "/projects/{project}/releases/{release_id}", "required_flags": ["", ""], "example": "pcl releases show "}, - {"name": "preview", "auth": true, "method": "POST", "path": "/projects/{project}/releases/preview", "required_flags": [""], "body_template": "release", "example": "pcl releases preview --body-file release.json"}, - {"name": "create", "auth": true, "method": "POST", "path": "/projects/{project}/releases", "required_flags": [""], "body_template": "release", "example": "pcl releases create --body-file release.json"}, - {"name": "backtest_progress", "auth": true, "method": "GET", "path": "/projects/{project}/releases/{release_id}/backtest-progress", "required_flags": ["", ""], "example": "pcl releases backtest-progress "}, - {"name": "retry_check", "auth": true, "method": "POST", "path": "/projects/{project}/releases/{release_id}/checks/{check_id}/retry", "required_flags": ["", "", ""], "body_template": "empty_object", "example": "pcl releases retry-check "}, - {"name": "deploy_calldata", "auth": true, "method": "GET", "path": "/projects/{project}/releases/{release_id}/deploy-calldata", "required_flags": ["", "", "--signer-address"], "query": {"signerAddress": ""}, "example": "pcl releases calldata deploy --signer-address 0x..."}, - {"name": "deploy", "auth": true, "method": "POST", "path": "/projects/{project}/releases/{release_id}/deploy", "required_flags": ["", ""], "body_template": "release_deploy", "example": "pcl releases deploy --body-template"}, - {"name": "remove_calldata", "auth": true, "method": "GET", "path": "/projects/{project}/releases/{release_id}/remove-calldata", "required_flags": ["", ""], "example": "pcl releases calldata remove "}, - {"name": "remove", "auth": true, "method": "POST", "path": "/projects/{project}/releases/{release_id}/remove", "required_flags": ["", ""], "body_template": "release_remove", "example": "pcl releases remove --body-template"} - ] - }, - { - "command": "pcl deployments --project [--confirm --body-template]", - "description": "Inspect deployment state and confirm deployed assertions.", - "output": "deployment view or confirmation result", - "actions": [ - {"name": "list", "auth": true, "method": "GET", "path": "/views/projects/{project}/deployments", "required_flags": ["--project"], "example": "pcl deployments --project "}, - {"name": "confirm", "auth": true, "method": "POST", "path": "/projects/{project}/confirm-deployment", "required_flags": ["--project"], "body_template": "deployment_confirmation", "example": "pcl deployments --project --confirm --body-template"} - ] - }, - { - "command": "pcl access ", - "description": "Manage project members, roles, and invitations.", - "output": "member lists, invitation lists, role data, or mutation results", - "legacy_examples": ["pcl access --project --members", "pcl access --project --invite --body-template", "pcl access --token --preview"], - "actions": [ - {"name": "members", "auth": true, "method": "GET", "path": "/projects/{project}/members", "required_flags": [""], "example": "pcl access members "}, - {"name": "my_role", "auth": true, "method": "GET", "path": "/projects/{project}/my-role", "required_flags": [""], "example": "pcl access my-role "}, - {"name": "invitations", "auth": true, "method": "GET", "path": "/projects/{project}/invitations", "required_flags": [""], "example": "pcl access invitations "}, - {"name": "invite", "auth": true, "method": "POST", "path": "/projects/{project}/invitations", "required_flags": [""], "body_template": "access_invite", "example": "pcl access invite --body-template"}, - {"name": "resend", "auth": true, "method": "POST", "path": "/projects/{project}/invitations/{invitation_id}/resend", "required_flags": ["", ""], "body_template": "empty_object", "example": "pcl access resend "}, - {"name": "revoke", "auth": true, "method": "DELETE", "path": "/projects/{project}/invitations/{invitation_id}", "required_flags": ["", ""], "body_template": "empty_object", "example": "pcl access revoke "}, - {"name": "update_role", "auth": true, "method": "PATCH", "path": "/projects/{project}/members/{member_user_id}", "required_flags": ["", ""], "body_template": "role_update", "example": "pcl access role update --body-template"}, - {"name": "remove", "auth": true, "method": "DELETE", "path": "/projects/{project}/members/{member_user_id}", "required_flags": ["", ""], "body_template": "empty_object", "example": "pcl access member remove "}, - {"name": "pending", "auth": true, "method": "GET", "path": "/invitations/pending", "example": "pcl access pending"}, - {"name": "preview", "auth": false, "method": "GET", "path": "/invitations/{token}/preview", "required_flags": [""], "example": "pcl access preview "}, - {"name": "accept", "auth": true, "method": "POST", "path": "/invitations/{token}/accept", "required_flags": [""], "body_template": "empty_object", "example": "pcl access accept "} - ] - }, - { - "command": "pcl integrations --project --provider [--configure|--test|--delete]", - "description": "Manage Slack and PagerDuty integrations.", - "output": "integration status or mutation/test results", - "actions": [ - {"name": "get", "auth": true, "method": "GET", "path": "/projects/{project}/integrations/{provider}", "required_flags": ["--project", "--provider"], "example": "pcl integrations --project --provider slack"}, - {"name": "configure", "auth": true, "method": "POST", "path": "/projects/{project}/integrations/{provider}", "required_flags": ["--project", "--provider"], "body_template": "slack|pagerduty", "example": "pcl integrations --project --provider slack --configure --body-template"}, - {"name": "test", "auth": true, "method": "POST", "path": "/projects/{project}/integrations/{provider}/test", "required_flags": ["--project", "--provider"], "body_template": "slack|pagerduty", "example": "pcl integrations --project --provider slack --test"}, - {"name": "delete", "auth": true, "method": "DELETE", "path": "/projects/{project}/integrations/{provider}", "required_flags": ["--project", "--provider"], "example": "pcl integrations --project --provider slack --delete"} - ] - }, - { - "command": "pcl protocol-manager --project [--nonce --address
|--set|--clear|--transfer-calldata|--accept-calldata|--pending-transfer|--confirm-transfer]", - "description": "Manage protocol manager transfers and calldata.", - "output": "manager state, nonce, calldata, pending transfer, or mutation result", - "actions": [ - {"name": "pending_transfer", "auth": true, "method": "GET", "path": "/projects/{project}/protocol-manager/pending-transfer", "required_flags": ["--project"], "example": "pcl protocol-manager --project --pending-transfer"}, - {"name": "nonce", "auth": true, "method": "GET", "path": "/projects/{project}/protocol-manager/nonce", "required_flags": ["--project", "--address"], "optional_flags": ["--chain-id"], "query": {"address": "
", "chain_id": ""}, "example": "pcl protocol-manager --project --nonce --address 0x..."}, - {"name": "set", "auth": true, "method": "POST", "path": "/projects/{project}/protocol-manager", "required_flags": ["--project"], "body_template": "protocol_manager_set", "example": "pcl protocol-manager --project --set --body-template"}, - {"name": "clear", "auth": true, "method": "DELETE", "path": "/projects/{project}/protocol-manager", "required_flags": ["--project"], "body_template": "empty_object", "example": "pcl protocol-manager --project --clear"}, - {"name": "transfer_calldata", "auth": true, "method": "GET", "path": "/projects/{project}/protocol-manager/transfer-calldata", "required_flags": ["--project", "--new-manager"], "query": {"new_manager": "
"}, "example": "pcl protocol-manager --project --transfer-calldata --new-manager 0x..."}, - {"name": "accept_calldata", "auth": true, "method": "GET", "path": "/projects/{project}/protocol-manager/accept-calldata", "required_flags": ["--project"], "example": "pcl protocol-manager --project --accept-calldata"}, - {"name": "confirm_transfer", "auth": true, "method": "POST", "path": "/projects/{project}/protocol-manager/confirm-transfer", "required_flags": ["--project"], "body_template": "protocol_manager_confirm", "example": "pcl protocol-manager --project --confirm-transfer --body-template"} - ] - }, - { - "command": "pcl transfers [--pending|--transfer-id |--reject --body-template]", - "description": "Inspect and reject protocol manager transfers.", - "output": "pending transfers, transfer detail, or reject result", - "actions": [ - {"name": "pending", "auth": true, "method": "GET", "path": "/views/transfers/pending", "example": "pcl transfers --pending"}, - {"name": "detail", "auth": true, "method": "GET", "path": "/views/transfers/{transfer_id}", "required_flags": ["--transfer-id"], "example": "pcl transfers --transfer-id "}, - {"name": "reject", "auth": true, "method": "POST", "path": "/transfers/reject", "body_template": "transfer_reject", "example": "pcl transfers --reject --body-template"} - ] - }, - { - "command": "pcl events --project [--audit-log]", - "description": "Inspect project events and audit logs.", - "output": "event or audit log data", - "actions": [ - {"name": "events", "auth": true, "method": "GET", "path": "/views/projects/{project}/events", "required_flags": ["--project"], "optional_flags": ["--page", "--limit", "--environment"], "example": "pcl events --project "}, - {"name": "audit_log", "auth": true, "method": "GET", "path": "/views/projects/{project}/audit-log", "required_flags": ["--project"], "optional_flags": ["--page", "--limit", "--environment"], "example": "pcl events --project --audit-log"} - ] - }, - { - "command": "pcl api manifest", - "description": "Print this agent-readable command manifest.", - }, - { - "command": "pcl api list [--filter ] [--method ]", - "description": "List OpenAPI operations with executable inspect and call commands.", - "output": "operations[] with operation_id, method, path, summary, tags, workflow_alternatives, raw_api_use, inspect_command, call_command", - }, - { - "command": "pcl api inspect | [--full]", - "description": "Inspect a compact operation manifest. Use --full for raw OpenAPI.", - "output": "operation_id, method, path, auth metadata, workflow_alternatives, raw_api_use, path_params, required_query, body_fields, required_body_fields, body_template, response_statuses, example_call", - }, - { - "command": "pcl api call [--query key=value] [--field key=value] [--body-file body.json] [--paginate ] [--page-param page] [--limit-param limit] [--jsonl] [--output ] [--dry-run]", - "description": "Execute any endpoint below /api/v1. Query strings in PATH and repeated --query flags are both accepted; --field merges simple JSON object body fields; GET calls can paginate any array response with --paginate. Add --dry-run to print the request plan without sending it.", - "output": "request and response status/body; non-2xx responses return structured error envelopes with request_id when the API provides one. Raw calls log operation_id when the live OpenAPI manifest can resolve the method/path.", - "actions": [ - {"name": "execute", "method": "*", "path": "", "auth": "default", "optional_flags": ["--dry-run"], "example": "pcl api call get /views/public/incidents --query limit=5 --allow-unauthenticated"}, - {"name": "paginate", "method": "GET", "path": "", "auth": "default", "required_flags": ["--paginate"], "optional_flags": ["--all", "--page", "--limit", "--page-param", "--limit-param", "--max-pages", "--jsonl", "--output"], "example": "pcl api call get /views/public/incidents --paginate incidents --limit 50 --allow-unauthenticated --output incidents.json"}, - {"name": "export_jsonl", "method": "GET", "path": "", "auth": "default", "required_flags": ["--paginate", "--jsonl", "--output"], "example": "pcl api call get /views/public/incidents --paginate incidents --limit 50 --allow-unauthenticated --jsonl --output incidents.jsonl"} - ] - }, - { - "command": "pcl api coverage [--records ] [--markdown ]", - "description": "Audit local request history against the live OpenAPI surface. Old records are matched by method/path; new raw api calls also persist operation_id.", - "output": "total operations, by-method coverage, no-hit operations, hit-but-no-2xx operations, side-effecting no-2xx operations, unmatched records", - }, - ], - "examples": [ - "pcl incidents --limit 5", - "pcl search --query settler", - "pcl releases list ", - "pcl access members ", - "pcl integrations --project --provider slack", - "pcl api list --filter incidents", - ], + "commands": command_manifests(), + "examples": agent_examples(), }) } diff --git a/crates/pcl/core/src/api/openapi.rs b/crates/pcl/core/src/api/openapi.rs index 4fec5d1..37338f0 100644 --- a/crates/pcl/core/src/api/openapi.rs +++ b/crates/pcl/core/src/api/openapi.rs @@ -1,9 +1,11 @@ use super::{ ApiCommandError, HttpMethod, - api_manifest, + definitions::workflow_definitions, method_side_effecting, }; +use crate::output::command_for_mode; +use pcl_common::args::OutputMode; use serde::Serialize; use serde_json::{ Map, @@ -608,34 +610,12 @@ pub(super) fn workflow_alternatives(method: HttpMethod, path: &str) -> Vec Vec { - let Some(commands) = api_manifest() - .get("commands") - .and_then(Value::as_array) - .cloned() - else { - return Vec::new(); - }; - let mut alternatives = Vec::new(); let mut best_score = None; - for command in commands { - let Some(command_text) = command.get("command").and_then(Value::as_str) else { - continue; - }; - if command_text.starts_with("pcl api ") { - continue; - } - let workflow = command_text - .split_whitespace() - .nth(1) - .unwrap_or(command_text) - .to_string(); - let Some(actions) = command.get("actions").and_then(Value::as_array) else { - continue; - }; - - for action in actions { - let Some(score) = manifest_action_match_score(action, method, path) else { + for definition in workflow_definitions() { + for action in definition.actions { + let Some(score) = manifest_action_match_score(action.method, action.path, method, path) + else { continue; }; match best_score { @@ -647,15 +627,13 @@ fn manifest_workflow_alternatives(method: HttpMethod, path: &str) -> Vec None => best_score = Some(score), Some(_) => {} } - let action_name = action.get("name").and_then(Value::as_str); - let example = workflow_example_for_operation(&workflow, action.get("example"), path); alternatives.push(json!({ - "workflow": workflow, - "action": action_name, - "command": command_text, - "example": example, - "required_flags": action.get("required_flags").cloned().unwrap_or(Value::Null), - "body_template": action.get("body_template").cloned().unwrap_or(Value::Null), + "workflow": definition.name, + "action": action.name, + "command": definition.command, + "example": action.example_for_operation(definition.name, path), + "required_flags": action.required_flags_value(), + "body_template": action.body_template_value(), })); } } @@ -663,33 +641,17 @@ fn manifest_workflow_alternatives(method: HttpMethod, path: &str) -> Vec alternatives } -fn workflow_example_for_operation( - workflow: &str, - example: Option<&Value>, - operation_path: &str, -) -> Option { - let example = example.and_then(Value::as_str)?; - if workflow == "integrations" { - if operation_path.contains("/integrations/pagerduty") { - return Some(example.replace("--provider slack", "--provider pagerduty")); - } - if operation_path.contains("/integrations/slack") { - return Some(example.replace("--provider pagerduty", "--provider slack")); - } - } - Some(example.to_string()) -} - -fn manifest_action_match_score(action: &Value, method: HttpMethod, path: &str) -> Option { - let method_matches = action - .get("method") - .and_then(Value::as_str) - .is_some_and(|action_method| action_method.eq_ignore_ascii_case(method.as_str())); +fn manifest_action_match_score( + action_method: &str, + action_path: &str, + method: HttpMethod, + path: &str, +) -> Option { + let method_matches = action_method.eq_ignore_ascii_case(method.as_str()); if !method_matches { return None; } - let action_path = action.get("path").and_then(Value::as_str)?; path_match_score(action_path, path) } @@ -844,6 +806,7 @@ fn single_special_workflow(workflow: &str, action: &str, example: &str, note: &s } fn special_workflow(workflow: &str, action: &str, example: &str, note: &str) -> Value { + let example = command_for_mode(example, OutputMode::Toon); json!({ "workflow": workflow, "action": action, @@ -1319,15 +1282,22 @@ pub(super) fn next_actions_for_operations(operations: &[OperationSummary]) -> Ve operations.first().map_or_else( || vec!["pcl api list".to_string(), "pcl api manifest".to_string()], |operation| { - if let Some(example) = operation - .workflow_alternatives - .first() - .and_then(|alternative| alternative.get("example")) - .and_then(Value::as_str) + if let Some((safe_operation, example)) = operations + .iter() + .filter(|operation| !operation.requires_input) + .find_map(safe_workflow_example) { return vec![ - example.to_string(), - format!("{} --toon", operation.inspect_command), + example, + format!("{} --toon", safe_operation.inspect_command), + ]; + } + if let Some((safe_operation, example)) = + operations.iter().find_map(safe_workflow_example) + { + return vec![ + example, + format!("{} --toon", safe_operation.inspect_command), ]; } if operation.requires_input { @@ -1347,14 +1317,18 @@ pub(super) fn next_actions_for_operations(operations: &[OperationSummary]) -> Ve } pub(super) fn command_next_actions(inspected: &Value) -> Vec { - if let Some(example) = inspected + if let Some(example) = safe_inspected_workflow_example(inspected) { + return vec![example]; + } + if inspected .get("workflow_alternatives") .and_then(Value::as_array) - .and_then(|alternatives| alternatives.first()) - .and_then(|alternative| alternative.get("example")) - .and_then(Value::as_str) + .is_some_and(|alternatives| !alternatives.is_empty()) { - return vec![example.to_string()]; + return vec![ + "Review data.workflow_alternatives before running mutating workflow commands" + .to_string(), + ]; } inspected .get("example_call") @@ -1365,6 +1339,56 @@ pub(super) fn command_next_actions(inspected: &Value) -> Vec { ) } +fn safe_workflow_example(operation: &OperationSummary) -> Option<(&OperationSummary, String)> { + operation + .workflow_alternatives + .iter() + .filter_map(|alternative| { + alternative + .get("example") + .and_then(Value::as_str) + .map(str::to_string) + }) + .find(|example| safe_next_action_example(operation.method, example)) + .map(|example| (operation, example)) +} + +fn safe_inspected_workflow_example(inspected: &Value) -> Option { + let method = inspected + .get("method") + .and_then(Value::as_str) + .unwrap_or(""); + inspected + .get("workflow_alternatives") + .and_then(Value::as_array)? + .iter() + .filter_map(|alternative| alternative.get("example").and_then(Value::as_str)) + .find(|example| safe_next_action_example(method, example)) + .map(ToString::to_string) +} + +fn safe_next_action_example(method: &str, example: &str) -> bool { + !is_dangerous_workflow_example(example) + && (!method_side_effecting(method) || example.contains("--body-template")) +} + +fn is_dangerous_workflow_example(example: &str) -> bool { + let action = example.trim(); + let has_dangerous_flag = action.split_whitespace().any(|token| { + matches!( + token, + "--clear" | "--delete" | "--remove" | "--revoke" | "--logout" + ) + }); + has_dangerous_flag + || action.starts_with("pcl projects delete") + || action.starts_with("pcl projects unsave") + || action.starts_with("pcl releases remove") + || action.starts_with("pcl access revoke") + || action.starts_with("pcl access member remove") + || action.starts_with("pcl transfers reject") +} + pub(super) fn synthetic_operation_id(method: HttpMethod, path: &str) -> String { let mut id = method.openapi_key().to_string(); let mut previous_was_separator = false; diff --git a/crates/pcl/core/src/api/tests.rs b/crates/pcl/core/src/api/tests.rs index 4eff0bd..6641cd0 100644 --- a/crates/pcl/core/src/api/tests.rs +++ b/crates/pcl/core/src/api/tests.rs @@ -176,6 +176,18 @@ fn incidents_args() -> IncidentsArgs { } } +fn transfers_args() -> TransfersArgs { + TransfersArgs { + transfer_id: None, + pending: false, + reject: false, + body: None, + field: Vec::new(), + body_file: None, + body_template: false, + } +} + #[test] fn parses_key_values() { let parsed = parse_key_values("query", &["limit=5".to_string()]).unwrap(); @@ -292,6 +304,111 @@ fn openapi_call_commands_include_required_inputs() { ); } +#[test] +fn openapi_next_actions_prefer_runnable_safe_workflow_examples() { + let spec = json!({ + "paths": { + "/incidents/{incident_id}": { + "get": { + "operationId": "get_incidents_incident_id", + "summary": "Get incident details", + "parameters": [ + {"name": "incident_id", "in": "path", "required": true, "schema": {"type": "string"}} + ] + } + }, + "/views/public/incidents": { + "get": { + "operationId": "get_views_public_incidents", + "summary": "Get public incidents" + } + } + } + }); + + let operations = list_operations(&spec, Some("incidents"), Some(HttpMethod::Get)).unwrap(); + + assert_eq!( + next_actions_for_operations(&operations), + vec![ + "pcl incidents --limit 5 --toon".to_string(), + "pcl api inspect get_views_public_incidents --toon".to_string(), + ] + ); +} + +#[test] +fn openapi_next_actions_skip_destructive_workflow_examples() { + let spec = json!({ + "paths": { + "/projects/{project_id}/protocol-manager": { + "delete": { + "operationId": "delete_projects_project_id_protocol_manager", + "summary": "Clear protocol manager for a project", + "tags": ["projects"], + "parameters": [ + {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string"}} + ] + }, + "post": { + "operationId": "post_projects_project_id_protocol_manager", + "summary": "Set protocol manager for a project", + "tags": ["projects"], + "parameters": [ + {"name": "project_id", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "requestBody": { + "content": { + "application/json": { + "schema": {"type": "object"} + } + } + } + } + } + } + }); + + let operations = list_operations(&spec, Some("protocol-manager"), None).unwrap(); + + assert_eq!( + next_actions_for_operations(&operations), + vec![ + "pcl protocol-manager --project --set --body-template --toon".to_string(), + "pcl api inspect post_projects_project_id_protocol_manager --toon".to_string(), + ] + ); + + let inspected_delete = inspect_operation( + &spec, + "delete_projects_project_id_protocol_manager", + None, + false, + ) + .unwrap(); + assert_eq!( + command_next_actions(&inspected_delete), + vec!["Review data.workflow_alternatives before running mutating workflow commands"] + ); +} + +#[test] +fn openapi_next_actions_keep_safe_remove_calldata_examples() { + let inspected = json!({ + "method": "GET", + "workflow_alternatives": [ + { + "example": "pcl assertions --project-id --remove-calldata" + } + ] + }); + + assert_eq!( + command_next_actions(&inspected), + vec!["pcl assertions --project-id --remove-calldata"] + ); +} + #[test] fn public_openapi_call_commands_opt_out_of_local_auth() { let health = json!({}); @@ -733,6 +850,176 @@ async fn incident_workflow_pagination_rejects_zero_limit() { ); } +#[tokio::test] +async fn authenticated_project_slug_resolution_attaches_auth() { + let mut server = mockito::Server::new_async().await; + let project_id = "550e8400-e29b-41d4-a716-446655440000"; + let resolve = server + .mock("GET", "/api/v1/projects/resolve/private-slug") + .match_header("authorization", "Bearer access-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!(r#"{{"project_id":"{project_id}"}}"#)) + .expect(1) + .create_async() + .await; + let detail = server + .mock("GET", format!("/api/v1/projects/{project_id}").as_str()) + .match_header("authorization", "Bearer access-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!( + r#"{{"project_id":"{project_id}","slug":"private-slug"}}"# + )) + .expect(1) + .create_async() + .await; + let api = ApiArgs { + command: ApiCommand::Manifest, + api_url: server.url().parse().unwrap(), + allow_unauthenticated: false, + dry_run: false, + refresh_after_401: Cell::new(true), + }; + let mut config = CliConfig { + auth: Some(UserAuth { + access_token: "access-token".to_string(), + refresh_token: "refresh-token".to_string(), + expires_at: Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(), + refresh_expires_at: None, + user_id: None, + wallet_address: None, + email: Some("agent@example.com".to_string()), + }), + }; + let request = WorkflowRequest::get("/projects/private-slug", true, Vec::::new()); + + let result = api + .call_workflow_result( + &mut config, + &CliArgs::default(), + &request, + test_request_log_path(), + ) + .await + .unwrap(); + + assert_eq!(result.body["slug"], "private-slug"); + assert_eq!(result.request["path"], format!("/projects/{project_id}")); + resolve.assert_async().await; + detail.assert_async().await; +} + +#[tokio::test] +async fn project_slug_resolution_errors_preserve_http_metadata() { + let mut server = mockito::Server::new_async().await; + let resolve = server + .mock("GET", "/api/v1/projects/resolve/missing-slug") + .match_header("authorization", "Bearer access-token") + .with_status(404) + .with_header("content-type", "application/json") + .with_header("x-request-id", "req-resolve-404") + .with_body(r#"{"error":"Project not found"}"#) + .expect(1) + .create_async() + .await; + let api = ApiArgs { + command: ApiCommand::Manifest, + api_url: server.url().parse().unwrap(), + allow_unauthenticated: false, + dry_run: false, + refresh_after_401: Cell::new(true), + }; + let mut config = CliConfig { + auth: Some(UserAuth { + access_token: "access-token".to_string(), + refresh_token: "refresh-token".to_string(), + expires_at: Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(), + refresh_expires_at: None, + user_id: None, + wallet_address: None, + email: Some("agent@example.com".to_string()), + }), + }; + let request = WorkflowRequest::get("/projects/missing-slug", true, Vec::::new()); + + let error = api + .call_workflow_result( + &mut config, + &CliArgs::default(), + &request, + test_request_log_path(), + ) + .await + .unwrap_err(); + + let ApiCommandError::HttpStatus { + method, + path, + status, + request_id, + body, + } = &error + else { + panic!("expected HTTP status error, got {error:?}"); + }; + assert_eq!(*method, "GET"); + assert_eq!(path, "/projects/resolve/missing-slug"); + assert_eq!(*status, 404); + assert_eq!(request_id.as_deref(), Some("req-resolve-404")); + assert_eq!(body["error"], "Project not found"); + assert_eq!( + error.json_envelope()["error"]["request_id"], + "req-resolve-404" + ); + assert_eq!(error.json_envelope()["http_status"], 404); + resolve.assert_async().await; +} + +#[tokio::test] +async fn openapi_discovery_errors_preserve_http_metadata() { + let mut server = mockito::Server::new_async().await; + let openapi = server + .mock("GET", "/api/v1/openapi") + .with_status(503) + .with_header("content-type", "application/json") + .with_header("x-request-id", "req-openapi-503") + .with_body(r#"{"error":"OpenAPI unavailable"}"#) + .expect(1) + .create_async() + .await; + let api = ApiArgs { + command: ApiCommand::Manifest, + api_url: server.url().parse().unwrap(), + allow_unauthenticated: true, + dry_run: false, + refresh_after_401: Cell::new(true), + }; + + let error = api.fetch_openapi(&CliConfig::default()).await.unwrap_err(); + + let ApiCommandError::HttpStatus { + method, + path, + status, + request_id, + body, + } = &error + else { + panic!("expected HTTP status error, got {error:?}"); + }; + assert_eq!(*method, "GET"); + assert_eq!(path, "/openapi"); + assert_eq!(*status, 503); + assert_eq!(request_id.as_deref(), Some("req-openapi-503")); + assert_eq!(body["error"], "OpenAPI unavailable"); + assert_eq!( + error.json_envelope()["error"]["request_id"], + "req-openapi-503" + ); + openapi.assert_async().await; +} + #[tokio::test] async fn public_workflows_do_not_attach_expired_stored_tokens() { let mut server = mockito::Server::new_async().await; @@ -767,6 +1054,7 @@ async fn public_workflows_do_not_attach_expired_stored_tokens() { .run_workflow( &mut config, &CliArgs::default(), + "search", WorkflowRequest::get("/health", false, vec!["pcl search --health".to_string()]), test_request_log_path(), ) @@ -1439,7 +1727,7 @@ fn raw_operations_advertise_workflow_alternatives_when_available() { let legacy = workflow_alternatives(HttpMethod::Get, "/public/incidents"); assert!(legacy.iter().any(|alternative| { alternative["workflow"] == "incidents" - && alternative["example"] == "pcl incidents --limit 5" + && alternative["example"] == "pcl incidents --limit 5 --toon" })); let project_detail = workflow_alternatives(HttpMethod::Get, "/projects/{project_id}"); @@ -1448,7 +1736,7 @@ fn raw_operations_advertise_workflow_alternatives_when_available() { assert_eq!(project_detail[0]["action"], "detail"); assert_eq!( project_detail[0]["example"], - "pcl projects show " + "pcl projects show --toon" ); let saved_delete = workflow_alternatives(HttpMethod::Delete, "/projects/saved"); @@ -1457,7 +1745,7 @@ fn raw_operations_advertise_workflow_alternatives_when_available() { assert_eq!(saved_delete[0]["action"], "unsave"); assert_eq!( saved_delete[0]["example"], - "pcl projects unsave " + "pcl projects unsave --toon" ); let project_literal = workflow_alternatives(HttpMethod::Get, "/projects/project-1"); @@ -1766,6 +2054,7 @@ async fn workflow_success_envelopes_include_request_provenance() { .run_workflow( &mut config, &CliArgs::default(), + "search", request, test_request_log_path(), ) @@ -1928,6 +2217,7 @@ fn workflow_dry_run_plans_destructive_requests() { query: vec![("environment".to_string(), "production".to_string())], body: None, require_auth: true, + attach_auth: true, next_actions: Vec::new(), }; let config = CliConfig { @@ -2247,6 +2537,15 @@ fn mutating_server_errors_mark_outcome_ambiguous() { ); } +#[test] +fn api_error_envelope_keeps_recoverable_inside_error_object() { + let envelope = ApiCommandError::InvalidPath("health".to_string()).json_envelope(); + + assert_eq!(envelope["status"], "error"); + assert_eq!(envelope["error"]["recoverable"], true); + assert!(envelope.get("recoverable").is_none(), "{envelope}"); +} + #[test] fn forbidden_errors_preserve_permission_context() { let error = ApiCommandError::HttpStatus { @@ -2455,6 +2754,316 @@ fn human_output_formats_empty_workflow_arrays_for_people() { assert!(!output.contains("")); } +#[test] +fn human_output_keeps_placeholder_actions_when_other_collections_are_non_empty() { + let output = envelope_output_string( + &json!({ + "status": "ok", + "data": { + "projects": [ + {"project_id": "project-1", "project_name": "Project 1"} + ], + "contracts": [] + }, + "request": {"method": "GET", "path": "/search"}, + "response": {"status": 200, "request_id": "req_search"}, + "next_actions": ["pcl contracts --project "], + }), + false, + ) + .unwrap(); + + assert!(output.contains("pcl contracts --project ")); +} + +#[test] +fn human_output_keeps_safe_remove_calldata_actions() { + let output = envelope_output_string( + &json!({ + "status": "ok", + "data": { + "assertions": [ + {"assertion_id": "assertion-1", "contract_name": "Guard"} + ] + }, + "request": {"method": "GET", "path": "/views/projects/project-1/assertions"}, + "response": {"status": 200, "request_id": "req_assertions"}, + "next_actions": ["pcl assertions --project-id project-1 --remove-calldata"], + }), + false, + ) + .unwrap(); + + assert!(output.contains("pcl assertions --project-id project-1 --remove-calldata")); +} + +#[test] +fn release_list_next_actions_use_returned_release_id() { + let mut args = release_args(); + args.project = Some("project-1".to_string()); + let next_actions = releases_next_actions( + &json!([ + {"id": "release-1", "status": "active"}, + {"id": "release-2", "status": "inactive"} + ]), + &args, + vec!["pcl releases show project-1 ".to_string()], + ); + + assert_eq!(next_actions, vec!["pcl releases show project-1 release-1"]); +} + +#[test] +fn contract_list_next_actions_use_returned_adopter_id() { + let mut args = contracts_args(); + args.project = Some("project-1".to_string()); + let next_actions = contracts_next_actions( + &json!({ + "data": { + "contracts": [ + {"id": "59144_0xabc", "address": "0xabc"}, + {"id": "59144_0xdef", "address": "0xdef"} + ] + } + }), + &args, + vec!["pcl contracts --project project-1 --adopter-id ".to_string()], + ); + + assert_eq!( + next_actions, + vec!["pcl contracts --project project-1 --adopter-id 59144_0xabc"] + ); +} + +#[test] +fn transfer_list_next_actions_use_returned_transfer_id() { + let args = transfers_args(); + let next_actions = transfers_next_actions( + &json!({ + "incoming": { + "project_transfers": [ + {"id": "transfer-1", "project_id": "project-1"} + ] + }, + "outgoing": {"project_transfers": []} + }), + &args, + vec!["pcl transfers --transfer-id ".to_string()], + ); + + assert_eq!(next_actions, vec!["pcl transfers --transfer-id transfer-1"]); +} + +#[test] +fn protocol_manager_pending_next_actions_use_current_manager_address() { + let args = protocol_manager_args(); + let next_actions = protocol_manager_next_actions( + &json!({ + "has_pending_transfer": false, + "current_manager_address": "0xmanager", + "new_manager_address": null + }), + &args, + vec![ + concat!( + "pcl protocol-manager --project project-1 --nonce ", + "--address " + ) + .to_string(), + ], + ); + + assert_eq!( + next_actions[0], + "pcl protocol-manager --project project-1 --nonce --address 0xmanager" + ); + assert!( + next_actions[1].contains("--new-manager "), + "{next_actions:?}" + ); +} + +#[test] +fn protocol_manager_pending_next_actions_offer_accept_calldata_when_pending() { + let args = protocol_manager_args(); + let next_actions = protocol_manager_next_actions( + &json!({ + "has_pending_transfer": true, + "current_manager_address": "0xmanager", + "new_manager_address": "0xnew" + }), + &args, + Vec::new(), + ); + + assert_eq!( + next_actions, + vec![ + "pcl protocol-manager --project project-1 --nonce --address 0xmanager", + "pcl protocol-manager --project project-1 --accept-calldata", + ] + ); +} + +#[test] +fn deployment_output_only_redacts_artifacts_for_human_mode() { + let deployment_data = json!({ + "project": {"project_id": "project-1", "project_name": "Demo"}, + "submitted_assertions": [ + { + "id": "assertion-1", + "contract_name": "Guard", + "source_code": "contract Guard { function ok() external {} }", + "bytecode": "0x6080604052348015600e575f80fd5b50" + } + ], + "staging_assertions": [], + "available_contracts": [], + "_meta": {"sources": ["offchain"]} + }); + + let json_data = + workflow_data_for_output_mode("deployments", &deployment_data, OutputMode::Json); + let toon_data = + workflow_data_for_output_mode("deployments", &deployment_data, OutputMode::Toon); + + assert_eq!( + json_data["submitted_assertions"][0]["source_code"], + "contract Guard { function ok() external {} }" + ); + assert_eq!( + json_data["submitted_assertions"][0]["bytecode"], + "0x6080604052348015600e575f80fd5b50" + ); + assert_eq!( + toon_data["submitted_assertions"][0]["source_code"], + "contract Guard { function ok() external {} }" + ); + assert_eq!( + toon_data["submitted_assertions"][0]["bytecode"], + "0x6080604052348015600e575f80fd5b50" + ); + + let compact = workflow_data_for_output_mode("deployments", &deployment_data, OutputMode::Human); + let rendered = serde_json::to_string(&compact).expect("json render"); + + assert!(rendered.contains("\"redacted\":true"), "{rendered}"); + assert!(!rendered.contains("contract Guard"), "{rendered}"); + assert!(!rendered.contains("0x608060405234"), "{rendered}"); + assert_eq!( + compact["submitted_assertions"][0]["source_code"]["reason"], + "large_artifact" + ); + assert_eq!( + compact["submitted_assertions"][0]["bytecode"]["reason"], + "large_artifact" + ); +} + +#[test] +fn human_output_formats_non_empty_releases_for_people() { + let output = envelope_output_string( + &json!({ + "status": "ok", + "data": [ + { + "id": "release-1", + "releaseNumber": 2, + "environment": "production", + "status": "active", + "createdAt": "2026-05-18T18:00:00Z" + } + ], + "request": {"method": "GET", "path": "/projects/project-1/releases"}, + "response": {"status": 200, "request_id": "req_releases"}, + "next_actions": ["pcl releases show project-1 release-1"], + }), + false, + ) + .unwrap(); + + assert!(output.contains("Releases\n")); + assert!(output.contains("Release")); + assert!(output.contains("Environment")); + assert!(output.contains("Status")); + assert!(output.contains("release-1")); + assert!(output.contains("production")); + assert!(!output.contains("Visibility")); +} + +#[test] +fn human_output_formats_contract_lists_for_people() { + let output = envelope_output_string( + &json!({ + "status": "ok", + "data": { + "data": { + "contracts": [ + { + "id": "59144_0xabc", + "address": "0xabc", + "chain_id": 59144, + "manager": "0xmanager", + "contract_name": "LineaSettler" + } + ] + }, + "_meta": {"sources": ["offchain"], "fetchedAt": "2026-05-18T18:00:00Z"} + }, + "request": {"method": "GET", "path": "/views/projects/project-1/contracts"}, + "response": {"status": 200, "request_id": "req_contracts"}, + "next_actions": ["pcl contracts --project project-1 --adopter-id 59144_0xabc"], + }), + false, + ) + .unwrap(); + + assert!(output.contains("Contracts\n")); + assert!(output.contains("Contract")); + assert!(output.contains("Chain")); + assert!(output.contains("Address")); + assert!(output.contains("Manager")); + assert!(output.contains("LineaSettler")); + assert!(output.contains("0xabc")); +} + +#[test] +fn human_output_formats_assertion_lists_for_people() { + let output = envelope_output_string( + &json!({ + "status": "ok", + "data": { + "data": { + "assertions": [ + { + "assertion_id": "0xassertion", + "contract_name": "AllowanceAssertion", + "environment": "PRODUCTION", + "lifecycle": "enforced", + "deployment_instances": [{ "id": "one" }, { "id": "two" }] + } + ] + }, + "_meta": {"sources": ["offchain"], "fetchedAt": "2026-05-18T18:00:00Z"} + }, + "request": {"method": "GET", "path": "/views/projects/project-1/assertions"}, + "response": {"status": 200, "request_id": "req_assertions"}, + "next_actions": ["pcl assertions --project-id project-1 --assertion-id 0xassertion"], + }), + false, + ) + .unwrap(); + + assert!(output.contains("Assertions\n")); + assert!(output.contains("Contract")); + assert!(output.contains("Lifecycle")); + assert!(output.contains("Instances")); + assert!(output.contains("AllowanceAssertion")); + assert!(output.contains("enforced")); + assert!(output.contains("0xassertion")); +} + #[test] fn human_output_formats_project_details_for_people() { let output = envelope_output_string( @@ -3093,6 +3702,12 @@ fn manifest_lists_structured_actions_for_every_workflow() { .is_some_and(|value| !value.is_empty()), "missing output shape for {command_name}" ); + assert!( + command["output_policy"] + .as_str() + .is_some_and(|value| !value.is_empty()), + "missing output policy for {command_name}" + ); let actions = command["actions"].as_array().unwrap_or_else(|| { panic!("missing structured actions for manifest command {command_name}") }); @@ -3106,6 +3721,12 @@ fn manifest_lists_structured_actions_for_every_workflow() { "missing {field} for {command_name} action {action:?}" ); } + assert!( + action["example"] + .as_str() + .is_some_and(|example| example.contains("--toon")), + "agent example must include --toon for {command_name} action {action:?}" + ); assert!( action["auth"].as_bool().is_some(), "missing auth for {command_name} action {action:?}" @@ -3170,6 +3791,14 @@ fn manifest_lists_structured_actions_for_every_workflow() { ); } + for example in manifest["examples"].as_array().unwrap() { + let example = example.as_str().unwrap(); + assert!( + example.contains("--toon"), + "top-level manifest example must include --toon: {example}" + ); + } + let incident_actions = commands .iter() .find(|command| { @@ -3239,6 +3868,41 @@ fn manifest_lists_structured_actions_for_every_workflow() { ); } +#[test] +fn workflow_definitions_are_the_manifest_source_of_truth() { + let manifest = api_manifest(); + let commands = manifest["commands"].as_array().unwrap(); + let workflow_definitions = definitions::workflow_definitions(); + + for definition in workflow_definitions { + let command = commands + .iter() + .find(|command| command["command"] == definition.command) + .unwrap_or_else(|| panic!("missing manifest command {}", definition.command)); + assert_eq!( + command["output_policy"], + definition.output_policy.as_str(), + "manifest should expose output policy from workflow definition {}", + definition.name + ); + assert_eq!( + command["actions"].as_array().unwrap().len(), + definition.actions.len(), + "manifest action count should come from workflow definition {}", + definition.name + ); + } + + assert_eq!( + definitions::workflow_output_policy("deployments"), + definitions::WorkflowOutputPolicy::MachineRawHumanCompactArtifacts + ); + assert_eq!( + definitions::workflow_output_policy("projects"), + definitions::WorkflowOutputPolicy::MachineRaw + ); +} + #[test] fn parser_rejects_conflicting_workflow_actions() { assert!(ApiArgs::try_parse_from(["api", "projects", "--save", "--unsave"]).is_err()); diff --git a/crates/pcl/core/src/api/workflows.rs b/crates/pcl/core/src/api/workflows.rs index b816d50..0eb1d3d 100644 --- a/crates/pcl/core/src/api/workflows.rs +++ b/crates/pcl/core/src/api/workflows.rs @@ -1,19 +1,98 @@ +macro_rules! action { + ( + $name:literal, $auth:literal, $method:literal, $path:literal, $example:literal + $(, required: [$($required:literal),* $(,)?])? + $(, optional: [$($optional:literal),* $(,)?])? + $(, body_template: $body_template:literal)? + $(, required_body: [$($required_body:literal),* $(,)?])? + $(, query: {$($query_key:literal => $query_value:literal),* $(,)?})? + $(, aliases: [$($alias:literal),* $(,)?])? + $(,)? + ) => { + WorkflowActionDefinition { + name: $name, + auth: $auth, + method: $method, + path: $path, + required_flags: &[$($($required),*)?], + optional_flags: &[$($($optional),*)?], + required_body_fields: &[$($($required_body),*)?], + body_template: optional_literal!($($body_template)?), + query: &[$($(($query_key, $query_value)),*)?], + legacy_aliases: &[$($($alias),*)?], + example: $example, + } + }; +} + +macro_rules! optional_literal { + () => { + None + }; + ($value:literal) => { + Some($value) + }; +} + +pub(in crate::api) mod access; +pub(in crate::api) mod account; +pub(in crate::api) mod assertions; +pub(in crate::api) mod contracts; +pub(in crate::api) mod deployments; +pub(in crate::api) mod events; +pub(in crate::api) mod incidents; +pub(in crate::api) mod integrations; +pub(in crate::api) mod projects; +pub(in crate::api) mod protocol_manager; +pub(in crate::api) mod releases; +pub(in crate::api) mod search; +pub(in crate::api) mod transfers; + +pub(super) use access::access_request; +pub(super) use account::account_request; +pub(super) use assertions::{ + assertions_next_actions, + assertions_request, +}; +pub(super) use contracts::{ + contracts_next_actions, + contracts_request, +}; +pub(super) use deployments::{ + compact_deployment_data, + deployments_request, +}; +pub(super) use events::events_request; +pub(super) use incidents::{ + incidents_next_actions, + incidents_request, +}; +pub(super) use integrations::integrations_request; +pub(super) use projects::{ + projects_next_actions, + projects_request, +}; +pub(super) use protocol_manager::{ + protocol_manager_next_actions, + protocol_manager_request, +}; +pub(super) use releases::{ + releases_next_actions, + releases_request, +}; +pub(super) use search::{ + search_next_actions, + search_request, +}; +pub(super) use transfers::{ + transfers_next_actions, + transfers_request, +}; + use super::{ - AccessArgs, - AccountArgs, ApiCommandError, - AssertionsArgs, - ContractsArgs, - DeploymentsArgs, - EventsArgs, HttpMethod, - IncidentsArgs, - IntegrationsArgs, ProjectsArgs, - ProtocolManagerArgs, - ReleasesArgs, - SearchArgs, - TransfersArgs, WorkflowRequest, read_body, }; @@ -24,628 +103,6 @@ use serde_json::{ }; use std::path::PathBuf; -pub(super) fn search_request(args: &SearchArgs) -> Result { - if args.health { - return Ok(WorkflowRequest::get( - "/health", - false, - ["pcl search --system-status"], - )); - } - if args.system_status { - return Ok(WorkflowRequest::get( - "/system-status", - false, - ["pcl search --stats"], - )); - } - if args.stats { - return Ok(WorkflowRequest::get( - "/stats", - false, - ["pcl projects list --limit 10"], - )); - } - if args.whitelist { - return Ok(WorkflowRequest::get( - "/whitelist", - true, - ["pcl projects mine"], - )); - } - if args.verified_contract { - let address = required_arg(args.address.as_deref(), "--address")?; - let chain_id = args.chain_id.ok_or_else(|| { - ApiCommandError::InvalidWorkflowWithActions { - message: "--verified-contract requires --chain-id".to_string(), - next_actions: vec![ - "pcl search --verified-contract --address
--chain-id " - .to_string(), - "pcl search --help".to_string(), - ], - } - })?; - let mut request = WorkflowRequest::get( - "/web/verified-contract", - false, - ["pcl contracts --project "], - ); - push_query(&mut request.query, "address", Some(address)); - push_query(&mut request.query, "chainId", Some(chain_id)); - return Ok(request); - } - - let query = args - .query - .as_deref() - .or(args.term.as_deref()) - .filter(|query| !query.trim().is_empty()) - .ok_or_else(|| { - ApiCommandError::InvalidWorkflowWithActions { - message: "Search query is required unless you choose a specific search action" - .to_string(), - next_actions: vec![ - "pcl search ".to_string(), - "pcl search --query ".to_string(), - "pcl search --stats".to_string(), - "pcl search --help".to_string(), - ], - } - })?; - - let mut request = WorkflowRequest::get( - "/search", - false, - [ - "pcl projects show ", - "pcl contracts --project ", - ], - ); - push_query(&mut request.query, "query", Some(query)); - Ok(request) -} - -pub(super) fn account_request(args: &AccountArgs) -> Result { - let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; - if args.accept_terms { - return Ok(workflow_with_body( - HttpMethod::Post, - "/web/auth/accept-terms", - true, - Some(body_or_empty(body)), - ["pcl account", "pcl projects mine"], - )); - } - if args.logout { - return Ok(workflow_with_body( - HttpMethod::Post, - "/web/auth/logout", - true, - Some(body_or_empty(body)), - ["pcl auth logout"], - )); - } - Ok(WorkflowRequest::get( - "/web/auth/me", - true, - ["pcl account --accept-terms", "pcl projects mine"], - )) -} - -pub(super) fn contracts_request(args: &ContractsArgs) -> Result { - let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; - if args.create { - return Ok(workflow_with_body( - HttpMethod::Post, - "/assertion_adopters", - true, - body, - ["pcl contracts --unassigned --manager "], - )); - } - if args.assign_project { - return Ok(workflow_with_body( - HttpMethod::Post, - "/assertion_adopters/assign-project", - true, - body, - ["pcl contracts --project "], - )); - } - if args.unassigned { - let manager = required_arg(args.manager.as_deref(), "--manager")?; - let mut request = WorkflowRequest::get( - "/assertion_adopters/no-project", - true, - ["pcl contracts --assign-project --body-template"], - ); - push_query(&mut request.query, "manager", Some(manager)); - return Ok(request); - } - if args.remove_calldata { - let address = required_arg(args.aa_address.as_deref(), "--aa-address")?; - if args.assertion_ids.is_empty() { - return Err(ApiCommandError::InvalidWorkflow { - message: "--assertion-id is required for --remove-calldata".to_string(), - }); - } - let mut request = WorkflowRequest::get( - format!("/assertion_adopters/{address}/remove-assertions-calldata"), - true, - ["pcl releases list "], - ); - push_query(&mut request.query, "network", args.network.as_deref()); - push_query( - &mut request.query, - "environment", - args.environment.as_deref(), - ); - for assertion_id in &args.assertion_ids { - push_query(&mut request.query, "assertion_ids", Some(assertion_id)); - } - return Ok(request); - } - if args.remove { - let project = required_arg(args.project.as_deref(), "--project")?; - let address = required_arg(args.aa_address.as_deref(), "--aa-address")?; - return Ok(workflow_with_body( - HttpMethod::Delete, - format!("/projects/{project}/{address}"), - true, - body, - vec![format!("pcl contracts --project {project}")], - )); - } - if let Some(project) = &args.project { - if let Some(adopter_id) = &args.adopter_id { - return Ok(WorkflowRequest::get( - format!("/views/projects/{project}/contracts/{adopter_id}"), - true, - vec![format!("pcl contracts --project {project}")], - )); - } - return Ok(WorkflowRequest::get( - format!("/views/projects/{project}/contracts"), - true, - vec![format!( - "pcl contracts --project {project} --adopter-id " - )], - )); - } - - Ok(WorkflowRequest::get( - "/assertion_adopters", - true, - ["pcl contracts --unassigned --manager "], - )) -} - -pub(super) fn releases_request(args: &ReleasesArgs) -> Result { - let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; - let project = required_project_arg(args.project.as_deref(), "releases", "--project")?; - if args.preview { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/releases/preview"), - true, - body, - vec![format!( - "pcl releases create {project} --body-file release.json" - )], - )); - } - if args.create { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/releases"), - true, - body, - vec![format!("pcl releases list {project}")], - )); - } - if args.deploy - || args.remove - || args.deploy_calldata - || args.remove_calldata - || args.backtest_progress - || args.retry_check - { - let release_id = required_arg(args.release_id.as_deref(), "--release-id")?; - if args.backtest_progress { - return Ok(WorkflowRequest::get( - format!("/projects/{project}/releases/{release_id}/backtest-progress"), - true, - vec![format!("pcl releases show {project} {release_id}")], - )); - } - if args.retry_check { - let check_id = required_arg(args.check_id.as_deref(), "--check-id")?; - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/releases/{release_id}/checks/{check_id}/retry"), - true, - Some(body_or_empty(body)), - vec![format!( - "pcl releases backtest-progress {project} {release_id}" - )], - )); - } - if args.deploy { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/releases/{release_id}/deploy"), - true, - body, - vec![format!("pcl releases show {project} {release_id}")], - )); - } - if args.remove { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/releases/{release_id}/remove"), - true, - body, - vec![format!("pcl releases list {project}")], - )); - } - if args.deploy_calldata { - let signer_address = required_arg(args.signer_address.as_deref(), "--signer-address")?; - let mut request = WorkflowRequest::get( - format!("/projects/{project}/releases/{release_id}/deploy-calldata"), - true, - vec![format!("pcl releases deploy {project} {release_id}")], - ); - push_query(&mut request.query, "signerAddress", Some(signer_address)); - return Ok(request); - } - return Ok(WorkflowRequest::get( - format!("/projects/{project}/releases/{release_id}/remove-calldata"), - true, - vec![format!("pcl releases remove {project} {release_id}")], - )); - } - let Some(release_id) = &args.release_id else { - return Ok(WorkflowRequest::get( - format!("/projects/{project}/releases"), - true, - vec![format!("pcl releases show {project} ")], - )); - }; - Ok(WorkflowRequest::get( - format!("/projects/{project}/releases/{release_id}"), - true, - vec![ - format!( - "pcl releases calldata deploy {project} {release_id} --signer-address " - ), - format!("pcl releases calldata remove {project} {release_id}"), - ], - )) -} - -pub(super) fn deployments_request( - args: &DeploymentsArgs, -) -> Result { - let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; - let project = required_project_arg(args.project.as_deref(), "deployments", "--project")?; - if args.confirm { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/confirm-deployment"), - true, - body, - vec![format!("pcl deployments --project {project}")], - )); - } - Ok(WorkflowRequest::get( - format!("/views/projects/{project}/deployments"), - true, - vec![format!("pcl releases list {project}")], - )) -} - -pub(super) fn access_request(args: &AccessArgs) -> Result { - let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; - if args.pending { - return Ok(WorkflowRequest::get( - "/invitations/pending", - true, - ["pcl access accept "], - )); - } - if args.accept || args.preview { - let token = required_arg(args.token.as_deref(), "--token")?; - if args.accept { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/invitations/{token}/accept"), - true, - Some(body_or_empty(body)), - ["pcl projects mine"], - )); - } - return Ok(WorkflowRequest::get( - format!("/invitations/{token}/preview"), - false, - vec![format!("pcl access accept {token}")], - )); - } - if let Some(token) = &args.token { - return Ok(WorkflowRequest::get( - format!("/invitations/{token}/preview"), - false, - vec![format!("pcl access accept {token}")], - )); - } - let project = required_project_arg(args.project.as_deref(), "access", "--project")?; - if args.my_role { - return Ok(WorkflowRequest::get( - format!("/projects/{project}/my-role"), - true, - vec![format!("pcl access members {project}")], - )); - } - if args.invite { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/invitations"), - true, - body, - vec![format!("pcl access invitations {project}")], - )); - } - if args.resend || args.revoke { - let invitation_id = required_arg(args.invitation_id.as_deref(), "--invitation-id")?; - if args.resend { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("/projects/{project}/invitations/{invitation_id}/resend"), - true, - Some(body_or_empty(body)), - vec![format!("pcl access invitations {project}")], - )); - } - return Ok(workflow_with_body( - HttpMethod::Delete, - format!("/projects/{project}/invitations/{invitation_id}"), - true, - body, - vec![format!("pcl access invitations {project}")], - )); - } - if args.update_role || args.remove { - let member_user_id = required_arg(args.member_user_id.as_deref(), "--member-user-id")?; - if args.update_role { - return Ok(workflow_with_body( - HttpMethod::Patch, - format!("/projects/{project}/members/{member_user_id}"), - true, - body, - vec![format!("pcl access members {project}")], - )); - } - return Ok(workflow_with_body( - HttpMethod::Delete, - format!("/projects/{project}/members/{member_user_id}"), - true, - body, - vec![format!("pcl access members {project}")], - )); - } - if args.invitations { - return Ok(WorkflowRequest::get( - format!("/projects/{project}/invitations"), - true, - vec![format!("pcl access invite {project} --body-template")], - )); - } - Ok(WorkflowRequest::get( - format!("/projects/{project}/members"), - true, - vec![ - format!("pcl access my-role {project}"), - format!("pcl access invitations {project}"), - ], - )) -} - -pub(super) fn integrations_request( - args: &IntegrationsArgs, -) -> Result { - let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; - let project = required_project_arg(args.project.as_deref(), "integrations", "--project")?; - let Some(provider) = args.provider else { - return Err(ApiCommandError::InvalidWorkflowWithActions { - message: "--provider is required".to_string(), - next_actions: vec![ - "pcl integrations --project --provider slack".to_string(), - "pcl integrations --project --provider pagerduty".to_string(), - "pcl integrations --help".to_string(), - ], - }); - }; - let provider = provider.path(); - let base = format!("/projects/{project}/integrations/{provider}"); - if args.configure { - return Ok(workflow_with_body( - HttpMethod::Post, - base, - true, - body, - vec![format!( - "pcl integrations --project {project} --provider {provider}" - )], - )); - } - if args.test { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("{base}/test"), - true, - Some(body_or_empty(body)), - vec![format!( - "pcl integrations --project {project} --provider {provider}" - )], - )); - } - if args.delete { - return Ok(workflow_with_body( - HttpMethod::Delete, - base, - true, - body, - vec![format!( - "pcl integrations --project {project} --provider {provider}" - )], - )); - } - Ok(WorkflowRequest::get( - base, - true, - vec![ - format!("pcl integrations --project {project} --provider {provider} --test"), - format!( - "pcl integrations --project {project} --provider {provider} --configure --body-template" - ), - ], - )) -} - -pub(super) fn protocol_manager_request( - args: &ProtocolManagerArgs, -) -> Result { - let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; - let project = required_project_arg(args.project.as_deref(), "protocol-manager", "--project")?; - let base = format!("/projects/{project}/protocol-manager"); - if args.nonce { - let address = required_arg(args.address.as_deref(), "--address")?; - let mut request = WorkflowRequest::get( - format!("{base}/nonce"), - true, - vec![format!( - "pcl protocol-manager --project {project} --set --body-template" - )], - ); - push_query(&mut request.query, "address", Some(address)); - push_query(&mut request.query, "chain_id", args.chain_id); - return Ok(request); - } - if args.set { - return Ok(workflow_with_body( - HttpMethod::Post, - base, - true, - body, - vec![format!( - "pcl protocol-manager --project {project} --pending-transfer" - )], - )); - } - if args.clear { - return Ok(workflow_with_body( - HttpMethod::Delete, - base, - true, - body, - vec![format!( - "pcl protocol-manager --project {project} --nonce --address " - )], - )); - } - if args.transfer_calldata { - let new_manager = required_arg(args.new_manager.as_deref(), "--new-manager")?; - let mut request = WorkflowRequest::get( - format!("{base}/transfer-calldata"), - true, - vec![format!( - "pcl protocol-manager --project {project} --set --body-template" - )], - ); - push_query(&mut request.query, "new_manager", Some(new_manager)); - return Ok(request); - } - if args.accept_calldata { - return Ok(WorkflowRequest::get( - format!("{base}/accept-calldata"), - true, - vec![format!( - "pcl protocol-manager --project {project} --confirm-transfer --body-template" - )], - )); - } - if args.confirm_transfer { - return Ok(workflow_with_body( - HttpMethod::Post, - format!("{base}/confirm-transfer"), - true, - body, - vec![format!( - "pcl protocol-manager --project {project} --pending-transfer" - )], - )); - } - Ok(WorkflowRequest::get( - format!("{base}/pending-transfer"), - true, - vec![ - format!("pcl protocol-manager --project {project} --nonce --address "), - format!( - "pcl protocol-manager --project {project} --transfer-calldata --new-manager " - ), - ], - )) -} - -pub(super) fn transfers_request(args: &TransfersArgs) -> Result { - let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; - if args.reject { - return Ok(workflow_with_body( - HttpMethod::Post, - "/transfers/reject", - true, - body, - ["pcl transfers --pending"], - )); - } - if let Some(transfer_id) = &args.transfer_id { - return Ok(WorkflowRequest::get( - format!("/views/transfers/{transfer_id}"), - true, - ["pcl transfers --pending"], - )); - } - Ok(WorkflowRequest::get( - "/views/transfers/pending", - true, - ["pcl transfers --transfer-id "], - )) -} - -pub(super) fn events_request(args: &EventsArgs) -> Result { - let project = required_project_arg(args.project.as_deref(), "events", "--project")?; - let mut request = if args.audit_log { - WorkflowRequest::get( - format!("/views/projects/{project}/audit-log"), - true, - vec![format!("pcl events --project {project}")], - ) - } else { - WorkflowRequest::get( - format!("/views/projects/{project}/events"), - true, - vec![format!("pcl events --project {project} --audit-log")], - ) - }; - push_query(&mut request.query, "page", args.page); - push_query(&mut request.query, "limit", args.limit); - push_query( - &mut request.query, - "environment", - args.environment.as_deref(), - ); - Ok(request) -} - fn workflow_with_body( method: HttpMethod, path: impl Into, @@ -659,6 +116,7 @@ fn workflow_with_body( query: Vec::new(), body, require_auth, + attach_auth: require_auth, next_actions: next_actions.into_iter().map(Into::into).collect(), } } @@ -824,214 +282,6 @@ fn split_first_segment(path: &str) -> (&str, &str) { }) } -pub(super) fn incidents_request(args: &IncidentsArgs) -> Result { - if args.all && (args.incident_id.is_some() || args.stats || args.retry_trace) { - return Err(ApiCommandError::InvalidWorkflow { - message: "--all is only supported for incident list workflows".to_string(), - }); - } - if args.stats && args.project_id.is_none() { - return Err(ApiCommandError::InvalidWorkflow { - message: "--stats requires --project-id".to_string(), - }); - } - if args.tx_id.is_some() && args.incident_id.is_none() { - return Err(ApiCommandError::InvalidWorkflow { - message: "--tx-id requires --incident-id".to_string(), - }); - } - if args.retry_trace && args.tx_id.is_none() { - return Err(ApiCommandError::InvalidWorkflow { - message: "--retry-trace requires --incident-id and --tx-id".to_string(), - }); - } - - let mut query = Vec::new(); - push_query(&mut query, "page", args.page); - push_query(&mut query, "limit", args.limit); - - if let Some(incident_id) = &args.incident_id { - if args.retry_trace { - let tx_id = required_arg(args.tx_id.as_deref(), "--tx-id")?; - return Ok(WorkflowRequest { - method: HttpMethod::Post, - path: format!("/incidents/{incident_id}/transactions/{tx_id}/trace/retry"), - query, - body: Some("{}".to_string()), - require_auth: true, - next_actions: vec![format!( - "pcl incidents --incident-id {incident_id} --tx-id {tx_id}" - )], - }); - } - let path = if let Some(tx_id) = &args.tx_id { - format!("/views/incidents/{incident_id}/transactions/{tx_id}/trace") - } else { - format!("/views/incidents/{incident_id}") - }; - let next_actions = vec![ - "pcl incidents --limit 5".to_string(), - format!("pcl api inspect get {}", path), - ]; - return Ok(WorkflowRequest::get_with_query( - path, - query, - true, - next_actions, - )); - } - - if let Some(project_id) = &args.project_id { - if args.stats { - let path = format!("/projects/{project_id}/incidents/stats"); - return Ok(WorkflowRequest::get_with_query( - path, - query, - true, - vec![format!( - "pcl incidents --project-id {project_id} --limit 10" - )], - )); - } - push_query(&mut query, "assertionId", args.assertion_id.as_deref()); - push_query( - &mut query, - "assertionAdopterId", - args.assertion_adopter_id.as_deref(), - ); - push_query(&mut query, "environment", args.environment.as_deref()); - push_query(&mut query, "fromDate", args.from_date.as_deref()); - push_query(&mut query, "toDate", args.to_date.as_deref()); - let path = format!("/views/projects/{project_id}/incidents"); - return Ok(WorkflowRequest::get_with_query( - path, - query, - true, - vec![ - format!("pcl assertions --project-id {project_id}"), - "pcl incidents --limit 5".to_string(), - ], - )); - } - - push_query(&mut query, "network", args.network); - push_query(&mut query, "sort", args.sort.as_deref()); - push_query(&mut query, "devMode", args.dev_mode.as_deref()); - Ok(WorkflowRequest::get_with_query( - "/views/public/incidents", - query, - false, - vec![ - "pcl incidents --project-id --limit 10".to_string(), - "pcl projects list --limit 10".to_string(), - ], - )) -} - -pub(super) fn incidents_next_actions( - data: &Value, - args: &IncidentsArgs, - fallback: Vec, -) -> Vec { - if let Some(incident_id) = &args.incident_id { - if args.tx_id.is_none() - && let Some(tx_id) = data - .get("data") - .and_then(|data| data.get("invalidating_transactions")) - .and_then(Value::as_array) - .and_then(|transactions| transactions.first()) - .and_then(|transaction| { - first_string_field(transaction, &["transaction_hash", "id", "tx_id"]) - }) - { - return vec![ - format!("pcl incidents --incident-id {incident_id} --tx-id {tx_id}"), - "pcl incidents --limit 5".to_string(), - ]; - } - return fallback; - } - first_string_field(data, &["id", "incidentId", "incident_id"]).map_or(fallback, |incident_id| { - vec![ - format!("pcl incidents --incident-id {incident_id}"), - "pcl projects list --limit 10".to_string(), - ] - }) -} - -pub(super) fn projects_next_actions(data: &Value, fallback: Vec) -> Vec { - if let Some(project_id) = data.get("project_id").and_then(Value::as_str) { - return vec![ - format!("pcl assertions --project-id {project_id}"), - format!("pcl incidents --project-id {project_id} --limit 10"), - ]; - } - first_string_field(data, &["project_id", "projectId", "id"]).map_or(fallback, |project_id| { - vec![ - format!("pcl projects show {project_id}"), - format!("pcl assertions --project-id {project_id}"), - format!("pcl incidents --project-id {project_id} --limit 10"), - ] - }) -} - -pub(super) fn assertions_next_actions( - data: &Value, - args: &AssertionsArgs, - fallback: Vec, -) -> Vec { - let Some(project_id) = &args.project_id else { - return first_string_field( - data, - &["assertion_adopter_address", "adopter_address", "address"], - ) - .map_or(fallback, |address| { - vec![format!("pcl assertions --adopter-address {address}")] - }); - }; - - first_string_field(data, &["assertion_id", "assertionId", "id"]).map_or( - fallback, - |assertion_id| { - vec![ - format!("pcl assertions --project-id {project_id} --assertion-id {assertion_id}",), - format!("pcl incidents --project-id {project_id} --assertion-id {assertion_id}",), - ] - }, - ) -} - -pub(super) fn search_next_actions(data: &Value, fallback: Vec) -> Vec { - if let Some(project_id) = data - .get("projects") - .and_then(Value::as_array) - .and_then(|projects| projects.first()) - .and_then(|project| first_string_field(project, &["project_id", "projectId", "id", "slug"])) - { - return vec![ - format!("pcl projects show {project_id}"), - format!("pcl contracts --project {project_id}"), - ]; - } - if let Some(project_id) = data - .get("contracts") - .and_then(Value::as_array) - .and_then(|contracts| contracts.first()) - .and_then(|contract| { - contract.get("data").map_or_else( - || first_string_field(contract, &["related_project_id", "related_project_slug"]), - |inner| first_string_field(inner, &["related_project_id", "related_project_slug"]), - ) - }) - { - return vec![ - format!("pcl projects show {project_id}"), - format!("pcl contracts --project {project_id}"), - ]; - } - fallback -} - pub(super) fn first_string_field(value: &Value, keys: &[&str]) -> Option { match value { Value::Object(object) => { @@ -1053,202 +303,63 @@ pub(super) fn first_string_field(value: &Value, keys: &[&str]) -> Option } } -pub(super) fn projects_request(args: &ProjectsArgs) -> Result { - let mut query = Vec::new(); - push_query(&mut query, "page", args.page); - push_query(&mut query, "limit", args.limit); - push_query(&mut query, "search", args.search.as_deref()); - let body = project_request_body(args)?; - - if args.create { - return Ok(workflow_with_body( - HttpMethod::Post, - "/projects", - true, - body, - vec!["pcl projects mine".to_string()], - )); - } - - if args.mine { - return Ok(WorkflowRequest::get_with_query( - "/views/projects/home", - query, - true, - vec![ - "pcl account".to_string(), - "pcl projects saved --user-id ".to_string(), - ], - )); - } - if args.saved { - let user_id = required_arg(args.user_id.as_deref(), "--user-id")?; - push_query(&mut query, "user_id", Some(user_id)); - return Ok(WorkflowRequest::get_with_query( - "/projects/saved", - query, - true, - vec!["pcl projects mine".to_string()], - )); - } - if args.project_id.is_none() - && (args.update || args.delete || args.save || args.unsave || args.resolve || args.widget) - { - required_project_arg(args.project_id.as_deref(), "projects", "--project-id")?; - } - if let Some(project_id) = &args.project_id { - if args.resolve { - return Ok(WorkflowRequest::get_with_query( - format!("/projects/resolve/{project_id}"), - query, - false, - vec![format!("pcl projects show {project_id}")], - )); - } - if args.widget { - return Ok(WorkflowRequest::get( - format!("/projects/{project_id}/widget"), - true, - vec![format!("pcl projects show {project_id}")], - )); - } - if args.save || args.unsave { - return Ok(workflow_with_body( - if args.save { - HttpMethod::Post +fn redact_large_artifacts(value: &Value) -> Value { + match value { + Value::Object(object) => { + let mut redacted = Map::new(); + for (key, value) in object { + let value = if is_large_artifact_key(key) { + artifact_redaction(value) } else { - HttpMethod::Delete - }, - "/projects/saved", - true, - Some(json!({ "project_id": project_id }).to_string()), - vec![ - format!("pcl projects show {project_id}"), - "pcl projects mine".to_string(), - ], - )); - } - if args.update { - return Ok(workflow_with_body( - HttpMethod::Put, - format!("/projects/{project_id}"), - true, - body, - vec![format!("pcl projects show {project_id}")], - )); - } - if args.delete { - return Ok(workflow_with_body( - HttpMethod::Delete, - format!("/projects/{project_id}"), - true, - body, - ["pcl projects mine"], - )); + redact_large_artifacts(value) + }; + redacted.insert(key.clone(), value); + } + Value::Object(redacted) } - return Ok(WorkflowRequest::get_with_query( - format!("/projects/{project_id}"), - query, - true, - vec![ - format!("pcl assertions --project-id {project_id}"), - format!("pcl incidents --project-id {project_id} --limit 10"), - ], - )); + Value::Array(values) => Value::Array(values.iter().map(redact_large_artifacts).collect()), + _ => value.clone(), } - - Ok(WorkflowRequest::get_with_query( - "/views/projects", - query, - false, - ["pcl projects show ", "pcl incidents --limit 5"], - )) } -pub(super) fn assertions_request( - args: &AssertionsArgs, -) -> Result { - if args.submit || args.submitted { - return Err(ApiCommandError::InvalidWorkflow { - message: - "Submitted assertions have been removed from the API; use releases and registered assertions instead" - .to_string(), - }); - } - - if let Some(adopter_address) = &args.adopter_address { - let mut request = WorkflowRequest::get( - "/assertions", - false, - ["pcl contracts --project "], - ); - push_query(&mut request.query, "adopter_address", Some(adopter_address)); - push_query(&mut request.query, "network", args.network.as_deref()); - push_query( - &mut request.query, - "environment", - args.environment.as_deref(), - ); - push_query( - &mut request.query, - "include_onchain_only", - args.include_onchain_only, - ); - return Ok(request); - } - - let project_id = - required_project_arg(args.project_id.as_deref(), "assertions", "--project-id")?; - let mut query = Vec::new(); - push_query(&mut query, "page", args.page); - push_query(&mut query, "limit", args.limit); - push_query(&mut query, "assertionAdopterId", args.adopter_id.as_deref()); - push_query(&mut query, "environment", args.environment.as_deref()); - - if args.registered { - return Ok(WorkflowRequest::get( - format!("/projects/{project_id}/registered-assertions"), - true, - vec![format!("pcl assertions --project-id {project_id}")], - )); - } - if args.remove_info { - return Ok(WorkflowRequest::get( - format!("/projects/{project_id}/remove-assertions-info"), - true, - vec![format!( - "pcl assertions --project-id {project_id} --remove-calldata" - )], - )); - } - if args.remove_calldata { - return Ok(WorkflowRequest::get( - format!("/projects/{project_id}/remove-assertions-calldata"), - true, - vec![format!("pcl releases list {project_id}")], - )); - } +fn is_large_artifact_key(key: &str) -> bool { + let normalized = key.replace(['_', '-'], "").to_ascii_lowercase(); + matches!( + normalized.as_str(), + "sourcecode" + | "soliditysource" + | "bytecode" + | "deploymentbytecode" + | "runtimebytecode" + | "creationbytecode" + ) +} - if let Some(assertion_id) = &args.assertion_id { - return Ok(WorkflowRequest::get_with_query( - format!("/views/projects/{project_id}/assertions/{assertion_id}"), - query, - true, - vec![format!( - "pcl incidents --project-id {project_id} --assertion-id {assertion_id}", - )], - )); +fn artifact_redaction(value: &Value) -> Value { + match value { + Value::String(source) => { + json!({ + "redacted": true, + "bytes": source.len(), + "reason": "large_artifact" + }) + } + Value::Array(values) => { + json!({ + "redacted": true, + "items": values.len(), + "reason": "large_artifact" + }) + } + Value::Object(object) => { + json!({ + "redacted": true, + "fields": object.len(), + "reason": "large_artifact" + }) + } + _ => Value::Null, } - - Ok(WorkflowRequest::get_with_query( - format!("/views/projects/{project_id}/assertions"), - query, - true, - vec![ - format!("pcl incidents --project-id {project_id} --limit 10"), - format!("pcl assertions --project-id {project_id} --assertion-id "), - ], - )) } fn push_query(query: &mut Vec<(String, String)>, name: &str, value: Option) { diff --git a/crates/pcl/core/src/api/workflows/access.rs b/crates/pcl/core/src/api/workflows/access.rs new file mode 100644 index 0000000..aeb6697 --- /dev/null +++ b/crates/pcl/core/src/api/workflows/access.rs @@ -0,0 +1,157 @@ +use super::{ + super::{ + AccessArgs, + ApiCommandError, + HttpMethod, + WorkflowRequest, + definitions::{ + WorkflowActionDefinition, + WorkflowDefinition, + WorkflowOutputPolicy, + }, + }, + body_or_empty, + request_body, + required_arg, + required_project_arg, + workflow_with_body, +}; + +pub(in crate::api) fn access_request( + args: &AccessArgs, +) -> Result { + let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; + if args.pending { + return Ok(WorkflowRequest::get( + "/invitations/pending", + true, + ["pcl access accept "], + )); + } + if args.accept || args.preview { + let token = required_arg(args.token.as_deref(), "--token")?; + if args.accept { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/invitations/{token}/accept"), + true, + Some(body_or_empty(body)), + ["pcl projects mine"], + )); + } + return Ok(WorkflowRequest::get( + format!("/invitations/{token}/preview"), + false, + vec![format!("pcl access accept {token}")], + )); + } + if let Some(token) = &args.token { + return Ok(WorkflowRequest::get( + format!("/invitations/{token}/preview"), + false, + vec![format!("pcl access accept {token}")], + )); + } + let project = required_project_arg(args.project.as_deref(), "access", "--project")?; + if args.my_role { + return Ok(WorkflowRequest::get( + format!("/projects/{project}/my-role"), + true, + vec![format!("pcl access members {project}")], + )); + } + if args.invite { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/projects/{project}/invitations"), + true, + body, + vec![format!("pcl access invitations {project}")], + )); + } + if args.resend || args.revoke { + let invitation_id = required_arg(args.invitation_id.as_deref(), "--invitation-id")?; + if args.resend { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/projects/{project}/invitations/{invitation_id}/resend"), + true, + Some(body_or_empty(body)), + vec![format!("pcl access invitations {project}")], + )); + } + return Ok(workflow_with_body( + HttpMethod::Delete, + format!("/projects/{project}/invitations/{invitation_id}"), + true, + body, + vec![format!("pcl access invitations {project}")], + )); + } + if args.update_role || args.remove { + let member_user_id = required_arg(args.member_user_id.as_deref(), "--member-user-id")?; + if args.update_role { + return Ok(workflow_with_body( + HttpMethod::Patch, + format!("/projects/{project}/members/{member_user_id}"), + true, + body, + vec![format!("pcl access members {project}")], + )); + } + return Ok(workflow_with_body( + HttpMethod::Delete, + format!("/projects/{project}/members/{member_user_id}"), + true, + body, + vec![format!("pcl access members {project}")], + )); + } + if args.invitations { + return Ok(WorkflowRequest::get( + format!("/projects/{project}/invitations"), + true, + vec![format!("pcl access invite {project} --body-template")], + )); + } + Ok(WorkflowRequest::get( + format!("/projects/{project}/members"), + true, + vec![ + format!("pcl access my-role {project}"), + format!("pcl access invitations {project}"), + ], + )) +} + +pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { + name: "access", + command: "pcl access ", + description: "Manage project members, roles, and invitations.", + output: "member lists, invitation lists, role data, or mutation results", + output_policy: WorkflowOutputPolicy::MachineRaw, + legacy_examples: &[ + "pcl access --project --members", + "pcl access --project --invite --body-template", + "pcl access --token --preview", + ], + actions: &[ + action!("members", true, "GET", "/projects/{project}/members", "pcl access members ", required: [""]), + action!("my_role", true, "GET", "/projects/{project}/my-role", "pcl access my-role ", required: [""]), + action!("invitations", true, "GET", "/projects/{project}/invitations", "pcl access invitations ", required: [""]), + action!("invite", true, "POST", "/projects/{project}/invitations", "pcl access invite --body-template", required: [""], body_template: "access_invite"), + action!("resend", true, "POST", "/projects/{project}/invitations/{invitation_id}/resend", "pcl access resend ", required: ["", ""], body_template: "empty_object"), + action!("revoke", true, "DELETE", "/projects/{project}/invitations/{invitation_id}", "pcl access revoke ", required: ["", ""], body_template: "empty_object"), + action!("update_role", true, "PATCH", "/projects/{project}/members/{member_user_id}", "pcl access role update --body-template", required: ["", ""], body_template: "role_update"), + action!("remove", true, "DELETE", "/projects/{project}/members/{member_user_id}", "pcl access member remove ", required: ["", ""], body_template: "empty_object"), + action!( + "pending", + true, + "GET", + "/invitations/pending", + "pcl access pending" + ), + action!("preview", false, "GET", "/invitations/{token}/preview", "pcl access preview ", required: [""]), + action!("accept", true, "POST", "/invitations/{token}/accept", "pcl access accept ", required: [""], body_template: "empty_object"), + ], +}; diff --git a/crates/pcl/core/src/api/workflows/account.rs b/crates/pcl/core/src/api/workflows/account.rs new file mode 100644 index 0000000..fb2b7a2 --- /dev/null +++ b/crates/pcl/core/src/api/workflows/account.rs @@ -0,0 +1,59 @@ +use super::{ + super::{ + AccountArgs, + ApiCommandError, + HttpMethod, + WorkflowRequest, + definitions::{ + WorkflowActionDefinition, + WorkflowDefinition, + WorkflowOutputPolicy, + }, + }, + body_or_empty, + request_body, + workflow_with_body, +}; + +pub(in crate::api) fn account_request( + args: &AccountArgs, +) -> Result { + let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; + if args.accept_terms { + return Ok(workflow_with_body( + HttpMethod::Post, + "/web/auth/accept-terms", + true, + Some(body_or_empty(body)), + ["pcl account", "pcl projects mine"], + )); + } + if args.logout { + return Ok(workflow_with_body( + HttpMethod::Post, + "/web/auth/logout", + true, + Some(body_or_empty(body)), + ["pcl auth logout"], + )); + } + Ok(WorkflowRequest::get( + "/web/auth/me", + true, + ["pcl account --accept-terms", "pcl projects mine"], + )) +} + +pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { + name: "account", + command: "pcl account [--me|--accept-terms|--logout]", + description: "Inspect authenticated web user state and perform onboarding actions.", + output: "current user account state, terms acceptance result, or logout result", + output_policy: WorkflowOutputPolicy::MachineRaw, + legacy_examples: &[], + actions: &[ + action!("me", true, "GET", "/web/auth/me", "pcl account"), + action!("accept_terms", true, "POST", "/web/auth/accept-terms", "pcl account --accept-terms", body_template: "empty_object"), + action!("logout", true, "POST", "/web/auth/logout", "pcl account --logout", body_template: "empty_object"), + ], +}; diff --git a/crates/pcl/core/src/api/workflows/assertions.rs b/crates/pcl/core/src/api/workflows/assertions.rs new file mode 100644 index 0000000..25681b9 --- /dev/null +++ b/crates/pcl/core/src/api/workflows/assertions.rs @@ -0,0 +1,145 @@ +use super::{ + super::{ + ApiCommandError, + AssertionsArgs, + WorkflowRequest, + definitions::{ + WorkflowActionDefinition, + WorkflowDefinition, + WorkflowOutputPolicy, + }, + }, + first_string_field, + push_query, + required_project_arg, +}; +use serde_json::Value; + +pub(in crate::api) fn assertions_next_actions( + data: &Value, + args: &AssertionsArgs, + fallback: Vec, +) -> Vec { + let Some(project_id) = &args.project_id else { + return first_string_field( + data, + &["assertion_adopter_address", "adopter_address", "address"], + ) + .map_or(fallback, |address| { + vec![format!("pcl assertions --adopter-address {address}")] + }); + }; + + first_string_field(data, &["assertion_id", "assertionId", "id"]).map_or( + fallback, + |assertion_id| { + vec![ + format!("pcl assertions --project-id {project_id} --assertion-id {assertion_id}",), + format!("pcl incidents --project-id {project_id} --assertion-id {assertion_id}",), + ] + }, + ) +} + +pub(in crate::api) fn assertions_request( + args: &AssertionsArgs, +) -> Result { + if args.submit || args.submitted { + return Err(ApiCommandError::InvalidWorkflow { + message: + "Submitted assertions have been removed from the API; use releases and registered assertions instead" + .to_string(), + }); + } + + if let Some(adopter_address) = &args.adopter_address { + let mut request = WorkflowRequest::get( + "/assertions", + false, + ["pcl contracts --project "], + ); + push_query(&mut request.query, "adopter_address", Some(adopter_address)); + push_query(&mut request.query, "network", args.network.as_deref()); + push_query( + &mut request.query, + "environment", + args.environment.as_deref(), + ); + push_query( + &mut request.query, + "include_onchain_only", + args.include_onchain_only, + ); + return Ok(request); + } + + let project_id = + required_project_arg(args.project_id.as_deref(), "assertions", "--project-id")?; + let mut query = Vec::new(); + push_query(&mut query, "page", args.page); + push_query(&mut query, "limit", args.limit); + push_query(&mut query, "assertionAdopterId", args.adopter_id.as_deref()); + push_query(&mut query, "environment", args.environment.as_deref()); + + if args.registered { + return Ok(WorkflowRequest::get( + format!("/projects/{project_id}/registered-assertions"), + true, + vec![format!("pcl assertions --project-id {project_id}")], + )); + } + if args.remove_info { + return Ok(WorkflowRequest::get( + format!("/projects/{project_id}/remove-assertions-info"), + true, + vec![format!( + "pcl assertions --project-id {project_id} --remove-calldata" + )], + )); + } + if args.remove_calldata { + return Ok(WorkflowRequest::get( + format!("/projects/{project_id}/remove-assertions-calldata"), + true, + vec![format!("pcl releases list {project_id}")], + )); + } + + if let Some(assertion_id) = &args.assertion_id { + return Ok(WorkflowRequest::get_with_query( + format!("/views/projects/{project_id}/assertions/{assertion_id}"), + query, + true, + vec![format!( + "pcl incidents --project-id {project_id} --assertion-id {assertion_id}", + )], + )); + } + + Ok(WorkflowRequest::get_with_query( + format!("/views/projects/{project_id}/assertions"), + query, + true, + vec![ + format!("pcl incidents --project-id {project_id} --limit 10"), + format!("pcl assertions --project-id {project_id} --assertion-id "), + ], + )) +} + +pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { + name: "assertions", + command: "pcl assertions --project [--assertion-id |--registered|--remove-info|--remove-calldata]", + description: "List, inspect, and manage project assertion lifecycle state.", + output: "assertion index/detail, registered assertions, or removal info/calldata", + output_policy: WorkflowOutputPolicy::MachineRaw, + legacy_examples: &[], + actions: &[ + action!("index", true, "GET", "/views/projects/{projectId}/assertions", "pcl assertions --project ", required: ["--project"]), + action!("detail", true, "GET", "/views/projects/{projectId}/assertions/{assertionId}", "pcl assertions --project --assertion-id ", required: ["--project", "--assertion-id"]), + action!("adopter_lookup", false, "GET", "/assertions", "pcl assertions --adopter-address 0x... --network 1", required: ["--adopter-address"], optional: ["--network", "--environment", "--include-onchain-only"]), + action!("registered", true, "GET", "/projects/{project_id}/registered-assertions", "pcl assertions --project --registered", required: ["--project"]), + action!("remove_info", true, "GET", "/projects/{project_id}/remove-assertions-info", "pcl assertions --project --remove-info", required: ["--project"]), + action!("remove_calldata", true, "GET", "/projects/{project_id}/remove-assertions-calldata", "pcl assertions --project --remove-calldata", required: ["--project"]), + ], +}; diff --git a/crates/pcl/core/src/api/workflows/contracts.rs b/crates/pcl/core/src/api/workflows/contracts.rs new file mode 100644 index 0000000..bce81ec --- /dev/null +++ b/crates/pcl/core/src/api/workflows/contracts.rs @@ -0,0 +1,169 @@ +use super::{ + super::{ + ApiCommandError, + ContractsArgs, + HttpMethod, + WorkflowRequest, + definitions::{ + WorkflowActionDefinition, + WorkflowDefinition, + WorkflowOutputPolicy, + }, + }, + first_string_field, + push_query, + request_body, + required_arg, + workflow_with_body, +}; +use serde_json::Value; + +pub(in crate::api) fn contracts_request( + args: &ContractsArgs, +) -> Result { + let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; + if args.create { + return Ok(workflow_with_body( + HttpMethod::Post, + "/assertion_adopters", + true, + body, + ["pcl contracts --unassigned --manager "], + )); + } + if args.assign_project { + return Ok(workflow_with_body( + HttpMethod::Post, + "/assertion_adopters/assign-project", + true, + body, + ["pcl contracts --project "], + )); + } + if args.unassigned { + let manager = required_arg(args.manager.as_deref(), "--manager")?; + let mut request = WorkflowRequest::get( + "/assertion_adopters/no-project", + true, + ["pcl contracts --assign-project --body-template"], + ); + push_query(&mut request.query, "manager", Some(manager)); + return Ok(request); + } + if args.remove_calldata { + let address = required_arg(args.aa_address.as_deref(), "--aa-address")?; + if args.assertion_ids.is_empty() { + return Err(ApiCommandError::InvalidWorkflow { + message: "--assertion-id is required for --remove-calldata".to_string(), + }); + } + let mut request = WorkflowRequest::get( + format!("/assertion_adopters/{address}/remove-assertions-calldata"), + true, + ["pcl releases list "], + ); + push_query(&mut request.query, "network", args.network.as_deref()); + push_query( + &mut request.query, + "environment", + args.environment.as_deref(), + ); + for assertion_id in &args.assertion_ids { + push_query(&mut request.query, "assertion_ids", Some(assertion_id)); + } + return Ok(request); + } + if args.remove { + let project = required_arg(args.project.as_deref(), "--project")?; + let address = required_arg(args.aa_address.as_deref(), "--aa-address")?; + return Ok(workflow_with_body( + HttpMethod::Delete, + format!("/projects/{project}/{address}"), + true, + body, + vec![format!("pcl contracts --project {project}")], + )); + } + if let Some(project) = &args.project { + if let Some(adopter_id) = &args.adopter_id { + return Ok(WorkflowRequest::get( + format!("/views/projects/{project}/contracts/{adopter_id}"), + true, + vec![format!("pcl contracts --project {project}")], + )); + } + return Ok(WorkflowRequest::get( + format!("/views/projects/{project}/contracts"), + true, + vec![format!( + "pcl contracts --project {project} --adopter-id " + )], + )); + } + + Ok(WorkflowRequest::get( + "/assertion_adopters", + true, + ["pcl contracts --unassigned --manager "], + )) +} + +pub(in crate::api) fn contracts_next_actions( + data: &Value, + args: &ContractsArgs, + fallback: Vec, +) -> Vec { + if args.project.is_none() + || args.adopter_id.is_some() + || args.unassigned + || args.create + || args.assign_project + || args.remove + || args.remove_calldata + { + return fallback; + } + let Some(project) = args.project.as_deref() else { + return fallback; + }; + first_string_field( + data, + &[ + "assertion_adopter_id", + "assertionAdopterId", + "adopter_id", + "adopterId", + "id", + ], + ) + .map_or(fallback, |adopter_id| { + vec![format!( + "pcl contracts --project {project} --adopter-id {adopter_id}" + )] + }) +} + +pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { + name: "contracts", + command: "pcl contracts [--project ] [--adopter-id ] [--unassigned --manager
] [--create --body-template]", + description: "List and manage project contracts and assertion adopters.", + output: "contract views, adopter records, assignment results, or remove calldata", + output_policy: WorkflowOutputPolicy::MachineRaw, + legacy_examples: &[], + actions: &[ + action!( + "list_all", + true, + "GET", + "/assertion_adopters", + "pcl contracts" + ), + action!("list_project", true, "GET", "/views/projects/{project}/contracts", "pcl contracts --project ", required: ["--project"]), + action!("detail", true, "GET", "/views/projects/{project}/contracts/{adopter_id}", "pcl contracts --project --adopter-id ", required: ["--project", "--adopter-id"]), + action!("unassigned", true, "GET", "/assertion_adopters/no-project", "pcl contracts --unassigned --manager 0x...", required: ["--manager"], query: {"manager" => ""}), + action!("create", true, "POST", "/assertion_adopters", "pcl contracts --create --body-template", body_template: "contracts"), + action!("assign_project", true, "POST", "/assertion_adopters/assign-project", "pcl contracts --assign-project --body-template", body_template: "contracts_assign_project"), + action!("remove", true, "DELETE", "/projects/{project}/{aa_address}", "pcl contracts --project --aa-address 0x... --remove", required: ["--project", "--aa-address"]), + action!("remove_calldata", true, "GET", "/assertion_adopters/{aa_address}/remove-assertions-calldata", "pcl contracts --aa-address 0x... --remove-calldata --network 1 --assertion-id 0x...", required: ["--aa-address", "--assertion-id"], optional: ["--network", "--environment"], query: {"assertion_ids" => "", "network" => "", "environment" => "production|staging"}), + ], +}; diff --git a/crates/pcl/core/src/api/workflows/deployments.rs b/crates/pcl/core/src/api/workflows/deployments.rs new file mode 100644 index 0000000..eda46b9 --- /dev/null +++ b/crates/pcl/core/src/api/workflows/deployments.rs @@ -0,0 +1,56 @@ +use super::{ + super::{ + ApiCommandError, + DeploymentsArgs, + HttpMethod, + WorkflowRequest, + definitions::{ + WorkflowActionDefinition, + WorkflowDefinition, + WorkflowOutputPolicy, + }, + }, + redact_large_artifacts, + request_body, + required_project_arg, + workflow_with_body, +}; +use serde_json::Value; + +pub(in crate::api) fn deployments_request( + args: &DeploymentsArgs, +) -> Result { + let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; + let project = required_project_arg(args.project.as_deref(), "deployments", "--project")?; + if args.confirm { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/projects/{project}/confirm-deployment"), + true, + body, + vec![format!("pcl deployments --project {project}")], + )); + } + Ok(WorkflowRequest::get( + format!("/views/projects/{project}/deployments"), + true, + vec![format!("pcl releases list {project}")], + )) +} + +pub(in crate::api) fn compact_deployment_data(data: &Value) -> Value { + redact_large_artifacts(data) +} + +pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { + name: "deployments", + command: "pcl deployments --project [--confirm --body-template]", + description: "Inspect deployment state and confirm deployed assertions.", + output: "deployment view or confirmation result", + output_policy: WorkflowOutputPolicy::MachineRawHumanCompactArtifacts, + legacy_examples: &[], + actions: &[ + action!("list", true, "GET", "/views/projects/{project}/deployments", "pcl deployments --project ", required: ["--project"]), + action!("confirm", true, "POST", "/projects/{project}/confirm-deployment", "pcl deployments --project --confirm --body-template", required: ["--project"], body_template: "deployment_confirmation"), + ], +}; diff --git a/crates/pcl/core/src/api/workflows/events.rs b/crates/pcl/core/src/api/workflows/events.rs new file mode 100644 index 0000000..cb02e9f --- /dev/null +++ b/crates/pcl/core/src/api/workflows/events.rs @@ -0,0 +1,54 @@ +use super::{ + super::{ + ApiCommandError, + EventsArgs, + WorkflowRequest, + definitions::{ + WorkflowActionDefinition, + WorkflowDefinition, + WorkflowOutputPolicy, + }, + }, + push_query, + required_project_arg, +}; + +pub(in crate::api) fn events_request( + args: &EventsArgs, +) -> Result { + let project = required_project_arg(args.project.as_deref(), "events", "--project")?; + let mut request = if args.audit_log { + WorkflowRequest::get( + format!("/views/projects/{project}/audit-log"), + true, + vec![format!("pcl events --project {project}")], + ) + } else { + WorkflowRequest::get( + format!("/views/projects/{project}/events"), + true, + vec![format!("pcl events --project {project} --audit-log")], + ) + }; + push_query(&mut request.query, "page", args.page); + push_query(&mut request.query, "limit", args.limit); + push_query( + &mut request.query, + "environment", + args.environment.as_deref(), + ); + Ok(request) +} + +pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { + name: "events", + command: "pcl events --project [--audit-log]", + description: "Inspect project events and audit logs.", + output: "event or audit log data", + output_policy: WorkflowOutputPolicy::MachineRaw, + legacy_examples: &[], + actions: &[ + action!("events", true, "GET", "/views/projects/{project}/events", "pcl events --project ", required: ["--project"], optional: ["--page", "--limit", "--environment"]), + action!("audit_log", true, "GET", "/views/projects/{project}/audit-log", "pcl events --project --audit-log", required: ["--project"], optional: ["--page", "--limit", "--environment"]), + ], +}; diff --git a/crates/pcl/core/src/api/workflows/incidents.rs b/crates/pcl/core/src/api/workflows/incidents.rs new file mode 100644 index 0000000..fd750e1 --- /dev/null +++ b/crates/pcl/core/src/api/workflows/incidents.rs @@ -0,0 +1,172 @@ +use super::{ + super::{ + ApiCommandError, + HttpMethod, + IncidentsArgs, + WorkflowRequest, + definitions::{ + WorkflowActionDefinition, + WorkflowDefinition, + WorkflowOutputPolicy, + }, + }, + first_string_field, + push_query, + required_arg, +}; +use serde_json::Value; + +pub(in crate::api) fn incidents_request( + args: &IncidentsArgs, +) -> Result { + if args.all && (args.incident_id.is_some() || args.stats || args.retry_trace) { + return Err(ApiCommandError::InvalidWorkflow { + message: "--all is only supported for incident list workflows".to_string(), + }); + } + if args.stats && args.project_id.is_none() { + return Err(ApiCommandError::InvalidWorkflow { + message: "--stats requires --project-id".to_string(), + }); + } + if args.tx_id.is_some() && args.incident_id.is_none() { + return Err(ApiCommandError::InvalidWorkflow { + message: "--tx-id requires --incident-id".to_string(), + }); + } + if args.retry_trace && args.tx_id.is_none() { + return Err(ApiCommandError::InvalidWorkflow { + message: "--retry-trace requires --incident-id and --tx-id".to_string(), + }); + } + + let mut query = Vec::new(); + push_query(&mut query, "page", args.page); + push_query(&mut query, "limit", args.limit); + + if let Some(incident_id) = &args.incident_id { + if args.retry_trace { + let tx_id = required_arg(args.tx_id.as_deref(), "--tx-id")?; + return Ok(WorkflowRequest { + method: HttpMethod::Post, + path: format!("/incidents/{incident_id}/transactions/{tx_id}/trace/retry"), + query, + body: Some("{}".to_string()), + require_auth: true, + attach_auth: true, + next_actions: vec![format!( + "pcl incidents --incident-id {incident_id} --tx-id {tx_id}" + )], + }); + } + let path = if let Some(tx_id) = &args.tx_id { + format!("/views/incidents/{incident_id}/transactions/{tx_id}/trace") + } else { + format!("/views/incidents/{incident_id}") + }; + let next_actions = vec![ + "pcl incidents --limit 5".to_string(), + format!("pcl api inspect get {}", path), + ]; + return Ok(WorkflowRequest::get_with_query( + path, + query, + true, + next_actions, + )); + } + + if let Some(project_id) = &args.project_id { + if args.stats { + let path = format!("/projects/{project_id}/incidents/stats"); + return Ok(WorkflowRequest::get_with_query( + path, + query, + true, + vec![format!( + "pcl incidents --project-id {project_id} --limit 10" + )], + )); + } + push_query(&mut query, "assertionId", args.assertion_id.as_deref()); + push_query( + &mut query, + "assertionAdopterId", + args.assertion_adopter_id.as_deref(), + ); + push_query(&mut query, "environment", args.environment.as_deref()); + push_query(&mut query, "fromDate", args.from_date.as_deref()); + push_query(&mut query, "toDate", args.to_date.as_deref()); + let path = format!("/views/projects/{project_id}/incidents"); + return Ok(WorkflowRequest::get_with_query( + path, + query, + true, + vec![ + format!("pcl assertions --project-id {project_id}"), + "pcl incidents --limit 5".to_string(), + ], + )); + } + + push_query(&mut query, "network", args.network); + push_query(&mut query, "sort", args.sort.as_deref()); + push_query(&mut query, "devMode", args.dev_mode.as_deref()); + Ok(WorkflowRequest::get_with_query( + "/views/public/incidents", + query, + false, + vec![ + "pcl incidents --project-id --limit 10".to_string(), + "pcl projects list --limit 10".to_string(), + ], + )) +} + +pub(in crate::api) fn incidents_next_actions( + data: &Value, + args: &IncidentsArgs, + fallback: Vec, +) -> Vec { + if let Some(incident_id) = &args.incident_id { + if args.tx_id.is_none() + && let Some(tx_id) = data + .get("data") + .and_then(|data| data.get("invalidating_transactions")) + .and_then(Value::as_array) + .and_then(|transactions| transactions.first()) + .and_then(|transaction| { + first_string_field(transaction, &["transaction_hash", "id", "tx_id"]) + }) + { + return vec![ + format!("pcl incidents --incident-id {incident_id} --tx-id {tx_id}"), + "pcl incidents --limit 5".to_string(), + ]; + } + return fallback; + } + first_string_field(data, &["id", "incidentId", "incident_id"]).map_or(fallback, |incident_id| { + vec![ + format!("pcl incidents --incident-id {incident_id}"), + "pcl projects list --limit 10".to_string(), + ] + }) +} + +pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { + name: "incidents", + command: "pcl incidents [--project-id ] [--incident-id ] [--stats] [--limit ] [--all --output ]", + description: "List public incidents, project incidents, fetch all incident pages, inspect incident detail, incident stats, or incident trace.", + output: "incident data from /views/public/incidents, /views/projects/{projectId}/incidents, /views/incidents/{incidentId}, or /projects/{project_id}/incidents/stats", + output_policy: WorkflowOutputPolicy::MachineRaw, + legacy_examples: &[], + actions: &[ + action!("list_public", false, "GET", "/views/public/incidents", "pcl incidents --limit 5", optional: ["--page", "--limit", "--network", "--sort", "--dev-mode", "--all", "--max-pages", "--output"]), + action!("list_project", true, "GET", "/views/projects/{projectId}/incidents", "pcl incidents --project --all --limit 50 --output incidents.json", required: ["--project"], optional: ["--page", "--limit", "--assertion-id", "--adopter-id", "--environment", "--from", "--to", "--all", "--max-pages", "--output"]), + action!("stats", true, "GET", "/projects/{project_id}/incidents/stats", "pcl incidents --project --stats", required: ["--project"]), + action!("detail", true, "GET", "/views/incidents/{incidentId}", "pcl incidents --incident-id ", required: ["--incident-id"]), + action!("trace", true, "GET", "/views/incidents/{incidentId}/transactions/{txId}/trace", "pcl incidents --incident-id --tx-id ", required: ["--incident-id", "--tx-id"]), + action!("retry_trace", true, "POST", "/incidents/{incident_id}/transactions/{tx_id}/trace/retry", "pcl incidents --incident-id --tx-id --retry-trace", required: ["--incident-id", "--tx-id"], body_template: "empty_object"), + ], +}; diff --git a/crates/pcl/core/src/api/workflows/integrations.rs b/crates/pcl/core/src/api/workflows/integrations.rs new file mode 100644 index 0000000..ab5c18e --- /dev/null +++ b/crates/pcl/core/src/api/workflows/integrations.rs @@ -0,0 +1,94 @@ +use super::{ + super::{ + ApiCommandError, + HttpMethod, + IntegrationsArgs, + WorkflowRequest, + definitions::{ + WorkflowActionDefinition, + WorkflowDefinition, + WorkflowOutputPolicy, + }, + }, + body_or_empty, + request_body, + required_project_arg, + workflow_with_body, +}; + +pub(in crate::api) fn integrations_request( + args: &IntegrationsArgs, +) -> Result { + let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; + let project = required_project_arg(args.project.as_deref(), "integrations", "--project")?; + let Some(provider) = args.provider else { + return Err(ApiCommandError::InvalidWorkflowWithActions { + message: "--provider is required".to_string(), + next_actions: vec![ + "pcl integrations --project --provider slack".to_string(), + "pcl integrations --project --provider pagerduty".to_string(), + "pcl integrations --help".to_string(), + ], + }); + }; + let provider = provider.path(); + let base = format!("/projects/{project}/integrations/{provider}"); + if args.configure { + return Ok(workflow_with_body( + HttpMethod::Post, + base, + true, + body, + vec![format!( + "pcl integrations --project {project} --provider {provider}" + )], + )); + } + if args.test { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("{base}/test"), + true, + Some(body_or_empty(body)), + vec![format!( + "pcl integrations --project {project} --provider {provider}" + )], + )); + } + if args.delete { + return Ok(workflow_with_body( + HttpMethod::Delete, + base, + true, + body, + vec![format!( + "pcl integrations --project {project} --provider {provider}" + )], + )); + } + Ok(WorkflowRequest::get( + base, + true, + vec![ + format!("pcl integrations --project {project} --provider {provider} --test"), + format!( + "pcl integrations --project {project} --provider {provider} --configure --body-template" + ), + ], + )) +} + +pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { + name: "integrations", + command: "pcl integrations --project --provider [--configure|--test|--delete]", + description: "Manage Slack and PagerDuty integrations.", + output: "integration status or mutation/test results", + output_policy: WorkflowOutputPolicy::MachineRaw, + legacy_examples: &[], + actions: &[ + action!("get", true, "GET", "/projects/{project}/integrations/{provider}", "pcl integrations --project --provider slack", required: ["--project", "--provider"]), + action!("configure", true, "POST", "/projects/{project}/integrations/{provider}", "pcl integrations --project --provider slack --configure --body-template", required: ["--project", "--provider"], body_template: "slack|pagerduty"), + action!("test", true, "POST", "/projects/{project}/integrations/{provider}/test", "pcl integrations --project --provider slack --test", required: ["--project", "--provider"], body_template: "slack|pagerduty"), + action!("delete", true, "DELETE", "/projects/{project}/integrations/{provider}", "pcl integrations --project --provider slack --delete", required: ["--project", "--provider"]), + ], +}; diff --git a/crates/pcl/core/src/api/workflows/projects.rs b/crates/pcl/core/src/api/workflows/projects.rs new file mode 100644 index 0000000..dc8ae08 --- /dev/null +++ b/crates/pcl/core/src/api/workflows/projects.rs @@ -0,0 +1,186 @@ +use super::{ + super::{ + ApiCommandError, + HttpMethod, + ProjectsArgs, + WorkflowRequest, + definitions::{ + WorkflowActionDefinition, + WorkflowDefinition, + WorkflowOutputPolicy, + }, + }, + first_string_field, + project_request_body, + push_query, + required_arg, + required_project_arg, + workflow_with_body, +}; +use serde_json::{ + Value, + json, +}; + +pub(in crate::api) fn projects_next_actions(data: &Value, fallback: Vec) -> Vec { + if let Some(project_id) = data.get("project_id").and_then(Value::as_str) { + return vec![ + format!("pcl assertions --project-id {project_id}"), + format!("pcl incidents --project-id {project_id} --limit 10"), + ]; + } + first_string_field(data, &["project_id", "projectId", "id"]).map_or(fallback, |project_id| { + vec![ + format!("pcl projects show {project_id}"), + format!("pcl assertions --project-id {project_id}"), + format!("pcl incidents --project-id {project_id} --limit 10"), + ] + }) +} + +pub(in crate::api) fn projects_request( + args: &ProjectsArgs, +) -> Result { + let mut query = Vec::new(); + push_query(&mut query, "page", args.page); + push_query(&mut query, "limit", args.limit); + push_query(&mut query, "search", args.search.as_deref()); + let body = project_request_body(args)?; + + if args.create { + return Ok(workflow_with_body( + HttpMethod::Post, + "/projects", + true, + body, + vec!["pcl projects mine".to_string()], + )); + } + + if args.mine { + return Ok(WorkflowRequest::get_with_query( + "/views/projects/home", + query, + true, + vec![ + "pcl account".to_string(), + "pcl projects saved --user-id ".to_string(), + ], + )); + } + if args.saved { + let user_id = required_arg(args.user_id.as_deref(), "--user-id")?; + push_query(&mut query, "user_id", Some(user_id)); + return Ok(WorkflowRequest::get_with_query( + "/projects/saved", + query, + true, + vec!["pcl projects mine".to_string()], + )); + } + if args.project_id.is_none() + && (args.update || args.delete || args.save || args.unsave || args.resolve || args.widget) + { + required_project_arg(args.project_id.as_deref(), "projects", "--project-id")?; + } + if let Some(project_id) = &args.project_id { + if args.resolve { + return Ok(WorkflowRequest::get_with_query( + format!("/projects/resolve/{project_id}"), + query, + false, + vec![format!("pcl projects show {project_id}")], + ) + .with_optional_auth()); + } + if args.widget { + return Ok(WorkflowRequest::get( + format!("/projects/{project_id}/widget"), + true, + vec![format!("pcl projects show {project_id}")], + )); + } + if args.save || args.unsave { + return Ok(workflow_with_body( + if args.save { + HttpMethod::Post + } else { + HttpMethod::Delete + }, + "/projects/saved", + true, + Some(json!({ "project_id": project_id }).to_string()), + vec![ + format!("pcl projects show {project_id}"), + "pcl projects mine".to_string(), + ], + )); + } + if args.update { + return Ok(workflow_with_body( + HttpMethod::Put, + format!("/projects/{project_id}"), + true, + body, + vec![format!("pcl projects show {project_id}")], + )); + } + if args.delete { + return Ok(workflow_with_body( + HttpMethod::Delete, + format!("/projects/{project_id}"), + true, + body, + ["pcl projects mine"], + )); + } + return Ok(WorkflowRequest::get_with_query( + format!("/projects/{project_id}"), + query, + true, + vec![ + format!("pcl assertions --project-id {project_id}"), + format!("pcl incidents --project-id {project_id} --limit 10"), + ], + )); + } + + Ok(WorkflowRequest::get_with_query( + "/views/projects", + query, + false, + ["pcl projects show ", "pcl incidents --limit 5"], + )) +} + +pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { + name: "projects", + command: "pcl projects ", + description: "List, inspect, create, update, save, unsave, resolve, widget, and delete projects.", + output: "project explorer, your projects, project detail, saved projects, widget, or mutation result", + output_policy: WorkflowOutputPolicy::MachineRaw, + legacy_examples: &[ + "pcl projects --mine", + "pcl projects --project ", + "pcl projects --create --project-name demo --chain-id 1", + ], + actions: &[ + action!( + "explorer", + false, + "GET", + "/views/projects", + "pcl projects list --limit 10" + ), + action!("mine", true, "GET", "/views/projects/home", "pcl projects mine", aliases: ["pcl projects --mine", "pcl projects --home"]), + action!("saved", true, "GET", "/projects/saved", "pcl projects saved --user-id ", required: ["--user-id"], query: {"user_id" => ""}), + action!("detail", true, "GET", "/projects/{project_id}", "pcl projects show ", required: [""]), + action!("create", true, "POST", "/projects", "pcl projects create --project-name demo --chain-id 1", body_template: "project_create", required_body: ["project_name", "chain_id"]), + action!("update", true, "PUT", "/projects/{project_id}", "pcl projects update --field github_url=https://github.com/org/repo", required: [""], body_template: "project_update"), + action!("delete", true, "DELETE", "/projects/{project_id}", "pcl projects delete ", required: [""]), + action!("save", true, "POST", "/projects/saved", "pcl projects save ", required: [""], body_template: "project_saved"), + action!("unsave", true, "DELETE", "/projects/saved", "pcl projects unsave ", required: [""], body_template: "project_saved"), + action!("resolve", false, "GET", "/projects/resolve/{project_ref}", "pcl projects resolve ", required: [""]), + action!("widget", true, "GET", "/projects/{project_id}/widget", "pcl projects widget ", required: [""]), + ], +}; diff --git a/crates/pcl/core/src/api/workflows/protocol_manager.rs b/crates/pcl/core/src/api/workflows/protocol_manager.rs new file mode 100644 index 0000000..cf0e594 --- /dev/null +++ b/crates/pcl/core/src/api/workflows/protocol_manager.rs @@ -0,0 +1,172 @@ +use super::{ + super::{ + ApiCommandError, + HttpMethod, + ProtocolManagerArgs, + WorkflowRequest, + definitions::{ + WorkflowActionDefinition, + WorkflowDefinition, + WorkflowOutputPolicy, + }, + }, + first_string_field, + push_query, + request_body, + required_arg, + required_project_arg, + workflow_with_body, +}; +use serde_json::Value; + +pub(in crate::api) fn protocol_manager_request( + args: &ProtocolManagerArgs, +) -> Result { + let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; + let project = required_project_arg(args.project.as_deref(), "protocol-manager", "--project")?; + let base = format!("/projects/{project}/protocol-manager"); + if args.nonce { + let address = required_arg(args.address.as_deref(), "--address")?; + let mut request = WorkflowRequest::get( + format!("{base}/nonce"), + true, + vec![format!( + "pcl protocol-manager --project {project} --set --body-template" + )], + ); + push_query(&mut request.query, "address", Some(address)); + push_query(&mut request.query, "chain_id", args.chain_id); + return Ok(request); + } + if args.set { + return Ok(workflow_with_body( + HttpMethod::Post, + base, + true, + body, + vec![format!( + "pcl protocol-manager --project {project} --pending-transfer" + )], + )); + } + if args.clear { + return Ok(workflow_with_body( + HttpMethod::Delete, + base, + true, + body, + vec![format!( + "pcl protocol-manager --project {project} --nonce --address " + )], + )); + } + if args.transfer_calldata { + let new_manager = required_arg(args.new_manager.as_deref(), "--new-manager")?; + let mut request = WorkflowRequest::get( + format!("{base}/transfer-calldata"), + true, + vec![format!( + "pcl protocol-manager --project {project} --set --body-template" + )], + ); + push_query(&mut request.query, "new_manager", Some(new_manager)); + return Ok(request); + } + if args.accept_calldata { + return Ok(WorkflowRequest::get( + format!("{base}/accept-calldata"), + true, + vec![format!( + "pcl protocol-manager --project {project} --confirm-transfer --body-template" + )], + )); + } + if args.confirm_transfer { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("{base}/confirm-transfer"), + true, + body, + vec![format!( + "pcl protocol-manager --project {project} --pending-transfer" + )], + )); + } + Ok(WorkflowRequest::get( + format!("{base}/pending-transfer"), + true, + vec![ + format!("pcl protocol-manager --project {project} --nonce --address "), + format!( + "pcl protocol-manager --project {project} --transfer-calldata --new-manager " + ), + ], + )) +} + +pub(in crate::api) fn protocol_manager_next_actions( + data: &Value, + args: &ProtocolManagerArgs, + fallback: Vec, +) -> Vec { + if args.nonce + || args.set + || args.clear + || args.transfer_calldata + || args.accept_calldata + || args.confirm_transfer + { + return fallback; + } + let Some(project) = args.project.as_deref() else { + return fallback; + }; + let Some(current_manager) = first_string_field( + data, + &[ + "current_manager_address", + "currentManagerAddress", + "manager_address", + "managerAddress", + ], + ) else { + return fallback; + }; + + let mut next_actions = vec![format!( + "pcl protocol-manager --project {project} --nonce --address {current_manager}" + )]; + if data + .get("has_pending_transfer") + .or_else(|| data.get("hasPendingTransfer")) + .and_then(Value::as_bool) + .unwrap_or(false) + { + next_actions.push(format!( + "pcl protocol-manager --project {project} --accept-calldata" + )); + } else { + next_actions.push(format!( + "pcl protocol-manager --project {project} --transfer-calldata --new-manager " + )); + } + next_actions +} + +pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { + name: "protocol-manager", + command: "pcl protocol-manager --project [--nonce --address
|--set|--clear|--transfer-calldata|--accept-calldata|--pending-transfer|--confirm-transfer]", + description: "Manage protocol manager transfers and calldata.", + output: "manager state, nonce, calldata, pending transfer, or mutation result", + output_policy: WorkflowOutputPolicy::MachineRaw, + legacy_examples: &[], + actions: &[ + action!("pending_transfer", true, "GET", "/projects/{project}/protocol-manager/pending-transfer", "pcl protocol-manager --project --pending-transfer", required: ["--project"]), + action!("nonce", true, "GET", "/projects/{project}/protocol-manager/nonce", "pcl protocol-manager --project --nonce --address 0x...", required: ["--project", "--address"], optional: ["--chain-id"], query: {"address" => "
", "chain_id" => ""}), + action!("set", true, "POST", "/projects/{project}/protocol-manager", "pcl protocol-manager --project --set --body-template", required: ["--project"], body_template: "protocol_manager_set"), + action!("clear", true, "DELETE", "/projects/{project}/protocol-manager", "pcl protocol-manager --project --clear", required: ["--project"], body_template: "empty_object"), + action!("transfer_calldata", true, "GET", "/projects/{project}/protocol-manager/transfer-calldata", "pcl protocol-manager --project --transfer-calldata --new-manager 0x...", required: ["--project", "--new-manager"], query: {"new_manager" => "
"}), + action!("accept_calldata", true, "GET", "/projects/{project}/protocol-manager/accept-calldata", "pcl protocol-manager --project --accept-calldata", required: ["--project"]), + action!("confirm_transfer", true, "POST", "/projects/{project}/protocol-manager/confirm-transfer", "pcl protocol-manager --project --confirm-transfer --body-template", required: ["--project"], body_template: "protocol_manager_confirm"), + ], +}; diff --git a/crates/pcl/core/src/api/workflows/releases.rs b/crates/pcl/core/src/api/workflows/releases.rs new file mode 100644 index 0000000..bf57763 --- /dev/null +++ b/crates/pcl/core/src/api/workflows/releases.rs @@ -0,0 +1,183 @@ +use super::{ + super::{ + ApiCommandError, + HttpMethod, + ReleasesArgs, + WorkflowRequest, + definitions::{ + WorkflowActionDefinition, + WorkflowDefinition, + WorkflowOutputPolicy, + }, + }, + body_or_empty, + first_string_field, + push_query, + request_body, + required_arg, + required_project_arg, + workflow_with_body, +}; +use serde_json::Value; + +pub(in crate::api) fn releases_request( + args: &ReleasesArgs, +) -> Result { + let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; + let project = required_project_arg(args.project.as_deref(), "releases", "--project")?; + if args.preview { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/projects/{project}/releases/preview"), + true, + body, + vec![format!( + "pcl releases create {project} --body-file release.json" + )], + )); + } + if args.create { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/projects/{project}/releases"), + true, + body, + vec![format!("pcl releases list {project}")], + )); + } + if args.deploy + || args.remove + || args.deploy_calldata + || args.remove_calldata + || args.backtest_progress + || args.retry_check + { + let release_id = required_arg(args.release_id.as_deref(), "--release-id")?; + if args.backtest_progress { + return Ok(WorkflowRequest::get( + format!("/projects/{project}/releases/{release_id}/backtest-progress"), + true, + vec![format!("pcl releases show {project} {release_id}")], + )); + } + if args.retry_check { + let check_id = required_arg(args.check_id.as_deref(), "--check-id")?; + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/projects/{project}/releases/{release_id}/checks/{check_id}/retry"), + true, + Some(body_or_empty(body)), + vec![format!( + "pcl releases backtest-progress {project} {release_id}" + )], + )); + } + if args.deploy { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/projects/{project}/releases/{release_id}/deploy"), + true, + body, + vec![format!("pcl releases show {project} {release_id}")], + )); + } + if args.remove { + return Ok(workflow_with_body( + HttpMethod::Post, + format!("/projects/{project}/releases/{release_id}/remove"), + true, + body, + vec![format!("pcl releases list {project}")], + )); + } + if args.deploy_calldata { + let signer_address = required_arg(args.signer_address.as_deref(), "--signer-address")?; + let mut request = WorkflowRequest::get( + format!("/projects/{project}/releases/{release_id}/deploy-calldata"), + true, + vec![format!("pcl releases deploy {project} {release_id}")], + ); + push_query(&mut request.query, "signerAddress", Some(signer_address)); + return Ok(request); + } + return Ok(WorkflowRequest::get( + format!("/projects/{project}/releases/{release_id}/remove-calldata"), + true, + vec![format!("pcl releases remove {project} {release_id}")], + )); + } + let Some(release_id) = &args.release_id else { + return Ok(WorkflowRequest::get( + format!("/projects/{project}/releases"), + true, + vec![format!("pcl releases show {project} ")], + )); + }; + Ok(WorkflowRequest::get( + format!("/projects/{project}/releases/{release_id}"), + true, + vec![ + format!( + "pcl releases calldata deploy {project} {release_id} --signer-address " + ), + format!("pcl releases calldata remove {project} {release_id}"), + ], + )) +} + +pub(in crate::api) fn releases_next_actions( + data: &Value, + args: &ReleasesArgs, + fallback: Vec, +) -> Vec { + let Some(project) = args.project.as_deref() else { + return fallback; + }; + if args.release_id.is_some() + || args.preview + || args.create + || args.deploy + || args.remove + || args.deploy_calldata + || args.remove_calldata + || args.backtest_progress + || args.retry_check + { + return fallback; + } + + let release_id = data + .as_array() + .and_then(|items| items.first()) + .and_then(|item| first_string_field(item, &["id", "release_id", "releaseId"])) + .or_else(|| first_string_field(data, &["id", "release_id", "releaseId"])); + + release_id.map_or(fallback, |release_id| { + vec![format!("pcl releases show {project} {release_id}")] + }) +} + +pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { + name: "releases", + command: "pcl releases ", + description: "List, inspect, create, preview, deploy, check progress, retry failed checks, and remove releases.", + output: "release data, diffs, check progress, deployment confirmations, or calldata", + output_policy: WorkflowOutputPolicy::MachineRaw, + legacy_examples: &[ + "pcl releases --project ", + "pcl releases --project --release-id ", + "pcl releases --project --preview --body-file release.json", + ], + actions: &[ + action!("list", true, "GET", "/projects/{project}/releases", "pcl releases list ", required: [""]), + action!("detail", true, "GET", "/projects/{project}/releases/{release_id}", "pcl releases show ", required: ["", ""]), + action!("preview", true, "POST", "/projects/{project}/releases/preview", "pcl releases preview --body-file release.json", required: [""], body_template: "release"), + action!("create", true, "POST", "/projects/{project}/releases", "pcl releases create --body-file release.json", required: [""], body_template: "release"), + action!("backtest_progress", true, "GET", "/projects/{project}/releases/{release_id}/backtest-progress", "pcl releases backtest-progress ", required: ["", ""]), + action!("retry_check", true, "POST", "/projects/{project}/releases/{release_id}/checks/{check_id}/retry", "pcl releases retry-check ", required: ["", "", ""], body_template: "empty_object"), + action!("deploy_calldata", true, "GET", "/projects/{project}/releases/{release_id}/deploy-calldata", "pcl releases calldata deploy --signer-address 0x...", required: ["", "", "--signer-address"], query: {"signerAddress" => ""}), + action!("deploy", true, "POST", "/projects/{project}/releases/{release_id}/deploy", "pcl releases deploy --body-template", required: ["", ""], body_template: "release_deploy"), + action!("remove_calldata", true, "GET", "/projects/{project}/releases/{release_id}/remove-calldata", "pcl releases calldata remove ", required: ["", ""]), + action!("remove", true, "POST", "/projects/{project}/releases/{release_id}/remove", "pcl releases remove --body-template", required: ["", ""], body_template: "release_remove"), + ], +}; diff --git a/crates/pcl/core/src/api/workflows/search.rs b/crates/pcl/core/src/api/workflows/search.rs new file mode 100644 index 0000000..a33e630 --- /dev/null +++ b/crates/pcl/core/src/api/workflows/search.rs @@ -0,0 +1,159 @@ +use super::{ + super::{ + ApiCommandError, + SearchArgs, + WorkflowRequest, + definitions::{ + WorkflowActionDefinition, + WorkflowDefinition, + WorkflowOutputPolicy, + }, + }, + first_string_field, + push_query, + required_arg, +}; +use serde_json::Value; + +pub(in crate::api) fn search_request( + args: &SearchArgs, +) -> Result { + if args.health { + return Ok(WorkflowRequest::get( + "/health", + false, + ["pcl search --system-status"], + )); + } + if args.system_status { + return Ok(WorkflowRequest::get( + "/system-status", + false, + ["pcl search --stats"], + )); + } + if args.stats { + return Ok(WorkflowRequest::get( + "/stats", + false, + ["pcl projects list --limit 10"], + )); + } + if args.whitelist { + return Ok(WorkflowRequest::get( + "/whitelist", + true, + ["pcl projects mine"], + )); + } + if args.verified_contract { + let address = required_arg(args.address.as_deref(), "--address")?; + let chain_id = args.chain_id.ok_or_else(|| { + ApiCommandError::InvalidWorkflowWithActions { + message: "--verified-contract requires --chain-id".to_string(), + next_actions: vec![ + "pcl search --verified-contract --address
--chain-id " + .to_string(), + "pcl search --help".to_string(), + ], + } + })?; + let mut request = WorkflowRequest::get( + "/web/verified-contract", + false, + ["pcl contracts --project "], + ); + push_query(&mut request.query, "address", Some(address)); + push_query(&mut request.query, "chainId", Some(chain_id)); + return Ok(request); + } + + let query = args + .query + .as_deref() + .or(args.term.as_deref()) + .filter(|query| !query.trim().is_empty()) + .ok_or_else(|| { + ApiCommandError::InvalidWorkflowWithActions { + message: "Search query is required unless you choose a specific search action" + .to_string(), + next_actions: vec![ + "pcl search ".to_string(), + "pcl search --query ".to_string(), + "pcl search --stats".to_string(), + "pcl search --help".to_string(), + ], + } + })?; + + let mut request = WorkflowRequest::get( + "/search", + false, + [ + "pcl projects show ", + "pcl contracts --project ", + ], + ); + push_query(&mut request.query, "query", Some(query)); + Ok(request) +} + +pub(in crate::api) fn search_next_actions(data: &Value, fallback: Vec) -> Vec { + if let Some(project_id) = data + .get("projects") + .and_then(Value::as_array) + .and_then(|projects| projects.first()) + .and_then(|project| first_string_field(project, &["project_id", "projectId", "id", "slug"])) + { + return vec![ + format!("pcl projects show {project_id}"), + format!("pcl contracts --project {project_id}"), + ]; + } + if let Some(project_id) = data + .get("contracts") + .and_then(Value::as_array) + .and_then(|contracts| contracts.first()) + .and_then(|contract| { + contract.get("data").map_or_else( + || first_string_field(contract, &["related_project_id", "related_project_slug"]), + |inner| first_string_field(inner, &["related_project_id", "related_project_slug"]), + ) + }) + { + return vec![ + format!("pcl projects show {project_id}"), + format!("pcl contracts --project {project_id}"), + ]; + } + fallback +} + +pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { + name: "search", + command: "pcl search [--query ] [--stats] [--system-status] [--verified-contract --address --chain-id ]", + description: "Search projects/contracts and inspect platform metadata.", + output: "search results, stats, system status, health, whitelist, or verified contract data", + output_policy: WorkflowOutputPolicy::MachineRaw, + legacy_examples: &[], + actions: &[ + action!("query", false, "GET", "/search", "pcl search --query settler", optional: ["--query"]), + action!("stats", false, "GET", "/stats", "pcl search --stats"), + action!( + "system_status", + false, + "GET", + "/system-status", + "pcl search --system-status" + ), + action!("health", false, "GET", "/health", "pcl search --health"), + action!( + "whitelist", + true, + "GET", + "/whitelist", + "pcl search --whitelist" + ), + action!("verified_contract", false, "GET", "/web/verified-contract", "pcl search --verified-contract --address 0x... --chain-id 1", required: ["--address", "--chain-id"]), + ], +}; diff --git a/crates/pcl/core/src/api/workflows/transfers.rs b/crates/pcl/core/src/api/workflows/transfers.rs new file mode 100644 index 0000000..19fd227 --- /dev/null +++ b/crates/pcl/core/src/api/workflows/transfers.rs @@ -0,0 +1,77 @@ +use super::{ + super::{ + ApiCommandError, + HttpMethod, + TransfersArgs, + WorkflowRequest, + definitions::{ + WorkflowActionDefinition, + WorkflowDefinition, + WorkflowOutputPolicy, + }, + }, + first_string_field, + request_body, + workflow_with_body, +}; +use serde_json::Value; + +pub(in crate::api) fn transfers_request( + args: &TransfersArgs, +) -> Result { + let body = request_body(args.body.as_deref(), args.body_file.as_ref(), &args.field)?; + if args.reject { + return Ok(workflow_with_body( + HttpMethod::Post, + "/transfers/reject", + true, + body, + ["pcl transfers --pending"], + )); + } + if let Some(transfer_id) = &args.transfer_id { + return Ok(WorkflowRequest::get( + format!("/views/transfers/{transfer_id}"), + true, + ["pcl transfers --pending"], + )); + } + Ok(WorkflowRequest::get( + "/views/transfers/pending", + true, + ["pcl transfers --transfer-id "], + )) +} + +pub(in crate::api) fn transfers_next_actions( + data: &Value, + args: &TransfersArgs, + fallback: Vec, +) -> Vec { + if args.transfer_id.is_some() || args.reject { + return fallback; + } + first_string_field(data, &["transfer_id", "transferId", "id"]).map_or(fallback, |transfer_id| { + vec![format!("pcl transfers --transfer-id {transfer_id}")] + }) +} + +pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { + name: "transfers", + command: "pcl transfers [--pending|--transfer-id |--reject --body-template]", + description: "Inspect and reject protocol manager transfers.", + output: "pending transfers, transfer detail, or reject result", + output_policy: WorkflowOutputPolicy::MachineRaw, + legacy_examples: &[], + actions: &[ + action!( + "pending", + true, + "GET", + "/views/transfers/pending", + "pcl transfers --pending" + ), + action!("detail", true, "GET", "/views/transfers/{transfer_id}", "pcl transfers --transfer-id ", required: ["--transfer-id"]), + action!("reject", true, "POST", "/transfers/reject", "pcl transfers --reject --body-template", body_template: "transfer_reject"), + ], +}; diff --git a/crates/pcl/core/src/apply.rs b/crates/pcl/core/src/apply.rs index d48f0b6..feb463e 100644 --- a/crates/pcl/core/src/apply.rs +++ b/crates/pcl/core/src/apply.rs @@ -9,7 +9,12 @@ use crate::verify::{ use crate::{ DEFAULT_PLATFORM_URL, abi, - client::authenticated_client, + client::{ + ClientBuildError, + authenticated_client, + authorization_header, + ensure_fresh_auth, + }, config::CliConfig, credible_config::{ CredibleToml, @@ -21,6 +26,7 @@ use crate::{ OutputStream, ok_envelope, print_envelope, + shell_word, }, }; use alloy_primitives::Bytes; @@ -111,7 +117,7 @@ pub struct ApplyArgs { } impl ApplyArgs { - pub async fn run(&self, cli_args: &CliArgs, config: &CliConfig) -> Result<(), ApplyError> { + pub async fn run(&self, cli_args: &CliArgs, config: &mut CliConfig) -> Result<(), ApplyError> { let output_mode = cli_args.output_mode(); let root = canonicalize_root(&self.root)?; let config_path = root.join(&self.config); @@ -124,20 +130,26 @@ impl ApplyArgs { .to_string(), )); } - None => self.select_project(config).await?, + None => { + Self::ensure_fresh_auth(config, cli_args, &self.api_url).await?; + self.select_project(config).await? + } }; - let (payload, _verification_inputs) = Self::build_payload(&credible, &root)?; + let (payload, verification_inputs) = Self::build_payload(&credible, &root)?; #[cfg(feature = "credible")] - let verification = Self::verify_all_assertions(&_verification_inputs, output_mode)?; + let verification = Self::verify_all_assertions(&verification_inputs, output_mode)?; + #[cfg(not(feature = "credible"))] + let _ = verification_inputs; if self.dry_run { #[cfg(feature = "credible")] - Self::print_dry_run_output(output_mode, project_id, &payload, &verification)?; + self.print_dry_run_output(output_mode, &root, project_id, &payload, &verification)?; #[cfg(not(feature = "credible"))] - Self::print_dry_run_output(output_mode, project_id, &payload)?; + self.print_dry_run_output(output_mode, &root, project_id, &payload)?; return Ok(()); } + Self::ensure_fresh_auth(config, cli_args, &self.api_url).await?; let (http_client, base_url) = Self::build_http_client(config, &self.api_url)?; let preview = Self::call_preview(&http_client, &base_url, &project_id, &payload).await?; @@ -215,31 +227,53 @@ impl ApplyArgs { Ok(()) } + fn apply_command(&self, root: &Path, yes: bool, dry_run: bool) -> String { + let mut parts = vec![ + "pcl".to_string(), + "apply".to_string(), + "--root".to_string(), + shell_word(root.display().to_string()), + "--config".to_string(), + shell_word(self.config.display().to_string()), + ]; + if yes { + parts.push("--yes".to_string()); + } + if dry_run { + parts.push("--dry-run".to_string()); + } + if self.api_url.as_str().trim_end_matches('/') != DEFAULT_PLATFORM_URL { + parts.push("--api-url".to_string()); + parts.push(shell_word(self.api_url.as_str())); + } + parts.join(" ") + } + fn build_client(&self, config: &CliConfig) -> Result { - authenticated_client(config, &self.api_url).map_err(|e| { - match e { - crate::client::ClientBuildError::NoAuthToken => ApplyError::NoAuthToken, - crate::client::ClientBuildError::InvalidConfig(msg) => { - ApplyError::InvalidConfig(msg) - } - } - }) + authenticated_client(config, &self.api_url).map_err(client_error_to_apply) + } + + async fn ensure_fresh_auth( + config: &mut CliConfig, + cli_args: &CliArgs, + api_url: &Url, + ) -> Result<(), ApplyError> { + ensure_fresh_auth(config, api_url, cli_args) + .await + .map_err(client_error_to_apply) } fn build_http_client( config: &CliConfig, api_url: &Url, ) -> Result<(reqwest::Client, String), ApplyError> { - let auth = config.auth.as_ref().ok_or(ApplyError::NoAuthToken)?; let mut base = api_url.clone(); base.set_path("/api/v1"); let base_url = base.to_string(); let mut headers = reqwest::header::HeaderMap::new(); - let auth_value = format!("Bearer {}", auth.access_token); - let header_val = reqwest::header::HeaderValue::from_str(&auth_value) - .map_err(|e| ApplyError::InvalidConfig(format!("Invalid auth token: {e}")))?; - headers.insert(reqwest::header::AUTHORIZATION, header_val); + let header_value = authorization_header(config).map_err(client_error_to_apply)?; + headers.insert(reqwest::header::AUTHORIZATION, header_value); let http_client = reqwest::Client::builder() .default_headers(headers) @@ -251,7 +285,9 @@ impl ApplyArgs { #[cfg(feature = "credible")] fn print_dry_run_output( + &self, output_mode: OutputMode, + root: &Path, project_id: Uuid, payload: &PostProjectsProjectIdReleasesBody, verification: &VerificationSummary, @@ -271,7 +307,7 @@ impl ApplyArgs { false, None, ), - vec!["pcl apply --yes".to_string()], + vec![self.apply_command(root, true, false)], ); print_envelope(&envelope, output_mode, OutputStream::Stdout)?; } @@ -280,7 +316,9 @@ impl ApplyArgs { #[cfg(not(feature = "credible"))] fn print_dry_run_output( + &self, output_mode: OutputMode, + root: &Path, project_id: Uuid, payload: &PostProjectsProjectIdReleasesBody, ) -> Result<(), ApplyError> { @@ -289,7 +327,7 @@ impl ApplyArgs { } else { let envelope = ok_envelope( apply_data("dry_run", project_id, Some(payload), None, false, None), - vec!["pcl apply --yes".to_string()], + vec![self.apply_command(root, true, false)], ); print_envelope(&envelope, output_mode, OutputStream::Stdout)?; } @@ -627,6 +665,15 @@ fn confirm_apply() -> Result { || trimmed.eq_ignore_ascii_case("yes")) } +fn client_error_to_apply(error: ClientBuildError) -> ApplyError { + match error { + ClientBuildError::NoAuthToken => ApplyError::NoAuthToken, + ClientBuildError::ExpiredAuthToken(expires_at) => ApplyError::ExpiredAuthToken(expires_at), + ClientBuildError::AuthRefresh(error) => ApplyError::AuthRefresh(error), + ClientBuildError::InvalidConfig(message) => ApplyError::InvalidConfig(message), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pcl/core/src/auth.rs b/crates/pcl/core/src/auth.rs index 7e61e6b..a864dfd 100644 --- a/crates/pcl/core/src/auth.rs +++ b/crates/pcl/core/src/auth.rs @@ -2,6 +2,7 @@ use crate::{ DEFAULT_PLATFORM_URL, api::{ envelope_output_string, + request_id_from_headers, with_envelope_metadata, }, config::{ @@ -29,10 +30,13 @@ use indicatif::{ ProgressBar, ProgressStyle, }; -use pcl_common::args::CliArgs; +use pcl_common::args::{ + CliArgs, + OutputMode, + current_output_mode, +}; use reqwest::header::{ CONTENT_TYPE, - HeaderMap, HeaderName, HeaderValue, RETRY_AFTER, @@ -394,7 +398,10 @@ impl AuthCommand { if auth.expires_at <= chrono::Utc::now() { expired_auth = Some(auth.expires_at); } - if auth.expires_at <= chrono::Utc::now() && !json_output { + if auth.expires_at <= chrono::Utc::now() + && !json_output + && current_output_mode() == OutputMode::Human + { println!( "{} Stored auth token expired at {}. Starting a fresh login.", "⚠️".yellow(), @@ -405,7 +412,7 @@ impl AuthCommand { let client = self.api_client(); let auth_response = Self::request_auth_code(&client).await?; - if no_wait { + if no_wait || (!json_output && current_output_mode() == OutputMode::Toon) { Self::print_output( &self.login_challenge_envelope( &auth_response, @@ -524,6 +531,7 @@ impl AuthCommand { reason: AuthChallengeReason, json_output: bool, ) -> Value { + let poll_command = self.poll_command(auth_response, json_output); let mut device_url = self.effective_auth_url(); device_url.set_path("/device"); device_url @@ -542,11 +550,11 @@ impl AuthCommand { "session_id": auth_response.session_id.to_string(), "device_secret": auth_response.device_secret.as_str(), "expires_at": auth_response.expires_at.to_rfc3339(), - "poll_command": self.poll_command(auth_response, json_output), + "poll_command": poll_command, "wait_command": if json_output { - "pcl auth login --force --json" + "pcl auth login --force --json".to_string() } else { - "pcl auth login --force --toon" + self.poll_command(auth_response, false) }, }, "next_actions": [ @@ -1194,13 +1202,6 @@ async fn refresh_error_details(response: reqwest::Response) -> RefreshErrorDetai } } -fn request_id_from_headers(headers: &HeaderMap) -> Option { - headers - .get("x-request-id") - .and_then(|value| value.to_str().ok()) - .map(ToOwned::to_owned) -} - fn finish_timeout_if_needed(spinner: &ProgressBar, json_output: bool, error: &AuthError) { if !matches!(error, AuthError::Timeout(_)) { return; @@ -1466,10 +1467,7 @@ mod tests { .as_str() .is_some_and(|command| command.ends_with("--toon")) ); - assert_eq!( - toon["data"]["wait_command"], - "pcl auth login --force --toon" - ); + assert_eq!(toon["data"]["wait_command"], toon["data"]["poll_command"]); let json = cmd.login_challenge_envelope(&auth_response, AuthChallengeReason::Missing, true); assert!( diff --git a/crates/pcl/core/src/client.rs b/crates/pcl/core/src/client.rs index 0ecb5ad..a286db5 100644 --- a/crates/pcl/core/src/client.rs +++ b/crates/pcl/core/src/client.rs @@ -1,29 +1,64 @@ -use crate::config::CliConfig; +use crate::{ + auth::refresh_stored_auth, + config::{ + AUTH_EXPIRES_SOON_SECONDS, + CliConfig, + }, + error::AuthError, +}; +use chrono::{ + DateTime, + Utc, +}; use dapp_api_client::generated::client::Client as GeneratedClient; +use pcl_common::args::CliArgs; #[derive(Debug, thiserror::Error)] pub enum ClientBuildError { #[error("Run `pcl auth login` first")] NoAuthToken, + #[error( + "Stored auth token expired at {0}. Run `pcl auth refresh --toon` or `pcl auth login` again." + )] + ExpiredAuthToken(DateTime), + + #[error("Failed to refresh stored auth before building an authenticated API client: {0}")] + AuthRefresh(#[source] AuthError), + #[error("Invalid config: {0}")] InvalidConfig(String), } +pub async fn ensure_fresh_auth( + config: &mut CliConfig, + auth_url: &url::Url, + cli_args: &CliArgs, +) -> Result<(), ClientBuildError> { + let auth = config.auth.as_ref().ok_or(ClientBuildError::NoAuthToken)?; + let now = Utc::now(); + let seconds_remaining = (auth.expires_at - now).num_seconds(); + if auth.expires_at <= now || seconds_remaining <= AUTH_EXPIRES_SOON_SECONDS { + refresh_stored_auth(config, auth_url, cli_args, false) + .await + .map_err(ClientBuildError::AuthRefresh)?; + } + validate_auth(config).map(|_| ()) +} + pub fn authenticated_client( config: &CliConfig, api_url: &url::Url, ) -> Result { - let auth = config.auth.as_ref().ok_or(ClientBuildError::NoAuthToken)?; let mut base = api_url.clone(); base.set_path("/api/v1"); let base_url = base.to_string(); let mut headers = reqwest::header::HeaderMap::new(); - let auth_value = format!("Bearer {}", auth.access_token); - let header_val = reqwest::header::HeaderValue::from_str(&auth_value) - .map_err(|e| ClientBuildError::InvalidConfig(format!("Invalid auth token: {e}")))?; - headers.insert(reqwest::header::AUTHORIZATION, header_val); + headers.insert( + reqwest::header::AUTHORIZATION, + authorization_header(config)?, + ); let http_client = reqwest::Client::builder() .default_headers(headers) @@ -34,3 +69,91 @@ pub fn authenticated_client( Ok(GeneratedClient::new_with_client(&base_url, http_client)) } + +pub fn authorization_header( + config: &CliConfig, +) -> Result { + let auth = validate_auth(config)?; + let auth_value = format!("Bearer {}", auth.access_token); + reqwest::header::HeaderValue::from_str(&auth_value) + .map_err(|e| ClientBuildError::InvalidConfig(format!("Invalid auth token: {e}"))) +} + +fn validate_auth(config: &CliConfig) -> Result<&crate::config::UserAuth, ClientBuildError> { + let auth = config.auth.as_ref().ok_or(ClientBuildError::NoAuthToken)?; + if auth.access_token.trim().is_empty() { + return Err(ClientBuildError::NoAuthToken); + } + if auth.expires_at <= Utc::now() { + return Err(ClientBuildError::ExpiredAuthToken(auth.expires_at)); + } + Ok(auth) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::UserAuth; + + #[tokio::test] + async fn ensure_fresh_auth_refreshes_expired_token_before_header_use() { + let mut server = mockito::Server::new_async().await; + let _refresh = server + .mock("POST", "/api/v1/auth/refresh") + .match_body(mockito::Matcher::Json(serde_json::json!({ + "refresh_token": "old-refresh" + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"token":"new-access","refresh_token":"new-refresh","expires_at":"2030-01-01T00:00:00Z","refresh_expires_at":"2030-02-01T00:00:00Z"}"#, + ) + .create_async() + .await; + let config_dir = tempfile::tempdir().expect("temp config dir"); + let cli_args = CliArgs { + config_dir: Some(config_dir.path().to_path_buf()), + ..CliArgs::default() + }; + let mut config = CliConfig { + auth: Some(UserAuth { + access_token: "expired-token".to_string(), + refresh_token: "old-refresh".to_string(), + expires_at: DateTime::from_timestamp(1, 0).expect("valid timestamp"), + refresh_expires_at: None, + user_id: None, + wallet_address: None, + email: Some("agent@example.com".to_string()), + }), + }; + let auth_url = url::Url::parse(&server.url()).expect("mock url"); + + ensure_fresh_auth(&mut config, &auth_url, &cli_args) + .await + .expect("refresh auth"); + + let auth = config.auth.as_ref().expect("auth present"); + assert_eq!(auth.access_token, "new-access"); + assert_eq!(auth.refresh_token, "new-refresh"); + let header = authorization_header(&config).expect("auth header"); + assert_eq!(header.to_str().expect("header utf8"), "Bearer new-access"); + } + + #[test] + fn authorization_header_rejects_expired_tokens() { + let config = CliConfig { + auth: Some(UserAuth { + access_token: "expired-token".to_string(), + refresh_token: String::new(), + expires_at: DateTime::from_timestamp(1, 0).expect("valid timestamp"), + refresh_expires_at: None, + user_id: None, + wallet_address: None, + email: Some("agent@example.com".to_string()), + }), + }; + + let error = authorization_header(&config).expect_err("expired auth rejected"); + assert!(matches!(error, ClientBuildError::ExpiredAuthToken(_))); + } +} diff --git a/crates/pcl/core/src/config.rs b/crates/pcl/core/src/config.rs index 8cf8881..eade2e7 100644 --- a/crates/pcl/core/src/config.rs +++ b/crates/pcl/core/src/config.rs @@ -13,7 +13,11 @@ use chrono::{ use clap::Parser; use colored::Colorize; use dirs::home_dir; -use pcl_common::args::CliArgs; +use pcl_common::args::{ + CliArgs, + OutputMode, + current_output_mode, +}; use serde::{ Deserialize, Serialize, @@ -333,12 +337,14 @@ impl CliConfig { } // Move the directory std::fs::rename(&legacy_dir, &new_dir).map_err(ConfigError::WriteError)?; - eprintln!( - "{}: Migrated PCL config from {} to {}", - "Warning".yellow().bold(), - legacy_dir.display(), - new_dir.display() - ); + if current_output_mode() == OutputMode::Human { + eprintln!( + "{}: Migrated PCL config from {} to {}", + "Warning".yellow().bold(), + legacy_dir.display(), + new_dir.display() + ); + } return Ok(true); } Ok(false) diff --git a/crates/pcl/core/src/download.rs b/crates/pcl/core/src/download.rs index 3f54063..47b4135 100644 --- a/crates/pcl/core/src/download.rs +++ b/crates/pcl/core/src/download.rs @@ -7,18 +7,19 @@ use crate::{ DEFAULT_PLATFORM_URL, - client::authenticated_client, + client::{ + ClientBuildError, + authorization_header, + ensure_fresh_auth, + }, config::CliConfig, + error::AuthError, output::{ OutputStream, ok_envelope, print_envelope, }, }; -use dapp_api_client::generated::client::{ - Client as GeneratedClient, - types::GetViewsProjectsProjectIdAssertionsAssertionIdAssertionId, -}; use pcl_common::args::{ CliArgs, OutputMode, @@ -67,17 +68,26 @@ pub enum DownloadError { #[error("Run `pcl auth login` first")] NoAuthToken, + #[error( + "Stored auth token expired at {0}. Run `pcl auth refresh --toon` or `pcl auth login` again." + )] + ExpiredAuthToken(chrono::DateTime), + + #[error("Failed to refresh stored auth before downloading assertions: {0}")] + AuthRefresh(#[source] AuthError), + #[error("--project-id is required")] MissingIdentifier, #[error("No assertions found for project")] NoAssertionsFound, - #[error("API request to {endpoint} failed{status_part}: {body}", status_part = .status.map_or(String::new(), |s| format!(" with status {s}")))] + #[error("API request to {endpoint} failed{status_part}{request_part}: {body}", status_part = .status.map_or(String::new(), |s| format!(" with status {s}")), request_part = .request_id.as_ref().map_or(String::new(), |id| format!(" request_id {id}")))] Api { endpoint: String, status: Option, - body: String, + request_id: Option, + body: Value, }, #[error("{message}: {source}")] @@ -105,11 +115,24 @@ struct DownloadedFile { source: String, } +#[derive(Debug)] +struct AssertionSummary { + assertion_id: String, + contract_name: Option, +} + impl DownloadArgs { - pub async fn run(&self, cli_args: &CliArgs, config: &CliConfig) -> Result<(), DownloadError> { + pub async fn run( + &self, + cli_args: &CliArgs, + config: &mut CliConfig, + ) -> Result<(), DownloadError> { let output_mode = cli_args.output_mode(); - let client = self.build_client(config)?; + ensure_fresh_auth(config, &self.api_url, cli_args) + .await + .map_err(client_error_to_download)?; + let client = Self::build_client(config)?; let (project_id, project_name) = self.resolve_project(&client).await?; @@ -154,7 +177,7 @@ impl DownloadArgs { } let envelope = ok_envelope( download_data("no_assertions", project_id, project_name, None, &[], 0), - vec!["pcl assertions --project-id ".to_string()], + no_assertions_next_actions(project_id), ); print_envelope(&envelope, output_mode, OutputStream::Stdout).map_err(DownloadError::Output) } @@ -163,7 +186,7 @@ impl DownloadArgs { let output_dir = self .output_dir .clone() - .unwrap_or_else(|| PathBuf::from(format!("{project_name}-assertions"))); + .unwrap_or_else(|| default_output_dir(project_name)); std::fs::create_dir_all(&output_dir).map_err(|e| { DownloadError::Io { @@ -180,9 +203,9 @@ impl DownloadArgs { async fn download_assertions( &self, - client: &GeneratedClient, + client: &reqwest::Client, project_id: &Uuid, - assertions: &[dapp_api_client::generated::client::types::GetViewsProjectsProjectIdAssertionsResponseDataAssertionsItem], + assertions: &[AssertionSummary], output_dir: &Path, output_mode: OutputMode, ) -> Result<(Vec, usize), DownloadError> { @@ -201,14 +224,19 @@ impl DownloadArgs { .await?; let source_code = detail - .source - .as_ref() - .and_then(|s| s.source_code.clone()) - .or_else(|| detail.artifact.as_ref().map(|a| a.solidity_source.clone())); + .pointer("/source/source_code") + .and_then(Value::as_str) + .map(ToString::to_string) + .or_else(|| { + detail + .pointer("/artifact/solidity_source") + .and_then(Value::as_str) + .map(ToString::to_string) + }); if let Some(code) = source_code { let id_prefix = assertion_id.get(..8).unwrap_or(assertion_id); - let file_name = format!("{contract_name}_{id_prefix}.sol"); + let file_name = format!("{}_{}.sol", safe_file_stem(&contract_name), id_prefix); let file_path = output_dir.join(&file_name); std::fs::write(&file_path, &code).map_err(|e| { @@ -223,18 +251,22 @@ impl DownloadArgs { } let source_label = detail - .source - .as_ref() - .filter(|s| s.source_code.is_some()) + .pointer("/source/source_code") + .and_then(Value::as_str) .map_or_else( || { detail - .artifact - .as_ref() + .get("artifact") .map(|_| "artifact".to_string()) .unwrap_or_default() }, - |s| s.verification_status.to_string(), + |_| { + detail + .pointer("/source/verification_status") + .and_then(Value::as_str) + .unwrap_or("source") + .to_string() + }, ); downloaded.push(DownloadedFile { @@ -279,10 +311,7 @@ impl DownloadArgs { downloaded, skipped, ), - vec![ - format!("pcl verify --root {}", output_dir.display()), - "pcl artifacts list".to_string(), - ], + downloaded_next_actions(project_id, output_dir), ); print_envelope(&envelope, output_mode, OutputStream::Stdout)?; } @@ -290,88 +319,166 @@ impl DownloadArgs { Ok(()) } - fn build_client(&self, config: &CliConfig) -> Result { - authenticated_client(config, &self.api_url).map_err(|e| { - match e { - crate::client::ClientBuildError::NoAuthToken => DownloadError::NoAuthToken, - crate::client::ClientBuildError::InvalidConfig(msg) => { - DownloadError::InvalidConfig(msg) - } - } - }) + fn build_client(config: &CliConfig) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::HeaderName::from_static("api-version"), + reqwest::header::HeaderValue::from_static("1"), + ); + headers.insert( + reqwest::header::AUTHORIZATION, + authorization_header(config).map_err(client_error_to_download)?, + ); + + reqwest::Client::builder() + .default_headers(headers) + .build() + .map_err(|error| { + DownloadError::InvalidConfig(format!("Failed to build HTTP client: {error}")) + }) } async fn resolve_project( &self, - client: &GeneratedClient, + client: &reqwest::Client, ) -> Result<(Uuid, String), DownloadError> { let pid = self.project_id.ok_or(DownloadError::MissingIdentifier)?; - - let project = client - .get_projects_project_id(&pid, None) - .await - .map(dapp_api_client::generated::client::ResponseValue::into_inner) - .map_err(|e| { - DownloadError::Api { - endpoint: format!("/projects/{pid}"), - status: e.status().map(|s| s.as_u16()), - body: e.to_string(), - } + let project = self.get_json(client, &format!("/projects/{pid}")).await?; + let project_id = project + .get("project_id") + .or_else(|| project.get("projectId")) + .and_then(Value::as_str) + .map_or(Ok(pid), Uuid::parse_str) + .map_err(|error| { + DownloadError::InvalidConfig(format!("Invalid project ID: {error}")) })?; - - Ok((project.project_id, project.project_name.to_string())) + let project_name = project + .get("project_name") + .or_else(|| project.get("name")) + .and_then(Value::as_str) + .unwrap_or("project") + .to_string(); + + Ok((project_id, project_name)) } async fn fetch_assertions_list( &self, - client: &GeneratedClient, + client: &reqwest::Client, project_id: &Uuid, - ) -> Result< - Vec< - dapp_api_client::generated::client::types::GetViewsProjectsProjectIdAssertionsResponseDataAssertionsItem, - >, - DownloadError, - >{ - let response = client - .get_views_projects_project_id_assertions(project_id, None) - .await - .map(dapp_api_client::generated::client::ResponseValue::into_inner) - .map_err(|e| { - DownloadError::Api { - endpoint: format!("/views/projects/{project_id}/assertions"), - status: e.status().map(|s| s.as_u16()), - body: e.to_string(), - } + ) -> Result, DownloadError> { + let response = self + .get_json(client, &format!("/views/projects/{project_id}/assertions")) + .await?; + let assertions = response + .pointer("/data/assertions") + .or_else(|| response.get("assertions")) + .and_then(Value::as_array) + .ok_or_else(|| { + DownloadError::InvalidConfig( + "Invalid assertions response: missing data.assertions array".to_string(), + ) })?; - Ok(response.data.assertions) + assertions + .iter() + .map(|assertion| { + let assertion_id = assertion + .get("assertion_id") + .or_else(|| assertion.get("assertionId")) + .or_else(|| assertion.get("id")) + .and_then(Value::as_str) + .ok_or_else(|| { + DownloadError::InvalidConfig( + "Invalid assertions response: missing assertion_id".to_string(), + ) + })? + .to_string(); + let contract_name = assertion + .get("contract_name") + .or_else(|| assertion.get("contractName")) + .and_then(Value::as_str) + .map(ToString::to_string); + Ok(AssertionSummary { + assertion_id, + contract_name, + }) + }) + .collect() } async fn fetch_assertion_detail( &self, - client: &GeneratedClient, + client: &reqwest::Client, project_id: &Uuid, assertion_id: &str, - ) -> Result< - dapp_api_client::generated::client::types::GetViewsProjectsProjectIdAssertionsAssertionIdResponseData, - DownloadError, - >{ - let aid = GetViewsProjectsProjectIdAssertionsAssertionIdAssertionId::try_from(assertion_id) - .map_err(|e| DownloadError::InvalidConfig(format!("Invalid assertion ID: {e}")))?; - - let response = client - .get_views_projects_project_id_assertions_assertion_id(project_id, &aid) - .await - .map(dapp_api_client::generated::client::ResponseValue::into_inner) - .map_err(|e| { - DownloadError::Api { - endpoint: format!("/views/projects/{project_id}/assertions/{assertion_id}"), - status: e.status().map(|s| s.as_u16()), - body: e.to_string(), - } - })?; + ) -> Result { + let response = self + .get_json( + client, + &format!("/views/projects/{project_id}/assertions/{assertion_id}"), + ) + .await?; + Ok(response.get("data").cloned().unwrap_or(response)) + } - Ok(response.data) + async fn get_json( + &self, + client: &reqwest::Client, + endpoint: &str, + ) -> Result { + let url = self.endpoint_url(endpoint); + let response = client.get(url).send().await.map_err(|error| { + DownloadError::Api { + endpoint: endpoint.to_string(), + status: error.status().map(|status| status.as_u16()), + request_id: None, + body: json!(error.to_string()), + } + })?; + let status = response.status(); + let request_id = crate::api::request_id_from_headers(response.headers()); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let bytes = response.bytes().await.map_err(|error| { + DownloadError::Api { + endpoint: endpoint.to_string(), + status: Some(status.as_u16()), + request_id: request_id.clone(), + body: json!(error.to_string()), + } + })?; + let body = crate::api::response_body_value(&content_type, &bytes); + if !status.is_success() { + return Err(DownloadError::Api { + endpoint: endpoint.to_string(), + status: Some(status.as_u16()), + request_id, + body, + }); + } + Ok(body) + } + + fn endpoint_url(&self, endpoint: &str) -> url::Url { + let mut url = self.api_url.clone(); + url.set_path(&format!("/api/v1/{}", endpoint.trim_start_matches('/'))); + url + } +} + +fn client_error_to_download(error: ClientBuildError) -> DownloadError { + match error { + ClientBuildError::NoAuthToken => DownloadError::NoAuthToken, + ClientBuildError::ExpiredAuthToken(expires_at) => { + DownloadError::ExpiredAuthToken(expires_at) + } + ClientBuildError::AuthRefresh(error) => DownloadError::AuthRefresh(error), + ClientBuildError::InvalidConfig(message) => DownloadError::InvalidConfig(message), } } @@ -394,6 +501,43 @@ fn download_data( }) } +fn no_assertions_next_actions(project_id: Uuid) -> Vec { + vec![format!("pcl assertions --project-id {project_id}")] +} + +fn downloaded_next_actions(project_id: Uuid, output_dir: &Path) -> Vec { + vec![ + format!( + "Inspect downloaded Solidity files in {}", + output_dir.display() + ), + format!("pcl assertions --project-id {project_id}"), + ] +} + +fn default_output_dir(project_name: &str) -> PathBuf { + PathBuf::from(format!("{}-assertions", safe_file_stem(project_name))) +} + +fn safe_file_stem(value: &str) -> String { + let sanitized = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') { + ch + } else { + '_' + } + }) + .collect::(); + let trimmed = sanitized.trim_matches('_'); + if trimmed.is_empty() { + "assertion".to_string() + } else { + trimmed.to_string() + } +} + #[cfg(test)] mod tests { use super::*; @@ -470,4 +614,55 @@ mod tests { ]); assert!(result.is_err()); } + + #[test] + fn no_assertions_next_actions_use_concrete_project_id() { + let project_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + + assert_eq!( + no_assertions_next_actions(project_id), + vec!["pcl assertions --project-id 550e8400-e29b-41d4-a716-446655440000"] + ); + } + + #[test] + fn downloaded_next_actions_do_not_suggest_invalid_verify_command() { + let project_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let actions = downloaded_next_actions(project_id, Path::new("/tmp/pcl review download")); + + assert_eq!( + actions[0], + "Inspect downloaded Solidity files in /tmp/pcl review download" + ); + assert_eq!( + actions[1], + "pcl assertions --project-id 550e8400-e29b-41d4-a716-446655440000" + ); + assert!( + !actions + .iter() + .any(|action| action.starts_with("pcl verify")) + ); + } + + #[test] + fn default_output_dir_sanitizes_project_name() { + assert_eq!( + default_output_dir("../escape").display().to_string(), + "escape-assertions" + ); + assert_eq!( + default_output_dir("private test lea").display().to_string(), + "private_test_lea-assertions" + ); + } + + #[test] + fn safe_file_stem_removes_path_separators() { + assert_eq!( + safe_file_stem("../Allowance/Guard.sol"), + "Allowance_Guard_sol" + ); + assert_eq!(safe_file_stem(" "), "assertion"); + } } diff --git a/crates/pcl/core/src/error.rs b/crates/pcl/core/src/error.rs index 3861698..fa0bdf7 100644 --- a/crates/pcl/core/src/error.rs +++ b/crates/pcl/core/src/error.rs @@ -22,6 +22,14 @@ pub enum ApplyError { #[error("Run `pcl auth login` first")] NoAuthToken, + #[error( + "Stored auth token expired at {0}. Run `pcl auth refresh --toon` or `pcl auth login` again." + )] + ExpiredAuthToken(DateTime), + + #[error("Failed to refresh stored auth before applying release changes: {0}")] + AuthRefresh(#[source] AuthError), + #[error("{message}: {source}")] Io { message: String, diff --git a/crates/pcl/core/src/output/actions.rs b/crates/pcl/core/src/output/actions.rs index 165b700..8c1fdf1 100644 --- a/crates/pcl/core/src/output/actions.rs +++ b/crates/pcl/core/src/output/actions.rs @@ -52,6 +52,19 @@ pub fn normalize_next_actions_for_mode(value: &mut Value, mode: OutputMode) { } } +pub fn shell_word(value: impl AsRef) -> String { + let value = value.as_ref(); + if !value.is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b'/' | b'.' | b'_' | b'-' | b':' | b'@' | b'=') + }) + { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "'\\''")) +} + fn strip_mode_flags(command: &str) -> String { command .replace(" --format toon", "") @@ -65,23 +78,49 @@ fn strip_mode_flags(command: &str) -> String { fn contains_shell_syntax(command: &str) -> bool { let mut in_single_quote = false; let mut in_double_quote = false; - let mut chars = command.chars(); + let chars = command.chars().collect::>(); + let mut index = 0; - while let Some(ch) = chars.next() { + while let Some(&ch) = chars.get(index) { match ch { '\'' if !in_double_quote => in_single_quote = !in_single_quote, '"' if !in_single_quote => in_double_quote = !in_double_quote, '\\' if !in_single_quote => { - let _ = chars.next(); + index += 1; } - '>' | '<' | '|' | '&' | ';' if !in_single_quote && !in_double_quote => return true, + '<' if !in_single_quote && !in_double_quote => { + if let Some(close_index) = placeholder_close_index(&chars, index) { + index = close_index; + } else { + return true; + } + } + '>' | '|' | '&' | ';' if !in_single_quote && !in_double_quote => return true, _ => {} } + index += 1; } false } +fn placeholder_close_index(chars: &[char], open_index: usize) -> Option { + let mut index = open_index + 1; + let mut has_content = false; + + while let Some(&ch) = chars.get(index) { + match ch { + '>' if has_content => return Some(index), + '>' | '<' | '|' | '&' | ';' | '\'' | '"' => return None, + ch if ch.is_whitespace() => return None, + _ => has_content = true, + } + index += 1; + } + + None +} + #[cfg(test)] mod tests { use super::*; @@ -129,6 +168,24 @@ mod tests { ); } + #[test] + fn command_for_mode_updates_placeholder_commands() { + assert_eq!( + command_for_mode( + "pcl incidents --project --all --limit 50 --output incidents.json", + OutputMode::Toon + ), + "pcl incidents --project --all --limit 50 --output incidents.json --toon" + ); + assert_eq!( + command_for_mode( + "pcl schema get --action ", + OutputMode::Json + ), + "pcl schema get --action --json" + ); + } + #[test] fn normalize_next_actions_skips_shell_snippets() { let mut envelope = json!({ diff --git a/crates/pcl/core/src/output/human.rs b/crates/pcl/core/src/output/human.rs index a6ade7d..d47d8c9 100644 --- a/crates/pcl/core/src/output/human.rs +++ b/crates/pcl/core/src/output/human.rs @@ -96,11 +96,21 @@ fn envelope_terms_accepted(envelope: &Value) -> bool { } fn is_dangerous_or_internal_action(action: &str) -> bool { + let trimmed = action.trim(); + let has_dangerous_flag = trimmed.split_whitespace().any(|token| { + matches!( + token, + "--delete" | "--remove" | "--revoke" | "--clear" | "--logout" + ) + }); action.contains(" config delete") - || action.contains(" --delete") - || action.contains(" --remove") - || action.contains(" --revoke") - || action.contains(" --logout") + || has_dangerous_flag + || trimmed.starts_with("pcl projects delete") + || trimmed.starts_with("pcl projects unsave") + || trimmed.starts_with("pcl releases remove") + || trimmed.starts_with("pcl access revoke") + || trimmed.starts_with("pcl access member remove") + || trimmed.starts_with("pcl transfers reject") || action.starts_with("Read error.http.body") || action.starts_with("Use data.") } @@ -132,16 +142,23 @@ fn value_has_empty_results(value: &Value) -> bool { match value { Value::Array(values) => values.is_empty(), Value::Object(object) => { - if let Some(inner) = object.get("data") - && value_has_empty_results(inner) - { - return true; + if let Some(inner) = object.get("data") { + return value_has_empty_results(inner); } - object.iter().any(|(key, value)| { - !key.starts_with('_') - && (value.as_array().is_some_and(Vec::is_empty) - || value_has_empty_results(value)) - }) + + let mut saw_collection = false; + let mut all_collections_empty = true; + for (key, value) in object { + if key.starts_with('_') { + continue; + } + if let Some(items) = value.as_array() { + saw_collection = true; + all_collections_empty &= items.is_empty(); + } + } + + saw_collection && all_collections_empty } _ => false, } @@ -1628,10 +1645,10 @@ fn infer_collection_field(request_path: &str) -> String { } for field in [ "incidents", - "projects", "assertions", "contracts", "releases", + "projects", "deployments", "events", "members", @@ -1727,6 +1744,8 @@ fn render_collection_items(output: &mut String, collection: &HumanCollection<'_> "members" => render_members_table(output, collection.items), "invitations" => render_invitations_table(output, collection.items), "projects" => render_projects_table(output, collection.items), + "contracts" => render_contracts_table(output, collection.items), + "assertions" => render_assertions_table(output, collection.items), "releases" => render_releases_table(output, collection.items), "events" => render_events_table(output, collection.items), "no_hit" | "no_2xx" | "write_no_2xx" => render_coverage_table(output, collection.items), @@ -1956,6 +1975,44 @@ fn render_releases_table(output: &mut String, items: &[Value]) { ); } +fn render_contracts_table(output: &mut String, items: &[Value]) { + render_rows!( + output, + items, + format!( + "{:<28} {:<8} {:<24} {:<24} ID", + "Contract", "Chain", "Address", "Manager" + ), + "{:<28} {:<8} {:<24} {:<24} {}", + |item| pad(str_any(item, &["contract_name", "name"], "-"), 28), + item.get("chain_id") + .or_else(|| item.get("chainId")) + .map_or_else(|| "-".to_string(), human_scalar), + pad(str_any(item, &["address", "adopter_address"], "-"), 24), + pad(str_field(item, "manager"), 24), + str_any(item, &["id", "assertion_adopter_id"], "-"), + ); +} + +fn render_assertions_table(output: &mut String, items: &[Value]) { + render_rows!( + output, + items, + format!( + "{:<28} {:<12} {:<12} {:<9} ID", + "Contract", "Lifecycle", "Environment", "Instances" + ), + "{:<28} {:<12} {:<12} {:<9} {}", + |item| pad(str_any(item, &["contract_name", "name"], "-"), 28), + pad(str_field(item, "lifecycle"), 12), + pad(str_field(item, "environment"), 12), + item.get("deployment_instances") + .and_then(Value::as_array) + .map_or_else(|| "-".to_string(), |instances| instances.len().to_string()), + str_any(item, &["assertion_id", "assertionId", "id"], "-"), + ); +} + fn render_events_table(output: &mut String, items: &[Value]) { render_rows!( output, diff --git a/crates/pcl/core/src/output/mod.rs b/crates/pcl/core/src/output/mod.rs index 8fc4b61..598efb8 100644 --- a/crates/pcl/core/src/output/mod.rs +++ b/crates/pcl/core/src/output/mod.rs @@ -7,6 +7,7 @@ pub use actions::{ NextAction, command_for_mode, normalize_next_actions_for_mode, + shell_word, }; pub use envelope::{ ENVELOPE_SCHEMA_VERSION, diff --git a/crates/pcl/core/src/surface.rs b/crates/pcl/core/src/surface.rs index 16b80f8..e4d82b6 100644 --- a/crates/pcl/core/src/surface.rs +++ b/crates/pcl/core/src/surface.rs @@ -9,18 +9,26 @@ use crate::{ api::{ api_manifest, envelope_output_string, + request_id_from_headers, response_body_value, with_envelope_metadata, }, + auth::refresh_stored_auth, config::{ AUTH_EXPIRES_SOON_SECONDS, CliConfig, UserAuth, }, + error::AuthError, + output::shell_word, request_log, }; use chrono::Utc; -use pcl_common::args::CliArgs; +use pcl_common::args::{ + CliArgs, + OutputMode, + current_output_mode, +}; use reqwest::header::{ HeaderMap, HeaderName, @@ -82,6 +90,9 @@ pub enum ProductSurfaceError { #[error("Stored auth token expired at {0}")] ExpiredAuthToken(chrono::DateTime), + #[error("Failed to refresh stored auth before running the command: {0}")] + AuthRefresh(#[source] AuthError), + #[error("{0}")] InvalidInput(String), @@ -122,6 +133,7 @@ impl ProductSurfaceError { match self { Self::NoAuthToken => "auth.no_token", Self::ExpiredAuthToken(_) => "auth.expired_token", + Self::AuthRefresh(_) => "auth.refresh_failed", Self::InvalidInput(_) => "input.invalid", Self::Io { .. } => "io.failed", Self::Json(_) => "json.failed", @@ -184,7 +196,6 @@ impl ProductSurfaceError { with_envelope_metadata(json!({ "status": "error", "error": error, - "recoverable": self.recoverable(), "next_actions": self.next_actions(), })) } @@ -195,9 +206,16 @@ impl ProductSurfaceError { fn next_actions(&self) -> Vec { match self { - Self::NoAuthToken | Self::ExpiredAuthToken(_) => { + Self::NoAuthToken => { vec!["pcl auth login".to_string(), "pcl doctor".to_string()] } + Self::ExpiredAuthToken(_) | Self::AuthRefresh(_) => { + vec![ + "pcl auth refresh".to_string(), + "pcl auth login --force".to_string(), + "pcl doctor".to_string(), + ] + } Self::InvalidInput(message) if message.starts_with("Unknown job") => { vec![ "pcl jobs list".to_string(), @@ -481,19 +499,7 @@ impl DoctorArgs { "ok" }; - let next_actions = if status == "error" { - json!([ - "Check --api-url or PCL_API_URL", - "pcl requests list --limit 20", - "pcl whoami", - ]) - } else { - json!([ - "pcl whoami", - "pcl workflows", - "pcl requests list --limit 20", - ]) - }; + let next_actions = doctor_next_actions(status, config.auth.as_ref()); print_output( &json!({ @@ -672,12 +678,15 @@ impl SchemaArgs { let schemas = commands .iter() .filter_map(|command| { + command["output_policy"].as_str()?; let command_text = command["command"].as_str()?; let workflow = command_text.split_whitespace().nth(1)?; Some(json!({ "workflow": workflow, "command": command_text, "description": command["description"], + "output": command["output"], + "output_policy": command["output_policy"], "actions": command["actions"].as_array().map_or(0, Vec::len), })) }) @@ -793,7 +802,7 @@ impl JobsArgs { impl ExportArgs { pub async fn run( &self, - config: &CliConfig, + config: &mut CliConfig, cli_args: &CliArgs, json_output: bool, ) -> Result<(), ProductSurfaceError> { @@ -807,7 +816,7 @@ impl ExportArgs { async fn export_incidents( args: &ExportIncidentsArgs, - config: &CliConfig, + config: &mut CliConfig, cli_args: &CliArgs, json_output: bool, ) -> Result<(), ProductSurfaceError> { @@ -836,7 +845,13 @@ async fn export_incidents( .unwrap_or_else(|| artifact_dir(cli_args).join("incident-export-checkpoint.json")); let plan = export_plan(args, &out, &errors, &checkpoint); let job_id = incident_export_job_id(args, &checkpoint); - let resume_command = incident_export_resume_command(args, &out, &errors, &checkpoint); + let output_mode = if json_output { + OutputMode::Json + } else { + current_output_mode() + }; + let resume_command = + incident_export_resume_command(args, &out, &errors, &checkpoint, output_mode); if args.dry_run { return print_output( @@ -856,6 +871,14 @@ async fn export_incidents( ensure_parent_dir(&out)?; ensure_parent_dir(&errors)?; ensure_parent_dir(&checkpoint)?; + ensure_export_auth( + config, + cli_args, + &args.api_url, + args.project_id.is_some(), + args.allow_unauthenticated, + ) + .await?; let start_page = if args.resume { read_checkpoint_page(&checkpoint).unwrap_or(args.page) @@ -1301,6 +1324,7 @@ fn incident_export_resume_command( out: &Path, errors: &Path, checkpoint: &Path, + output_mode: OutputMode, ) -> String { let mut parts = vec![ "pcl".to_string(), @@ -1339,23 +1363,15 @@ fn incident_export_resume_command( if args.allow_unauthenticated { parts.push("--allow-unauthenticated".to_string()); } + match output_mode { + OutputMode::Human => {} + OutputMode::Toon => parts.push("--toon".to_string()), + OutputMode::Json => parts.push("--json".to_string()), + } parts.join(" ") } -fn shell_word(value: impl AsRef) -> String { - let value = value.as_ref(); - if !value.is_empty() - && value.bytes().all(|byte| { - byte.is_ascii_alphanumeric() - || matches!(byte, b'/' | b'.' | b'_' | b'-' | b':' | b'@' | b'=') - }) - { - return value.to_string(); - } - format!("'{}'", value.replace('\'', "'\\''")) -} - pub fn print_llms_guide(json_output: bool) -> Result<(), ProductSurfaceError> { print_output( &json!({ @@ -1397,6 +1413,7 @@ fn llms_guide() -> Value { "consumption_order": [ "pcl --toon --llms", "pcl doctor --toon", + "pcl auth ensure --toon", "pcl whoami --toon", "pcl workflows --toon", "pcl schema list --toon", @@ -1543,6 +1560,40 @@ fn auth_check_status(auth: Option<&UserAuth>) -> &'static str { } } +fn doctor_next_actions(status: &str, auth: Option<&UserAuth>) -> Vec { + if status == "error" { + return vec![ + "Check --api-url or PCL_API_URL".to_string(), + "pcl requests list --limit 20".to_string(), + "pcl doctor --offline".to_string(), + ]; + } + + match auth_check_status(auth) { + "missing" => { + vec![ + "pcl auth ensure".to_string(), + "pcl auth login".to_string(), + "pcl workflows".to_string(), + ] + } + "warning" => { + vec![ + "pcl auth ensure".to_string(), + "pcl auth refresh".to_string(), + "pcl auth login --force".to_string(), + ] + } + _ => { + vec![ + "pcl whoami".to_string(), + "pcl workflows".to_string(), + "pcl requests list --limit 20".to_string(), + ] + } + } +} + fn auth_value(auth: Option<&UserAuth>) -> Value { let Some(auth) = auth else { return json!({ @@ -1767,6 +1818,29 @@ fn export_plan(args: &ExportIncidentsArgs, out: &Path, errors: &Path, checkpoint }) } +async fn ensure_export_auth( + config: &mut CliConfig, + cli_args: &CliArgs, + api_url: &url::Url, + require_auth: bool, + allow_unauthenticated: bool, +) -> Result<(), ProductSurfaceError> { + if allow_unauthenticated || !require_auth { + return Ok(()); + } + let Some(auth) = &config.auth else { + return Err(ProductSurfaceError::NoAuthToken); + }; + let now = Utc::now(); + let seconds_remaining = (auth.expires_at - now).num_seconds(); + if auth.expires_at <= now || seconds_remaining <= AUTH_EXPIRES_SOON_SECONDS { + refresh_stored_auth(config, api_url, cli_args, false) + .await + .map_err(ProductSurfaceError::AuthRefresh)?; + } + Ok(()) +} + fn default_headers( config: &CliConfig, require_auth: bool, @@ -1875,24 +1949,6 @@ fn build_api_url(base: &url::Url, path: &str) -> Result Option { - [ - "x-request-id", - "x-correlation-id", - "x-amzn-requestid", - "cf-ray", - "request-id", - ] - .into_iter() - .find_map(|name| { - headers - .get(name) - .and_then(|value| value.to_str().ok()) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) - }) -} - fn extract_items(body: &Value, field: &str) -> Vec { body.get(field) .or_else(|| body.pointer(&format!("/data/{field}"))) @@ -2037,12 +2093,67 @@ mod tests { assert!(matches!(error, ProductSurfaceError::ExpiredAuthToken(_))); } + #[test] + fn expired_auth_next_actions_prefer_refresh() { + let error = ProductSurfaceError::ExpiredAuthToken( + chrono::Utc::now() - chrono::Duration::minutes(1), + ); + + assert_eq!( + error.next_actions(), + vec![ + "pcl auth refresh".to_string(), + "pcl auth login --force".to_string(), + "pcl doctor".to_string(), + ] + ); + } + + #[test] + fn surface_error_envelope_keeps_recoverable_inside_error_object() { + let envelope = ProductSurfaceError::InvalidInput("bad input".to_string()).json_envelope(); + + assert_eq!(envelope["status"], "error"); + assert_eq!(envelope["error"]["recoverable"], true); + assert!(envelope.get("recoverable").is_none(), "{envelope}"); + } + + #[test] + fn doctor_next_actions_recover_missing_or_expired_auth() { + assert_eq!( + doctor_next_actions("warning", None), + vec![ + "pcl auth ensure".to_string(), + "pcl auth login".to_string(), + "pcl workflows".to_string(), + ] + ); + + let auth = UserAuth { + access_token: "expired-token".to_string(), + refresh_token: "refresh-token".to_string(), + expires_at: chrono::Utc::now() - chrono::Duration::minutes(1), + refresh_expires_at: None, + user_id: None, + wallet_address: None, + email: Some("agent@example.com".to_string()), + }; + assert_eq!( + doctor_next_actions("warning", Some(&auth)), + vec![ + "pcl auth ensure".to_string(), + "pcl auth refresh".to_string(), + "pcl auth login --force".to_string(), + ] + ); + } + #[test] fn schema_finds_action_contract() { let commands = api_manifest()["commands"].as_array().cloned().unwrap(); let schema = find_workflow_schema(&commands, "incidents").unwrap(); assert!(schema["actions"].as_array().unwrap().iter().any(|action| { - action["name"] == "list_public" && action["example"] == "pcl incidents --limit 5" + action["name"] == "list_public" && action["example"] == "pcl incidents --limit 5 --toon" })); } @@ -2078,6 +2189,16 @@ mod tests { .iter() .any(|command| command == "pcl api manifest --toon") ); + let consumption_order = guide["consumption_order"].as_array().unwrap(); + let auth_ensure_position = consumption_order + .iter() + .position(|command| command == "pcl auth ensure --toon") + .expect("auth ensure in consumption order"); + let whoami_position = consumption_order + .iter() + .position(|command| command == "pcl whoami --toon") + .expect("whoami in consumption order"); + assert!(auth_ensure_position < whoami_position); assert!( guide["command_surfaces"]["state"] .as_array() @@ -2166,12 +2287,24 @@ mod tests { Path::new("/tmp/pcl artifacts/incidents.jsonl"), Path::new("/tmp/pcl artifacts/errors.jsonl"), Path::new("/tmp/pcl artifacts/checkpoint.json"), + OutputMode::Human, ); assert!(command.contains("--resume")); assert!(command.contains("'project one'")); assert!(command.contains("'/tmp/pcl artifacts/incidents.jsonl'")); assert!(command.contains("--continue-on-error")); + assert!(!command.contains("--toon")); + assert!( + incident_export_resume_command( + &args, + Path::new("/tmp/pcl artifacts/incidents.jsonl"), + Path::new("/tmp/pcl artifacts/errors.jsonl"), + Path::new("/tmp/pcl artifacts/checkpoint.json"), + OutputMode::Toon, + ) + .contains("--toon") + ); } #[tokio::test] @@ -2224,7 +2357,8 @@ mod tests { allow_unauthenticated: true, }; - export_incidents(&args, &CliConfig::default(), &cli_args, true) + let mut config = CliConfig::default(); + export_incidents(&args, &mut config, &cli_args, true) .await .unwrap(); @@ -2235,6 +2369,84 @@ mod tests { assert_eq!(fs::read_to_string(errors).unwrap(), ""); } + #[tokio::test] + async fn incident_export_refreshes_expired_project_auth_before_request() { + let mut server = mockito::Server::new_async().await; + let refresh = server + .mock("POST", "/api/v1/auth/refresh") + .match_header("authorization", Matcher::Missing) + .match_body(Matcher::Json(json!({ "refresh_token": "old_refresh" }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"token":"new_access","refresh_token":"new_refresh","expires_at":"2030-01-01T00:00:00Z","refresh_expires_at":"2030-02-01T00:00:00Z"}"#, + ) + .expect(1) + .create_async() + .await; + let query = Matcher::AllOf(vec![ + Matcher::UrlEncoded("page".into(), "1".into()), + Matcher::UrlEncoded("limit".into(), "50".into()), + ]); + let export = server + .mock("GET", "/api/v1/views/projects/project-1/incidents") + .match_header("authorization", "Bearer new_access") + .match_query(query) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"incidents":[{"id":"i1"}]}"#) + .expect(1) + .create_async() + .await; + + let temp = tempdir().unwrap(); + let cli_args = CliArgs { + config_dir: Some(temp.path().join("config")), + ..Default::default() + }; + let mut config = CliConfig { + auth: Some(UserAuth { + access_token: "old_access".to_string(), + refresh_token: "old_refresh".to_string(), + expires_at: chrono::Utc::now() - chrono::Duration::minutes(1), + refresh_expires_at: None, + user_id: None, + wallet_address: None, + email: Some("agent@example.com".to_string()), + }), + }; + config.write_to_file(&cli_args).unwrap(); + let out = temp.path().join("incidents.jsonl"); + let errors = temp.path().join("errors.jsonl"); + let checkpoint = temp.path().join("checkpoint.json"); + let args = ExportIncidentsArgs { + project_id: Some("project-1".to_string()), + environment: None, + page: 1, + limit: 50, + max_pages: 1, + out: Some(out), + errors: Some(errors), + checkpoint: Some(checkpoint), + resume: false, + continue_on_error: false, + max_retries: 0, + dry_run: false, + api_url: server.url().parse().unwrap(), + allow_unauthenticated: false, + }; + + export_incidents(&args, &mut config, &cli_args, true) + .await + .unwrap(); + + refresh.assert_async().await; + export.assert_async().await; + let auth = config.auth.as_ref().expect("refreshed auth"); + assert_eq!(auth.access_token, "new_access"); + assert_eq!(auth.refresh_token, "new_refresh"); + } + #[tokio::test] async fn incident_export_records_failed_job_after_network_failure() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -2268,7 +2480,8 @@ mod tests { allow_unauthenticated: true, }; - let error = export_incidents(&args, &CliConfig::default(), &cli_args, true) + let mut config = CliConfig::default(); + let error = export_incidents(&args, &mut config, &cli_args, true) .await .unwrap_err(); assert!(matches!( @@ -2336,7 +2549,8 @@ mod tests { allow_unauthenticated: true, }; - export_incidents(&args, &CliConfig::default(), &cli_args, true) + let mut config = CliConfig::default(); + export_incidents(&args, &mut config, &cli_args, true) .await .unwrap(); diff --git a/crates/pcl/core/src/verify.rs b/crates/pcl/core/src/verify.rs index 2434932..92aa0ab 100644 --- a/crates/pcl/core/src/verify.rs +++ b/crates/pcl/core/src/verify.rs @@ -10,6 +10,7 @@ use crate::{ OutputStream, ok_envelope, print_envelope, + shell_word, }, }; use alloy_json_abi::JsonAbi; @@ -126,6 +127,7 @@ impl VerifyArgs { ); } } else if summary.failed == 0 { + let next_action = self.apply_dry_run_command(&root); let envelope = ok_envelope( json!({ "outcome": "success", @@ -134,7 +136,7 @@ impl VerifyArgs { "failed": summary.failed, "assertions": &summary.assertions, }), - vec!["pcl apply --dry-run".to_string()], + vec![next_action], ); print_envelope(&envelope, output_mode, OutputStream::Stdout)?; } @@ -146,6 +148,19 @@ impl VerifyArgs { Ok(()) } + fn apply_dry_run_command(&self, root: &Path) -> String { + [ + "pcl".to_string(), + "apply".to_string(), + "--root".to_string(), + shell_word(root.display().to_string()), + "--config".to_string(), + shell_word(self.config.display().to_string()), + "--dry-run".to_string(), + ] + .join(" ") + } + fn build_single(&self, assertion: &str, root: &Path) -> Result, VerifyError> { let contract_name = parse_assertion_name(assertion); let output = BuildAndFlattenArgs { diff --git a/scripts/agent-smoke.sh b/scripts/agent-smoke.sh index 93cba28..63c1b07 100755 --- a/scripts/agent-smoke.sh +++ b/scripts/agent-smoke.sh @@ -7,7 +7,10 @@ cd "$repo_root" cargo build -q -p pcl bin="$repo_root/target/debug/pcl" config_dir="$(mktemp -d)" -trap 'rm -rf "$config_dir"' EXIT +missing_auth_config_dir="$(mktemp -d)" +expired_auth_config_dir="$(mktemp -d)" +verify_project_dir="$(mktemp -d)" +trap 'rm -rf "$config_dir" "$missing_auth_config_dir" "$expired_auth_config_dir" "$verify_project_dir"' EXIT cat > "$config_dir/config.toml" <<'CONFIG' [auth] @@ -17,6 +20,14 @@ expires_at = 4102444800 email = "agent-smoke@example.com" CONFIG +cat > "$expired_auth_config_dir/config.toml" <<'CONFIG' +[auth] +access_token = "expired-token" +refresh_token = "agent-smoke-refresh-token" +expires_at = 1 +email = "agent-smoke@example.com" +CONFIG + json_envelope() { "$bin" --config-dir "$config_dir" --json "$@" | python3 -c 'import json, sys doc = json.load(sys.stdin) @@ -29,6 +40,12 @@ toon_envelope() { "$bin" --config-dir "$config_dir" --toon "$@" | grep -q "schema_version: pcl.envelope.v1" } +toon_ok() { + output="$("$bin" --config-dir "$config_dir" --toon "$@")" + grep -q "schema_version: pcl.envelope.v1" <<<"$output" + grep -q "status: ok" <<<"$output" +} + toon_error() { set +e output="$("$bin" --config-dir "$config_dir" --toon "$@" 2>&1 >/dev/null)" @@ -39,9 +56,23 @@ toon_error() { grep -q "status: error" <<<"$output" } +toon_error_starts_with_envelope() { + set +e + output="$("$bin" --config-dir "$1" --toon "${@:2}" 2>&1)" + status=$? + set -e + test "$status" -ne 0 + test "$(head -n 1 <<<"$output")" = "status: error" + grep -q "schema_version: pcl.envelope.v1" <<<"$output" +} + toon_envelope --llms +toon_ok --help toon_envelope llms toon_envelope doctor --offline +missing_auth_doctor="$("$bin" --config-dir "$missing_auth_config_dir" --toon doctor --offline)" +grep -q "pcl auth ensure --toon" <<<"$missing_auth_doctor" +PCL_AUTH_URL=http://127.0.0.1:9 toon_error_starts_with_envelope "$expired_auth_config_dir" auth login toon_envelope auth ensure toon_envelope whoami toon_envelope workflows @@ -56,6 +87,18 @@ toon_envelope access invite project-1 --body-template --dry-run toon_envelope completions bash toon_error build +"$bin" verify --help >/dev/null +cp -R "$repo_root/crates/pcl/cli/tests/fixtures/verify-project/." "$verify_project_dir/" +"$bin" --config-dir "$config_dir" --json apply --root "$verify_project_dir" --dry-run | 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 +verification = doc.get("data", {}).get("verification") +assert verification and verification.get("status") == "success", doc +assert verification.get("passed") == 1, doc +' >/dev/null + json_envelope llms +json_envelope --help json_envelope api manifest json_envelope completions bash From 9e01cf2d3cc2b4bf20ec531cca42cec47a8f442c Mon Sep 17 00:00:00 2001 From: "Odysseas.eth" Date: Wed, 20 May 2026 16:49:49 -0400 Subject: [PATCH 4/4] Modularize API workflow layer --- AGENTS.md | 3 +- README.md | 22 +- crates/pcl/cli/src/cli.rs | 13 +- crates/pcl/cli/tests/auth_output.rs | 218 -- crates/pcl/cli/tests/parse_output.rs | 34 +- crates/pcl/core/src/api.rs | 2472 +---------------- crates/pcl/core/src/api/definitions.rs | 6 +- crates/pcl/core/src/api/envelopes.rs | 117 + crates/pcl/core/src/api/error.rs | 435 +++ crates/pcl/core/src/api/input.rs | 143 + crates/pcl/core/src/api/manifest.rs | 4 +- crates/pcl/core/src/api/method.rs | 42 + crates/pcl/core/src/api/operations.rs | 189 ++ crates/pcl/core/src/api/runner.rs | 1411 ++++++++++ crates/pcl/core/src/api/runtime_types.rs | 117 + crates/pcl/core/src/api/tests.rs | 910 ++---- crates/pcl/core/src/api/transport.rs | 116 + crates/pcl/core/src/api/workflow_options.rs | 49 + crates/pcl/core/src/api/workflows.rs | 36 +- crates/pcl/core/src/api/workflows/access.rs | 17 +- crates/pcl/core/src/api/workflows/account.rs | 19 +- .../pcl/core/src/api/workflows/assertions.rs | 19 +- .../pcl/core/src/api/workflows/contracts.rs | 19 +- .../pcl/core/src/api/workflows/deployments.rs | 19 +- crates/pcl/core/src/api/workflows/events.rs | 19 +- .../pcl/core/src/api/workflows/incidents.rs | 118 +- .../core/src/api/workflows/integrations.rs | 19 +- crates/pcl/core/src/api/workflows/projects.rs | 17 +- .../src/api/workflows/protocol_manager.rs | 19 +- crates/pcl/core/src/api/workflows/releases.rs | 17 +- crates/pcl/core/src/api/workflows/search.rs | 19 +- .../pcl/core/src/api/workflows/transfers.rs | 19 +- crates/pcl/core/src/download.rs | 2 +- crates/pcl/core/src/surface.rs | 11 +- scripts/agent-smoke.sh | 7 +- 35 files changed, 3193 insertions(+), 3504 deletions(-) create mode 100644 crates/pcl/core/src/api/envelopes.rs create mode 100644 crates/pcl/core/src/api/error.rs create mode 100644 crates/pcl/core/src/api/input.rs create mode 100644 crates/pcl/core/src/api/method.rs create mode 100644 crates/pcl/core/src/api/operations.rs create mode 100644 crates/pcl/core/src/api/runner.rs create mode 100644 crates/pcl/core/src/api/runtime_types.rs create mode 100644 crates/pcl/core/src/api/transport.rs create mode 100644 crates/pcl/core/src/api/workflow_options.rs diff --git a/AGENTS.md b/AGENTS.md index 04d1f8d..7257d95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,11 +61,10 @@ For mutations: ```bash pcl --body-template -pcl --dry-run ... pcl --body-file body.json ``` -Use typed flags first. Use `--field key=value` for simple payload fields. Use `--body-file` for nested payloads. Avoid constructing opaque inline JSON unless the command has no typed surface yet. +Use typed flags first. Use `--field key=value` for simple payload fields. Use `--body-file` for nested payloads. Use `--body-template` before nested mutation payloads. Avoid constructing opaque inline JSON unless the command has no typed surface yet. ## Raw API Calls diff --git a/README.md b/README.md index 8840447..1e8a422 100644 --- a/README.md +++ b/README.md @@ -207,8 +207,8 @@ pcl incidents --incident-id --toon pcl incidents --incident-id --tx-id --retry-trace --toon pcl projects list --limit 10 --toon pcl projects show --toon -pcl projects create --project-name demo --chain-id 1 --dry-run --toon -pcl projects update --field github_url=https://github.com/org/repo --dry-run --toon +pcl projects create --body-template --toon +pcl projects update --body-template --toon pcl assertions --project-id --toon pcl assertions --adopter-address 0x... --network 1 --toon pcl account --toon @@ -225,8 +225,7 @@ pcl search --query settler --toon ### Mutation Rules -Use `--dry-run` before writes and `--body-template` before constructing mutation payloads. -`--dry-run` is a planning mode, not an enforced confirmation gate; rerunning without it executes the request. +Use `--body-template` before constructing nested mutation payloads. Prefer typed flags, then `--field key=value`, then `--body-file` for nested payloads. ```bash @@ -242,8 +241,7 @@ For complex bodies: 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 --toon`. -4. Execute without `--dry-run` only after the request plan is correct. +3. Run the write with `--body-file --toon` once the payload is correct. ### Raw API Fallback @@ -256,12 +254,12 @@ For simple JSON object bodies, repeated `--field key=value` works on raw `pcl ap ```bash 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 -pcl incidents --all --limit 50 --output incidents.json -pcl export incidents --project-id --environment production --out incidents.jsonl --errors errors.jsonl --resume -pcl assertions --project +pcl incidents --limit 5 --toon +pcl projects create --body-template --toon +pcl incidents --project --environment production --toon +pcl incidents --all --limit 50 --output incidents.json --toon +pcl export incidents --project-id --environment production --out incidents.jsonl --errors errors.jsonl --resume --toon +pcl assertions --project --toon pcl account --logout ``` diff --git a/crates/pcl/cli/src/cli.rs b/crates/pcl/cli/src/cli.rs index b71f5a6..8f0dd79 100644 --- a/crates/pcl/cli/src/cli.rs +++ b/crates/pcl/cli/src/cli.rs @@ -359,17 +359,8 @@ mod tests { .unwrap(); assert!(matches!(incidents.command, Commands::Incidents(_))); - let projects = Cli::try_parse_from([ - "pcl", - "projects", - "--dry-run", - "--create", - "--project-name", - "demo", - "--chain-id", - "1", - ]) - .unwrap(); + let projects = + Cli::try_parse_from(["pcl", "projects", "--create", "--body-template"]).unwrap(); assert!(matches!(projects.command, Commands::Projects(_))); let manager = Cli::try_parse_from([ diff --git a/crates/pcl/cli/tests/auth_output.rs b/crates/pcl/cli/tests/auth_output.rs index 28f70aa..b43344e 100644 --- a/crates/pcl/cli/tests/auth_output.rs +++ b/crates/pcl/cli/tests/auth_output.rs @@ -1148,224 +1148,6 @@ fn completions_can_run_with_invalid_config_without_overwriting_file() { original_config ); } - -#[test] -fn api_dry_run_project_create_does_not_hit_network() { - 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"), - "--json", - "api", - "--api-url", - "http://127.0.0.1:9", - "--dry-run", - "projects", - "--create", - "--project-name", - "demo", - "--chain-id", - "1", - ]) - .output() - .expect("run pcl api projects dry-run"); - - assert!( - output.status.success(), - "dry-run attempted network/auth path: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); - let envelope: serde_json::Value = serde_json::from_str(&stdout).expect("json envelope"); - assert_eq!(envelope["status"], "ok"); - assert_eq!(envelope["data"]["dry_run"], true); - assert_eq!(envelope["data"]["request"]["method"], "POST"); - assert_eq!(envelope["data"]["request"]["path"], "/projects"); -} - -#[test] -fn api_dry_run_assertion_registered_does_not_hit_network() { - 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"), - "--json", - "api", - "--api-url", - "http://127.0.0.1:9", - "--dry-run", - "assertions", - "--project-id", - "project-1", - "--registered", - ]) - .output() - .expect("run pcl api assertions dry-run"); - - assert!( - output.status.success(), - "dry-run attempted network/auth path: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); - let envelope: serde_json::Value = serde_json::from_str(&stdout).expect("json envelope"); - assert_eq!(envelope["status"], "ok"); - assert_eq!(envelope["data"]["dry_run"], true); - assert_eq!(envelope["data"]["request"]["method"], "GET"); - assert_eq!( - envelope["data"]["request"]["path"], - "/projects/project-1/registered-assertions" - ); -} - -#[test] -fn api_dry_run_auth_metadata_keeps_required_separate_from_attachment() { - 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"), - "--json", - "api", - "--allow-unauthenticated", - "--dry-run", - "projects", - "--home", - ]) - .output() - .expect("run pcl api dry-run"); - - assert!( - output.status.success(), - "command failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); - let envelope: serde_json::Value = serde_json::from_str(&stdout).expect("json envelope"); - assert_eq!(envelope["data"]["request"]["auth"]["required"], true); - assert_eq!( - envelope["data"]["request"]["auth"]["will_attach_stored_token"], - false - ); -} - -#[test] -fn top_level_project_workflow_matches_api_alias() { - let temp_dir = tempfile::tempdir().expect("create temp config dir"); - write_valid_auth_config(temp_dir.path()); - - let api_output = Command::new(env!("CARGO_BIN_EXE_pcl")) - .args([ - "--config-dir", - temp_dir.path().to_str().expect("utf-8 temp path"), - "--json", - "api", - "--api-url", - "http://127.0.0.1:9", - "--dry-run", - "projects", - "--create", - "--project-name", - "demo", - "--chain-id", - "1", - ]) - .output() - .expect("run pcl api projects dry-run"); - - let top_level_output = Command::new(env!("CARGO_BIN_EXE_pcl")) - .args([ - "--config-dir", - temp_dir.path().to_str().expect("utf-8 temp path"), - "--json", - "projects", - "--api-url", - "http://127.0.0.1:9", - "--dry-run", - "--create", - "--project-name", - "demo", - "--chain-id", - "1", - ]) - .output() - .expect("run pcl projects dry-run"); - - assert!( - api_output.status.success(), - "api alias failed: {}", - String::from_utf8_lossy(&api_output.stderr) - ); - assert!( - top_level_output.status.success(), - "top-level workflow failed: {}", - String::from_utf8_lossy(&top_level_output.stderr) - ); - let api_envelope: serde_json::Value = - serde_json::from_slice(&api_output.stdout).expect("api json envelope"); - let top_level_envelope: serde_json::Value = - serde_json::from_slice(&top_level_output.stdout).expect("top-level json envelope"); - assert_eq!(top_level_envelope["status"], "ok"); - assert_eq!(top_level_envelope["data"], api_envelope["data"]); -} - -#[test] -fn top_level_public_incidents_workflow_matches_api_alias() { - let temp_dir = tempfile::tempdir().expect("create temp config dir"); - - let api_output = Command::new(env!("CARGO_BIN_EXE_pcl")) - .args([ - "--config-dir", - temp_dir.path().to_str().expect("utf-8 temp path"), - "--json", - "api", - "--dry-run", - "incidents", - "--limit", - "5", - ]) - .output() - .expect("run pcl api incidents dry-run"); - - let top_level_output = Command::new(env!("CARGO_BIN_EXE_pcl")) - .args([ - "--config-dir", - temp_dir.path().to_str().expect("utf-8 temp path"), - "--json", - "incidents", - "--dry-run", - "--limit", - "5", - ]) - .output() - .expect("run pcl incidents dry-run"); - - assert!( - api_output.status.success(), - "api alias failed: {}", - String::from_utf8_lossy(&api_output.stderr) - ); - assert!( - top_level_output.status.success(), - "top-level workflow failed: {}", - String::from_utf8_lossy(&top_level_output.stderr) - ); - let api_envelope: serde_json::Value = - serde_json::from_slice(&api_output.stdout).expect("api json envelope"); - let top_level_envelope: serde_json::Value = - serde_json::from_slice(&top_level_output.stdout).expect("top-level json envelope"); - assert_eq!(top_level_envelope["status"], "ok"); - assert_eq!(top_level_envelope["data"], api_envelope["data"]); -} - #[test] fn agent_product_surfaces_emit_json_envelopes() { let temp_dir = tempfile::tempdir().expect("create temp config dir"); diff --git a/crates/pcl/cli/tests/parse_output.rs b/crates/pcl/cli/tests/parse_output.rs index 9569476..d23ddf0 100644 --- a/crates/pcl/cli/tests/parse_output.rs +++ b/crates/pcl/cli/tests/parse_output.rs @@ -177,47 +177,17 @@ fn machine_help_requests_stay_structured() { } #[test] -fn new_workflow_subcommands_parse_and_emit_structured_dry_runs() { +fn new_workflow_subcommands_parse_and_emit_structured_templates() { for args in [ - [ - "--json", - "projects", - "create", - "--project-name", - "demo", - "--chain-id", - "1", - "--dry-run", - ] - .as_slice(), - [ - "--json", - "projects", - "update", - "project-1", - "--field", - "github_url=https://github.com/org/repo", - "--dry-run", - ] - .as_slice(), [ "--json", "releases", "preview", "project-1", "--body-template", - "--dry-run", - ] - .as_slice(), - [ - "--json", - "access", - "invite", - "project-1", - "--body-template", - "--dry-run", ] .as_slice(), + ["--json", "access", "invite", "project-1", "--body-template"].as_slice(), ["--json", "releases", "deploy", "--body-template"].as_slice(), ["--json", "access", "invite", "--body-template"].as_slice(), ] { diff --git a/crates/pcl/core/src/api.rs b/crates/pcl/core/src/api.rs index 0a175db..65573aa 100644 --- a/crates/pcl/core/src/api.rs +++ b/crates/pcl/core/src/api.rs @@ -9,51 +9,40 @@ use crate::{ DEFAULT_PLATFORM_URL, - auth::refresh_stored_auth, config::CliConfig, - error::AuthError, }; use clap::{ ArgGroup, ValueEnum, }; -use pcl_common::args::{ - CliArgs, - OutputMode, -}; -use reqwest::header::{ - HeaderMap, - HeaderName, - HeaderValue, -}; -use serde_json::{ - Map, - Value, - json, -}; +use pcl_common::args::CliArgs; use std::{ cell::Cell, - fs, - io::Read, - path::{ - Path, - PathBuf, - }, - str::FromStr, + path::PathBuf, }; mod definitions; +mod envelopes; +mod error; +mod input; mod manifest; +mod method; mod openapi; +mod operations; mod render; +mod runner; +mod runtime_types; mod spec; mod templates; +mod transport; +mod workflow_options; mod workflows; pub use crate::output::{ ENVELOPE_SCHEMA_VERSION, with_envelope_metadata, }; +pub use error::ApiCommandError; pub use manifest::api_manifest; pub use render::{ envelope_output_string, @@ -61,10 +50,25 @@ pub use render::{ toon_string, }; -use definitions::{ - WorkflowOutputPolicy, - workflow_output_policy, +use envelopes::{ + extract_paginated_items, + ok_envelope, + query_pairs_value, + upsert_query, + workflow_data_for_output_mode, + workflow_success_envelope, + workflow_success_envelope_with_data, }; +pub(in crate::api) use error::method_side_effecting; +use input::{ + parse_headers, + parse_key_values, + read_body, + split_path_and_inline_query, + write_json_output_file, + write_jsonl_items_output_file, +}; +pub(in crate::api) use method::HttpMethod; use openapi::{ api_coverage, command_next_actions, @@ -88,7 +92,16 @@ use openapi::{ synthetic_operation_id, workflow_alternatives, }; +use operations::WorkflowOperation; use render::print_output; +use runtime_types::{ + ApiRequestInput, + PreparedApiRequest, + RawPaginationOptions, + WorkflowCallResult, + WorkflowPaginationOptions, + WorkflowRequest, +}; use templates::{ access_body_template, body_template, @@ -101,12 +114,20 @@ use templates::{ template_envelope, transfer_body_template, }; +use transport::{ + read_api_response, + write_request_log, +}; +pub(crate) use transport::{ + request_id_from_headers, + response_body_value, +}; +use workflow_options::ApiWorkflowOptions; use workflows::{ access_request, account_request, assertions_next_actions, assertions_request, - compact_deployment_data, contracts_next_actions, contracts_request, deployments_request, @@ -128,423 +149,6 @@ use workflows::{ transfers_request, }; -#[derive(Debug, thiserror::Error)] -pub enum ApiCommandError { - #[error("Run `pcl auth login` first, or pass `--allow-unauthenticated`")] - NoAuthToken, - - #[error( - "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), - - #[error("Failed to refresh stored auth before retrying the API request: {0}")] - AuthRefresh(#[source] AuthError), - - #[error("Invalid {kind} `{input}`. Expected KEY=VALUE.")] - InvalidKeyValue { kind: &'static str, input: String }, - - #[error("Invalid header name `{name}`: {source}")] - InvalidHeaderName { - name: String, - #[source] - source: reqwest::header::InvalidHeaderName, - }, - - #[error("Invalid header value for `{name}`: {source}")] - InvalidHeaderValue { - name: String, - #[source] - source: reqwest::header::InvalidHeaderValue, - }, - - #[error("Invalid API path `{0}`. Paths must start with `/`.")] - InvalidPath(String), - - #[error("Failed to build API URL: {0}")] - Url(#[from] url::ParseError), - - #[error("Failed to read body file `{path}`: {source}")] - BodyFile { - path: PathBuf, - #[source] - source: std::io::Error, - }, - - #[error("Failed to read request log `{path}`: {source}")] - RequestLog { - path: PathBuf, - #[source] - source: std::io::Error, - }, - - #[error("Failed to write output file `{path}`: {source}")] - OutputFile { - path: PathBuf, - #[source] - source: std::io::Error, - }, - - #[error("Failed to read request body from stdin: {0}")] - Stdin(std::io::Error), - - #[error("Invalid JSON body: {0}")] - Json(#[from] serde_json::Error), - - #[error("API request failed: {0}")] - Request(#[from] reqwest::Error), - - #[error("API request failed with status {status} for {method} {path}")] - HttpStatus { - method: &'static str, - path: String, - status: u16, - request_id: Option, - body: Box, - }, - - #[error("OpenAPI spec does not contain a paths object")] - MissingPaths, - - #[error("No API operation matched `{0}`")] - OperationNotFound(String), - - #[error("{message}")] - InvalidWorkflow { message: String }, - - #[error("{message}")] - InvalidWorkflowWithActions { - message: String, - next_actions: Vec, - }, -} - -impl ApiCommandError { - pub fn code(&self) -> &'static str { - match self { - Self::NoAuthToken => "auth.no_token", - Self::ExpiredAuthToken(_) => "auth.expired_token", - Self::AuthRefresh(_) => "auth.refresh_failed", - Self::InvalidKeyValue { .. } => "input.invalid_key_value", - Self::InvalidHeaderName { .. } => "input.invalid_header_name", - Self::InvalidHeaderValue { .. } => "input.invalid_header_value", - Self::InvalidPath(_) => "input.invalid_path", - Self::Url(_) => "input.invalid_url", - Self::BodyFile { .. } => "input.body_file_read_failed", - Self::RequestLog { .. } => "request_log.read_failed", - Self::OutputFile { .. } => "output.file_write_failed", - Self::Stdin(_) => "input.stdin_read_failed", - Self::Json(_) => "input.invalid_json", - Self::Request(source) => { - match source.status().map(|status| status.as_u16()) { - Some(400) => "api.bad_request", - Some(401) => "auth.unauthorized", - Some(403) => "auth.forbidden", - Some(404) => "api.not_found", - Some(422) => "api.validation_failed", - Some(500..=599) => "api.server_error", - _ => "network.request_failed", - } - } - Self::HttpStatus { status, .. } => { - match *status { - 400 => "api.bad_request", - 401 => "auth.unauthorized", - 403 => "auth.forbidden", - 404 => "api.not_found", - 422 => "api.validation_failed", - 500..=599 => "api.server_error", - _ => "api.request_failed", - } - } - Self::MissingPaths => "openapi.missing_paths", - Self::OperationNotFound(_) => "openapi.operation_not_found", - Self::InvalidWorkflow { .. } | Self::InvalidWorkflowWithActions { .. } => { - "workflow.invalid_arguments" - } - } - } - - pub fn recoverable(&self) -> bool { - !matches!(self, Self::MissingPaths) - } - - pub fn next_actions(&self) -> Vec { - match self { - Self::NoAuthToken | Self::ExpiredAuthToken(_) | Self::AuthRefresh(_) => { - vec![ - "pcl auth refresh --toon".to_string(), - "pcl auth login".to_string(), - "pcl api list --allow-unauthenticated --toon".to_string(), - ] - } - Self::InvalidPath(_) => { - vec![ - "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" - )] - } - Self::InvalidHeaderName { .. } | Self::InvalidHeaderValue { .. } => { - vec![ - "Use --header name=value, for example: --header x-cl-dev-mode=true".to_string(), - ] - } - Self::Json(_) => { - vec![ - "Use --field key=value for simple request bodies".to_string(), - "Use --body-file request.json for nested request bodies".to_string(), - ] - } - Self::OperationNotFound(_) => { - vec![ - "pcl api list --toon".to_string(), - "pcl api inspect get /views/public/incidents --toon".to_string(), - ] - } - Self::InvalidWorkflowWithActions { next_actions, .. } => next_actions.clone(), - Self::InvalidWorkflow { .. } => { - vec![ - "pcl projects mine".to_string(), - "pcl schema list".to_string(), - "pcl workflows".to_string(), - ] - } - Self::Request(source) - if matches!( - source.status().map(|status| status.as_u16()), - Some(401 | 403) - ) => - { - vec![ - "pcl auth login".to_string(), - "Use --allow-unauthenticated only for public endpoints".to_string(), - ] - } - Self::HttpStatus { status: 401, .. } => { - vec![ - "pcl auth refresh --toon".to_string(), - "pcl auth login".to_string(), - "Use --allow-unauthenticated only for public endpoints".to_string(), - ] - } - Self::HttpStatus { status: 403, .. } => { - vec![ - "Read error.http.body for the API-provided reason".to_string(), - "Check whether the endpoint is enabled and your user has permission" - .to_string(), - "Use --allow-unauthenticated only for endpoints documented as public" - .to_string(), - ] - } - Self::HttpStatus { - method, - path, - status: 400 | 422, - .. - } => { - vec![ - format!( - "pcl api inspect {} {} --toon", - method.to_ascii_lowercase(), - path - ), - "pcl api manifest --toon".to_string(), - "Read error.http.body for the rejected field details".to_string(), - ] - } - Self::HttpStatus { status: 404, .. } => { - vec![ - "Check the project ID, slug, or API path and retry".to_string(), - "pcl projects mine".to_string(), - ] - } - Self::HttpStatus { - method, - status: status @ 500..=599, - request_id, - path, - .. - } => { - let mut actions = if mutation_outcome_ambiguous(method, *status) { - vec![ - format!( - "Do not retry immediately; inspect the target resource for {} {} to confirm whether the mutation applied", - method.to_ascii_lowercase(), - path - ), - "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 --toon".to_string(), - "Read error.http.body for API-provided failure details".to_string(), - ] - }; - if let Some(request_id) = request_id { - actions.push(format!( - "Include request_id {request_id} when reporting this server error" - )); - } - actions - } - Self::HttpStatus { .. } => { - vec![ - "pcl api manifest --toon".to_string(), - "Read error.http.body for API-provided failure details".to_string(), - ] - } - Self::Request(source) if source.status().map(|status| status.as_u16()) == Some(404) => { - vec![ - "Check the project ID, slug, or API path and retry".to_string(), - "pcl projects mine".to_string(), - ] - } - Self::Request(_) | Self::Url(_) => { - vec!["Check --api-url and your network connection, then retry".to_string()] - } - Self::BodyFile { .. } => { - vec!["Check --body-file path or pass --body directly".to_string()] - } - Self::RequestLog { .. } => { - vec![ - "pcl requests path --toon".to_string(), - "Check request log permissions or move the PCL state directory".to_string(), - ] - } - Self::OutputFile { .. } => { - vec!["Check --output path permissions or choose a writable file".to_string()] - } - Self::Stdin(_) => vec!["Pipe a JSON body into --body-file -".to_string()], - Self::MissingPaths => { - vec!["Check that /api/v1/openapi returns an OpenAPI document".to_string()] - } - } - } - - pub fn suggested_next_actions(&self) -> Vec<&'static str> { - match self { - Self::NoAuthToken | Self::ExpiredAuthToken(_) | Self::AuthRefresh(_) => { - vec!["refresh_or_login", "retry"] - } - Self::InvalidKeyValue { .. } - | Self::InvalidHeaderName { .. } - | Self::InvalidHeaderValue { .. } - | Self::InvalidPath(_) - | Self::Json(_) - | Self::InvalidWorkflow { .. } - | Self::InvalidWorkflowWithActions { .. } => vec!["fix_input", "retry"], - Self::OperationNotFound(_) | Self::MissingPaths => vec!["inspect_manifest"], - Self::Request(source) if source.status().map(|status| status.as_u16()) == Some(404) => { - vec!["check_ids", "retry"] - } - Self::Request(_) | Self::Url(_) => vec!["check_network", "retry"], - Self::BodyFile { .. } | Self::Stdin(_) => vec!["fix_body_input", "retry"], - Self::RequestLog { .. } => vec!["inspect_request_log", "retry"], - Self::OutputFile { .. } => vec!["fix_output_path", "retry"], - Self::HttpStatus { status: 401, .. } => vec!["refresh_or_login", "retry"], - Self::HttpStatus { status: 403, .. } => { - vec!["check_permissions", "inspect_response_body"] - } - Self::HttpStatus { - status: 400 | 422, .. - } => vec!["inspect_operation", "fix_request", "retry"], - Self::HttpStatus { status: 404, .. } => vec!["inspect_manifest", "check_ids"], - Self::HttpStatus { status: 429, .. } => vec!["retry_later", "reduce_request_rate"], - Self::HttpStatus { - method, - status: status @ 500..=599, - .. - } if mutation_outcome_ambiguous(method, *status) => { - vec!["reconcile_mutation", "contact_platform_with_request_id"] - } - Self::HttpStatus { - status: 500..=599, .. - } => { - vec![ - "retry_later", - "export_project_incidents_with_errors", - "contact_platform_with_request_id", - ] - } - Self::HttpStatus { .. } => vec!["inspect_response_body", "retry"], - } - } - - pub fn json_envelope(&self) -> Value { - let mut error = Map::new(); - error.insert("code".to_string(), json!(self.code())); - error.insert("message".to_string(), json!(self.to_string())); - error.insert("recoverable".to_string(), json!(self.recoverable())); - - if let Self::HttpStatus { - method, - path, - status, - request_id, - body, - } = self - { - let outcome_ambiguous = mutation_outcome_ambiguous(method, *status); - if let Some(request_id) = request_id { - error.insert("request_id".to_string(), json!(request_id)); - } - error.insert( - "http".to_string(), - json!({ - "method": method, - "path": path, - "status": status, - "request_id": request_id, - "body": body.as_ref(), - }), - ); - error.insert( - "mutation".to_string(), - json!({ - "side_effecting": method_side_effecting(method), - "outcome_ambiguous": outcome_ambiguous, - }), - ); - } - - let mut envelope = json!({ - "status": "error", - "error": error, - "suggested_next_actions": self.suggested_next_actions(), - "next_actions": self.next_actions(), - }); - - if let Self::HttpStatus { - method, - path, - status, - request_id, - .. - } = self - && let Some(object) = envelope.as_object_mut() - { - object.insert("http_status".to_string(), json!(status)); - object.insert("method".to_string(), json!(method)); - object.insert("path".to_string(), json!(path)); - object.insert("request_id".to_string(), json!(request_id)); - object.insert( - "outcome_ambiguous".to_string(), - json!(mutation_outcome_ambiguous(method, *status)), - ); - } - - with_envelope_metadata(envelope) - } -} - #[derive(clap::Parser, Debug)] #[command( about = "Discover and call the platform API", @@ -570,63 +174,10 @@ pub struct ApiArgs { )] allow_unauthenticated: bool, - #[arg( - long = "dry-run", - global = true, - help = "Print the request plan without sending an API request" - )] - dry_run: bool, - #[arg(skip = Cell::new(true))] refresh_after_401: Cell, } -#[derive(clap::Args, Debug)] -struct ApiWorkflowOptions { - #[arg( - long = "api-url", - env = "PCL_API_URL", - default_value = DEFAULT_PLATFORM_URL, - global = true, - help = "Base URL for the platform API" - )] - api_url: url::Url, - - #[arg( - long, - global = true, - help = "Do not attach the stored bearer token to API requests" - )] - allow_unauthenticated: bool, - - #[arg( - long = "dry-run", - global = true, - help = "Print the request plan without sending an API request" - )] - dry_run: bool, -} - -impl ApiWorkflowOptions { - async fn run( - self, - command: ApiCommand, - config: &mut CliConfig, - cli_args: &CliArgs, - json_output: bool, - ) -> Result<(), ApiCommandError> { - ApiArgs { - command, - api_url: self.api_url, - allow_unauthenticated: self.allow_unauthenticated, - dry_run: self.dry_run, - refresh_after_401: Cell::new(true), - } - .run(config, cli_args, json_output) - .await - } -} - macro_rules! top_level_workflow_command { ($name:ident, $args:ty, $variant:ident, $about:literal, $after_help:literal) => { #[derive(clap::Args, Debug)] @@ -860,143 +411,6 @@ enum ApiCommand { }, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] -enum HttpMethod { - Get, - Post, - Put, - Patch, - Delete, -} - -impl HttpMethod { - fn as_str(self) -> &'static str { - match self { - Self::Get => "GET", - Self::Post => "POST", - Self::Put => "PUT", - Self::Patch => "PATCH", - Self::Delete => "DELETE", - } - } - - fn openapi_key(self) -> &'static str { - match self { - Self::Get => "get", - Self::Post => "post", - Self::Put => "put", - Self::Patch => "patch", - Self::Delete => "delete", - } - } - - fn reqwest(self) -> reqwest::Method { - match self { - Self::Get => reqwest::Method::GET, - Self::Post => reqwest::Method::POST, - Self::Put => reqwest::Method::PUT, - Self::Patch => reqwest::Method::PATCH, - Self::Delete => reqwest::Method::DELETE, - } - } -} - -fn method_side_effecting(method: &str) -> bool { - !method.eq_ignore_ascii_case("GET") && !method.eq_ignore_ascii_case("HEAD") -} - -fn mutation_outcome_ambiguous(method: &str, status: u16) -> bool { - method_side_effecting(method) && status >= 500 -} - -struct ApiRequestInput<'a> { - method: HttpMethod, - path: &'a str, - query: &'a [String], - header: &'a [String], - body: Option<&'a str>, - body_file: Option<&'a PathBuf>, - field: &'a [String], - require_auth: bool, -} - -struct PreparedApiRequest<'a> { - attach_auth: bool, - method: HttpMethod, - url: &'a url::Url, - headers: &'a HeaderMap, - query: &'a [(String, String)], - body: Option<&'a Value>, -} - -#[derive(Clone, Copy)] -struct RawPaginationOptions<'a> { - item_field: &'a str, - start_page: u64, - limit: u64, - page_param: &'a str, - limit_param: &'a str, - max_pages: u64, -} - -#[derive(Clone, Copy)] -struct WorkflowPaginationOptions<'a> { - item_field: &'a str, - start_page: u64, - limit: u64, - max_pages: u64, -} - -#[derive(Debug)] -struct WorkflowCallResult { - body: Value, - request: Value, - response: Value, -} - -#[derive(Clone, Debug)] -struct WorkflowRequest { - method: HttpMethod, - path: String, - query: Vec<(String, String)>, - body: Option, - require_auth: bool, - attach_auth: bool, - next_actions: Vec, -} - -impl WorkflowRequest { - fn get( - path: impl Into, - require_auth: bool, - next_actions: impl IntoIterator>, - ) -> Self { - Self::get_with_query(path, Vec::new(), require_auth, next_actions) - } - - fn get_with_query( - path: impl Into, - query: Vec<(String, String)>, - require_auth: bool, - next_actions: impl IntoIterator>, - ) -> Self { - Self { - method: HttpMethod::Get, - path: path.into(), - query, - body: None, - require_auth, - attach_auth: require_auth, - next_actions: next_actions.into_iter().map(Into::into).collect(), - } - } - - fn with_optional_auth(mut self) -> Self { - self.attach_auth = true; - self - } -} - #[derive(clap::Args, Debug)] struct IncidentsArgs { #[arg( @@ -2413,1795 +1827,5 @@ top_level_workflow_command!( "Examples:\n pcl events --project \n pcl events --project --audit-log\n\nCompatibility alias:\n pcl api events ..." ); -impl ApiArgs { - pub async fn run( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - json_output: bool, - ) -> Result<(), ApiCommandError> { - let request_log_path = crate::request_log::request_log_path_for_args(cli_args); - match &self.command { - ApiCommand::Incidents(args) => { - let output = self - .run_incidents(config, cli_args, args, &request_log_path) - .await?; - print_output(&output, json_output)?; - } - ApiCommand::Projects(args) => { - let output = self - .run_projects(config, cli_args, args, &request_log_path) - .await?; - print_output(&output, json_output)?; - } - ApiCommand::Assertions(args) => { - let output = self - .run_assertions(config, cli_args, args, &request_log_path) - .await?; - print_output(&output, json_output)?; - } - ApiCommand::Search(args) => { - let output = self - .run_search(config, cli_args, args, &request_log_path) - .await?; - print_output(&output, json_output)?; - } - ApiCommand::Account(args) => { - if args.body_template { - let output = template_envelope(body_template("empty_object")); - print_output(&output, json_output)?; - return Ok(()); - } - let output = self - .run_workflow( - config, - cli_args, - "account", - account_request(args)?, - &request_log_path, - ) - .await?; - print_output(&output, json_output)?; - } - ApiCommand::Contracts(args) => { - if args.body_template { - let output = template_envelope(contracts_body_template(args)); - print_output(&output, json_output)?; - return Ok(()); - } - let output = self - .run_contracts(config, cli_args, args, &request_log_path) - .await?; - print_output(&output, json_output)?; - } - ApiCommand::Releases(args) => { - if args.body_template { - let output = template_envelope(release_body_template(args)); - print_output(&output, json_output)?; - return Ok(()); - } - let output = self - .run_releases(config, cli_args, args, &request_log_path) - .await?; - print_output(&output, json_output)?; - } - ApiCommand::Deployments(args) => { - if args.body_template { - let output = template_envelope(deployment_body_template(args)); - print_output(&output, json_output)?; - return Ok(()); - } - let output = self - .run_deployments(config, cli_args, args, &request_log_path) - .await?; - print_output(&output, json_output)?; - } - ApiCommand::Access(args) => { - if args.body_template { - let output = template_envelope(access_body_template(args)); - print_output(&output, json_output)?; - return Ok(()); - } - let output = self - .run_workflow( - config, - cli_args, - "access", - access_request(args)?, - &request_log_path, - ) - .await?; - print_output(&output, json_output)?; - } - ApiCommand::Integrations(args) => { - if args.body_template { - let output = template_envelope(integration_body_template(args)); - print_output(&output, json_output)?; - return Ok(()); - } - let output = self - .run_workflow( - config, - cli_args, - "integrations", - integrations_request(args)?, - &request_log_path, - ) - .await?; - print_output(&output, json_output)?; - } - ApiCommand::ProtocolManager(args) => { - if args.body_template { - let output = template_envelope(protocol_manager_body_template(args)); - print_output(&output, json_output)?; - return Ok(()); - } - let output = self - .run_protocol_manager(config, cli_args, args, &request_log_path) - .await?; - print_output(&output, json_output)?; - } - ApiCommand::Transfers(args) => { - if args.body_template { - let output = template_envelope(transfer_body_template(args)); - print_output(&output, json_output)?; - return Ok(()); - } - let output = self - .run_transfers(config, cli_args, args, &request_log_path) - .await?; - print_output(&output, json_output)?; - } - ApiCommand::Events(args) => { - let output = self - .run_workflow( - config, - cli_args, - "events", - events_request(args)?, - &request_log_path, - ) - .await?; - print_output(&output, json_output)?; - } - ApiCommand::Manifest => { - let output = ok_envelope(api_manifest()); - print_output(&output, json_output)?; - } - ApiCommand::List { filter, method } => { - let spec = self.fetch_openapi(config).await?; - let operations = list_operations(&spec, filter.as_deref(), *method)?; - let next_actions = next_actions_for_operations(&operations); - let output = json!({ - "status": "ok", - "data": { - "operations": operations, - }, - "next_actions": next_actions, - }); - print_output(&output, json_output)?; - } - ApiCommand::Inspect { - operation, - path, - full, - } => { - let spec = self.fetch_openapi(config).await?; - let inspected = inspect_operation(&spec, operation, path.as_deref(), *full)?; - let next_actions = command_next_actions(&inspected); - let output = json!({ - "status": "ok", - "data": inspected, - "next_actions": next_actions, - }); - print_output(&output, json_output)?; - } - ApiCommand::Coverage { records, markdown } => { - let spec = self.fetch_openapi(config).await?; - let coverage = - api_coverage(&spec, &request_log_path, *records, self.api_url.as_str())?; - if let Some(path) = markdown { - write_api_coverage_markdown(path, &coverage)?; - } - let output = json!({ - "status": "ok", - "data": coverage, - "next_actions": [ - "pcl requests list --toon", - "pcl api list --toon", - "pcl api coverage --markdown api-coverage.md", - ], - }); - print_output(&output, json_output)?; - } - ApiCommand::Call { - method, - path, - query, - header, - body, - body_file, - field, - paginate, - all: _, - page, - limit, - page_param, - limit_param, - max_pages, - jsonl, - output, - } => { - if *jsonl && output.is_none() { - return Err(ApiCommandError::InvalidWorkflow { - message: "--jsonl requires --output".to_string(), - }); - } - let input = ApiRequestInput { - method: *method, - path, - query, - header, - body: body.as_deref(), - body_file: body_file.as_ref(), - field, - require_auth: self.raw_call_requires_auth(*method, path)?, - }; - let pagination = paginate.as_ref().map(|item_field| { - RawPaginationOptions { - item_field, - start_page: page.unwrap_or(1), - limit: limit.unwrap_or(50), - page_param: page_param.as_deref().unwrap_or("page"), - limit_param: limit_param.as_deref().unwrap_or("limit"), - max_pages: max_pages.unwrap_or(100), - } - }); - if self.dry_run { - let output = dry_run_envelope(self.raw_call_plan(input, pagination, config)?); - print_output(&output, json_output)?; - return Ok(()); - } - let (mut response, next_actions) = if let Some(pagination) = pagination { - let response = self - .call_api_paginated(config, cli_args, input, pagination, &request_log_path) - .await?; - ( - response, - vec![ - "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 --toon".to_string(), - ], - ) - } else { - let response = self - .call_api(config, cli_args, input, &request_log_path) - .await?; - ( - response, - vec![ - "pcl api list --toon".to_string(), - "pcl api manifest --toon".to_string(), - ], - ) - }; - if let Some(path) = output { - if *jsonl { - write_jsonl_items_output_file(path, &response)?; - } else { - let body = response.pointer("/response/body").unwrap_or(&response); - write_json_output_file(path, body)?; - } - if let Some(object) = response.as_object_mut() { - object.insert("output_path".to_string(), json!(path.display().to_string())); - } - } - let output = json!({ - "status": "ok", - "data": response, - "next_actions": next_actions, - }); - print_output(&output, json_output)?; - } - } - - Ok(()) - } - - async fn call_api_paginated( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - input: ApiRequestInput<'_>, - pagination: RawPaginationOptions<'_>, - request_log_path: &Path, - ) -> Result { - if input.method.openapi_key() != "get" { - return Err(ApiCommandError::InvalidWorkflow { - message: "--paginate is only supported for GET requests".to_string(), - }); - } - if input.body.is_some() || input.body_file.is_some() || !input.field.is_empty() { - return Err(ApiCommandError::InvalidWorkflow { - message: "--paginate cannot be used with request bodies".to_string(), - }); - } - if pagination.limit == 0 { - return Err(ApiCommandError::InvalidWorkflow { - message: "--limit must be greater than zero".to_string(), - }); - } - if pagination.max_pages == 0 { - return Err(ApiCommandError::InvalidWorkflow { - message: "--max-pages must be greater than zero".to_string(), - }); - } - - let (path, mut base_query) = split_path_and_inline_query(input.path)?; - base_query.extend(parse_key_values("query", input.query)?); - let url = self.api_url(&path)?; - let headers = parse_headers(input.header)?; - let operation_id = self.resolve_operation_id(config, input.method, &path).await; - self.ensure_request_auth(config, cli_args, input.require_auth) - .await?; - let client = self.http_client( - config, - input.require_auth && !self.allow_unauthenticated, - input.require_auth && !self.allow_unauthenticated, - )?; - - let mut items = Vec::new(); - let mut pages_fetched = 0_u64; - let mut last_page_count = 0_usize; - - for offset in 0..pagination.max_pages { - let page = pagination.start_page + offset; - let mut page_query = base_query.clone(); - upsert_query(&mut page_query, pagination.page_param, page.to_string()); - upsert_query( - &mut page_query, - pagination.limit_param, - pagination.limit.to_string(), - ); - - let response = client - .get(url.clone()) - .headers(headers.clone()) - .query(&page_query) - .send() - .await?; - let status = response.status(); - let request_id = request_id_from_headers(response.headers()); - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let bytes = response.bytes().await?; - let body = response_body_value(&content_type, &bytes); - write_request_log( - request_log_path, - "raw_paginated", - input.method.as_str(), - &path, - status.as_u16(), - request_id.as_deref(), - operation_id.as_deref(), - ); - if !status.is_success() { - return Err(ApiCommandError::HttpStatus { - method: input.method.as_str(), - path, - status: status.as_u16(), - request_id, - body: Box::new(body), - }); - } - - let page_items = - extract_paginated_items(&body, pagination.item_field).ok_or_else(|| { - ApiCommandError::InvalidWorkflow { - message: format!( - "Could not find an array at `{}` or common pagination fields in response", - pagination.item_field - ), - } - })?; - last_page_count = page_items.len(); - pages_fetched += 1; - items.extend(page_items); - - if last_page_count < usize::try_from(pagination.limit).unwrap_or(usize::MAX) { - break; - } - } - - let count = items.len(); - Ok(json!({ - "request": { - "method": input.method.as_str(), - "path": path, - "operation_id": operation_id, - "query": query_pairs_value(&base_query), - "pagination": { - "field": pagination.item_field, - "start_page": pagination.start_page, - "limit": pagination.limit, - "page_param": pagination.page_param, - "limit_param": pagination.limit_param, - "max_pages": pagination.max_pages, - } - }, - "items": items, - "count": count, - "pages_fetched": pages_fetched, - "last_page_count": last_page_count, - })) - } - - async fn run_incidents( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - args: &IncidentsArgs, - request_log_path: &Path, - ) -> Result { - let request = incidents_request(args)?; - if args.jsonl && args.output.is_none() { - return Err(ApiCommandError::InvalidWorkflow { - message: "--jsonl requires --output".to_string(), - }); - } - if self.dry_run { - let pagination = args.all.then(|| { - json!({ - "enabled": true, - "item_field": "incidents", - "start_page": args.page.unwrap_or(1), - "limit": args.limit.unwrap_or(50), - "max_pages": args.max_pages.unwrap_or(100), - "output": args.output.as_ref().map(|path| path.display().to_string()), - "jsonl": args.jsonl, - }) - }); - return Ok(dry_run_envelope( - self.workflow_request_plan(&request, pagination, config), - )); - } - if args.all { - let mut data = self - .call_workflow_paginated( - config, - cli_args, - request.clone(), - WorkflowPaginationOptions { - item_field: "incidents", - start_page: args.page.unwrap_or(1), - limit: args.limit.unwrap_or(50), - max_pages: args.max_pages.unwrap_or(100), - }, - request_log_path, - ) - .await?; - if let Some(path) = &args.output { - if args.jsonl { - write_jsonl_items_output_file(path, &data)?; - } else { - write_json_output_file(path, &data)?; - } - if let Some(object) = data.as_object_mut() { - object.insert("output_path".to_string(), json!(path.display().to_string())); - } - } - let mut next_actions = request.next_actions; - if args.output.is_none() { - next_actions.insert( - 0, - "Use --output incidents.json to save large paginated results".to_string(), - ); - } - return Ok(json!({ - "status": "ok", - "data": data, - "next_actions": next_actions, - })); - } - let result = self - .call_workflow_result(config, cli_args, &request, request_log_path) - .await?; - let next_actions = incidents_next_actions(&result.body, args, request.next_actions); - Ok(workflow_success_envelope(result, next_actions)) - } - - async fn run_projects( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - args: &ProjectsArgs, - request_log_path: &Path, - ) -> Result { - if args.body_template { - return Ok(template_envelope(project_body_template(args))); - } - let request = projects_request(args)?; - self.run_prepared_workflow( - config, - cli_args, - "projects", - request, - request_log_path, - projects_next_actions, - ) - .await - } - - async fn run_assertions( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - args: &AssertionsArgs, - request_log_path: &Path, - ) -> Result { - if args.body_template { - return Ok(template_envelope(body_template("empty_object"))); - } - let request = assertions_request(args)?; - self.run_prepared_workflow( - config, - cli_args, - "assertions", - request, - request_log_path, - |data, fallback| assertions_next_actions(data, args, fallback), - ) - .await - } - - async fn run_search( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - args: &SearchArgs, - request_log_path: &Path, - ) -> Result { - let request = search_request(args)?; - self.run_prepared_workflow( - config, - cli_args, - "search", - request, - request_log_path, - search_next_actions, - ) - .await - } - - async fn run_contracts( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - args: &ContractsArgs, - request_log_path: &Path, - ) -> Result { - let request = contracts_request(args)?; - self.run_prepared_workflow( - config, - cli_args, - "contracts", - request, - request_log_path, - |data, fallback| contracts_next_actions(data, args, fallback), - ) - .await - } - - async fn run_releases( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - args: &ReleasesArgs, - request_log_path: &Path, - ) -> Result { - let request = releases_request(args)?; - self.run_prepared_workflow( - config, - cli_args, - "releases", - request, - request_log_path, - |data, fallback| releases_next_actions(data, args, fallback), - ) - .await - } - - async fn run_deployments( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - args: &DeploymentsArgs, - request_log_path: &Path, - ) -> Result { - let request = deployments_request(args)?; - self.run_prepared_workflow( - config, - cli_args, - "deployments", - request, - request_log_path, - |_data, fallback| fallback, - ) - .await - } - - async fn run_transfers( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - args: &TransfersArgs, - request_log_path: &Path, - ) -> Result { - let request = transfers_request(args)?; - self.run_prepared_workflow( - config, - cli_args, - "transfers", - request, - request_log_path, - |data, fallback| transfers_next_actions(data, args, fallback), - ) - .await - } - - async fn run_protocol_manager( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - args: &ProtocolManagerArgs, - request_log_path: &Path, - ) -> Result { - let request = protocol_manager_request(args)?; - self.run_prepared_workflow( - config, - cli_args, - "protocol-manager", - request, - request_log_path, - |data, fallback| protocol_manager_next_actions(data, args, fallback), - ) - .await - } - - async fn run_workflow( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - workflow: &'static str, - request: WorkflowRequest, - request_log_path: &Path, - ) -> Result { - self.run_prepared_workflow( - config, - cli_args, - workflow, - request, - request_log_path, - |_data, fallback| fallback, - ) - .await - } - - async fn run_prepared_workflow( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - workflow: &'static str, - request: WorkflowRequest, - request_log_path: &Path, - next_actions_for: F, - ) -> Result - where - F: FnOnce(&Value, Vec) -> Vec, - { - if self.dry_run { - return Ok(dry_run_envelope( - self.workflow_request_plan(&request, None, config), - )); - } - let result = self - .call_workflow_result(config, cli_args, &request, request_log_path) - .await?; - let next_actions = next_actions_for(&result.body, request.next_actions); - let data = workflow_data_for_output_mode(workflow, &result.body, cli_args.output_mode()); - Ok(workflow_success_envelope_with_data( - result, - data, - next_actions, - )) - } - - fn workflow_request_plan( - &self, - request: &WorkflowRequest, - pagination: Option, - config: &CliConfig, - ) -> Value { - let body = request.body.as_deref().map_or(Ok(Value::Null), |body| { - serde_json::from_str(body).map_err(ApiCommandError::Json) - }); - let body = match body { - Ok(body) => body, - Err(error) => { - return json!({ - "dry_run": true, - "valid": false, - "error": { - "code": error.code(), - "message": error.to_string(), - }, - }); - } - }; - - let destructive = request_is_destructive(request.method, &request.path); - json!({ - "dry_run": true, - "valid": true, - "request": { - "method": request.method.as_str(), - "path": request.path.as_str(), - "query": query_pairs_value(&request.query), - "body": body, - "auth": self.auth_plan(request.require_auth, request.attach_auth, config), - "side_effecting": request.method != HttpMethod::Get, - "destructive": destructive, - "project_resolution": "not_performed", - }, - "pagination": pagination, - }) - } - - fn raw_call_plan( - &self, - input: ApiRequestInput<'_>, - pagination: Option>, - config: &CliConfig, - ) -> Result { - let (path, mut query) = split_path_and_inline_query(input.path)?; - query.extend(parse_key_values("query", input.query)?); - let header = parse_key_values("header", input.header)?; - let body = request_body(input.body, input.body_file, input.field)? - .map(|body| serde_json::from_str::(&body)) - .transpose()?; - let destructive = request_is_destructive(input.method, &path); - - Ok(json!({ - "dry_run": true, - "valid": true, - "request": { - "method": input.method.as_str(), - "path": path.as_str(), - "query": query_pairs_value(&query), - "headers": query_pairs_value(&header), - "body": body.unwrap_or(Value::Null), - "auth": self.auth_plan(input.require_auth, input.require_auth, config), - "side_effecting": input.method != HttpMethod::Get, - "destructive": destructive, - }, - "pagination": pagination.map(|pagination| json!({ - "enabled": true, - "item_field": pagination.item_field, - "start_page": pagination.start_page, - "limit": pagination.limit, - "page_param": pagination.page_param, - "limit_param": pagination.limit_param, - "max_pages": pagination.max_pages, - })), - })) - } - - fn auth_plan(&self, require_auth: bool, attach_auth: bool, config: &CliConfig) -> Value { - let now = chrono::Utc::now(); - let stored_token_present = config - .auth - .as_ref() - .is_some_and(|auth| !auth.access_token.trim().is_empty()); - let stored_token_valid = config - .auth - .as_ref() - .is_some_and(|auth| !auth.access_token.trim().is_empty() && auth.expires_at > now); - let will_attach_stored_token = - attach_auth && !self.allow_unauthenticated && stored_token_valid; - json!({ - "required": require_auth, - "will_attach_stored_token": will_attach_stored_token, - "stored_token_present": stored_token_present, - "stored_token_valid": stored_token_valid, - "allow_unauthenticated": self.allow_unauthenticated, - }) - } - - fn raw_call_requires_auth( - &self, - method: HttpMethod, - path: &str, - ) -> Result { - if self.allow_unauthenticated { - return Ok(false); - } - let (path, _) = split_path_and_inline_query(path)?; - Ok(!public_raw_call_path(method, &path)) - } - - async fn fetch_openapi(&self, config: &CliConfig) -> Result { - let url = self.api_url("/openapi")?; - let request = self.http_client(config, false, false)?.get(url); - let response = request.send().await?; - let status = response.status(); - let request_id = request_id_from_headers(response.headers()); - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let bytes = response.bytes().await?; - let body = response_body_value(&content_type, &bytes); - if !status.is_success() { - return Err(ApiCommandError::HttpStatus { - method: "GET", - path: "/openapi".to_string(), - status: status.as_u16(), - request_id, - body: Box::new(body), - }); - } - Ok(body) - } - - async fn try_refresh_after_401( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - ) -> Result { - if !self.refresh_after_401.get() { - return Ok(false); - } - - match refresh_stored_auth(config, &self.api_url, cli_args, true).await { - Ok(_) => Ok(true), - Err(AuthError::RefreshEndpointNotFound { .. }) => { - self.refresh_after_401.set(false); - Ok(false) - } - Err(error) => Err(ApiCommandError::AuthRefresh(error)), - } - } - - async fn call_api( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - input: ApiRequestInput<'_>, - request_log_path: &Path, - ) -> Result { - let (path, mut query) = split_path_and_inline_query(input.path)?; - query.extend(parse_key_values("query", input.query)?); - let url = self.api_url(&path)?; - let headers = parse_headers(input.header)?; - let body = request_body(input.body, input.body_file, input.field)?; - let operation_id = self.resolve_operation_id(config, input.method, &path).await; - let requires_auth = input.require_auth && !self.allow_unauthenticated; - self.ensure_request_auth(config, cli_args, input.require_auth) - .await?; - - let json_body = body - .as_deref() - .map(serde_json::from_str::) - .transpose()?; - let mut response = self - .send_api_request( - config, - PreparedApiRequest { - attach_auth: requires_auth, - method: input.method, - url: &url, - headers: &headers, - query: &query, - body: json_body.as_ref(), - }, - ) - .await?; - let mut status = response.status(); - let request_id = request_id_from_headers(response.headers()); - let response_headers = response - .headers() - .iter() - .filter_map(|(name, value)| { - value - .to_str() - .ok() - .map(|value| (name.as_str().to_string(), json!(value))) - }) - .collect::>(); - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let bytes = response.bytes().await?; - let mut body = response_body_value(&content_type, &bytes); - write_request_log( - request_log_path, - "raw", - input.method.as_str(), - &path, - status.as_u16(), - request_id.as_deref(), - operation_id.as_deref(), - ); - if status.as_u16() == 401 - && requires_auth - && self.try_refresh_after_401(config, cli_args).await? - { - response = self - .send_api_request( - config, - PreparedApiRequest { - attach_auth: requires_auth, - method: input.method, - url: &url, - headers: &headers, - query: &query, - body: json_body.as_ref(), - }, - ) - .await?; - status = response.status(); - let retry_request_id = request_id_from_headers(response.headers()); - let retry_headers = response - .headers() - .iter() - .filter_map(|(name, value)| { - value - .to_str() - .ok() - .map(|value| (name.as_str().to_string(), json!(value))) - }) - .collect::>(); - let retry_content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let retry_bytes = response.bytes().await?; - body = response_body_value(&retry_content_type, &retry_bytes); - write_request_log( - request_log_path, - "raw_retry_after_refresh", - input.method.as_str(), - &path, - status.as_u16(), - retry_request_id.as_deref(), - operation_id.as_deref(), - ); - if !status.is_success() { - return Err(ApiCommandError::HttpStatus { - method: input.method.as_str(), - path, - status: status.as_u16(), - request_id: retry_request_id, - body: Box::new(body), - }); - } - return Ok(json!({ - "request": { - "method": input.method.as_str(), - "path": path, - "operation_id": operation_id, - "query": query_pairs_value(&query), - "retried_after_refresh": true, - }, - "response": { - "status": status.as_u16(), - "success": status.is_success(), - "request_id": retry_request_id, - "headers": retry_headers, - "body": body, - } - })); - } - if !status.is_success() { - return Err(ApiCommandError::HttpStatus { - method: input.method.as_str(), - path, - status: status.as_u16(), - request_id, - body: Box::new(body), - }); - } - - Ok(json!({ - "request": { - "method": input.method.as_str(), - "path": path, - "operation_id": operation_id, - "query": query_pairs_value(&query), - }, - "response": { - "status": status.as_u16(), - "success": status.is_success(), - "request_id": request_id, - "headers": response_headers, - "body": body, - } - })) - } - - async fn call_workflow_result( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - request: &WorkflowRequest, - request_log_path: &Path, - ) -> Result { - let requires_auth = request.require_auth && !self.allow_unauthenticated; - self.ensure_request_auth(config, cli_args, request.require_auth) - .await?; - let attach_auth = self.workflow_attach_auth(request, config); - let path = self - .normalize_project_path( - config, - &request.path, - attach_auth, - requires_auth, - request_log_path, - ) - .await?; - let url = self.api_url(&path)?; - let json_body = if let Some(body) = &request.body { - Some( - self.normalize_request_body( - config, - &path, - body, - attach_auth, - requires_auth, - request_log_path, - ) - .await?, - ) - } else { - None - }; - let mut response = self - .send_workflow_request(config, request, &url, json_body.as_ref()) - .await?; - let mut status = response.status(); - let mut request_id = request_id_from_headers(response.headers()); - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let bytes = response.bytes().await?; - let mut body = response_body_value(&content_type, &bytes); - write_request_log( - request_log_path, - "workflow", - request.method.as_str(), - &path, - status.as_u16(), - request_id.as_deref(), - None, - ); - let mut retried_after_refresh = false; - if status.as_u16() == 401 - && requires_auth - && self.try_refresh_after_401(config, cli_args).await? - { - response = self - .send_workflow_request(config, request, &url, json_body.as_ref()) - .await?; - status = response.status(); - request_id = request_id_from_headers(response.headers()); - let retry_content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let retry_bytes = response.bytes().await?; - body = response_body_value(&retry_content_type, &retry_bytes); - retried_after_refresh = true; - write_request_log( - request_log_path, - "workflow_retry_after_refresh", - request.method.as_str(), - &path, - status.as_u16(), - request_id.as_deref(), - None, - ); - } - if !status.is_success() { - return Err(ApiCommandError::HttpStatus { - method: request.method.as_str(), - path, - status: status.as_u16(), - request_id, - body: Box::new(body), - }); - } - Ok(WorkflowCallResult { - body, - request: json!({ - "method": request.method.as_str(), - "path": path, - "query": query_pairs_value(&request.query), - "auth": self.auth_plan(request.require_auth, request.attach_auth, config), - "side_effecting": request.method != HttpMethod::Get, - "destructive": request_is_destructive(request.method, &request.path), - "retried_after_refresh": retried_after_refresh, - }), - response: json!({ - "status": status.as_u16(), - "success": true, - "request_id": request_id, - "fetched_at": chrono::Utc::now().to_rfc3339(), - }), - }) - } - - async fn call_workflow_paginated( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - request: WorkflowRequest, - pagination: WorkflowPaginationOptions<'_>, - request_log_path: &Path, - ) -> Result { - if request.method.openapi_key() != "get" { - return Err(ApiCommandError::InvalidWorkflow { - message: "--all is only supported for GET list workflows".to_string(), - }); - } - if pagination.limit == 0 { - return Err(ApiCommandError::InvalidWorkflow { - message: "--limit must be greater than zero".to_string(), - }); - } - if pagination.max_pages == 0 { - return Err(ApiCommandError::InvalidWorkflow { - message: "--max-pages must be greater than zero".to_string(), - }); - } - - let mut items = Vec::new(); - let mut pages_fetched = 0_u64; - let mut last_page_count = 0_usize; - - for offset in 0..pagination.max_pages { - let page = pagination.start_page + offset; - let mut page_request = request.clone(); - upsert_query(&mut page_request.query, "page", page.to_string()); - upsert_query( - &mut page_request.query, - "limit", - pagination.limit.to_string(), - ); - let data = self - .call_workflow_result(config, cli_args, &page_request, request_log_path) - .await? - .body; - let page_items = - extract_paginated_items(&data, pagination.item_field).ok_or_else(|| { - ApiCommandError::InvalidWorkflow { - message: format!( - "Could not find an array at `{}` or common pagination fields in response", - pagination.item_field - ), - } - })?; - last_page_count = page_items.len(); - pages_fetched += 1; - items.extend(page_items); - - if last_page_count < usize::try_from(pagination.limit).unwrap_or(usize::MAX) { - break; - } - } - - let count = items.len(); - Ok(json!({ - "items": items, - "count": count, - "pages_fetched": pages_fetched, - "start_page": pagination.start_page, - "limit": pagination.limit, - "max_pages": pagination.max_pages, - "last_page_count": last_page_count, - })) - } - - async fn normalize_request_body( - &self, - config: &CliConfig, - path: &str, - body: &str, - attach_auth: bool, - require_auth: bool, - request_log_path: &Path, - ) -> Result { - let mut json_body: Value = serde_json::from_str(body)?; - if path == "/projects/saved" - && let Some(project_ref) = json_body.get("project_id").and_then(Value::as_str) - && project_ref.parse::().is_err() - { - let project_id = self - .resolve_project_id( - config, - project_ref, - attach_auth, - require_auth, - request_log_path, - ) - .await?; - if let Some(object) = json_body.as_object_mut() { - object.insert("project_id".to_string(), Value::String(project_id)); - } - } - Ok(json_body) - } - - async fn normalize_project_path( - &self, - config: &CliConfig, - path: &str, - attach_auth: bool, - require_auth: bool, - request_log_path: &Path, - ) -> Result { - let Some((prefix, project_ref, suffix)) = project_segment(path) else { - return Ok(path.to_string()); - }; - if project_ref.parse::().is_ok() { - return Ok(path.to_string()); - } - let project_id = self - .resolve_project_id( - config, - project_ref, - attach_auth, - require_auth, - request_log_path, - ) - .await?; - Ok(format!("{prefix}{project_id}{suffix}")) - } - - async fn resolve_project_id( - &self, - config: &CliConfig, - project_ref: &str, - attach_auth: bool, - require_auth: bool, - request_log_path: &Path, - ) -> Result { - let path = format!("/projects/resolve/{project_ref}"); - let url = self.api_url(&path)?; - let client = self.http_client(config, attach_auth, require_auth)?; - let response = client.get(url).send().await?; - let status = response.status(); - let request_id = request_id_from_headers(response.headers()); - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let bytes = response.bytes().await?; - let response = response_body_value(&content_type, &bytes); - write_request_log( - request_log_path, - "workflow_project_resolution", - "GET", - &path, - status.as_u16(), - request_id.as_deref(), - Some("get_projects_resolve_project_ref"), - ); - if !status.is_success() { - return Err(ApiCommandError::HttpStatus { - method: "GET", - path, - status: status.as_u16(), - request_id, - body: Box::new(response), - }); - } - response - .get("project_id") - .or_else(|| response.get("projectId")) - .or_else(|| response.get("id")) - .and_then(Value::as_str) - .map(ToString::to_string) - .ok_or_else(|| { - ApiCommandError::InvalidWorkflow { - message: format!("Could not resolve project reference `{project_ref}`"), - } - }) - } - - async fn ensure_request_auth( - &self, - config: &mut CliConfig, - cli_args: &CliArgs, - require_auth: bool, - ) -> Result<(), ApiCommandError> { - if self.allow_unauthenticated || !require_auth { - return Ok(()); - } - let Some(auth) = &config.auth else { - return Err(ApiCommandError::NoAuthToken); - }; - let now = chrono::Utc::now(); - let seconds_remaining = (auth.expires_at - now).num_seconds(); - if auth.expires_at <= now || seconds_remaining <= crate::config::AUTH_EXPIRES_SOON_SECONDS { - refresh_stored_auth(config, &self.api_url, cli_args, false) - .await - .map_err(ApiCommandError::AuthRefresh)?; - } - Ok(()) - } - - async fn send_api_request( - &self, - config: &CliConfig, - request: PreparedApiRequest<'_>, - ) -> Result { - let client = self.http_client(config, request.attach_auth, request.attach_auth)?; - let mut builder = client - .request(request.method.reqwest(), request.url.clone()) - .headers(request.headers.clone()); - if !request.query.is_empty() { - builder = builder.query(request.query); - } - if let Some(body) = request.body { - builder = builder.json(body); - } - Ok(builder.send().await?) - } - - async fn send_workflow_request( - &self, - config: &CliConfig, - request: &WorkflowRequest, - url: &url::Url, - body: Option<&Value>, - ) -> Result { - let requires_auth = request.require_auth && !self.allow_unauthenticated; - let attach_auth = self.workflow_attach_auth(request, config); - let client = self.http_client(config, attach_auth, requires_auth)?; - let mut builder = client.request(request.method.reqwest(), url.clone()); - if !request.query.is_empty() { - builder = builder.query(&request.query); - } - if let Some(body) = body { - builder = builder.json(body); - } - Ok(builder.send().await?) - } - - fn workflow_attach_auth(&self, request: &WorkflowRequest, config: &CliConfig) -> bool { - if self.allow_unauthenticated { - return false; - } - if request.require_auth { - return true; - } - request.attach_auth - && config.auth.as_ref().is_some_and(|auth| { - !auth.access_token.trim().is_empty() && auth.expires_at > chrono::Utc::now() - }) - } - - fn http_client( - &self, - config: &CliConfig, - attach_auth: bool, - require_auth: bool, - ) -> Result { - let mut headers = HeaderMap::new(); - headers.insert( - HeaderName::from_static("api-version"), - HeaderValue::from_static("1"), - ); - - if attach_auth && let Some(auth) = &config.auth { - if auth.expires_at <= chrono::Utc::now() { - return Err(ApiCommandError::ExpiredAuthToken(auth.expires_at)); - } - - let value = format!("Bearer {}", auth.access_token); - let value = HeaderValue::from_str(&value).map_err(|source| { - ApiCommandError::InvalidHeaderValue { - name: "authorization".to_string(), - source, - } - })?; - headers.insert(reqwest::header::AUTHORIZATION, value); - } else if require_auth { - return Err(ApiCommandError::NoAuthToken); - } - - reqwest::Client::builder() - .default_headers(headers) - .build() - .map_err(ApiCommandError::Request) - } - - async fn resolve_operation_id( - &self, - config: &CliConfig, - method: HttpMethod, - path: &str, - ) -> Option { - let spec = self.fetch_openapi(config).await.ok()?; - let operations = list_operations(&spec, None, Some(method)).ok()?; - operations - .into_iter() - .find(|operation| openapi_path_matches(&operation.path, path)) - .map(|operation| operation.operation_id) - } - - fn api_url(&self, path: &str) -> Result { - if !path.starts_with('/') { - return Err(ApiCommandError::InvalidPath(path.to_string())); - } - - let mut url = self.api_url.clone(); - url.set_path(&format!("/api/v1{path}")); - Ok(url) - } -} - -fn split_path_and_inline_query( - input: &str, -) -> Result<(String, Vec<(String, String)>), ApiCommandError> { - if !input.starts_with('/') { - return Err(ApiCommandError::InvalidPath(input.to_string())); - } - let Some((path, query)) = input.split_once('?') else { - return Ok((input.to_string(), Vec::new())); - }; - if path.is_empty() || !path.starts_with('/') { - return Err(ApiCommandError::InvalidPath(input.to_string())); - } - let query = url::form_urlencoded::parse(query.as_bytes()) - .map(|(key, value)| (key.into_owned(), value.into_owned())) - .collect(); - Ok((path.to_string(), query)) -} - -pub(crate) fn request_id_from_headers(headers: &HeaderMap) -> Option { - [ - "x-request-id", - "x-correlation-id", - "x-amzn-requestid", - "cf-ray", - "request-id", - ] - .into_iter() - .find_map(|name| { - headers - .get(name) - .and_then(|value| value.to_str().ok()) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) - }) -} - -fn write_request_log( - request_log_path: &Path, - kind: &str, - method: &str, - path: &str, - status: u16, - request_id: Option<&str>, - operation_id: Option<&str>, -) { - #[cfg(not(test))] - { - let _ = crate::request_log::append_request_record_at( - request_log_path, - &json!({ - "timestamp": chrono::Utc::now().to_rfc3339(), - "kind": kind, - "method": method, - "path": path, - "status": status, - "success": (200..=299).contains(&status), - "request_id": request_id, - "operation_id": operation_id, - }), - ); - } - #[cfg(test)] - let _ = ( - request_log_path, - kind, - method, - path, - status, - request_id, - operation_id, - ); -} - -pub(crate) fn response_body_value(content_type: &str, bytes: &[u8]) -> Value { - if content_type.contains("application/json") { - return serde_json::from_slice(bytes).unwrap_or_else(|_| { - json!({ - "parse_error": "response declared JSON but could not be parsed", - "raw": String::from_utf8_lossy(bytes), - }) - }); - } - - serde_json::from_slice(bytes) - .unwrap_or_else(|_| json!(String::from_utf8_lossy(bytes).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 method = data - .pointer("/request/method") - .and_then(Value::as_str) - .unwrap_or_default(); - let mut next_actions = if auth_required && !allow_unauthenticated && !stored_token_valid { - vec![ - "pcl auth ensure --toon", - "Authenticate before removing --dry-run", - ] - } else { - vec![ - "Remove --dry-run to execute this request", - "Use --toon for agent consumption or --json for strict JSON parsing", - ] - }; - if method_side_effecting(method) { - next_actions.push("Use --body-template when constructing mutation bodies"); - } - with_envelope_metadata(json!({ - "status": "ok", - "data": data, - "next_actions": next_actions, - })) -} - -fn workflow_success_envelope(result: WorkflowCallResult, next_actions: Vec) -> Value { - with_envelope_metadata(json!({ - "status": "ok", - "data": result.body, - "request": result.request, - "response": result.response, - "next_actions": next_actions, - })) -} - -fn workflow_success_envelope_with_data( - result: WorkflowCallResult, - data: Value, - next_actions: Vec, -) -> Value { - with_envelope_metadata(json!({ - "status": "ok", - "data": data, - "request": result.request, - "response": result.response, - "next_actions": next_actions, - })) -} - -fn workflow_data_for_output_mode(workflow: &str, data: &Value, output_mode: OutputMode) -> Value { - match (workflow_output_policy(workflow), output_mode) { - (WorkflowOutputPolicy::MachineRawHumanCompactArtifacts, OutputMode::Human) => { - compact_deployment_data(data) - } - _ => data.clone(), - } -} - -fn request_is_destructive(method: HttpMethod, path: &str) -> bool { - method == HttpMethod::Delete - || path.contains("/delete") - || path.contains("/remove") - || path.contains("/reject") - || path.contains("/logout") -} - -fn query_pairs_value(query: &[(String, String)]) -> Value { - Value::Array( - query - .iter() - .map(|(name, value)| json!({ "name": name, "value": value })) - .collect(), - ) -} - -fn upsert_query(query: &mut Vec<(String, String)>, name: &str, value: String) { - if let Some((_, existing)) = query.iter_mut().find(|(key, _)| key == name) { - *existing = value; - } else { - query.push((name.to_string(), value)); - } -} - -fn extract_paginated_items(value: &Value, preferred_field: &str) -> Option> { - if let Some(items) = array_at_path(value, preferred_field) { - return Some(items.to_vec()); - } - for path in [ - "items", - "incidents", - "results", - "data.items", - "data.incidents", - "data.results", - "data", - ] { - if let Some(items) = array_at_path(value, path) { - return Some(items.to_vec()); - } - } - value.as_array().cloned() -} - -fn array_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a [Value]> { - let mut current = value; - for segment in path.split('.') { - if segment.is_empty() { - continue; - } - current = current.get(segment)?; - } - current.as_array().map(Vec::as_slice) -} - -fn parse_key_values( - kind: &'static str, - entries: &[String], -) -> Result, ApiCommandError> { - entries - .iter() - .map(|entry| { - let (key, value) = entry.split_once('=').ok_or_else(|| { - ApiCommandError::InvalidKeyValue { - kind, - input: entry.clone(), - } - })?; - Ok((key.to_string(), value.to_string())) - }) - .collect() -} - -fn parse_headers(entries: &[String]) -> Result { - let mut headers = HeaderMap::new(); - - for entry in entries { - let (name, value) = entry.split_once('=').ok_or_else(|| { - ApiCommandError::InvalidKeyValue { - kind: "header", - input: entry.clone(), - } - })?; - let header_name = HeaderName::from_str(name).map_err(|source| { - ApiCommandError::InvalidHeaderName { - name: name.to_string(), - source, - } - })?; - let header_value = HeaderValue::from_str(value).map_err(|source| { - ApiCommandError::InvalidHeaderValue { - name: name.to_string(), - source, - } - })?; - headers.insert(header_name, header_value); - } - - Ok(headers) -} - -fn read_body( - body: Option<&str>, - body_file: Option<&PathBuf>, -) -> Result, ApiCommandError> { - if let Some(body) = body { - return Ok(Some(body.to_string())); - } - - if let Some(path) = body_file { - if path.as_os_str() == "-" { - let mut body = String::new(); - std::io::stdin() - .read_to_string(&mut body) - .map_err(ApiCommandError::Stdin)?; - return Ok(Some(body)); - } - - return fs::read_to_string(path).map(Some).map_err(|source| { - ApiCommandError::BodyFile { - path: path.clone(), - source, - } - }); - } - - Ok(None) -} - -fn write_json_output_file(path: &PathBuf, value: &Value) -> Result<(), ApiCommandError> { - let body = serde_json::to_string_pretty(value)?; - fs::write(path, body).map_err(|source| { - ApiCommandError::OutputFile { - path: path.clone(), - source, - } - }) -} - -fn write_jsonl_items_output_file(path: &PathBuf, value: &Value) -> Result<(), ApiCommandError> { - let items = value - .get("items") - .and_then(Value::as_array) - .ok_or_else(|| { - ApiCommandError::InvalidWorkflow { - message: "--jsonl output requires paginated data with an items array".to_string(), - } - })?; - let mut body = String::new(); - for item in items { - body.push_str(&serde_json::to_string(item)?); - body.push('\n'); - } - fs::write(path, body).map_err(|source| { - ApiCommandError::OutputFile { - path: path.clone(), - source, - } - }) -} - #[cfg(test)] mod tests; diff --git a/crates/pcl/core/src/api/definitions.rs b/crates/pcl/core/src/api/definitions.rs index ad953dd..590fa80 100644 --- a/crates/pcl/core/src/api/definitions.rs +++ b/crates/pcl/core/src/api/definitions.rs @@ -204,11 +204,11 @@ fn raw_api_command_manifests() -> Vec { "output": "operation_id, method, path, auth metadata, workflow_alternatives, raw_api_use, path_params, required_query, body_fields, required_body_fields, body_template, response_statuses, example_call", }), json!({ - "command": "pcl api call [--query key=value] [--field key=value] [--body-file body.json] [--paginate ] [--page-param page] [--limit-param limit] [--jsonl] [--output ] [--dry-run]", - "description": "Execute any endpoint below /api/v1. Query strings in PATH and repeated --query flags are both accepted; --field merges simple JSON object body fields; GET calls can paginate any array response with --paginate. Add --dry-run to print the request plan without sending it.", + "command": "pcl api call [--query key=value] [--field key=value] [--body-file body.json] [--paginate ] [--page-param page] [--limit-param limit] [--jsonl] [--output ]", + "description": "Execute any endpoint below /api/v1. Query strings in PATH and repeated --query flags are both accepted; --field merges simple JSON object body fields; GET calls can paginate any array response with --paginate.", "output": "request and response status/body; non-2xx responses return structured error envelopes with request_id when the API provides one. Raw calls log operation_id when the live OpenAPI manifest can resolve the method/path.", "actions": [ - {"name": "execute", "method": "*", "path": "", "auth": "default", "optional_flags": ["--dry-run"], "example": agent_command("pcl api call get /views/public/incidents --query limit=5 --allow-unauthenticated")}, + {"name": "execute", "method": "*", "path": "", "auth": "default", "example": agent_command("pcl api call get /views/public/incidents --query limit=5 --allow-unauthenticated")}, {"name": "paginate", "method": "GET", "path": "", "auth": "default", "required_flags": ["--paginate"], "optional_flags": ["--all", "--page", "--limit", "--page-param", "--limit-param", "--max-pages", "--jsonl", "--output"], "example": agent_command("pcl api call get /views/public/incidents --paginate incidents --limit 50 --allow-unauthenticated --output incidents.json")}, {"name": "export_jsonl", "method": "GET", "path": "", "auth": "default", "required_flags": ["--paginate", "--jsonl", "--output"], "example": agent_command("pcl api call get /views/public/incidents --paginate incidents --limit 50 --allow-unauthenticated --jsonl --output incidents.jsonl")} ], diff --git a/crates/pcl/core/src/api/envelopes.rs b/crates/pcl/core/src/api/envelopes.rs new file mode 100644 index 0000000..96d01d2 --- /dev/null +++ b/crates/pcl/core/src/api/envelopes.rs @@ -0,0 +1,117 @@ +use super::{ + WorkflowCallResult, + definitions::{ + WorkflowOutputPolicy, + workflow_output_policy, + }, + workflows::compact_deployment_data, +}; +use crate::output::with_envelope_metadata; +use pcl_common::args::OutputMode; +use serde_json::{ + Value, + json, +}; + +pub(in crate::api) 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", + ], + })) +} + +pub(in crate::api) fn workflow_success_envelope( + result: WorkflowCallResult, + next_actions: Vec, +) -> Value { + with_envelope_metadata(json!({ + "status": "ok", + "data": result.body, + "request": result.request, + "response": result.response, + "next_actions": next_actions, + })) +} + +pub(in crate::api) fn workflow_success_envelope_with_data( + result: WorkflowCallResult, + data: Value, + next_actions: Vec, +) -> Value { + with_envelope_metadata(json!({ + "status": "ok", + "data": data, + "request": result.request, + "response": result.response, + "next_actions": next_actions, + })) +} + +pub(in crate::api) fn workflow_data_for_output_mode( + workflow: &str, + data: &Value, + output_mode: OutputMode, +) -> Value { + match (workflow_output_policy(workflow), output_mode) { + (WorkflowOutputPolicy::MachineRawHumanCompactArtifacts, OutputMode::Human) => { + compact_deployment_data(data) + } + _ => data.clone(), + } +} + +pub(in crate::api) fn query_pairs_value(query: &[(String, String)]) -> Value { + Value::Array( + query + .iter() + .map(|(name, value)| json!({ "name": name, "value": value })) + .collect(), + ) +} + +pub(in crate::api) fn upsert_query(query: &mut Vec<(String, String)>, name: &str, value: String) { + if let Some((_, existing)) = query.iter_mut().find(|(key, _)| key == name) { + *existing = value; + } else { + query.push((name.to_string(), value)); + } +} + +pub(in crate::api) fn extract_paginated_items( + value: &Value, + preferred_field: &str, +) -> Option> { + if let Some(items) = array_at_path(value, preferred_field) { + return Some(items.to_vec()); + } + for path in [ + "items", + "incidents", + "results", + "data.items", + "data.incidents", + "data.results", + "data", + ] { + if let Some(items) = array_at_path(value, path) { + return Some(items.to_vec()); + } + } + value.as_array().cloned() +} + +fn array_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a [Value]> { + let mut current = value; + for segment in path.split('.') { + if segment.is_empty() { + continue; + } + current = current.get(segment)?; + } + current.as_array().map(Vec::as_slice) +} diff --git a/crates/pcl/core/src/api/error.rs b/crates/pcl/core/src/api/error.rs new file mode 100644 index 0000000..b180cec --- /dev/null +++ b/crates/pcl/core/src/api/error.rs @@ -0,0 +1,435 @@ +use crate::{ + error::AuthError, + output::with_envelope_metadata, +}; +use serde_json::{ + Map, + Value, + json, +}; +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +pub enum ApiCommandError { + #[error("Run `pcl auth login` first, or pass `--allow-unauthenticated`")] + NoAuthToken, + + #[error( + "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), + + #[error("Failed to refresh stored auth before retrying the API request: {0}")] + AuthRefresh(#[source] AuthError), + + #[error("Invalid {kind} `{input}`. Expected KEY=VALUE.")] + InvalidKeyValue { kind: &'static str, input: String }, + + #[error("Invalid header name `{name}`: {source}")] + InvalidHeaderName { + name: String, + #[source] + source: reqwest::header::InvalidHeaderName, + }, + + #[error("Invalid header value for `{name}`: {source}")] + InvalidHeaderValue { + name: String, + #[source] + source: reqwest::header::InvalidHeaderValue, + }, + + #[error("Invalid API path `{0}`. Paths must start with `/`.")] + InvalidPath(String), + + #[error("Failed to build API URL: {0}")] + Url(#[from] url::ParseError), + + #[error("Failed to read body file `{path}`: {source}")] + BodyFile { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("Failed to read request log `{path}`: {source}")] + RequestLog { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("Failed to write output file `{path}`: {source}")] + OutputFile { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("Failed to read request body from stdin: {0}")] + Stdin(std::io::Error), + + #[error("Invalid JSON body: {0}")] + Json(#[from] serde_json::Error), + + #[error("API request failed: {0}")] + Request(#[from] reqwest::Error), + + #[error("API request failed with status {status} for {method} {path}")] + HttpStatus { + method: &'static str, + path: String, + status: u16, + request_id: Option, + body: Box, + }, + + #[error("OpenAPI spec does not contain a paths object")] + MissingPaths, + + #[error("No API operation matched `{0}`")] + OperationNotFound(String), + + #[error("{message}")] + InvalidWorkflow { message: String }, + + #[error("{message}")] + InvalidWorkflowWithActions { + message: String, + next_actions: Vec, + }, +} + +impl ApiCommandError { + pub fn code(&self) -> &'static str { + match self { + Self::NoAuthToken => "auth.no_token", + Self::ExpiredAuthToken(_) => "auth.expired_token", + Self::AuthRefresh(_) => "auth.refresh_failed", + Self::InvalidKeyValue { .. } => "input.invalid_key_value", + Self::InvalidHeaderName { .. } => "input.invalid_header_name", + Self::InvalidHeaderValue { .. } => "input.invalid_header_value", + Self::InvalidPath(_) => "input.invalid_path", + Self::Url(_) => "input.invalid_url", + Self::BodyFile { .. } => "input.body_file_read_failed", + Self::RequestLog { .. } => "request_log.read_failed", + Self::OutputFile { .. } => "output.file_write_failed", + Self::Stdin(_) => "input.stdin_read_failed", + Self::Json(_) => "input.invalid_json", + Self::Request(source) => { + match source.status().map(|status| status.as_u16()) { + Some(400) => "api.bad_request", + Some(401) => "auth.unauthorized", + Some(403) => "auth.forbidden", + Some(404) => "api.not_found", + Some(422) => "api.validation_failed", + Some(500..=599) => "api.server_error", + _ => "network.request_failed", + } + } + Self::HttpStatus { status, .. } => { + match *status { + 400 => "api.bad_request", + 401 => "auth.unauthorized", + 403 => "auth.forbidden", + 404 => "api.not_found", + 422 => "api.validation_failed", + 500..=599 => "api.server_error", + _ => "api.request_failed", + } + } + Self::MissingPaths => "openapi.missing_paths", + Self::OperationNotFound(_) => "openapi.operation_not_found", + Self::InvalidWorkflow { .. } | Self::InvalidWorkflowWithActions { .. } => { + "workflow.invalid_arguments" + } + } + } + + pub fn recoverable(&self) -> bool { + !matches!(self, Self::MissingPaths) + } + + pub fn next_actions(&self) -> Vec { + match self { + Self::NoAuthToken | Self::ExpiredAuthToken(_) | Self::AuthRefresh(_) => { + vec![ + "pcl auth refresh --toon".to_string(), + "pcl auth login".to_string(), + "pcl api list --allow-unauthenticated --toon".to_string(), + ] + } + Self::InvalidPath(_) => { + vec![ + "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" + )] + } + Self::InvalidHeaderName { .. } | Self::InvalidHeaderValue { .. } => { + vec![ + "Use --header name=value, for example: --header x-cl-dev-mode=true".to_string(), + ] + } + Self::Json(_) => { + vec![ + "Use --field key=value for simple request bodies".to_string(), + "Use --body-file request.json for nested request bodies".to_string(), + ] + } + Self::OperationNotFound(_) => { + vec![ + "pcl api list --toon".to_string(), + "pcl api inspect get /views/public/incidents --toon".to_string(), + ] + } + Self::InvalidWorkflowWithActions { next_actions, .. } => next_actions.clone(), + Self::InvalidWorkflow { .. } => { + vec![ + "pcl projects mine".to_string(), + "pcl schema list".to_string(), + "pcl workflows".to_string(), + ] + } + Self::Request(source) + if matches!( + source.status().map(|status| status.as_u16()), + Some(401 | 403) + ) => + { + vec![ + "pcl auth login".to_string(), + "Use --allow-unauthenticated only for public endpoints".to_string(), + ] + } + Self::HttpStatus { status: 401, .. } => { + vec![ + "pcl auth refresh --toon".to_string(), + "pcl auth login".to_string(), + "Use --allow-unauthenticated only for public endpoints".to_string(), + ] + } + Self::HttpStatus { status: 403, .. } => { + vec![ + "Read error.http.body for the API-provided reason".to_string(), + "Check whether the endpoint is enabled and your user has permission" + .to_string(), + "Use --allow-unauthenticated only for endpoints documented as public" + .to_string(), + ] + } + Self::HttpStatus { + method, + path, + status: 400 | 422, + .. + } => { + vec![ + format!( + "pcl api inspect {} {} --toon", + method.to_ascii_lowercase(), + path + ), + "pcl api manifest --toon".to_string(), + "Read error.http.body for the rejected field details".to_string(), + ] + } + Self::HttpStatus { status: 404, .. } => { + vec![ + "Check the project ID, slug, or API path and retry".to_string(), + "pcl projects mine".to_string(), + ] + } + Self::HttpStatus { + method, + status: status @ 500..=599, + request_id, + path, + .. + } => { + let mut actions = if mutation_outcome_ambiguous(method, *status) { + vec![ + format!( + "Do not retry immediately; inspect the target resource for {} {} to confirm whether the mutation applied", + method.to_ascii_lowercase(), + path + ), + "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 --toon".to_string(), + "Read error.http.body for API-provided failure details".to_string(), + ] + }; + if let Some(request_id) = request_id { + actions.push(format!( + "Include request_id {request_id} when reporting this server error" + )); + } + actions + } + Self::HttpStatus { .. } => { + vec![ + "pcl api manifest --toon".to_string(), + "Read error.http.body for API-provided failure details".to_string(), + ] + } + Self::Request(source) if source.status().map(|status| status.as_u16()) == Some(404) => { + vec![ + "Check the project ID, slug, or API path and retry".to_string(), + "pcl projects mine".to_string(), + ] + } + Self::Request(_) | Self::Url(_) => { + vec!["Check --api-url and your network connection, then retry".to_string()] + } + Self::BodyFile { .. } => { + vec!["Check --body-file path or pass --body directly".to_string()] + } + Self::RequestLog { .. } => { + vec![ + "pcl requests path --toon".to_string(), + "Check request log permissions or move the PCL state directory".to_string(), + ] + } + Self::OutputFile { .. } => { + vec!["Check --output path permissions or choose a writable file".to_string()] + } + Self::Stdin(_) => vec!["Pipe a JSON body into --body-file -".to_string()], + Self::MissingPaths => { + vec!["Check that /api/v1/openapi returns an OpenAPI document".to_string()] + } + } + } + + pub fn suggested_next_actions(&self) -> Vec<&'static str> { + match self { + Self::NoAuthToken | Self::ExpiredAuthToken(_) | Self::AuthRefresh(_) => { + vec!["refresh_or_login", "retry"] + } + Self::InvalidKeyValue { .. } + | Self::InvalidHeaderName { .. } + | Self::InvalidHeaderValue { .. } + | Self::InvalidPath(_) + | Self::Json(_) + | Self::InvalidWorkflow { .. } + | Self::InvalidWorkflowWithActions { .. } => vec!["fix_input", "retry"], + Self::OperationNotFound(_) | Self::MissingPaths => vec!["inspect_manifest"], + Self::Request(source) if source.status().map(|status| status.as_u16()) == Some(404) => { + vec!["check_ids", "retry"] + } + Self::Request(_) | Self::Url(_) => vec!["check_network", "retry"], + Self::BodyFile { .. } | Self::Stdin(_) => vec!["fix_body_input", "retry"], + Self::RequestLog { .. } => vec!["inspect_request_log", "retry"], + Self::OutputFile { .. } => vec!["fix_output_path", "retry"], + Self::HttpStatus { status: 401, .. } => vec!["refresh_or_login", "retry"], + Self::HttpStatus { status: 403, .. } => { + vec!["check_permissions", "inspect_response_body"] + } + Self::HttpStatus { + status: 400 | 422, .. + } => vec!["inspect_operation", "fix_request", "retry"], + Self::HttpStatus { status: 404, .. } => vec!["inspect_manifest", "check_ids"], + Self::HttpStatus { status: 429, .. } => vec!["retry_later", "reduce_request_rate"], + Self::HttpStatus { + method, + status: status @ 500..=599, + .. + } if mutation_outcome_ambiguous(method, *status) => { + vec!["reconcile_mutation", "contact_platform_with_request_id"] + } + Self::HttpStatus { + status: 500..=599, .. + } => { + vec![ + "retry_later", + "export_project_incidents_with_errors", + "contact_platform_with_request_id", + ] + } + Self::HttpStatus { .. } => vec!["inspect_response_body", "retry"], + } + } + + pub fn json_envelope(&self) -> Value { + let mut error = Map::new(); + error.insert("code".to_string(), json!(self.code())); + error.insert("message".to_string(), json!(self.to_string())); + error.insert("recoverable".to_string(), json!(self.recoverable())); + + if let Self::HttpStatus { + method, + path, + status, + request_id, + body, + } = self + { + let outcome_ambiguous = mutation_outcome_ambiguous(method, *status); + if let Some(request_id) = request_id { + error.insert("request_id".to_string(), json!(request_id)); + } + error.insert( + "http".to_string(), + json!({ + "method": method, + "path": path, + "status": status, + "request_id": request_id, + "body": body.as_ref(), + }), + ); + error.insert( + "mutation".to_string(), + json!({ + "side_effecting": method_side_effecting(method), + "outcome_ambiguous": outcome_ambiguous, + }), + ); + } + + let mut envelope = json!({ + "status": "error", + "error": error, + "suggested_next_actions": self.suggested_next_actions(), + "next_actions": self.next_actions(), + }); + + if let Self::HttpStatus { + method, + path, + status, + request_id, + .. + } = self + && let Some(object) = envelope.as_object_mut() + { + object.insert("http_status".to_string(), json!(status)); + object.insert("method".to_string(), json!(method)); + object.insert("path".to_string(), json!(path)); + object.insert("request_id".to_string(), json!(request_id)); + object.insert( + "outcome_ambiguous".to_string(), + json!(mutation_outcome_ambiguous(method, *status)), + ); + } + + with_envelope_metadata(envelope) + } +} + +pub(in crate::api) fn method_side_effecting(method: &str) -> bool { + !method.eq_ignore_ascii_case("GET") && !method.eq_ignore_ascii_case("HEAD") +} + +fn mutation_outcome_ambiguous(method: &str, status: u16) -> bool { + method_side_effecting(method) && status >= 500 +} diff --git a/crates/pcl/core/src/api/input.rs b/crates/pcl/core/src/api/input.rs new file mode 100644 index 0000000..5c4f60b --- /dev/null +++ b/crates/pcl/core/src/api/input.rs @@ -0,0 +1,143 @@ +use super::ApiCommandError; +use reqwest::header::{ + HeaderMap, + HeaderName, + HeaderValue, +}; +use serde_json::Value; +use std::{ + fs, + io::Read, + path::PathBuf, + str::FromStr, +}; + +pub(in crate::api) fn split_path_and_inline_query( + input: &str, +) -> Result<(String, Vec<(String, String)>), ApiCommandError> { + if !input.starts_with('/') { + return Err(ApiCommandError::InvalidPath(input.to_string())); + } + let Some((path, query)) = input.split_once('?') else { + return Ok((input.to_string(), Vec::new())); + }; + if path.is_empty() || !path.starts_with('/') { + return Err(ApiCommandError::InvalidPath(input.to_string())); + } + let query = url::form_urlencoded::parse(query.as_bytes()) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); + Ok((path.to_string(), query)) +} + +pub(in crate::api) fn parse_key_values( + kind: &'static str, + entries: &[String], +) -> Result, ApiCommandError> { + entries + .iter() + .map(|entry| { + let (key, value) = entry.split_once('=').ok_or_else(|| { + ApiCommandError::InvalidKeyValue { + kind, + input: entry.clone(), + } + })?; + Ok((key.to_string(), value.to_string())) + }) + .collect() +} + +pub(in crate::api) fn parse_headers(entries: &[String]) -> Result { + let mut headers = HeaderMap::new(); + + for entry in entries { + let (name, value) = entry.split_once('=').ok_or_else(|| { + ApiCommandError::InvalidKeyValue { + kind: "header", + input: entry.clone(), + } + })?; + let header_name = HeaderName::from_str(name).map_err(|source| { + ApiCommandError::InvalidHeaderName { + name: name.to_string(), + source, + } + })?; + let header_value = HeaderValue::from_str(value).map_err(|source| { + ApiCommandError::InvalidHeaderValue { + name: name.to_string(), + source, + } + })?; + headers.insert(header_name, header_value); + } + + Ok(headers) +} + +pub(in crate::api) fn read_body( + body: Option<&str>, + body_file: Option<&PathBuf>, +) -> Result, ApiCommandError> { + if let Some(body) = body { + return Ok(Some(body.to_string())); + } + + if let Some(path) = body_file { + if path.as_os_str() == "-" { + let mut body = String::new(); + std::io::stdin() + .read_to_string(&mut body) + .map_err(ApiCommandError::Stdin)?; + return Ok(Some(body)); + } + + return fs::read_to_string(path).map(Some).map_err(|source| { + ApiCommandError::BodyFile { + path: path.clone(), + source, + } + }); + } + + Ok(None) +} + +pub(in crate::api) fn write_json_output_file( + path: &PathBuf, + value: &Value, +) -> Result<(), ApiCommandError> { + let body = serde_json::to_string_pretty(value)?; + fs::write(path, body).map_err(|source| { + ApiCommandError::OutputFile { + path: path.clone(), + source, + } + }) +} + +pub(in crate::api) fn write_jsonl_items_output_file( + path: &PathBuf, + value: &Value, +) -> Result<(), ApiCommandError> { + let items = value + .get("items") + .and_then(Value::as_array) + .ok_or_else(|| { + ApiCommandError::InvalidWorkflow { + message: "--jsonl output requires paginated data with an items array".to_string(), + } + })?; + let mut body = String::new(); + for item in items { + body.push_str(&serde_json::to_string(item)?); + body.push('\n'); + } + fs::write(path, body).map_err(|source| { + ApiCommandError::OutputFile { + path: path.clone(), + source, + } + }) +} diff --git a/crates/pcl/core/src/api/manifest.rs b/crates/pcl/core/src/api/manifest.rs index b74c333..6127e75 100644 --- a/crates/pcl/core/src/api/manifest.rs +++ b/crates/pcl/core/src/api/manifest.rs @@ -42,8 +42,8 @@ pub fn api_manifest() -> serde_json::Value { "login_command": "pcl auth login", }, "safety": { - "dry_run": "Optional planning mode: add --dry-run to workflow commands before write flags, for example `pcl projects --dry-run --create ...`. Re-run without --dry-run only when ready to execute.", - "destructive_detection": "Request plans flag likely destructive paths, but raw api call does not enforce a confirmation gate." + "body_templates": "Use --body-template before mutation workflows that require nested payloads.", + "execution": "Workflow commands execute when invoked. Use typed flags first, then --field key=value or --body-file body.json for request bodies." }, "product_surfaces": [ {"command": "pcl --toon --llms | pcl llms --toon", "description": "Print the CLI-native LLM usage guide for agents."}, diff --git a/crates/pcl/core/src/api/method.rs b/crates/pcl/core/src/api/method.rs new file mode 100644 index 0000000..b5d5c12 --- /dev/null +++ b/crates/pcl/core/src/api/method.rs @@ -0,0 +1,42 @@ +use clap::ValueEnum; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub(in crate::api) enum HttpMethod { + Get, + Post, + Put, + Patch, + Delete, +} + +impl HttpMethod { + pub(in crate::api) fn as_str(self) -> &'static str { + match self { + Self::Get => "GET", + Self::Post => "POST", + Self::Put => "PUT", + Self::Patch => "PATCH", + Self::Delete => "DELETE", + } + } + + pub(in crate::api) fn openapi_key(self) -> &'static str { + match self { + Self::Get => "get", + Self::Post => "post", + Self::Put => "put", + Self::Patch => "patch", + Self::Delete => "delete", + } + } + + pub(in crate::api) fn reqwest(self) -> reqwest::Method { + match self { + Self::Get => reqwest::Method::GET, + Self::Post => reqwest::Method::POST, + Self::Put => reqwest::Method::PUT, + Self::Patch => reqwest::Method::PATCH, + Self::Delete => reqwest::Method::DELETE, + } + } +} diff --git a/crates/pcl/core/src/api/operations.rs b/crates/pcl/core/src/api/operations.rs new file mode 100644 index 0000000..ea3f1c2 --- /dev/null +++ b/crates/pcl/core/src/api/operations.rs @@ -0,0 +1,189 @@ +use super::{ + ApiCommandError, + HttpMethod, +}; +use serde_json::Value; +use std::{ + collections::HashMap, + sync::LazyLock, +}; + +static OPERATION_PATHS: LazyLock> = LazyLock::new(|| { + let spec: Value = serde_json::from_str(include_str!( + "../../../../dapp-api-client/openapi/spec.json" + )) + .expect("cached dapp OpenAPI spec must parse"); + let mut operations = HashMap::new(); + let paths = spec + .get("paths") + .and_then(Value::as_object) + .expect("cached dapp OpenAPI spec must contain paths"); + + for (path, path_item) in paths { + let Some(methods) = path_item.as_object() else { + continue; + }; + for (method, operation) in methods { + let Some(method) = method_from_openapi_key(method) else { + continue; + }; + let operation_id = operation + .get("operationId") + .and_then(Value::as_str) + .map_or_else( + || generated_operation_id(method.openapi_key(), path), + ToString::to_string, + ); + operations.insert(operation_id, (method, path.clone())); + } + } + + operations +}); + +#[derive(Clone, Debug)] +pub(in crate::api) struct WorkflowOperation { + pub(in crate::api) method: HttpMethod, + pub(in crate::api) operation_id: &'static str, + path_params: Vec<(&'static str, String)>, +} + +impl WorkflowOperation { + pub(in crate::api) fn new(method: HttpMethod, operation_id: &'static str) -> Self { + Self { + method, + operation_id, + path_params: Vec::new(), + } + } + + pub(in crate::api) fn path_param( + mut self, + name: &'static str, + value: impl Into, + ) -> Self { + self.path_params.push((name, value.into())); + self + } + + pub(in crate::api) fn path(&self) -> Result { + let (method, template) = OPERATION_PATHS.get(self.operation_id).ok_or_else(|| { + ApiCommandError::InvalidWorkflow { + message: format!( + "Generated OpenAPI operation `{}` was not found", + self.operation_id + ), + } + })?; + if *method != self.method { + return Err(ApiCommandError::InvalidWorkflow { + message: format!( + "Generated OpenAPI operation `{}` uses method {}, not {}", + self.operation_id, + method.as_str(), + self.method.as_str() + ), + }); + } + + let mut path = template.clone(); + for (name, value) in &self.path_params { + let encoded = encode_path_segment(value); + path = path.replace(&format!("{{{name}}}"), &encoded); + } + if path.contains('{') || path.contains('}') { + return Err(ApiCommandError::InvalidWorkflow { + message: format!( + "Missing path parameter for generated OpenAPI operation `{}`", + self.operation_id + ), + }); + } + Ok(path) + } +} + +fn method_from_openapi_key(method: &str) -> Option { + match method { + "get" => Some(HttpMethod::Get), + "post" => Some(HttpMethod::Post), + "put" => Some(HttpMethod::Put), + "patch" => Some(HttpMethod::Patch), + "delete" => Some(HttpMethod::Delete), + _ => None, + } +} + +fn generated_operation_id(method: &str, path: &str) -> String { + let path_parts = path + .split('/') + .filter(|segment| !segment.is_empty()) + .map(generated_operation_segment) + .collect::>() + .join("_"); + format!("{method}_{path_parts}") +} + +fn generated_operation_segment(segment: &str) -> String { + let segment = segment.trim_matches(|ch| ch == '{' || ch == '}'); + let mut output = String::new(); + let mut previous_was_lower_or_digit = false; + + for ch in segment.chars() { + if ch.is_ascii_uppercase() { + if previous_was_lower_or_digit { + output.push('_'); + } + output.push(ch.to_ascii_lowercase()); + previous_was_lower_or_digit = false; + } else if ch.is_ascii_alphanumeric() { + output.push(ch.to_ascii_lowercase()); + previous_was_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit(); + } else if !output.ends_with('_') { + output.push('_'); + previous_was_lower_or_digit = false; + } + } + + output.trim_matches('_').to_string() +} + +fn encode_path_segment(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + encoded.push(byte as char); + } + _ => { + use std::fmt::Write as _; + write!(&mut encoded, "%{byte:02X}").expect("writing to a String cannot fail"); + } + } + } + + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_operations_expand_and_encode_path_segments() { + let path = WorkflowOperation::new( + HttpMethod::Get, + "get_views_incidents_incident_id_transactions_tx_id_trace", + ) + .path_param("incidentId", "incident 1") + .path_param("txId", "0xabc/def") + .path() + .unwrap(); + + assert_eq!( + path, + "/views/incidents/incident%201/transactions/0xabc%2Fdef/trace" + ); + } +} diff --git a/crates/pcl/core/src/api/runner.rs b/crates/pcl/core/src/api/runner.rs new file mode 100644 index 0000000..5b8523c --- /dev/null +++ b/crates/pcl/core/src/api/runner.rs @@ -0,0 +1,1411 @@ +use super::{ + ApiArgs, + ApiCommand, + ApiCommandError, + ApiRequestInput, + AssertionsArgs, + ContractsArgs, + DeploymentsArgs, + HttpMethod, + IncidentsArgs, + PreparedApiRequest, + ProjectsArgs, + ProtocolManagerArgs, + RawPaginationOptions, + ReleasesArgs, + SearchArgs, + TransfersArgs, + WorkflowCallResult, + WorkflowPaginationOptions, + WorkflowRequest, + access_body_template, + access_request, + account_request, + api_coverage, + api_manifest, + assertions_next_actions, + assertions_request, + body_template, + command_next_actions, + contracts_body_template, + contracts_next_actions, + contracts_request, + deployment_body_template, + deployments_request, + events_request, + extract_paginated_items, + incidents_next_actions, + incidents_request, + inspect_operation, + integration_body_template, + integrations_request, + list_operations, + next_actions_for_operations, + ok_envelope, + openapi_path_matches, + parse_headers, + parse_key_values, + print_output, + project_body_template, + project_segment, + projects_next_actions, + projects_request, + protocol_manager_body_template, + protocol_manager_next_actions, + protocol_manager_request, + public_raw_call_path, + query_pairs_value, + read_api_response, + release_body_template, + releases_next_actions, + releases_request, + request_body, + search_next_actions, + search_request, + split_path_and_inline_query, + template_envelope, + transfer_body_template, + transfers_next_actions, + transfers_request, + upsert_query, + workflow_data_for_output_mode, + workflow_success_envelope, + workflow_success_envelope_with_data, + write_api_coverage_markdown, + write_json_output_file, + write_jsonl_items_output_file, + write_request_log, +}; +use crate::{ + auth::refresh_stored_auth, + config::CliConfig, + error::AuthError, +}; +use pcl_common::args::CliArgs; +use reqwest::header::{ + HeaderMap, + HeaderName, + HeaderValue, +}; +use serde_json::{ + Value, + json, +}; +use std::path::Path; + +fn print_api_value(output: Value, json_output: bool) -> Result<(), ApiCommandError> { + print_output(&output, json_output) +} + +impl ApiArgs { + pub async fn run( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + json_output: bool, + ) -> Result<(), ApiCommandError> { + let request_log_path = crate::request_log::request_log_path_for_args(cli_args); + match &self.command { + ApiCommand::Incidents(args) => { + print_api_value( + self.run_incidents(config, cli_args, args, &request_log_path) + .await?, + json_output, + )?; + } + ApiCommand::Projects(args) => { + print_api_value( + self.run_projects(config, cli_args, args, &request_log_path) + .await?, + json_output, + )?; + } + ApiCommand::Assertions(args) => { + print_api_value( + self.run_assertions(config, cli_args, args, &request_log_path) + .await?, + json_output, + )?; + } + ApiCommand::Search(args) => { + print_api_value( + self.run_search(config, cli_args, args, &request_log_path) + .await?, + json_output, + )?; + } + ApiCommand::Account(args) => { + if args.body_template { + return print_api_value( + template_envelope(body_template("empty_object")), + json_output, + ); + } + print_api_value( + self.run_workflow( + config, + cli_args, + "account", + account_request(args)?, + &request_log_path, + ) + .await?, + json_output, + )?; + } + ApiCommand::Contracts(args) => { + if args.body_template { + return print_api_value( + template_envelope(contracts_body_template(args)), + json_output, + ); + } + print_api_value( + self.run_contracts(config, cli_args, args, &request_log_path) + .await?, + json_output, + )?; + } + ApiCommand::Releases(args) => { + if args.body_template { + return print_api_value( + template_envelope(release_body_template(args)), + json_output, + ); + } + print_api_value( + self.run_releases(config, cli_args, args, &request_log_path) + .await?, + json_output, + )?; + } + ApiCommand::Deployments(args) => { + if args.body_template { + return print_api_value( + template_envelope(deployment_body_template(args)), + json_output, + ); + } + print_api_value( + self.run_deployments(config, cli_args, args, &request_log_path) + .await?, + json_output, + )?; + } + ApiCommand::Access(args) => { + if args.body_template { + return print_api_value( + template_envelope(access_body_template(args)), + json_output, + ); + } + print_api_value( + self.run_workflow( + config, + cli_args, + "access", + access_request(args)?, + &request_log_path, + ) + .await?, + json_output, + )?; + } + ApiCommand::Integrations(args) => { + if args.body_template { + return print_api_value( + template_envelope(integration_body_template(args)), + json_output, + ); + } + print_api_value( + self.run_workflow( + config, + cli_args, + "integrations", + integrations_request(args)?, + &request_log_path, + ) + .await?, + json_output, + )?; + } + ApiCommand::ProtocolManager(args) => { + if args.body_template { + return print_api_value( + template_envelope(protocol_manager_body_template(args)), + json_output, + ); + } + print_api_value( + self.run_protocol_manager(config, cli_args, args, &request_log_path) + .await?, + json_output, + )?; + } + ApiCommand::Transfers(args) => { + if args.body_template { + return print_api_value( + template_envelope(transfer_body_template(args)), + json_output, + ); + } + print_api_value( + self.run_transfers(config, cli_args, args, &request_log_path) + .await?, + json_output, + )?; + } + ApiCommand::Events(args) => { + print_api_value( + self.run_workflow( + config, + cli_args, + "events", + events_request(args)?, + &request_log_path, + ) + .await?, + json_output, + )?; + } + ApiCommand::Manifest => print_api_value(ok_envelope(api_manifest()), json_output)?, + ApiCommand::List { filter, method } => { + let spec = self.fetch_openapi(config).await?; + let operations = list_operations(&spec, filter.as_deref(), *method)?; + let next_actions = next_actions_for_operations(&operations); + print_api_value( + json!({ + "status": "ok", + "data": { + "operations": operations, + }, + "next_actions": next_actions, + }), + json_output, + )?; + } + ApiCommand::Inspect { + operation, + path, + full, + } => { + let spec = self.fetch_openapi(config).await?; + let inspected = inspect_operation(&spec, operation, path.as_deref(), *full)?; + let next_actions = command_next_actions(&inspected); + print_api_value( + json!({ + "status": "ok", + "data": inspected, + "next_actions": next_actions, + }), + json_output, + )?; + } + ApiCommand::Coverage { records, markdown } => { + let spec = self.fetch_openapi(config).await?; + let coverage = + api_coverage(&spec, &request_log_path, *records, self.api_url.as_str())?; + if let Some(path) = markdown { + write_api_coverage_markdown(path, &coverage)?; + } + print_api_value( + json!({ + "status": "ok", + "data": coverage, + "next_actions": [ + "pcl requests list --toon", + "pcl api list --toon", + "pcl api coverage --markdown api-coverage.md", + ], + }), + json_output, + )?; + } + ApiCommand::Call { + method, + path, + query, + header, + body, + body_file, + field, + paginate, + all: _, + page, + limit, + page_param, + limit_param, + max_pages, + jsonl, + output, + } => { + if *jsonl && output.is_none() { + return Err(ApiCommandError::InvalidWorkflow { + message: "--jsonl requires --output".to_string(), + }); + } + let input = ApiRequestInput { + method: *method, + path, + query, + header, + body: body.as_deref(), + body_file: body_file.as_ref(), + field, + require_auth: self.raw_call_requires_auth(*method, path)?, + }; + let pagination = paginate.as_ref().map(|item_field| { + RawPaginationOptions { + item_field, + start_page: page.unwrap_or(1), + limit: limit.unwrap_or(50), + page_param: page_param.as_deref().unwrap_or("page"), + limit_param: limit_param.as_deref().unwrap_or("limit"), + max_pages: max_pages.unwrap_or(100), + } + }); + let (mut response, next_actions) = if let Some(pagination) = pagination { + let response = self + .call_api_paginated(config, cli_args, input, pagination, &request_log_path) + .await?; + ( + response, + vec![ + "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 --toon".to_string(), + ], + ) + } else { + let response = self + .call_api(config, cli_args, input, &request_log_path) + .await?; + ( + response, + vec![ + "pcl api list --toon".to_string(), + "pcl api manifest --toon".to_string(), + ], + ) + }; + if let Some(path) = output { + if *jsonl { + write_jsonl_items_output_file(path, &response)?; + } else { + let body = response.pointer("/response/body").unwrap_or(&response); + write_json_output_file(path, body)?; + } + if let Some(object) = response.as_object_mut() { + object.insert("output_path".to_string(), json!(path.display().to_string())); + } + } + print_api_value( + json!({ + "status": "ok", + "data": response, + "next_actions": next_actions, + }), + json_output, + )?; + } + } + + Ok(()) + } + + pub(in crate::api) async fn call_api_paginated( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + input: ApiRequestInput<'_>, + pagination: RawPaginationOptions<'_>, + request_log_path: &Path, + ) -> Result { + if input.method.openapi_key() != "get" { + return Err(ApiCommandError::InvalidWorkflow { + message: "--paginate is only supported for GET requests".to_string(), + }); + } + if input.body.is_some() || input.body_file.is_some() || !input.field.is_empty() { + return Err(ApiCommandError::InvalidWorkflow { + message: "--paginate cannot be used with request bodies".to_string(), + }); + } + if pagination.limit == 0 { + return Err(ApiCommandError::InvalidWorkflow { + message: "--limit must be greater than zero".to_string(), + }); + } + if pagination.max_pages == 0 { + return Err(ApiCommandError::InvalidWorkflow { + message: "--max-pages must be greater than zero".to_string(), + }); + } + + let (path, mut base_query) = split_path_and_inline_query(input.path)?; + base_query.extend(parse_key_values("query", input.query)?); + let url = self.api_url(&path)?; + let headers = parse_headers(input.header)?; + let operation_id = self.resolve_operation_id(config, input.method, &path).await; + self.ensure_request_auth(config, cli_args, input.require_auth) + .await?; + let client = self.http_client( + config, + input.require_auth && !self.allow_unauthenticated, + input.require_auth && !self.allow_unauthenticated, + )?; + + let mut items = Vec::new(); + let mut pages_fetched = 0_u64; + let mut last_page_count = 0_usize; + + for offset in 0..pagination.max_pages { + let page = pagination.start_page + offset; + let mut page_query = base_query.clone(); + upsert_query(&mut page_query, pagination.page_param, page.to_string()); + upsert_query( + &mut page_query, + pagination.limit_param, + pagination.limit.to_string(), + ); + + let response = read_api_response( + client + .get(url.clone()) + .headers(headers.clone()) + .query(&page_query) + .send() + .await?, + ) + .await?; + write_request_log( + request_log_path, + "raw_paginated", + input.method.as_str(), + &path, + response.status.as_u16(), + response.request_id.as_deref(), + operation_id.as_deref(), + ); + if !response.status.is_success() { + return Err(ApiCommandError::HttpStatus { + method: input.method.as_str(), + path, + status: response.status.as_u16(), + request_id: response.request_id, + body: Box::new(response.body), + }); + } + + let page_items = + extract_paginated_items(&response.body, pagination.item_field).ok_or_else(|| { + ApiCommandError::InvalidWorkflow { + message: format!( + "Could not find an array at `{}` or common pagination fields in response", + pagination.item_field + ), + } + })?; + last_page_count = page_items.len(); + pages_fetched += 1; + items.extend(page_items); + + if last_page_count < usize::try_from(pagination.limit).unwrap_or(usize::MAX) { + break; + } + } + + let count = items.len(); + Ok(json!({ + "request": { + "method": input.method.as_str(), + "path": path, + "operation_id": operation_id, + "query": query_pairs_value(&base_query), + "pagination": { + "field": pagination.item_field, + "start_page": pagination.start_page, + "limit": pagination.limit, + "page_param": pagination.page_param, + "limit_param": pagination.limit_param, + "max_pages": pagination.max_pages, + } + }, + "items": items, + "count": count, + "pages_fetched": pages_fetched, + "last_page_count": last_page_count, + })) + } + + pub(in crate::api) async fn run_incidents( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + args: &IncidentsArgs, + request_log_path: &Path, + ) -> Result { + let request = incidents_request(args)?; + if args.jsonl && args.output.is_none() { + return Err(ApiCommandError::InvalidWorkflow { + message: "--jsonl requires --output".to_string(), + }); + } + if args.all { + let mut data = self + .call_workflow_paginated( + config, + cli_args, + request.clone(), + WorkflowPaginationOptions { + item_field: "incidents", + start_page: args.page.unwrap_or(1), + limit: args.limit.unwrap_or(50), + max_pages: args.max_pages.unwrap_or(100), + }, + request_log_path, + ) + .await?; + if let Some(path) = &args.output { + if args.jsonl { + write_jsonl_items_output_file(path, &data)?; + } else { + write_json_output_file(path, &data)?; + } + if let Some(object) = data.as_object_mut() { + object.insert("output_path".to_string(), json!(path.display().to_string())); + } + } + let mut next_actions = request.next_actions; + if args.output.is_none() { + next_actions.insert( + 0, + "Use --output incidents.json to save large paginated results".to_string(), + ); + } + return Ok(json!({ + "status": "ok", + "data": data, + "next_actions": next_actions, + })); + } + let result = self + .call_workflow_result(config, cli_args, &request, request_log_path) + .await?; + let next_actions = incidents_next_actions(&result.body, args, request.next_actions); + Ok(workflow_success_envelope(result, next_actions)) + } + + pub(in crate::api) async fn run_projects( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + args: &ProjectsArgs, + request_log_path: &Path, + ) -> Result { + if args.body_template { + return Ok(template_envelope(project_body_template(args))); + } + let request = projects_request(args)?; + self.run_prepared_workflow( + config, + cli_args, + "projects", + request, + request_log_path, + projects_next_actions, + ) + .await + } + + pub(in crate::api) async fn run_assertions( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + args: &AssertionsArgs, + request_log_path: &Path, + ) -> Result { + if args.body_template { + return Ok(template_envelope(body_template("empty_object"))); + } + let request = assertions_request(args)?; + self.run_prepared_workflow( + config, + cli_args, + "assertions", + request, + request_log_path, + |data, fallback| assertions_next_actions(data, args, fallback), + ) + .await + } + + pub(in crate::api) async fn run_search( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + args: &SearchArgs, + request_log_path: &Path, + ) -> Result { + let request = search_request(args)?; + self.run_prepared_workflow( + config, + cli_args, + "search", + request, + request_log_path, + search_next_actions, + ) + .await + } + + pub(in crate::api) async fn run_contracts( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + args: &ContractsArgs, + request_log_path: &Path, + ) -> Result { + let request = contracts_request(args)?; + self.run_prepared_workflow( + config, + cli_args, + "contracts", + request, + request_log_path, + |data, fallback| contracts_next_actions(data, args, fallback), + ) + .await + } + + pub(in crate::api) async fn run_releases( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + args: &ReleasesArgs, + request_log_path: &Path, + ) -> Result { + let request = releases_request(args)?; + self.run_prepared_workflow( + config, + cli_args, + "releases", + request, + request_log_path, + |data, fallback| releases_next_actions(data, args, fallback), + ) + .await + } + + pub(in crate::api) async fn run_deployments( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + args: &DeploymentsArgs, + request_log_path: &Path, + ) -> Result { + let request = deployments_request(args)?; + self.run_prepared_workflow( + config, + cli_args, + "deployments", + request, + request_log_path, + |_data, fallback| fallback, + ) + .await + } + + pub(in crate::api) async fn run_transfers( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + args: &TransfersArgs, + request_log_path: &Path, + ) -> Result { + let request = transfers_request(args)?; + self.run_prepared_workflow( + config, + cli_args, + "transfers", + request, + request_log_path, + |data, fallback| transfers_next_actions(data, args, fallback), + ) + .await + } + + pub(in crate::api) async fn run_protocol_manager( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + args: &ProtocolManagerArgs, + request_log_path: &Path, + ) -> Result { + let request = protocol_manager_request(args)?; + self.run_prepared_workflow( + config, + cli_args, + "protocol-manager", + request, + request_log_path, + |data, fallback| protocol_manager_next_actions(data, args, fallback), + ) + .await + } + + pub(in crate::api) async fn run_workflow( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + workflow: &'static str, + request: WorkflowRequest, + request_log_path: &Path, + ) -> Result { + self.run_prepared_workflow( + config, + cli_args, + workflow, + request, + request_log_path, + |_data, fallback| fallback, + ) + .await + } + + pub(in crate::api) async fn run_prepared_workflow( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + workflow: &'static str, + request: WorkflowRequest, + request_log_path: &Path, + next_actions_for: F, + ) -> Result + where + F: FnOnce(&Value, Vec) -> Vec, + { + let result = self + .call_workflow_result(config, cli_args, &request, request_log_path) + .await?; + let next_actions = next_actions_for(&result.body, request.next_actions); + let data = workflow_data_for_output_mode(workflow, &result.body, cli_args.output_mode()); + Ok(workflow_success_envelope_with_data( + result, + data, + next_actions, + )) + } + + pub(in crate::api) fn auth_plan( + &self, + require_auth: bool, + attach_auth: bool, + config: &CliConfig, + ) -> Value { + let now = chrono::Utc::now(); + let stored_token_present = config + .auth + .as_ref() + .is_some_and(|auth| !auth.access_token.trim().is_empty()); + let stored_token_valid = config + .auth + .as_ref() + .is_some_and(|auth| !auth.access_token.trim().is_empty() && auth.expires_at > now); + let will_attach_stored_token = + attach_auth && !self.allow_unauthenticated && stored_token_valid; + json!({ + "required": require_auth, + "will_attach_stored_token": will_attach_stored_token, + "stored_token_present": stored_token_present, + "stored_token_valid": stored_token_valid, + "allow_unauthenticated": self.allow_unauthenticated, + }) + } + + pub(in crate::api) fn raw_call_requires_auth( + &self, + method: HttpMethod, + path: &str, + ) -> Result { + if self.allow_unauthenticated { + return Ok(false); + } + let (path, _) = split_path_and_inline_query(path)?; + Ok(!public_raw_call_path(method, &path)) + } + + pub(in crate::api) async fn fetch_openapi( + &self, + config: &CliConfig, + ) -> Result { + let url = self.api_url("/openapi")?; + let request = self.http_client(config, false, false)?.get(url); + let response = read_api_response(request.send().await?).await?; + if !response.status.is_success() { + return Err(ApiCommandError::HttpStatus { + method: "GET", + path: "/openapi".to_string(), + status: response.status.as_u16(), + request_id: response.request_id, + body: Box::new(response.body), + }); + } + Ok(response.body) + } + + pub(in crate::api) async fn try_refresh_after_401( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + ) -> Result { + if !self.refresh_after_401.get() { + return Ok(false); + } + + match refresh_stored_auth(config, &self.api_url, cli_args, true).await { + Ok(_) => Ok(true), + Err(AuthError::RefreshEndpointNotFound { .. }) => { + self.refresh_after_401.set(false); + Ok(false) + } + Err(error) => Err(ApiCommandError::AuthRefresh(error)), + } + } + + pub(in crate::api) async fn call_api( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + input: ApiRequestInput<'_>, + request_log_path: &Path, + ) -> Result { + let (path, mut query) = split_path_and_inline_query(input.path)?; + query.extend(parse_key_values("query", input.query)?); + let url = self.api_url(&path)?; + let headers = parse_headers(input.header)?; + let body = request_body(input.body, input.body_file, input.field)?; + let operation_id = self.resolve_operation_id(config, input.method, &path).await; + let requires_auth = input.require_auth && !self.allow_unauthenticated; + self.ensure_request_auth(config, cli_args, input.require_auth) + .await?; + + let json_body = body + .as_deref() + .map(serde_json::from_str::) + .transpose()?; + let mut response = read_api_response( + self.send_api_request( + config, + PreparedApiRequest { + attach_auth: requires_auth, + method: input.method, + url: &url, + headers: &headers, + query: &query, + body: json_body.as_ref(), + }, + ) + .await?, + ) + .await?; + write_request_log( + request_log_path, + "raw", + input.method.as_str(), + &path, + response.status.as_u16(), + response.request_id.as_deref(), + operation_id.as_deref(), + ); + if response.status.as_u16() == 401 + && requires_auth + && self.try_refresh_after_401(config, cli_args).await? + { + response = read_api_response( + self.send_api_request( + config, + PreparedApiRequest { + attach_auth: requires_auth, + method: input.method, + url: &url, + headers: &headers, + query: &query, + body: json_body.as_ref(), + }, + ) + .await?, + ) + .await?; + write_request_log( + request_log_path, + "raw_retry_after_refresh", + input.method.as_str(), + &path, + response.status.as_u16(), + response.request_id.as_deref(), + operation_id.as_deref(), + ); + if !response.status.is_success() { + return Err(ApiCommandError::HttpStatus { + method: input.method.as_str(), + path, + status: response.status.as_u16(), + request_id: response.request_id, + body: Box::new(response.body), + }); + } + return Ok(json!({ + "request": { + "method": input.method.as_str(), + "path": path, + "operation_id": operation_id, + "query": query_pairs_value(&query), + "retried_after_refresh": true, + }, + "response": { + "status": response.status.as_u16(), + "success": response.status.is_success(), + "request_id": response.request_id, + "headers": response.headers, + "body": response.body, + } + })); + } + if !response.status.is_success() { + return Err(ApiCommandError::HttpStatus { + method: input.method.as_str(), + path, + status: response.status.as_u16(), + request_id: response.request_id, + body: Box::new(response.body), + }); + } + + Ok(json!({ + "request": { + "method": input.method.as_str(), + "path": path, + "operation_id": operation_id, + "query": query_pairs_value(&query), + }, + "response": { + "status": response.status.as_u16(), + "success": response.status.is_success(), + "request_id": response.request_id, + "headers": response.headers, + "body": response.body, + } + })) + } + + pub(in crate::api) async fn call_workflow_result( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + request: &WorkflowRequest, + request_log_path: &Path, + ) -> Result { + let requires_auth = request.require_auth && !self.allow_unauthenticated; + self.ensure_request_auth(config, cli_args, request.require_auth) + .await?; + let attach_auth = self.workflow_attach_auth(request, config); + let path = self + .normalize_project_path( + config, + &request.path, + attach_auth, + requires_auth, + request_log_path, + ) + .await?; + let url = self.api_url(&path)?; + let json_body = if let Some(body) = &request.body { + Some( + self.normalize_request_body( + config, + &path, + body, + attach_auth, + requires_auth, + request_log_path, + ) + .await?, + ) + } else { + None + }; + let mut response = read_api_response( + self.send_workflow_request(config, request, &url, json_body.as_ref()) + .await?, + ) + .await?; + write_request_log( + request_log_path, + "workflow", + request.method.as_str(), + &path, + response.status.as_u16(), + response.request_id.as_deref(), + request.operation_id, + ); + let mut retried_after_refresh = false; + if response.status.as_u16() == 401 + && requires_auth + && self.try_refresh_after_401(config, cli_args).await? + { + response = read_api_response( + self.send_workflow_request(config, request, &url, json_body.as_ref()) + .await?, + ) + .await?; + retried_after_refresh = true; + write_request_log( + request_log_path, + "workflow_retry_after_refresh", + request.method.as_str(), + &path, + response.status.as_u16(), + response.request_id.as_deref(), + request.operation_id, + ); + } + if !response.status.is_success() { + return Err(ApiCommandError::HttpStatus { + method: request.method.as_str(), + path, + status: response.status.as_u16(), + request_id: response.request_id, + body: Box::new(response.body), + }); + } + let status = response.status; + let request_id = response.request_id; + Ok(WorkflowCallResult { + body: response.body, + request: json!({ + "method": request.method.as_str(), + "operation_id": request.operation_id, + "path": path, + "query": query_pairs_value(&request.query), + "auth": self.auth_plan(request.require_auth, request.attach_auth, config), + "side_effecting": request.method != HttpMethod::Get, + "retried_after_refresh": retried_after_refresh, + }), + response: json!({ + "status": status.as_u16(), + "success": true, + "request_id": request_id, + "fetched_at": chrono::Utc::now().to_rfc3339(), + }), + }) + } + + pub(in crate::api) async fn call_workflow_paginated( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + request: WorkflowRequest, + pagination: WorkflowPaginationOptions<'_>, + request_log_path: &Path, + ) -> Result { + if request.method.openapi_key() != "get" { + return Err(ApiCommandError::InvalidWorkflow { + message: "--all is only supported for GET list workflows".to_string(), + }); + } + if pagination.limit == 0 { + return Err(ApiCommandError::InvalidWorkflow { + message: "--limit must be greater than zero".to_string(), + }); + } + if pagination.max_pages == 0 { + return Err(ApiCommandError::InvalidWorkflow { + message: "--max-pages must be greater than zero".to_string(), + }); + } + + let mut items = Vec::new(); + let mut pages_fetched = 0_u64; + let mut last_page_count = 0_usize; + + for offset in 0..pagination.max_pages { + let page = pagination.start_page + offset; + let mut page_request = request.clone(); + upsert_query(&mut page_request.query, "page", page.to_string()); + upsert_query( + &mut page_request.query, + "limit", + pagination.limit.to_string(), + ); + let data = self + .call_workflow_result(config, cli_args, &page_request, request_log_path) + .await? + .body; + let page_items = + extract_paginated_items(&data, pagination.item_field).ok_or_else(|| { + ApiCommandError::InvalidWorkflow { + message: format!( + "Could not find an array at `{}` or common pagination fields in response", + pagination.item_field + ), + } + })?; + last_page_count = page_items.len(); + pages_fetched += 1; + items.extend(page_items); + + if last_page_count < usize::try_from(pagination.limit).unwrap_or(usize::MAX) { + break; + } + } + + let count = items.len(); + Ok(json!({ + "items": items, + "count": count, + "pages_fetched": pages_fetched, + "start_page": pagination.start_page, + "limit": pagination.limit, + "max_pages": pagination.max_pages, + "last_page_count": last_page_count, + })) + } + + pub(in crate::api) async fn normalize_request_body( + &self, + config: &CliConfig, + path: &str, + body: &str, + attach_auth: bool, + require_auth: bool, + request_log_path: &Path, + ) -> Result { + let mut json_body: Value = serde_json::from_str(body)?; + if path == "/projects/saved" + && let Some(project_ref) = json_body.get("project_id").and_then(Value::as_str) + && project_ref.parse::().is_err() + { + let project_id = self + .resolve_project_id( + config, + project_ref, + attach_auth, + require_auth, + request_log_path, + ) + .await?; + if let Some(object) = json_body.as_object_mut() { + object.insert("project_id".to_string(), Value::String(project_id)); + } + } + Ok(json_body) + } + + pub(in crate::api) async fn normalize_project_path( + &self, + config: &CliConfig, + path: &str, + attach_auth: bool, + require_auth: bool, + request_log_path: &Path, + ) -> Result { + let Some((prefix, project_ref, suffix)) = project_segment(path) else { + return Ok(path.to_string()); + }; + if project_ref.parse::().is_ok() { + return Ok(path.to_string()); + } + let project_id = self + .resolve_project_id( + config, + project_ref, + attach_auth, + require_auth, + request_log_path, + ) + .await?; + Ok(format!("{prefix}{project_id}{suffix}")) + } + + pub(in crate::api) async fn resolve_project_id( + &self, + config: &CliConfig, + project_ref: &str, + attach_auth: bool, + require_auth: bool, + request_log_path: &Path, + ) -> Result { + let path = format!("/projects/resolve/{project_ref}"); + let url = self.api_url(&path)?; + let client = self.http_client(config, attach_auth, require_auth)?; + let response = read_api_response(client.get(url).send().await?).await?; + write_request_log( + request_log_path, + "workflow_project_resolution", + "GET", + &path, + response.status.as_u16(), + response.request_id.as_deref(), + Some("get_projects_resolve_project_ref"), + ); + if !response.status.is_success() { + return Err(ApiCommandError::HttpStatus { + method: "GET", + path, + status: response.status.as_u16(), + request_id: response.request_id, + body: Box::new(response.body), + }); + } + let body = response.body; + body.get("project_id") + .or_else(|| body.get("projectId")) + .or_else(|| body.get("id")) + .and_then(Value::as_str) + .map(ToString::to_string) + .ok_or_else(|| { + ApiCommandError::InvalidWorkflow { + message: format!("Could not resolve project reference `{project_ref}`"), + } + }) + } + + pub(in crate::api) async fn ensure_request_auth( + &self, + config: &mut CliConfig, + cli_args: &CliArgs, + require_auth: bool, + ) -> Result<(), ApiCommandError> { + if self.allow_unauthenticated || !require_auth { + return Ok(()); + } + let Some(auth) = &config.auth else { + return Err(ApiCommandError::NoAuthToken); + }; + let now = chrono::Utc::now(); + let seconds_remaining = (auth.expires_at - now).num_seconds(); + if auth.expires_at <= now || seconds_remaining <= crate::config::AUTH_EXPIRES_SOON_SECONDS { + refresh_stored_auth(config, &self.api_url, cli_args, false) + .await + .map_err(ApiCommandError::AuthRefresh)?; + } + Ok(()) + } + + pub(in crate::api) async fn send_api_request( + &self, + config: &CliConfig, + request: PreparedApiRequest<'_>, + ) -> Result { + let client = self.http_client(config, request.attach_auth, request.attach_auth)?; + let mut builder = client + .request(request.method.reqwest(), request.url.clone()) + .headers(request.headers.clone()); + if !request.query.is_empty() { + builder = builder.query(request.query); + } + if let Some(body) = request.body { + builder = builder.json(body); + } + Ok(builder.send().await?) + } + + pub(in crate::api) async fn send_workflow_request( + &self, + config: &CliConfig, + request: &WorkflowRequest, + url: &url::Url, + body: Option<&Value>, + ) -> Result { + let requires_auth = request.require_auth && !self.allow_unauthenticated; + let attach_auth = self.workflow_attach_auth(request, config); + let client = self.http_client(config, attach_auth, requires_auth)?; + let mut builder = client.request(request.method.reqwest(), url.clone()); + if !request.query.is_empty() { + builder = builder.query(&request.query); + } + if let Some(body) = body { + builder = builder.json(body); + } + Ok(builder.send().await?) + } + + pub(in crate::api) fn workflow_attach_auth( + &self, + request: &WorkflowRequest, + config: &CliConfig, + ) -> bool { + if self.allow_unauthenticated { + return false; + } + if request.require_auth { + return true; + } + request.attach_auth + && config.auth.as_ref().is_some_and(|auth| { + !auth.access_token.trim().is_empty() && auth.expires_at > chrono::Utc::now() + }) + } + + pub(in crate::api) fn http_client( + &self, + config: &CliConfig, + attach_auth: bool, + require_auth: bool, + ) -> Result { + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("api-version"), + HeaderValue::from_static("1"), + ); + + if attach_auth && let Some(auth) = &config.auth { + if auth.expires_at <= chrono::Utc::now() { + return Err(ApiCommandError::ExpiredAuthToken(auth.expires_at)); + } + + let value = format!("Bearer {}", auth.access_token); + let value = HeaderValue::from_str(&value).map_err(|source| { + ApiCommandError::InvalidHeaderValue { + name: "authorization".to_string(), + source, + } + })?; + headers.insert(reqwest::header::AUTHORIZATION, value); + } else if require_auth { + return Err(ApiCommandError::NoAuthToken); + } + + reqwest::Client::builder() + .default_headers(headers) + .build() + .map_err(ApiCommandError::Request) + } + + pub(in crate::api) async fn resolve_operation_id( + &self, + config: &CliConfig, + method: HttpMethod, + path: &str, + ) -> Option { + let spec = self.fetch_openapi(config).await.ok()?; + let operations = list_operations(&spec, None, Some(method)).ok()?; + operations + .into_iter() + .find(|operation| openapi_path_matches(&operation.path, path)) + .map(|operation| operation.operation_id) + } + + pub(in crate::api) fn api_url(&self, path: &str) -> Result { + if !path.starts_with('/') { + return Err(ApiCommandError::InvalidPath(path.to_string())); + } + + let mut url = self.api_url.clone(); + url.set_path(&format!("/api/v1{path}")); + Ok(url) + } +} diff --git a/crates/pcl/core/src/api/runtime_types.rs b/crates/pcl/core/src/api/runtime_types.rs new file mode 100644 index 0000000..885597d --- /dev/null +++ b/crates/pcl/core/src/api/runtime_types.rs @@ -0,0 +1,117 @@ +use super::{ + ApiCommandError, + HttpMethod, + WorkflowOperation, +}; +use reqwest::header::HeaderMap; +use serde_json::Value; +use std::path::PathBuf; + +pub(in crate::api) struct ApiRequestInput<'a> { + pub(in crate::api) method: HttpMethod, + pub(in crate::api) path: &'a str, + pub(in crate::api) query: &'a [String], + pub(in crate::api) header: &'a [String], + pub(in crate::api) body: Option<&'a str>, + pub(in crate::api) body_file: Option<&'a PathBuf>, + pub(in crate::api) field: &'a [String], + pub(in crate::api) require_auth: bool, +} + +pub(in crate::api) struct PreparedApiRequest<'a> { + pub(in crate::api) attach_auth: bool, + pub(in crate::api) method: HttpMethod, + pub(in crate::api) url: &'a url::Url, + pub(in crate::api) headers: &'a HeaderMap, + pub(in crate::api) query: &'a [(String, String)], + pub(in crate::api) body: Option<&'a Value>, +} + +#[derive(Clone, Copy)] +pub(in crate::api) struct RawPaginationOptions<'a> { + pub(in crate::api) item_field: &'a str, + pub(in crate::api) start_page: u64, + pub(in crate::api) limit: u64, + pub(in crate::api) page_param: &'a str, + pub(in crate::api) limit_param: &'a str, + pub(in crate::api) max_pages: u64, +} + +#[derive(Clone, Copy)] +pub(in crate::api) struct WorkflowPaginationOptions<'a> { + pub(in crate::api) item_field: &'a str, + pub(in crate::api) start_page: u64, + pub(in crate::api) limit: u64, + pub(in crate::api) max_pages: u64, +} + +#[derive(Debug)] +pub(in crate::api) struct WorkflowCallResult { + pub(in crate::api) body: Value, + pub(in crate::api) request: Value, + pub(in crate::api) response: Value, +} + +#[derive(Clone, Debug)] +pub(in crate::api) struct WorkflowRequest { + pub(in crate::api) method: HttpMethod, + pub(in crate::api) operation_id: Option<&'static str>, + pub(in crate::api) path: String, + pub(in crate::api) query: Vec<(String, String)>, + pub(in crate::api) body: Option, + pub(in crate::api) require_auth: bool, + pub(in crate::api) attach_auth: bool, + pub(in crate::api) next_actions: Vec, +} + +impl WorkflowRequest { + pub(in crate::api) fn get( + path: impl Into, + require_auth: bool, + next_actions: impl IntoIterator>, + ) -> Self { + Self::get_with_query(path, Vec::new(), require_auth, next_actions) + } + + pub(in crate::api) fn get_with_query( + path: impl Into, + query: Vec<(String, String)>, + require_auth: bool, + next_actions: impl IntoIterator>, + ) -> Self { + Self { + method: HttpMethod::Get, + operation_id: None, + path: path.into(), + query, + body: None, + require_auth, + attach_auth: require_auth, + next_actions: next_actions.into_iter().map(Into::into).collect(), + } + } + + pub(in crate::api) fn with_optional_auth(mut self) -> Self { + self.attach_auth = true; + self + } + + pub(in crate::api) fn from_operation( + operation: WorkflowOperation, + query: Vec<(String, String)>, + body: Option, + require_auth: bool, + next_actions: impl IntoIterator>, + ) -> Result { + Ok(Self { + method: operation.method, + operation_id: Some(operation.operation_id), + path: operation.path()?, + query, + body, + require_auth, + attach_auth: require_auth, + next_actions: next_actions.into_iter().map(Into::into).collect(), + }) + } +} diff --git a/crates/pcl/core/src/api/tests.rs b/crates/pcl/core/src/api/tests.rs index 6641cd0..41b12f3 100644 --- a/crates/pcl/core/src/api/tests.rs +++ b/crates/pcl/core/src/api/tests.rs @@ -1,18 +1,86 @@ use super::*; -use crate::config::UserAuth; +use crate::config::{ + CliConfig, + UserAuth, +}; use chrono::{ TimeZone, Utc, }; use clap::Parser; use mockito::Matcher; -use pcl_common::args::CliArgs; -use std::path::Path; +use pcl_common::args::{ + CliArgs, + OutputMode, +}; +use serde_json::{ + Value, + json, +}; +use std::{ + cell::Cell, + fs, + path::Path, +}; fn test_request_log_path() -> &'static Path { Path::new("/tmp/pcl-test-requests.jsonl") } +fn test_api(api_url: impl AsRef, allow_unauthenticated: bool) -> ApiArgs { + ApiArgs { + command: ApiCommand::Manifest, + api_url: api_url.as_ref().parse().unwrap(), + allow_unauthenticated, + refresh_after_401: Cell::new(true), + } +} + +fn valid_auth_config(access_token: &str, refresh_token: &str) -> CliConfig { + auth_config(access_token, refresh_token, 2030, Some("agent@example.com")) +} + +fn expired_auth_config(access_token: &str, refresh_token: &str) -> CliConfig { + auth_config(access_token, refresh_token, 2020, Some("agent@example.com")) +} + +fn auth_config( + access_token: &str, + refresh_token: &str, + expires_year: i32, + email: Option<&str>, +) -> CliConfig { + CliConfig { + auth: Some(UserAuth { + access_token: access_token.to_string(), + refresh_token: refresh_token.to_string(), + expires_at: Utc.with_ymd_and_hms(expires_year, 1, 1, 0, 0, 0).unwrap(), + refresh_expires_at: None, + user_id: None, + wallet_address: None, + email: email.map(ToString::to_string), + }), + } +} + +fn assert_output_contains(output: &str, expected: &[&str]) { + for &needle in expected { + assert!( + output.contains(needle), + "expected output to contain {needle:?}:\n{output}" + ); + } +} + +fn assert_output_omits(output: &str, forbidden: &[&str]) { + for &needle in forbidden { + assert!( + !output.contains(needle), + "expected output to omit {needle:?}:\n{output}" + ); + } +} + fn assertions_args(project_id: Option<&str>) -> AssertionsArgs { AssertionsArgs { project_id: project_id.map(ToString::to_string), @@ -755,6 +823,33 @@ fn builds_incident_trace_retry_request() { assert!(request.require_auth); } +#[test] +fn incident_detail_next_action_uses_invalidating_transaction_id() { + let next_actions = incidents_next_actions( + &json!({ + "data": { + "invalidating_transactions": [{ + "id": "invalidating-tx-1", + "transaction_hash": "0xde68add41baa3f8541de6acd572ad61b1dae78c4916412d8f273cc6f57af540b" + }] + } + }), + &IncidentsArgs { + incident_id: Some("incident-1".to_string()), + ..incidents_args() + }, + vec!["fallback".to_string()], + ); + + assert_eq!( + next_actions, + vec![ + "pcl incidents --incident-id incident-1 --tx-id invalidating-tx-1".to_string(), + "pcl incidents --limit 5".to_string(), + ] + ); +} + #[tokio::test] async fn paginates_incident_list_workflows() { let mut server = mockito::Server::new_async().await; @@ -780,13 +875,7 @@ async fn paginates_incident_list_workflows() { .with_body(r#"{"incidents":[{"id":"i3"}]}"#) .create_async() .await; - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: server.url().parse().unwrap(), - allow_unauthenticated: true, - dry_run: false, - refresh_after_401: Cell::new(true), - }; + let api = test_api(server.url(), true); let request = WorkflowRequest::get("/views/public/incidents", false, Vec::::new()); let mut config = CliConfig::default(); let cli_args = CliArgs::default(); @@ -816,13 +905,7 @@ async fn paginates_incident_list_workflows() { #[tokio::test] async fn incident_workflow_pagination_rejects_zero_limit() { - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: "https://app.phylax.systems".parse().unwrap(), - allow_unauthenticated: true, - dry_run: false, - refresh_after_401: Cell::new(true), - }; + let api = test_api("https://app.phylax.systems", true); let request = WorkflowRequest::get("/views/public/incidents", false, Vec::::new()); let mut config = CliConfig::default(); let cli_args = CliArgs::default(); @@ -874,24 +957,8 @@ async fn authenticated_project_slug_resolution_attaches_auth() { .expect(1) .create_async() .await; - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: server.url().parse().unwrap(), - allow_unauthenticated: false, - dry_run: false, - refresh_after_401: Cell::new(true), - }; - let mut config = CliConfig { - auth: Some(UserAuth { - access_token: "access-token".to_string(), - refresh_token: "refresh-token".to_string(), - expires_at: Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(), - refresh_expires_at: None, - user_id: None, - wallet_address: None, - email: Some("agent@example.com".to_string()), - }), - }; + let api = test_api(server.url(), false); + let mut config = valid_auth_config("access-token", "refresh-token"); let request = WorkflowRequest::get("/projects/private-slug", true, Vec::::new()); let result = api @@ -923,24 +990,8 @@ async fn project_slug_resolution_errors_preserve_http_metadata() { .expect(1) .create_async() .await; - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: server.url().parse().unwrap(), - allow_unauthenticated: false, - dry_run: false, - refresh_after_401: Cell::new(true), - }; - let mut config = CliConfig { - auth: Some(UserAuth { - access_token: "access-token".to_string(), - refresh_token: "refresh-token".to_string(), - expires_at: Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(), - refresh_expires_at: None, - user_id: None, - wallet_address: None, - email: Some("agent@example.com".to_string()), - }), - }; + let api = test_api(server.url(), false); + let mut config = valid_auth_config("access-token", "refresh-token"); let request = WorkflowRequest::get("/projects/missing-slug", true, Vec::::new()); let error = api @@ -988,13 +1039,7 @@ async fn openapi_discovery_errors_preserve_http_metadata() { .expect(1) .create_async() .await; - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: server.url().parse().unwrap(), - allow_unauthenticated: true, - dry_run: false, - refresh_after_401: Cell::new(true), - }; + let api = test_api(server.url(), true); let error = api.fetch_openapi(&CliConfig::default()).await.unwrap_err(); @@ -1031,24 +1076,8 @@ async fn public_workflows_do_not_attach_expired_stored_tokens() { .with_body(r#"{"healthy":true}"#) .create_async() .await; - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: server.url().parse().unwrap(), - allow_unauthenticated: false, - dry_run: false, - refresh_after_401: Cell::new(true), - }; - let mut config = CliConfig { - auth: Some(UserAuth { - access_token: "expired-token".to_string(), - refresh_token: "refresh-token".to_string(), - expires_at: Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(), - refresh_expires_at: None, - user_id: None, - wallet_address: None, - email: Some("agent@example.com".to_string()), - }), - }; + let api = test_api(server.url(), false); + let mut config = expired_auth_config("expired-token", "refresh-token"); let output = api .run_workflow( @@ -1077,24 +1106,8 @@ async fn public_raw_calls_do_not_attach_expired_stored_tokens() { .with_body(r#"{"incidents":[]}"#) .create_async() .await; - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: server.url().parse().unwrap(), - allow_unauthenticated: false, - dry_run: false, - refresh_after_401: Cell::new(true), - }; - let mut config = CliConfig { - auth: Some(UserAuth { - access_token: "expired-token".to_string(), - refresh_token: "refresh-token".to_string(), - expires_at: Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(), - refresh_expires_at: None, - user_id: None, - wallet_address: None, - email: Some("agent@example.com".to_string()), - }), - }; + let api = test_api(server.url(), false); + let mut config = expired_auth_config("expired-token", "refresh-token"); let input = ApiRequestInput { method: HttpMethod::Get, @@ -1155,29 +1168,13 @@ async fn authenticated_workflow_retries_once_after_refresh_on_401() { .expect(1) .create_async() .await; - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: server.url().parse().unwrap(), - allow_unauthenticated: false, - dry_run: false, - refresh_after_401: Cell::new(true), - }; + let api = test_api(server.url(), false); let temp_dir = tempfile::tempdir().unwrap(); let cli_args = CliArgs { config_dir: Some(temp_dir.path().to_path_buf()), ..Default::default() }; - let mut config = CliConfig { - auth: Some(UserAuth { - access_token: "old_access".to_string(), - refresh_token: "old_refresh".to_string(), - expires_at: Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(), - refresh_expires_at: None, - user_id: None, - wallet_address: None, - email: Some("agent@example.com".to_string()), - }), - }; + let mut config = valid_auth_config("old_access", "old_refresh"); config.write_to_file(&cli_args).unwrap(); let request = WorkflowRequest::get("/web/auth/me", true, Vec::::new()); @@ -1220,29 +1217,13 @@ async fn raw_401_preserves_original_error_when_refresh_endpoint_is_missing() { .expect(1) .create_async() .await; - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: server.url().parse().unwrap(), - allow_unauthenticated: false, - dry_run: false, - refresh_after_401: Cell::new(true), - }; + let api = test_api(server.url(), false); let temp_dir = tempfile::tempdir().unwrap(); let cli_args = CliArgs { config_dir: Some(temp_dir.path().to_path_buf()), ..Default::default() }; - let mut config = CliConfig { - auth: Some(UserAuth { - access_token: "old_access".to_string(), - refresh_token: "old_refresh".to_string(), - expires_at: Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(), - refresh_expires_at: None, - user_id: None, - wallet_address: None, - email: Some("agent@example.com".to_string()), - }), - }; + let mut config = valid_auth_config("old_access", "old_refresh"); config.write_to_file(&cli_args).unwrap(); let input = ApiRequestInput { method: HttpMethod::Get, @@ -1304,29 +1285,13 @@ async fn incident_stats_401_propagates_original_http_error() { .expect(1) .create_async() .await; - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: server.url().parse().unwrap(), - allow_unauthenticated: false, - dry_run: false, - refresh_after_401: Cell::new(true), - }; + let api = test_api(server.url(), false); let temp_dir = tempfile::tempdir().unwrap(); let cli_args = CliArgs { config_dir: Some(temp_dir.path().to_path_buf()), ..Default::default() }; - let mut config = CliConfig { - auth: Some(UserAuth { - access_token: "old_access".to_string(), - refresh_token: "old_refresh".to_string(), - expires_at: Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(), - refresh_expires_at: None, - user_id: None, - wallet_address: None, - email: Some("agent@example.com".to_string()), - }), - }; + let mut config = valid_auth_config("old_access", "old_refresh"); config.write_to_file(&cli_args).unwrap(); let args = IncidentsArgs { project_id: Some(project_id.to_string()), @@ -1354,69 +1319,6 @@ async fn incident_stats_401_propagates_original_http_error() { original.assert_async().await; refresh.assert_async().await; } - -#[tokio::test] -async fn dry_run_projects_and_assertions_do_not_execute_requests() { - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: "https://app.phylax.systems".parse().unwrap(), - allow_unauthenticated: false, - dry_run: true, - refresh_after_401: Cell::new(true), - }; - let mut config = CliConfig::default(); - let cli_args = CliArgs::default(); - - let project_output = api - .run_projects( - &mut config, - &cli_args, - &ProjectsArgs { - create: true, - project_name: Some("Demo".to_string()), - chain_id: Some(1), - ..projects_args() - }, - test_request_log_path(), - ) - .await - .unwrap(); - assert_eq!(project_output["status"], "ok"); - assert_eq!(project_output["data"]["dry_run"], true); - assert_eq!(project_output["data"]["request"]["method"], "POST"); - assert_eq!(project_output["data"]["request"]["path"], "/projects"); - assert_eq!( - project_output["data"]["request"]["auth"]["stored_token_present"], - false - ); - assert_eq!( - project_output["data"]["request"]["auth"]["will_attach_stored_token"], - false - ); - assert_eq!(project_output["next_actions"][0], "pcl auth ensure --toon"); - - let assertion_output = api - .run_assertions( - &mut config, - &cli_args, - &AssertionsArgs { - project_id: Some("project-1".to_string()), - registered: true, - ..assertions_args(None) - }, - test_request_log_path(), - ) - .await - .unwrap(); - assert_eq!(assertion_output["status"], "ok"); - assert_eq!(assertion_output["data"]["dry_run"], true); - assert_eq!(assertion_output["data"]["request"]["method"], "GET"); - assert_eq!( - assertion_output["data"]["request"]["path"], - "/projects/project-1/registered-assertions" - ); -} - #[test] fn builds_project_create_body_from_typed_flags() { let request = projects_request(&ProjectsArgs { @@ -1986,13 +1888,7 @@ async fn workflow_http_errors_include_response_body() { .with_body(r#"{"message":"address is required","field":"address"}"#) .create_async() .await; - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: server.url().parse().unwrap(), - allow_unauthenticated: true, - dry_run: false, - refresh_after_401: Cell::new(true), - }; + let api = test_api(server.url(), true); let mut config = CliConfig::default(); let request = WorkflowRequest::get("/health", false, Vec::::new()); @@ -2040,13 +1936,7 @@ async fn workflow_success_envelopes_include_request_provenance() { .with_body(r#"{"ok":true}"#) .create_async() .await; - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: server.url().parse().unwrap(), - allow_unauthenticated: true, - dry_run: false, - refresh_after_401: Cell::new(true), - }; + let api = test_api(server.url(), true); let request = WorkflowRequest::get("/health", false, vec!["next".to_string()]); let mut config = CliConfig::default(); @@ -2087,13 +1977,7 @@ async fn raw_api_call_accepts_inline_query_strings() { .with_body(r#"{"ok":true}"#) .create_async() .await; - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: server.url().parse().unwrap(), - allow_unauthenticated: true, - dry_run: false, - refresh_after_401: Cell::new(true), - }; + let api = test_api(server.url(), true); let mut config = CliConfig::default(); let response = api @@ -2126,126 +2010,6 @@ async fn raw_api_call_accepts_inline_query_strings() { assert_eq!(response["response"]["body"]["ok"], true); mock.assert_async().await; } - -#[test] -fn parser_accepts_global_dry_run() { - let args = ApiArgs::try_parse_from([ - "api", - "--dry-run", - "call", - "post", - "/web/auth/logout", - "--body", - "{}", - "--field", - "reason=manual", - ]) - .unwrap(); - - assert!(args.dry_run); - let ApiCommand::Call { field, .. } = args.command else { - panic!("expected raw api call"); - }; - assert_eq!(field, vec!["reason=manual".to_string()]); -} - -#[test] -fn raw_api_dry_run_plans_request_without_auth_or_network() { - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: "https://api.example.com".parse().unwrap(), - allow_unauthenticated: false, - dry_run: true, - refresh_after_401: Cell::new(true), - }; - let query = vec!["force=true".to_string()]; - let header = vec!["x-test=yes".to_string()]; - let field = vec!["reason=manual".to_string(), "notify=false".to_string()]; - let config = CliConfig::default(); - - let plan = api - .raw_call_plan( - ApiRequestInput { - method: HttpMethod::Post, - path: "/web/auth/logout?reason=test", - query: &query, - header: &header, - body: Some("{}"), - body_file: None, - field: &field, - require_auth: true, - }, - None, - &config, - ) - .unwrap(); - - assert_eq!(plan["dry_run"], true); - assert_eq!(plan["valid"], true); - assert_eq!(plan["request"]["method"], "POST"); - assert_eq!(plan["request"]["path"], "/web/auth/logout"); - assert_eq!( - plan["request"]["query"], - json!([ - {"name": "reason", "value": "test"}, - {"name": "force", "value": "true"} - ]) - ); - assert_eq!( - plan["request"]["body"], - json!({"reason": "manual", "notify": false}) - ); - assert_eq!(plan["request"]["auth"]["required"], true); - assert_eq!(plan["request"]["auth"]["stored_token_present"], false); - assert_eq!(plan["request"]["auth"]["will_attach_stored_token"], false); - assert_eq!(plan["request"]["side_effecting"], true); - assert_eq!(plan["request"]["destructive"], true); -} - -#[test] -fn workflow_dry_run_plans_destructive_requests() { - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: "https://api.example.com".parse().unwrap(), - allow_unauthenticated: false, - dry_run: true, - refresh_after_401: Cell::new(true), - }; - let request = WorkflowRequest { - method: HttpMethod::Delete, - path: "/projects/project-1".to_string(), - query: vec![("environment".to_string(), "production".to_string())], - body: None, - require_auth: true, - attach_auth: true, - next_actions: Vec::new(), - }; - let config = CliConfig { - auth: Some(UserAuth { - access_token: "access-token".to_string(), - refresh_token: "refresh-token".to_string(), - expires_at: Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(), - refresh_expires_at: None, - user_id: None, - wallet_address: None, - email: None, - }), - }; - - let plan = api.workflow_request_plan(&request, None, &config); - - assert_eq!(plan["dry_run"], true); - assert_eq!(plan["valid"], true); - assert_eq!(plan["request"]["method"], "DELETE"); - assert_eq!(plan["request"]["path"], "/projects/project-1"); - assert_eq!(plan["request"]["auth"]["stored_token_present"], true); - assert_eq!(plan["request"]["auth"]["stored_token_valid"], true); - assert_eq!(plan["request"]["auth"]["will_attach_stored_token"], true); - assert_eq!(plan["request"]["side_effecting"], true); - assert_eq!(plan["request"]["destructive"], true); - assert_eq!(plan["request"]["project_resolution"], "not_performed"); -} - #[tokio::test] async fn raw_api_call_paginates_any_array_response() { let mut server = mockito::Server::new_async().await; @@ -2273,13 +2037,7 @@ async fn raw_api_call_paginates_any_array_response() { .with_body(r#"{"incidents":[{"id":"i3"}]}"#) .create_async() .await; - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: server.url().parse().unwrap(), - allow_unauthenticated: true, - dry_run: false, - refresh_after_401: Cell::new(true), - }; + let api = test_api(server.url(), true); let mut config = CliConfig::default(); let response = api @@ -2335,13 +2093,7 @@ async fn raw_api_call_pagination_supports_custom_param_names() { .with_body(r#"{"items":[{"id":"i1"}]}"#) .create_async() .await; - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: server.url().parse().unwrap(), - allow_unauthenticated: true, - dry_run: false, - refresh_after_401: Cell::new(true), - }; + let api = test_api(server.url(), true); let mut config = CliConfig::default(); let response = api @@ -2379,13 +2131,7 @@ async fn raw_api_call_pagination_supports_custom_param_names() { #[tokio::test] async fn raw_api_call_pagination_rejects_non_get_requests() { - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: "https://api.example.com".parse().unwrap(), - allow_unauthenticated: true, - dry_run: false, - refresh_after_401: Cell::new(true), - }; + let api = test_api("https://api.example.com", true); let mut config = CliConfig::default(); let error = api @@ -2451,13 +2197,7 @@ async fn raw_api_call_errors_include_request_id() { .with_body(r#"{"message":"server failed"}"#) .create_async() .await; - let api = ApiArgs { - command: ApiCommand::Manifest, - api_url: server.url().parse().unwrap(), - allow_unauthenticated: true, - dry_run: false, - refresh_after_401: Cell::new(true), - }; + let api = test_api(server.url(), true); let mut config = CliConfig::default(); let error = api @@ -2678,10 +2418,8 @@ fn default_api_output_is_human_readable() { .unwrap(); assert!(output.starts_with("OK\n")); - assert!(output.contains("Healthy: yes")); - assert!(output.contains("Next:")); - assert!(!output.contains("Schema: pcl.envelope.v1")); - assert!(!output.contains("Details:")); + assert_output_contains(&output, &["Healthy: yes", "Next:"]); + assert_output_omits(&output, &["Schema: pcl.envelope.v1", "Details:"]); } #[test] @@ -2720,18 +2458,21 @@ fn human_api_output_formats_incident_lists_for_people() { ) .unwrap(); - assert!(output.contains("Incidents\n")); - assert!(output.contains("Showing 1 of 332 incidents on page 1 (limit 20)")); - assert!(output.contains("Updated: 2026-05-09 23:30")); - assert!(output.contains("Source: Phylax platform index")); - 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:")); + assert_output_contains( + &output, + &[ + "Incidents\n", + "Showing 1 of 332 incidents on page 1 (limit 20)", + "Updated: 2026-05-09 23:30", + "Source: Phylax platform index", + "Linea Mainnet (59144)", + "Removed invalid transaction", + "7dfe71ee-9d69-41bb-b33c-992c0fbd684f", + "More results available. Try --page 2 --limit 20.", + "Request ID: req_123 (HTTP 200)", + ], + ); + assert_output_omits(&output, &["Details:", "Request:\n", "Schema:"]); } #[test] @@ -2748,10 +2489,11 @@ fn human_output_formats_empty_workflow_arrays_for_people() { ) .unwrap(); - assert!(output.contains("Releases\n")); - assert!(output.contains("Showing 0 releases")); - assert!(output.contains("No releases found.")); - assert!(!output.contains("")); + assert_output_contains( + &output, + &["Releases\n", "Showing 0 releases", "No releases found."], + ); + assert_output_omits(&output, &[""]); } #[test] @@ -2773,7 +2515,7 @@ fn human_output_keeps_placeholder_actions_when_other_collections_are_non_empty() ) .unwrap(); - assert!(output.contains("pcl contracts --project ")); + assert_output_contains(&output, &["pcl contracts --project "]); } #[test] @@ -2794,7 +2536,10 @@ fn human_output_keeps_safe_remove_calldata_actions() { ) .unwrap(); - assert!(output.contains("pcl assertions --project-id project-1 --remove-calldata")); + assert_output_contains( + &output, + &["pcl assertions --project-id project-1 --remove-calldata"], + ); } #[test] @@ -2983,13 +2728,18 @@ fn human_output_formats_non_empty_releases_for_people() { ) .unwrap(); - assert!(output.contains("Releases\n")); - assert!(output.contains("Release")); - assert!(output.contains("Environment")); - assert!(output.contains("Status")); - assert!(output.contains("release-1")); - assert!(output.contains("production")); - assert!(!output.contains("Visibility")); + assert_output_contains( + &output, + &[ + "Releases\n", + "Release", + "Environment", + "Status", + "release-1", + "production", + ], + ); + assert_output_omits(&output, &["Visibility"]); } #[test] @@ -3019,13 +2769,18 @@ fn human_output_formats_contract_lists_for_people() { ) .unwrap(); - assert!(output.contains("Contracts\n")); - assert!(output.contains("Contract")); - assert!(output.contains("Chain")); - assert!(output.contains("Address")); - assert!(output.contains("Manager")); - assert!(output.contains("LineaSettler")); - assert!(output.contains("0xabc")); + assert_output_contains( + &output, + &[ + "Contracts\n", + "Contract", + "Chain", + "Address", + "Manager", + "LineaSettler", + "0xabc", + ], + ); } #[test] @@ -3055,13 +2810,18 @@ fn human_output_formats_assertion_lists_for_people() { ) .unwrap(); - assert!(output.contains("Assertions\n")); - assert!(output.contains("Contract")); - assert!(output.contains("Lifecycle")); - assert!(output.contains("Instances")); - assert!(output.contains("AllowanceAssertion")); - assert!(output.contains("enforced")); - assert!(output.contains("0xassertion")); + assert_output_contains( + &output, + &[ + "Assertions\n", + "Contract", + "Lifecycle", + "Instances", + "AllowanceAssertion", + "enforced", + "0xassertion", + ], + ); } #[test] @@ -3093,13 +2853,17 @@ fn human_output_formats_project_details_for_people() { ) .unwrap(); - assert!(output.contains("Project\n")); - assert!(output.contains("ID: project-1")); - assert!(output.contains("Visibility: private")); - assert!(output.contains("Networks: Linea Mainnet")); - assert!(output.contains("Submitted assertions: 0 assertions")); - assert!(!output.contains("Project Id:")); - assert!(!output.contains("item(s)")); + assert_output_contains( + &output, + &[ + "Project\n", + "ID: project-1", + "Visibility: private", + "Networks: Linea Mainnet", + "Submitted assertions: 0 assertions", + ], + ); + assert_output_omits(&output, &["Project Id:", "item(s)"]); } #[test] @@ -3116,8 +2880,8 @@ fn human_output_names_success_only_mutations() { ) .unwrap(); - assert!(output.contains("Project deleted")); - assert!(!output.contains("Success: yes")); + assert_output_contains(&output, &["Project deleted"]); + assert_output_omits(&output, &["Success: yes"]); let output = envelope_output_string( &json!({ @@ -3131,8 +2895,8 @@ fn human_output_names_success_only_mutations() { ) .unwrap(); - assert!(output.contains("Invitation revoked")); - assert!(!output.contains("Success: yes")); + assert_output_contains(&output, &["Invitation revoked"]); + assert_output_omits(&output, &["Success: yes"]); } #[test] @@ -3167,23 +2931,27 @@ fn human_output_formats_project_home_for_people() { ) .unwrap(); - assert!(output.contains("Your projects\n")); - assert!(output.contains("Showing 1 project you belong to")); - assert!(output.contains("Updated: 2026-05-10 04:16")); - assert!(output.contains("Source: Phylax platform index")); - assert!(output.contains("Project")); - assert!(output.contains("Slug")); - assert!(output.contains("Network")); - assert!(output.contains("Visibility")); - assert!(output.contains("Private Test")); - assert!(output.contains("private-test")); - assert!(output.contains("Linea Mainnet")); - assert!(output.contains("private")); - assert!(output.contains("project-1")); - assert!(output.contains("Saved projects: 0 projects")); - assert!(output.contains("Contracts without a project: 0 contracts")); - assert!(!output.contains("Member projects:")); - assert!(!output.contains("Details:")); + assert_output_contains( + &output, + &[ + "Your projects\n", + "Showing 1 project you belong to", + "Updated: 2026-05-10 04:16", + "Source: Phylax platform index", + "Project", + "Slug", + "Network", + "Visibility", + "Private Test", + "private-test", + "Linea Mainnet", + "private", + "project-1", + "Saved projects: 0 projects", + "Contracts without a project: 0 contracts", + ], + ); + assert_output_omits(&output, &["Member projects:", "Details:"]); } #[test] @@ -3208,13 +2976,18 @@ fn human_output_formats_invitations_for_people() { ) .unwrap(); - assert!(output.contains("Invitations\n")); - assert!(output.contains("Showing 1 invitation")); - assert!(output.contains("cli-ux-test@example.invalid")); - assert!(output.contains("viewer")); - assert!(output.contains("pending")); - assert!(output.contains("pcl access invite project-1 --body-template")); - assert!(!output.contains("--body '{...}'")); + assert_output_contains( + &output, + &[ + "Invitations\n", + "Showing 1 invitation", + "cli-ux-test@example.invalid", + "viewer", + "pending", + "pcl access invite project-1 --body-template", + ], + ); + assert_output_omits(&output, &["--body '{...}'"]); } #[test] @@ -3246,11 +3019,16 @@ fn human_output_formats_mixed_search_results_for_people() { ) .unwrap(); - assert!(output.contains("Search results")); - assert!(output.contains("Projects: 0")); - assert!(output.contains("Contracts: 1")); - assert!(output.contains("LineaSettler")); - assert!(!output.contains("Assertions\nShowing 0")); + assert_output_contains( + &output, + &[ + "Search results", + "Projects: 0", + "Contracts: 1", + "LineaSettler", + ], + ); + assert_output_omits(&output, &["Assertions\nShowing 0"]); } #[test] @@ -3278,10 +3056,14 @@ fn human_errors_include_api_reason_and_hide_internal_actions() { ) .unwrap(); - assert!(output.contains("API reason: System status checks are temporarily disabled")); - assert!(output.contains("Request ID: req_forbidden")); - assert!(!output.contains("Code: auth.forbidden")); - assert!(!output.contains("Read error.http.body")); + assert_output_contains( + &output, + &[ + "API reason: System status checks are temporarily disabled", + "Request ID: req_forbidden", + ], + ); + assert_output_omits(&output, &["Code: auth.forbidden", "Read error.http.body"]); } #[test] @@ -3300,10 +3082,8 @@ fn human_cli_errors_strip_raw_usage_dump() { ) .unwrap(); - assert!(output.contains("unexpected argument '--limit' found")); - assert!(!output.contains("error:")); - assert!(!output.contains("Usage:")); - assert!(!output.contains("--toon")); + assert_output_contains(&output, &["unexpected argument '--limit' found"]); + assert_output_omits(&output, &["error:", "Usage:", "--toon"]); } #[test] @@ -3325,8 +3105,7 @@ fn human_llms_guide_keeps_toon_commands() { ) .unwrap(); - assert!(output.contains("pcl doctor --toon")); - assert!(output.contains("pcl api manifest --toon")); + assert_output_contains(&output, &["pcl doctor --toon", "pcl api manifest --toon"]); } #[test] @@ -3371,11 +3150,16 @@ fn human_incident_detail_uses_readable_sections() { ) .unwrap(); - assert!(output.contains("Incident\nID: incident-1")); - assert!(output.contains("Assertion\nTitle: AllowanceAssertion")); - assert!(output.contains("Assertion adopter\nName: LineaSettler")); - assert!(output.contains("Invalidating transactions (first 1 of 1)")); - assert!(!output.contains("Assertion: Assertion ID=")); + assert_output_contains( + &output, + &[ + "Incident\nID: incident-1", + "Assertion\nTitle: AllowanceAssertion", + "Assertion adopter\nName: LineaSettler", + "Invalidating transactions (first 1 of 1)", + ], + ); + assert_output_omits(&output, &["Assertion: Assertion ID="]); } #[test] @@ -3400,12 +3184,16 @@ fn human_output_formats_surface_lists_for_people() { ) .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:")); + assert_output_contains( + &output, + &[ + "Workflows\n", + "incident-investigation", + "Export incidents and inspect traces.", + "pcl schema list", + ], + ); + assert_output_omits(&output, &["--toon", "Details:"]); } #[test] @@ -3430,11 +3218,8 @@ fn human_output_honors_display_metadata_before_shape_detection() { "next_actions": [] }))); - assert!(output.contains("Projects\n"), "{output}"); - assert!(output.contains("Name"), "{output}"); - assert!(output.contains("Demo"), "{output}"); - assert!(output.contains("project-1"), "{output}"); - assert!(!output.contains("ignored"), "{output}"); + assert_output_contains(&output, &["Projects\n", "Name", "Demo", "project-1"]); + assert_output_omits(&output, &["ignored"]); } #[test] @@ -3459,122 +3244,17 @@ fn human_output_formats_schema_action_for_people() { ) .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 --json")); - assert!(!output.contains("Use --body-template when constructing mutation bodies")); - assert!(!output.contains("Details:")); -} - -#[test] -fn human_output_formats_mutation_dry_run_for_people() { - let output = envelope_output_string( - &dry_run_envelope(json!({ - "dry_run": true, - "valid": true, - "request": { - "method": "POST", - "path": "/projects/project-1/invitations", - "auth": { - "required": true, - "will_attach_stored_token": true, - "stored_token_valid": true, - }, - "body": { - "identifier": "user@example.com", - "role": "viewer" - } - }, - "pagination": null, - })), - false, - ) - .unwrap(); - - assert!(output.contains("Dry run")); - assert!(output.contains("POST /projects/project-1/invitations")); - assert!(output.contains("Remove --dry-run to execute this request")); - assert!(output.contains("Use --body-template to start from an example request body")); - assert!(!output.contains("Use --json")); - assert!(!output.contains("Use --body-template when constructing mutation bodies")); -} - -#[test] -fn dry_run_auth_recovery_only_suggests_body_templates_for_mutations() { - let get = dry_run_envelope(json!({ - "dry_run": true, - "valid": true, - "request": { - "method": "GET", - "path": "/views/projects/home", - "auth": { - "required": true, - "allow_unauthenticated": false, - "stored_token_valid": false, - } - } - })); - assert_eq!( - get["next_actions"], - json!([ - "pcl auth ensure --toon", - "Authenticate before removing --dry-run" - ]) - ); - - let post = dry_run_envelope(json!({ - "dry_run": true, - "valid": true, - "request": { - "method": "POST", - "path": "/projects", - "auth": { - "required": true, - "allow_unauthenticated": false, - "stored_token_valid": false, - } - } - })); - assert_eq!( - post["next_actions"], - json!([ - "pcl auth ensure --toon", - "Authenticate before removing --dry-run", - "Use --body-template when constructing mutation bodies" - ]) + assert_output_contains( + &output, + &[ + "Schema: incidents", + "Action: list_public", + "Request: GET /views/public/incidents", + "Example: pcl incidents --limit 5", + ], ); + assert_output_omits(&output, &["--toon"]); } - #[test] fn human_output_formats_api_discovery_for_people() { let output = envelope_output_string( @@ -3596,11 +3276,16 @@ fn human_output_formats_api_discovery_for_people() { ) .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")); + assert_output_contains( + &output, + &[ + "Operations\n", + "GET", + "/views/public/incidents", + "Prefer workflow", + ], + ); + assert_output_omits(&output, &["--toon"]); } #[test] @@ -3624,11 +3309,6 @@ fn machine_envelopes_keep_required_root_contract() { let envelopes = [ ok_envelope(json!({"healthy": true})), template_envelope(body_template("empty_object")), - dry_run_envelope(json!({ - "dry_run": true, - "valid": true, - "request": {"method": "GET", "path": "/health"}, - })), ApiCommandError::InvalidPath("health".to_string()).json_envelope(), ]; diff --git a/crates/pcl/core/src/api/transport.rs b/crates/pcl/core/src/api/transport.rs new file mode 100644 index 0000000..5a48ebd --- /dev/null +++ b/crates/pcl/core/src/api/transport.rs @@ -0,0 +1,116 @@ +use super::ApiCommandError; +use reqwest::header::HeaderMap; +use serde_json::{ + Map, + Value, + json, +}; +use std::path::Path; + +pub(in crate::api) struct ApiResponsePayload { + pub(in crate::api) status: reqwest::StatusCode, + pub(in crate::api) request_id: Option, + pub(in crate::api) headers: Map, + pub(in crate::api) body: Value, +} + +pub(crate) fn request_id_from_headers(headers: &HeaderMap) -> Option { + [ + "x-request-id", + "x-correlation-id", + "x-amzn-requestid", + "cf-ray", + "request-id", + ] + .into_iter() + .find_map(|name| { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) +} + +pub(in crate::api) fn write_request_log( + request_log_path: &Path, + kind: &str, + method: &str, + path: &str, + status: u16, + request_id: Option<&str>, + operation_id: Option<&str>, +) { + #[cfg(not(test))] + { + let _ = crate::request_log::append_request_record_at( + request_log_path, + &json!({ + "timestamp": chrono::Utc::now().to_rfc3339(), + "kind": kind, + "method": method, + "path": path, + "status": status, + "success": (200..=299).contains(&status), + "request_id": request_id, + "operation_id": operation_id, + }), + ); + } + #[cfg(test)] + let _ = ( + request_log_path, + kind, + method, + path, + status, + request_id, + operation_id, + ); +} + +pub(in crate::api) async fn read_api_response( + response: reqwest::Response, +) -> Result { + let status = response.status(); + let request_id = request_id_from_headers(response.headers()); + let headers = response + .headers() + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.as_str().to_string(), json!(value))) + }) + .collect::>(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let bytes = response.bytes().await?; + let body = response_body_value(&content_type, &bytes); + + Ok(ApiResponsePayload { + status, + request_id, + headers, + body, + }) +} + +pub(crate) fn response_body_value(content_type: &str, bytes: &[u8]) -> Value { + if content_type.contains("application/json") { + return serde_json::from_slice(bytes).unwrap_or_else(|_| { + json!({ + "parse_error": "response declared JSON but could not be parsed", + "raw": String::from_utf8_lossy(bytes), + }) + }); + } + + serde_json::from_slice(bytes) + .unwrap_or_else(|_| json!(String::from_utf8_lossy(bytes).to_string())) +} diff --git a/crates/pcl/core/src/api/workflow_options.rs b/crates/pcl/core/src/api/workflow_options.rs new file mode 100644 index 0000000..d698ad2 --- /dev/null +++ b/crates/pcl/core/src/api/workflow_options.rs @@ -0,0 +1,49 @@ +use super::{ + ApiArgs, + ApiCommand, + ApiCommandError, +}; +use crate::{ + DEFAULT_PLATFORM_URL, + config::CliConfig, +}; +use pcl_common::args::CliArgs; +use std::cell::Cell; + +#[derive(clap::Args, Debug)] +pub(in crate::api) struct ApiWorkflowOptions { + #[arg( + long = "api-url", + env = "PCL_API_URL", + default_value = DEFAULT_PLATFORM_URL, + global = true, + help = "Base URL for the platform API" + )] + api_url: url::Url, + + #[arg( + long, + global = true, + help = "Do not attach the stored bearer token to API requests" + )] + allow_unauthenticated: bool, +} + +impl ApiWorkflowOptions { + pub(in crate::api) async fn run( + self, + command: ApiCommand, + config: &mut CliConfig, + cli_args: &CliArgs, + json_output: bool, + ) -> Result<(), ApiCommandError> { + ApiArgs { + command, + api_url: self.api_url, + allow_unauthenticated: self.allow_unauthenticated, + refresh_after_401: Cell::new(true), + } + .run(config, cli_args, json_output) + .await + } +} diff --git a/crates/pcl/core/src/api/workflows.rs b/crates/pcl/core/src/api/workflows.rs index 0eb1d3d..db3a6b0 100644 --- a/crates/pcl/core/src/api/workflows.rs +++ b/crates/pcl/core/src/api/workflows.rs @@ -9,7 +9,7 @@ macro_rules! action { $(, aliases: [$($alias:literal),* $(,)?])? $(,)? ) => { - WorkflowActionDefinition { + super::super::definitions::WorkflowActionDefinition { name: $name, auth: $auth, method: $method, @@ -25,6 +25,29 @@ macro_rules! action { }; } +macro_rules! workflow_definition { + ( + $name:literal, + command: $command:literal, + description: $description:literal, + output: $output:literal, + policy: $policy:ident, + legacy_examples: [$($legacy:literal),* $(,)?], + actions: [$($action:expr),* $(,)?], + ) => { + pub(in crate::api) const DEFINITION: super::super::definitions::WorkflowDefinition = + super::super::definitions::WorkflowDefinition { + name: $name, + command: $command, + description: $description, + output: $output, + output_policy: super::super::definitions::WorkflowOutputPolicy::$policy, + legacy_examples: &[$($legacy),*], + actions: &[$($action),*], + }; + }; +} + macro_rules! optional_literal { () => { None @@ -93,6 +116,7 @@ use super::{ ApiCommandError, HttpMethod, ProjectsArgs, + WorkflowOperation, WorkflowRequest, read_body, }; @@ -112,6 +136,7 @@ fn workflow_with_body( ) -> WorkflowRequest { WorkflowRequest { method, + operation_id: None, path: path.into(), query: Vec::new(), body, @@ -121,6 +146,15 @@ fn workflow_with_body( } } +fn workflow_operation_with_body( + operation: WorkflowOperation, + require_auth: bool, + body: Option, + next_actions: impl IntoIterator>, +) -> Result { + WorkflowRequest::from_operation(operation, Vec::new(), body, require_auth, next_actions) +} + fn body_or_empty(body: Option) -> String { body.unwrap_or_else(|| "{}".to_string()) } diff --git a/crates/pcl/core/src/api/workflows/access.rs b/crates/pcl/core/src/api/workflows/access.rs index aeb6697..3c0a3b3 100644 --- a/crates/pcl/core/src/api/workflows/access.rs +++ b/crates/pcl/core/src/api/workflows/access.rs @@ -4,11 +4,6 @@ use super::{ ApiCommandError, HttpMethod, WorkflowRequest, - definitions::{ - WorkflowActionDefinition, - WorkflowDefinition, - WorkflowOutputPolicy, - }, }, body_or_empty, request_body, @@ -124,18 +119,18 @@ pub(in crate::api) fn access_request( )) } -pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { - name: "access", +workflow_definition!( + "access", command: "pcl access ", description: "Manage project members, roles, and invitations.", output: "member lists, invitation lists, role data, or mutation results", - output_policy: WorkflowOutputPolicy::MachineRaw, - legacy_examples: &[ + policy: MachineRaw, + legacy_examples: [ "pcl access --project --members", "pcl access --project --invite --body-template", "pcl access --token --preview", ], - actions: &[ + actions: [ action!("members", true, "GET", "/projects/{project}/members", "pcl access members ", required: [""]), action!("my_role", true, "GET", "/projects/{project}/my-role", "pcl access my-role ", required: [""]), action!("invitations", true, "GET", "/projects/{project}/invitations", "pcl access invitations ", required: [""]), @@ -154,4 +149,4 @@ pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { action!("preview", false, "GET", "/invitations/{token}/preview", "pcl access preview ", required: [""]), action!("accept", true, "POST", "/invitations/{token}/accept", "pcl access accept ", required: [""], body_template: "empty_object"), ], -}; +); diff --git a/crates/pcl/core/src/api/workflows/account.rs b/crates/pcl/core/src/api/workflows/account.rs index fb2b7a2..3b27333 100644 --- a/crates/pcl/core/src/api/workflows/account.rs +++ b/crates/pcl/core/src/api/workflows/account.rs @@ -4,11 +4,6 @@ use super::{ ApiCommandError, HttpMethod, WorkflowRequest, - definitions::{ - WorkflowActionDefinition, - WorkflowDefinition, - WorkflowOutputPolicy, - }, }, body_or_empty, request_body, @@ -44,16 +39,18 @@ pub(in crate::api) fn account_request( )) } -pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { - name: "account", +workflow_definition!( + "account", command: "pcl account [--me|--accept-terms|--logout]", description: "Inspect authenticated web user state and perform onboarding actions.", output: "current user account state, terms acceptance result, or logout result", - output_policy: WorkflowOutputPolicy::MachineRaw, - legacy_examples: &[], - actions: &[ + policy: MachineRaw, + legacy_examples: [ + + ], + actions: [ action!("me", true, "GET", "/web/auth/me", "pcl account"), action!("accept_terms", true, "POST", "/web/auth/accept-terms", "pcl account --accept-terms", body_template: "empty_object"), action!("logout", true, "POST", "/web/auth/logout", "pcl account --logout", body_template: "empty_object"), ], -}; +); diff --git a/crates/pcl/core/src/api/workflows/assertions.rs b/crates/pcl/core/src/api/workflows/assertions.rs index 25681b9..e734939 100644 --- a/crates/pcl/core/src/api/workflows/assertions.rs +++ b/crates/pcl/core/src/api/workflows/assertions.rs @@ -3,11 +3,6 @@ use super::{ ApiCommandError, AssertionsArgs, WorkflowRequest, - definitions::{ - WorkflowActionDefinition, - WorkflowDefinition, - WorkflowOutputPolicy, - }, }, first_string_field, push_query, @@ -127,14 +122,16 @@ pub(in crate::api) fn assertions_request( )) } -pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { - name: "assertions", +workflow_definition!( + "assertions", command: "pcl assertions --project [--assertion-id |--registered|--remove-info|--remove-calldata]", description: "List, inspect, and manage project assertion lifecycle state.", output: "assertion index/detail, registered assertions, or removal info/calldata", - output_policy: WorkflowOutputPolicy::MachineRaw, - legacy_examples: &[], - actions: &[ + policy: MachineRaw, + legacy_examples: [ + + ], + actions: [ action!("index", true, "GET", "/views/projects/{projectId}/assertions", "pcl assertions --project ", required: ["--project"]), action!("detail", true, "GET", "/views/projects/{projectId}/assertions/{assertionId}", "pcl assertions --project --assertion-id ", required: ["--project", "--assertion-id"]), action!("adopter_lookup", false, "GET", "/assertions", "pcl assertions --adopter-address 0x... --network 1", required: ["--adopter-address"], optional: ["--network", "--environment", "--include-onchain-only"]), @@ -142,4 +139,4 @@ pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { action!("remove_info", true, "GET", "/projects/{project_id}/remove-assertions-info", "pcl assertions --project --remove-info", required: ["--project"]), action!("remove_calldata", true, "GET", "/projects/{project_id}/remove-assertions-calldata", "pcl assertions --project --remove-calldata", required: ["--project"]), ], -}; +); diff --git a/crates/pcl/core/src/api/workflows/contracts.rs b/crates/pcl/core/src/api/workflows/contracts.rs index bce81ec..08775ad 100644 --- a/crates/pcl/core/src/api/workflows/contracts.rs +++ b/crates/pcl/core/src/api/workflows/contracts.rs @@ -4,11 +4,6 @@ use super::{ ContractsArgs, HttpMethod, WorkflowRequest, - definitions::{ - WorkflowActionDefinition, - WorkflowDefinition, - WorkflowOutputPolicy, - }, }, first_string_field, push_query, @@ -143,14 +138,16 @@ pub(in crate::api) fn contracts_next_actions( }) } -pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { - name: "contracts", +workflow_definition!( + "contracts", command: "pcl contracts [--project ] [--adopter-id ] [--unassigned --manager
] [--create --body-template]", description: "List and manage project contracts and assertion adopters.", output: "contract views, adopter records, assignment results, or remove calldata", - output_policy: WorkflowOutputPolicy::MachineRaw, - legacy_examples: &[], - actions: &[ + policy: MachineRaw, + legacy_examples: [ + + ], + actions: [ action!( "list_all", true, @@ -166,4 +163,4 @@ pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { action!("remove", true, "DELETE", "/projects/{project}/{aa_address}", "pcl contracts --project --aa-address 0x... --remove", required: ["--project", "--aa-address"]), action!("remove_calldata", true, "GET", "/assertion_adopters/{aa_address}/remove-assertions-calldata", "pcl contracts --aa-address 0x... --remove-calldata --network 1 --assertion-id 0x...", required: ["--aa-address", "--assertion-id"], optional: ["--network", "--environment"], query: {"assertion_ids" => "", "network" => "", "environment" => "production|staging"}), ], -}; +); diff --git a/crates/pcl/core/src/api/workflows/deployments.rs b/crates/pcl/core/src/api/workflows/deployments.rs index eda46b9..054ea8d 100644 --- a/crates/pcl/core/src/api/workflows/deployments.rs +++ b/crates/pcl/core/src/api/workflows/deployments.rs @@ -4,11 +4,6 @@ use super::{ DeploymentsArgs, HttpMethod, WorkflowRequest, - definitions::{ - WorkflowActionDefinition, - WorkflowDefinition, - WorkflowOutputPolicy, - }, }, redact_large_artifacts, request_body, @@ -42,15 +37,17 @@ pub(in crate::api) fn compact_deployment_data(data: &Value) -> Value { redact_large_artifacts(data) } -pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { - name: "deployments", +workflow_definition!( + "deployments", command: "pcl deployments --project [--confirm --body-template]", description: "Inspect deployment state and confirm deployed assertions.", output: "deployment view or confirmation result", - output_policy: WorkflowOutputPolicy::MachineRawHumanCompactArtifacts, - legacy_examples: &[], - actions: &[ + policy: MachineRawHumanCompactArtifacts, + legacy_examples: [ + + ], + actions: [ action!("list", true, "GET", "/views/projects/{project}/deployments", "pcl deployments --project ", required: ["--project"]), action!("confirm", true, "POST", "/projects/{project}/confirm-deployment", "pcl deployments --project --confirm --body-template", required: ["--project"], body_template: "deployment_confirmation"), ], -}; +); diff --git a/crates/pcl/core/src/api/workflows/events.rs b/crates/pcl/core/src/api/workflows/events.rs index cb02e9f..185ccf4 100644 --- a/crates/pcl/core/src/api/workflows/events.rs +++ b/crates/pcl/core/src/api/workflows/events.rs @@ -3,11 +3,6 @@ use super::{ ApiCommandError, EventsArgs, WorkflowRequest, - definitions::{ - WorkflowActionDefinition, - WorkflowDefinition, - WorkflowOutputPolicy, - }, }, push_query, required_project_arg, @@ -40,15 +35,17 @@ pub(in crate::api) fn events_request( Ok(request) } -pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { - name: "events", +workflow_definition!( + "events", command: "pcl events --project [--audit-log]", description: "Inspect project events and audit logs.", output: "event or audit log data", - output_policy: WorkflowOutputPolicy::MachineRaw, - legacy_examples: &[], - actions: &[ + policy: MachineRaw, + legacy_examples: [ + + ], + actions: [ action!("events", true, "GET", "/views/projects/{project}/events", "pcl events --project ", required: ["--project"], optional: ["--page", "--limit", "--environment"]), action!("audit_log", true, "GET", "/views/projects/{project}/audit-log", "pcl events --project --audit-log", required: ["--project"], optional: ["--page", "--limit", "--environment"]), ], -}; +); diff --git a/crates/pcl/core/src/api/workflows/incidents.rs b/crates/pcl/core/src/api/workflows/incidents.rs index fd750e1..d45e6e5 100644 --- a/crates/pcl/core/src/api/workflows/incidents.rs +++ b/crates/pcl/core/src/api/workflows/incidents.rs @@ -3,16 +3,13 @@ use super::{ ApiCommandError, HttpMethod, IncidentsArgs, + WorkflowOperation, WorkflowRequest, - definitions::{ - WorkflowActionDefinition, - WorkflowDefinition, - WorkflowOutputPolicy, - }, }, first_string_field, push_query, required_arg, + workflow_operation_with_body, }; use serde_json::Value; @@ -47,46 +44,65 @@ pub(in crate::api) fn incidents_request( if let Some(incident_id) = &args.incident_id { if args.retry_trace { let tx_id = required_arg(args.tx_id.as_deref(), "--tx-id")?; - return Ok(WorkflowRequest { - method: HttpMethod::Post, - path: format!("/incidents/{incident_id}/transactions/{tx_id}/trace/retry"), - query, - body: Some("{}".to_string()), - require_auth: true, - attach_auth: true, - next_actions: vec![format!( + return workflow_operation_with_body( + WorkflowOperation::new( + HttpMethod::Post, + "post_incidents_incident_id_transactions_tx_id_trace_retry", + ) + .path_param("incident_id", incident_id) + .path_param("tx_id", &tx_id), + true, + Some("{}".to_string()), + vec![format!( "pcl incidents --incident-id {incident_id} --tx-id {tx_id}" )], - }); + ); } - let path = if let Some(tx_id) = &args.tx_id { - format!("/views/incidents/{incident_id}/transactions/{tx_id}/trace") + let request = if let Some(tx_id) = &args.tx_id { + WorkflowRequest::from_operation( + WorkflowOperation::new( + HttpMethod::Get, + "get_views_incidents_incident_id_transactions_tx_id_trace", + ) + .path_param("incidentId", incident_id) + .path_param("txId", tx_id), + query, + None, + true, + vec![ + "pcl incidents --limit 5".to_string(), + "pcl api inspect get_views_incidents_incident_id_transactions_tx_id_trace" + .to_string(), + ], + )? } else { - format!("/views/incidents/{incident_id}") + WorkflowRequest::from_operation( + WorkflowOperation::new(HttpMethod::Get, "get_views_incidents_incident_id") + .path_param("incidentId", incident_id), + query, + None, + true, + vec![ + "pcl incidents --limit 5".to_string(), + "pcl api inspect get_views_incidents_incident_id".to_string(), + ], + )? }; - let next_actions = vec![ - "pcl incidents --limit 5".to_string(), - format!("pcl api inspect get {}", path), - ]; - return Ok(WorkflowRequest::get_with_query( - path, - query, - true, - next_actions, - )); + return Ok(request); } if let Some(project_id) = &args.project_id { if args.stats { - let path = format!("/projects/{project_id}/incidents/stats"); - return Ok(WorkflowRequest::get_with_query( - path, + return WorkflowRequest::from_operation( + WorkflowOperation::new(HttpMethod::Get, "get_projects_project_id_incidents_stats") + .path_param("project_id", project_id), query, + None, true, vec![format!( "pcl incidents --project-id {project_id} --limit 10" )], - )); + ); } push_query(&mut query, "assertionId", args.assertion_id.as_deref()); push_query( @@ -97,30 +113,32 @@ pub(in crate::api) fn incidents_request( push_query(&mut query, "environment", args.environment.as_deref()); push_query(&mut query, "fromDate", args.from_date.as_deref()); push_query(&mut query, "toDate", args.to_date.as_deref()); - let path = format!("/views/projects/{project_id}/incidents"); - return Ok(WorkflowRequest::get_with_query( - path, + return WorkflowRequest::from_operation( + WorkflowOperation::new(HttpMethod::Get, "get_views_projects_project_id_incidents") + .path_param("projectId", project_id), query, + None, true, vec![ format!("pcl assertions --project-id {project_id}"), "pcl incidents --limit 5".to_string(), ], - )); + ); } push_query(&mut query, "network", args.network); push_query(&mut query, "sort", args.sort.as_deref()); push_query(&mut query, "devMode", args.dev_mode.as_deref()); - Ok(WorkflowRequest::get_with_query( - "/views/public/incidents", + WorkflowRequest::from_operation( + WorkflowOperation::new(HttpMethod::Get, "get_views_public_incidents"), query, + None, false, vec![ "pcl incidents --project-id --limit 10".to_string(), "pcl projects list --limit 10".to_string(), ], - )) + ) } pub(in crate::api) fn incidents_next_actions( @@ -136,7 +154,15 @@ pub(in crate::api) fn incidents_next_actions( .and_then(Value::as_array) .and_then(|transactions| transactions.first()) .and_then(|transaction| { - first_string_field(transaction, &["transaction_hash", "id", "tx_id"]) + first_string_field( + transaction, + &[ + "id", + "tx_id", + "invalidating_transaction_id", + "invalidatingTransactionId", + ], + ) }) { return vec![ @@ -154,14 +180,16 @@ pub(in crate::api) fn incidents_next_actions( }) } -pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { - name: "incidents", +workflow_definition!( + "incidents", command: "pcl incidents [--project-id ] [--incident-id ] [--stats] [--limit ] [--all --output ]", description: "List public incidents, project incidents, fetch all incident pages, inspect incident detail, incident stats, or incident trace.", output: "incident data from /views/public/incidents, /views/projects/{projectId}/incidents, /views/incidents/{incidentId}, or /projects/{project_id}/incidents/stats", - output_policy: WorkflowOutputPolicy::MachineRaw, - legacy_examples: &[], - actions: &[ + policy: MachineRaw, + legacy_examples: [ + + ], + actions: [ action!("list_public", false, "GET", "/views/public/incidents", "pcl incidents --limit 5", optional: ["--page", "--limit", "--network", "--sort", "--dev-mode", "--all", "--max-pages", "--output"]), action!("list_project", true, "GET", "/views/projects/{projectId}/incidents", "pcl incidents --project --all --limit 50 --output incidents.json", required: ["--project"], optional: ["--page", "--limit", "--assertion-id", "--adopter-id", "--environment", "--from", "--to", "--all", "--max-pages", "--output"]), action!("stats", true, "GET", "/projects/{project_id}/incidents/stats", "pcl incidents --project --stats", required: ["--project"]), @@ -169,4 +197,4 @@ pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { action!("trace", true, "GET", "/views/incidents/{incidentId}/transactions/{txId}/trace", "pcl incidents --incident-id --tx-id ", required: ["--incident-id", "--tx-id"]), action!("retry_trace", true, "POST", "/incidents/{incident_id}/transactions/{tx_id}/trace/retry", "pcl incidents --incident-id --tx-id --retry-trace", required: ["--incident-id", "--tx-id"], body_template: "empty_object"), ], -}; +); diff --git a/crates/pcl/core/src/api/workflows/integrations.rs b/crates/pcl/core/src/api/workflows/integrations.rs index ab5c18e..8d9900f 100644 --- a/crates/pcl/core/src/api/workflows/integrations.rs +++ b/crates/pcl/core/src/api/workflows/integrations.rs @@ -4,11 +4,6 @@ use super::{ HttpMethod, IntegrationsArgs, WorkflowRequest, - definitions::{ - WorkflowActionDefinition, - WorkflowDefinition, - WorkflowOutputPolicy, - }, }, body_or_empty, request_body, @@ -78,17 +73,19 @@ pub(in crate::api) fn integrations_request( )) } -pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { - name: "integrations", +workflow_definition!( + "integrations", command: "pcl integrations --project --provider [--configure|--test|--delete]", description: "Manage Slack and PagerDuty integrations.", output: "integration status or mutation/test results", - output_policy: WorkflowOutputPolicy::MachineRaw, - legacy_examples: &[], - actions: &[ + policy: MachineRaw, + legacy_examples: [ + + ], + actions: [ action!("get", true, "GET", "/projects/{project}/integrations/{provider}", "pcl integrations --project --provider slack", required: ["--project", "--provider"]), action!("configure", true, "POST", "/projects/{project}/integrations/{provider}", "pcl integrations --project --provider slack --configure --body-template", required: ["--project", "--provider"], body_template: "slack|pagerduty"), action!("test", true, "POST", "/projects/{project}/integrations/{provider}/test", "pcl integrations --project --provider slack --test", required: ["--project", "--provider"], body_template: "slack|pagerduty"), action!("delete", true, "DELETE", "/projects/{project}/integrations/{provider}", "pcl integrations --project --provider slack --delete", required: ["--project", "--provider"]), ], -}; +); diff --git a/crates/pcl/core/src/api/workflows/projects.rs b/crates/pcl/core/src/api/workflows/projects.rs index dc8ae08..03c4bdc 100644 --- a/crates/pcl/core/src/api/workflows/projects.rs +++ b/crates/pcl/core/src/api/workflows/projects.rs @@ -4,11 +4,6 @@ use super::{ HttpMethod, ProjectsArgs, WorkflowRequest, - definitions::{ - WorkflowActionDefinition, - WorkflowDefinition, - WorkflowOutputPolicy, - }, }, first_string_field, project_request_body, @@ -153,18 +148,18 @@ pub(in crate::api) fn projects_request( )) } -pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { - name: "projects", +workflow_definition!( + "projects", command: "pcl projects ", description: "List, inspect, create, update, save, unsave, resolve, widget, and delete projects.", output: "project explorer, your projects, project detail, saved projects, widget, or mutation result", - output_policy: WorkflowOutputPolicy::MachineRaw, - legacy_examples: &[ + policy: MachineRaw, + legacy_examples: [ "pcl projects --mine", "pcl projects --project ", "pcl projects --create --project-name demo --chain-id 1", ], - actions: &[ + actions: [ action!( "explorer", false, @@ -183,4 +178,4 @@ pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { action!("resolve", false, "GET", "/projects/resolve/{project_ref}", "pcl projects resolve ", required: [""]), action!("widget", true, "GET", "/projects/{project_id}/widget", "pcl projects widget ", required: [""]), ], -}; +); diff --git a/crates/pcl/core/src/api/workflows/protocol_manager.rs b/crates/pcl/core/src/api/workflows/protocol_manager.rs index cf0e594..726bee7 100644 --- a/crates/pcl/core/src/api/workflows/protocol_manager.rs +++ b/crates/pcl/core/src/api/workflows/protocol_manager.rs @@ -4,11 +4,6 @@ use super::{ HttpMethod, ProtocolManagerArgs, WorkflowRequest, - definitions::{ - WorkflowActionDefinition, - WorkflowDefinition, - WorkflowOutputPolicy, - }, }, first_string_field, push_query, @@ -153,14 +148,16 @@ pub(in crate::api) fn protocol_manager_next_actions( next_actions } -pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { - name: "protocol-manager", +workflow_definition!( + "protocol-manager", command: "pcl protocol-manager --project [--nonce --address
|--set|--clear|--transfer-calldata|--accept-calldata|--pending-transfer|--confirm-transfer]", description: "Manage protocol manager transfers and calldata.", output: "manager state, nonce, calldata, pending transfer, or mutation result", - output_policy: WorkflowOutputPolicy::MachineRaw, - legacy_examples: &[], - actions: &[ + policy: MachineRaw, + legacy_examples: [ + + ], + actions: [ action!("pending_transfer", true, "GET", "/projects/{project}/protocol-manager/pending-transfer", "pcl protocol-manager --project --pending-transfer", required: ["--project"]), action!("nonce", true, "GET", "/projects/{project}/protocol-manager/nonce", "pcl protocol-manager --project --nonce --address 0x...", required: ["--project", "--address"], optional: ["--chain-id"], query: {"address" => "
", "chain_id" => ""}), action!("set", true, "POST", "/projects/{project}/protocol-manager", "pcl protocol-manager --project --set --body-template", required: ["--project"], body_template: "protocol_manager_set"), @@ -169,4 +166,4 @@ pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { action!("accept_calldata", true, "GET", "/projects/{project}/protocol-manager/accept-calldata", "pcl protocol-manager --project --accept-calldata", required: ["--project"]), action!("confirm_transfer", true, "POST", "/projects/{project}/protocol-manager/confirm-transfer", "pcl protocol-manager --project --confirm-transfer --body-template", required: ["--project"], body_template: "protocol_manager_confirm"), ], -}; +); diff --git a/crates/pcl/core/src/api/workflows/releases.rs b/crates/pcl/core/src/api/workflows/releases.rs index bf57763..d059978 100644 --- a/crates/pcl/core/src/api/workflows/releases.rs +++ b/crates/pcl/core/src/api/workflows/releases.rs @@ -4,11 +4,6 @@ use super::{ HttpMethod, ReleasesArgs, WorkflowRequest, - definitions::{ - WorkflowActionDefinition, - WorkflowDefinition, - WorkflowOutputPolicy, - }, }, body_or_empty, first_string_field, @@ -157,18 +152,18 @@ pub(in crate::api) fn releases_next_actions( }) } -pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { - name: "releases", +workflow_definition!( + "releases", command: "pcl releases ", description: "List, inspect, create, preview, deploy, check progress, retry failed checks, and remove releases.", output: "release data, diffs, check progress, deployment confirmations, or calldata", - output_policy: WorkflowOutputPolicy::MachineRaw, - legacy_examples: &[ + policy: MachineRaw, + legacy_examples: [ "pcl releases --project ", "pcl releases --project --release-id ", "pcl releases --project --preview --body-file release.json", ], - actions: &[ + actions: [ action!("list", true, "GET", "/projects/{project}/releases", "pcl releases list ", required: [""]), action!("detail", true, "GET", "/projects/{project}/releases/{release_id}", "pcl releases show ", required: ["", ""]), action!("preview", true, "POST", "/projects/{project}/releases/preview", "pcl releases preview --body-file release.json", required: [""], body_template: "release"), @@ -180,4 +175,4 @@ pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { action!("remove_calldata", true, "GET", "/projects/{project}/releases/{release_id}/remove-calldata", "pcl releases calldata remove ", required: ["", ""]), action!("remove", true, "POST", "/projects/{project}/releases/{release_id}/remove", "pcl releases remove --body-template", required: ["", ""], body_template: "release_remove"), ], -}; +); diff --git a/crates/pcl/core/src/api/workflows/search.rs b/crates/pcl/core/src/api/workflows/search.rs index a33e630..c229fd5 100644 --- a/crates/pcl/core/src/api/workflows/search.rs +++ b/crates/pcl/core/src/api/workflows/search.rs @@ -3,11 +3,6 @@ use super::{ ApiCommandError, SearchArgs, WorkflowRequest, - definitions::{ - WorkflowActionDefinition, - WorkflowDefinition, - WorkflowOutputPolicy, - }, }, first_string_field, push_query, @@ -129,14 +124,16 @@ pub(in crate::api) fn search_next_actions(data: &Value, fallback: Vec) - fallback } -pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { - name: "search", +workflow_definition!( + "search", command: "pcl search [--query ] [--stats] [--system-status] [--verified-contract --address --chain-id ]", description: "Search projects/contracts and inspect platform metadata.", output: "search results, stats, system status, health, whitelist, or verified contract data", - output_policy: WorkflowOutputPolicy::MachineRaw, - legacy_examples: &[], - actions: &[ + policy: MachineRaw, + legacy_examples: [ + + ], + actions: [ action!("query", false, "GET", "/search", "pcl search --query settler", optional: ["--query"]), action!("stats", false, "GET", "/stats", "pcl search --stats"), action!( @@ -156,4 +153,4 @@ pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { ), action!("verified_contract", false, "GET", "/web/verified-contract", "pcl search --verified-contract --address 0x... --chain-id 1", required: ["--address", "--chain-id"]), ], -}; +); diff --git a/crates/pcl/core/src/api/workflows/transfers.rs b/crates/pcl/core/src/api/workflows/transfers.rs index 19fd227..c2f268d 100644 --- a/crates/pcl/core/src/api/workflows/transfers.rs +++ b/crates/pcl/core/src/api/workflows/transfers.rs @@ -4,11 +4,6 @@ use super::{ HttpMethod, TransfersArgs, WorkflowRequest, - definitions::{ - WorkflowActionDefinition, - WorkflowDefinition, - WorkflowOutputPolicy, - }, }, first_string_field, request_body, @@ -56,14 +51,16 @@ pub(in crate::api) fn transfers_next_actions( }) } -pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { - name: "transfers", +workflow_definition!( + "transfers", command: "pcl transfers [--pending|--transfer-id |--reject --body-template]", description: "Inspect and reject protocol manager transfers.", output: "pending transfers, transfer detail, or reject result", - output_policy: WorkflowOutputPolicy::MachineRaw, - legacy_examples: &[], - actions: &[ + policy: MachineRaw, + legacy_examples: [ + + ], + actions: [ action!( "pending", true, @@ -74,4 +71,4 @@ pub(in crate::api) const DEFINITION: WorkflowDefinition = WorkflowDefinition { action!("detail", true, "GET", "/views/transfers/{transfer_id}", "pcl transfers --transfer-id ", required: ["--transfer-id"]), action!("reject", true, "POST", "/transfers/reject", "pcl transfers --reject --body-template", body_template: "transfer_reject"), ], -}; +); diff --git a/crates/pcl/core/src/download.rs b/crates/pcl/core/src/download.rs index 47b4135..5b4c487 100644 --- a/crates/pcl/core/src/download.rs +++ b/crates/pcl/core/src/download.rs @@ -278,7 +278,7 @@ impl DownloadArgs { } else { skipped += 1; if output_mode == OutputMode::Human { - println!(" [skipped] {contract_name} — no source code available"); + println!(" [skipped] {contract_name} - no source code available"); } } } diff --git a/crates/pcl/core/src/surface.rs b/crates/pcl/core/src/surface.rs index e4d82b6..ba00f93 100644 --- a/crates/pcl/core/src/surface.rs +++ b/crates/pcl/core/src/surface.rs @@ -1475,9 +1475,9 @@ fn llms_guide() -> Value { "logout": "pcl auth logout attempts remote logout first, then clears local credentials; pass --local to skip the remote request." }, "mutation_safety": { - "order": ["--body-template", "--dry-run", "typed flags", "--field key=value", "--body-file body.json"], + "order": ["--body-template", "typed flags", "--field key=value", "--body-file body.json"], "body_templates": "Print payload contracts before writes; choose a concrete body variant when body_variants is returned.", - "dry_run": "Use dry-run request plans before destructive project, assertion, release, access, integration, transfer, or protocol-manager operations. Dry-run is a planner, not an enforced confirmation gate." + "execution": "Workflow commands execute when invoked; inspect body templates and use typed flags or body files deliberately." }, "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.", @@ -2208,6 +2208,13 @@ mod tests { ); assert!( guide["mutation_safety"]["order"] + .as_array() + .unwrap() + .iter() + .any(|step| step == "--body-template") + ); + assert!( + !guide["mutation_safety"]["order"] .as_array() .unwrap() .iter() diff --git a/scripts/agent-smoke.sh b/scripts/agent-smoke.sh index 63c1b07..82f5b16 100755 --- a/scripts/agent-smoke.sh +++ b/scripts/agent-smoke.sh @@ -80,10 +80,9 @@ 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 projects create --project-name demo --chain-id 1 --dry-run -toon_envelope releases preview project-1 --body-template --dry-run -toon_envelope access invite project-1 --body-template --dry-run +toon_envelope projects create --body-template +toon_envelope releases preview project-1 --body-template +toon_envelope access invite project-1 --body-template toon_envelope completions bash toon_error build