aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@atuin.sh>2026-04-13 20:41:30 +0100
committerGitHub <noreply@github.com>2026-04-13 20:41:30 +0100
commit129ad222d2ea2896a06ed29cbce5faa0b0d7399e (patch)
tree06eca765ff80c62581ec69a23a631bdf64aaedfb
parentfeat: remove agent search from tui (#3397) (diff)
downloadatuin-129ad222d2ea2896a06ed29cbce5faa0b0d7399e.zip
feat: add pi hook installer (#3398)
Support installing the pi extension via `atuin hook install pi`. Bundle the extension in the binary and update the docs. <!-- Thank you for making a PR! Bug fixes are always welcome, but if you're adding a new feature or changing an existing one, we'd really appreciate if you open an issue, post on the forum, or drop in on Discord --> ## Checks - [ ] I am happy for maintainers to push small adjustments to this PR, to speed up the review cycle - [ ] I have checked that there are no existing pull requests for the same thing
-rw-r--r--crates/atuin-client/src/history.rs22
-rw-r--r--crates/atuin/src/command/client/hook.rs170
-rw-r--r--docs/docs/configuration/config.md27
-rw-r--r--docs/docs/guide/agent-hooks.md31
-rw-r--r--examples/pi/atuin.ts87
5 files changed, 282 insertions, 55 deletions
diff --git a/crates/atuin-client/src/history.rs b/crates/atuin-client/src/history.rs
index 996208d9..16f7d85a 100644
--- a/crates/atuin-client/src/history.rs
+++ b/crates/atuin-client/src/history.rs
@@ -19,7 +19,7 @@ mod builder;
pub mod store;
/// Known AI agent author values. Used to expand `$all-agent` and `$all-user` filters.
-pub const KNOWN_AGENTS: &[&str] = &["claude-code", "codex", "copilot"];
+pub const KNOWN_AGENTS: &[&str] = &["claude-code", "codex", "copilot", "pi"];
pub const AUTHOR_FILTER_ALL_USER: &str = "$all-user";
pub const AUTHOR_FILTER_ALL_AGENT: &str = "$all-agent";
@@ -540,9 +540,12 @@ mod tests {
use regex::RegexSet;
use time::macros::datetime;
- use crate::{history::HISTORY_VERSION, settings::Settings};
+ use crate::{
+ history::{AUTHOR_FILTER_ALL_AGENT, AUTHOR_FILTER_ALL_USER, HISTORY_VERSION},
+ settings::Settings,
+ };
- use super::History;
+ use super::{History, author_matches_filters, is_known_agent};
// Test that we don't save history where necessary
#[test]
@@ -604,6 +607,19 @@ mod tests {
}
#[test]
+ fn known_agents_include_pi() {
+ assert!(is_known_agent("pi"));
+ assert!(author_matches_filters(
+ "pi",
+ &[AUTHOR_FILTER_ALL_AGENT.to_string()]
+ ));
+ assert!(!author_matches_filters(
+ "pi",
+ &[AUTHOR_FILTER_ALL_USER.to_string()]
+ ));
+ }
+
+ #[test]
fn disable_secrets() {
let settings = Settings {
secrets_filter: false,
diff --git a/crates/atuin/src/command/client/hook.rs b/crates/atuin/src/command/client/hook.rs
index bb333c5f..ab7f4b7d 100644
--- a/crates/atuin/src/command/client/hook.rs
+++ b/crates/atuin/src/command/client/hook.rs
@@ -10,32 +10,54 @@ use serde_json::Value;
use super::history;
const HOOK_EVENT_TYPES: &[&str] = &["PreToolUse", "PostToolUse", "PostToolUseFailure"];
+const PI_EXTENSION_SOURCE: &str = include_str!("../../../../../examples/pi/atuin.ts");
+
+enum InstallKind {
+ JsonHooks {
+ config_path: &'static [&'static str],
+ hook_command: &'static str,
+ matcher: &'static str,
+ },
+ PiExtension {
+ extension_path: &'static [&'static str],
+ },
+}
struct AgentSpec {
aliases: &'static [&'static str],
actor_name: &'static str,
- config_path: &'static [&'static str],
- hook_command: &'static str,
- matcher: &'static str,
+ install_kind: InstallKind,
}
const CLAUDE_CODE: AgentSpec = AgentSpec {
aliases: &["claude-code", "claude"],
actor_name: "claude-code",
- config_path: &[".claude", "settings.json"],
- hook_command: "atuin hook claude-code",
- matcher: "Bash",
+ install_kind: InstallKind::JsonHooks {
+ config_path: &[".claude", "settings.json"],
+ hook_command: "atuin hook claude-code",
+ matcher: "Bash",
+ },
};
const CODEX: AgentSpec = AgentSpec {
aliases: &["codex"],
actor_name: "codex",
- config_path: &[".codex", "hooks.json"],
- hook_command: "atuin hook codex",
- matcher: "^Bash$",
+ install_kind: InstallKind::JsonHooks {
+ config_path: &[".codex", "hooks.json"],
+ hook_command: "atuin hook codex",
+ matcher: "^Bash$",
+ },
};
-const AGENTS: &[&AgentSpec] = &[&CLAUDE_CODE, &CODEX];
+const PI: AgentSpec = AgentSpec {
+ aliases: &["pi"],
+ actor_name: "pi",
+ install_kind: InstallKind::PiExtension {
+ extension_path: &[".pi", "agent", "extensions", "atuin.ts"],
+ },
+};
+
+const AGENTS: &[&AgentSpec] = &[&CLAUDE_CODE, &CODEX, &PI];
struct Agent(&'static AgentSpec);
@@ -47,7 +69,7 @@ impl Agent {
.find(|spec| spec.aliases.contains(&name))
.map(Self)
.ok_or_else(|| {
- eyre::eyre!("unknown agent: {name}. Supported agents: claude-code, codex")
+ eyre::eyre!("unknown agent: {name}. Supported agents: claude-code, codex, pi")
})
}
@@ -55,19 +77,13 @@ impl Agent {
self.0.actor_name
}
- fn config_path(&self) -> PathBuf {
- self.0
- .config_path
- .iter()
+ fn path(path: &'static [&'static str]) -> PathBuf {
+ path.iter()
.fold(home_dir(), |path, segment| path.join(segment))
}
- fn hook_command(&self) -> &'static str {
- self.0.hook_command
- }
-
- fn matcher(&self) -> &'static str {
- self.0.matcher
+ fn install_kind(&self) -> &InstallKind {
+ &self.0.install_kind
}
}
@@ -175,6 +191,10 @@ fn id_file_path(tool_use_id: &str) -> PathBuf {
async fn handle(agent_name: &str, settings: &Settings) -> Result<()> {
let agent = Agent::from_name(agent_name)?;
+ if matches!(agent.install_kind(), InstallKind::PiExtension { .. }) {
+ bail!("`atuin hook pi` is not supported. Use `atuin hook install pi` and reload pi.");
+ }
+
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
@@ -218,42 +238,80 @@ async fn handle(agent_name: &str, settings: &Settings) -> Result<()> {
fn install(agent_name: &str) -> Result<()> {
let agent = Agent::from_name(agent_name)?;
- let config_path = agent.config_path();
- if let Some(parent) = config_path.parent() {
- std::fs::create_dir_all(parent)?;
- }
+ match agent.install_kind() {
+ InstallKind::JsonHooks {
+ config_path,
+ hook_command: _,
+ matcher: _,
+ } => {
+ let config_path = Agent::path(config_path);
- let mut root: Value = if config_path.exists() {
- let content = std::fs::read_to_string(&config_path)?;
- serde_json::from_str(&content)?
- } else {
- Value::Object(serde_json::Map::new())
- };
+ if let Some(parent) = config_path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+
+ let mut root: Value = if config_path.exists() {
+ let content = std::fs::read_to_string(&config_path)?;
+ serde_json::from_str(&content)?
+ } else {
+ Value::Object(serde_json::Map::new())
+ };
+
+ let hooks = root
+ .as_object_mut()
+ .ok_or_else(|| eyre::eyre!("config is not a JSON object"))?
+ .entry("hooks")
+ .or_insert_with(|| Value::Object(serde_json::Map::new()));
- let hooks = root
- .as_object_mut()
- .ok_or_else(|| eyre::eyre!("config is not a JSON object"))?
- .entry("hooks")
- .or_insert_with(|| Value::Object(serde_json::Map::new()));
+ add_hook_entries(hooks, &agent)?;
- add_hook_entries(hooks, &agent)?;
+ let content = serde_json::to_string_pretty(&root)?;
+ std::fs::write(&config_path, content)?;
- let content = serde_json::to_string_pretty(&root)?;
- std::fs::write(&config_path, content)?;
+ eprintln!(
+ "\nAtuin hooks installed for {}. Config: {}",
+ agent.actor_name(),
+ config_path.display()
+ );
+ }
+ InstallKind::PiExtension { extension_path } => {
+ let extension_path = Agent::path(extension_path);
+
+ if let Some(parent) = extension_path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
- eprintln!(
- "\nAtuin hooks installed for {}. Config: {}",
- agent.actor_name(),
- config_path.display()
- );
+ let already_installed = std::fs::read_to_string(&extension_path)
+ .is_ok_and(|existing| existing == PI_EXTENSION_SOURCE);
+
+ if already_installed {
+ eprintln!("pi extension: already installed, skipping");
+ } else {
+ std::fs::write(&extension_path, PI_EXTENSION_SOURCE)?;
+ eprintln!("pi extension: installed atuin extension");
+ }
+
+ eprintln!(
+ "\nAtuin extension installed for {}. Extension: {}\nReload pi with `/reload` or restart pi.",
+ agent.actor_name(),
+ extension_path.display()
+ );
+ }
+ }
Ok(())
}
fn add_hook_entries(hooks: &mut Value, agent: &Agent) -> Result<()> {
- let hook_command = agent.hook_command();
- let matcher = agent.matcher();
+ let InstallKind::JsonHooks {
+ config_path: _,
+ hook_command,
+ matcher,
+ } = agent.install_kind()
+ else {
+ bail!("agent does not use JSON hooks")
+ };
for event_type in HOOK_EVENT_TYPES {
let event_hooks = hooks
@@ -318,6 +376,26 @@ mod tests {
}
#[test]
+ fn parse_hook_install_pi_command() {
+ let cmd = Cmd::try_parse_from(["hook", "install", "pi"]).unwrap();
+
+ match (cmd.action, cmd.agent) {
+ (Some(Action::Install { agent }), None) => assert_eq!(agent, "pi"),
+ other => panic!("unexpected parsed command: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn agent_from_name_supports_pi() {
+ let agent = Agent::from_name("pi").unwrap();
+ assert_eq!(agent.actor_name(), "pi");
+ assert!(matches!(
+ agent.install_kind(),
+ InstallKind::PiExtension { .. }
+ ));
+ }
+
+ #[test]
fn parse_top_level_hook_command() {
let cmd = Atuin::try_parse_from(["atuin", "hook", "codex"]).unwrap();
diff --git a/docs/docs/configuration/config.md b/docs/docs/configuration/config.md
index f453f903..68ac9b82 100644
--- a/docs/docs/configuration/config.md
+++ b/docs/docs/configuration/config.md
@@ -576,6 +576,33 @@ frequency_score_multiplier = 0.8
frecency_score_multiplier = 2.0
```
+#### `authors`
+
+Default: `["$all-user"]`
+
+Filter search results by command author. This controls which commands appear in interactive search based on who (or what) ran them. Useful when AI coding agents are recording commands via [agent hooks](../guide/agent-hooks.md).
+
+Special values:
+
+| Value | Meaning |
+|-------|---------|
+| `$all-user` | Commands from any author that is **not** a known AI agent |
+| `$all-agent` | Commands from any known AI agent |
+
+You can also use literal author names like `"claude-code"`, `"codex"`, or `"pi"`.
+
+```toml
+[search]
+# Default: only show human-authored commands
+authors = ["$all-user"]
+
+# Show everything (no author filtering)
+# authors = []
+
+# Show commands from you and Claude Code
+# authors = ["$all-user", "claude-code"]
+```
+
## Stats
This section of client config is specifically for configuring Atuin stats calculations
diff --git a/docs/docs/guide/agent-hooks.md b/docs/docs/guide/agent-hooks.md
index 6e02b794..cefeb155 100644
--- a/docs/docs/guide/agent-hooks.md
+++ b/docs/docs/guide/agent-hooks.md
@@ -1,10 +1,10 @@
# AI Agent Hooks
-Atuin can capture commands run by AI coding agents (like Claude Code and Codex) alongside your regular shell history. Each command is tagged with the agent that ran it, so you can filter your history by author.
+Atuin can capture commands run by AI coding agents (like Claude Code, Codex, and pi) alongside your regular shell history. Each command is tagged with the agent that ran it, so you can filter your history by author.
## Quick Start
-Install hooks for your agent, then restart the agent:
+Install hooks for your agent, then restart or reload the agent:
```shell
# Claude Code
@@ -12,6 +12,9 @@ atuin hook install claude-code
# Codex
atuin hook install codex
+
+# pi
+atuin hook install pi
```
That's it. Commands the agent runs will now appear in your Atuin history, tagged with the agent's name.
@@ -20,12 +23,13 @@ That's it. Commands the agent runs will now appear in your Atuin history, tagged
AI coding agents support hook systems that notify external tools when they're about to run a shell command and when the command finishes. Atuin uses these hooks to record each command as a history entry, just like commands you type yourself.
-When `atuin hook install` runs, it writes the agent's config file to register Atuin as a hook handler:
+When `atuin hook install` runs, it writes the agent's config file or extension to register Atuin as a hook handler:
-| Agent | Config file |
-|-------|-------------|
+| Agent | Config file / extension |
+|-------|-------------------------|
| Claude Code | `~/.claude/settings.json` |
| Codex | `~/.codex/hooks.json` |
+| pi | `~/.pi/agent/extensions/atuin.ts` |
The hook lifecycle:
@@ -73,7 +77,7 @@ authors = []
authors = ["$all-agent"]
```
-Currently recognized agent names are: `claude-code`, `codex`, and `copilot`.
+Currently recognized agent names are: `claude-code`, `codex`, `copilot`, and `pi`.
## Supported Agents
@@ -93,6 +97,16 @@ atuin hook install codex
This adds hook entries to `~/.codex/hooks.json`. Codex calls `atuin hook codex` on each Bash tool use matching `^Bash$`.
+### pi
+
+```shell
+atuin hook install pi
+```
+
+This writes Atuin's extension to `~/.pi/agent/extensions/atuin.ts`.
+
+Then restart pi or run `/reload`. The extension wraps pi's built-in `bash` tool and records every bash command with author `pi` by calling `atuin history start` before execution and `atuin history end` afterwards.
+
## Verifying Installation
After installing hooks and restarting your agent, run a command through the agent and then check your history:
@@ -113,6 +127,9 @@ cat ~/.claude/settings.json | grep atuin
# Codex
cat ~/.codex/hooks.json | grep atuin
+
+# pi
+ls ~/.pi/agent/extensions/atuin.ts
```
## Re-installing
@@ -124,3 +141,5 @@ hooks.PreToolUse: already installed, skipping
hooks.PostToolUse: already installed, skipping
hooks.PostToolUseFailure: already installed, skipping
```
+
+For pi, reinstalling will also skip if the managed extension already matches the bundled version.
diff --git a/examples/pi/atuin.ts b/examples/pi/atuin.ts
new file mode 100644
index 00000000..55c17cb8
--- /dev/null
+++ b/examples/pi/atuin.ts
@@ -0,0 +1,87 @@
+/**
+ * Atuin extension for pi.
+ *
+ * Tracks bash commands executed by pi in Atuin history with author `pi`.
+ *
+ * Install with:
+ * atuin hook install pi
+ *
+ * Then restart pi or run /reload.
+ */
+
+import type { BashOperations, ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { createBashTool, createLocalBashOperations } from "@mariozechner/pi-coding-agent";
+
+const ATUIN_AUTHOR = "pi";
+const ATUIN_TIMEOUT_MS = 10_000;
+
+async function startHistory(
+ pi: ExtensionAPI,
+ cwd: string,
+ command: string,
+): Promise<string | undefined> {
+ try {
+ const result = await pi.exec(
+ "atuin",
+ ["history", "start", "--author", ATUIN_AUTHOR, "--", command],
+ { cwd, timeout: ATUIN_TIMEOUT_MS },
+ );
+
+ if (result.code !== 0) return undefined;
+
+ const id = result.stdout.trim();
+ return id.length > 0 ? id : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+async function endHistory(
+ pi: ExtensionAPI,
+ cwd: string,
+ historyId: string,
+ exitCode: number,
+): Promise<void> {
+ try {
+ await pi.exec(
+ "atuin",
+ ["history", "end", historyId, "--exit", String(exitCode)],
+ { cwd, timeout: ATUIN_TIMEOUT_MS },
+ );
+ } catch {
+ // Ignore Atuin failures so command execution is never blocked.
+ }
+}
+
+export default function atuinPiExtension(pi: ExtensionAPI) {
+ const cwd = process.cwd();
+ const local = createLocalBashOperations();
+
+ const trackedOperations: BashOperations = {
+ async exec(command, commandCwd, options) {
+ const historyId = await startHistory(pi, commandCwd, command);
+ let exitCode: number | null = null;
+
+ try {
+ const result = await local.exec(command, commandCwd, options);
+ exitCode = result.exitCode;
+ return result;
+ } finally {
+ if (historyId) {
+ await endHistory(
+ pi,
+ commandCwd,
+ historyId,
+ exitCode ?? (options.signal?.aborted ? 130 : 1),
+ );
+ }
+ }
+ },
+ };
+
+ pi.registerTool(
+ createBashTool(cwd, {
+ operations: trackedOperations,
+ }),
+ );
+}