diff --git a/Cargo.lock b/Cargo.lock index 8ee5d08..2092292 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1780,7 +1780,7 @@ dependencies = [ [[package]] name = "proofline-cli" -version = "0.1.7" +version = "0.1.8" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 60e3b72..f6fa2fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proofline-cli" -version = "0.1.7" +version = "0.1.8" edition = "2024" rust-version = "1.95" description = "Proofline command-line client for the review API" diff --git a/README.md b/README.md index f14c3a3..816e958 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,14 @@ The command-line client for the [Proofline review API](https://proofline.sh/docs/). Install with `curl -fsSL https://proofline.sh/cli | sh`, then run `proofline login`. +To register MCP without a prompt, run `proofline agent install CLIENT --yes`. +Clients include `claude-code` (or `claude`), `cursor`, `codex`, `gemini` and `kiro`. +Without `--yes`, the command previews the entry and asks for confirmation. +`--print` previews without writing. Existing Proofline entries remain unchanged. +New entries use this executable's absolute path and store no credentials. +Claude Code uses the top-level `mcpServers` in `~/.claude.json`. +A custom `CLAUDE_CONFIG_DIR` requires manual configuration with `--print`. + See the [CLI documentation](https://proofline.sh/docs/cli/) for commands. `proofline review --path src/main.rs` reviews only that file's local changes diff --git a/openapi.public.json b/openapi.public.json index 2641819..0eceff5 100644 --- a/openapi.public.json +++ b/openapi.public.json @@ -763,6 +763,47 @@ ], "type": "object" }, + "CheckFile": { + "description": "One file World cited for a check's result.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/components/schemas/UntrustedText" + } + ], + "description": "The repository-relative path. It is repository text." + }, + "revision": { + "allOf": [ + { + "$ref": "#/components/schemas/CheckFileRevision" + } + ], + "description": "The revision World read the file at." + } + }, + "required": [ + "revision", + "path" + ], + "type": "object" + }, + "CheckFileRevision": { + "description": "The revision a cited file was read at.", + "oneOf": [ + { + "const": "PROPOSED_CODE_REVISION", + "description": "The reviewed commit.", + "type": "string" + }, + { + "const": "BASE_CODE_REVISION", + "description": "Its merge base.", + "type": "string" + } + ] + }, "CheckLifecycle": { "description": "World's lifecycle disclosure for the evaluator version that answered, as\n the receipt recorded it. A deployment gate counts only `GATING` results\n (`DeterministicCoverage::gate_effective_status`), and only when it\n evaluates a deployment; the lifecycle alone permits or blocks nothing.", "oneOf": [ @@ -1405,6 +1446,13 @@ "null" ] }, + "files": { + "description": "The files World cited as evidence for this result, once each in\n World's order, with the revision World read each at. Empty when World\n cited no file, and on results recorded before World cited files.", + "items": { + "$ref": "#/components/schemas/CheckFile" + }, + "type": "array" + }, "lifecycle": { "allOf": [ { @@ -1436,7 +1484,8 @@ "required": [ "name", "outcome", - "lifecycle" + "lifecycle", + "files" ], "type": "object" }, @@ -1480,6 +1529,13 @@ "null" ] }, + "coverage_details": { + "description": "Recorded check coverage counts and fixed cancellation causes, without source text.\n Absent coverage does not establish that every check ran.", + "items": { + "type": "string" + }, + "type": "array" + }, "findings": { "description": "The code-only findings the code assessment recorded, `SERIOUS` first.\n An empty list does not establish deployment safety.", "items": { @@ -5656,6 +5712,14 @@ ], "description": "Human override beside the original decision, when one was requested." }, + "publication_group": { + "description": "The finding that represents this finding's publication group, when an\n applied group publishes it in one comment with other findings. Every\n member names the same finding, the representative included.", + "format": "uuid", + "type": [ + "string", + "null" + ] + }, "reason_codes": { "description": "Recognized safe reason codes; unknown values are represented as `UNRECOGNIZED`.", "items": { diff --git a/src/agent.rs b/src/agent.rs index 64dc8b7..1d1ca06 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -35,10 +35,15 @@ struct InstallArgs { /// Print the credential-free server entry without writing any file. #[arg(long)] print: bool, + /// Save the previewed entry without an interactive confirmation. + #[arg(long, conflicts_with = "print")] + yes: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] enum Client { + #[value(alias = "claude")] + ClaudeCode, Codex, Cursor, Gemini, @@ -48,6 +53,7 @@ enum Client { impl Client { const fn relative_path(self) -> &'static str { match self { + Self::ClaudeCode => ".claude.json", Self::Codex => ".codex/config.toml", Self::Cursor => ".cursor/mcp.json", Self::Gemini => ".gemini/settings.json", @@ -59,6 +65,7 @@ impl Client { impl fmt::Display for Client { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { + Self::ClaudeCode => "Claude Code", Self::Codex => "Codex", Self::Cursor => "Cursor", Self::Gemini => "Gemini CLI", @@ -69,6 +76,14 @@ impl fmt::Display for Client { #[derive(Debug, thiserror::Error)] enum InstallError { + #[error("could not resolve the running Proofline executable: {0}")] + Executable(io::Error), + #[error("the running Proofline executable path is not UTF-8")] + ExecutableEncoding, + #[error( + "CLAUDE_CONFIG_DIR selects custom configuration; use --print and configure Claude Code manually" + )] + CustomClaudeHome, #[error("could not find an absolute user home directory")] Home, #[error( @@ -152,6 +167,12 @@ pub(crate) async fn run(args: AgentArgs, origin: Origin) -> anyhow::Result<()> { let AgentCommand::Install(args) = args.command; let home = std::env::home_dir().ok_or(InstallError::Home)?; let codex_home = std::env::var_os("CODEX_HOME"); + if args.client == Client::ClaudeCode + && !args.print + && std::env::var_os("CLAUDE_CONFIG_DIR").is_some() + { + return Err(InstallError::CustomClaudeHome); + } let stdin = io::stdin(); let stdout = io::stdout(); let stderr = io::stderr(); @@ -209,21 +230,24 @@ fn install( layout.target() ) .map_err(|error| InstallError::console("show the proposed server entry", error))?; - write!(diagnostics, "Write this entry? [y/N] ") - .and_then(|()| diagnostics.flush()) - .map_err(|error| InstallError::console("ask for confirmation", error))?; - let mut answer = String::new(); - input - .take(65) - .read_line(&mut answer) - .map_err(|error| InstallError::console("read confirmation", error))?; - if answer.len() > 64 - || !answer.ends_with('\n') - || !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") - { - writeln!(diagnostics, "No configuration was written.") - .map_err(|error| InstallError::console("report the unchanged configuration", error))?; - return Ok(Outcome::Cancelled); + if !args.yes { + write!(diagnostics, "Write this entry? [y/N] ") + .and_then(|()| diagnostics.flush()) + .map_err(|error| InstallError::console("ask for confirmation", error))?; + let mut answer = String::new(); + input + .take(65) + .read_line(&mut answer) + .map_err(|error| InstallError::console("read confirmation", error))?; + if answer.len() > 64 + || !answer.ends_with('\n') + || !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") + { + writeln!(diagnostics, "No configuration was written.").map_err(|error| { + InstallError::console("report the unchanged configuration", error) + })?; + return Ok(Outcome::Cancelled); + } } match layout.save(&before, &bytes, origin)? { store::Saved::Existing => { diff --git a/src/agent/config.rs b/src/agent/config.rs index 22dd3eb..43b20c6 100644 --- a/src/agent/config.rs +++ b/src/agent/config.rs @@ -46,7 +46,9 @@ pub(super) fn prepare( ) -> Result { match client { Client::Codex => codex(original, origin), - Client::Cursor | Client::Gemini | Client::Kiro => json(client, original, origin), + Client::ClaudeCode | Client::Cursor | Client::Gemini | Client::Kiro => { + json(client, original, origin) + } } } @@ -54,6 +56,14 @@ fn arguments(origin: &Origin) -> [String; 3] { ["--base-url".to_owned(), origin.as_key(), "mcp".to_owned()] } +fn executable() -> Result { + std::env::current_exe() + .map_err(InstallError::Executable)? + .into_os_string() + .into_string() + .map_err(|_| InstallError::ExecutableEncoding) +} + fn codex(original: Option<&[u8]>, origin: &Origin) -> Result { let raw = std::str::from_utf8(original.unwrap_or_default()) .map_err(|_| InstallError::InvalidConfig(Client::Codex))?; @@ -68,21 +78,21 @@ fn codex(original: Option<&[u8]>, origin: &Origin) -> Result { let mut servers = Table::new(); - servers.insert("proofline", Item::Table(codex_server(args))); + servers.insert("proofline", Item::Table(codex_server(args)?)); document.insert("mcp_servers", Item::Table(servers)); } Some(Item::Table(servers)) => { if servers.contains_key("proofline") { return Ok(Update::Existing); } - servers.insert("proofline", Item::Table(codex_server(args))); + servers.insert("proofline", Item::Table(codex_server(args)?)); } Some(Item::Value(toml_edit::Value::InlineTable(servers))) => { if servers.contains_key("proofline") { return Ok(Update::Existing); } let mut server = InlineTable::new(); - server.insert("command", "proofline".into()); + server.insert("command", executable()?.into()); server.insert("args", args.into()); servers.insert("proofline", server.into()); } @@ -96,11 +106,11 @@ fn codex(original: Option<&[u8]>, origin: &Origin) -> Result Table { +fn codex_server(args: Array) -> Result { let mut server = Table::new(); - server.insert("command", toml_edit::value("proofline")); + server.insert("command", toml_edit::value(executable()?)); server.insert("args", toml_edit::value(args)); - server + Ok(server) } fn json(client: Client, original: Option<&[u8]>, origin: &Origin) -> Result { @@ -133,10 +143,10 @@ fn json(client: Client, original: Option<&[u8]>, origin: &Origin) -> Result Vec { Client::Codex => format!( "# keep this comment\nmodel = \"chosen-model\"\nlabels = [\"one\", \"two\"]\n\n[mcp_servers.other]\ncommand = \"other-server\"\nargs = [\"a b\"]\n\n[mcp_servers.other.env]\nSECRET = \"{PRIVATE}\"\n" ).into_bytes(), - Client::Cursor | Client::Gemini | Client::Kiro => serde_json::to_vec_pretty(&json!({ + Client::ClaudeCode | Client::Cursor | Client::Gemini | Client::Kiro => serde_json::to_vec_pretty(&json!({ "theme": "chosen-theme", "policy": {"trust": false, "autoApprove": [], "excluded": ["untrusted"]}, "mcpServers": { @@ -97,16 +107,23 @@ fn configuration(client: Client) -> Vec { } fn assert_new_server(client: Client, server: &Value, selected_origin: &str) { - assert_eq!(server["command"], "proofline"); + assert_eq!( + server["command"], + std::env::current_exe().unwrap().to_str().unwrap() + ); assert_eq!( server["args"], json!(["--base-url", selected_origin, "mcp"]) ); assert_eq!( server.as_object().unwrap().len(), - if client == Client::Cursor { 3 } else { 2 } + if matches!(client, Client::Cursor | Client::ClaudeCode) { + 3 + } else { + 2 + } ); - if client == Client::Cursor { + if matches!(client, Client::Cursor | Client::ClaudeCode) { assert_eq!(server["type"], "stdio"); } } @@ -134,7 +151,7 @@ fn entries(root: &Path) -> Vec<(PathBuf, Option>)> { } #[test] -fn four_clients_preserve_private_settings_and_add_only_the_launcher() { +fn all_clients_preserve_private_settings_and_add_only_the_launcher() { for client in CLIENTS { let home = TempDir::new().unwrap(); let original = configuration(client); @@ -158,7 +175,10 @@ fn four_clients_preserve_private_settings_and_add_only_the_launcher() { .unwrap() .remove("proofline") .unwrap(); - assert_eq!(server["command"].as_str(), Some("proofline")); + assert_eq!( + server["command"].as_str(), + std::env::current_exe().unwrap().to_str() + ); assert_eq!( server["args"] .as_array() @@ -171,7 +191,7 @@ fn four_clients_preserve_private_settings_and_add_only_the_launcher() { assert_eq!(server.as_table().unwrap().len(), 2); assert_eq!(parsed, expected); } - Client::Cursor | Client::Gemini | Client::Kiro => { + Client::ClaudeCode | Client::Cursor | Client::Gemini | Client::Kiro => { let expected: Value = serde_json::from_slice(&original).unwrap(); let mut parsed: Value = serde_json::from_str(&actual).unwrap(); let server = parsed["mcpServers"] @@ -288,7 +308,7 @@ fn custom_existing_entries_and_codex_home_are_never_overwritten() { let home = TempDir::new().unwrap(); let original = match client { Client::Codex => format!("# {PRIVATE}\nmcp_servers.proofline = {{ command = \"custom-launcher\", args = [\"custom\"] }}\n").into_bytes(), - Client::Cursor | Client::Gemini | Client::Kiro => format!("{{\"mcpServers\":{{\"proofline\":null}},\"private\":\"{PRIVATE}\"}}").into_bytes(), + Client::ClaudeCode | Client::Cursor | Client::Gemini | Client::Kiro => format!("{{\"mcpServers\":{{\"proofline\":null}},\"private\":\"{PRIVATE}\"}}").into_bytes(), }; let path = seed(home.path(), client, &original); let modified = fs::metadata(&path).unwrap().modified().unwrap(); @@ -391,7 +411,12 @@ fn toml_inline_tables_and_separate_arguments_round_trip_in_unicode_homes() { Cli::try_parse_from(std::iter::once("proofline").chain(args.iter().copied())).unwrap(); assert_eq!(cli.base_url, "http://[::1]:8080"); assert!(matches!(cli.command, CliCommand::Mcp)); - for client in [Client::Cursor, Client::Gemini, Client::Kiro] { + for client in [ + Client::ClaudeCode, + Client::Cursor, + Client::Gemini, + Client::Kiro, + ] { let precision = "{\"mcpServers\":{\"other\":{\"value\":184467440737095516170}},\"precision\":1.2345678901234567890123456789}"; seed(&home, client, precision.as_bytes()); let result = invoke( @@ -673,7 +698,7 @@ fn invalid_duplicate_oversized_and_readonly_documents_stay_private_and_intact() "mcp_servers = 42\n".to_owned(), "mcp_servers = []\nmcp_servers = {}\n".to_owned(), ], - Client::Cursor | Client::Gemini | Client::Kiro => vec![ + Client::ClaudeCode | Client::Cursor | Client::Gemini | Client::Kiro => vec![ format!("{{\"private\":\"{PRIVATE}\", \"unfinished\": "), "[]".to_owned(), "{\"mcpServers\": []}".to_owned(), @@ -771,7 +796,14 @@ fn an_overlong_answer_and_unknown_client_or_output_flag_cannot_write() { "--output", ".codex/config.toml", ], - vec!["proofline", "agent", "install", "gemini", "--yes"], + vec![ + "proofline", + "agent", + "install", + "gemini", + "--yes", + "--print", + ], ] { assert!(Cli::try_parse_from(args).is_err()); } diff --git a/tests/agent_install.rs b/tests/agent_install.rs new file mode 100644 index 0000000..f525bc5 --- /dev/null +++ b/tests/agent_install.rs @@ -0,0 +1,124 @@ +//! The noninteractive install contract exercised through the shipped binary. + +use std::fs; +use std::path::Path; +use std::process::{Command, Stdio}; + +use serde_json::{Value, json}; +use tempfile::TempDir; + +fn command(binary: &Path, home: &Path) -> Command { + let mut command = Command::new(binary); + command + .env("HOME", home) + .env("USERPROFILE", home) + .env_remove("CODEX_HOME") + .env_remove("CLAUDE_CONFIG_DIR") + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env("PROOFLINE_NO_UPDATE_CHECK", "1") + .stdin(Stdio::null()); + command +} + +#[test] +fn unattended_registration_preserves_settings_and_uses_the_installed_binary() { + let root = TempDir::new().unwrap(); + let home = root.path().join("home"); + let directory = root.path().join("install with spaces 東京"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&directory).unwrap(); + let binary = directory.join(if cfg!(windows) { + "proofline.exe" + } else { + "proofline" + }); + fs::copy(env!("CARGO_BIN_EXE_proofline"), &binary).unwrap(); + let executable = binary.canonicalize().unwrap(); + for (client, relative) in [ + ("claude-code", ".claude.json"), + ("cursor", ".cursor/mcp.json"), + ("codex", ".codex/config.toml"), + ] { + let path = home.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let original = if client == "codex" { + "# preserved\nmodel = 'chosen-model'\n".to_owned() + } else { + json!({"projects": {"private-project": {"hasTrustDialogAccepted": false}}, + "mcpServers": {"other": {"command": "other-server"}}}) + .to_string() + }; + fs::write(&path, &original).unwrap(); + let installed = command(&binary, &home) + .args(["agent", "install", client, "--yes"]) + .output() + .unwrap(); + assert!( + installed.status.success(), + "{}", + String::from_utf8_lossy(&installed.stderr) + ); + let bytes = fs::read_to_string(&path).unwrap(); + let server = if client == "codex" { + assert!(bytes.starts_with(&original)); + let config: toml::Value = toml::from_str(&bytes).unwrap(); + serde_json::to_value(&config["mcp_servers"]["proofline"]).unwrap() + } else { + let mut config: Value = serde_json::from_str(&bytes).unwrap(); + let server = config["mcpServers"] + .as_object_mut() + .unwrap() + .remove("proofline") + .unwrap(); + assert_eq!(config, serde_json::from_str::(&original).unwrap()); + server + }; + assert_eq!(server["command"], executable.to_str().unwrap()); + assert_eq!( + server["args"], + json!(["--base-url", "https://proofline.sh", "mcp"]) + ); + let replay = command(&binary, &home) + .args(["agent", "install", client, "--yes"]) + .output() + .unwrap(); + assert!(replay.status.success()); + assert_eq!(fs::read_to_string(&path).unwrap(), bytes); + } +} + +#[test] +fn unattended_confirmation_keeps_refusals_and_manual_preview() { + let home = TempDir::new().unwrap(); + let binary = Path::new(env!("CARGO_BIN_EXE_proofline")); + let path = home.path().join(".claude.json"); + let private = "{\"private-canary\":true,\"mcpServers\":[]}"; + fs::write(&path, private).unwrap(); + let refused = command(binary, home.path()) + .args(["agent", "install", "claude", "--yes"]) + .output() + .unwrap(); + assert!(!refused.status.success()); + assert!(!String::from_utf8_lossy(&refused.stderr).contains("private-canary")); + assert_eq!(fs::read_to_string(&path).unwrap(), private); + fs::remove_file(&path).unwrap(); + let custom = home.path().join("custom"); + let refused = command(binary, home.path()) + .env("CLAUDE_CONFIG_DIR", &custom) + .args(["agent", "install", "claude-code", "--yes"]) + .output() + .unwrap(); + assert!(!refused.status.success()); + assert!(!path.exists()); + assert!(!custom.exists()); + let preview = command(binary, home.path()) + .env("CLAUDE_CONFIG_DIR", &custom) + .args(["agent", "install", "claude-code", "--print"]) + .output() + .unwrap(); + assert!(preview.status.success()); + assert!(serde_json::from_slice::(&preview.stdout).is_ok()); + assert!(!path.exists()); + assert!(!custom.exists()); +}