@jmylchreest/aide-plugin 0.0.57 → 0.0.58

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.
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Permission Handler Hook (PermissionRequest)
4
+ *
5
+ * OPT-IN: This hook is NOT registered in plugin.json by default.
6
+ * To enable, add a PermissionRequest entry to .claude-plugin/plugin.json.
7
+ * Not available in OpenCode (no equivalent event).
8
+ *
9
+ * Validates Bash commands before permission prompts are shown.
10
+ * Can auto-approve safe commands or block dangerous ones.
11
+ *
12
+ * PermissionRequest data from Claude Code:
13
+ * - tool_name: "Bash"
14
+ * - tool_input: { command: "...", ... }
15
+ * - cwd, session_id
16
+ *
17
+ * Returns:
18
+ * - { allow: true } to auto-approve
19
+ * - { allow: false, reason: "..." } to block
20
+ * - { continue: true } to show normal permission prompt
21
+ */
22
+
23
+ import { readStdin, findAideBinary, runAide } from "../lib/hook-utils.js";
24
+ import { sanitizeForLog } from "../core/aide-client.js";
25
+ import { debug } from "../lib/logger.js";
26
+
27
+ const SOURCE = "permission-handler";
28
+
29
+ interface PermissionRequestInput {
30
+ event: "PermissionRequest";
31
+ tool_name: string;
32
+ tool_input: {
33
+ command?: string;
34
+ [key: string]: unknown;
35
+ };
36
+ cwd: string;
37
+ session_id: string;
38
+ }
39
+
40
+ interface PermissionResponse {
41
+ allow?: boolean;
42
+ reason?: string;
43
+ continue?: boolean;
44
+ }
45
+
46
+ // Commands that are always safe to auto-approve
47
+ const SAFE_COMMANDS = [
48
+ /^ls\b/,
49
+ /^pwd$/,
50
+ /^echo\b/,
51
+ /^cat\b/,
52
+ /^head\b/,
53
+ /^tail\b/,
54
+ /^wc\b/,
55
+ /^grep\b/,
56
+ /^find\b/,
57
+ /^which\b/,
58
+ /^git\s+(status|log|diff|branch|show)\b/,
59
+ /^git\s+stash\s+list\b/,
60
+ /^npm\s+(list|ls|outdated|view)\b/,
61
+ /^yarn\s+(list|info)\b/,
62
+ /^pnpm\s+(list|outdated)\b/,
63
+ /^node\s+--version$/,
64
+ /^npm\s+--version$/,
65
+ /^python\s+--version$/,
66
+ /^go\s+version$/,
67
+ /^cargo\s+--version$/,
68
+ ];
69
+
70
+ // Commands that should be blocked without prompting
71
+ const BLOCKED_COMMANDS = [
72
+ /rm\s+-rf\s+[/~]/, // rm -rf / or ~
73
+ /rm\s+-rf\s+\*/, // rm -rf *
74
+ /dd\s+.*of=\/dev\//, // dd to device
75
+ /mkfs\./, // format filesystem
76
+ /:\(\)\{:\|:&\};:/, // fork bomb
77
+ />\s*\/dev\/sd[a-z]/, // write to disk device
78
+ /curl.*\|\s*(ba)?sh/, // curl pipe to shell
79
+ /wget.*\|\s*(ba)?sh/, // wget pipe to shell
80
+ ];
81
+
82
+ /**
83
+ * Log permission decision to aide-memory
84
+ */
85
+ function logPermission(cwd: string, command: string, decision: string): void {
86
+ if (!findAideBinary(cwd)) return;
87
+
88
+ const safeCommand = sanitizeForLog(command).slice(0, 200);
89
+ runAide(cwd, [
90
+ "message",
91
+ "send",
92
+ `${decision}: ${safeCommand}`,
93
+ "--from=system",
94
+ "--type=permission",
95
+ ]);
96
+ }
97
+
98
+ /**
99
+ * Check if command matches any patterns
100
+ */
101
+ function matchesPatterns(command: string, patterns: RegExp[]): boolean {
102
+ return patterns.some((pattern) => pattern.test(command));
103
+ }
104
+
105
+ async function main(): Promise<void> {
106
+ try {
107
+ const input = await readStdin();
108
+ if (!input.trim()) {
109
+ console.log(JSON.stringify({ continue: true }));
110
+ return;
111
+ }
112
+
113
+ const data: PermissionRequestInput = JSON.parse(input);
114
+
115
+ // Only handle Bash permissions
116
+ if (data.tool_name !== "Bash" || !data.tool_input?.command) {
117
+ console.log(JSON.stringify({ continue: true }));
118
+ return;
119
+ }
120
+
121
+ const command = data.tool_input.command;
122
+ const cwd = data.cwd || process.cwd();
123
+
124
+ // Check for blocked commands first
125
+ if (matchesPatterns(command, BLOCKED_COMMANDS)) {
126
+ logPermission(cwd, command, "BLOCKED");
127
+ const response: PermissionResponse = {
128
+ allow: false,
129
+ reason:
130
+ "This command has been blocked for safety. It matches a dangerous pattern.",
131
+ };
132
+ console.log(JSON.stringify(response));
133
+ return;
134
+ }
135
+
136
+ // Check for safe commands to auto-approve
137
+ if (matchesPatterns(command, SAFE_COMMANDS)) {
138
+ logPermission(cwd, command, "AUTO-APPROVED");
139
+ const response: PermissionResponse = {
140
+ allow: true,
141
+ };
142
+ console.log(JSON.stringify(response));
143
+ return;
144
+ }
145
+
146
+ // Default: show normal permission prompt
147
+ console.log(JSON.stringify({ continue: true }));
148
+ } catch (error) {
149
+ debug(SOURCE, `Hook error: ${error}`);
150
+ console.log(JSON.stringify({ continue: true }));
151
+ }
152
+ }
153
+
154
+ process.on("uncaughtException", (err) => {
155
+ debug(SOURCE, `UNCAUGHT EXCEPTION: ${err}`);
156
+ try {
157
+ console.log(JSON.stringify({ continue: true }));
158
+ } catch {
159
+ console.log('{"continue":true}');
160
+ }
161
+ process.exit(0);
162
+ });
163
+ process.on("unhandledRejection", (reason) => {
164
+ debug(SOURCE, `UNHANDLED REJECTION: ${reason}`);
165
+ try {
166
+ console.log(JSON.stringify({ continue: true }));
167
+ } catch {
168
+ console.log('{"continue":true}');
169
+ }
170
+ process.exit(0);
171
+ });
172
+
173
+ main();
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Persistence Hook (Stop)
4
+ *
5
+ * Prevents Claude from stopping when work is incomplete.
6
+ * Checks for active modes (autopilot) via aide-memory state.
7
+ */
8
+
9
+ import { readStdin } from "../lib/hook-utils.js";
10
+ import { findAideBinary } from "../core/aide-client.js";
11
+ import { checkPersistence } from "../core/persistence-logic.js";
12
+ import { debug } from "../lib/logger.js";
13
+
14
+ const SOURCE = "persistence";
15
+
16
+ interface HookInput {
17
+ hook_event_name: string;
18
+ session_id: string;
19
+ cwd: string;
20
+ stop_hook_active?: boolean;
21
+ transcript_path?: string;
22
+ permission_mode?: string;
23
+ }
24
+
25
+ interface HookOutput {
26
+ decision?: "block";
27
+ reason?: string;
28
+ }
29
+
30
+ async function main(): Promise<void> {
31
+ try {
32
+ const input = await readStdin();
33
+ if (!input.trim()) {
34
+ console.log(JSON.stringify({}));
35
+ return;
36
+ }
37
+
38
+ const data: HookInput = JSON.parse(input);
39
+ const cwd = data.cwd || process.cwd();
40
+
41
+ if (data.stop_hook_active) {
42
+ console.log(JSON.stringify({}));
43
+ return;
44
+ }
45
+
46
+ const binary = findAideBinary({
47
+ cwd,
48
+ pluginRoot:
49
+ process.env.AIDE_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT,
50
+ });
51
+ if (!binary) {
52
+ console.log(JSON.stringify({}));
53
+ return;
54
+ }
55
+
56
+ const result = checkPersistence(binary, cwd, data.session_id);
57
+ if (!result) {
58
+ console.log(JSON.stringify({}));
59
+ return;
60
+ }
61
+
62
+ const output: HookOutput = {
63
+ decision: "block",
64
+ reason: result.reason,
65
+ };
66
+
67
+ console.log(JSON.stringify(output));
68
+ } catch (err) {
69
+ debug(SOURCE, `Hook error: ${err}`);
70
+ console.log(JSON.stringify({}));
71
+ }
72
+ }
73
+
74
+ process.on("uncaughtException", (err) => {
75
+ debug(SOURCE, `UNCAUGHT EXCEPTION: ${err}`);
76
+ try {
77
+ console.log(JSON.stringify({}));
78
+ } catch {
79
+ console.log("{}");
80
+ }
81
+ process.exit(0);
82
+ });
83
+ process.on("unhandledRejection", (reason) => {
84
+ debug(SOURCE, `UNHANDLED REJECTION: ${reason}`);
85
+ try {
86
+ console.log(JSON.stringify({}));
87
+ } catch {
88
+ console.log("{}");
89
+ }
90
+ process.exit(0);
91
+ });
92
+
93
+ main();
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pre-Compact Hook (PreCompact)
4
+ *
5
+ * Called before Claude Code compacts/summarizes the conversation.
6
+ * Preserves important context in aide-memory before summarization.
7
+ *
8
+ * PreCompact data from Claude Code:
9
+ * - session_id, cwd
10
+ * - summary_prompt (the prompt used for summarization)
11
+ */
12
+
13
+ import { readStdin } from "../lib/hook-utils.js";
14
+ import { findAideBinary } from "../core/aide-client.js";
15
+ import { saveStateSnapshot as coreSaveStateSnapshot } from "../core/pre-compact-logic.js";
16
+ import {
17
+ buildSessionSummaryFromState,
18
+ getSessionCommits,
19
+ storeSessionSummary,
20
+ } from "../core/session-summary-logic.js";
21
+ import {
22
+ gatherPartials,
23
+ buildSummaryFromPartials,
24
+ } from "../core/partial-memory.js";
25
+ import { debug } from "../lib/logger.js";
26
+
27
+ const SOURCE = "pre-compact";
28
+
29
+ interface PreCompactInput {
30
+ event: "PreCompact";
31
+ session_id: string;
32
+ cwd: string;
33
+ summary_prompt?: string;
34
+ }
35
+
36
+ async function main(): Promise<void> {
37
+ try {
38
+ const input = await readStdin();
39
+ if (!input.trim()) {
40
+ console.log(JSON.stringify({ continue: true }));
41
+ return;
42
+ }
43
+
44
+ const data: PreCompactInput = JSON.parse(input);
45
+ const cwd = data.cwd || process.cwd();
46
+ const sessionId = data.session_id || "unknown";
47
+
48
+ // Save state snapshot before compaction — delegates to core
49
+ const binary = findAideBinary({
50
+ cwd,
51
+ pluginRoot:
52
+ process.env.AIDE_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT,
53
+ });
54
+ if (binary) {
55
+ coreSaveStateSnapshot(binary, cwd, sessionId);
56
+
57
+ // Persist a session summary as a memory before context is compacted.
58
+ // This ensures the work-so-far is recoverable after compaction.
59
+ // Uses partials (if available) for a richer summary, falling back to git-only.
60
+ try {
61
+ const partials = gatherPartials(binary, cwd, sessionId);
62
+ let summary: string | null = null;
63
+
64
+ if (partials.length > 0) {
65
+ // Build from partials + git data
66
+ const commits = getSessionCommits(cwd);
67
+ summary = buildSummaryFromPartials(partials, commits, []);
68
+ debug(
69
+ SOURCE,
70
+ `Built pre-compact summary from ${partials.length} partials`,
71
+ );
72
+ }
73
+
74
+ // Fall back to state-only summary if no partials
75
+ if (!summary) {
76
+ summary = buildSessionSummaryFromState(cwd);
77
+ }
78
+
79
+ if (summary) {
80
+ // Tag as partial so the session-end summary supersedes it
81
+ const tags = `partial,session-summary,session:${sessionId}`;
82
+ (await import("child_process")).execFileSync(
83
+ binary,
84
+ ["memory", "add", "--category=session", `--tags=${tags}`, summary],
85
+ { cwd, stdio: "pipe", timeout: 5000 },
86
+ );
87
+ debug(
88
+ SOURCE,
89
+ `Saved pre-compaction partial session summary for ${sessionId.slice(0, 8)}`,
90
+ );
91
+ }
92
+ } catch (err) {
93
+ debug(
94
+ SOURCE,
95
+ `Failed to save pre-compaction summary (non-fatal): ${err}`,
96
+ );
97
+ }
98
+ }
99
+
100
+ // Always allow compaction to continue
101
+ console.log(JSON.stringify({ continue: true }));
102
+ } catch (error) {
103
+ debug(SOURCE, `Hook error: ${error}`);
104
+ console.log(JSON.stringify({ continue: true }));
105
+ }
106
+ }
107
+
108
+ process.on("uncaughtException", (err) => {
109
+ debug(SOURCE, `UNCAUGHT EXCEPTION: ${err}`);
110
+ try {
111
+ console.log(JSON.stringify({ continue: true }));
112
+ } catch {
113
+ console.log('{"continue":true}');
114
+ }
115
+ process.exit(0);
116
+ });
117
+ process.on("unhandledRejection", (reason) => {
118
+ debug(SOURCE, `UNHANDLED REJECTION: ${reason}`);
119
+ try {
120
+ console.log(JSON.stringify({ continue: true }));
121
+ } catch {
122
+ console.log('{"continue":true}');
123
+ }
124
+ process.exit(0);
125
+ });
126
+
127
+ main();
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pre-Tool Enforcer Hook (PreToolUse)
4
+ *
5
+ * Enforces tool access rules:
6
+ * - Read-only agents cannot use write tools
7
+ * - Injects contextual reminders
8
+ * - Tracks active state
9
+ *
10
+ * Core logic is in src/core/tool-enforcement.ts for cross-platform reuse.
11
+ */
12
+
13
+ import { readStdin } from "../lib/hook-utils.js";
14
+ import { debug } from "../lib/logger.js";
15
+ import { evaluateToolUse } from "../core/tool-enforcement.js";
16
+ import { findAideBinary, getState } from "../core/aide-client.js";
17
+
18
+ const SOURCE = "pre-tool-enforcer";
19
+
20
+ interface HookInput {
21
+ hook_event_name: string;
22
+ session_id: string;
23
+ cwd: string;
24
+ tool_name?: string;
25
+ agent_name?: string;
26
+ agent_id?: string;
27
+ transcript_path?: string;
28
+ permission_mode?: string;
29
+ }
30
+
31
+ interface HookOutput {
32
+ continue: boolean;
33
+ message?: string;
34
+ hookSpecificOutput?: {
35
+ hookEventName: string;
36
+ additionalContext?: string;
37
+ };
38
+ }
39
+
40
+ async function main(): Promise<void> {
41
+ try {
42
+ const input = await readStdin();
43
+ if (!input.trim()) {
44
+ console.log(JSON.stringify({ continue: true }));
45
+ return;
46
+ }
47
+
48
+ const data: HookInput = JSON.parse(input);
49
+ const toolName = data.tool_name || "";
50
+ const agentName = data.agent_name || "";
51
+ const cwd = data.cwd || process.cwd();
52
+
53
+ // Resolve active mode from aide binary (source of truth: BBolt store)
54
+ let activeMode: string | null = null;
55
+ try {
56
+ const binary = findAideBinary({
57
+ cwd,
58
+ pluginRoot:
59
+ process.env.AIDE_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT,
60
+ });
61
+ if (binary) {
62
+ activeMode = getState(binary, cwd, "mode");
63
+ }
64
+ } catch (err) {
65
+ debug(SOURCE, `Failed to resolve active mode (non-fatal): ${err}`);
66
+ }
67
+
68
+ const result = evaluateToolUse(
69
+ toolName,
70
+ agentName || undefined,
71
+ activeMode,
72
+ );
73
+
74
+ if (!result.allowed) {
75
+ const output: HookOutput = {
76
+ continue: false,
77
+ message: result.denyMessage,
78
+ };
79
+ console.log(JSON.stringify(output));
80
+ return;
81
+ }
82
+
83
+ if (result.reminder) {
84
+ const output: HookOutput = {
85
+ continue: true,
86
+ hookSpecificOutput: {
87
+ hookEventName: "PreToolUse",
88
+ additionalContext: result.reminder,
89
+ },
90
+ };
91
+ console.log(JSON.stringify(output));
92
+ } else {
93
+ console.log(JSON.stringify({ continue: true }));
94
+ }
95
+ } catch (error) {
96
+ debug(SOURCE, `Hook error: ${error}`);
97
+ console.log(JSON.stringify({ continue: true }));
98
+ }
99
+ }
100
+
101
+ process.on("uncaughtException", (err) => {
102
+ debug(SOURCE, `UNCAUGHT EXCEPTION: ${err}`);
103
+ try {
104
+ console.log(JSON.stringify({ continue: true }));
105
+ } catch {
106
+ console.log('{"continue":true}');
107
+ }
108
+ process.exit(0);
109
+ });
110
+ process.on("unhandledRejection", (reason) => {
111
+ debug(SOURCE, `UNHANDLED REJECTION: ${reason}`);
112
+ try {
113
+ console.log(JSON.stringify({ continue: true }));
114
+ } catch {
115
+ console.log('{"continue":true}');
116
+ }
117
+ process.exit(0);
118
+ });
119
+
120
+ main();
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Session End Hook (SessionEnd)
4
+ *
5
+ * Delegates cleanup to `aide session end` — single binary invocation.
6
+ *
7
+ * When invoked from Codex CLI's Stop hook (which has no separate SessionEnd),
8
+ * checks whether autopilot mode is active and skips cleanup if so — the
9
+ * session is continuing, not ending.
10
+ *
11
+ * CONSTRAINTS:
12
+ * - No ES module imports (hoisted resolution adds ~3s)
13
+ * - Output {"continue": true} before require() resolves
14
+ * - Cleanup via detached spawn to avoid blocking exit
15
+ * - MUST complete well within Claude Code's ~1.5s hook timeout
16
+ *
17
+ * STDIN:
18
+ * Claude Code pipes JSON via stdin and closes the pipe. The documented
19
+ * payload includes session_id, but in practice Claude Code often sends
20
+ * just `{}`. Bun's `for await` async iterator on stdin hangs even when
21
+ * the pipe is closed — use synchronous readFileSync(0) instead.
22
+ */
23
+
24
+ const T0 = performance.now();
25
+
26
+ // Output continue IMMEDIATELY — before require(), before anything.
27
+ console.log(JSON.stringify({ continue: true }));
28
+
29
+ const { spawn, execFileSync } = require("child_process") as typeof import("child_process");
30
+ const { existsSync, realpathSync, appendFileSync, mkdirSync, readFileSync } = require("fs") as typeof import("fs");
31
+ const { join } = require("path") as typeof import("path");
32
+ const whichSync = (require("which") as typeof import("which")).sync;
33
+
34
+ const SESSION_ID_RE = /^[a-zA-Z0-9_-]{1,128}$/;
35
+
36
+ /** Elapsed ms since T0. */
37
+ function ms(): string {
38
+ return `+${(performance.now() - T0).toFixed(0)}ms`;
39
+ }
40
+
41
+ /**
42
+ * Always log to .aide/_logs/session-end.log (NOT gated on AIDE_DEBUG).
43
+ * This hook has historically been invisible when it fails — always log.
44
+ */
45
+ function log(cwd: string, msg: string): void {
46
+ try {
47
+ const logDir = join(cwd, ".aide", "_logs");
48
+ if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
49
+ const line = `[${new Date().toISOString()}] [session-end] ${ms()} ${msg}\n`;
50
+ appendFileSync(join(logDir, "session-end.log"), line);
51
+ } catch { /* best effort */ }
52
+ }
53
+
54
+ /** Find aide binary — inline, no external module imports. */
55
+ function findBinary(cwd?: string): string | null {
56
+ const pluginRoot = process.env.AIDE_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT;
57
+ let resolvedRoot = pluginRoot;
58
+ if (resolvedRoot) {
59
+ try { resolvedRoot = realpathSync(resolvedRoot); } catch { /* symlink may not resolve */ }
60
+ const p = join(resolvedRoot, "bin", "aide");
61
+ if (existsSync(p)) return p;
62
+ }
63
+ if (cwd) {
64
+ const p = join(cwd, ".aide", "bin", "aide");
65
+ if (existsSync(p)) return p;
66
+ }
67
+ try {
68
+ return whichSync("aide", { nothrow: true });
69
+ } catch { return null; }
70
+ }
71
+
72
+ function main(): void {
73
+ const cwd = process.cwd();
74
+ log(cwd, "started");
75
+
76
+ try {
77
+ // Read stdin synchronously — avoids bun's broken async iterator.
78
+ // Claude Code closes the pipe so this returns immediately.
79
+ let input = "";
80
+ try {
81
+ input = readFileSync(0, "utf-8");
82
+ } catch {
83
+ log(cwd, "stdin not readable (not a pipe?)");
84
+ }
85
+ log(cwd, `stdin (${input.length} bytes): ${input.trim().slice(0, 200)}`);
86
+
87
+ // Parse session_id from stdin JSON
88
+ let sessionId = "";
89
+ if (input.trim()) {
90
+ try {
91
+ const data = JSON.parse(input);
92
+ sessionId = data.session_id || "";
93
+ } catch {
94
+ log(cwd, "stdin is not valid JSON");
95
+ }
96
+ }
97
+
98
+ if (!sessionId) {
99
+ log(cwd, "no session_id in payload, skipping cleanup");
100
+ return;
101
+ }
102
+
103
+ if (!SESSION_ID_RE.test(sessionId)) {
104
+ log(cwd, `invalid session_id: ${sessionId}`);
105
+ return;
106
+ }
107
+
108
+ const binary = findBinary(cwd);
109
+ log(cwd, `binary: ${binary}`);
110
+ if (!binary) {
111
+ log(cwd, "no binary found, skipping cleanup");
112
+ return;
113
+ }
114
+
115
+ // Mode guard: skip cleanup if autopilot is active (session is continuing,
116
+ // not ending). This matters when Codex CLI invokes session-end from Stop
117
+ // hook since there's no separate SessionEnd event.
118
+ try {
119
+ const mode = execFileSync(binary, ["state", "get", "mode"], {
120
+ cwd, timeout: 500, encoding: "utf-8",
121
+ }).trim();
122
+ if (mode === "autopilot") {
123
+ log(cwd, "autopilot mode active, skipping cleanup (session continuing)");
124
+ return;
125
+ }
126
+ } catch {
127
+ // Binary may not support 'state get' or state may not exist — proceed
128
+ }
129
+
130
+ log(cwd, `spawning cleanup: session end --session=${sessionId}`);
131
+ const child = spawn(binary, ["session", "end", `--session=${sessionId}`], {
132
+ cwd, detached: true, stdio: "ignore",
133
+ });
134
+ child.unref();
135
+ log(cwd, "cleanup spawned (detached)");
136
+ } catch (error) {
137
+ log(cwd, `error: ${error}`);
138
+ }
139
+
140
+ log(cwd, `done ${ms()}`);
141
+ }
142
+
143
+ // On SIGINT/SIGTERM, exit cleanly (continue already output).
144
+ process.on("SIGINT", () => process.exit(0));
145
+ process.on("SIGTERM", () => process.exit(0));
146
+
147
+ main();
148
+ process.exit(0);