From 5d46c9eadbfffb00b7566d3063143fdb7dcdd1a7 Mon Sep 17 00:00:00 2001 From: Mohsen Azimi Date: Sun, 1 Nov 2026 14:50:51 +0000 Subject: [PATCH 1/6] Sync the routing publication group contract proofline#5755 adds the optional RoutingFindingView.publication_group to the public contract. Copy openapi.public.json byte for byte and regenerate the typed client. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015eE1s8zZUb3FFVuNqiL3Rh --- openapi.public.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openapi.public.json b/openapi.public.json index 2641819..01ccfc5 100644 --- a/openapi.public.json +++ b/openapi.public.json @@ -5656,6 +5656,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": { From 1b932a8c75ce4355b8cb7237432a9bbebb12b92e Mon Sep 17 00:00:00 2001 From: Mohsen Azimi Date: Mon, 2 Nov 2026 19:59:21 +0100 Subject: [PATCH 2/6] Register Claude Code and support unattended MCP setup Co-Authored-By: Codex (GPT-6.1) --- README.md | 8 +++ src/agent.rs | 54 +++++++++++++----- src/agent/config.rs | 28 +++++++--- src/agent/tests.rs | 58 ++++++++++++++----- tests/agent_install.rs | 123 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 234 insertions(+), 37 deletions(-) create mode 100644 tests/agent_install.rs 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/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..506b7c9 --- /dev/null +++ b/tests/agent_install.rs @@ -0,0 +1,123 @@ +//! 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_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()); +} From 686f9e5a1855f5766205be86d8ee6175588bee8b Mon Sep 17 00:00:00 2001 From: Mohsen Azimi Date: Mon, 2 Nov 2026 20:00:56 +0100 Subject: [PATCH 3/6] Prepare CLI 0.1.8 for the agent installer Co-Authored-By: Codex (GPT-6.1) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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" From 4f72410a0d0aa55176242b1418ae8051491b1909 Mon Sep 17 00:00:00 2001 From: Mohsen Azimi Date: Mon, 2 Nov 2026 20:14:31 +0100 Subject: [PATCH 4/6] Keep installer integration homes isolated on Windows Co-Authored-By: Codex (GPT-6.1) --- tests/agent_install.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/agent_install.rs b/tests/agent_install.rs index 506b7c9..f525bc5 100644 --- a/tests/agent_install.rs +++ b/tests/agent_install.rs @@ -11,6 +11,7 @@ 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") From 4e729ebdc4b178156b58a972d34bcefefb33e9ed Mon Sep 17 00:00:00 2001 From: Mohsen Azimi Date: Tue, 3 Nov 2026 14:35:36 +0100 Subject: [PATCH 5/6] Expose cited files in check results Add file paths and their proposed or base revision to the public check response schema. Co-Authored-By: Codex (GPT-6.1) --- openapi.public.json | 51 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/openapi.public.json b/openapi.public.json index 01ccfc5..c6cb21c 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" }, From a42feae9582ee2f79c5a8b5ebe2ab6f3b8bb8616 Mon Sep 17 00:00:00 2001 From: Mohsen Azimi Date: Wed, 4 Nov 2026 03:06:12 +0100 Subject: [PATCH 6/6] Sync optional assessment coverage metadata Co-Authored-By: Codex (GPT-6.1) --- openapi.public.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/openapi.public.json b/openapi.public.json index 2641819..17a7f38 100644 --- a/openapi.public.json +++ b/openapi.public.json @@ -1480,6 +1480,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 +5663,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": {