pi-atuin 0.1.3 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,30 +35,56 @@ curl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh | sh
35
35
 
36
36
  # Register / login
37
37
  atuin register -u <username> -e <email>
38
+ ```
39
+
40
+ pi-atuin records history into atuin automatically — no `atuin hook install pi` needed. That hook registers a conflicting `bash` tool and breaks extensions like [pi-tool-display](https://github.com/MasuRii/pi-tool-display).
41
+
42
+ If you previously ran `atuin hook install pi`, remove the hook extension:
38
43
 
39
- # Install pi hook (tracks bash commands in atuin)
40
- atuin hook install pi
44
+ ```bash
45
+ rm ~/.pi/agent/extensions/atuin.ts
46
+ pi /reload
41
47
  ```
42
48
 
43
- With this, pi prompts and bash commands sync bidirectionally to atuin DB. Search in either pi or your shell.
49
+ With atuin installed, history syncs bidirectionally. Search in either pi (↑) or your shell (`atuin search`).
44
50
 
45
51
  ### JSONL-only (no atuin)
46
52
 
47
- No setup needed. History stored in `~/.pi/agent/pi-history.jsonl`.
53
+ No setup needed. Pi prompts are stored in `~/.pi/agent/pi-history.jsonl`.
54
+
55
+ ## What gets recorded
56
+
57
+ | Source | JSONL (↑ search) | Atuin DB |
58
+ |--------|------------------|----------|
59
+ | Your pi prompts | Yes | Yes |
60
+ | Agent `bash` tool calls | No | Yes (default on) |
61
+ | Your `!` shell commands | No | Yes |
62
+
63
+ - **Prompts** always go to JSONL and atuin (when installed).
64
+ - **Agent bash** is recorded to atuin only (not mixed into ↑ prompt search). On by default; `/atuin record-agent-history` toggles it.
65
+ - **`!` commands** are your direct shell input in pi — tracked in atuin, not JSONL.
48
66
 
49
67
  ## Usage
50
68
 
51
- 1. Press **↑** when your input is empty (or cursor is on the first line)
69
+ ### History search
70
+
71
+ 1. Press **↑** when the input is empty
52
72
  2. Type to fuzzy-filter
53
73
  3. **↑↓** to navigate, **Enter** to select, **Esc** to cancel
54
74
 
55
- All pi prompts are recorded automatically. Supports bash, zsh, and fish.
75
+ ### Commands
76
+
77
+ ```text
78
+ /atuin record-agent-history # toggle agent bash → atuin, shows on/off
79
+ ```
80
+
81
+ Settings persist in `~/.pi/agent/extensions/pi-atuin/config.json`.
56
82
 
57
83
  ## Privacy
58
84
 
59
85
  - History file permissions: `600` (owner-only)
60
- - Max 1,000 entries, auto-trimmed
61
- - Atuin calls use `execFile` (no shell injection)
86
+ - Max 1,000 JSONL entries, auto-trimmed
87
+ - Atuin calls use `execFile` / `pi.exec` (no shell injection)
62
88
  - No hardcoded credentials or keys
63
89
 
64
90
  ## License
package/atuin-cli.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Shared Atuin CLI helpers for pi-atuin.
3
+ *
4
+ * Uses execFile (history-store) or pi.exec (bash-tracker) via a thin adapter.
5
+ */
6
+
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ export const ATUIN_AUTHOR = "pi";
14
+ export const ATUIN_TIMEOUT_MS = 10_000;
15
+
16
+ export type AtuinExecResult = {
17
+ code: number;
18
+ stdout: string;
19
+ stderr: string;
20
+ };
21
+
22
+ export type AtuinExec = (
23
+ command: string,
24
+ args: string[],
25
+ options?: { cwd?: string; timeout?: number },
26
+ ) => Promise<AtuinExecResult>;
27
+
28
+ export function createExecFileAtuinExec(): AtuinExec {
29
+ return async (command, args, options) => {
30
+ try {
31
+ const { stdout, stderr } = await execFileAsync(command, args, {
32
+ cwd: options?.cwd,
33
+ timeout: options?.timeout ?? ATUIN_TIMEOUT_MS,
34
+ });
35
+ return { code: 0, stdout: String(stdout), stderr: String(stderr) };
36
+ } catch (err: unknown) {
37
+ const e = err as { code?: number; stdout?: string; stderr?: string };
38
+ return {
39
+ code: typeof e.code === "number" ? e.code : 1,
40
+ stdout: String(e.stdout ?? ""),
41
+ stderr: String(e.stderr ?? ""),
42
+ };
43
+ }
44
+ };
45
+ }
46
+
47
+ export function createPiAtuinExec(pi: ExtensionAPI): AtuinExec {
48
+ return async (command, args, options) => {
49
+ const result = await pi.exec(command, args, {
50
+ cwd: options?.cwd,
51
+ timeout: options?.timeout ?? ATUIN_TIMEOUT_MS,
52
+ });
53
+ return {
54
+ code: result.code,
55
+ stdout: result.stdout,
56
+ stderr: result.stderr,
57
+ };
58
+ };
59
+ }
60
+
61
+ export async function startAtuinHistory(
62
+ exec: AtuinExec,
63
+ cwd: string,
64
+ command: string,
65
+ ): Promise<string | undefined> {
66
+ try {
67
+ const result = await exec(
68
+ "atuin",
69
+ ["history", "start", "--author", ATUIN_AUTHOR, "--", command],
70
+ { cwd, timeout: ATUIN_TIMEOUT_MS },
71
+ );
72
+ if (result.code !== 0) return undefined;
73
+ const id = result.stdout.trim();
74
+ return id.length > 0 ? id : undefined;
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ }
79
+
80
+ export async function endAtuinHistory(
81
+ exec: AtuinExec,
82
+ cwd: string,
83
+ historyId: string,
84
+ exitCode: number,
85
+ ): Promise<void> {
86
+ try {
87
+ await exec(
88
+ "atuin",
89
+ ["history", "end", historyId, "--exit", String(exitCode)],
90
+ { cwd, timeout: ATUIN_TIMEOUT_MS },
91
+ );
92
+ } catch {
93
+ // Never block pi on atuin failures.
94
+ }
95
+ }
96
+
97
+ export async function recordAtuinCommand(
98
+ exec: AtuinExec,
99
+ cwd: string,
100
+ command: string,
101
+ exitCode = 0,
102
+ ): Promise<void> {
103
+ const historyId = await startAtuinHistory(exec, cwd, command);
104
+ if (!historyId) return;
105
+ await endAtuinHistory(exec, cwd, historyId, exitCode);
106
+ }
@@ -0,0 +1,20 @@
1
+ /** Derive exit code from a bash tool_result event. */
2
+ export function parseBashExitCode(event: {
3
+ isError: boolean;
4
+ content: Array<{ type: string; text?: string }>;
5
+ }): number {
6
+ if (!event.isError) return 0;
7
+
8
+ const text = event.content
9
+ .filter((part) => part.type === "text")
10
+ .map((part) => part.text ?? "")
11
+ .join("\n");
12
+
13
+ const exitMatch = text.match(/Command exited with code (\d+)/);
14
+ if (exitMatch) return Number.parseInt(exitMatch[1]!, 10);
15
+
16
+ if (text.includes("Command aborted")) return 130;
17
+ if (text.includes("Command timed out")) return 124;
18
+
19
+ return 1;
20
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Event-based bash → atuin tracking for pi-atuin.
3
+ *
4
+ * Replaces `atuin hook install pi` without registering a conflicting `bash` tool.
5
+ * Compatible with pi-tool-display and other extensions that own bash rendering.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { createLocalBashOperations, isToolCallEventType } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ createPiAtuinExec,
12
+ endAtuinHistory,
13
+ startAtuinHistory,
14
+ } from "./atuin-cli.js";
15
+ import { parseBashExitCode } from "./bash-exit-code.js";
16
+ import type { ConfigGetter } from "./commands.js";
17
+
18
+ interface PendingBashHistory {
19
+ historyId: string;
20
+ cwd: string;
21
+ }
22
+
23
+ export function registerBashTracker(pi: ExtensionAPI, getConfig: ConfigGetter): void {
24
+ const exec = createPiAtuinExec(pi);
25
+ const pending = new Map<string, PendingBashHistory>();
26
+
27
+ pi.on("tool_call", async (event, ctx) => {
28
+ if (!getConfig().recordAgentHistory) return;
29
+ if (!isToolCallEventType("bash", event)) return;
30
+
31
+ const command = event.input.command?.trim();
32
+ if (!command) return;
33
+
34
+ const historyId = await startAtuinHistory(exec, ctx.cwd, command);
35
+ if (historyId) {
36
+ pending.set(event.toolCallId, { historyId, cwd: ctx.cwd });
37
+ }
38
+ });
39
+
40
+ pi.on("tool_result", async (event) => {
41
+ if (!getConfig().recordAgentHistory) return;
42
+ if (event.toolName !== "bash") return;
43
+
44
+ const track = pending.get(event.toolCallId);
45
+ if (!track) return;
46
+
47
+ pending.delete(event.toolCallId);
48
+ await endAtuinHistory(
49
+ exec,
50
+ track.cwd,
51
+ track.historyId,
52
+ parseBashExitCode(event),
53
+ );
54
+ });
55
+
56
+ const local = createLocalBashOperations();
57
+
58
+ pi.on("user_bash", async (event) => {
59
+ const historyId = await startAtuinHistory(exec, event.cwd, event.command);
60
+
61
+ return {
62
+ operations: {
63
+ async exec(command, cwd, options) {
64
+ let exitCode: number | null = null;
65
+ try {
66
+ const result = await local.exec(command, cwd, options);
67
+ exitCode = result.exitCode;
68
+ return result;
69
+ } finally {
70
+ if (historyId) {
71
+ await endAtuinHistory(
72
+ exec,
73
+ cwd,
74
+ historyId,
75
+ exitCode ?? (options.signal?.aborted ? 130 : 1),
76
+ );
77
+ }
78
+ }
79
+ },
80
+ },
81
+ };
82
+ });
83
+
84
+ pi.on("session_shutdown", () => {
85
+ pending.clear();
86
+ });
87
+ }
package/commands.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * /atuin slash commands for pi-atuin.
3
+ */
4
+
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+ import type { PiAtuinConfig } from "./config.js";
7
+
8
+ export type ConfigGetter = () => PiAtuinConfig;
9
+ export type ConfigSetter = (next: PiAtuinConfig) => void;
10
+
11
+ export function toggleRecordAgentHistory(current: boolean): boolean {
12
+ return !current;
13
+ }
14
+
15
+ export function formatRecordAgentHistoryStatus(enabled: boolean): string {
16
+ return `Agent bash history recording: ${enabled ? "on" : "off"}`;
17
+ }
18
+
19
+ export function registerAtuinCommands(
20
+ pi: ExtensionAPI,
21
+ getConfig: ConfigGetter,
22
+ setConfig: ConfigSetter,
23
+ ): void {
24
+ pi.registerCommand("atuin", {
25
+ description: "Toggle agent bash history recording",
26
+ handler: async (args, ctx) => {
27
+ const subcommand = args.trim().split(/\s+/)[0]?.toLowerCase();
28
+
29
+ if (subcommand !== "record-agent-history") {
30
+ ctx.ui.notify("/atuin record-agent-history", "info");
31
+ return;
32
+ }
33
+
34
+ toggleRecordAgentHistoryCommand(ctx, getConfig, setConfig);
35
+ },
36
+ });
37
+ }
38
+
39
+ function toggleRecordAgentHistoryCommand(
40
+ ctx: ExtensionCommandContext,
41
+ getConfig: ConfigGetter,
42
+ setConfig: ConfigSetter,
43
+ ): void {
44
+ const config = getConfig();
45
+ const next = toggleRecordAgentHistory(config.recordAgentHistory);
46
+ setConfig({ ...config, recordAgentHistory: next });
47
+ ctx.ui.notify(formatRecordAgentHistoryStatus(next), "info");
48
+ }
package/config.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * pi-atuin settings persisted under ~/.pi/agent/extensions/pi-atuin/config.json
3
+ */
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const CONFIG_DIR = join(
10
+ process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"),
11
+ "extensions",
12
+ "pi-atuin",
13
+ );
14
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
15
+
16
+ export interface PiAtuinConfig {
17
+ /** Record agent `bash` tool calls into atuin (default: true). */
18
+ recordAgentHistory: boolean;
19
+ }
20
+
21
+ export const DEFAULT_CONFIG: PiAtuinConfig = {
22
+ recordAgentHistory: true,
23
+ };
24
+
25
+ function normalizeConfig(source: unknown): PiAtuinConfig {
26
+ const raw = source && typeof source === "object" ? (source as Partial<PiAtuinConfig>) : {};
27
+ return {
28
+ recordAgentHistory:
29
+ typeof raw.recordAgentHistory === "boolean"
30
+ ? raw.recordAgentHistory
31
+ : DEFAULT_CONFIG.recordAgentHistory,
32
+ };
33
+ }
34
+
35
+ export function loadConfig(): PiAtuinConfig {
36
+ try {
37
+ if (!existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG };
38
+ const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf8")) as unknown;
39
+ return normalizeConfig(raw);
40
+ } catch {
41
+ return { ...DEFAULT_CONFIG };
42
+ }
43
+ }
44
+
45
+ export function saveConfig(config: PiAtuinConfig): void {
46
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
47
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", {
48
+ encoding: "utf8",
49
+ mode: 0o600,
50
+ });
51
+ }
52
+
53
+ export function getConfigPath(): string {
54
+ return CONFIG_FILE;
55
+ }
package/history-store.ts CHANGED
@@ -5,14 +5,16 @@
5
5
  import { readFile, writeFile, appendFile, mkdir } from "node:fs/promises";
6
6
  import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
+ import { fuzzySearch } from "./fuzzy-match.js";
9
+ import { createExecFileAtuinExec, recordAtuinCommand } from "./atuin-cli.js";
8
10
  import { execFile } from "node:child_process";
9
11
  import { promisify } from "node:util";
10
- import { fuzzySearch } from "./fuzzy-match.js";
11
12
 
12
13
  const execFileAsync = promisify(execFile);
14
+ const atuinExec = createExecFileAtuinExec();
13
15
 
14
16
  const JSONL_PATH = join(homedir(), ".pi", "agent", "pi-history.jsonl");
15
- const ATUIN_TIMEOUT_MS = 5_000;
17
+ const ATUIN_READ_TIMEOUT_MS = 5_000;
16
18
  const MAX_ENTRIES = 1000;
17
19
 
18
20
  export interface HistoryEntry {
@@ -111,7 +113,7 @@ async function readAtuinHistory(): Promise<HistoryEntry[]> {
111
113
  const { stdout } = await execFileAsync(
112
114
  "atuin",
113
115
  ["search", "--format", "{time}\t{command}", "--limit", "200"],
114
- { timeout: ATUIN_TIMEOUT_MS },
116
+ { timeout: ATUIN_READ_TIMEOUT_MS },
115
117
  );
116
118
 
117
119
  const lines = stdout.split("\n").filter((l) => l.trim());
@@ -212,24 +214,6 @@ export function invalidateCache(): void {
212
214
  // Write back to atuin (so shell atuin search also sees pi prompts)
213
215
  // ---------------------------------------------------------------------------
214
216
 
215
- const ATUIN_AUTHOR = "pi";
216
-
217
217
  export async function writeToAtuin(text: string, cwd: string): Promise<void> {
218
- try {
219
- const { stdout } = await execFileAsync(
220
- "atuin",
221
- ["history", "start", "--author", ATUIN_AUTHOR, "--", text],
222
- { cwd, timeout: ATUIN_TIMEOUT_MS },
223
- );
224
- const id = stdout.trim();
225
- if (id) {
226
- await execFileAsync(
227
- "atuin",
228
- ["history", "end", id, "--exit", "0"],
229
- { cwd, timeout: ATUIN_TIMEOUT_MS },
230
- );
231
- }
232
- } catch {
233
- // atuin not installed or failed — silently ignore
234
- }
218
+ await recordAtuinCommand(atuinExec, cwd, text, 0);
235
219
  }
package/index.ts CHANGED
@@ -11,8 +11,21 @@ import { CustomEditor } from "@earendil-works/pi-coding-agent";
11
11
  import { matchesKey, Key } from "@earendil-works/pi-tui";
12
12
  import { addEntry, invalidateCache, listRecent, writeToAtuin, isRecentWrite, markRecentWrite } from "./history-store.js";
13
13
  import { HistorySearchComponent } from "./search-ui.js";
14
+ import { registerBashTracker } from "./bash-tracker.js";
15
+ import { loadConfig, saveConfig } from "./config.js";
16
+ import { registerAtuinCommands } from "./commands.js";
14
17
 
15
18
  export default function piAtuin(pi: ExtensionAPI) {
19
+ let config = loadConfig();
20
+
21
+ const getConfig = () => config;
22
+ const setConfig = (next: typeof config) => {
23
+ config = next;
24
+ saveConfig(config);
25
+ };
26
+
27
+ registerBashTracker(pi, getConfig);
28
+ registerAtuinCommands(pi, getConfig, setConfig);
16
29
  // ---- 1. Record user inputs to history ----
17
30
  pi.on("input", async (event, ctx) => {
18
31
  if (event.source === "interactive" && event.text.trim()) {
@@ -35,12 +48,9 @@ export default function piAtuin(pi: ExtensionAPI) {
35
48
  const editor = new CustomEditor(tui, theme, keybindings);
36
49
 
37
50
  editor.onExtensionShortcut = (data: string) => {
38
- if (matchesKey(data, Key.up)) {
39
- const cursor = editor.getCursor();
40
- if (cursor.line === 0) {
41
- openSearch(ctx, editor);
42
- return true;
43
- }
51
+ if (matchesKey(data, Key.up) && editor.getText().length === 0) {
52
+ openSearch(ctx, editor);
53
+ return true;
44
54
  }
45
55
  return false;
46
56
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-atuin",
3
- "version": "0.1.3",
3
+ "version": "0.1.7",
4
4
  "description": "Atuin-style fuzzy history search for pi. Press ↑ for interactive history.",
5
5
  "license": "MIT",
6
6
  "author": "RealAlexandreAI",
@@ -0,0 +1,60 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseBashExitCode } from "../bash-exit-code.ts";
4
+
5
+ describe("parseBashExitCode", () => {
6
+ it("returns 0 for successful commands", () => {
7
+ assert.equal(
8
+ parseBashExitCode({
9
+ isError: false,
10
+ content: [{ type: "text", text: "hello\n" }],
11
+ }),
12
+ 0,
13
+ );
14
+ });
15
+
16
+ it("parses explicit exit codes from bash output", () => {
17
+ assert.equal(
18
+ parseBashExitCode({
19
+ isError: true,
20
+ content: [
21
+ {
22
+ type: "text",
23
+ text: "something failed\n\nCommand exited with code 42",
24
+ },
25
+ ],
26
+ }),
27
+ 42,
28
+ );
29
+ });
30
+
31
+ it("returns 130 for aborted commands", () => {
32
+ assert.equal(
33
+ parseBashExitCode({
34
+ isError: true,
35
+ content: [{ type: "text", text: "Command aborted" }],
36
+ }),
37
+ 130,
38
+ );
39
+ });
40
+
41
+ it("returns 124 for timed out commands", () => {
42
+ assert.equal(
43
+ parseBashExitCode({
44
+ isError: true,
45
+ content: [{ type: "text", text: "Command timed out after 30 seconds" }],
46
+ }),
47
+ 124,
48
+ );
49
+ });
50
+
51
+ it("defaults to 1 for generic errors", () => {
52
+ assert.equal(
53
+ parseBashExitCode({
54
+ isError: true,
55
+ content: [{ type: "text", text: "unknown failure" }],
56
+ }),
57
+ 1,
58
+ );
59
+ });
60
+ });
@@ -0,0 +1,18 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ formatRecordAgentHistoryStatus,
5
+ toggleRecordAgentHistory,
6
+ } from "../commands.ts";
7
+
8
+ describe("record-agent-history toggle", () => {
9
+ it("flips the current value", () => {
10
+ assert.equal(toggleRecordAgentHistory(true), false);
11
+ assert.equal(toggleRecordAgentHistory(false), true);
12
+ });
13
+
14
+ it("formats status for notify", () => {
15
+ assert.equal(formatRecordAgentHistoryStatus(true), "Agent bash history recording: on");
16
+ assert.equal(formatRecordAgentHistoryStatus(false), "Agent bash history recording: off");
17
+ });
18
+ });
@@ -0,0 +1,9 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { DEFAULT_CONFIG } from "../config.ts";
4
+
5
+ describe("DEFAULT_CONFIG", () => {
6
+ it("records agent bash history by default", () => {
7
+ assert.equal(DEFAULT_CONFIG.recordAgentHistory, true);
8
+ });
9
+ });