pub mod claude;
pub mod codex;
pub mod io;
pub mod target;
use std::io::IsTerminal;
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use target::{Target, BACKUP_SUFFIX};
const NO_CLIS_MSG: &str = "no supported CLIs detected; pass --target claude|codex|all";
fn present_targets(rows: &[(&'static Target, bool)]) -> Vec<&'static Target> {
rows.iter().filter(|(_, p)| *p).map(|(t, _)| *t).collect()
}
pub struct InstallArgs {
pub hook_path: Option<PathBuf>,
pub config: Option<PathBuf>,
pub target: Option<String>,
pub yes: bool,
}
pub struct UninstallArgs {
pub config: Option<PathBuf>,
pub target: Option<String>,
pub yes: bool,
}
pub enum Plan {
Targets(Vec<&'static Target>),
NothingDetected,
Conflict(String),
}
pub fn plan_targets(
requested: Option<&str>,
explicit_config: bool,
present: &[(&'static Target, bool)],
is_tty: bool,
) -> Plan {
match requested {
Some("all") => {
if explicit_config {
return Plan::Conflict(
"--config applies to a single target; use --target claude|codex".into(),
);
}
let chosen = present_targets(present);
if chosen.is_empty() {
Plan::NothingDetected
} else {
Plan::Targets(chosen)
}
}
Some(name) => match target::by_name(name) {
Some(t) => Plan::Targets(vec![t]),
None => Plan::Conflict(format!("unknown target: {name}")),
},
None => {
if explicit_config {
return match target::by_name("claude") {
Some(t) => Plan::Targets(vec![t]),
None => Plan::Conflict("claude target not registered".into()),
};
}
let detected = present_targets(present);
match detected.len() {
0 => Plan::NothingDetected,
1 => Plan::Targets(detected), _ if is_tty => Plan::Targets(detected), _ => {
Plan::Conflict("multiple CLIs detected; pass --target claude|codex|all".into())
}
}
}
}
}
fn parse_confirm(answer: &str) -> bool {
let a = answer.trim().to_ascii_lowercase();
a.is_empty() || a == "y" || a == "yes"
}
fn interpret_confirm_read(read: Result<usize, ()>, line: &str) -> bool {
match read {
Ok(0) | Err(()) => false,
Ok(_) => parse_confirm(line),
}
}
fn confirm(prompt: &str) -> bool {
use std::io::Write;
print!("{prompt} [Y/n] ");
let _ = std::io::stdout().flush();
let mut line = String::new();
let read = std::io::stdin().read_line(&mut line).map_err(|_| ());
interpret_confirm_read(read, &line)
}
fn detection() -> Vec<(&'static Target, bool)> {
target::TARGETS
.iter()
.map(|t| (*t, target::is_present(t)))
.collect()
}
fn has_hooks(t: &'static Target) -> bool {
let path = (t.default_config_path)();
match io::read_config(&path) {
Ok(c) if c.trim().is_empty() => false,
Ok(c) => (t.merge_uninstall)(&c).map(|o| o.changed).unwrap_or(true),
Err(_) => true,
}
}
fn select_targets(
prompt: &str,
candidates: &[&'static Target],
) -> Result<Option<Vec<&'static Target>>> {
let options: Vec<&str> = candidates.iter().map(|t| t.display_name).collect();
let all: Vec<usize> = (0..options.len()).collect();
let chosen = inquire::MultiSelect::new(prompt, options)
.with_default(&all)
.raw_prompt_skippable()
.context("target selection prompt failed")?;
Ok(chosen.map(|sel| sel.into_iter().map(|opt| candidates[opt.index]).collect()))
}
fn interactive_terminal() -> bool {
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
}
fn interactive_pick(
target: &Option<String>,
config: &Option<PathBuf>,
yes: bool,
is_tty: bool,
) -> bool {
target.is_none() && config.is_none() && !yes && is_tty
}
fn run_interactive(
candidates: Vec<&'static Target>,
empty_msg: &str,
prompt: &str,
verb: &str,
op: impl Fn(&'static Target) -> Result<()>,
) -> Result<()> {
let chosen = match candidates.len() {
0 => {
println!("{empty_msg}");
return Ok(());
}
1 => candidates,
_ => match select_targets(prompt, &candidates)? {
Some(sel) if !sel.is_empty() => sel,
Some(_) => {
println!("nothing selected");
return Ok(());
}
None => {
println!("aborted");
return Ok(());
}
},
};
run_each(&chosen, verb, op)
}
pub fn install(args: InstallArgs) -> Result<()> {
let is_tty = interactive_terminal();
if interactive_pick(&args.target, &args.config, args.yes, is_tty) {
let detected = present_targets(&detection());
return run_interactive(
detected,
NO_CLIS_MSG,
"Install pixtuoid hooks into",
"install",
|t| run_install(t, None, args.hook_path.clone()),
);
}
let plan = plan_targets(
args.target.as_deref(),
args.config.is_some(),
&detection(),
is_tty,
);
let targets = resolve_plan(plan)?;
run_each(&targets, "install", |t| {
run_install(t, args.config.clone(), args.hook_path.clone())
})
}
pub fn uninstall(args: UninstallArgs) -> Result<()> {
let is_tty = interactive_terminal();
if interactive_pick(&args.target, &args.config, args.yes, is_tty) {
let installed: Vec<&'static Target> = target::TARGETS
.iter()
.copied()
.filter(|t| has_hooks(t))
.collect();
return run_interactive(
installed,
"no pixtuoid hooks found to remove",
"Remove pixtuoid hooks from",
"uninstall",
|t| run_uninstall(t, None),
);
}
let plan = plan_targets(
args.target.as_deref(),
args.config.is_some(),
&detection(),
is_tty,
);
let targets = resolve_plan(plan)?;
if needs_confirm(targets.len(), args.yes, is_tty)
&& !confirm_targets("remove pixtuoid hooks from", &targets)
{
println!("aborted");
return Ok(());
}
run_each(&targets, "uninstall", |t| {
run_uninstall(t, args.config.clone())
})
}
fn resolve_plan(plan: Plan) -> Result<Vec<&'static Target>> {
match plan {
Plan::Targets(t) => Ok(t),
Plan::NothingDetected => {
println!("{NO_CLIS_MSG}");
Ok(vec![])
}
Plan::Conflict(msg) => bail!(msg),
}
}
fn needs_confirm(n: usize, yes: bool, is_tty: bool) -> bool {
!yes && is_tty && n > 1
}
fn confirm_targets(verb: &str, targets: &[&'static Target]) -> bool {
let names: Vec<_> = targets.iter().map(|t| t.display_name).collect();
confirm(&format!("{verb} {}?", names.join(" + ")))
}
fn run_each(
targets: &[&'static Target],
verb: &str,
op: impl Fn(&'static Target) -> Result<()>,
) -> Result<()> {
let mut failed = 0usize;
for &t in targets {
if let Err(e) = op(t) {
eprintln!("error: {verb} for {} failed: {e:#}", t.display_name);
failed += 1;
}
}
if failed > 0 {
bail!("{failed} of {} target(s) failed", targets.len());
}
Ok(())
}
fn resolve_hook_binary(
t: &Target,
hook_path: Option<PathBuf>,
locate: impl FnOnce() -> Result<PathBuf>,
) -> Result<PathBuf> {
if let Some(p) = hook_path {
return Ok(p);
}
match locate() {
Ok(p) => Ok(p),
Err(e) if t.needs_resolved_binary => Err(e),
Err(_) => Ok(PathBuf::from("pixtuoid-hook")),
}
}
fn run_install(t: &Target, config: Option<PathBuf>, hook_path: Option<PathBuf>) -> Result<()> {
let path = config.unwrap_or_else(|| (t.default_config_path)());
let binary = resolve_hook_binary(t, hook_path, io::default_hook_binary)?;
let hook_cmd = (t.hook_command)(&binary)?;
let content = io::read_config(&path)?;
let outcome = (t.merge_install)(&content, &hook_cmd)
.with_context(|| format!("processing {}", path.display()))?;
if t.needs_path_warning && !io::hook_on_path() {
println!("warn: `pixtuoid-hook` not found on PATH (checked against this shell).");
println!(" Install it on PATH, e.g. `cargo install --path crates/pixtuoid-hook`.");
}
if !outcome.changed {
println!("[{}] already up to date — {}", t.name, path.display());
return Ok(());
}
let backup = io::backup_once(&path, BACKUP_SUFFIX)?;
io::write_config_atomic(&path, &outcome.content)?;
println!(
"ok: installed pixtuoid hooks into {} ({})",
path.display(),
t.display_name
);
if let Some(b) = backup {
println!(
"backup: {} (removed automatically on uninstall-hooks)",
b.display()
);
}
if let Some(note) = t.post_install_note {
println!("{note}");
}
println!(
"→ start a new {} session for this to take effect.",
t.restart_noun
);
Ok(())
}
fn run_uninstall(t: &Target, config: Option<PathBuf>) -> Result<()> {
let path = config.unwrap_or_else(|| (t.default_config_path)());
let content = io::read_config(&path)?;
let outcome =
(t.merge_uninstall)(&content).with_context(|| format!("processing {}", path.display()))?;
if !outcome.changed {
println!(
"[{}] no pixtuoid hooks found in {} — nothing to remove",
t.name,
path.display()
);
return Ok(());
}
io::write_config_atomic(&path, &outcome.content)?;
println!(
"ok: removed pixtuoid hooks from {} ({})",
path.display(),
t.display_name
);
if let Some(b) = io::remove_backup(&path, BACKUP_SUFFIX)? {
println!("removed backup: {}", b.display());
}
println!(
"→ start a new {} session for this to take effect.",
t.restart_noun
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::install::target::{MergeOutcome, Target, CLAUDE, CODEX};
static FAKE: Target = Target {
name: "fake",
display_name: "Fake",
restart_noun: "Fake",
default_config_path: || std::path::PathBuf::from("/nonexistent/fake"),
hook_command: |_| Ok("x".into()),
merge_install: |c, _| {
Ok(MergeOutcome {
content: c.to_string(),
changed: false,
})
},
merge_uninstall: |c| {
Ok(MergeOutcome {
content: c.to_string(),
changed: false,
})
},
needs_path_warning: false,
needs_resolved_binary: false,
post_install_note: None,
};
fn fake2_config_path() -> std::path::PathBuf {
std::env::temp_dir().join(format!("pixtuoid-test-fake2-{}.toml", std::process::id()))
}
fn fake_dir_config_path() -> std::path::PathBuf {
std::env::temp_dir().join(format!("pixtuoid-test-fake-dir-{}", std::process::id()))
}
static FAKE2: Target = Target {
name: "fake2",
display_name: "Fake2",
restart_noun: "Fake2",
default_config_path: fake2_config_path,
hook_command: |_| Ok("x".into()),
merge_install: |c, _| {
Ok(MergeOutcome {
content: c.to_string(),
changed: false,
})
},
merge_uninstall: |c| {
Ok(MergeOutcome {
content: c.to_string(),
changed: !c.trim().is_empty(),
})
},
needs_path_warning: false,
needs_resolved_binary: false,
post_install_note: None,
};
static FAKE_DIR: Target = Target {
name: "fakedir",
display_name: "FakeDir",
restart_noun: "FakeDir",
default_config_path: fake_dir_config_path,
hook_command: |_| Ok("x".into()),
merge_install: |c, _| {
Ok(MergeOutcome {
content: c.to_string(),
changed: false,
})
},
merge_uninstall: |c| {
Ok(MergeOutcome {
content: c.to_string(),
changed: false,
})
},
needs_path_warning: false,
needs_resolved_binary: false,
post_install_note: None,
};
fn present(claude: bool, fake: bool) -> Vec<(&'static Target, bool)> {
vec![(&CLAUDE, claude), (&FAKE, fake)]
}
#[test]
fn resolve_hook_binary_explicit_path_wins() {
let got = resolve_hook_binary(&CLAUDE, Some(PathBuf::from("/x/hook")), || {
panic!("locate must not be called when --hook-path is given")
});
assert_eq!(got.unwrap(), PathBuf::from("/x/hook"));
}
#[test]
fn resolve_hook_binary_claude_falls_back_to_bare_name_when_unresolvable() {
let got = resolve_hook_binary(&CLAUDE, None, || Err(anyhow::anyhow!("could not locate")));
assert_eq!(got.unwrap(), PathBuf::from("pixtuoid-hook"));
}
#[test]
fn resolve_hook_binary_codex_errors_when_unresolvable() {
let got = resolve_hook_binary(&CODEX, None, || Err(anyhow::anyhow!("could not locate")));
assert!(got.is_err());
}
#[test]
fn explicit_target_claude_ignores_detection() {
let p = plan_targets(Some("claude"), false, &present(false, false), false);
assert!(matches!(p, Plan::Targets(ref t) if t.len() == 1 && t[0].name == "claude"));
}
#[test]
fn explicit_all_with_config_is_conflict() {
let p = plan_targets(Some("all"), true, &present(true, true), true);
assert!(matches!(p, Plan::Conflict(_)));
}
#[test]
fn no_target_tty_returns_detected() {
let p = plan_targets(None, false, &present(true, true), true);
assert!(matches!(p, Plan::Targets(ref t) if t.len() == 2));
}
#[test]
fn no_target_non_tty_single_claude_installs_claude() {
let p = plan_targets(None, false, &present(true, false), false);
assert!(matches!(p, Plan::Targets(ref t) if t.len() == 1 && t[0].name == "claude"));
}
#[test]
fn no_target_non_tty_multiple_present_is_conflict() {
let p = plan_targets(None, false, &present(true, true), false);
assert!(matches!(p, Plan::Conflict(_)));
}
#[test]
fn no_target_nothing_present_is_nothing_detected() {
let p = plan_targets(None, false, &present(false, false), false);
assert!(matches!(p, Plan::NothingDetected));
}
#[test]
fn confirm_answer_parses_default_yes() {
assert!(parse_confirm(""));
assert!(parse_confirm("y"));
assert!(parse_confirm("YES"));
assert!(!parse_confirm("n"));
assert!(!parse_confirm("no"));
assert!(!parse_confirm("garbage")); }
#[test]
fn interactive_pick_only_on_bare_tty() {
let none: Option<String> = None;
let no_cfg: Option<PathBuf> = None;
assert!(interactive_pick(&none, &no_cfg, false, true));
assert!(!interactive_pick(&none, &no_cfg, false, false));
assert!(!interactive_pick(&none, &no_cfg, true, true));
assert!(!interactive_pick(
&Some("claude".into()),
&no_cfg,
false,
true
));
assert!(!interactive_pick(
&none,
&Some(PathBuf::from("/x")),
false,
true
));
}
#[test]
fn confirm_read_eof_and_error_cancel_but_entered_line_decides() {
assert!(!interpret_confirm_read(Ok(0), ""));
assert!(!interpret_confirm_read(Err(()), ""));
assert!(interpret_confirm_read(Ok(1), "\n"));
assert!(interpret_confirm_read(Ok(2), "y\n"));
assert!(!interpret_confirm_read(Ok(2), "n\n"));
}
#[test]
fn all_with_nothing_present_is_nothing_detected() {
let p = plan_targets(Some("all"), false, &present(false, false), false);
assert!(matches!(p, Plan::NothingDetected));
}
#[test]
fn all_with_both_present_returns_both() {
let p = plan_targets(Some("all"), false, &present(true, true), false);
assert!(matches!(p, Plan::Targets(ref t) if t.len() == 2));
}
#[test]
fn unknown_explicit_target_is_conflict() {
let p = plan_targets(Some("bogus"), false, &present(true, true), false);
assert!(matches!(p, Plan::Conflict(_)));
}
#[test]
fn resolve_plan_targets_passes_through() {
match resolve_plan(Plan::Targets(vec![&CLAUDE])) {
Ok(got) => {
assert_eq!(got.len(), 1);
assert_eq!(got[0].name, "claude");
}
Err(e) => panic!("expected Ok, got {e}"),
}
}
#[test]
fn resolve_plan_nothing_detected_is_ok_empty() {
match resolve_plan(Plan::NothingDetected) {
Ok(got) => assert!(got.is_empty()),
Err(e) => panic!("expected Ok(empty), got {e}"),
}
}
#[test]
fn resolve_plan_conflict_is_err() {
match resolve_plan(Plan::Conflict("boom".into())) {
Ok(_) => panic!("expected a Conflict to be an Err"),
Err(e) => assert!(e.to_string().contains("boom")),
}
}
#[test]
fn run_each_all_ok_returns_ok() {
let n = std::cell::Cell::new(0);
run_each(&[&FAKE, &FAKE2], "install", |_| {
n.set(n.get() + 1);
Ok(())
})
.unwrap();
assert_eq!(n.get(), 2, "op ran for each target");
}
#[test]
fn run_each_reports_failed_count_and_bails() {
let err = run_each(&[&FAKE, &FAKE2], "install", |_| anyhow::bail!("kaboom")).unwrap_err();
assert!(
err.to_string().contains("2 of 2 target(s) failed"),
"got: {err}"
);
}
#[test]
fn needs_confirm_only_multi_target_interactive_no_yes() {
assert!(needs_confirm(2, false, true));
assert!(!needs_confirm(1, false, true)); assert!(!needs_confirm(2, true, true)); assert!(!needs_confirm(2, false, false)); }
#[test]
fn has_hooks_empty_config_is_false() {
assert!(!has_hooks(&FAKE));
}
#[test]
fn has_hooks_unreadable_config_is_true() {
let dir = fake_dir_config_path();
let _ = std::fs::remove_file(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert!(has_hooks(&FAKE_DIR));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn has_hooks_changed_vs_unchanged_arms() {
let path = fake2_config_path();
std::fs::write(&path, "model = \"x\"\n").unwrap();
assert!(has_hooks(&FAKE2));
std::fs::write(&path, " \n").unwrap();
assert!(!has_hooks(&FAKE2));
let _ = std::fs::remove_file(&path);
}
#[test]
fn run_interactive_zero_candidates_prints_and_skips_op() {
let ran = std::cell::Cell::new(false);
run_interactive(vec![], "nothing here", "prompt", "install", |_| {
ran.set(true);
Ok(())
})
.unwrap();
assert!(!ran.get(), "op must NOT run when there are no candidates");
}
#[test]
fn run_interactive_single_candidate_runs_op_once() {
let count = std::cell::Cell::new(0);
run_interactive(vec![&FAKE], "nothing here", "prompt", "install", |_| {
count.set(count.get() + 1);
Ok(())
})
.unwrap();
assert_eq!(
count.get(),
1,
"single candidate acts directly, no checklist"
);
}
#[test]
fn run_install_fake_target_is_up_to_date_noop() {
run_install(&FAKE, Some(PathBuf::from("/nonexistent/fake")), None).unwrap();
}
#[test]
fn run_install_claude_writes_sentinel_and_backs_up() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("settings.json");
std::fs::write(&cfg, "{}\n").unwrap();
run_install(
&CLAUDE,
Some(cfg.clone()),
Some(PathBuf::from("/fake/pixtuoid-hook")),
)
.unwrap();
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap();
assert!(v["hooks"]["PreToolUse"][0]["_pixtuoid"].as_bool().unwrap());
assert!(
tmp.path().join("settings.json.pixtuoid.bak").exists(),
"a backup of the prior content was written"
);
run_install(
&CLAUDE,
Some(cfg.clone()),
Some(PathBuf::from("/fake/pixtuoid-hook")),
)
.unwrap();
}
#[test]
fn run_uninstall_fake2_changed_writes_and_removes_backup() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("config.toml");
std::fs::write(&cfg, "model = \"x\"\n").unwrap(); let bak = tmp.path().join("config.toml.pixtuoid.bak");
std::fs::write(&bak, "backup").unwrap();
run_uninstall(&FAKE2, Some(cfg.clone())).unwrap();
assert!(
!bak.exists(),
"the backup is removed on a changing uninstall"
);
}
#[test]
fn run_uninstall_fake_unchanged_is_noop() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("config.toml");
std::fs::write(&cfg, "anything\n").unwrap();
let bak = tmp.path().join("config.toml.pixtuoid.bak");
std::fs::write(&bak, "backup").unwrap();
run_uninstall(&FAKE, Some(cfg.clone())).unwrap();
assert!(bak.exists(), "a no-op uninstall must NOT delete the backup");
}
}