Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions openapi.public.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
54 changes: 39 additions & 15 deletions src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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(
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 => {
Expand Down
28 changes: 19 additions & 9 deletions src/agent/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,24 @@ pub(super) fn prepare(
) -> Result<Update, InstallError> {
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)
}
}
}

fn arguments(origin: &Origin) -> [String; 3] {
["--base-url".to_owned(), origin.as_key(), "mcp".to_owned()]
}

fn executable() -> Result<String, InstallError> {
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<Update, InstallError> {
let raw = std::str::from_utf8(original.unwrap_or_default())
.map_err(|_| InstallError::InvalidConfig(Client::Codex))?;
Expand All @@ -68,21 +78,21 @@ fn codex(original: Option<&[u8]>, origin: &Origin) -> Result<Update, InstallErro
match document.get_mut("mcp_servers") {
None => {
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());
}
Expand All @@ -96,11 +106,11 @@ fn codex(original: Option<&[u8]>, origin: &Origin) -> Result<Update, InstallErro
Ok(Update::Change(document.to_string().into_bytes()))
}

fn codex_server(args: Array) -> Table {
fn codex_server(args: Array) -> Result<Table, InstallError> {
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<Update, InstallError> {
Expand Down Expand Up @@ -133,10 +143,10 @@ fn json(client: Client, original: Option<&[u8]>, origin: &Origin) -> Result<Upda
return Ok(Update::Existing);
}
let mut server = Map::new();
if client == Client::Cursor {
if matches!(client, Client::Cursor | Client::ClaudeCode) {
server.insert("type".to_owned(), Value::String("stdio".to_owned()));
}
server.insert("command".to_owned(), Value::String("proofline".to_owned()));
server.insert("command".to_owned(), Value::String(executable()?));
server.insert(
"args".to_owned(),
Value::Array(arguments(origin).into_iter().map(Value::String).collect()),
Expand Down
58 changes: 45 additions & 13 deletions src/agent/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ use crate::origin::Origin;
use crate::{Cli, Command as CliCommand};

const PRIVATE: &str = "installer-private-config-canary";
const CLIENTS: [Client; 4] = [Client::Codex, Client::Cursor, Client::Gemini, Client::Kiro];
const CLIENTS: [Client; 5] = [
Client::ClaudeCode,
Client::Codex,
Client::Cursor,
Client::Gemini,
Client::Kiro,
];

#[derive(Debug)]
struct Invocation {
Expand All @@ -41,7 +47,11 @@ fn invoke(
let result = install(
home,
codex_home.map(Path::as_os_str),
&InstallArgs { client, print },
&InstallArgs {
client,
print,
yes: false,
},
origin,
input,
&mut output,
Expand Down Expand Up @@ -85,7 +95,7 @@ fn configuration(client: Client) -> Vec<u8> {
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": {
Expand All @@ -97,16 +107,23 @@ fn configuration(client: Client) -> Vec<u8> {
}

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");
}
}
Expand Down Expand Up @@ -134,7 +151,7 @@ fn entries(root: &Path) -> Vec<(PathBuf, Option<Vec<u8>>)> {
}

#[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);
Expand All @@ -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()
Expand All @@ -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"]
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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());
}
Expand Down
Loading
Loading