diff --git a/dev/pi-plugin.ts b/dev/pi-plugin.ts new file mode 100644 index 0000000000..a0879f35e5 --- /dev/null +++ b/dev/pi-plugin.ts @@ -0,0 +1,32 @@ +// Worktrunk activity tracking hook for Pi / oh-my-pi. +// +// Tracks agent activity per branch, showing status markers in `wt list`: +// 🤖 — agent is working +// 💬 — agent is waiting for input +// +// Installed globally via: wt config plugins pi install + +import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks"; + +export default function worktrunkActivity(pi: HookAPI): void { + const run = async ( + ctx: { cwd: string }, + args: ["set", string] | ["clear"], + ): Promise => { + await pi.exec("wt", ["config", "state", "marker", ...args], { + cwd: ctx.cwd, + }); + }; + + pi.on("agent_start", async (_event, ctx) => { + await run(ctx, ["set", "🤖"]); + }); + + pi.on("agent_end", async (_event, ctx) => { + await run(ctx, ["set", "💬"]); + }); + + pi.on("session_shutdown", async (_event, ctx) => { + await run(ctx, ["clear"]); + }); +} diff --git a/src/cli/config.rs b/src/cli/config.rs index a881e640a0..9cd2b59e2f 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -202,6 +202,41 @@ $ wt config plugins opencode uninstall Uninstall, } +// Ordering: action + inverse adjacent (install, uninstall). +#[derive(Subcommand)] +pub enum ConfigPluginsPiCommand { + /// Install the activity tracking hook + #[command( + after_long_help = r#"Writes the Worktrunk hook to Pi's profile-aware user hook directory. + +## Examples + +```console +$ wt config plugins pi install +$ wt config plugins pi install --yes +``` + +## Plugin location + +The default location is `~/.omp/agent/hooks/pre/worktrunk.ts`. The installer +honors `$PI_CODING_AGENT_DIR`, `$PI_CONFIG_DIR`, and active +`$OMP_PROFILE` / `$PI_PROFILE` profiles."# + )] + Install, + + /// Remove the activity tracking hook + #[command( + after_long_help = r#"Removes the Worktrunk hook from Pi's active user hook directory. + +## Examples + +```console +$ wt config plugins pi uninstall +```"# + )] + Uninstall, +} + // Ordering: action + inverse adjacent (install, uninstall). #[derive(Subcommand)] pub enum ConfigPluginsCodexCommand { @@ -342,6 +377,26 @@ config precedence: `$OPENCODE_CONFIG_DIR` > `$XDG_CONFIG_HOME/opencode` > #[command(subcommand)] action: ConfigPluginsOpencodeCommand, }, + + /// Pi / oh-my-pi activity hook + #[command( + after_long_help = r#"Activity tracking hook — shows status markers in `wt list`: +- 🤖 — agent is working +- 💬 — agent is waiting for input + +Pi's `session_shutdown` event clears the marker when the session exits. + +## Examples + +```console +$ wt config plugins pi install +$ wt config plugins pi uninstall +```"# + )] + Pi { + #[command(subcommand)] + action: ConfigPluginsPiCommand, + }, } // Ordering: action + inverse adjacent (install, uninstall), then related diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3357c96983..e87637e61b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,9 +6,9 @@ mod step; pub(crate) use config::{ ApprovalsCommand, CacheAction, CiStatusAction, ConfigAliasCommand, ConfigCommand, ConfigPluginsClaudeCommand, ConfigPluginsCodexCommand, ConfigPluginsCommand, - ConfigPluginsOpencodeCommand, ConfigShellCommand, DefaultBranchAction, GlobalFormatFlag, - HintsAction, LogsAction, MarkerAction, PreviousBranchAction, StateCommand, StateWrite, - VarsAction, + ConfigPluginsOpencodeCommand, ConfigPluginsPiCommand, ConfigShellCommand, DefaultBranchAction, + GlobalFormatFlag, HintsAction, LogsAction, MarkerAction, PreviousBranchAction, StateCommand, + StateWrite, VarsAction, }; pub(crate) use hook::{HOOK_TYPE_NAMES, HookCommand, HookOptions, parse_hook_type}; pub(crate) use list::ListSubcommand; diff --git a/src/commands/config/mod.rs b/src/commands/config/mod.rs index e204aaf487..039825c7e1 100644 --- a/src/commands/config/mod.rs +++ b/src/commands/config/mod.rs @@ -8,6 +8,7 @@ mod codex; mod create; mod hints; pub mod opencode; +mod pi; mod plugins; mod show; mod state; @@ -20,6 +21,7 @@ pub use codex::{handle_codex_install, handle_codex_uninstall}; pub use create::handle_config_create; pub use hints::{handle_hints_clear, handle_hints_get}; pub use opencode::{handle_opencode_install, handle_opencode_uninstall}; +pub use pi::{handle_pi_install, handle_pi_uninstall}; pub use plugins::{ handle_claude_install, handle_claude_install_statusline, handle_claude_uninstall, }; diff --git a/src/commands/config/pi.rs b/src/commands/config/pi.rs new file mode 100644 index 0000000000..533d677e8d --- /dev/null +++ b/src/commands/config/pi.rs @@ -0,0 +1,133 @@ +//! Pi / oh-my-pi activity-hook installation. +//! +//! Installs the embedded hook factory under Pi's profile-aware user agent +//! directory at `hooks/pre/worktrunk.ts`. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use color_print::cformat; +use worktrunk::path::format_path_for_display; +use worktrunk::styling::{eprintln, hint_message, info_message, success_message}; + +use crate::output::prompt::{PromptResponse, prompt_yes_no_preview}; + +const PLUGIN_SOURCE: &str = include_str!("../../../dev/pi-plugin.ts"); + +fn active_profile() -> Option { + let value = std::env::var("OMP_PROFILE") + .ok() + .or_else(|| std::env::var("PI_PROFILE").ok())?; + let profile = value.trim(); + (!profile.is_empty() && profile != "default").then(|| profile.to_owned()) +} + +fn pi_agent_dir() -> Result { + if let Some(path) = std::env::var("PI_CODING_AGENT_DIR") + .ok() + .filter(|value| !value.is_empty()) + .filter(|_| active_profile().is_none()) + { + return Ok(PathBuf::from(path)); + } + + let home = worktrunk::path::home_dir().context("Could not determine home directory")?; + let config_dir = std::env::var("PI_CONFIG_DIR") + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| ".omp".to_owned()); + let root = home.join(config_dir); + + Ok(match active_profile() { + Some(profile) => root.join("profiles").join(profile).join("agent"), + None => root.join("agent"), + }) +} + +pub fn plugin_path() -> Result { + Ok(pi_agent_dir()? + .join("hooks") + .join("pre") + .join("worktrunk.ts")) +} + +fn confirm_or_yes(yes: bool, prompt: &str, preview: impl Fn()) -> Result { + Ok(yes || prompt_yes_no_preview(prompt, preview)? == PromptResponse::Accepted) +} + +pub fn handle_pi_install(yes: bool) -> Result<()> { + let target = plugin_path()?; + let target_display = format_path_for_display(&target); + + if target.exists() + && let Ok(existing) = std::fs::read_to_string(&target) + && existing == PLUGIN_SOURCE + { + eprintln!( + "{}", + info_message(cformat!( + "Plugin already installed @ {target_display}" + )) + ); + return Ok(()); + } + + let action = if target.exists() { "Update" } else { "Install" }; + let preview_msg = info_message(cformat!("Would write to {target_display}")); + let preview = || eprintln!("{}", preview_msg); + if !confirm_or_yes( + yes, + &cformat!("{action} Pi plugin @ {target_display}?"), + preview, + )? { + return Ok(()); + } + + let parent = target + .parent() + .context("Plugin path has no parent directory")?; + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory {}", parent.display()))?; + worktrunk::utils::write_atomically(&target, PLUGIN_SOURCE) + .with_context(|| format!("Failed to write plugin to {target_display}"))?; + + eprintln!( + "{}", + success_message(cformat!("Plugin installed @ {target_display}")) + ); + eprintln!( + "{}", + hint_message(cformat!( + "Activity markers (🤖/💬) will appear in wt list" + )) + ); + Ok(()) +} + +pub fn handle_pi_uninstall(yes: bool) -> Result<()> { + let target = plugin_path()?; + let target_display = format_path_for_display(&target); + + if !target.exists() { + eprintln!("{}", info_message("Plugin not installed")); + return Ok(()); + } + + let preview_msg = info_message(cformat!("Would remove {target_display}")); + let preview = || eprintln!("{}", preview_msg); + if !confirm_or_yes( + yes, + &cformat!("Remove Pi plugin @ {target_display}?"), + preview, + )? { + return Ok(()); + } + + std::fs::remove_file(&target) + .with_context(|| format!("Failed to remove plugin @ {target_display}"))?; + eprintln!( + "{}", + success_message(cformat!("Plugin removed @ {target_display}")) + ); + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 63f3d96b10..4593014367 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -40,9 +40,9 @@ pub(crate) use config::{ handle_claude_uninstall, handle_codex_install, handle_codex_uninstall, handle_config_create, handle_config_show, handle_config_update, handle_hints_clear, handle_hints_get, handle_logs_list, handle_logs_profile, handle_opencode_install, handle_opencode_uninstall, - handle_state_clear, handle_state_clear_all, handle_state_get, handle_state_set, - handle_state_show, handle_vars_clear, handle_vars_get, handle_vars_list, handle_vars_set, - list_approvals, + handle_pi_install, handle_pi_uninstall, handle_state_clear, handle_state_clear_all, + handle_state_get, handle_state_set, handle_state_show, handle_vars_clear, handle_vars_get, + handle_vars_list, handle_vars_set, list_approvals, }; pub(crate) use configure_shell::{ handle_configure_shell, handle_show_theme, handle_unconfigure_shell, diff --git a/src/main.rs b/src/main.rs index 930036670a..e246a43cf2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,21 +46,21 @@ use commands::{ handle_config_create, handle_config_show, handle_config_update, handle_configure_shell, handle_custom_command, handle_hints_clear, handle_hints_get, handle_hook_show, handle_init, handle_list, handle_logs_list, handle_logs_profile, handle_merge, handle_opencode_install, - handle_opencode_uninstall, handle_promote, handle_rebase, handle_remove_command, - handle_show_theme, handle_squash, handle_state_clear, handle_state_clear_all, handle_state_get, - handle_state_set, handle_state_show, handle_switch_command, handle_unconfigure_shell, - handle_vars_clear, handle_vars_get, handle_vars_list, handle_vars_set, list_approvals, - run_hook, step_commit, step_copy_ignored, step_diff, step_eval, step_for_each, step_prune, - step_relocate, step_tether, + handle_opencode_uninstall, handle_pi_install, handle_pi_uninstall, handle_promote, + handle_rebase, handle_remove_command, handle_show_theme, handle_squash, handle_state_clear, + handle_state_clear_all, handle_state_get, handle_state_set, handle_state_show, + handle_switch_command, handle_unconfigure_shell, handle_vars_clear, handle_vars_get, + handle_vars_list, handle_vars_set, list_approvals, run_hook, step_commit, step_copy_ignored, + step_diff, step_eval, step_for_each, step_prune, step_relocate, step_tether, }; use cli::{ ApprovalsCommand, CacheAction, CiStatusAction, Cli, Commands, ConfigAliasCommand, ConfigCommand, ConfigPluginsClaudeCommand, ConfigPluginsCodexCommand, ConfigPluginsCommand, - ConfigPluginsOpencodeCommand, ConfigShellCommand, DefaultBranchAction, GlobalFormatFlag, - HintsAction, HookCommand, HookOptions, ListArgs, ListSubcommand, LogsAction, MarkerAction, - MergeArgs, PreviousBranchAction, StateCommand, StateWrite, StepCommand, SwitchFormat, - VarsAction, + ConfigPluginsOpencodeCommand, ConfigPluginsPiCommand, ConfigShellCommand, DefaultBranchAction, + GlobalFormatFlag, HintsAction, HookCommand, HookOptions, ListArgs, ListSubcommand, LogsAction, + MarkerAction, MergeArgs, PreviousBranchAction, StateCommand, StateWrite, StepCommand, + SwitchFormat, VarsAction, }; /// Render a clap error to stderr, appending a wt-specific nested-subcommand @@ -661,6 +661,10 @@ fn handle_plugins_command(action: ConfigPluginsCommand, yes: bool) -> anyhow::Re ConfigPluginsOpencodeCommand::Install => handle_opencode_install(yes), ConfigPluginsOpencodeCommand::Uninstall => handle_opencode_uninstall(yes), }, + ConfigPluginsCommand::Pi { action } => match action { + ConfigPluginsPiCommand::Install => handle_pi_install(yes), + ConfigPluginsPiCommand::Uninstall => handle_pi_uninstall(yes), + }, } } diff --git a/tests/integration_tests/config_show.rs b/tests/integration_tests/config_show.rs index 80c2d1f887..c4a941c53a 100644 --- a/tests/integration_tests/config_show.rs +++ b/tests/integration_tests/config_show.rs @@ -2789,6 +2789,68 @@ fn test_opencode_uninstall_prompt_declined(temp_home: TempDir) { ); } +// ============================================================================= +// Pi plugin install/uninstall +// ============================================================================= + +#[rstest] +fn test_pi_install_creates_profile_aware_hook(temp_home: TempDir) { + let settings = setup_home_snapshot_settings(&temp_home); + settings.bind(|| { + let mut cmd = wt_command(); + set_temp_home_env(&mut cmd, temp_home.path()); + cmd.env("OMP_PROFILE", "research"); + cmd.args(["config", "plugins", "pi", "install", "--yes"]); + + assert_cmd_snapshot!(cmd); + }); + + let canonical_home = + crate::common::canonicalize(temp_home.path()).unwrap_or_else(|_| temp_home.path().into()); + let plugin_path = canonical_home.join(".omp/profiles/research/agent/hooks/pre/worktrunk.ts"); + let content = fs::read_to_string(&plugin_path).expect("Pi hook should be installed"); + assert!(content.contains("agent_start")); + assert!(content.contains("agent_end")); + assert!(content.contains("session_shutdown")); +} + +#[rstest] +fn test_pi_install_honors_agent_dir_override(temp_home: TempDir) { + let agent_dir = temp_home.path().join("custom-pi-agent"); + let mut cmd = wt_command(); + set_temp_home_env(&mut cmd, temp_home.path()); + cmd.env("PI_CODING_AGENT_DIR", &agent_dir); + cmd.args(["config", "plugins", "pi", "install", "--yes"]); + + let output = cmd.output().expect("install command should run"); + assert!( + output.status.success(), + "install failed: stdout={}, stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + assert!(agent_dir.join("hooks/pre/worktrunk.ts").exists()); +} + +#[rstest] +fn test_pi_uninstall_removes_hook(temp_home: TempDir) { + let agent_dir = temp_home.path().join(".omp/agent"); + let plugin_path = agent_dir.join("hooks/pre/worktrunk.ts"); + fs::create_dir_all(plugin_path.parent().unwrap()).unwrap(); + fs::write(&plugin_path, include_str!("../../dev/pi-plugin.ts")).unwrap(); + + let settings = setup_home_snapshot_settings(&temp_home); + settings.bind(|| { + let mut cmd = wt_command(); + set_temp_home_env(&mut cmd, temp_home.path()); + cmd.args(["config", "plugins", "pi", "uninstall", "--yes"]); + + assert_cmd_snapshot!(cmd); + }); + + assert!(!plugin_path.exists()); +} + /// When $SHELL is not set but PSModulePath is, config show should display /// "Detected shell: powershell" in the diagnostics and show the verification hint. #[rstest] diff --git a/tests/snapshots/integration__integration_tests__config_show__pi_install_creates_profile_aware_hook.snap b/tests/snapshots/integration__integration_tests__config_show__pi_install_creates_profile_aware_hook.snap new file mode 100644 index 0000000000..e2204a3e64 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__config_show__pi_install_creates_profile_aware_hook.snap @@ -0,0 +1,49 @@ +--- +source: tests/integration_tests/config_show.rs +info: + program: wt + args: + - config + - plugins + - pi + - install + - "--yes" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + OMP_PROFILE: research + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Plugin installed @ ~/.omp/profiles/research/agent/hooks/pre/worktrunk.ts +↳ Activity markers (🤖/💬) will appear in wt list diff --git a/tests/snapshots/integration__integration_tests__config_show__pi_uninstall_removes_hook.snap b/tests/snapshots/integration__integration_tests__config_show__pi_uninstall_removes_hook.snap new file mode 100644 index 0000000000..efbb2bf3d9 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__config_show__pi_uninstall_removes_hook.snap @@ -0,0 +1,47 @@ +--- +source: tests/integration_tests/config_show.rs +info: + program: wt + args: + - config + - plugins + - pi + - uninstall + - "--yes" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Plugin removed @ ~/.omp/agent/hooks/pre/worktrunk.ts