Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions diffr/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" ;;
Expand All @@ -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 </dev/tty
Expand Down
3 changes: 2 additions & 1 deletion diffr/packages/claude-code/plugin/hooks/register.js
Original file line number Diff line number Diff line change
Expand Up @@ -25250,7 +25250,8 @@ async function checkVersion($, binary) {
try {
shown = await $.process.run([binary, "--version"]);
} catch (error62) {
throw new Error(`${binary} could not run (${error62 instanceof Error ? error62.message : String(error62)}); install diffr with: ${INSTALL}`);
const reason = error62 instanceof Error ? error62.message : String(error62);
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);
Expand Down
3 changes: 2 additions & 1 deletion diffr/packages/claude-code/src/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ async function checkVersion($: EngineInterface, binary: string): Promise<void> {
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);
Expand Down
64 changes: 38 additions & 26 deletions diffr/src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Agent>,
config: Config,
/// Each provider's id, title and key variables, from the schema.
providers: Vec<(String, String, Vec<String>)>,
Expand Down Expand Up @@ -110,7 +111,7 @@ impl State {
})
.collect();
Ok(Self {
record: Record::load()?,
found: Agent::found(),
config: Config::load()?,
providers,
})
Expand All @@ -120,16 +121,6 @@ impl State {
&self.config.plugins.shape.entries["bundled.summarize"].options
}

fn agents(&self) -> Result<Vec<Agent>> {
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
Expand Down Expand Up @@ -217,12 +208,11 @@ pub(crate) fn run(json: bool, answers: Option<&Path>) -> Result<i32> {
}

fn questions(state: &State) -> Result<Value> {
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<Value> = state
.found
.iter()
.map(|agent| json!({ "value": agent.id(), "label": agent.title() }))
.collect();
let providers: Vec<Value> = state
.providers
.iter()
Expand All @@ -241,7 +231,7 @@ fn questions(state: &State) -> Result<Value> {
"question": AGENTS_QUESTION,
"multi_select": true,
"options": agents,
"default": state.agents()?.iter().map(|agent| agent.id()).collect::<Vec<_>>(),
"default": state.found.iter().map(|agent| agent.id()).collect::<Vec<_>>(),
},
{
"id": "summaries",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -364,14 +363,26 @@ fn outcome(state: &State, answers: &Answers) -> Value {

fn prompt(state: &State) -> Result<i32> {
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())
Expand Down Expand Up @@ -427,6 +438,7 @@ fn prompt(state: &State) -> Result<i32> {
// 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),
Expand Down
99 changes: 81 additions & 18 deletions diffr/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,22 @@ impl Agent {
}
}

pub(crate) fn detected(self) -> Result<bool> {
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> {
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.
Expand All @@ -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(())
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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<i32> {
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}"))?;
Expand All @@ -206,6 +237,9 @@ pub(crate) fn upgrade() -> Result<i32> {
/// reads it too.
pub(crate) fn uninstall() -> Result<i32> {
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}"))?;
Expand All @@ -225,6 +259,7 @@ pub(crate) fn uninstall() -> Result<i32> {
}
eprintln!("Removed diffr from {}", dir.display());
}
Method::Whiteboard(_) => unreachable!("refused above"),
}
Ok(0)
}
Expand All @@ -243,17 +278,45 @@ pub(crate) fn home() -> Result<PathBuf> {
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<PathBuf> {
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<Vec<u8>> {
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<Vec<u8>> {
let output = command
.stdin(Stdio::null())
.output()
/// `claude` with `input` on stdin.
fn claude_with_input(args: &[&str], input: &[u8]) -> Result<Vec<u8>> {
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<Vec<u8>> {
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{}{}",
Expand Down
Loading
Loading