diff --git a/contrib/opencode/atuin.ts b/contrib/opencode/atuin.ts new file mode 120000 index 000000000..523eabb24 --- /dev/null +++ b/contrib/opencode/atuin.ts @@ -0,0 +1 @@ +../../crates/atuin/contrib/opencode/atuin.ts \ No newline at end of file diff --git a/crates/atuin/contrib/opencode/atuin.ts b/crates/atuin/contrib/opencode/atuin.ts new file mode 100644 index 000000000..9bf40622c --- /dev/null +++ b/crates/atuin/contrib/opencode/atuin.ts @@ -0,0 +1,215 @@ +/** + * Atuin plugin for opencode. + * + * Tracks bash commands executed by opencode in Atuin history with author + * `opencode`. + * + * Install with: + * atuin hook install opencode + * + * Then restart opencode. + */ + +import type { Plugin } from "@opencode-ai/plugin"; +import { spawn } from "node:child_process"; + +const ATUIN_AUTHOR = "opencode"; +const ATUIN_TIMEOUT_MS = 10_000; + +// opencode's shell tool keeps the id `bash` for compatibility, even though its +// module is named `shell`. +const BASH_TOOL = "bash"; + +// A command that did not exit on its own reports a null exit code, so +// substitute the conventional code for each of the two ways that happens. +const EXIT_ABORTED = 130; +const EXIT_TIMED_OUT = 124; + +// A denied command reaches neither `shell.env` nor `tool.execute.after`, so its +// proposal is never claimed and would sit in the map for the life of the +// session. Bound the map to cap that. Evicting the oldest can in principle drop +// a command still waiting at its permission prompt, which just goes unrecorded, +// but only a handful of calls are ever genuinely in flight at once. +const MAX_PROPOSED = 128; + +interface Proposal { + command: string; + intent?: string; +} + +interface Entry { + historyId: string; + cwd: string; +} + +interface AtuinResult { + code: number | null; + stdout: string; +} + +interface ToolOutput { + output: string; + metadata: unknown; +} + +/** + * Run Atuin, resolving rather than rejecting on every failure. + * + * Spawned without a shell: a recorded command is arbitrary text that has to + * reach Atuin as a single argv entry unmangled, which rules out the Bun shell + * the plugin API hands us. + */ +function atuin(args: string[], cwd: string): Promise { + return new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn("atuin", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }); + } catch { + resolve({ code: null, stdout: "" }); + return; + } + + let stdout = ""; + let settled = false; + const timer = setTimeout(() => child.kill("SIGKILL"), ATUIN_TIMEOUT_MS); + + const settle = (result: AtuinResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + }); + child.on("error", () => settle({ code: null, stdout: "" })); + child.on("close", (code) => settle({ code, stdout })); + }); +} + +async function startHistory( + cwd: string, + proposal: Proposal, +): Promise { + const args = ["history", "start", "--author", ATUIN_AUTHOR]; + if (proposal.intent) args.push("--intent", proposal.intent); + args.push("--", proposal.command); + + const result = await atuin(args, cwd); + if (result.code !== 0) return undefined; + + const historyId = result.stdout.trim(); + return historyId.length > 0 ? historyId : undefined; +} + +// The tool records an abort or a timeout in its output text rather than as an +// exit code, so string matching is the only way to tell the two apart. +function exitCodeFrom(output: ToolOutput): number { + const exit = (output.metadata as { exit?: unknown } | undefined)?.exit; + if (typeof exit === "number") return exit; + + const text = typeof output.output === "string" ? output.output : ""; + if (text.includes("User aborted the command")) return EXIT_ABORTED; + if (/exceeding timeout \d+ ms/.test(text)) return EXIT_TIMED_OUT; + return 1; +} + +// A rejected hook aborts opencode's tool call and discards the command's +// output. A missing history entry is always the better failure. +async function swallowFailures(work: () => void | Promise): Promise { + try { + await work(); + } catch { + // Deliberately ignored. + } +} + +// opencode treats every export of a plugin file as a plugin function and fails +// to load the file if one is not, so keep this the only export. +export const AtuinPlugin: Plugin = async ({ directory }) => { + // Commands opencode has proposed but is not yet cleared to run, keyed by + // tool call ID. + const proposed = new Map(); + // Atuin history IDs for commands that did start, keyed by tool call ID. + const running = new Map(); + + function propose(callID: string, args: unknown) { + const { command, description } = (args ?? {}) as { + command?: unknown; + description?: unknown; + }; + if (typeof command !== "string" || command.length === 0) return; + + if (proposed.size >= MAX_PROPOSED) { + const oldest = proposed.keys().next().value; + if (oldest !== undefined) proposed.delete(oldest); + } + + proposed.set(callID, { + command, + intent: + typeof description === "string" && description.length > 0 + ? description + : undefined, + }); + } + + async function start(callID: string, cwd: string) { + const proposal = proposed.get(callID); + if (!proposal) return; + proposed.delete(callID); + + const historyId = await startHistory(cwd, proposal); + if (historyId) running.set(callID, { historyId, cwd }); + } + + async function finish(callID: string, output: ToolOutput) { + proposed.delete(callID); + + const entry = running.get(callID); + if (!entry) return; + running.delete(callID); + + await atuin( + [ + "history", + "end", + entry.historyId, + "--exit", + String(exitCodeFrom(output)), + ], + entry.cwd, + ); + } + + return { + // Fires before the permission prompt, so only remember the command here. + // Starting an entry would record commands the user went on to deny. + "tool.execute.before": (input, output) => + swallowFailures(() => { + if (input.tool !== BASH_TOOL) return; + propose(input.callID, output.args); + }), + + // The only hook that runs after the permission prompt but before the + // command does, and the only one given the resolved working directory. + // It also fires for user-run shells and for PTY sessions, which + // opencode did not run; those are skipped because they have no + // proposal to claim, not because of the call ID check below. + "shell.env": (input) => + swallowFailures(() => { + if (!input.callID) return; + return start(input.callID, input.cwd || directory); + }), + + // Also fires when the user aborts a command, unlike the error paths, + // which skip this hook and leave the entry open. + "tool.execute.after": (input, output) => + swallowFailures(() => { + if (input.tool !== BASH_TOOL) return; + return finish(input.callID, output); + }), + }; +}; diff --git a/crates/atuin/src/command/client/hook.rs b/crates/atuin/src/command/client/hook.rs index 8ef518ca5..87a277f91 100644 --- a/crates/atuin/src/command/client/hook.rs +++ b/crates/atuin/src/command/client/hook.rs @@ -1,3 +1,4 @@ +use std::ffi::OsString; use std::io::Read; use std::path::PathBuf; @@ -16,6 +17,7 @@ use event::HookEvent; const HOOK_EVENT_TYPES: &[&str] = &["PreToolUse", "PostToolUse", "PostToolUseFailure"]; const PI_EXTENSION_SOURCE: &str = include_str!("../../../contrib/pi/atuin.ts"); +const OPENCODE_PLUGIN_SOURCE: &str = include_str!("../../../contrib/opencode/atuin.ts"); enum InstallKind { JsonHooks { @@ -23,20 +25,42 @@ enum InstallKind { hook_command: &'static str, matcher: &'static str, }, - PiExtension { + /// An agent that loads TypeScript extensions from a directory, rather than + /// invoking `atuin hook ` from its config. + Extension { extension_path: &'static [&'static str], + source: &'static str, + /// How the user makes the agent pick the file up. + reload_hint: &'static str, }, } +/// The directory an agent's [`InstallKind`] path is relative to. +enum PathRoot { + Home, + /// See [`xdg_config_home`]. + XdgConfig, +} + +/// Resolve `$XDG_CONFIG_HOME` the way agents' XDG libraries do, treating an +/// empty value as unset. Taking it literally would resolve to a relative path +/// and install under the current directory, where the agent never looks. +fn xdg_config_home(var: Option) -> PathBuf { + var.filter(|value| !value.is_empty()) + .map_or_else(|| home_dir().join(".config"), PathBuf::from) +} + struct AgentSpec { aliases: &'static [&'static str], actor_name: &'static str, + path_root: PathRoot, install_kind: InstallKind, } const CLAUDE_CODE: AgentSpec = AgentSpec { aliases: &["claude-code", "claude"], actor_name: "claude-code", + path_root: PathRoot::Home, install_kind: InstallKind::JsonHooks { config_path: &[".claude", "settings.json"], hook_command: "atuin hook claude-code", @@ -47,6 +71,7 @@ const CLAUDE_CODE: AgentSpec = AgentSpec { const CODEX: AgentSpec = AgentSpec { aliases: &["codex"], actor_name: "codex", + path_root: PathRoot::Home, install_kind: InstallKind::JsonHooks { config_path: &[".codex", "hooks.json"], hook_command: "atuin hook codex", @@ -57,12 +82,26 @@ const CODEX: AgentSpec = AgentSpec { const PI: AgentSpec = AgentSpec { aliases: &["pi"], actor_name: "pi", - install_kind: InstallKind::PiExtension { + path_root: PathRoot::Home, + install_kind: InstallKind::Extension { extension_path: &[".pi", "agent", "extensions", "atuin.ts"], + source: PI_EXTENSION_SOURCE, + reload_hint: "Reload pi with `/reload` or restart pi.", }, }; -const AGENTS: &[&AgentSpec] = &[&CLAUDE_CODE, &CODEX, &PI]; +const OPENCODE: AgentSpec = AgentSpec { + aliases: &["opencode"], + actor_name: "opencode", + path_root: PathRoot::XdgConfig, + install_kind: InstallKind::Extension { + extension_path: &["opencode", "plugins", "atuin.ts"], + source: OPENCODE_PLUGIN_SOURCE, + reload_hint: "Restart opencode to load the plugin.", + }, +}; + +const AGENTS: &[&AgentSpec] = &[&CLAUDE_CODE, &CODEX, &OPENCODE, &PI]; struct Agent(&'static AgentSpec); @@ -74,7 +113,9 @@ impl Agent { .find(|spec| spec.aliases.contains(&name)) .map(Self) .ok_or_else(|| { - eyre::eyre!("unknown agent: {name}. Supported agents: claude-code, codex, pi") + eyre::eyre!( + "unknown agent: {name}. Supported agents: claude-code, codex, opencode, pi" + ) }) } @@ -82,9 +123,13 @@ impl Agent { self.0.actor_name } - fn path(path: &'static [&'static str]) -> PathBuf { - path.iter() - .fold(home_dir(), |path, segment| path.join(segment)) + fn path(&self, path: &'static [&'static str]) -> PathBuf { + let root = match self.0.path_root { + PathRoot::Home => home_dir(), + PathRoot::XdgConfig => xdg_config_home(std::env::var_os("XDG_CONFIG_HOME")), + }; + + path.iter().fold(root, |path, segment| path.join(segment)) } fn install_kind(&self) -> &InstallKind { @@ -135,8 +180,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."); + if let InstallKind::Extension { reload_hint, .. } = agent.install_kind() { + bail!( + "`atuin hook {agent_name}` is not supported. Use `atuin hook install {agent_name}`. {reload_hint}" + ); } let mut input = String::new(); @@ -189,7 +236,7 @@ fn install(agent_name: &str) -> Result<()> { hook_command: _, matcher: _, } => { - let config_path = Agent::path(config_path); + let config_path = agent.path(config_path); if let Some(parent) = config_path.parent() { std::fs::create_dir_all(parent)?; @@ -219,26 +266,30 @@ fn install(agent_name: &str) -> Result<()> { config_path.display() ); } - InstallKind::PiExtension { extension_path } => { - let extension_path = Agent::path(extension_path); + InstallKind::Extension { + extension_path, + source, + reload_hint, + } => { + let extension_path = agent.path(extension_path); + let actor_name = agent.actor_name(); if let Some(parent) = extension_path.parent() { std::fs::create_dir_all(parent)?; } - let already_installed = std::fs::read_to_string(&extension_path) - .is_ok_and(|existing| existing == PI_EXTENSION_SOURCE); + let already_installed = + std::fs::read_to_string(&extension_path).is_ok_and(|existing| existing == *source); if already_installed { - eprintln!("pi extension: already installed, skipping"); + eprintln!("{actor_name} extension: already installed, skipping"); } else { - std::fs::write(&extension_path, PI_EXTENSION_SOURCE)?; - eprintln!("pi extension: installed atuin extension"); + std::fs::write(&extension_path, source)?; + eprintln!("{actor_name} extension: installed atuin extension"); } eprintln!( - "\nAtuin extension installed for {}. Extension: {}\nReload pi with `/reload` or restart pi.", - agent.actor_name(), + "\nAtuin extension installed for {actor_name}. Extension: {}\n{reload_hint}", extension_path.display() ); } @@ -301,6 +352,7 @@ mod tests { Atuin, command::{AtuinCmd, client}, }; + use atuin_client::history::is_known_agent; use clap::Parser; use rstest::rstest; @@ -316,6 +368,7 @@ mod tests { #[rstest] #[case::codex("codex")] + #[case::opencode("opencode")] #[case::pi("pi")] fn parse_hook_install_command(#[case] agent_name: &str) { let cmd = Cmd::try_parse_from(["hook", "install", agent_name]).unwrap(); @@ -326,16 +379,60 @@ mod tests { } } - #[test] - fn agent_from_name_supports_pi() { - let agent = Agent::from_name("pi").unwrap(); - assert_eq!(agent.actor_name(), "pi"); + #[rstest] + #[case::opencode("opencode")] + #[case::pi("pi")] + fn agent_from_name_supports_extension_agents(#[case] agent_name: &str) { + let agent = Agent::from_name(agent_name).unwrap(); + assert_eq!(agent.actor_name(), agent_name); assert!(matches!( agent.install_kind(), - InstallKind::PiExtension { .. } + InstallKind::Extension { .. } )); } + /// An agent missing from `KNOWN_AGENTS` would be installable but invisible + /// to `$all-agent`, and would pollute `$all-user` with its commands. + #[test] + fn every_agent_author_is_a_known_agent() { + for spec in AGENTS { + assert!( + is_known_agent(spec.actor_name), + "{} is missing from KNOWN_AGENTS", + spec.actor_name + ); + } + } + + /// An empty `XDG_CONFIG_HOME` taken literally would resolve to a relative + /// path, installing under the current directory instead of the agent's + /// config directory. + #[rstest] + #[case::set(Some("/tmp/xdg"), PathBuf::from("/tmp/xdg"))] + #[case::empty_is_unset(Some(""), home_dir().join(".config"))] + #[case::unset(None, home_dir().join(".config"))] + fn xdg_config_home_resolves(#[case] var: Option<&str>, #[case] expected: PathBuf) { + assert_eq!(xdg_config_home(var.map(OsString::from)), expected); + } + + /// opencode reads plugins from its XDG config directory, not from `$HOME`. + #[test] + fn opencode_plugin_is_rooted_in_the_xdg_config_dir() { + let agent = Agent::from_name("opencode").unwrap(); + let InstallKind::Extension { extension_path, .. } = agent.install_kind() else { + panic!("opencode does not install an extension"); + }; + + let root = xdg_config_home(std::env::var_os("XDG_CONFIG_HOME")); + let installed = agent.path(extension_path); + + assert!( + installed.starts_with(&root), + "{installed:?} is not under {root:?}" + ); + assert!(installed.ends_with("opencode/plugins/atuin.ts")); + } + #[test] fn parse_top_level_hook_command() { let cmd = Atuin::try_parse_from(["atuin", "hook", "codex"]).unwrap(); diff --git a/docs/docs/guide/agent-hooks.md b/docs/docs/guide/agent-hooks.md index 46a20a4d2..84cd011c4 100644 --- a/docs/docs/guide/agent-hooks.md +++ b/docs/docs/guide/agent-hooks.md @@ -1,6 +1,6 @@ # AI Agent Hooks -Atuin can capture commands run by AI coding agents (like Claude Code, Codex, and pi) alongside your regular shell history. Atuin tags each command 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, opencode, and pi) alongside your regular shell history. Atuin tags each command with the agent that ran it, so you can filter your history by author. ## Quick Start @@ -13,6 +13,9 @@ atuin hook install claude-code # Codex atuin hook install codex +# opencode +atuin hook install opencode + # pi atuin hook install pi ``` @@ -29,6 +32,7 @@ When `atuin hook install` runs, it writes the agent's config file or extension t |-------|-------------------------| | Claude Code | `~/.claude/settings.json` | | Codex | `~/.codex/hooks.json` | +| opencode | `~/.config/opencode/plugins/atuin.ts` | | pi | `~/.pi/agent/extensions/atuin.ts` | The hook lifecycle: @@ -38,6 +42,8 @@ The hook lifecycle: Atuin only captures `Bash` tool invocations. It ignores other tool types (file writes, web fetches, etc.). +Agents that load extensions rather than shelling out to a hook command -- opencode and pi -- call `atuin history start` and `atuin history end` directly instead, but record the same thing. + ## Filtering by Author By default, Atuin's interactive search shows only your own commands. Agent-run commands are hidden so they don't clutter your history. @@ -92,6 +98,21 @@ atuin hook install codex This adds hook entries to `~/.codex/hooks.json`. Codex calls `atuin hook codex` on each Bash tool use matching `^Bash$`. +### opencode + +```shell +atuin hook install opencode +``` + +This writes Atuin's plugin to `~/.config/opencode/plugins/atuin.ts`, or to `$XDG_CONFIG_HOME/opencode/plugins/atuin.ts` if you have set `XDG_CONFIG_HOME`. + +Then restart opencode. The plugin records every `bash` tool command with author `opencode`, using the tool's description as the entry's intent when opencode supplies one. + +Two details are worth knowing: + +- Commands you deny at the permission prompt aren't recorded. The plugin waits until opencode is cleared to run a command before opening a history entry. +- Commands you run yourself in opencode -- with the `!command` prompt prefix or in a terminal pane -- aren't recorded either, since opencode didn't run them. Use Atuin's own shell integration for those. + ### pi ```shell @@ -123,6 +144,9 @@ cat ~/.claude/settings.json | grep atuin # Codex cat ~/.codex/hooks.json | grep atuin +# opencode +ls ~/.config/opencode/plugins/atuin.ts + # pi ls ~/.pi/agent/extensions/atuin.ts ``` @@ -137,4 +161,4 @@ 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. +For opencode and pi, reinstalling will also skip if the installed extension already matches the bundled version. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 08490b4e5..d39a2ffe3 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -145,7 +145,7 @@ plugins: - guide/basic-usage.md: What Atuin records, how to use the TUI, and common config tweaks. - guide/advanced-usage.md: Filter modes (global, host, session, directory, workspace) and search modes (prefix, fulltext, fuzzy, daemon-fuzzy). - guide/shell-integration.md: How shell hooks work, environment variables, and embedded terminals. - - guide/agent-hooks.md: Capture commands from AI coding agents (Claude Code, Codex, pi) and filter by author. + - guide/agent-hooks.md: Capture commands from AI coding agents (Claude Code, Codex, opencode, pi) and filter by author. - guide/excluding-commands.md: Keep commands out of history with ignorespace, history_filter, and cwd_filter. - guide/delete-history.md: Delete single entries, bulk delete by query, remove duplicates, or wipe everything. - guide/dotfiles.md: Sync shell aliases and environment variables across machines. diff --git a/install.sh b/install.sh index 655a52f19..a9f42e01e 100755 --- a/install.sh +++ b/install.sh @@ -101,6 +101,7 @@ __atuin_install_agent_hook(){ __atuin_install_agent_hook "claude-code" "Claude Code" "$HOME/.claude" claude __atuin_install_agent_hook "codex" "Codex" "$HOME/.codex" codex +__atuin_install_agent_hook "opencode" "opencode" "${XDG_CONFIG_HOME:-$HOME/.config}/opencode" opencode __atuin_install_agent_hook "pi" "pi" "$HOME/.config/pi" pi echo ""