@agiflowai/hooks-adapter 0.0.0

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/dist/index.mjs ADDED
@@ -0,0 +1,480 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ import * as crypto from "node:crypto";
5
+ import { CLAUDE_CODE } from "@agiflowai/coding-agent-bridge";
6
+
7
+ //#region src/constants/index.ts
8
+ /**
9
+ * @agiflowai/hooks-adapter - Constants
10
+ *
11
+ * DESIGN PATTERNS:
12
+ * - Strongly-typed constant exports for compile-time safety
13
+ * - Immutable by default (as const assertions)
14
+ *
15
+ * CODING STANDARDS:
16
+ * - Primitive constants: UPPER_SNAKE_CASE
17
+ * - Always include JSDoc with purpose and usage
18
+ *
19
+ * AVOID:
20
+ * - Mutable exports (let, var)
21
+ * - Magic strings without explanation
22
+ */
23
+ /**
24
+ * Hook type identifiers for different hook events
25
+ */
26
+ const PRE_TOOL_USE = "PreToolUse";
27
+ const POST_TOOL_USE = "PostToolUse";
28
+
29
+ //#endregion
30
+ //#region src/adapters/BaseAdapter.ts
31
+ /**
32
+ * Abstract base adapter for normalizing AI agent hook formats
33
+ */
34
+ var BaseAdapter = class {
35
+ /**
36
+ * Execute hook callback with normalized context
37
+ * Template method that orchestrates the hook execution flow
38
+ *
39
+ * @param callback - Hook callback function to execute
40
+ */
41
+ async execute(callback) {
42
+ try {
43
+ const stdin = await this.readStdin();
44
+ const response = await callback(this.parseInput(stdin));
45
+ if (response.decision === "skip") {
46
+ process.exit(0);
47
+ return;
48
+ }
49
+ const output = this.formatOutput(response);
50
+ console.log(output);
51
+ process.exit(0);
52
+ } catch (error) {
53
+ this.handleError(error);
54
+ }
55
+ }
56
+ /**
57
+ * Read stdin from AI agent
58
+ * @returns Promise resolving to stdin content
59
+ */
60
+ readStdin() {
61
+ return new Promise((resolve, reject) => {
62
+ const chunks = [];
63
+ process.stdin.on("data", (chunk) => {
64
+ chunks.push(chunk);
65
+ });
66
+ process.stdin.on("end", () => {
67
+ resolve(Buffer.concat(chunks).toString("utf8"));
68
+ });
69
+ process.stdin.on("error", (error) => {
70
+ reject(error);
71
+ });
72
+ });
73
+ }
74
+ /**
75
+ * Handle errors with fail-open behavior
76
+ * Allows operation to proceed with warning message
77
+ *
78
+ * @param error - Error that occurred during hook execution
79
+ */
80
+ handleError(error) {
81
+ const errorMessage = error instanceof Error ? error.message : String(error);
82
+ const output = this.formatOutput({
83
+ decision: "allow",
84
+ message: `⚠️ Hook error: ${errorMessage}`
85
+ });
86
+ console.log(output);
87
+ process.exit(0);
88
+ }
89
+ };
90
+
91
+ //#endregion
92
+ //#region src/adapters/ClaudeCodeAdapter.ts
93
+ /**
94
+ * ClaudeCodeAdapter - Adapter for Claude Code hook format
95
+ *
96
+ * DESIGN PATTERNS:
97
+ * - Adapter pattern: Converts Claude Code format to normalized format
98
+ * - Parser pattern: Extracts file paths and operations from tool inputs
99
+ *
100
+ * CODING STANDARDS:
101
+ * - Parse Claude Code JSON stdin format exactly as specified
102
+ * - Format output to match Claude Code hook response schema
103
+ * - Handle missing/optional fields gracefully
104
+ *
105
+ * AVOID:
106
+ * - Assuming all fields are present
107
+ * - Hardcoding tool names (use constants if needed)
108
+ * - Mutating input objects
109
+ */
110
+ /**
111
+ * Adapter for Claude Code hook format
112
+ */
113
+ var ClaudeCodeAdapter = class extends BaseAdapter {
114
+ /**
115
+ * Parse Claude Code stdin into normalized HookContext
116
+ *
117
+ * @param stdin - Raw JSON string from Claude Code
118
+ * @returns Normalized hook context
119
+ */
120
+ parseInput(stdin) {
121
+ const input = JSON.parse(stdin);
122
+ return {
123
+ toolName: input.tool_name,
124
+ toolInput: input.tool_input,
125
+ filePath: this.extractFilePath(input.tool_name, input.tool_input),
126
+ operation: this.extractOperation(input.tool_name),
127
+ cwd: input.cwd,
128
+ sessionId: input.session_id,
129
+ llmTool: input.llm_tool
130
+ };
131
+ }
132
+ /**
133
+ * Format normalized HookResponse into Claude Code output
134
+ *
135
+ * @param response - Normalized hook response
136
+ * @returns JSON string for Claude Code
137
+ */
138
+ formatOutput(response) {
139
+ if (response.decision === "skip") return JSON.stringify({}, null, 2);
140
+ const output = { hookSpecificOutput: {
141
+ hookEventName: "PreToolUse",
142
+ permissionDecision: response.decision,
143
+ permissionDecisionReason: response.message
144
+ } };
145
+ if (response.updatedInput) output.hookSpecificOutput.updatedInput = response.updatedInput;
146
+ return JSON.stringify(output, null, 2);
147
+ }
148
+ /**
149
+ * Extract file path from tool input
150
+ *
151
+ * @param toolName - Name of the tool
152
+ * @param toolInput - Tool input parameters
153
+ * @returns File path if this is a file operation
154
+ */
155
+ extractFilePath(toolName, toolInput) {
156
+ if ([
157
+ "Read",
158
+ "Write",
159
+ "Edit"
160
+ ].includes(toolName)) return toolInput.file_path;
161
+ }
162
+ /**
163
+ * Extract operation type from tool name
164
+ *
165
+ * @param toolName - Name of the tool
166
+ * @returns Operation type if this is a file operation
167
+ */
168
+ extractOperation(toolName) {
169
+ return {
170
+ Read: "read",
171
+ Write: "write",
172
+ Edit: "edit"
173
+ }[toolName];
174
+ }
175
+ };
176
+
177
+ //#endregion
178
+ //#region src/adapters/ClaudeCodePostToolUseAdapter.ts
179
+ /**
180
+ * ClaudeCodePostToolUseAdapter - Adapter for Claude Code PostToolUse hook format
181
+ *
182
+ * DESIGN PATTERNS:
183
+ * - Adapter pattern: Converts Claude Code PostToolUse format to normalized format
184
+ * - Parser pattern: Extracts file paths and operations from tool response
185
+ *
186
+ * CODING STANDARDS:
187
+ * - Parse Claude Code JSON stdin format exactly as specified
188
+ * - Format output to match Claude Code PostToolUse hook response schema
189
+ * - Handle missing/optional fields gracefully
190
+ *
191
+ * AVOID:
192
+ * - Assuming all fields are present
193
+ * - Hardcoding tool names (use constants if needed)
194
+ * - Mutating input objects
195
+ */
196
+ /**
197
+ * Adapter for Claude Code PostToolUse hook format
198
+ */
199
+ var ClaudeCodePostToolUseAdapter = class extends BaseAdapter {
200
+ /**
201
+ * Parse Claude Code PostToolUse stdin into normalized HookContext
202
+ *
203
+ * @param stdin - Raw JSON string from Claude Code
204
+ * @returns Normalized hook context
205
+ */
206
+ parseInput(stdin) {
207
+ const input = JSON.parse(stdin);
208
+ return {
209
+ toolName: input.tool_name,
210
+ toolInput: input.tool_input,
211
+ filePath: this.extractFilePath(input.tool_name, input.tool_input, input.tool_response),
212
+ operation: this.extractOperation(input.tool_name),
213
+ cwd: input.cwd,
214
+ sessionId: input.session_id,
215
+ llmTool: input.llm_tool
216
+ };
217
+ }
218
+ /**
219
+ * Format normalized HookResponse into Claude Code PostToolUse output
220
+ *
221
+ * @param response - Normalized hook response
222
+ * @returns JSON string for Claude Code
223
+ */
224
+ formatOutput(response) {
225
+ const output = { hookSpecificOutput: { hookEventName: "PostToolUse" } };
226
+ if (response.decision === "deny") {
227
+ output.decision = "block";
228
+ output.reason = response.message;
229
+ }
230
+ if (response.decision === "allow" && response.message) output.hookSpecificOutput.additionalContext = response.message;
231
+ return JSON.stringify(output, null, 2);
232
+ }
233
+ /**
234
+ * Extract file path from tool input or response
235
+ *
236
+ * @param toolName - Name of the tool
237
+ * @param toolInput - Tool input parameters
238
+ * @param toolResponse - Tool response data
239
+ * @returns File path if this is a file operation
240
+ */
241
+ extractFilePath(toolName, toolInput, toolResponse) {
242
+ if ([
243
+ "Read",
244
+ "Write",
245
+ "Edit"
246
+ ].includes(toolName)) return toolInput.file_path || toolResponse.filePath;
247
+ }
248
+ /**
249
+ * Extract operation type from tool name
250
+ *
251
+ * @param toolName - Name of the tool
252
+ * @returns Operation type if this is a file operation
253
+ */
254
+ extractOperation(toolName) {
255
+ return {
256
+ Read: "read",
257
+ Write: "write",
258
+ Edit: "edit"
259
+ }[toolName];
260
+ }
261
+ };
262
+
263
+ //#endregion
264
+ //#region src/services/ExecutionLogService.ts
265
+ /**
266
+ * ExecutionLogService - Tracks hook executions to prevent duplicate actions
267
+ *
268
+ * DESIGN PATTERNS:
269
+ * - Repository pattern: Abstracts data access to execution log
270
+ * - Query pattern: Provides efficient lookups for hook execution history
271
+ * - Singleton cache: In-memory cache for performance
272
+ *
273
+ * CODING STANDARDS:
274
+ * - Use static methods for stateless operations
275
+ * - Handle file system errors gracefully
276
+ * - Optimize for performance with efficient data structures
277
+ *
278
+ * AVOID:
279
+ * - Loading entire log file into memory
280
+ * - Blocking I/O operations
281
+ * - Complex parsing logic (keep it simple)
282
+ */
283
+ /**
284
+ * Service for tracking hook executions using an append-only log
285
+ * Prevents duplicate hook actions (e.g., showing design patterns twice for same file)
286
+ */
287
+ var ExecutionLogService = class ExecutionLogService {
288
+ /** Log file path - stored in system temp directory */
289
+ static LOG_FILE = path.join(os.tmpdir(), "hook-adapter-executions.jsonl");
290
+ /** In-memory cache of recent executions (last 1000 entries) */
291
+ static cache = null;
292
+ /** Max cache size to prevent memory bloat */
293
+ static MAX_CACHE_SIZE = 1e3;
294
+ /**
295
+ * Check if a specific action was already taken for this file in this session
296
+ *
297
+ * @param sessionId - Session identifier
298
+ * @param filePath - File path to check
299
+ * @param decision - Decision to check for (e.g., 'deny' means we already showed patterns)
300
+ * @returns true if the action was already taken
301
+ */
302
+ static async hasExecuted(sessionId, filePath, decision) {
303
+ const entries = await ExecutionLogService.loadLog();
304
+ for (let i = entries.length - 1; i >= 0; i--) {
305
+ const entry = entries[i];
306
+ if (entry.sessionId === sessionId && entry.filePath === filePath && entry.decision === decision) return true;
307
+ }
308
+ return false;
309
+ }
310
+ /**
311
+ * Log a hook execution
312
+ *
313
+ * @param params - Log execution parameters
314
+ */
315
+ static async logExecution(params) {
316
+ const entry = {
317
+ timestamp: Date.now(),
318
+ sessionId: params.sessionId,
319
+ filePath: params.filePath,
320
+ operation: params.operation,
321
+ decision: params.decision,
322
+ filePattern: params.filePattern,
323
+ fileMtime: params.fileMtime,
324
+ fileChecksum: params.fileChecksum
325
+ };
326
+ try {
327
+ await fs.appendFile(ExecutionLogService.LOG_FILE, `${JSON.stringify(entry)}\n`, "utf-8");
328
+ if (ExecutionLogService.cache) {
329
+ ExecutionLogService.cache.push(entry);
330
+ if (ExecutionLogService.cache.length > ExecutionLogService.MAX_CACHE_SIZE) ExecutionLogService.cache = ExecutionLogService.cache.slice(-ExecutionLogService.MAX_CACHE_SIZE);
331
+ }
332
+ } catch (error) {
333
+ console.error("Failed to log hook execution:", error);
334
+ }
335
+ }
336
+ /**
337
+ * Load execution log from file
338
+ * Uses in-memory cache for performance
339
+ */
340
+ static async loadLog() {
341
+ if (ExecutionLogService.cache !== null) return ExecutionLogService.cache;
342
+ try {
343
+ const lines = (await fs.readFile(ExecutionLogService.LOG_FILE, "utf-8")).trim().split("\n").filter(Boolean);
344
+ const entries = [];
345
+ for (const line of lines) try {
346
+ entries.push(JSON.parse(line));
347
+ } catch {}
348
+ ExecutionLogService.cache = entries.slice(-ExecutionLogService.MAX_CACHE_SIZE);
349
+ return ExecutionLogService.cache;
350
+ } catch (error) {
351
+ if (error.code === "ENOENT") {
352
+ ExecutionLogService.cache = [];
353
+ return ExecutionLogService.cache;
354
+ }
355
+ console.error("Failed to load execution log:", error);
356
+ ExecutionLogService.cache = [];
357
+ return ExecutionLogService.cache;
358
+ }
359
+ }
360
+ /**
361
+ * Clear the execution log (for testing)
362
+ */
363
+ static async clearLog() {
364
+ try {
365
+ await fs.unlink(ExecutionLogService.LOG_FILE);
366
+ ExecutionLogService.cache = [];
367
+ } catch (error) {
368
+ if (error.code !== "ENOENT") throw error;
369
+ }
370
+ }
371
+ /**
372
+ * Get log statistics (for debugging)
373
+ */
374
+ static async getStats() {
375
+ const entries = await ExecutionLogService.loadLog();
376
+ const sessions = new Set(entries.map((e) => e.sessionId));
377
+ const files = new Set(entries.map((e) => e.filePath));
378
+ return {
379
+ totalEntries: entries.length,
380
+ uniqueSessions: sessions.size,
381
+ uniqueFiles: files.size
382
+ };
383
+ }
384
+ /**
385
+ * Get file metadata (mtime and checksum) for a file
386
+ *
387
+ * @param filePath - Path to the file
388
+ * @returns File metadata or null if file doesn't exist
389
+ */
390
+ static async getFileMetadata(filePath) {
391
+ try {
392
+ const content = await fs.readFile(filePath, "utf-8");
393
+ const checksum = crypto.createHash("md5").update(content).digest("hex");
394
+ return {
395
+ mtime: (await fs.stat(filePath)).mtimeMs,
396
+ checksum
397
+ };
398
+ } catch {
399
+ return null;
400
+ }
401
+ }
402
+ /**
403
+ * Check if a file has changed since the last execution for this session
404
+ * Returns true if the file should be reviewed (new file or content changed)
405
+ *
406
+ * @param sessionId - Session identifier
407
+ * @param filePath - File path to check
408
+ * @param decision - Decision type to check for
409
+ * @returns true if file has changed or no previous execution found
410
+ */
411
+ static async hasFileChanged(sessionId, filePath, decision) {
412
+ const entries = await ExecutionLogService.loadLog();
413
+ let lastExecution = null;
414
+ for (let i = entries.length - 1; i >= 0; i--) {
415
+ const entry = entries[i];
416
+ if (entry.sessionId === sessionId && entry.filePath === filePath && entry.decision === decision) {
417
+ lastExecution = entry;
418
+ break;
419
+ }
420
+ }
421
+ if (!lastExecution || !lastExecution.fileChecksum) return true;
422
+ const currentMetadata = await ExecutionLogService.getFileMetadata(filePath);
423
+ if (!currentMetadata) return true;
424
+ return currentMetadata.checksum !== lastExecution.fileChecksum;
425
+ }
426
+ };
427
+
428
+ //#endregion
429
+ //#region src/services/AdapterProxyService.ts
430
+ /**
431
+ * AdapterProxyService - Routes hook execution to appropriate adapter
432
+ *
433
+ * DESIGN PATTERNS:
434
+ * - Proxy pattern: Routes requests to appropriate handlers
435
+ * - Factory pattern: Creates adapter instances based on agent name
436
+ *
437
+ * CODING STANDARDS:
438
+ * - Use static methods for stateless operations
439
+ * - Provide clear error messages for invalid inputs
440
+ * - Follow TitleCase naming convention for service classes
441
+ *
442
+ * AVOID:
443
+ * - Creating adapter instances unnecessarily
444
+ * - Silently falling back to defaults
445
+ * - Complex conditional logic (use lookup tables)
446
+ */
447
+ /**
448
+ * Proxy service for routing hook execution
449
+ * Eliminates duplication across commands by centralizing hook routing logic
450
+ */
451
+ var AdapterProxyService = class AdapterProxyService {
452
+ /**
453
+ * Execute hook with the appropriate adapter for the agent
454
+ *
455
+ * @param agentName - Agent identifier (e.g., "claude-code")
456
+ * @param hookType - Type of hook ("PreToolUse" or "PostToolUse")
457
+ * @param callback - Hook callback function to execute
458
+ */
459
+ static async execute(agentName, hookType, callback) {
460
+ await AdapterProxyService.getAdapter(agentName, hookType).execute(callback);
461
+ }
462
+ /**
463
+ * Get adapter instance for agent and hook type
464
+ *
465
+ * @param agentName - Name of the AI agent (e.g., "claude-code")
466
+ * @param hookType - Type of hook ("PreToolUse" or "PostToolUse")
467
+ * @returns Adapter instance
468
+ */
469
+ static getAdapter(agentName, hookType) {
470
+ switch (agentName) {
471
+ case CLAUDE_CODE:
472
+ if (hookType === POST_TOOL_USE) return new ClaudeCodePostToolUseAdapter();
473
+ return new ClaudeCodeAdapter();
474
+ default: throw new Error(`Unknown agent: ${agentName}. Supported: ${CLAUDE_CODE}`);
475
+ }
476
+ }
477
+ };
478
+
479
+ //#endregion
480
+ export { AdapterProxyService, BaseAdapter, ClaudeCodeAdapter, ClaudeCodePostToolUseAdapter, ExecutionLogService, POST_TOOL_USE, PRE_TOOL_USE };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@agiflowai/hooks-adapter",
3
+ "description": "Hook adapters for normalizing AI agent hook formats (Claude Code, Gemini, etc.)",
4
+ "version": "0.0.0",
5
+ "license": "AGPL-3.0",
6
+ "author": "AgiflowIO",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/AgiFlow/aicode-toolkit.git",
10
+ "directory": "packages/hooks-adapter"
11
+ },
12
+ "homepage": "https://github.com/AgiFlow/aicode-toolkit#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/AgiFlow/aicode-toolkit/issues"
15
+ },
16
+ "keywords": [
17
+ "typescript",
18
+ "library"
19
+ ],
20
+ "main": "./dist/index.cjs",
21
+ "module": "./dist/index.mjs",
22
+ "types": "./dist/index.d.cts",
23
+ "files": [
24
+ "dist",
25
+ "README.md"
26
+ ],
27
+ "dependencies": {
28
+ "@agiflowai/coding-agent-bridge": "1.0.5"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.0.0",
32
+ "@vitest/coverage-v8": "^3.0.0",
33
+ "tsdown": "^0.16.4",
34
+ "typescript": "5.9.3",
35
+ "vitest": "^3.0.0"
36
+ },
37
+ "type": "module",
38
+ "exports": {
39
+ ".": {
40
+ "import": "./dist/index.mjs",
41
+ "require": "./dist/index.cjs"
42
+ },
43
+ "./package.json": "./package.json"
44
+ },
45
+ "scripts": {
46
+ "build": "tsdown",
47
+ "test": "vitest --run",
48
+ "typecheck": "tsc --noEmit"
49
+ }
50
+ }