Skip to content
Draft
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
32 changes: 32 additions & 0 deletions dev/pi-plugin.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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"]);
});
}
55 changes: 55 additions & 0 deletions src/cli/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/commands/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod codex;
mod create;
mod hints;
pub mod opencode;
mod pi;
mod plugins;
mod show;
mod state;
Expand All @@ -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,
};
Expand Down
133 changes: 133 additions & 0 deletions src/commands/config/pi.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<PathBuf> {
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<PathBuf> {
Ok(pi_agent_dir()?
.join("hooks")
.join("pre")
.join("worktrunk.ts"))
}

fn confirm_or_yes(yes: bool, prompt: &str, preview: impl Fn()) -> Result<bool> {
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 @ <bold>{target_display}</>"
))
);
return Ok(());
}

let action = if target.exists() { "Update" } else { "Install" };
let preview_msg = info_message(cformat!("Would write to <bold>{target_display}</>"));
let preview = || eprintln!("{}", preview_msg);
if !confirm_or_yes(
yes,
&cformat!("{action} Pi plugin @ <bold>{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 @ <bold>{target_display}</>"))
);
eprintln!(
"{}",
hint_message(cformat!(
"Activity markers (🤖/💬) will appear in <underline>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 <bold>{target_display}</>"));
let preview = || eprintln!("{}", preview_msg);
if !confirm_or_yes(
yes,
&cformat!("Remove Pi plugin @ <bold>{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 @ <bold>{target_display}</>"))
);
Ok(())
}
6 changes: 3 additions & 3 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 14 additions & 10 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
},
}
}

Expand Down
62 changes: 62 additions & 0 deletions tests/integration_tests/config_show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading