From 121a98ae51217fdf76b3fd2d098eb93e03d8b804 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 9 Oct 2026 10:07:01 -0700 Subject: [PATCH 1/4] fix(diffr): find Claude Code by its claude command `config init` counted Claude Code as installed when ~/.claude existed, then ran `claude`. With the folder there but `claude` off PATH (the desktop app alone, or a fresh install in an old shell), setup failed with "could not run claude plugin marketplace list --json: No such file or directory". Claude Code is now found only when `claude` is on PATH, and choosing it anyway says how to fix it. Claude-Session: https://claude.ai/code/session_01HTHD79i4QdthzVCkDZ26gd Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/src/init.rs | 6 +++--- diffr/src/install.rs | 27 +++++++++++++++++++++------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/diffr/src/init.rs b/diffr/src/init.rs index fc6a270d5..7ca0291ac 100644 --- a/diffr/src/init.rs +++ b/diffr/src/init.rs @@ -123,7 +123,7 @@ impl State { fn agents(&self) -> Result> { let mut agents = Vec::new(); for agent in Agent::ALL { - if self.record.agents.contains(&agent) || agent.detected()? { + if self.record.agents.contains(&agent) || agent.detected() { agents.push(agent); } } @@ -220,7 +220,7 @@ 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()? }), + json!({ "value": agent.id(), "label": agent.title(), "detected": agent.detected() }), ); } let providers: Vec = state @@ -368,7 +368,7 @@ fn prompt(state: &State) -> Result { .initial_values(state.agents()?) .required(false); for agent in Agent::ALL { - let hint = if agent.detected()? { "found" } else { "" }; + let hint = if agent.detected() { "found" } else { "" }; agents = agents.item(agent, agent.title(), hint); } let agents = agents.interact()?; diff --git a/diffr/src/install.rs b/diffr/src/install.rs index 0ba3817b1..6b80c1294 100644 --- a/diffr/src/install.rs +++ b/diffr/src/install.rs @@ -40,11 +40,10 @@ impl Agent { } } - pub(crate) fn detected(self) -> Result { - let home = home()?; - Ok(match self { - Agent::ClaudeCode => home.join(".claude").is_dir(), - }) + pub(crate) fn detected(self) -> bool { + match self { + Agent::ClaudeCode => claude_path().is_some(), + } } /// `step` hears what each slow command is about to do. @@ -243,8 +242,24 @@ pub(crate) fn home() -> Result { Ok(dirs::home_dir().ok_or("no home directory for this user")?) } +/// 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)) + let Some(path) = claude_path() else { + return Err( + "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` again." + .into(), + ); + }; + host(Command::new(path).args(args)) } /// Run a host command. Its output is kept, so prompts and `--json` stay From 1087e2987d7f98f230bdae947c6c9339842a76d7 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 9 Oct 2026 10:28:16 -0700 Subject: [PATCH 2/4] fix(diffr): skip Claude Code in config init when claude is not on PATH Claude Code is offered only when `claude` is on PATH. Without it, `config init` says it skipped the plugin and why, leaves out the agents question, and finishes. A plugin recorded as installed is left alone when `claude` is gone, since there is nothing to remove it with. Claude-Session: https://claude.ai/code/session_01HTHD79i4QdthzVCkDZ26gd Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 Agent-Session: 8881e3f3-a916-4405-9706-bb6e3a2f193d --- diffr/src/init.rs | 63 ++++++++++++++++++------------- diffr/src/install.rs | 27 +++++++++---- diffr/tests/cli.rs | 90 ++++++++++++++++++++++++++++++++++++-------- 3 files changed, 131 insertions(+), 49 deletions(-) diff --git a/diffr/src/init.rs b/diffr/src/init.rs index 7ca0291ac..6f0e89730 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()) diff --git a/diffr/src/install.rs b/diffr/src/install.rs index 6b80c1294..5febc8fc5 100644 --- a/diffr/src/install.rs +++ b/diffr/src/install.rs @@ -40,9 +40,21 @@ impl Agent { } } - pub(crate) fn detected(self) -> bool { + /// 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_path().is_some(), + Agent::ClaudeCode => CLAUDE_MISSING, } } @@ -242,6 +254,10 @@ 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); @@ -252,12 +268,7 @@ fn claude_path() -> Option { fn claude(args: &[&str]) -> Result> { let Some(path) = claude_path() else { - return Err( - "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` again." - .into(), - ); + return Err(CLAUDE_MISSING.into()); }; host(Command::new(path).args(args)) } diff --git a/diffr/tests/cli.rs b/diffr/tests/cli.rs index 119ea8d33..cbddc7c84 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", ) .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(), @@ -448,7 +466,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 +477,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 +491,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 +513,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 +524,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 +584,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 +623,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"); From 35d5bffeafe0ed5d28a0c62ce487ef23f6158e8e Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 9 Oct 2026 10:55:17 -0700 Subject: [PATCH 3/4] fix(diffr): repairable onboarding, wherever claude finds diffr - install.sh runs `config init` on every run, not only the first, so running it again repairs or changes the setup. `diffr upgrade` sets DIFFR_SKIP_SETUP, since it upgrades the plugins itself. - install.sh no longer stops at an unset SHELL (dash under `set -u`), and prints the line to add to PATH for a shell it does not know. - `config init` sets the Claude Code plugin's `diffr` option to this diffr's path for a script install, so /diffr works when Claude Code's PATH lacks ~/.local/bin. - A failed setup ends saying to run `diffr config init` again, and the plugin's could-not-run error names `diffr config init` as well as installing. Claude-Session: https://claude.ai/code/session_01HTHD79i4QdthzVCkDZ26gd Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 Agent-Session: 8881e3f3-a916-4405-9706-bb6e3a2f193d Agent-Session: 5fddfa2a-e3c2-401d-bf87-2f6650ad81d0 --- diffr/install.sh | 11 ++-- .../claude-code/plugin/hooks/register.js | 5 +- diffr/packages/claude-code/src/register.tsx | 3 +- diffr/src/init.rs | 1 + diffr/src/install.rs | 63 +++++++++++++++---- diffr/tests/cli.rs | 13 +++- 6 files changed, 75 insertions(+), 21 deletions(-) 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 6f0e89730..ec54a961b 100644 --- a/diffr/src/init.rs +++ b/diffr/src/init.rs @@ -438,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 5febc8fc5..00be56000 100644 --- a/diffr/src/install.rs +++ b/diffr/src/install.rs @@ -76,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(()) } } @@ -145,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 { @@ -156,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) @@ -179,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}"))?; @@ -217,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}"))?; @@ -236,6 +259,7 @@ pub(crate) fn uninstall() -> Result { } eprintln!("Removed diffr from {}", dir.display()); } + Method::Whiteboard(_) => unreachable!("refused above"), } Ok(0) } @@ -267,19 +291,32 @@ fn claude_path() -> Option { } fn claude(args: &[&str]) -> Result> { + claude_with_input(args, b"") +} + +/// `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)) + host(Command::new(path).args(args), input) } -/// 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() +/// 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 cbddc7c84..68621602f 100644 --- a/diffr/tests/cli.rs +++ b/diffr/tests/cli.rs @@ -406,7 +406,7 @@ fn config_init( } 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(); @@ -458,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!( From 05e2e72b9f7d44487541ec5993642ee76dd0627f Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 9 Oct 2026 10:59:58 -0700 Subject: [PATCH 4/4] fix(diffr): rebuild the Claude Code plugin from the locked dependencies Claude-Session: https://claude.ai/code/session_01HTHD79i4QdthzVCkDZ26gd Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 Agent-Session: 8881e3f3-a916-4405-9706-bb6e3a2f193d Agent-Session: 5fddfa2a-e3c2-401d-bf87-2f6650ad81d0 --- diffr/packages/claude-code/plugin/hooks/register.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diffr/packages/claude-code/plugin/hooks/register.js b/diffr/packages/claude-code/plugin/hooks/register.js index e8b54e63f..253ec6956 100644 --- a/diffr/packages/claude-code/plugin/hooks/register.js +++ b/diffr/packages/claude-code/plugin/hooks/register.js @@ -21148,7 +21148,7 @@ function ansiRegex({ onlyFirst = false } = {}) { return new RegExp(pattern, onlyFirst ? void 0 : "g"); } -// ../../../node_modules/strip-ansi/index.js +// ../viewer/node_modules/strip-ansi/index.js var regex = ansiRegex(); function stripAnsi(string4) { if (typeof string4 !== "string") {