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
66 changes: 65 additions & 1 deletion openapi.public.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
{
Expand Down Expand Up @@ -1436,7 +1484,8 @@
"required": [
"name",
"outcome",
"lifecycle"
"lifecycle",
"files"
],
"type": "object"
},
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
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
Loading
Loading