From 12751787dd294df62ea40fd20531b00185827909 Mon Sep 17 00:00:00 2001 From: winjer Date: Mon, 20 Jul 2026 16:41:09 +0100 Subject: [PATCH] feat: move OpenCode/Pi plugin install into `code-trace setup` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final slice of the installer migration: agent plugin/extension install moves from install.sh into the binary as `code-trace setup --install-opencode | --offer-opencode | --install-pi | --offer-pi`. - src/setup.rs embeds the plugin sources with include_str!, so install works under curl | bash — where there is no local checkout to copy from and the old shell path silently skipped. Detection, the y/N prompt (read from /dev/tty in-process, so it can't consume the piped script), and the write are all in Rust and unit-tested. - install.sh shrinks from 365 to 173 lines: the plugin-source resolution, copy helpers, agent detection, the TTY_IN/resolve_tty_in machinery, and the remaining bash prompts are all gone. It is now a bootstrap (detect platform, download binary, PATH) that hands off to `code-trace setup` for every configuration step. - Tests: 5 new unit tests (embedded sources, plugin paths, detection, install write) + 4 integration tests driving the real binary (install writes the embedded source; offer skips when not detected). Verified end to end through a piped curl | bash simulation with OpenCode present: the plugin is offered, installed from the embedded source, and the email prompt still works — all in one run. Co-Authored-By: Claude Opus 4.8 (1M context) --- install.sh | 111 +++---------------------- src/setup.rs | 184 +++++++++++++++++++++++++++++++++++++++++- tests/install_test.rs | 58 +++++++++++++ 3 files changed, 251 insertions(+), 102 deletions(-) diff --git a/install.sh b/install.sh index 6af8270..d3d4651 100755 --- a/install.sh +++ b/install.sh @@ -6,36 +6,6 @@ BINARY="code-trace" INSTALL_DIR="${HOME}/.local/bin" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SETTINGS_FILE="${HOME}/.claude/settings.json" -OPENCODE_PLUGIN_DIR="${HOME}/.config/opencode/plugins" -PI_EXTENSION_DIR="${HOME}/.pi/agent/extensions" - -# Where interactive prompts read from. Resolved once by resolve_tty_in; empty -# until then, and empty means "no terminal — skip every prompt". -TTY_IN="" - -# Resolve where interactive prompts should read from. Prefer stdin when it is a -# TTY (e.g. `bash install.sh`); otherwise the controlling terminal, so a piped -# install (curl | bash) — whose stdin is the script itself, not a TTY — can -# still prompt without reading its own script text as the answer. Empty when no -# terminal can be opened at all (CI/unattended): every prompt is then skipped -# rather than consuming the piped script. Idempotent. -resolve_tty_in() { - if [ -t 0 ]; then - TTY_IN="/dev/stdin" - elif { : < /dev/tty; } 2>/dev/null; then - TTY_IN="/dev/tty" - else - TTY_IN="" - fi -} - -detect_opencode() { - [ -d "${HOME}/.config/opencode" ] || [ -f "${HOME}/.config/opencode/opencode.json" ] -} - -detect_pi() { - [ -d "${HOME}/.pi/agent" ] -} # Resolve platform target triple into the global TARGET. resolve_target() { @@ -136,80 +106,24 @@ register_claude_code_hook() { fi } -# Resolve plugin/extension source paths into globals. -resolve_plugin_sources() { - PLUGIN_SRC="" - if [ -f "${SCRIPT_DIR}/plugin/opencode/code-trace.ts" ]; then - PLUGIN_SRC="${SCRIPT_DIR}/plugin/opencode/code-trace.ts" - elif [ -f "${SCRIPT_DIR}/../plugin/opencode/code-trace.ts" ]; then - PLUGIN_SRC="$(cd "${SCRIPT_DIR}/../plugin/opencode" && pwd)/code-trace.ts" - fi - - PI_PLUGIN_SRC="" - if [ -f "${SCRIPT_DIR}/plugin/pi-agent/code-trace.ts" ]; then - PI_PLUGIN_SRC="${SCRIPT_DIR}/plugin/pi-agent/code-trace.ts" - elif [ -f "${SCRIPT_DIR}/../plugin/pi-agent/code-trace.ts" ]; then - PI_PLUGIN_SRC="$(cd "${SCRIPT_DIR}/../plugin/pi-agent" && pwd)/code-trace.ts" - fi -} - -# Install OpenCode plugin -install_opencode_plugin() { - if [ -z "${PLUGIN_SRC}" ] || [ ! -f "${PLUGIN_SRC}" ]; then - echo "" - echo "Note: Plugin source not found at ${PLUGIN_SRC}. Skipping OpenCode plugin install." - echo "You can manually copy plugin/opencode/code-trace.ts to ${OPENCODE_PLUGIN_DIR}/" - return - fi - - mkdir -p "${OPENCODE_PLUGIN_DIR}" - cp "${PLUGIN_SRC}" "${OPENCODE_PLUGIN_DIR}/code-trace.ts" - echo "Installed OpenCode plugin to ${OPENCODE_PLUGIN_DIR}/code-trace.ts" -} - -# Install Pi Agent extension -install_pi_extension() { - if [ -z "${PI_PLUGIN_SRC}" ] || [ ! -f "${PI_PLUGIN_SRC}" ]; then - echo "" - echo "Note: Pi extension source not found at ${PI_PLUGIN_SRC}. Skipping Pi Agent extension install." - echo "You can manually copy plugin/pi-agent/code-trace.ts to ${PI_EXTENSION_DIR}/" - return - fi - - mkdir -p "${PI_EXTENSION_DIR}" - cp "${PI_PLUGIN_SRC}" "${PI_EXTENSION_DIR}/code-trace.ts" - echo "Installed Pi Agent extension to ${PI_EXTENSION_DIR}/code-trace.ts" -} - +# Install the OpenCode plugin (forced with --opencode, otherwise offered when +# OpenCode is detected). The binary owns detection, the prompt, and the embedded +# plugin source, so this works under curl | bash with no local checkout. maybe_install_opencode() { if [ "${INSTALL_OPENCODE}" = true ]; then - install_opencode_plugin - elif detect_opencode && [ -n "${TTY_IN}" ]; then - echo "" - echo "OpenCode detected. Install the code-trace plugin?" - echo " ${OPENCODE_PLUGIN_DIR}/code-trace.ts" - echo "" - local reply="" - read -p "Install OpenCode plugin? [y/N] " -r reply < "${TTY_IN}" || reply="" - if [[ "${reply}" =~ ^[Yy]$ ]]; then - install_opencode_plugin - fi + "${INSTALL_DIR}/${BINARY}" setup --install-opencode || true + else + "${INSTALL_DIR}/${BINARY}" setup --offer-opencode || true fi } +# Install the Pi Agent extension (forced with --pi, otherwise offered when Pi is +# detected). As above, the binary owns detection, the prompt, and the source. maybe_install_pi() { if [ "${INSTALL_PI}" = true ]; then - install_pi_extension - elif detect_pi && [ -n "${TTY_IN}" ]; then - echo "" - echo "Pi Agent detected. Install the code-trace extension?" - echo " ${PI_EXTENSION_DIR}/code-trace.ts" - echo "" - local reply="" - read -p "Install Pi Agent extension? [y/N] " -r reply < "${TTY_IN}" || reply="" - if [[ "${reply}" =~ ^[Yy]$ ]]; then - install_pi_extension - fi + "${INSTALL_DIR}/${BINARY}" setup --install-pi || true + else + "${INSTALL_DIR}/${BINARY}" setup --offer-pi || true fi } @@ -240,12 +154,9 @@ main() { INSTALL_PI=true fi - resolve_tty_in - resolve_target install_binary ensure_path - resolve_plugin_sources if ! register_claude_code_hook "${SETTINGS_FILE}"; then : # message already printed; do not abort the rest of the install diff --git a/src/setup.rs b/src/setup.rs index 078965b..3a27de2 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -154,6 +154,41 @@ fn with_appended_user_id(existing: &str, email: &str) -> String { out } +// The agent plugin/extension sources are baked into the binary, so plugin +// install works even under `curl | bash`, where there is no local checkout to +// copy from. +const OPENCODE_PLUGIN: &str = include_str!("../plugin/opencode/code-trace.ts"); +const PI_EXTENSION: &str = include_str!("../plugin/pi-agent/code-trace.ts"); + +fn opencode_plugin_path(home: &Path) -> PathBuf { + home.join(".config/opencode/plugins/code-trace.ts") +} + +fn pi_extension_path(home: &Path) -> PathBuf { + home.join(".pi/agent/extensions/code-trace.ts") +} + +/// OpenCode is considered present if its config directory (or config file) +/// exists under `home`. +fn opencode_detected(home: &Path) -> bool { + home.join(".config/opencode").is_dir() || home.join(".config/opencode/opencode.json").is_file() +} + +/// Pi is considered present if its agent directory exists under `home`. +fn pi_detected(home: &Path) -> bool { + home.join(".pi/agent").is_dir() +} + +/// Write `contents` to `target`, creating parent directories as needed. +fn install_plugin(target: &Path, contents: &str) -> Result<(), String> { + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("could not create {}: {e}", parent.display()))?; + } + std::fs::write(target, contents) + .map_err(|e| format!("could not write {}: {e}", target.display())) +} + /// Read the settings file (treating a missing file as empty), register the /// canonical hook, and write it back with 2-space indentation. Refuses to /// overwrite a file whose existing contents are not valid JSON. @@ -278,11 +313,68 @@ pub fn write_config( Ok(()) } +fn home_dir() -> PathBuf { + PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "~".to_string())) +} + +/// Ask a yes/no question on the terminal. None when no terminal is available; +/// otherwise true only for an exact `y`/`Y`, matching the previous shell prompt. +fn prompt_yes_no(question: &str) -> Option { + use std::io::Write; + eprint!("{question}"); + let _ = std::io::stderr().flush(); + let line = read_terminal_line()?; + let answer = line.trim(); + Some(answer == "y" || answer == "Y") +} + +/// Install an agent plugin/extension, either forced or offered (detected + +/// confirmed at the prompt). Returns a process exit code (0 = success/skipped). +fn offer_or_install( + force: bool, + detected: bool, + target: &Path, + contents: &str, + detected_line: &str, + question: &str, + installed_line: &str, +) -> i32 { + let should_install = if force { + true + } else if detected { + println!(); + println!("{detected_line}"); + println!(" {}", target.display()); + matches!(prompt_yes_no(question), Some(true)) + } else { + false + }; + + if !should_install { + return 0; + } + + match install_plugin(target, contents) { + Ok(()) => { + println!("{installed_line} {}", target.display()); + 0 + } + Err(msg) => { + eprintln!("{msg}"); + 1 + } + } +} + /// `code-trace setup` entry point. `args` is everything after `setup`. pub fn run(args: &[String]) -> i32 { let mut register = false; let mut write = false; let mut no_prompt = false; + let mut install_opencode = false; + let mut offer_opencode = false; + let mut install_pi = false; + let mut offer_pi = false; let mut settings_path: Option = None; let mut config_path: Option = None; let mut user_email: Option = None; @@ -293,6 +385,10 @@ pub fn run(args: &[String]) -> i32 { "--register-hook" => register = true, "--write-config" => write = true, "--no-prompt" => no_prompt = true, + "--install-opencode" => install_opencode = true, + "--offer-opencode" => offer_opencode = true, + "--install-pi" => install_pi = true, + "--offer-pi" => offer_pi = true, "--settings-file" => { i += 1; match args.get(i) { @@ -331,12 +427,18 @@ pub fn run(args: &[String]) -> i32 { i += 1; } - if !register && !write { - eprintln!("setup: nothing to do (expected --register-hook and/or --write-config)"); + let any_action = + register || write || install_opencode || offer_opencode || install_pi || offer_pi; + if !any_action { + eprintln!( + "setup: nothing to do (expected one of --register-hook, --write-config, \ + --install-opencode, --offer-opencode, --install-pi, --offer-pi)" + ); return 2; } let mut code = 0; + let home = home_dir(); if register { let path = settings_path.unwrap_or_else(default_settings_path); @@ -349,6 +451,30 @@ pub fn run(args: &[String]) -> i32 { } } + if install_opencode || offer_opencode { + code |= offer_or_install( + install_opencode, + opencode_detected(&home), + &opencode_plugin_path(&home), + OPENCODE_PLUGIN, + "OpenCode detected. Install the code-trace plugin?", + "Install OpenCode plugin? [y/N] ", + "Installed OpenCode plugin to", + ); + } + + if install_pi || offer_pi { + code |= offer_or_install( + install_pi, + pi_detected(&home), + &pi_extension_path(&home), + PI_EXTENSION, + "Pi Agent detected. Install the code-trace extension?", + "Install Pi Agent extension? [y/N] ", + "Installed Pi Agent extension to", + ); + } + if write { let path = config_path.unwrap_or_else(default_config_path); if let Err(msg) = write_config(&path, user_email.as_deref(), !no_prompt) { @@ -571,4 +697,58 @@ mod tests { assert!(out.contains("TRACE_TO_LANGFUSE=true\nLANGFUSE_USER_ID=me@example.com\n")); assert!(config_has_user_id(&out)); } + + // --- plugin install --- + + /// A unique empty temp directory for a test. + fn temp_home(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("code-trace-ut-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn embedded_plugin_sources_are_present() { + assert!(!OPENCODE_PLUGIN.is_empty()); + assert!(!PI_EXTENSION.is_empty()); + assert!(OPENCODE_PLUGIN.contains("code-trace")); + assert!(PI_EXTENSION.contains("code-trace")); + } + + #[test] + fn plugin_paths_are_under_home() { + assert_eq!( + opencode_plugin_path(Path::new("/home/x")), + Path::new("/home/x/.config/opencode/plugins/code-trace.ts") + ); + assert_eq!( + pi_extension_path(Path::new("/home/x")), + Path::new("/home/x/.pi/agent/extensions/code-trace.ts") + ); + } + + #[test] + fn opencode_detected_only_when_config_dir_exists() { + let home = temp_home("oc-detect"); + assert!(!opencode_detected(&home)); + std::fs::create_dir_all(home.join(".config/opencode")).unwrap(); + assert!(opencode_detected(&home)); + } + + #[test] + fn pi_detected_only_when_agent_dir_exists() { + let home = temp_home("pi-detect"); + assert!(!pi_detected(&home)); + std::fs::create_dir_all(home.join(".pi/agent")).unwrap(); + assert!(pi_detected(&home)); + } + + #[test] + fn install_plugin_creates_parents_and_writes_contents() { + let home = temp_home("install"); + let target = home.join(".config/opencode/plugins/code-trace.ts"); + install_plugin(&target, "hello world").unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "hello world"); + } } diff --git a/tests/install_test.rs b/tests/install_test.rs index 49f738a..a1f3a97 100644 --- a/tests/install_test.rs +++ b/tests/install_test.rs @@ -216,3 +216,61 @@ fn write_config_existing_file_without_email_is_noop() { assert!(write_config(&file, &["--no-prompt"]).status.success()); assert_eq!(std::fs::read_to_string(&file).unwrap(), original); } + +// --- setup plugin install --- + +/// Run `setup` with `$HOME` pointed at an isolated directory, so plugin paths +/// and agent detection resolve under the scratch dir. +fn setup_with_home(home: &Path, args: &[&str]) -> std::process::Output { + Command::new(BIN) + .arg("setup") + .args(args) + .env("HOME", home) + .output() + .expect("failed to run code-trace setup") +} + +fn repo_file(rel: &str) -> String { + std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(rel)) + .expect("read repo plugin source") +} + +#[test] +fn install_opencode_writes_embedded_plugin() { + let home = scratch("oc-install"); + assert!(setup_with_home(&home, &["--install-opencode"]).status.success()); + let installed = home.join(".config/opencode/plugins/code-trace.ts"); + assert_eq!( + std::fs::read_to_string(&installed).unwrap(), + repo_file("plugin/opencode/code-trace.ts"), + "installed plugin must match the repo source embedded in the binary" + ); +} + +#[test] +fn install_pi_writes_embedded_extension() { + let home = scratch("pi-install"); + assert!(setup_with_home(&home, &["--install-pi"]).status.success()); + let installed = home.join(".pi/agent/extensions/code-trace.ts"); + assert_eq!( + std::fs::read_to_string(&installed).unwrap(), + repo_file("plugin/pi-agent/code-trace.ts") + ); +} + +#[test] +fn offer_opencode_skips_when_not_detected() { + let home = scratch("oc-offer-absent"); + assert!(setup_with_home(&home, &["--offer-opencode"]).status.success()); + assert!( + !home.join(".config/opencode/plugins/code-trace.ts").exists(), + "must not install when OpenCode is not detected (no prompt)" + ); +} + +#[test] +fn offer_pi_skips_when_not_detected() { + let home = scratch("pi-offer-absent"); + assert!(setup_with_home(&home, &["--offer-pi"]).status.success()); + assert!(!home.join(".pi/agent/extensions/code-trace.ts").exists()); +}