diff --git a/Cargo.lock b/Cargo.lock index 7947635c..e33ddb69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,56 +108,6 @@ dependencies = [ "thiserror 2.0.20", ] -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - [[package]] name = "anyhow" version = "1.0.104" @@ -521,55 +471,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "clap" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_complete" -version = "4.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" -dependencies = [ - "clap", -] - -[[package]] -name = "clap_derive" -version = "4.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 3.0.4", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - [[package]] name = "clipboard-win" version = "5.4.1" @@ -585,12 +486,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - [[package]] name = "compact_str" version = "0.8.2" @@ -712,8 +607,6 @@ dependencies = [ "base64", "bytes", "chrono", - "clap", - "clap_complete", "copypasta", "cuid2", "derive_builder", @@ -762,6 +655,7 @@ dependencies = [ "two-face", "unicode-width 0.1.14", "url", + "usage-rs", "vt100", ] @@ -2066,12 +1960,6 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - [[package]] name = "itertools" version = "0.13.0" @@ -2731,12 +2619,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "onig" version = "6.5.3" @@ -4698,6 +4580,43 @@ dependencies = [ "serde", ] +[[package]] +name = "usage-argv" +version = "6.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832f849be4b8046c843219bc92d067a6689530df53f56e3c0a52ff5ebbd2f89b" + +[[package]] +name = "usage-derive" +version = "6.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5634bb965ae075c5cedfc27942f7e6110bf5e7e169cba04de86346f4aeee41e7" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "usage-rs" +version = "6.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a8c2b63902582429527f21cd91d2da0065fb9222b8a2a5b87d8f77c7daf71e" +dependencies = [ + "usage-argv", + "usage-derive", + "usage-test", +] + +[[package]] +name = "usage-test" +version = "6.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "574ec714315c7acd46b892ae156d105e346cb1a27b53759eb6cc6b0a45256adb" +dependencies = [ + "usage-argv", +] + [[package]] name = "utf-8" version = "0.7.6" diff --git a/Cargo.toml b/Cargo.toml index 23d8ebc3..7160d0e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,9 +42,8 @@ serde_json = { version = "1.0", features = ["preserve_order"] } json5 = "0.4" schemars = "1.0" anyhow = "1.0" -clap = { version = "4.5", features = ["derive"] } +usage = { package = "usage-rs", version = "6", features = ["completions"] } agent-client-protocol = { version = "=2.0.0", features = ["unstable_session_fork"] } -clap_complete = "4.5" ignore = "0.4" copypasta = "0.10" async-trait = "0.1" diff --git a/src/main.rs b/src/main.rs index 82c643d6..29494728 100644 --- a/src/main.rs +++ b/src/main.rs @@ -68,8 +68,6 @@ pub mod log { use crate::toast::{Toast, ToastManager}; use anyhow::{Context, Result}; use app::App; -use clap::{CommandFactory, Parser, Subcommand}; -use clap_complete::{generate, shells}; use ratatui::crossterm::{ event::{ self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, @@ -81,10 +79,11 @@ use ratatui::crossterm::{ }; use ratatui::{backend::CrosstermBackend, buffer::Buffer, style::Color, Terminal}; use std::io::{self, IsTerminal, Read, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command as ProcessCommand; use std::sync::Mutex; use std::time::Duration; +use usage::{Cli, Subcommands}; const POST_CLOSE_LOGO: &str = include_str!("../crabcode-logo.txt"); const ANSI_RESET: &str = "\x1b[0m"; @@ -664,15 +663,6 @@ fn estimate_text_tokens(content: &str) -> usize { content.chars().count().max(1) / 4 } -fn parse_reasoning_effort_arg( - value: &str, -) -> Result { - value.parse().map_err(|_| { - "reasoning effort must be one of none, minimal, low, medium, high, xhigh, or max" - .to_string() - }) -} - lazy_static::lazy_static! { static ref TOAST_MANAGER: Mutex = Mutex::new(ToastManager::new()); } @@ -689,55 +679,61 @@ pub fn get_toast_manager() -> &'static Mutex { &TOAST_MANAGER } -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] +#[derive(Cli, Debug)] +#[usage( + bin = "crabcode", + version, + about = "Rust AI CLI Coding Agent with a beautiful terminal UI", + completion, + unknown_flags = "error" +)] struct Args { - #[command(subcommand)] + #[usage(subcommand)] command: Option, /// Resume a session by ID - #[arg(short = 's', long = "session")] + #[usage(short = 's', long = "session")] session: Option, /// Run in print mode (non-interactive, streams output to stdout) - #[arg(short = 'p', long = "print")] + #[usage(short = 'p', long = "print")] print_mode: bool, /// Attach print mode or interactive attach to a remote crabcode host - #[arg(long = "attach", value_name = "URL_OR_ALIAS")] + #[usage(long = "attach", value_name = "URL_OR_ALIAS")] attach: Option, /// Do not persist session data to disk - #[arg(long = "no-session-persistence")] + #[usage(long = "no-session-persistence")] no_session_persistence: bool, /// Model to use for this invocation, formatted as provider/model - #[arg(short = 'm', long = "model")] + #[usage(short = 'm', long = "model")] model: Option, /// Agent to start with (e.g. build, plan). Overrides config `default_agent`. - #[arg(long = "agent", value_name = "NAME")] + #[usage(long = "agent", value_name = "NAME")] agent: Option, /// Reasoning effort to use for this invocation: none, minimal, low, medium, high, xhigh, or max - #[arg(long = "reasoning-effort", value_parser = parse_reasoning_effort_arg)] + #[usage(long = "reasoning-effort")] reasoning_effort: Option, /// Skip permission prompts in print mode. Intended for isolated benchmark/CI workspaces. - #[arg(long = "dangerously-skip-permissions")] + #[usage(long = "dangerously-skip-permissions")] dangerously_skip_permissions: bool, - #[arg(long = "emit-logs", hide = true)] + #[usage(long = "emit-logs", hide)] emit_logs: bool, - #[arg(long = "test-notification", hide = true)] + #[usage(long = "test-notification", hide)] test_notification: bool, /// The prompt to run (positional, used in print mode) prompt: Vec, } -#[derive(Subcommand, Debug)] +#[derive(Subcommands, Debug)] enum Command { /// List available models, optionally filtered by provider Models { @@ -748,21 +744,31 @@ enum Command { /// Start an Agent Client Protocol server over stdin/stdout Acp { /// Working directory used for the initial ACP workspace - #[arg(long)] + #[usage(long = "cwd")] cwd: Option, }, - /// Generate shell completion script - Completion, + /// Generate or install shell completion + Completion { + /// Write the script where the shell autoloads it (not stdout) + #[usage(long)] + install: bool, + /// Overwrite a completion file that was not generated by usage + #[usage(long)] + force: bool, + /// Shell: bash, zsh, fish, elvish, nu, powershell. Default: $SHELL + #[usage(long)] + shell: Option, + }, /// Host the current workspace for browser and CLI clients Serve { /// Address to bind, for example 127.0.0.1:8421 or 0.0.0.0:8421 - #[arg(long = "bind", default_value = "127.0.0.1:8421")] + #[usage(long = "bind", default = "127.0.0.1:8421")] bind: String, /// Require pairing with this code, or use "random" to generate one - #[arg(long = "paircode", alias = "pair-code", value_name = "CODE_OR_RANDOM")] + #[usage(long = "paircode", alias = "pair-code", value_name = "CODE_OR_RANDOM")] pair_code: Option, }, @@ -783,26 +789,26 @@ enum Command { /// Manage survive-quit background jobs (list / logs / stop) Jobs { - #[command(subcommand)] + #[usage(subcommand)] command: JobsCommand, }, /// Periodic cleanup tasks (jobs GC today; workspaces later) Maintenance { - #[command(subcommand)] + #[usage(subcommand)] command: MaintenanceCommand, }, } -#[derive(Subcommand, Debug)] +#[derive(Subcommands, Debug)] enum JobsCommand { /// List background jobs (default: current project only) List { /// Show jobs from all projects - #[arg(long)] + #[usage(long)] all: bool, /// Human-friendly table (future: interactive TUI picker; for now just a pretty table) - #[arg(short = 'i', long)] + #[usage(short = 'i', long)] interactive: bool, }, /// Print a job's output.log @@ -810,10 +816,10 @@ enum JobsCommand { /// Job id (e.g. job_01HXYZ…) id: String, /// Follow new output (like tail -f) - #[arg(long)] + #[usage(long)] follow: bool, /// Number of trailing lines to print - #[arg(long, default_value_t = 200)] + #[usage(long, default = "200")] tail: usize, }, /// Stop a background job (kill process group + update ledger) @@ -824,7 +830,7 @@ enum JobsCommand { /// Stop all running jobs (default: current project only) StopAll { /// Stop running jobs from every project - #[arg(long)] + #[usage(long)] all: bool, }, /// Restart a background job (same id / command / cwd) @@ -837,29 +843,29 @@ enum JobsCommand { /// Scope: default = current session; `--all` = current project; `--global` = everything. Clean { /// Clean finished jobs for the current project (all sessions) - #[arg(long)] + #[usage(long)] all: bool, /// Clean finished jobs across every project - #[arg(long)] + #[usage(long)] global: bool, /// Age threshold (e.g. 7d, 24h, 30m). Ignored when --all/--global. - #[arg(long, default_value = "7d")] + #[usage(long, default = "7d")] older_than: String, /// Report what would be removed without deleting - #[arg(long)] + #[usage(long)] dry_run: bool, }, } -#[derive(Subcommand, Debug)] +#[derive(Subcommands, Debug)] enum MaintenanceCommand { /// Run registered maintenance tasks Run { /// Only run this task id (e.g. jobs) - #[arg(long)] + #[usage(long)] only: Option, /// Report without deleting - #[arg(long)] + #[usage(long)] dry_run: bool, }, /// List registered maintenance tasks @@ -870,33 +876,304 @@ fn is_completion_help(args: &[String]) -> bool { matches!(args, [command, help] if command == "completion" && matches!(help.as_str(), "--help" | "-h")) } -fn completion_shell(shell: Option<&str>) -> shells::Shell { +fn completion_shell_from_path(shell: Option<&str>) -> usage::complete::Shell { match shell.and_then(|shell| shell.rsplit('/').next()) { - Some("zsh") => shells::Shell::Zsh, - _ => shells::Shell::Bash, + Some("zsh") => usage::complete::Shell::Zsh, + Some("fish") => usage::complete::Shell::Fish, + Some("elvish") => usage::complete::Shell::Elvish, + Some("nu" | "nushell") => usage::complete::Shell::Nu, + Some("pwsh" | "powershell") => usage::complete::Shell::PowerShell, + _ => usage::complete::Shell::Bash, + } +} + +fn resolve_completion_shell(explicit: Option<&str>) -> Result { + match explicit { + Some(name) => usage::complete::Shell::from_name(name).ok_or_else(|| { + anyhow::anyhow!( + "unknown shell {name:?}; expected bash, zsh, fish, elvish, nu, or powershell" + ) + }), + None => Ok(completion_shell_from_path( + std::env::var("SHELL").ok().as_deref(), + )), } } -fn generate_completion(shell: shells::Shell) -> Vec { - let mut command = Args::command(); - let mut output = Vec::new(); - generate(shell, &mut command, "crabcode", &mut output); - output +fn generate_completion(shell: usage::complete::Shell) -> Vec { + Args::completion_script(shell).into_bytes() } fn root_help() -> Result { - let mut command = Args::command(); - let mut output = Vec::new(); - command.write_long_help(&mut output)?; - Ok(String::from_utf8(output).expect("Clap help is valid UTF-8")) + Args::render_help(Args::command(), true) + .ok_or_else(|| anyhow::anyhow!("failed to render crabcode help")) } -fn print_completion() -> Result<()> { - let shell = completion_shell(std::env::var("SHELL").ok().as_deref()); +const CRABCODE_RC_BEGIN: &str = "# >>> crabcode installer >>>"; +const CRABCODE_RC_END: &str = "# <<< crabcode installer <<<"; + +fn print_completion(shell: usage::complete::Shell) -> Result<()> { io::stdout().write_all(&generate_completion(shell))?; Ok(()) } +fn home_dir() -> PathBuf { + dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) +} + +fn display_home_path(path: &Path) -> String { + let home = home_dir(); + path.strip_prefix(&home) + .map(|rest| format!("~/{}", rest.display())) + .unwrap_or_else(|_| path.display().to_string()) +} + +fn zshrc_path() -> PathBuf { + std::env::var_os("ZDOTDIR") + .map(PathBuf::from) + .unwrap_or_else(home_dir) + .join(".zshrc") +} + +fn resolve_existing_path(path: &Path) -> PathBuf { + let mut current = path.to_path_buf(); + for _ in 0..40 { + match std::fs::read_link(¤t) { + Ok(target) => { + current = if target.is_absolute() { + target + } else if let Some(parent) = current.parent() { + parent.join(target) + } else { + target + }; + } + Err(_) => break, + } + } + current +} + +fn zsh_installer_block(script: &Path) -> String { + format!( + "{CRABCODE_RC_BEGIN}\n[[ -r {} ]] && source {}\n{CRABCODE_RC_END}\n", + display_home_path(script), + display_home_path(script) + ) +} + +fn crabcode_aliases_from_rc(rc: &str) -> Vec { + let mut names = Vec::new(); + for line in rc.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix("alias ") else { + continue; + }; + let Some((name, value)) = rest.split_once('=') else { + continue; + }; + let name = name.trim(); + if name.is_empty() + || name == "crabcode" + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + continue; + } + let value = value + .trim() + .trim_matches(['\'', '"']) + .split_whitespace() + .next() + .unwrap_or(""); + if value == "crabcode" && !names.iter().any(|existing| existing == name) { + names.push(name.to_string()); + } + } + names +} + +fn with_zsh_compdef_names(script: &str, extra: &[String]) -> String { + if extra.is_empty() { + return script.to_string(); + } + let mut names = vec!["crabcode".to_string()]; + names.extend(extra.iter().cloned()); + let header = format!("#compdef {}", names.join(" ")); + let sourced = format!( + " compdef _crabcode {}", + names + .iter() + .map(|name| format!("'{name}'")) + .collect::>() + .join(" ") + ); + let mut out = String::new(); + for (i, line) in script.lines().enumerate() { + if i == 0 && line.starts_with("#compdef ") { + out.push_str(&header); + } else if line.trim_start().starts_with("compdef _crabcode ") { + out.push_str(&sourced); + } else { + out.push_str(line); + } + out.push('\n'); + } + out +} + +fn strip_inline_usage_completion(rc: &str) -> String { + let lines: Vec<&str> = rc.lines().collect(); + let start = lines.iter().position(|line| { + let trimmed = line.trim_start(); + trimmed == "#compdef crabcode" + || trimmed.starts_with("# @generated by usage-argv for `crabcode") + }); + let Some(start) = start else { + return rc.to_string(); + }; + let Some(end) = lines.iter().skip(start).position(|line| { + let trimmed = line.trim(); + trimmed.starts_with("compdef _crabcode") + }) else { + return rc.to_string(); + }; + let end = start + end; + let mut kept = Vec::with_capacity(lines.len()); + kept.extend_from_slice(&lines[..start]); + if end + 1 < lines.len() { + let rest = &lines[end + 1..]; + let skip = rest + .iter() + .take_while(|line| line.trim().is_empty()) + .count(); + kept.extend_from_slice(&rest[skip..]); + } + let mut out = kept.join("\n"); + if rc.ends_with('\n') && !out.ends_with('\n') { + out.push('\n'); + } + out +} + +fn strip_marked_block(rc: &str) -> String { + let Some(start) = rc.find(CRABCODE_RC_BEGIN) else { + return rc.to_string(); + }; + let Some(end_rel) = rc[start..].find(CRABCODE_RC_END) else { + return rc.to_string(); + }; + let end = start + end_rel + CRABCODE_RC_END.len(); + let mut out = String::new(); + out.push_str(rc[..start].trim_end()); + let rest = rc[end..].trim_start_matches(['\r', '\n']); + if !rest.is_empty() { + if !out.is_empty() { + out.push('\n'); + out.push('\n'); + } + out.push_str(rest); + } + if rc.ends_with('\n') && !out.ends_with('\n') { + out.push('\n'); + } + out +} + +fn upsert_marked_block(rc: &str, block: &str) -> String { + let rc = strip_inline_usage_completion(rc); + let rc = strip_marked_block(&rc); + let block = block.trim_end(); + let insert_at = rc.len(); + let mut out = String::new(); + out.push_str(rc[..insert_at].trim_end()); + if !out.is_empty() { + out.push('\n'); + out.push('\n'); + } + out.push_str(block); + out.push('\n'); + let rest = rc[insert_at..].trim_start_matches(['\r', '\n']); + if !rest.is_empty() { + out.push('\n'); + out.push_str(rest); + if rc.ends_with('\n') && !out.ends_with('\n') { + out.push('\n'); + } + } + out +} + +fn write_text_atomic(path: &Path, contents: &str) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("crabcode.tmp"); + std::fs::write(&tmp, contents)?; + std::fs::rename(&tmp, path).or_else(|_| { + std::fs::copy(&tmp, path)?; + std::fs::remove_file(&tmp)?; + Ok::<(), anyhow::Error>(()) + })?; + Ok(()) +} + +fn ensure_zsh_hook(script: &Path) -> Result { + let path = resolve_existing_path(&zshrc_path()); + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + let next = upsert_marked_block(&existing, &zsh_installer_block(script)); + if next != existing { + write_text_atomic(&path, &next)?; + } + Ok(path) +} + +fn install_completion(shell: usage::complete::Shell, force: bool) -> Result<()> { + let env = usage::install::Env::from_process(); + let on_foreign = if force { + usage::install::OnForeign::Overwrite + } else { + usage::install::OnForeign::Refuse + }; + let installed = Args::install_completion(shell, &env, on_foreign) + .map_err(|err| anyhow::anyhow!("{err}"))?; + + println!("script {}", display_home_path(&installed.plan.path)); + + if shell == usage::complete::Shell::Zsh { + let rc_path = resolve_existing_path(&zshrc_path()); + let rc = std::fs::read_to_string(&rc_path).unwrap_or_default(); + let aliases = crabcode_aliases_from_rc(&rc); + let script = std::fs::read_to_string(&installed.plan.path)?; + let patched = with_zsh_compdef_names(&script, &aliases); + write_text_atomic(&installed.plan.path, &patched)?; + let rc = ensure_zsh_hook(&installed.plan.path)?; + println!("hook {}", display_home_path(&rc)); + println!("Restart the shell (`exec zsh`) and press Tab."); + return Ok(()); + } + + match &installed.plan.loading { + usage::install::Loading::Automatic => {} + usage::install::Loading::Manual { line, file, .. } => { + println!("Add this to {file} (once):\n{line}"); + } + _ => {} + } + + Ok(()) +} + +fn run_completion(install: bool, force: bool, shell: Option<&str>) -> Result<()> { + let shell = resolve_completion_shell(shell)?; + if install { + install_completion(shell, force) + } else { + print_completion(shell) + } +} + fn merge_prompt_with_stdin(prompt: &str, stdin: &str) -> String { if stdin.trim().is_empty() { return prompt.to_string(); @@ -981,8 +1258,12 @@ async fn main() -> Result<()> { Some(Command::Acp { cwd }) => { return crate::acp::run(cwd.clone()).await; } - Some(Command::Completion) => { - print_completion()?; + Some(Command::Completion { + install, + force, + shell, + }) => { + run_completion(*install, *force, shell.as_deref())?; return Ok(()); } Some(Command::Serve { bind, pair_code }) => { @@ -1179,17 +1460,22 @@ async fn main() -> Result<()> { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsStr; + + fn parse_args(argv: &[&str]) -> Args { + let argv: Vec<&OsStr> = argv.iter().copied().map(OsStr::new).collect(); + Args::try_parse_from(&argv).unwrap_or_else(|err| panic!("parse failed: {err:?}")) + } #[test] fn parses_model_after_print_prompt() { - let args = Args::try_parse_from([ + let args = parse_args(&[ "crabcode", "-p", "hi", "--model", "opencode-go/deepseek-v4-flash", - ]) - .unwrap(); + ]); assert_eq!(args.prompt, vec!["hi"]); assert_eq!(args.model.as_deref(), Some("opencode-go/deepseek-v4-flash")); @@ -1197,15 +1483,14 @@ mod tests { #[test] fn parses_model_with_no_session_persistence_after_print_prompt() { - let args = Args::try_parse_from([ + let args = parse_args(&[ "crabcode", "-p", "hi", "--no-session-persistence", "--model", "opencode-go/kimi-k2.5", - ]) - .unwrap(); + ]); assert_eq!(args.prompt, vec!["hi"]); assert!(args.no_session_persistence); @@ -1214,7 +1499,7 @@ mod tests { #[test] fn parses_short_model_alias() { - let args = Args::try_parse_from(["crabcode", "-p", "hi", "-m", "openai/gpt-5.2"]).unwrap(); + let args = parse_args(&["crabcode", "-p", "hi", "-m", "openai/gpt-5.2"]); assert_eq!(args.prompt, vec!["hi"]); assert_eq!(args.model.as_deref(), Some("openai/gpt-5.2")); @@ -1222,8 +1507,7 @@ mod tests { #[test] fn parses_print_reasoning_effort_override() { - let args = - Args::try_parse_from(["crabcode", "-p", "hi", "--reasoning-effort", "medium"]).unwrap(); + let args = parse_args(&["crabcode", "-p", "hi", "--reasoning-effort", "medium"]); assert_eq!( args.reasoning_effort, @@ -1233,7 +1517,7 @@ mod tests { #[test] fn parses_agent_override() { - let args = Args::try_parse_from(["crabcode", "-p", "hi", "--agent", "plan"]).unwrap(); + let args = parse_args(&["crabcode", "-p", "hi", "--agent", "plan"]); assert_eq!(args.agent.as_deref(), Some("plan")); } @@ -1254,7 +1538,7 @@ mod tests { #[test] fn parses_serve_command() { - let args = Args::try_parse_from(["crabcode", "serve", "--bind", "0.0.0.0:8421"]).unwrap(); + let args = parse_args(&["crabcode", "serve", "--bind", "0.0.0.0:8421"]); match args.command { Some(Command::Serve { bind, pair_code }) => { @@ -1267,7 +1551,7 @@ mod tests { #[test] fn parses_acp_command_with_workspace() { - let args = Args::try_parse_from(["crabcode", "acp", "--cwd", "/tmp/workspace"]).unwrap(); + let args = parse_args(&["crabcode", "acp", "--cwd", "/tmp/workspace"]); match args.command { Some(Command::Acp { cwd }) => assert_eq!(cwd, Some(PathBuf::from("/tmp/workspace"))), @@ -1277,14 +1561,14 @@ mod tests { #[test] fn parses_models_command_with_optional_provider() { - let args = Args::try_parse_from(["crabcode", "models", "openai"]).unwrap(); + let args = parse_args(&["crabcode", "models", "openai"]); match args.command { Some(Command::Models { provider }) => assert_eq!(provider.as_deref(), Some("openai")), other => panic!("expected models command, got {other:?}"), } - let args = Args::try_parse_from(["crabcode", "models"]).unwrap(); + let args = parse_args(&["crabcode", "models"]); match args.command { Some(Command::Models { provider }) => assert!(provider.is_none()), other => panic!("expected models command, got {other:?}"), @@ -1293,21 +1577,25 @@ mod tests { #[test] fn generates_bash_completion() { - let script = - String::from_utf8(generate_completion(completion_shell(Some("/bin/bash")))).unwrap(); + let script = String::from_utf8(generate_completion(completion_shell_from_path(Some( + "/bin/bash", + )))) + .unwrap(); - assert!(script.contains("_crabcode")); + assert!(script.contains("_crabcode") || script.contains("_usage_complete_crabcode")); assert!(script.contains("complete")); assert!(script.contains("crabcode")); } #[test] fn generates_zsh_completion() { - let script = - String::from_utf8(generate_completion(completion_shell(Some("/bin/zsh")))).unwrap(); + let script = String::from_utf8(generate_completion(completion_shell_from_path(Some( + "/bin/zsh", + )))) + .unwrap(); assert!(script.starts_with("#compdef crabcode")); - assert!(script.contains("_crabcode")); + assert!(script.contains("_crabcode") || script.contains("_usage_complete_crabcode")); } #[test] @@ -1328,15 +1616,17 @@ mod tests { let help = root_help().unwrap(); assert!(help.contains("Usage: crabcode")); - assert!(help.contains("completion Generate shell completion script")); - assert!( - help.contains("serve Host the current workspace for browser and CLI clients") - ); + assert!(help.contains("completion")); + assert!(help.contains("Generate or install shell completion")); + assert!(help.contains("serve")); + assert!(help.contains("Host the current workspace")); + assert!(help.contains("jobs")); + assert!(help.contains("maintenance")); } #[test] fn parses_serve_paircode() { - let args = Args::try_parse_from(["crabcode", "serve", "--paircode", "random"]).unwrap(); + let args = parse_args(&["crabcode", "serve", "--paircode", "random"]); match args.command { Some(Command::Serve { pair_code, .. }) => { @@ -1348,7 +1638,7 @@ mod tests { #[test] fn parses_attach_command() { - let args = Args::try_parse_from(["crabcode", "attach", "http://127.0.0.1:8421"]).unwrap(); + let args = parse_args(&["crabcode", "attach", "http://127.0.0.1:8421"]); match args.command { Some(Command::Attach { target }) => assert_eq!(target, "http://127.0.0.1:8421"), @@ -1358,7 +1648,7 @@ mod tests { #[test] fn parses_upgrade_command() { - let args = Args::try_parse_from(["crabcode", "upgrade"]).unwrap(); + let args = parse_args(&["crabcode", "upgrade"]); assert!(matches!( args.command, @@ -1368,7 +1658,7 @@ mod tests { #[test] fn parses_upgrade_target() { - let args = Args::try_parse_from(["crabcode", "upgrade", "0.1.0"]).unwrap(); + let args = parse_args(&["crabcode", "upgrade", "0.1.0"]); match args.command { Some(Command::Upgrade { target }) => assert_eq!(target.as_deref(), Some("0.1.0")), @@ -1376,12 +1666,114 @@ mod tests { } } + #[test] + fn parses_completion_install_flags() { + let args = parse_args(&["crabcode", "completion", "--install", "--shell", "zsh"]); + match args.command { + Some(Command::Completion { + install, + force, + shell, + }) => { + assert!(install); + assert!(!force); + assert_eq!(shell.as_deref(), Some("zsh")); + } + other => panic!("expected completion, got {other:?}"), + } + } + + #[test] + fn completion_install_plan_puts_zsh_on_fpath() { + let env = usage::install::Env::from_process().with("HOME", "/tmp/crabcode-test-home"); + let plan = Args::completion_install_plan(usage::complete::Shell::Zsh, &env).expect("plan"); + assert!( + plan.path.ends_with("zsh/site-functions/_crabcode"), + "{}", + plan.path.display() + ); + match plan.loading { + usage::install::Loading::Manual { line, file, .. } => { + assert!(line.contains("fpath"), "{line}"); + assert!(file.contains(".zshrc"), "{file}"); + } + _ => panic!("expected zsh fpath instruction"), + } + } + + #[test] + fn upsert_zsh_hook_strips_pasted_script_and_is_idempotent() { + let pasted = "alias cc=crabcode\n#compdef crabcode\n# @generated by usage-argv for `crabcode __complete_word__ --shell zsh`\n_crabcode() {\n true\n}\ncompdef _crabcode 'crabcode'\n\n# >>> grok installer >>>\nfpath=(~/.grok/completions/zsh $fpath)\n# <<< grok installer <<<\n"; + let script = Path::new("/Users/carlo/.local/share/zsh/site-functions/_crabcode"); + let once = upsert_marked_block(pasted, &zsh_installer_block(script)); + assert!(!once.contains("#compdef crabcode")); + assert!(once.contains(CRABCODE_RC_BEGIN)); + assert!(once.contains("source ~/.local/share/zsh/site-functions/_crabcode")); + assert!(!once.contains("compinit")); + assert!(once.contains("# >>> grok installer >>>")); + assert!( + once.find("# >>> grok installer >>>").unwrap() < once.find(CRABCODE_RC_BEGIN).unwrap() + ); + assert_eq!( + once, + upsert_marked_block(&once, &zsh_installer_block(script)) + ); + } + + #[test] + fn zsh_compdef_includes_cc_alias() { + let aliases = crabcode_aliases_from_rc("alias cc=\"crabcode\"\nalias lg=lazygit\n"); + assert_eq!(aliases, vec!["cc".to_string()]); + let generated = Args::completion_script(usage::complete::Shell::Zsh); + let script = with_zsh_compdef_names(&generated, &aliases); + assert!(script.starts_with("#compdef crabcode cc\n")); + assert!(script.contains("compdef _crabcode 'crabcode' 'cc'")); + assert!(!script.contains(r#"__usage_input="${__usage_input}-""#)); + } + + #[test] + fn parses_jobs_logs_defaults() { + let args = parse_args(&["crabcode", "jobs", "logs", "job_01"]); + + match args.command { + Some(Command::Jobs { + command: JobsCommand::Logs { id, follow, tail }, + }) => { + assert_eq!(id, "job_01"); + assert!(!follow); + assert_eq!(tail, 200); + } + other => panic!("expected jobs logs, got {other:?}"), + } + } + + #[test] + fn parses_jobs_stop_all_and_maintenance_run() { + let args = parse_args(&["crabcode", "jobs", "stop-all", "--all"]); + match args.command { + Some(Command::Jobs { + command: JobsCommand::StopAll { all }, + }) => assert!(all), + other => panic!("expected jobs stop-all, got {other:?}"), + } + + let args = parse_args(&["crabcode", "maintenance", "run", "--dry-run"]); + match args.command { + Some(Command::Maintenance { + command: MaintenanceCommand::Run { only, dry_run }, + }) => { + assert!(only.is_none()); + assert!(dry_run); + } + other => panic!("expected maintenance run, got {other:?}"), + } + } + #[test] fn parses_print_attach_flag() { - let args = Args::try_parse_from([ + let args = parse_args(&[ "crabcode", "-p", "--attach", "devbox", "continue", "the", "refactor", - ]) - .unwrap(); + ]); assert!(args.print_mode); assert_eq!(args.attach.as_deref(), Some("devbox")); @@ -1390,15 +1782,14 @@ mod tests { #[test] fn double_dash_keeps_model_like_tokens_in_prompt() { - let args = Args::try_parse_from([ + let args = parse_args(&[ "crabcode", "-p", "hi", "--", "--model", "opencode-go/deepseek-v4-flash", - ]) - .unwrap(); + ]); assert_eq!( args.prompt, diff --git a/src/model/reasoning.rs b/src/model/reasoning.rs index 2964b547..3a288508 100644 --- a/src/model/reasoning.rs +++ b/src/model/reasoning.rs @@ -105,7 +105,7 @@ impl fmt::Display for ReasoningEffort { } impl FromStr for ReasoningEffort { - type Err = (); + type Err = &'static str; fn from_str(value: &str) -> Result { match normalize_effort_token(value).as_str() { @@ -116,7 +116,9 @@ impl FromStr for ReasoningEffort { "high" => Ok(Self::High), "xhigh" => Ok(Self::XHigh), "max" => Ok(Self::Max), - _ => Err(()), + _ => Err( + "reasoning effort must be one of none, minimal, low, medium, high, xhigh, or max", + ), } } }