diff --git a/diffr/install.sh b/diffr/install.sh index d0bb457d6..4438ae084 100644 --- a/diffr/install.sh +++ b/diffr/install.sh @@ -6,7 +6,9 @@ # Install diffr: curl -fsSL https://install.dev.fast/diffr | sh # # Download the newest diffr release (or DIFFR_VERSION) for this machine, -# puts diffr in DIFFR_INSTALL_DIR (default ~/.local/bin), then runs `diffr config init` +# puts diffr in DIFFR_INSTALL_DIR (default ~/.local/bin), then runs `diffr config init`, +# again on every run, so running this again repairs or changes the setup +# (unless DIFFR_SKIP_SETUP is set, as `diffr upgrade` does) set -eu REPO=devdotfast/whiteboard @@ -89,7 +91,8 @@ case ":$PATH:" in *":$dir:"*) ;; *) rc= line="export PATH=\"$dir:\$PATH\"" - case "${SHELL##*/}" in + shell=${SHELL:-} + case "${shell##*/}" in zsh) rc=${ZDOTDIR:-$HOME}/.zshrc ;; bash) if [ "$(uname -s)" = Darwin ]; then rc=$HOME/.bash_profile; else rc=$HOME/.bashrc; fi ;; fish) rc=${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish line="fish_add_path $dir" ;; @@ -105,12 +108,12 @@ case ":$PATH:" in printf '\n# diffr\n%s\n' "$line" >>"$rc" echo "Added $dir to your PATH in $rc; it applies in new shells" ;; - *) echo "next, add diffr's install path ($dir) to your PATH" ;; + *) echo "next, add diffr's install path to your PATH, for example with: $line" ;; esac ;; esac -[ -n "$first" ] || exit 0 +[ -z "${DIFFR_SKIP_SETUP:-}" ] || exit 0 if terminal; then exec "$dir/diffr" config init { try { shown = await $.process.run([binary, "--version"]); } catch (error) { - throw new Error(`${binary} could not run (${error instanceof Error ? error.message : String(error)}); install diffr with: ${INSTALL}`); + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`${binary} could not run (${reason}). If diffr is installed, run \`diffr config init\` in a terminal so this plugin finds it; otherwise install diffr with: ${INSTALL}`); } if (shown.exitCode !== 0) throw new Error(shown.stderr || `${binary} --version exited with status ${shown.exitCode}`); const match = /^diffr (\d+)\.(\d+)\.(\d+)/.exec(shown.stdout); diff --git a/diffr/src/init.rs b/diffr/src/init.rs index fc6a270d5..ec54a961b 100644 --- a/diffr/src/init.rs +++ b/diffr/src/init.rs @@ -67,7 +67,8 @@ impl Summaries { /// What the questions need: the current answers and the providers. struct State { - record: Record, + /// The agents whose command is on PATH: the only ones offered. + found: Vec, config: Config, /// Each provider's id, title and key variables, from the schema. providers: Vec<(String, String, Vec)>, @@ -110,7 +111,7 @@ impl State { }) .collect(); Ok(Self { - record: Record::load()?, + found: Agent::found(), config: Config::load()?, providers, }) @@ -120,16 +121,6 @@ impl State { &self.config.plugins.shape.entries["bundled.summarize"].options } - fn agents(&self) -> Result> { - let mut agents = Vec::new(); - for agent in Agent::ALL { - if self.record.agents.contains(&agent) || agent.detected()? { - agents.push(agent); - } - } - Ok(agents) - } - fn on(&self) -> bool { self.config .plugins @@ -217,12 +208,11 @@ pub(crate) fn run(json: bool, answers: Option<&Path>) -> Result { } fn questions(state: &State) -> Result { - let mut agents = Vec::new(); - for agent in Agent::ALL { - agents.push( - json!({ "value": agent.id(), "label": agent.title(), "detected": agent.detected()? }), - ); - } + let agents: Vec = state + .found + .iter() + .map(|agent| json!({ "value": agent.id(), "label": agent.title() })) + .collect(); let providers: Vec = state .providers .iter() @@ -241,7 +231,7 @@ fn questions(state: &State) -> Result { "question": AGENTS_QUESTION, "multi_select": true, "options": agents, - "default": state.agents()?.iter().map(|agent| agent.id()).collect::>(), + "default": state.found.iter().map(|agent| agent.id()).collect::>(), }, { "id": "summaries", @@ -322,8 +312,17 @@ fn apply( } } + if let Some(agent) = answers + .agents + .iter() + .find(|agent| !state.found.contains(agent)) + { + return Err(agent.missing().into()); + } + // An agent whose command is gone keeps its record: there is nothing to + // remove the plugin with. let mut record = Record::load()?; - for agent in Agent::ALL { + for &agent in &state.found { let chosen = answers.agents.contains(&agent); let installed = record.agents.contains(&agent); if chosen { @@ -364,14 +363,26 @@ fn outcome(state: &State, answers: &Answers) -> Value { fn prompt(state: &State) -> Result { cliclack::intro("diffr")?; - let mut agents = cliclack::multiselect(AGENTS_QUESTION) - .initial_values(state.agents()?) - .required(false); for agent in Agent::ALL { - let hint = if agent.detected()? { "found" } else { "" }; - agents = agents.item(agent, agent.title(), hint); + if !state.found.contains(&agent) { + cliclack::log::info(format!( + "Skipping the {} plugin. {}", + agent.title(), + agent.missing() + ))?; + } } - let agents = agents.interact()?; + let agents = if state.found.is_empty() { + Vec::new() + } else { + let mut agents = cliclack::multiselect(AGENTS_QUESTION) + .initial_values(state.found.clone()) + .required(false); + for &agent in &state.found { + agents = agents.item(agent, agent.title(), ""); + } + agents.interact()? + }; let on = cliclack::confirm(SUMMARIES_QUESTION) .initial_value(state.on()) @@ -427,6 +438,7 @@ fn prompt(state: &State) -> Result { // The spinner shows the error, so it is not printed again. (Err(error), Some((spinner, _))) => { spinner.error(&error); + cliclack::outro_cancel("Setup stopped. Run `diffr config init` to try again")?; return Ok(2); } (Err(error), None) => return Err(error), diff --git a/diffr/src/install.rs b/diffr/src/install.rs index 0ba3817b1..00be56000 100644 --- a/diffr/src/install.rs +++ b/diffr/src/install.rs @@ -40,11 +40,22 @@ impl Agent { } } - pub(crate) fn detected(self) -> Result { - let home = home()?; - Ok(match self { - Agent::ClaudeCode => home.join(".claude").is_dir(), - }) + /// The agents whose command is on PATH, which are the ones `config init` + /// can install the plugin for. + pub(crate) fn found() -> Vec { + Agent::ALL + .into_iter() + .filter(|agent| match agent { + Agent::ClaudeCode => claude_path().is_some(), + }) + .collect() + } + + /// Why `config init` skips this agent: its command is not on PATH. + pub(crate) fn missing(self) -> &'static str { + match self { + Agent::ClaudeCode => CLAUDE_MISSING, + } } /// `step` hears what each slow command is about to do. @@ -65,6 +76,18 @@ impl Agent { } step("Installing the diffr plugin in Claude Code"); claude(&["plugin", "install", PLUGIN, "--scope", "user"])?; + // Claude Code's PATH may lack diffr, as in a terminal opened + // before install.sh added it. + let binary = match Method::find()? { + Method::Script(dir) => dir + .join(format!("diffr{}", std::env::consts::EXE_SUFFIX)) + .to_string_lossy() + .into_owned(), + // On PATH, at a path that changes with each version. + Method::Brew | Method::Cargo | Method::Whiteboard(_) => "diffr".to_owned(), + }; + let values = serde_json::to_vec(&serde_json::json!({ "diffr": binary }))?; + claude_with_input(&["plugin", "configure", PLUGIN, "--values-stdin"], &values)?; Ok(()) } } @@ -134,6 +157,8 @@ enum Method { Cargo, /// install.sh, or an archive extracted by hand, into this directory. Script(PathBuf), + /// Whiteboard's own copy, at this path in its node_modules. + Whiteboard(PathBuf), } impl Method { @@ -145,11 +170,7 @@ impl Method { .to_owned(); let parts: Vec<_> = exe.components().map(|part| part.as_os_str()).collect(); if parts.iter().any(|part| *part == "node_modules") { - return Err(format!( - "{} is likely managed by Whiteboard; skipping automatic deletion for safety (remove via Whiteboard app instead)", - exe.display() - ) - .into()); + return Ok(Method::Whiteboard(exe)); } if parts .windows(2) @@ -168,20 +189,30 @@ impl Method { } } +fn managed(exe: &Path) -> String { + format!( + "{} is likely managed by Whiteboard; skipping automatic deletion for safety (remove via Whiteboard app instead)", + exe.display() + ) +} + /// `diffr upgrade`: the newest release, the way this diffr was installed, /// then each recorded agent's plugin. pub(crate) fn upgrade() -> Result { match Method::find()? { + Method::Whiteboard(exe) => return Err(managed(&exe).into()), Method::Brew => run(Command::new("brew").args(["upgrade", FORMULA]))?, Method::Cargo => run(Command::new("cargo").args(["install", "--locked", CRATE]))?, Method::Script(dir) => { if cfg!(windows) { return Err("diffr upgrade runs install.sh, which supports macOS and Linux".into()); } - let script = host(Command::new("curl").args(["-fsSL", INSTALL_SCRIPT]))?; + let script = host(Command::new("curl").args(["-fsSL", INSTALL_SCRIPT]), b"")?; let mut sh = Command::new("sh") .arg("-s") .env("DIFFR_INSTALL_DIR", dir) + // The agents are upgraded below, without asking again. + .env("DIFFR_SKIP_SETUP", "1") .stdin(Stdio::piped()) .spawn() .map_err(|error| format!("could not run sh: {error}"))?; @@ -206,6 +237,9 @@ pub(crate) fn upgrade() -> Result { /// reads it too. pub(crate) fn uninstall() -> Result { let method = Method::find()?; + if let Method::Whiteboard(exe) = &method { + return Err(managed(exe).into()); + } let mut record = Record::load()?; while let Some(agent) = record.agents.first().copied() { agent.remove(&mut |message| eprintln!("{message}"))?; @@ -225,6 +259,7 @@ pub(crate) fn uninstall() -> Result { } eprintln!("Removed diffr from {}", dir.display()); } + Method::Whiteboard(_) => unreachable!("refused above"), } Ok(0) } @@ -243,17 +278,45 @@ pub(crate) fn home() -> Result { Ok(dirs::home_dir().ok_or("no home directory for this user")?) } +const CLAUDE_MISSING: &str = "Claude Code's `claude` command is not on your PATH. Install \ + Claude Code (https://code.claude.com/docs/en/setup), or open a new terminal if you just \ + did, then run `diffr config init`."; + +/// Where `claude` is on PATH, found the way `Command::new("claude")` finds it. +fn claude_path() -> Option { + let name = format!("claude{}", std::env::consts::EXE_SUFFIX); + std::env::split_paths(&std::env::var_os("PATH")?) + .map(|dir| dir.join(&name)) + .find(|path| path.is_file()) +} + fn claude(args: &[&str]) -> Result> { - host(Command::new("claude").args(args)) + claude_with_input(args, b"") } -/// Run a host command. Its output is kept, so prompts and `--json` stay -/// clean, and shown only when it fails. -fn host(command: &mut Command) -> Result> { - let output = command - .stdin(Stdio::null()) - .output() +/// `claude` with `input` on stdin. +fn claude_with_input(args: &[&str], input: &[u8]) -> Result> { + let Some(path) = claude_path() else { + return Err(CLAUDE_MISSING.into()); + }; + host(Command::new(path).args(args), input) +} + +/// Run a host command with `input` on stdin. Its output is kept, so prompts +/// and `--json` stay clean, and shown only when it fails. +fn host(command: &mut Command, input: &[u8]) -> Result> { + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() .map_err(|error| format!("could not run {}: {error}", describe(command)))?; + child + .stdin + .take() + .expect("stdin is piped") + .write_all(input)?; + let output = child.wait_with_output()?; if !output.status.success() { return Err(format!( "{} failed ({}):\n{}{}", diff --git a/diffr/tests/cli.rs b/diffr/tests/cli.rs index 119ea8d33..68621602f 100644 --- a/diffr/tests/cli.rs +++ b/diffr/tests/cli.rs @@ -378,21 +378,39 @@ fn long_help_ends_with_a_guide_and_short_help_does_not() { } } -/// `config init --json` against a stand-in `claude` that logs its arguments -/// and fails `plugin install` when `FAIL` is set. +/// The stand-in `claude` that `config_init` puts on PATH, if any. #[cfg(unix)] -fn config_init(dir: &std::path::Path, answers: Option<&str>, fail: bool) -> std::process::Output { +#[derive(Clone, Copy, PartialEq)] +enum Claude { + Works, + FailsInstall, + Missing, +} + +/// `config init --json` against a stand-in `claude` that logs its arguments. +#[cfg(unix)] +fn config_init( + dir: &std::path::Path, + answers: Option<&str>, + stand_in: Claude, +) -> std::process::Output { use std::os::unix::fs::PermissionsExt; let bin = dir.join("bin"); std::fs::create_dir_all(&bin).unwrap(); std::fs::create_dir_all(dir.join("home/.claude")).unwrap(); let claude = bin.join("claude"); - std::fs::write( + if stand_in == Claude::Missing { + if claude.exists() { + std::fs::remove_file(&claude).unwrap(); + } + } else { + std::fs::write( &claude, - "#!/bin/sh\necho \"$*\" >> \"$LOG\"\ncase \"$*\" in\n \"plugin marketplace list --json\") echo '[]' ;;\n \"plugin install\"*) if [ -n \"$FAIL\" ]; then exit 1; fi ;;\nesac\n", + "#!/bin/sh\necho \"$*\" >> \"$LOG\"\ncase \"$*\" in\n \"plugin marketplace list --json\") echo '[]' ;;\n \"plugin install\"*) if [ -n \"$FAIL\" ]; then exit 1; fi ;;\n \"plugin configure\"*) cat >> \"$LOG\"; echo >> \"$LOG\" ;;\nesac\n", ) .unwrap(); - std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + } let mut command = get_base_command(); command .env("PATH", format!("{}:/usr/bin:/bin", bin.display())) @@ -408,7 +426,7 @@ fn config_init(dir: &std::path::Path, answers: Option<&str>, fail: bool) -> std: .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - if fail { + if stand_in == Claude::FailsInstall { command.env("FAIL", "1"); } if answers.is_some() { @@ -431,7 +449,7 @@ fn config_init_installs_the_chosen_plugins_and_asks_again_with_them() { let output = config_init( dir.path(), Some(r#"{"agents":["claude-code"],"summaries":{"provider":"openai"}}"#), - false, + Claude::Works, ); assert!( output.status.success(), @@ -440,6 +458,17 @@ fn config_init_installs_the_chosen_plugins_and_asks_again_with_them() { ); let log = std::fs::read_to_string(dir.path().join("log")).unwrap(); assert!(log.contains("plugin install diffr@devfast"), "{log}"); + // The plugin runs this diffr by its path, whatever Claude Code's PATH is. + let binary = assert_cmd::cargo::cargo_bin("diffr") + .canonicalize() + .unwrap(); + let pinned = serde_json::json!({ "diffr": binary }).to_string(); + assert!( + log.contains(&format!( + "plugin configure diffr@devfast --values-stdin\n{pinned}" + )), + "{log}" + ); let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(result["key"]["present"], false); assert_eq!( @@ -448,7 +477,7 @@ fn config_init_installs_the_chosen_plugins_and_asks_again_with_them() { ); let questions: serde_json::Value = - serde_json::from_slice(&config_init(dir.path(), None, false).stdout).unwrap(); + serde_json::from_slice(&config_init(dir.path(), None, Claude::Works).stdout).unwrap(); assert_eq!( questions["questions"][0]["default"], serde_json::json!(["claude-code"]) @@ -459,7 +488,7 @@ fn config_init_installs_the_chosen_plugins_and_asks_again_with_them() { let output = config_init( dir.path(), Some(r#"{"agents":[],"summaries":"off"}"#), - false, + Claude::Works, ); assert!(output.status.success()); let log = std::fs::read_to_string(dir.path().join("log")).unwrap(); @@ -473,17 +502,17 @@ fn config_init_without_a_summaries_answer_keeps_the_summaries_choice() { let output = config_init( dir.path(), Some(r#"{"agents":[],"summaries":{"provider":"openai"}}"#), - false, + Claude::Works, ); assert!(output.status.success()); - let output = config_init(dir.path(), Some(r#"{"agents":[]}"#), false); + let output = config_init(dir.path(), Some(r#"{"agents":[]}"#), Claude::Works); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stdout) ); let questions: serde_json::Value = - serde_json::from_slice(&config_init(dir.path(), None, false).stdout).unwrap(); + serde_json::from_slice(&config_init(dir.path(), None, Claude::Works).stdout).unwrap(); assert_eq!(questions["questions"][1]["default"], true); assert_eq!(questions["questions"][2]["default"], "openai"); } @@ -495,7 +524,7 @@ fn config_init_stops_at_a_failed_plugin_install_and_keeps_the_config() { let output = config_init( dir.path(), Some(r#"{"agents":["claude-code"],"summaries":{"provider":"openai"}}"#), - true, + Claude::FailsInstall, ); assert_eq!(output.status.code(), Some(2)); let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); @@ -506,6 +535,48 @@ fn config_init_stops_at_a_failed_plugin_install_and_keeps_the_config() { assert!(!dir.path().join("config/diffr/config.toml").exists()); } +#[cfg(unix)] +#[test] +fn config_init_skips_claude_code_when_claude_is_not_on_path() { + let dir = tempfile::tempdir().unwrap(); + let output = config_init( + dir.path(), + Some(r#"{"agents":["claude-code"]}"#), + Claude::Works, + ); + assert!(output.status.success()); + + let questions: serde_json::Value = + serde_json::from_slice(&config_init(dir.path(), None, Claude::Missing).stdout).unwrap(); + assert_eq!(questions["questions"][0]["options"], serde_json::json!([])); + assert_eq!(questions["questions"][0]["default"], serde_json::json!([])); + + let output = config_init( + dir.path(), + Some(r#"{"agents":[],"summaries":"off"}"#), + Claude::Missing, + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + let log = std::fs::read_to_string(dir.path().join("log")).unwrap(); + assert!(!log.contains("plugin uninstall"), "{log}"); + + let output = config_init( + dir.path(), + Some(r#"{"agents":["claude-code"]}"#), + Claude::Missing, + ); + assert_eq!(output.status.code(), Some(2)); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(result["error"]["message"] + .as_str() + .unwrap() + .contains("not on your PATH")); +} + #[test] fn config_init_needs_a_terminal_without_json() { get_base_command() @@ -524,7 +595,7 @@ fn uninstall_removes_plugins_binaries_and_record_and_keeps_the_config() { let output = config_init( dir.path(), Some(r#"{"agents":["claude-code"],"summaries":{"provider":"openai"}}"#), - false, + Claude::Works, ); assert!(output.status.success()); let bin = dir.path().join("installed"); @@ -563,7 +634,7 @@ fn installed_at(dir: &std::path::Path, at: &str, args: &[&str]) -> (std::process let output = config_init( dir, Some(r#"{"agents":["claude-code"],"summaries":"off"}"#), - false, + Claude::Works, ); assert!(output.status.success()); let brew = dir.join("bin/brew");