From 95809312a8ecdc52f6965a19fd052540a83b98a1 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 30 Aug 2026 03:43:44 +0800 Subject: [PATCH 1/3] feat(cli): grok-style clap completions with tiny rc hook `crabcode completion ` prints a clap_complete script. `--install` writes it to the autoload path and a marked rc block appended after other installers. Zsh uses fpath + autoload + compdef so the script is not sourced at startup (parsed on first Tab). `#compdef` includes aliases discovered from `.zshrc` at install time. clap#6282: drop the unused prompt slot. --- README.md | 10 +- npm/README.md | 21 ++- src/completion.rs | 417 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 87 ++++------ 4 files changed, 470 insertions(+), 65 deletions(-) create mode 100644 src/completion.rs diff --git a/README.md b/README.md index 216bc4ca..b5bcc9c0 100644 --- a/README.md +++ b/README.md @@ -80,13 +80,15 @@ It works (almost) exactly like OpenCode. Just opens faster, with some intuitive ### Shell Completion -Generate a completion script for the current shell: - ```sh -crabcode completion >> ~/.zshrc +crabcode completion zsh --install ``` -`crabcode completion` generates Zsh completions when `$SHELL` ends in `zsh`; it generates Bash completions for all other shells. +Same for `bash`, `fish`, `elvish`, `powershell`. Restart the shell, then Tab. + +`--install` writes the script to the shell autoload path and a small marked block in your rc (zsh/bash/elvish/powershell). Zsh autoloads on first Tab (`fpath` + `compdef`, no `source` of the script at startup). Fish autoloads from `~/.config/fish/completions` with no rc edit. If `.zshrc` has `alias cc=crabcode` (or similar), that name is included in the zsh script. + +Without `--install`, the script is printed to stdout. ### Agent Types diff --git a/npm/README.md b/npm/README.md index 798ee9c4..b5bcc9c0 100644 --- a/npm/README.md +++ b/npm/README.md @@ -36,6 +36,15 @@ cargo install crabcode # or cargo (build from source) curl -sSL https://raw.githubusercontent.com/Blankeos/crabcode/main/install.sh | sh # or linux/macos (via curl) ``` +### Upgrade + +Detects how you installed (brew / npm / bun / cargo / install.sh) and upgrades in place: + +```sh +crabcode upgrade # latest +crabcode upgrade 0.0.12 # specific version +``` + ## Quick Start 1. Run crabcode: @@ -71,13 +80,15 @@ It works (almost) exactly like OpenCode. Just opens faster, with some intuitive ### Shell Completion -Generate a completion script for the current shell: - ```sh -crabcode completion >> ~/.zshrc +crabcode completion zsh --install ``` -`crabcode completion` generates Zsh completions when `$SHELL` ends in `zsh`; it generates Bash completions for all other shells. +Same for `bash`, `fish`, `elvish`, `powershell`. Restart the shell, then Tab. + +`--install` writes the script to the shell autoload path and a small marked block in your rc (zsh/bash/elvish/powershell). Zsh autoloads on first Tab (`fpath` + `compdef`, no `source` of the script at startup). Fish autoloads from `~/.config/fish/completions` with no rc edit. If `.zshrc` has `alias cc=crabcode` (or similar), that name is included in the zsh script. + +Without `--install`, the script is printed to stdout. ### Agent Types @@ -134,6 +145,8 @@ Like any benchmark, please take this with a grain of salt. I have a cherry-picke | πŸ”² opencode | 100% | 19/19 | 34.9s | 4612 | $0.0279 | | βš›οΈ codex | 100% | 19/19 | 33.7s | 36888 | $0.3506 | +CLI startup / first-frame / idle-CPU vs peers (hyperfine + PTY): see **[PERF.md](PERF.md)** (`just bench-perf`). + ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/src/completion.rs b/src/completion.rs new file mode 100644 index 00000000..f19e84aa --- /dev/null +++ b/src/completion.rs @@ -0,0 +1,417 @@ +//! `crabcode completion ` β€” clap_complete scripts, grok-style install. + +use anyhow::{Context, Result}; +use clap::CommandFactory; +use clap_complete::{generate, shells::Shell}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +const RC_BEGIN: &str = "# >>> crabcode installer >>>"; +const RC_END: &str = "# <<< crabcode installer <<<"; + +pub fn generate_script(shell: Shell) -> Vec { + let mut command = crate::Args::command(); + let mut output = Vec::new(); + generate(shell, &mut command, "crabcode", &mut output); + if shell == Shell::Zsh { + let raw = String::from_utf8(output).expect("clap_complete is UTF-8"); + fix_zsh_root_prompt_positional(&raw).into_bytes() + } else { + output + } +} + +pub fn run(shell: Shell, install: bool) -> Result<()> { + if install { + install_completion(shell) + } else { + std::io::stdout().write_all(&generate_script(shell))?; + Ok(()) + } +} + +fn install_completion(shell: Shell) -> Result<()> { + let script_path = script_path(shell)?; + if let Some(parent) = script_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } + + let mut script = generate_script(shell); + let aliases = if shell == Shell::Zsh { + let rc = std::fs::read_to_string(resolve_existing_path(&zshrc_path())).unwrap_or_default(); + let aliases = crabcode_aliases_from_rc(&rc); + script = with_zsh_compdef_names(&String::from_utf8(script).expect("UTF-8"), &aliases) + .into_bytes(); + aliases + } else { + Vec::new() + }; + write_atomic(&script_path, &script)?; + println!("script {}", display_home_path(&script_path)); + + if let Some(hook) = ensure_shell_hook(shell, &script_path, &aliases)? { + println!("hook {}", display_home_path(&hook)); + } + println!("Restart the shell and press Tab."); + Ok(()) +} + +fn script_path(shell: Shell) -> Result { + let home = home_dir(); + Ok(match shell { + Shell::Bash => home.join(".local/share/bash-completion/completions/crabcode"), + Shell::Zsh => home.join(".local/share/zsh/site-functions/_crabcode"), + Shell::Fish => fish_config_dir().join("completions/crabcode.fish"), + Shell::Elvish => home.join(".config/elvish/lib/crabcode.elv"), + Shell::PowerShell => home.join(".local/share/powershell/Completions/crabcode.ps1"), + _ => anyhow::bail!("unsupported shell {shell}"), + }) +} + +fn ensure_shell_hook(shell: Shell, script: &Path, aliases: &[String]) -> Result> { + match shell { + Shell::Zsh => { + let path = resolve_existing_path(&zshrc_path()); + upsert_rc(&path, &zsh_installer_block(script, aliases))?; + Ok(Some(path)) + } + Shell::Bash => { + let path = resolve_existing_path(&home_dir().join(".bashrc")); + upsert_rc( + &path, + &format!( + "{RC_BEGIN}\n[[ -r {script} ]] && source {script}\n{RC_END}\n", + script = display_home_path(script) + ), + )?; + Ok(Some(path)) + } + Shell::Fish => Ok(None), + Shell::Elvish => { + let path = home_dir().join(".config/elvish/rc.elv"); + upsert_rc( + &path, + &format!( + "{RC_BEGIN}\neval (slurp <{script})\n{RC_END}\n", + script = display_home_path(script) + ), + )?; + Ok(Some(path)) + } + Shell::PowerShell => { + let path = powershell_profile_path(); + upsert_rc( + &path, + &format!( + "{RC_BEGIN}\n. {script}\n{RC_END}\n", + script = display_home_path(script) + ), + )?; + Ok(Some(path)) + } + _ => Ok(None), + } +} + +fn zsh_installer_block(script: &Path, aliases: &[String]) -> String { + let dir = script.parent().map_or_else( + || "~/.local/share/zsh/site-functions".to_string(), + display_home_path, + ); + let mut names = vec!["crabcode".to_string()]; + names.extend(aliases.iter().cloned()); + let names = names.join(" "); + format!( + "{RC_BEGIN}\nfpath=({dir} $fpath)\n(( $+functions[compdef] )) && autoload -Uz _crabcode && compdef _crabcode {names}\n{RC_END}\n" + ) +} + +fn crabcode_aliases_from_rc(rc: &str) -> Vec { + let mut names = Vec::new(); + for line in rc.lines() { + let line = line.trim(); + if line.starts_with('#') { + continue; + } + 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.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 upsert_rc(path: &Path, block: &str) -> Result<()> { + let existing = std::fs::read_to_string(path).unwrap_or_default(); + let next = upsert_marked_block(&existing, block); + if next != existing { + write_atomic(path, next.as_bytes())?; + } + Ok(()) +} + +fn upsert_marked_block(rc: &str, block: &str) -> String { + let rc = strip_inline_usage_completion(rc); + let rc = strip_marked_block(&rc); + let mut out = rc.trim_end().to_string(); + if !out.is_empty() { + out.push('\n'); + out.push('\n'); + } + out.push_str(block.trim_end()); + out.push('\n'); + out +} + +fn strip_marked_block(rc: &str) -> String { + let Some(start) = rc.find(RC_BEGIN) else { + return rc.to_string(); + }; + let Some(end_rel) = rc[start..].find(RC_END) else { + return rc.to_string(); + }; + let end = start + end_rel + 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 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 +} + +/// clap_complete + optional `[PROMPT]` puts the subcommand in `$line[2]` (clap#6282). +fn fix_zsh_root_prompt_positional(script: &str) -> String { + let mut out = String::with_capacity(script.len()); + script + .lines() + .filter(|line| !line.starts_with("'::prompt -- ")) + .for_each(|line| { + out.push_str(line); + out.push('\n'); + }); + for (from, to) in [ + ( + r#"words=($line[2] "${words[@]}")"#, + r#"words=($line[1] "${words[@]}")"#, + ), + ( + r#"curcontext="${curcontext%:*:*}:crabcode-command-$line[2]:""#, + r#"curcontext="${curcontext%:*:*}:crabcode-command-$line[1]:""#, + ), + (r#"case $line[2] in"#, r#"case $line[1] in"#), + ] { + out = out.replacen(from, to, 1); + } + out +} + +fn home_dir() -> PathBuf { + dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) +} + +fn display_home_path(path: &Path) -> String { + path.strip_prefix(home_dir()) + .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 fish_config_dir() -> PathBuf { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home_dir().join(".config")) + .join("fish") +} + +fn powershell_profile_path() -> PathBuf { + if cfg!(windows) { + home_dir().join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1") + } else { + home_dir().join(".config/powershell/Microsoft.PowerShell_profile.ps1") + } +} + +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 write_atomic(path: &Path, contents: &[u8]) -> 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(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zsh_script_drops_prompt_slot() { + let raw = String::from_utf8(generate_script(Shell::Zsh)).unwrap(); + assert!(raw.starts_with("#compdef crabcode")); + assert!( + !raw.contains("::prompt"), + "prompt positional must not appear" + ); + assert!( + !raw.contains("$line[2]"), + "root dispatch must be on $line[1]" + ); + assert!(raw.contains("_crabcode_commands") || raw.contains("crabcode-command-$line[1]")); + } + + #[test] + fn zsh_hook_appends_after_grok_and_is_idempotent() { + let script = Path::new("/Users/carlo/.local/share/zsh/site-functions/_crabcode"); + let rc = "alias cc=crabcode\n# >>> grok installer >>>\nfpath=(~/.grok/completions/zsh $fpath)\nautoload -Uz compinit && compinit -C\n# <<< grok installer <<<\n"; + let aliases = crabcode_aliases_from_rc(rc); + let once = upsert_marked_block(rc, &zsh_installer_block(script, &aliases)); + let grok_end = once.find("# <<< grok installer <<<").unwrap(); + let crab = once.find(RC_BEGIN).unwrap(); + assert!(crab > grok_end, "append after grok; do not nest"); + assert!(once.contains("fpath=(~/.local/share/zsh/site-functions $fpath)")); + assert!(once.contains("autoload -Uz _crabcode")); + assert!(once.contains("compdef _crabcode crabcode cc")); + assert!(!once.contains("source ")); + assert_eq!( + once, + upsert_marked_block(&once, &zsh_installer_block(script, &aliases)) + ); + assert_eq!(once.matches("autoload -Uz compinit").count(), 1); + } + + #[test] + fn scans_zshrc_aliases_without_hardcoding_cc() { + let rc = concat!( + "alias cc=\"crabcode\"\n", + "alias crc='crabcode'\n", + "# alias nope=crabcode\n", + "alias lg=lazygit\n", + "alias gcc=gcc\n", + ); + assert_eq!( + crabcode_aliases_from_rc(rc), + vec!["cc".to_string(), "crc".to_string()] + ); + assert!(crabcode_aliases_from_rc("").is_empty()); + let script = with_zsh_compdef_names( + "#compdef crabcode\n compdef _crabcode crabcode\n", + &crabcode_aliases_from_rc(rc), + ); + assert!(script.starts_with("#compdef crabcode cc crc\n")); + } +} diff --git a/src/main.rs b/src/main.rs index c48eefb9..e1e4a59c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod app; mod auth; mod autocomplete; mod command; +mod completion; mod config; mod herdr; mod jobs; @@ -69,7 +70,7 @@ use crate::toast::{Toast, ToastManager}; use anyhow::{Context, Result}; use app::App; use clap::{CommandFactory, Parser, Subcommand}; -use clap_complete::{generate, shells}; +use clap_complete::Shell; use ratatui::crossterm::{ event::{ self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, @@ -691,7 +692,7 @@ pub fn get_toast_manager() -> &'static Mutex { #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] -struct Args { +pub(crate) struct Args { #[command(subcommand)] command: Option, @@ -752,8 +753,15 @@ enum Command { cwd: Option, }, - /// Generate shell completion script - Completion, + /// Generate or install shell completions + Completion { + /// Target shell + #[arg(value_enum)] + shell: Shell, + /// Write the script where the shell autoloads it + #[arg(long)] + install: bool, + }, /// Host the current workspace for browser and CLI clients Serve { @@ -888,24 +896,6 @@ enum MaintenanceCommand { List, } -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 { - match shell.and_then(|shell| shell.rsplit('/').next()) { - Some("zsh") => shells::Shell::Zsh, - _ => shells::Shell::Bash, - } -} - -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 root_help() -> Result { let mut command = Args::command(); let mut output = Vec::new(); @@ -913,12 +903,6 @@ fn root_help() -> Result { Ok(String::from_utf8(output).expect("Clap help is valid UTF-8")) } -fn print_completion() -> Result<()> { - let shell = completion_shell(std::env::var("SHELL").ok().as_deref()); - io::stdout().write_all(&generate_completion(shell))?; - Ok(()) -} - fn merge_prompt_with_stdin(prompt: &str, stdin: &str) -> String { if stdin.trim().is_empty() { return prompt.to_string(); @@ -967,12 +951,6 @@ fn launch_remote_serve(request: app::RemoteLaunchRequest) -> Result<()> { #[tokio::main] async fn main() -> Result<()> { - let raw_args: Vec = std::env::args().skip(1).collect(); - if is_completion_help(&raw_args) { - println!("{}", root_help()?); - return Ok(()); - } - let args = Args::parse(); crate::logging::set_enabled(args.emit_logs); crate::aisdk::log::set_logger(|msg| { @@ -1003,8 +981,8 @@ async fn main() -> Result<()> { Some(Command::Acp { cwd }) => { return crate::acp::run(cwd.clone()).await; } - Some(Command::Completion) => { - print_completion()?; + Some(Command::Completion { shell, install }) => { + crate::completion::run(*shell, *install)?; return Ok(()); } Some(Command::Serve { bind, pair_code }) => { @@ -1327,9 +1305,7 @@ 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(crate::completion::generate_script(Shell::Bash)).unwrap(); assert!(script.contains("_crabcode")); assert!(script.contains("complete")); assert!(script.contains("crabcode")); @@ -1337,32 +1313,29 @@ mod tests { #[test] fn generates_zsh_completion() { - let script = - String::from_utf8(generate_completion(completion_shell(Some("/bin/zsh")))).unwrap(); - + let script = String::from_utf8(crate::completion::generate_script(Shell::Zsh)).unwrap(); assert!(script.starts_with("#compdef crabcode")); assert!(script.contains("_crabcode")); } #[test] - fn completion_help_uses_root_help() { - assert!(is_completion_help(&[ - "completion".to_string(), - "--help".to_string() - ])); - assert!(is_completion_help(&[ - "completion".to_string(), - "-h".to_string() - ])); - assert!(!is_completion_help(&["completion".to_string()])); - assert!(!is_completion_help(&[ - "serve".to_string(), - "--help".to_string() - ])); + fn parses_completion_shell_and_install() { + let args = Args::try_parse_from(["crabcode", "completion", "zsh", "--install"]).unwrap(); + match args.command { + Some(Command::Completion { shell, install }) => { + assert_eq!(shell, Shell::Zsh); + assert!(install); + } + other => panic!("expected completion, got {other:?}"), + } + } + #[test] + fn root_help_lists_completion() { let help = root_help().unwrap(); assert!(help.contains("Usage: crabcode")); - assert!(help.contains("completion Generate shell completion script")); + assert!(help.contains("completion")); + assert!(help.contains("Generate or install shell completions")); assert!( help.contains("serve Host the current workspace for browser and CLI clients") ); From 40a5d4e148eae2a7b87668ecd133423516b84d11 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 30 Aug 2026 05:02:34 +0800 Subject: [PATCH 2/3] feat(completion): XDG paths, $SHELL default, bash rc fallback, pwsh $HOME dotsources, strip old inline dumps - Default shell from `$SHELL` env var (zsh/fish/elvish/pwsh, else bash) - Script paths use `$XDG_DATA_HOME` / `$XDG_CONFIG_HOME` when set - Bash falls back to `.bash_profile` when `.bashrc` is absent - PowerShell hooks dotsource `"$HOME/..."` instead of bare `~` - Strip old inline completion dumps from rc before installing marked block - Add CI workflow for rustfmt + completion tests on PRs - Add PR review doc - Update README and npm/README with XDG, $SHELL default, and re-run note - 19 completion tests pass chore(ci): drop PR Actions workflow Too expensive, and cargo test hits build.rs requiring remote-client assets. Keep local completion tests; merge confidence stays 4.5/5. --- PR_REVIEW_20260830_043010.md | 99 ++++++++++++ README.md | 6 +- npm/README.md | 6 +- src/completion.rs | 284 ++++++++++++++++++++++++++++++++--- src/main.rs | 23 ++- 5 files changed, 386 insertions(+), 32 deletions(-) create mode 100644 PR_REVIEW_20260830_043010.md diff --git a/PR_REVIEW_20260830_043010.md b/PR_REVIEW_20260830_043010.md new file mode 100644 index 00000000..bfb4892e --- /dev/null +++ b/PR_REVIEW_20260830_043010.md @@ -0,0 +1,99 @@ +# PR Review β€” #37 feat(cli): grok-style clap completions with tiny rc hook + +**Branch:** `feat/clap-completion-install` β†’ `main` +**PR:** https://github.com/Blankeos/crabcode/pull/37 +**HEAD:** `56b69cc` + drop `.github/workflows/pr.yml` (Actions too expensive; remote-client `build.rs` gate) +**Merge confidence:** **4.5 / 5** + +--- + +## Body (GitHub comment) + +Grok-style `crabcode completion [shell]` (+ `--install`). Clap prints the script; `--install` writes the autoload file and a marked rc block (zsh: `fpath` + `autoload` + `compdef`, no startup `source`). Fish is file-only. Zsh `#compdef` picks up `alias foo=crabcode` from `.zshrc` at install time. clap#6282 workaround drops the unused `[PROMPT]` slot so nested commands complete on `$line[1]`. + +Bare `crabcode completion` still prints (shell from `$SHELL`). `--install` strips an old `>> ~/.zshrc` dump. No DB/config migrations. + +Verified locally: 19/19 completion tests (incl. rc stripper, grok-hook idempotence, `$HOME` pwsh, zsh `-n` + command-list smoke). `cargo fmt --check` clean. **No PR Actions** β€” `build.rs` needs `just remote-client-build`; skipping CI on purpose. + +--- + +## Diff summary + +| File | Change | +| --- | --- | +| `src/completion.rs` | generate / install / rc upsert / alias scan / clap#6282 patch; XDG; bash `.bashrc` else `.bash_profile`; pwsh `"$HOME/..."`; strip + hook tests | +| `src/main.rs` | `Completion { shell: Option, install }`; default from `$SHELL` | +| `README.md` / `npm/README.md` | `--install` docs, XDG, `$SHELL` default, re-run note for old dumps | + +`.github/workflows/pr.yml` was added then **removed** (cost + `remote-client/dist` missing in CI). + +--- + +## Regressions? + +**No remaining CLI break.** Previously `completion` required `` (`exit 2`); now `shell` is optional and defaults from `$SHELL` (zsh/fish/elvish/pwsh, else bash). Tab still lists shells for `crabcode completion `. + +Remaining (non-blocking): + +1. Old inline dumps in `.zshrc` are only stripped on `--install` (documented). Stripper is unit-tested (dump, usage-argv header, missing end marker = no-op, installer hook left alone, strip-then-append after grok). +2. Zsh hook skips `compdef` if `compinit` has not run yet (`(( $+functions[compdef] ))`). Deliberate; block is appended at EOF. +3. PowerShell completions path is still XDG-ish, not the usual pwsh profile completions dir. Hook now dotsources `"$HOME/..."` (tested). +4. No `--uninstall`. CHANGELOG is git-cliff at tag time β€” don’t invent Unreleased. + +TUI / agent / persistence paths are untouched. + +--- + +## Migrations? + +**No.** + +- No SQLite / `prefs` schema change +- No `auth.json` format change +- No `crabcode.json(c)` contract change + +`--install` mutates shell rc + autoload files (user-config install, not app state). Idempotent via markers. + +--- + +## Checks run (non-mutating) + +| Check | Result | +| --- | --- | +| `cargo test --bin crabcode -- completion` | **19 passed** | +| `cargo fmt --check` | **clean** | +| `crabcode completion` (no shell) | parses; prints from `$SHELL` | +| `crabcode completion zsh` | `#compdef crabcode`; `$line[1]`; no `::prompt` / `$line[2]` | +| `--install` | **not run** (mutates rc) | +| GitHub Actions PR CI | **skipped on purpose** | + +--- + +## Checklist before merge + +- [x] Default `shell` from `$SHELL` so bare `crabcode completion` still prints +- [x] Tests for `strip_inline_usage_completion` +- [x] HOME-independent grok-hook test +- [x] zsh smoke: parse (`zsh -n`) + command list includes `completion` +- [x] `--install` idempotent after grok block (unit-tested) +- [x] PowerShell `"$HOME/..."` +- [x] XDG data/config; bash `.bashrc` else `.bash_profile` +- [x] README: re-run `--install` if you used `>> ~/.zshrc` +- [x] No PR Actions (too expensive; `build.rs` requires remote-client assets) +- [ ] CHANGELOG: git-cliff on next `just tag` β€” no Unreleased section +- [ ] No migration / prefs / auth follow-up + +--- + +## Confidence bumps + +Was **3.5 / 5**. Now **4.5 / 5**. + +| Done | Score | +| --- | --- | +| Unit-test `strip_inline_usage_completion` + HOME-independent grok-hook test | 4.0 | +| PowerShell `$HOME` + README re-run note | 4.25 | +| `$SHELL` default **and** zsh Tab/parse smoke | **4.5** | +| rust tests in CI on PRs | skipped (cost + remote-client `build.rs`) β€” would have been 4.75 | + +**5/5** would need a real `compadd` / `compgen` integration test or a dry-run `--install` that never touches `$HOME`. Not blocking if you dogfood zsh after merge. diff --git a/README.md b/README.md index b5bcc9c0..481bd606 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,11 @@ crabcode completion zsh --install Same for `bash`, `fish`, `elvish`, `powershell`. Restart the shell, then Tab. -`--install` writes the script to the shell autoload path and a small marked block in your rc (zsh/bash/elvish/powershell). Zsh autoloads on first Tab (`fpath` + `compdef`, no `source` of the script at startup). Fish autoloads from `~/.config/fish/completions` with no rc edit. If `.zshrc` has `alias cc=crabcode` (or similar), that name is included in the zsh script. +`--install` writes the script to the shell autoload path (`$XDG_DATA_HOME` / `$XDG_CONFIG_HOME` when set) and a small marked block in your rc (zsh/bash/elvish/powershell). Zsh autoloads on first Tab (`fpath` + `compdef`, no `source` of the script at startup). Fish autoloads from `~/.config/fish/completions` with no rc edit. Bash uses `~/.bashrc` if present, otherwise `~/.bash_profile`. PowerShell dotsources `"$HOME/..."`. If `.zshrc` has `alias cc=crabcode` (or similar), that name is included in the zsh script. -Without `--install`, the script is printed to stdout. +Without `--install`, the script is printed to stdout. `crabcode completion` with no shell uses `$SHELL` (zsh/fish/elvish/pwsh, else bash). + +If you previously ran `crabcode completion >> ~/.zshrc`, re-run `--install` so that dump is stripped and replaced by the autoload hook. ### Agent Types diff --git a/npm/README.md b/npm/README.md index b5bcc9c0..481bd606 100644 --- a/npm/README.md +++ b/npm/README.md @@ -86,9 +86,11 @@ crabcode completion zsh --install Same for `bash`, `fish`, `elvish`, `powershell`. Restart the shell, then Tab. -`--install` writes the script to the shell autoload path and a small marked block in your rc (zsh/bash/elvish/powershell). Zsh autoloads on first Tab (`fpath` + `compdef`, no `source` of the script at startup). Fish autoloads from `~/.config/fish/completions` with no rc edit. If `.zshrc` has `alias cc=crabcode` (or similar), that name is included in the zsh script. +`--install` writes the script to the shell autoload path (`$XDG_DATA_HOME` / `$XDG_CONFIG_HOME` when set) and a small marked block in your rc (zsh/bash/elvish/powershell). Zsh autoloads on first Tab (`fpath` + `compdef`, no `source` of the script at startup). Fish autoloads from `~/.config/fish/completions` with no rc edit. Bash uses `~/.bashrc` if present, otherwise `~/.bash_profile`. PowerShell dotsources `"$HOME/..."`. If `.zshrc` has `alias cc=crabcode` (or similar), that name is included in the zsh script. -Without `--install`, the script is printed to stdout. +Without `--install`, the script is printed to stdout. `crabcode completion` with no shell uses `$SHELL` (zsh/fish/elvish/pwsh, else bash). + +If you previously ran `crabcode completion >> ~/.zshrc`, re-run `--install` so that dump is stripped and replaced by the autoload hook. ### Agent Types diff --git a/src/completion.rs b/src/completion.rs index f19e84aa..ee377f3c 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -9,6 +9,34 @@ use std::path::{Path, PathBuf}; const RC_BEGIN: &str = "# >>> crabcode installer >>>"; const RC_END: &str = "# <<< crabcode installer <<<"; +pub fn default_shell() -> Shell { + std::env::var("SHELL") + .ok() + .as_deref() + .map(shell_from_path) + .unwrap_or_else(|| { + if cfg!(windows) { + Shell::PowerShell + } else { + Shell::Bash + } + }) +} + +fn shell_from_path(shell: &str) -> Shell { + match Path::new(shell) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(shell) + { + "zsh" => Shell::Zsh, + "fish" => Shell::Fish, + "elvish" => Shell::Elvish, + "pwsh" | "powershell" => Shell::PowerShell, + _ => Shell::Bash, + } +} + pub fn generate_script(shell: Shell) -> Vec { let mut command = crate::Args::command(); let mut output = Vec::new(); @@ -58,13 +86,13 @@ fn install_completion(shell: Shell) -> Result<()> { } fn script_path(shell: Shell) -> Result { - let home = home_dir(); + let data = data_home(); Ok(match shell { - Shell::Bash => home.join(".local/share/bash-completion/completions/crabcode"), - Shell::Zsh => home.join(".local/share/zsh/site-functions/_crabcode"), + Shell::Bash => data.join("bash-completion/completions/crabcode"), + Shell::Zsh => data.join("zsh/site-functions/_crabcode"), Shell::Fish => fish_config_dir().join("completions/crabcode.fish"), - Shell::Elvish => home.join(".config/elvish/lib/crabcode.elv"), - Shell::PowerShell => home.join(".local/share/powershell/Completions/crabcode.ps1"), + Shell::Elvish => config_home().join("elvish/lib/crabcode.elv"), + Shell::PowerShell => data.join("powershell/Completions/crabcode.ps1"), _ => anyhow::bail!("unsupported shell {shell}"), }) } @@ -77,7 +105,7 @@ fn ensure_shell_hook(shell: Shell, script: &Path, aliases: &[String]) -> Result< Ok(Some(path)) } Shell::Bash => { - let path = resolve_existing_path(&home_dir().join(".bashrc")); + let path = resolve_existing_path(&bash_rc_path()); upsert_rc( &path, &format!( @@ -89,7 +117,7 @@ fn ensure_shell_hook(shell: Shell, script: &Path, aliases: &[String]) -> Result< } Shell::Fish => Ok(None), Shell::Elvish => { - let path = home_dir().join(".config/elvish/rc.elv"); + let path = config_home().join("elvish/rc.elv"); upsert_rc( &path, &format!( @@ -101,22 +129,29 @@ fn ensure_shell_hook(shell: Shell, script: &Path, aliases: &[String]) -> Result< } Shell::PowerShell => { let path = powershell_profile_path(); - upsert_rc( - &path, - &format!( - "{RC_BEGIN}\n. {script}\n{RC_END}\n", - script = display_home_path(script) - ), - )?; + upsert_rc(&path, &powershell_installer_block(script))?; Ok(Some(path)) } _ => Ok(None), } } +fn powershell_installer_block(script: &Path) -> String { + format!("{RC_BEGIN}\n{}\n{RC_END}\n", powershell_source_line(script)) +} + +fn powershell_source_line(script: &Path) -> String { + let rendered = display_home_path(script); + if let Some(rest) = rendered.strip_prefix("~/") { + format!(". \"$HOME/{rest}\"") + } else { + format!(". \"{}\"", rendered.replace('"', "`\"")) + } +} + fn zsh_installer_block(script: &Path, aliases: &[String]) -> String { let dir = script.parent().map_or_else( - || "~/.local/share/zsh/site-functions".to_string(), + || display_home_path(&data_home().join("zsh/site-functions")), display_home_path, ); let mut names = vec!["crabcode".to_string()]; @@ -294,6 +329,21 @@ fn home_dir() -> PathBuf { dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) } +fn xdg_dir(var: &str, fallback: impl FnOnce() -> PathBuf) -> PathBuf { + std::env::var_os(var) + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(fallback) +} + +fn data_home() -> PathBuf { + xdg_dir("XDG_DATA_HOME", || home_dir().join(".local/share")) +} + +fn config_home() -> PathBuf { + xdg_dir("XDG_CONFIG_HOME", || home_dir().join(".config")) +} + fn display_home_path(path: &Path) -> String { path.strip_prefix(home_dir()) .map(|rest| format!("~/{}", rest.display())) @@ -307,18 +357,26 @@ fn zshrc_path() -> PathBuf { .join(".zshrc") } +fn bash_rc_path() -> PathBuf { + let home = home_dir(); + let bashrc = home.join(".bashrc"); + let profile = home.join(".bash_profile"); + if bashrc.exists() || !profile.exists() { + bashrc + } else { + profile + } +} + fn fish_config_dir() -> PathBuf { - std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| home_dir().join(".config")) - .join("fish") + config_home().join("fish") } fn powershell_profile_path() -> PathBuf { if cfg!(windows) { home_dir().join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1") } else { - home_dir().join(".config/powershell/Microsoft.PowerShell_profile.ps1") + config_home().join("powershell/Microsoft.PowerShell_profile.ps1") } } @@ -376,24 +434,202 @@ mod tests { #[test] fn zsh_hook_appends_after_grok_and_is_idempotent() { - let script = Path::new("/Users/carlo/.local/share/zsh/site-functions/_crabcode"); + let script = home_dir().join(".local/share/zsh/site-functions/_crabcode"); + let dir = display_home_path(script.parent().unwrap()); let rc = "alias cc=crabcode\n# >>> grok installer >>>\nfpath=(~/.grok/completions/zsh $fpath)\nautoload -Uz compinit && compinit -C\n# <<< grok installer <<<\n"; let aliases = crabcode_aliases_from_rc(rc); - let once = upsert_marked_block(rc, &zsh_installer_block(script, &aliases)); + let once = upsert_marked_block(rc, &zsh_installer_block(&script, &aliases)); let grok_end = once.find("# <<< grok installer <<<").unwrap(); let crab = once.find(RC_BEGIN).unwrap(); assert!(crab > grok_end, "append after grok; do not nest"); - assert!(once.contains("fpath=(~/.local/share/zsh/site-functions $fpath)")); + assert!(once.contains(&format!("fpath=({dir} $fpath)"))); assert!(once.contains("autoload -Uz _crabcode")); assert!(once.contains("compdef _crabcode crabcode cc")); assert!(!once.contains("source ")); assert_eq!( once, - upsert_marked_block(&once, &zsh_installer_block(script, &aliases)) + upsert_marked_block(&once, &zsh_installer_block(&script, &aliases)) ); assert_eq!(once.matches("autoload -Uz compinit").count(), 1); } + fn old_inline_dump() -> String { + concat!( + "#compdef crabcode\n", + "\n", + "_crabcode() {\n", + " echo dummy\n", + "}\n", + "\n", + "if [ \"$funcstack[1]\" = \"_crabcode\" ]; then\n", + " _crabcode \"$@\"\n", + "else\n", + " compdef _crabcode crabcode\n", + "fi\n", + ) + .to_string() + } + + #[test] + fn strip_inline_usage_completion_removes_old_zshrc_dump() { + let rc = format!( + "export KEEP_BEFORE=1\n\n{}\nexport KEEP_AFTER=1\n", + old_inline_dump() + ); + let stripped = strip_inline_usage_completion(&rc); + assert!(stripped.contains("export KEEP_BEFORE=1")); + assert!(stripped.contains("export KEEP_AFTER=1")); + assert!(!stripped.contains("#compdef crabcode")); + assert!(!stripped.contains("compdef _crabcode")); + assert!(!stripped.contains("_crabcode()")); + } + + #[test] + fn strip_inline_usage_completion_removes_usage_argv_header() { + let rc = concat!( + "export KEEP=1\n", + "# @generated by usage-argv for `crabcode`.\n", + "_crabcode() { : }\n", + "compdef _crabcode crabcode\n", + "export AFTER=1\n", + ); + let stripped = strip_inline_usage_completion(rc); + assert!(stripped.contains("export KEEP=1")); + assert!(stripped.contains("export AFTER=1")); + assert!(!stripped.contains("usage-argv")); + assert!(!stripped.contains("compdef _crabcode")); + } + + #[test] + fn strip_inline_usage_completion_is_noop_without_end_marker() { + let rc = "export FOO=1\n#compdef crabcode\nexport BAR=2\ncompdef _git git\n"; + assert_eq!(strip_inline_usage_completion(rc), rc); + } + + #[test] + fn strip_inline_usage_completion_does_not_match_installer_hook() { + let rc = concat!( + "alias cc=crabcode\n", + "# >>> crabcode installer >>>\n", + "fpath=(~/.local/share/zsh/site-functions $fpath)\n", + "(( $+functions[compdef] )) && autoload -Uz _crabcode && compdef _crabcode crabcode cc\n", + "# <<< crabcode installer <<<\n", + ); + assert_eq!(strip_inline_usage_completion(rc), rc); + } + + #[test] + fn upsert_strips_old_dump_then_appends_after_grok() { + let script = home_dir().join(".local/share/zsh/site-functions/_crabcode"); + let rc = format!( + "export PATH=/usr/bin\n{}# >>> grok installer >>>\ncompinit\n# <<< grok installer <<<\n", + old_inline_dump() + ); + let once = upsert_marked_block(&rc, &zsh_installer_block(&script, &[])); + assert!(once.contains("export PATH=/usr/bin")); + assert!(once.contains("# >>> grok installer >>>")); + assert!(!once.contains("#compdef crabcode")); + let grok_end = once.find("# <<< grok installer <<<").unwrap(); + assert!(once.find(RC_BEGIN).unwrap() > grok_end); + } + + #[test] + fn powershell_hook_dotsources_home() { + let script = home_dir().join(".local/share/powershell/Completions/crabcode.ps1"); + let line = powershell_source_line(&script); + assert!( + line.starts_with(". \"$HOME/"), + "pwsh does not expand ~: {line}" + ); + assert!(line.contains("powershell/Completions/crabcode.ps1")); + let block = powershell_installer_block(&script); + assert!(block.contains(RC_BEGIN)); + assert!(block.contains(&line)); + } + + #[test] + fn default_shell_from_path() { + assert_eq!(shell_from_path("/bin/zsh"), Shell::Zsh); + assert_eq!(shell_from_path("/usr/bin/fish"), Shell::Fish); + assert_eq!(shell_from_path("/opt/homebrew/bin/elvish"), Shell::Elvish); + assert_eq!(shell_from_path("/usr/local/bin/pwsh"), Shell::PowerShell); + assert_eq!(shell_from_path("powershell"), Shell::PowerShell); + assert_eq!(shell_from_path("/bin/bash"), Shell::Bash); + assert_eq!(shell_from_path("/bin/sh"), Shell::Bash); + assert_eq!(shell_from_path(""), Shell::Bash); + } + + #[test] + fn zsh_completion_subcommand_offers_shells_after_flags() { + let script = String::from_utf8(generate_script(Shell::Zsh)).unwrap(); + let start = script + .find("(completion)\n") + .expect("completion subcommand case"); + let body = &script[start..]; + let end = body.find("\n;;").expect("end of completion case"); + let spec = &body[..end]; + let install = spec.find("'--install[").expect("--install flag"); + let shells = spec + .find("bash elvish fish powershell zsh") + .expect("shell values"); + assert!( + install < shells, + "empty Tab should offer flags before the shell positional" + ); + assert!(script.contains("'completion:Generate or install shell completions'")); + } + + #[test] + fn zsh_tab_smoke_lists_completion_and_parses() { + let Ok(which) = std::process::Command::new("zsh") + .args(["-c", "echo ok"]) + .output() + else { + return; + }; + if !which.status.success() { + return; + } + + let dir = std::env::temp_dir().join(format!("crabcode-comp-smoke-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("_crabcode"); + std::fs::write(&path, generate_script(Shell::Zsh)).unwrap(); + + let parse = std::process::Command::new("zsh") + .args(["-n", "--", path.to_str().unwrap()]) + .status() + .unwrap(); + assert!(parse.success(), "generated zsh script failed zsh -n"); + + let quoted = path.display().to_string().replace('\'', "'\\''"); + let output = std::process::Command::new("zsh") + .args([ + "--no-rcs", + "-c", + &format!( + "compdef() {{ : }}\nsource '{quoted}'\n_describe() {{ print -l -- \"${{commands[@]}}\" }}\n_crabcode_commands\n" + ), + ]) + .output() + .unwrap(); + let _ = std::fs::remove_dir_all(&dir); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "zsh smoke failed: {}\n{stdout}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + stdout.contains("completion:Generate or install shell completions"), + "Tab command list missing completion: {stdout}" + ); + assert!( + stdout.contains("models:List available models"), + "Tab command list missing models: {stdout}" + ); + } + #[test] fn scans_zshrc_aliases_without_hardcoding_cc() { let rc = concat!( diff --git a/src/main.rs b/src/main.rs index e1e4a59c..a0034048 100644 --- a/src/main.rs +++ b/src/main.rs @@ -755,9 +755,9 @@ enum Command { /// Generate or install shell completions Completion { - /// Target shell + /// Target shell. Defaults from `$SHELL`. #[arg(value_enum)] - shell: Shell, + shell: Option, /// Write the script where the shell autoloads it #[arg(long)] install: bool, @@ -982,7 +982,10 @@ async fn main() -> Result<()> { return crate::acp::run(cwd.clone()).await; } Some(Command::Completion { shell, install }) => { - crate::completion::run(*shell, *install)?; + crate::completion::run( + shell.unwrap_or_else(crate::completion::default_shell), + *install, + )?; return Ok(()); } Some(Command::Serve { bind, pair_code }) => { @@ -1323,13 +1326,25 @@ mod tests { let args = Args::try_parse_from(["crabcode", "completion", "zsh", "--install"]).unwrap(); match args.command { Some(Command::Completion { shell, install }) => { - assert_eq!(shell, Shell::Zsh); + assert_eq!(shell, Some(Shell::Zsh)); assert!(install); } other => panic!("expected completion, got {other:?}"), } } + #[test] + fn parses_completion_without_shell() { + let args = Args::try_parse_from(["crabcode", "completion"]).unwrap(); + match args.command { + Some(Command::Completion { shell, install }) => { + assert_eq!(shell, None); + assert!(!install); + } + other => panic!("expected completion, got {other:?}"), + } + } + #[test] fn root_help_lists_completion() { let help = root_help().unwrap(); From f843b9320e6734c86de83088424d854baca74f06 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 2 Sep 2026 03:42:27 +0800 Subject: [PATCH 3/3] fix(completion): consume trailing fi when stripping old zsh dumps clap_complete zsh scripts end with `compdef _crabcode` inside if/else/fi. The stripper treated `compdef` as the dump end and left a stray `fi` in .zshrc (`parse error near 'fi'`). Eat that closer (and following blanks). Drop the in-branch PR review doc. Tests now assert no leftover `fi` and `zsh -n` on a real clap dump. --- PR_REVIEW_20260830_043010.md | 99 ------------------------------------ src/completion.rs | 93 ++++++++++++++++++++++++++++++--- 2 files changed, 87 insertions(+), 105 deletions(-) delete mode 100644 PR_REVIEW_20260830_043010.md diff --git a/PR_REVIEW_20260830_043010.md b/PR_REVIEW_20260830_043010.md deleted file mode 100644 index bfb4892e..00000000 --- a/PR_REVIEW_20260830_043010.md +++ /dev/null @@ -1,99 +0,0 @@ -# PR Review β€” #37 feat(cli): grok-style clap completions with tiny rc hook - -**Branch:** `feat/clap-completion-install` β†’ `main` -**PR:** https://github.com/Blankeos/crabcode/pull/37 -**HEAD:** `56b69cc` + drop `.github/workflows/pr.yml` (Actions too expensive; remote-client `build.rs` gate) -**Merge confidence:** **4.5 / 5** - ---- - -## Body (GitHub comment) - -Grok-style `crabcode completion [shell]` (+ `--install`). Clap prints the script; `--install` writes the autoload file and a marked rc block (zsh: `fpath` + `autoload` + `compdef`, no startup `source`). Fish is file-only. Zsh `#compdef` picks up `alias foo=crabcode` from `.zshrc` at install time. clap#6282 workaround drops the unused `[PROMPT]` slot so nested commands complete on `$line[1]`. - -Bare `crabcode completion` still prints (shell from `$SHELL`). `--install` strips an old `>> ~/.zshrc` dump. No DB/config migrations. - -Verified locally: 19/19 completion tests (incl. rc stripper, grok-hook idempotence, `$HOME` pwsh, zsh `-n` + command-list smoke). `cargo fmt --check` clean. **No PR Actions** β€” `build.rs` needs `just remote-client-build`; skipping CI on purpose. - ---- - -## Diff summary - -| File | Change | -| --- | --- | -| `src/completion.rs` | generate / install / rc upsert / alias scan / clap#6282 patch; XDG; bash `.bashrc` else `.bash_profile`; pwsh `"$HOME/..."`; strip + hook tests | -| `src/main.rs` | `Completion { shell: Option, install }`; default from `$SHELL` | -| `README.md` / `npm/README.md` | `--install` docs, XDG, `$SHELL` default, re-run note for old dumps | - -`.github/workflows/pr.yml` was added then **removed** (cost + `remote-client/dist` missing in CI). - ---- - -## Regressions? - -**No remaining CLI break.** Previously `completion` required `` (`exit 2`); now `shell` is optional and defaults from `$SHELL` (zsh/fish/elvish/pwsh, else bash). Tab still lists shells for `crabcode completion `. - -Remaining (non-blocking): - -1. Old inline dumps in `.zshrc` are only stripped on `--install` (documented). Stripper is unit-tested (dump, usage-argv header, missing end marker = no-op, installer hook left alone, strip-then-append after grok). -2. Zsh hook skips `compdef` if `compinit` has not run yet (`(( $+functions[compdef] ))`). Deliberate; block is appended at EOF. -3. PowerShell completions path is still XDG-ish, not the usual pwsh profile completions dir. Hook now dotsources `"$HOME/..."` (tested). -4. No `--uninstall`. CHANGELOG is git-cliff at tag time β€” don’t invent Unreleased. - -TUI / agent / persistence paths are untouched. - ---- - -## Migrations? - -**No.** - -- No SQLite / `prefs` schema change -- No `auth.json` format change -- No `crabcode.json(c)` contract change - -`--install` mutates shell rc + autoload files (user-config install, not app state). Idempotent via markers. - ---- - -## Checks run (non-mutating) - -| Check | Result | -| --- | --- | -| `cargo test --bin crabcode -- completion` | **19 passed** | -| `cargo fmt --check` | **clean** | -| `crabcode completion` (no shell) | parses; prints from `$SHELL` | -| `crabcode completion zsh` | `#compdef crabcode`; `$line[1]`; no `::prompt` / `$line[2]` | -| `--install` | **not run** (mutates rc) | -| GitHub Actions PR CI | **skipped on purpose** | - ---- - -## Checklist before merge - -- [x] Default `shell` from `$SHELL` so bare `crabcode completion` still prints -- [x] Tests for `strip_inline_usage_completion` -- [x] HOME-independent grok-hook test -- [x] zsh smoke: parse (`zsh -n`) + command list includes `completion` -- [x] `--install` idempotent after grok block (unit-tested) -- [x] PowerShell `"$HOME/..."` -- [x] XDG data/config; bash `.bashrc` else `.bash_profile` -- [x] README: re-run `--install` if you used `>> ~/.zshrc` -- [x] No PR Actions (too expensive; `build.rs` requires remote-client assets) -- [ ] CHANGELOG: git-cliff on next `just tag` β€” no Unreleased section -- [ ] No migration / prefs / auth follow-up - ---- - -## Confidence bumps - -Was **3.5 / 5**. Now **4.5 / 5**. - -| Done | Score | -| --- | --- | -| Unit-test `strip_inline_usage_completion` + HOME-independent grok-hook test | 4.0 | -| PowerShell `$HOME` + README re-run note | 4.25 | -| `$SHELL` default **and** zsh Tab/parse smoke | **4.5** | -| rust tests in CI on PRs | skipped (cost + remote-client `build.rs`) β€” would have been 4.75 | - -**5/5** would need a real `compadd` / `compgen` integration test or a dry-run `--install` that never touches `$HOME`. Not blocking if you dogfood zsh after merge. diff --git a/src/completion.rs b/src/completion.rs index ee377f3c..d3ffcbee 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -270,6 +270,7 @@ fn strip_inline_usage_completion(rc: &str) -> String { let start = lines.iter().position(|line| { let trimmed = line.trim_start(); trimmed == "#compdef crabcode" + || trimmed.starts_with("#compdef crabcode ") || trimmed.starts_with("# @generated by usage-argv for `crabcode") }); let Some(start) = start else { @@ -285,12 +286,7 @@ fn strip_inline_usage_completion(rc: &str) -> String { 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..]); + kept.extend_from_slice(&lines[end + 1..][skip_dumped_compdef_tail(&lines[end + 1..])..]); } let mut out = kept.join("\n"); if rc.ends_with('\n') && !out.ends_with('\n') { @@ -299,6 +295,24 @@ fn strip_inline_usage_completion(rc: &str) -> String { out } +/// clap_complete wraps `compdef _crabcode` in `if/else/fi`. The `compdef` line is +/// the dump's end marker; skip the closing `fi` so `--install` does not leave a +/// parse error in `.zshrc`. +fn skip_dumped_compdef_tail(rest: &[&str]) -> usize { + let mut skip = rest + .iter() + .take_while(|line| line.trim().is_empty()) + .count(); + if rest.get(skip).is_some_and(|line| line.trim() == "fi") { + skip += 1; + skip += rest[skip..] + .iter() + .take_while(|line| line.trim().is_empty()) + .count(); + } + skip +} + /// clap_complete + optional `[PROMPT]` puts the subcommand in `$line[2]` (clap#6282). fn fix_zsh_root_prompt_positional(script: &str) -> String { let mut out = String::with_capacity(script.len()); @@ -482,6 +496,8 @@ mod tests { assert!(!stripped.contains("#compdef crabcode")); assert!(!stripped.contains("compdef _crabcode")); assert!(!stripped.contains("_crabcode()")); + assert_no_stray_fi(&stripped); + assert_zsh_parses(&stripped); } #[test] @@ -529,10 +545,75 @@ mod tests { assert!(once.contains("export PATH=/usr/bin")); assert!(once.contains("# >>> grok installer >>>")); assert!(!once.contains("#compdef crabcode")); + assert_no_stray_fi(&once); + assert_zsh_parses(&once); let grok_end = once.find("# <<< grok installer <<<").unwrap(); assert!(once.find(RC_BEGIN).unwrap() > grok_end); } + #[test] + fn strip_real_clap_zsh_dump_leaves_parseable_rc() { + let dump = String::from_utf8(generate_script(Shell::Zsh)).unwrap(); + assert!( + dump.contains("compdef _crabcode crabcode"), + "expected clap dump end marker" + ); + assert!( + dump.trim_end().ends_with("fi"), + "clap zsh dump must still close with fi so the stripper has something to eat" + ); + let rc = format!("export KEEP_BEFORE=1\nalias cc=crabcode\n{dump}\nexport KEEP_AFTER=1\n"); + let stripped = strip_inline_usage_completion(&rc); + assert!(stripped.contains("export KEEP_BEFORE=1")); + assert!(stripped.contains("export KEEP_AFTER=1")); + assert!(stripped.contains("alias cc=crabcode")); + assert!(!stripped.contains("#compdef crabcode")); + assert!(!stripped.contains("compdef _crabcode")); + assert_no_stray_fi(&stripped); + assert_zsh_parses(&stripped); + + let script = home_dir().join(".local/share/zsh/site-functions/_crabcode"); + let aliases = crabcode_aliases_from_rc(&stripped); + let once = upsert_marked_block(&rc, &zsh_installer_block(&script, &aliases)); + assert!(once.contains("compdef _crabcode crabcode cc")); + assert_no_stray_fi(&once); + assert_zsh_parses(&once); + } + + fn assert_no_stray_fi(rc: &str) { + assert!( + !rc.lines().any(|line| line.trim() == "fi"), + "stray fi left in rc:\n{rc}" + ); + } + + fn assert_zsh_parses(rc: &str) { + let Ok(which) = std::process::Command::new("zsh") + .args(["-c", "echo ok"]) + .output() + else { + return; + }; + if !which.status.success() { + return; + } + let path = std::env::temp_dir().join(format!( + "crabcode-strip-zshn-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, rc).unwrap(); + let status = std::process::Command::new("zsh") + .args(["-n", "--", path.to_str().unwrap()]) + .status() + .unwrap(); + let _ = std::fs::remove_file(&path); + assert!(status.success(), "zsh -n failed for rc:\n{rc}"); + } + #[test] fn powershell_hook_dotsources_home() { let script = home_dir().join(".local/share/powershell/Completions/crabcode.ps1");