From 5d46c9eadbfffb00b7566d3063143fdb7dcdd1a7 Mon Sep 17 00:00:00 2001 From: Mohsen Azimi Date: Sun, 1 Nov 2026 14:50:51 +0000 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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")