@hasna/hooks 0.2.20 → 0.3.1
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 +46 -2
- package/bin/index.js +1205 -93
- package/dist/db/migrations/002_session_events.d.ts +10 -0
- package/dist/db/migrations/003_user_prompt_submit_event.d.ts +10 -0
- package/dist/db/pg-migrations.d.ts +1 -1
- package/dist/db/remote-storage.d.ts +9 -0
- package/dist/db/schema.d.ts +4 -3
- package/dist/db/storage-sync.d.ts +57 -0
- package/dist/index.d.ts +16 -3
- package/dist/index.js +1092 -67
- package/dist/lib/installer.d.ts +26 -5
- package/dist/lib/registry.d.ts +5 -1
- package/dist/storage.d.ts +4 -0
- package/dist/storage.js +696 -0
- package/hooks/codewith-native-common.test.ts +61 -0
- package/hooks/codewith-native-common.ts +478 -0
- package/hooks/hook-agent-rules-version-check/LICENSE +191 -0
- package/hooks/hook-agent-rules-version-check/README.md +50 -0
- package/hooks/hook-agent-rules-version-check/package.json +53 -0
- package/hooks/hook-agent-rules-version-check/src/hook.test.ts +104 -0
- package/hooks/hook-agent-rules-version-check/src/hook.ts +215 -0
- package/hooks/hook-agent-rules-version-check/tsconfig.json +25 -0
- package/hooks/hook-announce-start/README.md +7 -5
- package/hooks/hook-announce-start/package.json +2 -2
- package/hooks/hook-announce-start/src/hook.ts +33 -14
- package/hooks/hook-fleet-blockers-gate/LICENSE +191 -0
- package/hooks/hook-fleet-blockers-gate/README.md +39 -0
- package/hooks/hook-fleet-blockers-gate/package.json +52 -0
- package/hooks/hook-fleet-blockers-gate/src/hook.test.ts +106 -0
- package/hooks/hook-fleet-blockers-gate/src/hook.ts +242 -0
- package/hooks/hook-fleet-blockers-gate/tsconfig.json +25 -0
- package/hooks/hook-fleet-catchup/LICENSE +191 -0
- package/hooks/hook-fleet-catchup/README.md +40 -0
- package/hooks/hook-fleet-catchup/package.json +52 -0
- package/hooks/hook-fleet-catchup/src/hook.test.ts +156 -0
- package/hooks/hook-fleet-catchup/src/hook.ts +283 -0
- package/hooks/hook-fleet-catchup/tsconfig.json +25 -0
- package/hooks/hook-knowledge-context/README.md +53 -0
- package/hooks/hook-knowledge-context/package.json +52 -0
- package/hooks/hook-knowledge-context/src/hook.test.ts +168 -0
- package/hooks/hook-knowledge-context/src/hook.ts +355 -0
- package/hooks/hook-knowledge-context/tsconfig.json +25 -0
- package/hooks/pre-bash/README.md +17 -0
- package/hooks/pre-bash/package.json +12 -0
- package/hooks/pre-bash/src/hook.ts +103 -0
- package/hooks/prompt-guard/README.md +17 -0
- package/hooks/prompt-guard/package.json +12 -0
- package/hooks/prompt-guard/src/hook.ts +76 -0
- package/hooks/session-start/README.md +17 -0
- package/hooks/session-start/package.json +12 -0
- package/hooks/session-start/src/hook.ts +150 -0
- package/hooks/stop-sync/README.md +17 -0
- package/hooks/stop-sync/package.json +12 -0
- package/hooks/stop-sync/src/hook.ts +69 -0
- package/hooks/worktree-guard/README.md +17 -0
- package/hooks/worktree-guard/package.json +12 -0
- package/hooks/worktree-guard/src/hook.ts +88 -0
- package/package.json +14 -6
- package/dist/cli/components/App.d.ts +0 -7
- package/dist/cli/components/CategorySelect.d.ts +0 -7
- package/dist/cli/components/DataTable.d.ts +0 -11
- package/dist/cli/components/Header.d.ts +0 -7
- package/dist/cli/components/HookSelect.d.ts +0 -11
- package/dist/cli/components/InstallProgress.d.ts +0 -9
- package/dist/cli/components/SearchView.d.ts +0 -9
- package/dist/cli/index.d.ts +0 -2
- package/dist/lib/db-writer.d.ts +0 -9
- package/dist/mcp/http.d.ts +0 -16
- package/dist/mcp/server.d.ts +0 -17
package/dist/index.js
CHANGED
|
@@ -28,25 +28,25 @@ __export(exports_profiles, {
|
|
|
28
28
|
deleteProfile: () => deleteProfile,
|
|
29
29
|
createProfile: () => createProfile
|
|
30
30
|
});
|
|
31
|
-
import { existsSync as
|
|
32
|
-
import { join as
|
|
33
|
-
import { homedir as
|
|
31
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, readdirSync as readdirSync2, rmSync, cpSync as cpSync2 } from "fs";
|
|
32
|
+
import { join as join4 } from "path";
|
|
33
|
+
import { homedir as homedir4 } from "os";
|
|
34
34
|
function resolveProfilesDir() {
|
|
35
|
-
const newDir =
|
|
36
|
-
const oldDir =
|
|
37
|
-
if (!
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
const newDir = join4(homedir4(), ".hasna", "hooks", "profiles");
|
|
36
|
+
const oldDir = join4(homedir4(), ".hooks", "profiles");
|
|
37
|
+
if (!existsSync4(newDir) && existsSync4(oldDir)) {
|
|
38
|
+
mkdirSync3(join4(homedir4(), ".hasna", "hooks"), { recursive: true });
|
|
39
|
+
cpSync2(oldDir, newDir, { recursive: true });
|
|
40
40
|
}
|
|
41
41
|
return newDir;
|
|
42
42
|
}
|
|
43
43
|
function ensureProfilesDir() {
|
|
44
|
-
if (!
|
|
45
|
-
|
|
44
|
+
if (!existsSync4(PROFILES_DIR)) {
|
|
45
|
+
mkdirSync3(PROFILES_DIR, { recursive: true });
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
function profilePath(id) {
|
|
49
|
-
return
|
|
49
|
+
return join4(PROFILES_DIR, `${id}.json`);
|
|
50
50
|
}
|
|
51
51
|
function shortUuid() {
|
|
52
52
|
return crypto.randomUUID().slice(0, 8);
|
|
@@ -75,22 +75,22 @@ function createProfile(input) {
|
|
|
75
75
|
function getProfile(id) {
|
|
76
76
|
const path = profilePath(id);
|
|
77
77
|
try {
|
|
78
|
-
if (!
|
|
78
|
+
if (!existsSync4(path))
|
|
79
79
|
return null;
|
|
80
|
-
return JSON.parse(
|
|
80
|
+
return JSON.parse(readFileSync3(path, "utf-8"));
|
|
81
81
|
} catch {
|
|
82
82
|
return null;
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
function listProfiles() {
|
|
86
|
-
if (!
|
|
86
|
+
if (!existsSync4(PROFILES_DIR))
|
|
87
87
|
return [];
|
|
88
88
|
try {
|
|
89
|
-
const files =
|
|
89
|
+
const files = readdirSync2(PROFILES_DIR).filter((f) => f.endsWith(".json"));
|
|
90
90
|
const profiles = [];
|
|
91
91
|
for (const file of files) {
|
|
92
92
|
try {
|
|
93
|
-
const content =
|
|
93
|
+
const content = readFileSync3(join4(PROFILES_DIR, file), "utf-8");
|
|
94
94
|
profiles.push(JSON.parse(content));
|
|
95
95
|
} catch {}
|
|
96
96
|
}
|
|
@@ -113,7 +113,7 @@ function updateProfile(id, data) {
|
|
|
113
113
|
}
|
|
114
114
|
function deleteProfile(id) {
|
|
115
115
|
const path = profilePath(id);
|
|
116
|
-
if (!
|
|
116
|
+
if (!existsSync4(path))
|
|
117
117
|
return false;
|
|
118
118
|
try {
|
|
119
119
|
rmSync(path);
|
|
@@ -143,7 +143,7 @@ function importProfiles(profiles) {
|
|
|
143
143
|
continue;
|
|
144
144
|
}
|
|
145
145
|
const path = profilePath(profile.agent_id);
|
|
146
|
-
if (
|
|
146
|
+
if (existsSync4(path)) {
|
|
147
147
|
skipped++;
|
|
148
148
|
continue;
|
|
149
149
|
}
|
|
@@ -158,7 +158,671 @@ var init_profiles = __esm(() => {
|
|
|
158
158
|
PROFILES_DIR = resolveProfilesDir();
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
// src/db/index.ts
|
|
162
|
+
import { Database } from "bun:sqlite";
|
|
163
|
+
import { existsSync as existsSync2, mkdirSync, cpSync } from "fs";
|
|
164
|
+
import { join as join2 } from "path";
|
|
165
|
+
import { homedir as homedir2 } from "os";
|
|
166
|
+
|
|
167
|
+
// src/db/schema.ts
|
|
168
|
+
var CREATE_HOOK_EVENTS_TABLE = `
|
|
169
|
+
CREATE TABLE IF NOT EXISTS hook_events (
|
|
170
|
+
id TEXT PRIMARY KEY,
|
|
171
|
+
timestamp TEXT NOT NULL,
|
|
172
|
+
session_id TEXT NOT NULL,
|
|
173
|
+
hook_name TEXT NOT NULL,
|
|
174
|
+
event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit')),
|
|
175
|
+
tool_name TEXT,
|
|
176
|
+
tool_input TEXT,
|
|
177
|
+
result TEXT CHECK (result IN ('continue', 'block', NULL)),
|
|
178
|
+
error TEXT,
|
|
179
|
+
duration_ms INTEGER,
|
|
180
|
+
project_dir TEXT,
|
|
181
|
+
metadata TEXT
|
|
182
|
+
)
|
|
183
|
+
`;
|
|
184
|
+
var CREATE_INDEXES = [
|
|
185
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_timestamp ON hook_events (timestamp)`,
|
|
186
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_session_id ON hook_events (session_id)`,
|
|
187
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_hook_name ON hook_events (hook_name)`,
|
|
188
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_event_type ON hook_events (event_type)`,
|
|
189
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_errors ON hook_events (timestamp) WHERE error IS NOT NULL`
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
// src/db/migrations/001_initial.ts
|
|
193
|
+
function up(db) {
|
|
194
|
+
db.exec(CREATE_HOOK_EVENTS_TABLE);
|
|
195
|
+
for (const idx of CREATE_INDEXES) {
|
|
196
|
+
db.exec(idx);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// src/db/migrations/002_session_events.ts
|
|
201
|
+
function up2(db) {
|
|
202
|
+
const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
|
|
203
|
+
if (!row?.sql)
|
|
204
|
+
return;
|
|
205
|
+
if (row.sql.includes("SessionStart"))
|
|
206
|
+
return;
|
|
207
|
+
db.exec("BEGIN");
|
|
208
|
+
try {
|
|
209
|
+
db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
|
|
210
|
+
db.exec(CREATE_HOOK_EVENTS_TABLE);
|
|
211
|
+
db.exec(`INSERT INTO hook_events
|
|
212
|
+
(id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
|
|
213
|
+
SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
|
|
214
|
+
FROM hook_events_old`);
|
|
215
|
+
db.exec("DROP TABLE hook_events_old");
|
|
216
|
+
for (const idx of CREATE_INDEXES) {
|
|
217
|
+
db.exec(idx);
|
|
218
|
+
}
|
|
219
|
+
db.exec("COMMIT");
|
|
220
|
+
} catch (err) {
|
|
221
|
+
db.exec("ROLLBACK");
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// src/db/migrations/003_user_prompt_submit_event.ts
|
|
227
|
+
function up3(db) {
|
|
228
|
+
const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
|
|
229
|
+
if (!row?.sql)
|
|
230
|
+
return;
|
|
231
|
+
if (row.sql.includes("UserPromptSubmit"))
|
|
232
|
+
return;
|
|
233
|
+
db.exec("BEGIN");
|
|
234
|
+
try {
|
|
235
|
+
db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
|
|
236
|
+
db.exec(CREATE_HOOK_EVENTS_TABLE);
|
|
237
|
+
db.exec(`INSERT INTO hook_events
|
|
238
|
+
(id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
|
|
239
|
+
SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
|
|
240
|
+
FROM hook_events_old`);
|
|
241
|
+
db.exec("DROP TABLE hook_events_old");
|
|
242
|
+
for (const idx of CREATE_INDEXES) {
|
|
243
|
+
db.exec(idx);
|
|
244
|
+
}
|
|
245
|
+
db.exec("COMMIT");
|
|
246
|
+
} catch (err) {
|
|
247
|
+
db.exec("ROLLBACK");
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/db/migrations/index.ts
|
|
253
|
+
var MIGRATIONS = [
|
|
254
|
+
{ version: "001_initial", up },
|
|
255
|
+
{ version: "002_session_events", up: up2 },
|
|
256
|
+
{ version: "003_user_prompt_submit_event", up: up3 }
|
|
257
|
+
];
|
|
258
|
+
function ensureMigrationsTable(db) {
|
|
259
|
+
db.exec(`
|
|
260
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
261
|
+
version TEXT PRIMARY KEY,
|
|
262
|
+
applied_at TEXT NOT NULL
|
|
263
|
+
)
|
|
264
|
+
`);
|
|
265
|
+
}
|
|
266
|
+
function getApplied(db) {
|
|
267
|
+
const rows = db.query("SELECT version FROM schema_migrations").all();
|
|
268
|
+
return new Set(rows.map((r) => r.version));
|
|
269
|
+
}
|
|
270
|
+
function runMigrations(db) {
|
|
271
|
+
ensureMigrationsTable(db);
|
|
272
|
+
const applied = getApplied(db);
|
|
273
|
+
for (const migration of MIGRATIONS) {
|
|
274
|
+
if (applied.has(migration.version))
|
|
275
|
+
continue;
|
|
276
|
+
migration.up(db);
|
|
277
|
+
db.run("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", [
|
|
278
|
+
migration.version,
|
|
279
|
+
new Date().toISOString()
|
|
280
|
+
]);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/db/legacy-import.ts
|
|
285
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
286
|
+
import { join } from "path";
|
|
287
|
+
import { homedir } from "os";
|
|
288
|
+
var META_KEY = "legacy_import_done";
|
|
289
|
+
function ensureMetaTable(db) {
|
|
290
|
+
db.exec(`
|
|
291
|
+
CREATE TABLE IF NOT EXISTS _meta (
|
|
292
|
+
key TEXT PRIMARY KEY,
|
|
293
|
+
value TEXT NOT NULL
|
|
294
|
+
)
|
|
295
|
+
`);
|
|
296
|
+
}
|
|
297
|
+
function isAlreadyDone(db) {
|
|
298
|
+
ensureMetaTable(db);
|
|
299
|
+
const row = db.query("SELECT value FROM _meta WHERE key = ?").get(META_KEY);
|
|
300
|
+
return row?.value === "1";
|
|
301
|
+
}
|
|
302
|
+
function markDone(db) {
|
|
303
|
+
db.run("INSERT OR REPLACE INTO _meta (key, value) VALUES (?, ?)", [META_KEY, "1"]);
|
|
304
|
+
}
|
|
305
|
+
function nanoid() {
|
|
306
|
+
return crypto.randomUUID().replace(/-/g, "").slice(0, 21);
|
|
307
|
+
}
|
|
308
|
+
function importJsonlFile(db, filePath) {
|
|
309
|
+
let count = 0;
|
|
310
|
+
try {
|
|
311
|
+
const lines = readFileSync(filePath, "utf-8").split(`
|
|
312
|
+
`).filter(Boolean);
|
|
313
|
+
for (const line of lines) {
|
|
314
|
+
try {
|
|
315
|
+
const entry = JSON.parse(line);
|
|
316
|
+
db.run(`INSERT OR IGNORE INTO hook_events
|
|
317
|
+
(id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, project_dir)
|
|
318
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
319
|
+
nanoid(),
|
|
320
|
+
entry.timestamp ?? new Date().toISOString(),
|
|
321
|
+
entry.session_id ?? "legacy",
|
|
322
|
+
"sessionlog",
|
|
323
|
+
"PostToolUse",
|
|
324
|
+
entry.tool_name ?? null,
|
|
325
|
+
entry.tool_input ? String(entry.tool_input).slice(0, 500) : null,
|
|
326
|
+
null
|
|
327
|
+
]);
|
|
328
|
+
count++;
|
|
329
|
+
} catch {}
|
|
330
|
+
}
|
|
331
|
+
} catch {}
|
|
332
|
+
return count;
|
|
333
|
+
}
|
|
334
|
+
function importErrorsLog(db, filePath) {
|
|
335
|
+
let count = 0;
|
|
336
|
+
try {
|
|
337
|
+
const lines = readFileSync(filePath, "utf-8").split(`
|
|
338
|
+
`).filter(Boolean);
|
|
339
|
+
const linePattern = /^\[(.+?)\]\s+(?:\[session:(\S+)\]\s+)?(.+?)\s+\u2014\s+(.+)$/;
|
|
340
|
+
for (const line of lines) {
|
|
341
|
+
try {
|
|
342
|
+
const m = line.match(linePattern);
|
|
343
|
+
if (!m)
|
|
344
|
+
continue;
|
|
345
|
+
const [, timestamp, sessionPrefix, , errorMsg] = m;
|
|
346
|
+
db.run(`INSERT OR IGNORE INTO hook_events
|
|
347
|
+
(id, timestamp, session_id, hook_name, event_type, error)
|
|
348
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
349
|
+
nanoid(),
|
|
350
|
+
timestamp,
|
|
351
|
+
sessionPrefix ? `legacy-${sessionPrefix}` : "legacy",
|
|
352
|
+
"errornotify",
|
|
353
|
+
"PostToolUse",
|
|
354
|
+
errorMsg.slice(0, 500)
|
|
355
|
+
]);
|
|
356
|
+
count++;
|
|
357
|
+
} catch {}
|
|
358
|
+
}
|
|
359
|
+
} catch {}
|
|
360
|
+
return count;
|
|
361
|
+
}
|
|
362
|
+
function runLegacyImport(db) {
|
|
363
|
+
try {
|
|
364
|
+
if (isAlreadyDone(db))
|
|
365
|
+
return;
|
|
366
|
+
let total = 0;
|
|
367
|
+
const claudeProjectsDir = join(homedir(), ".claude", "projects");
|
|
368
|
+
if (existsSync(claudeProjectsDir)) {
|
|
369
|
+
try {
|
|
370
|
+
const projectDirs = readdirSync(claudeProjectsDir);
|
|
371
|
+
for (const dir of projectDirs) {
|
|
372
|
+
const projectDir = join(claudeProjectsDir, dir);
|
|
373
|
+
try {
|
|
374
|
+
const files = readdirSync(projectDir);
|
|
375
|
+
for (const file of files) {
|
|
376
|
+
if (file.match(/^session-log-\d{4}-\d{2}-\d{2}\.jsonl$/)) {
|
|
377
|
+
total += importJsonlFile(db, join(projectDir, file));
|
|
378
|
+
}
|
|
379
|
+
if (file === "errors.log") {
|
|
380
|
+
total += importErrorsLog(db, join(projectDir, file));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
} catch {}
|
|
384
|
+
}
|
|
385
|
+
} catch {}
|
|
386
|
+
}
|
|
387
|
+
markDone(db);
|
|
388
|
+
if (total > 0) {
|
|
389
|
+
process.stderr.write(`[hooks] Imported ${total} legacy log entries into SQLite.
|
|
390
|
+
`);
|
|
391
|
+
}
|
|
392
|
+
} catch (err) {
|
|
393
|
+
process.stderr.write(`[hooks] Legacy import failed (non-fatal): ${err}
|
|
394
|
+
`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// src/db/retention.ts
|
|
399
|
+
function runRetention(db, days) {
|
|
400
|
+
const envDays = parseInt(process.env.HOOKS_RETENTION_DAYS ?? "30");
|
|
401
|
+
const retentionDays = days ?? (isNaN(envDays) || envDays <= 0 ? 30 : envDays);
|
|
402
|
+
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
|
403
|
+
try {
|
|
404
|
+
db.run("DELETE FROM hook_events WHERE timestamp < ?", [cutoff]);
|
|
405
|
+
const changes = db.query("SELECT changes() as changes").get()?.changes ?? 0;
|
|
406
|
+
return changes;
|
|
407
|
+
} catch {
|
|
408
|
+
return 0;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// src/db/index.ts
|
|
413
|
+
var instance = null;
|
|
414
|
+
function resolveDataDir() {
|
|
415
|
+
const explicit = process.env.HASNA_HOOKS_DATA_DIR ?? process.env.HOOKS_DATA_DIR;
|
|
416
|
+
if (explicit)
|
|
417
|
+
return explicit;
|
|
418
|
+
const newDir = join2(homedir2(), ".hasna", "hooks");
|
|
419
|
+
const oldDir = join2(homedir2(), ".hooks");
|
|
420
|
+
if (!existsSync2(newDir) && existsSync2(oldDir)) {
|
|
421
|
+
mkdirSync(join2(homedir2(), ".hasna"), { recursive: true });
|
|
422
|
+
cpSync(oldDir, newDir, { recursive: true });
|
|
423
|
+
}
|
|
424
|
+
return newDir;
|
|
425
|
+
}
|
|
426
|
+
function getDbPath() {
|
|
427
|
+
const explicitDb = process.env.HASNA_HOOKS_DB_PATH ?? process.env.HOOKS_DB_PATH;
|
|
428
|
+
if (explicitDb)
|
|
429
|
+
return explicitDb;
|
|
430
|
+
const dataDir = resolveDataDir();
|
|
431
|
+
return join2(dataDir, "hooks.db");
|
|
432
|
+
}
|
|
433
|
+
function ensureDir(dbPath) {
|
|
434
|
+
const dir = dbPath.substring(0, dbPath.lastIndexOf("/"));
|
|
435
|
+
if (dir && !existsSync2(dir)) {
|
|
436
|
+
mkdirSync(dir, { recursive: true });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
function getDb() {
|
|
440
|
+
if (instance)
|
|
441
|
+
return instance;
|
|
442
|
+
const dbPath = getDbPath();
|
|
443
|
+
const isNew = dbPath === ":memory:" || !existsSync2(dbPath);
|
|
444
|
+
ensureDir(dbPath);
|
|
445
|
+
instance = new Database(dbPath);
|
|
446
|
+
instance.exec("PRAGMA journal_mode=WAL");
|
|
447
|
+
instance.exec("PRAGMA foreign_keys=ON");
|
|
448
|
+
runMigrations(instance);
|
|
449
|
+
runRetention(instance);
|
|
450
|
+
instance.exec(`CREATE TABLE IF NOT EXISTS feedback (
|
|
451
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
452
|
+
message TEXT NOT NULL,
|
|
453
|
+
email TEXT,
|
|
454
|
+
category TEXT DEFAULT 'general',
|
|
455
|
+
version TEXT,
|
|
456
|
+
machine_id TEXT,
|
|
457
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
458
|
+
)`);
|
|
459
|
+
if (isNew) {
|
|
460
|
+
runLegacyImport(instance);
|
|
461
|
+
}
|
|
462
|
+
return instance;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// src/db/pg-migrations.ts
|
|
466
|
+
var PG_MIGRATIONS = [
|
|
467
|
+
`CREATE TABLE IF NOT EXISTS hook_events (
|
|
468
|
+
id TEXT PRIMARY KEY,
|
|
469
|
+
timestamp TEXT NOT NULL,
|
|
470
|
+
session_id TEXT NOT NULL,
|
|
471
|
+
hook_name TEXT NOT NULL,
|
|
472
|
+
event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit')),
|
|
473
|
+
tool_name TEXT,
|
|
474
|
+
tool_input TEXT,
|
|
475
|
+
result TEXT CHECK (result IN ('continue', 'block', NULL)),
|
|
476
|
+
error TEXT,
|
|
477
|
+
duration_ms INTEGER,
|
|
478
|
+
project_dir TEXT,
|
|
479
|
+
metadata TEXT
|
|
480
|
+
)`,
|
|
481
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_timestamp ON hook_events (timestamp)`,
|
|
482
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_session_id ON hook_events (session_id)`,
|
|
483
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_hook_name ON hook_events (hook_name)`,
|
|
484
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_event_type ON hook_events (event_type)`,
|
|
485
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_errors ON hook_events (timestamp) WHERE error IS NOT NULL`,
|
|
486
|
+
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
487
|
+
version TEXT PRIMARY KEY,
|
|
488
|
+
applied_at TEXT NOT NULL
|
|
489
|
+
)`,
|
|
490
|
+
`CREATE TABLE IF NOT EXISTS _meta (
|
|
491
|
+
key TEXT PRIMARY KEY,
|
|
492
|
+
value TEXT NOT NULL
|
|
493
|
+
)`,
|
|
494
|
+
`CREATE TABLE IF NOT EXISTS feedback (
|
|
495
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
496
|
+
message TEXT NOT NULL,
|
|
497
|
+
email TEXT,
|
|
498
|
+
category TEXT DEFAULT 'general',
|
|
499
|
+
version TEXT,
|
|
500
|
+
machine_id TEXT,
|
|
501
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text
|
|
502
|
+
)`,
|
|
503
|
+
`ALTER TABLE hook_events DROP CONSTRAINT IF EXISTS hook_events_event_type_check`,
|
|
504
|
+
`ALTER TABLE hook_events ADD CONSTRAINT hook_events_event_type_check
|
|
505
|
+
CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit'))`
|
|
506
|
+
];
|
|
507
|
+
|
|
508
|
+
// src/db/remote-storage.ts
|
|
509
|
+
import pg from "pg";
|
|
510
|
+
function translatePlaceholders(sql) {
|
|
511
|
+
let index = 0;
|
|
512
|
+
return sql.replace(/\?/g, () => `$${++index}`);
|
|
513
|
+
}
|
|
514
|
+
function normalizeParams(params) {
|
|
515
|
+
const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
|
|
516
|
+
return flat.map((value) => value === undefined ? null : value);
|
|
517
|
+
}
|
|
518
|
+
function sslConfigFor(connectionString) {
|
|
519
|
+
return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
class PgAdapterAsync {
|
|
523
|
+
pool;
|
|
524
|
+
constructor(connectionString) {
|
|
525
|
+
this.pool = new pg.Pool({ connectionString, ssl: sslConfigFor(connectionString) });
|
|
526
|
+
}
|
|
527
|
+
async run(sql, ...params) {
|
|
528
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
529
|
+
return { changes: result.rowCount ?? 0 };
|
|
530
|
+
}
|
|
531
|
+
async all(sql, ...params) {
|
|
532
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
533
|
+
return result.rows;
|
|
534
|
+
}
|
|
535
|
+
async close() {
|
|
536
|
+
await this.pool.end();
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/db/storage-sync.ts
|
|
541
|
+
var STORAGE_TABLES = [
|
|
542
|
+
"hook_events",
|
|
543
|
+
"schema_migrations",
|
|
544
|
+
"_meta",
|
|
545
|
+
"feedback"
|
|
546
|
+
];
|
|
547
|
+
var HOOKS_STORAGE_TABLES = STORAGE_TABLES;
|
|
548
|
+
var HOOKS_STORAGE_ENV = "HASNA_HOOKS_DATABASE_URL";
|
|
549
|
+
var HOOKS_STORAGE_FALLBACK_ENV = "HOOKS_DATABASE_URL";
|
|
550
|
+
var HOOKS_STORAGE_MODE_ENV = "HASNA_HOOKS_STORAGE_MODE";
|
|
551
|
+
var HOOKS_STORAGE_MODE_FALLBACK_ENV = "HOOKS_STORAGE_MODE";
|
|
552
|
+
var STORAGE_DATABASE_ENV = [HOOKS_STORAGE_ENV, HOOKS_STORAGE_FALLBACK_ENV];
|
|
553
|
+
var STORAGE_MODE_ENV = [HOOKS_STORAGE_MODE_ENV, HOOKS_STORAGE_MODE_FALLBACK_ENV];
|
|
554
|
+
var PRIMARY_KEYS = {
|
|
555
|
+
hook_events: ["id"],
|
|
556
|
+
schema_migrations: ["version"],
|
|
557
|
+
_meta: ["key"],
|
|
558
|
+
feedback: ["id"]
|
|
559
|
+
};
|
|
560
|
+
function readEnv(name) {
|
|
561
|
+
const value = process.env[name]?.trim();
|
|
562
|
+
return value || undefined;
|
|
563
|
+
}
|
|
564
|
+
function normalizeStorageMode(value) {
|
|
565
|
+
const normalized = value?.trim().toLowerCase();
|
|
566
|
+
if (normalized === "local" || normalized === "hybrid" || normalized === "remote")
|
|
567
|
+
return normalized;
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
function getStorageDatabaseEnvName() {
|
|
571
|
+
for (const name of STORAGE_DATABASE_ENV) {
|
|
572
|
+
if (readEnv(name))
|
|
573
|
+
return name;
|
|
574
|
+
}
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
function getStorageDatabaseEnv() {
|
|
578
|
+
const name = getStorageDatabaseEnvName();
|
|
579
|
+
return name ? { name } : null;
|
|
580
|
+
}
|
|
581
|
+
function getStorageDatabaseUrl() {
|
|
582
|
+
const env = getStorageDatabaseEnv();
|
|
583
|
+
return env ? readEnv(env.name) ?? null : null;
|
|
584
|
+
}
|
|
585
|
+
function getStorageMode() {
|
|
586
|
+
const mode = normalizeStorageMode(readEnv(HOOKS_STORAGE_MODE_ENV)) ?? normalizeStorageMode(readEnv(HOOKS_STORAGE_MODE_FALLBACK_ENV));
|
|
587
|
+
if (mode)
|
|
588
|
+
return mode;
|
|
589
|
+
return getStorageDatabaseUrl() ? "hybrid" : "local";
|
|
590
|
+
}
|
|
591
|
+
async function getStoragePg() {
|
|
592
|
+
const url = getStorageDatabaseUrl();
|
|
593
|
+
if (!url) {
|
|
594
|
+
throw new Error("Missing HASNA_HOOKS_DATABASE_URL or HOOKS_DATABASE_URL");
|
|
595
|
+
}
|
|
596
|
+
return new PgAdapterAsync(url);
|
|
597
|
+
}
|
|
598
|
+
async function runStorageMigrations(remote) {
|
|
599
|
+
await remote.run("CREATE EXTENSION IF NOT EXISTS pgcrypto");
|
|
600
|
+
for (const sql of PG_MIGRATIONS)
|
|
601
|
+
await remote.run(sql);
|
|
602
|
+
}
|
|
603
|
+
async function storagePush(options) {
|
|
604
|
+
const remote = await getStoragePg();
|
|
605
|
+
const db = getDb();
|
|
606
|
+
try {
|
|
607
|
+
await runStorageMigrations(remote);
|
|
608
|
+
const results = [];
|
|
609
|
+
for (const table of resolveTables(options?.tables)) {
|
|
610
|
+
results.push(await pushTable(db, remote, table));
|
|
611
|
+
}
|
|
612
|
+
recordSyncMeta(db, "push", results);
|
|
613
|
+
return results;
|
|
614
|
+
} finally {
|
|
615
|
+
await remote.close();
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
async function storagePull(options) {
|
|
619
|
+
const remote = await getStoragePg();
|
|
620
|
+
const db = getDb();
|
|
621
|
+
try {
|
|
622
|
+
await runStorageMigrations(remote);
|
|
623
|
+
const results = [];
|
|
624
|
+
for (const table of resolveTables(options?.tables)) {
|
|
625
|
+
results.push(await pullTable(remote, db, table));
|
|
626
|
+
}
|
|
627
|
+
recordSyncMeta(db, "pull", results);
|
|
628
|
+
return results;
|
|
629
|
+
} finally {
|
|
630
|
+
await remote.close();
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
async function storageSync(options) {
|
|
634
|
+
const pull = await storagePull(options);
|
|
635
|
+
const push = await storagePush(options);
|
|
636
|
+
return { pull, push };
|
|
637
|
+
}
|
|
638
|
+
function getSyncMetaAll() {
|
|
639
|
+
const db = getDb();
|
|
640
|
+
ensureSyncMetaTable(db);
|
|
641
|
+
return db.query("SELECT table_name, last_synced_at, direction FROM _hooks_sync_meta ORDER BY table_name, direction").all();
|
|
642
|
+
}
|
|
643
|
+
function getStorageStatus() {
|
|
644
|
+
const activeEnv = getStorageDatabaseEnv();
|
|
645
|
+
return {
|
|
646
|
+
configured: Boolean(activeEnv),
|
|
647
|
+
mode: getStorageMode(),
|
|
648
|
+
env: STORAGE_DATABASE_ENV,
|
|
649
|
+
activeEnv: activeEnv?.name ?? null,
|
|
650
|
+
service: "hooks",
|
|
651
|
+
tables: STORAGE_TABLES,
|
|
652
|
+
sync: getSyncMetaAll()
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
function resolveTables(tables) {
|
|
656
|
+
if (!tables || tables.length === 0)
|
|
657
|
+
return [...STORAGE_TABLES];
|
|
658
|
+
const allowed = new Set(STORAGE_TABLES);
|
|
659
|
+
const requested = tables.map((table) => table.trim()).filter(Boolean);
|
|
660
|
+
const invalid = requested.filter((table) => !allowed.has(table));
|
|
661
|
+
if (invalid.length > 0)
|
|
662
|
+
throw new Error(`Unknown hooks sync table(s): ${invalid.join(", ")}`);
|
|
663
|
+
return requested;
|
|
664
|
+
}
|
|
665
|
+
function parseStorageTables(value) {
|
|
666
|
+
if (!value)
|
|
667
|
+
return;
|
|
668
|
+
return resolveTables(Array.isArray(value) ? value : value.split(","));
|
|
669
|
+
}
|
|
670
|
+
async function pushTable(db, remote, table) {
|
|
671
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
672
|
+
try {
|
|
673
|
+
if (!tableExists(db, table))
|
|
674
|
+
return result;
|
|
675
|
+
const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all();
|
|
676
|
+
result.rowsRead = rows.length;
|
|
677
|
+
if (rows.length === 0)
|
|
678
|
+
return result;
|
|
679
|
+
const remoteColumns = await getRemoteColumns(remote, table);
|
|
680
|
+
const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
|
|
681
|
+
result.rowsWritten = await upsertPg(remote, table, columns, rows, remoteColumns);
|
|
682
|
+
} catch (error) {
|
|
683
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
684
|
+
}
|
|
685
|
+
return result;
|
|
686
|
+
}
|
|
687
|
+
async function pullTable(remote, db, table) {
|
|
688
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
689
|
+
try {
|
|
690
|
+
if (!tableExists(db, table))
|
|
691
|
+
return result;
|
|
692
|
+
const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`);
|
|
693
|
+
result.rowsRead = rows.length;
|
|
694
|
+
if (rows.length === 0)
|
|
695
|
+
return result;
|
|
696
|
+
const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
|
|
697
|
+
result.rowsWritten = upsertSqlite(db, table, columns, rows);
|
|
698
|
+
} catch (error) {
|
|
699
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
700
|
+
}
|
|
701
|
+
return result;
|
|
702
|
+
}
|
|
703
|
+
async function getRemoteColumns(remote, table) {
|
|
704
|
+
const rows = await remote.all("SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?", table);
|
|
705
|
+
return new Map(rows.map((row) => [row.column_name, row.data_type]));
|
|
706
|
+
}
|
|
707
|
+
function filterRemoteColumns(remoteColumns, columns) {
|
|
708
|
+
if (remoteColumns.size === 0)
|
|
709
|
+
return columns;
|
|
710
|
+
return columns.filter((column) => remoteColumns.has(column));
|
|
711
|
+
}
|
|
712
|
+
function filterLocalColumns(db, table, columns) {
|
|
713
|
+
const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all();
|
|
714
|
+
const allowed = new Set(rows.map((row) => row.name));
|
|
715
|
+
return columns.filter((column) => allowed.has(column));
|
|
716
|
+
}
|
|
717
|
+
async function upsertPg(remote, table, columns, rows, remoteColumns) {
|
|
718
|
+
if (columns.length === 0)
|
|
719
|
+
return 0;
|
|
720
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
721
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
722
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
723
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
724
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
725
|
+
const fallbackKey = primaryKeys[0];
|
|
726
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
|
|
727
|
+
for (const row of rows) {
|
|
728
|
+
await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
|
|
729
|
+
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`, ...columns.map((column) => coerceForPg(row[column], remoteColumns.get(column))));
|
|
730
|
+
}
|
|
731
|
+
return rows.length;
|
|
732
|
+
}
|
|
733
|
+
function upsertSqlite(db, table, columns, rows) {
|
|
734
|
+
if (columns.length === 0)
|
|
735
|
+
return 0;
|
|
736
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
737
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
738
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
739
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
740
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
741
|
+
const fallbackKey = primaryKeys[0];
|
|
742
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`;
|
|
743
|
+
const statement = db.query(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
|
|
744
|
+
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`);
|
|
745
|
+
const insert = db.transaction((batch) => {
|
|
746
|
+
for (const row of batch)
|
|
747
|
+
statement.run(...columns.map((column) => coerceForSqlite(row[column])));
|
|
748
|
+
});
|
|
749
|
+
insert(rows);
|
|
750
|
+
return rows.length;
|
|
751
|
+
}
|
|
752
|
+
function recordSyncMeta(db, direction, results) {
|
|
753
|
+
ensureSyncMetaTable(db);
|
|
754
|
+
const now = new Date().toISOString();
|
|
755
|
+
const statement = db.query(`
|
|
756
|
+
INSERT INTO _hooks_sync_meta (table_name, last_synced_at, direction)
|
|
757
|
+
VALUES (?, ?, ?)
|
|
758
|
+
ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at
|
|
759
|
+
`);
|
|
760
|
+
for (const result of results) {
|
|
761
|
+
if (result.errors.length > 0)
|
|
762
|
+
continue;
|
|
763
|
+
statement.run(result.table, now, direction);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
function ensureSyncMetaTable(db) {
|
|
767
|
+
db.exec(`
|
|
768
|
+
CREATE TABLE IF NOT EXISTS _hooks_sync_meta (
|
|
769
|
+
table_name TEXT NOT NULL,
|
|
770
|
+
last_synced_at TEXT,
|
|
771
|
+
direction TEXT NOT NULL CHECK(direction IN ('push', 'pull')),
|
|
772
|
+
PRIMARY KEY (table_name, direction)
|
|
773
|
+
)
|
|
774
|
+
`);
|
|
775
|
+
}
|
|
776
|
+
function tableExists(db, table) {
|
|
777
|
+
const row = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
778
|
+
return Boolean(row);
|
|
779
|
+
}
|
|
780
|
+
function quoteIdent(identifier) {
|
|
781
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
782
|
+
}
|
|
783
|
+
function coerceForPg(value, dataType) {
|
|
784
|
+
if (value === undefined || value === null)
|
|
785
|
+
return null;
|
|
786
|
+
if (dataType === "boolean") {
|
|
787
|
+
if (typeof value === "boolean")
|
|
788
|
+
return value;
|
|
789
|
+
if (typeof value === "number")
|
|
790
|
+
return value !== 0;
|
|
791
|
+
if (typeof value === "string")
|
|
792
|
+
return value === "1" || value.toLowerCase() === "true";
|
|
793
|
+
}
|
|
794
|
+
if (value instanceof Date)
|
|
795
|
+
return value.toISOString();
|
|
796
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
797
|
+
return value;
|
|
798
|
+
if (typeof value === "object")
|
|
799
|
+
return JSON.stringify(value);
|
|
800
|
+
return value;
|
|
801
|
+
}
|
|
802
|
+
function coerceForSqlite(value) {
|
|
803
|
+
if (value === undefined || value === null)
|
|
804
|
+
return null;
|
|
805
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean")
|
|
806
|
+
return value;
|
|
807
|
+
if (value instanceof Date)
|
|
808
|
+
return value.toISOString();
|
|
809
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
810
|
+
return value;
|
|
811
|
+
if (typeof value === "object")
|
|
812
|
+
return JSON.stringify(value);
|
|
813
|
+
return String(value);
|
|
814
|
+
}
|
|
161
815
|
// src/lib/registry.ts
|
|
816
|
+
var HOOK_EVENTS = [
|
|
817
|
+
"PreToolUse",
|
|
818
|
+
"PostToolUse",
|
|
819
|
+
"Stop",
|
|
820
|
+
"Notification",
|
|
821
|
+
"SessionStart",
|
|
822
|
+
"SessionEnd",
|
|
823
|
+
"UserPromptSubmit",
|
|
824
|
+
"SubagentStart"
|
|
825
|
+
];
|
|
162
826
|
var CATEGORIES = [
|
|
163
827
|
"Git Safety",
|
|
164
828
|
"Code Quality",
|
|
@@ -202,6 +866,16 @@ var HOOKS = [
|
|
|
202
866
|
matcher: "Write|Edit|NotebookEdit",
|
|
203
867
|
tags: ["git", "snapshot", "rollback", "backup"]
|
|
204
868
|
},
|
|
869
|
+
{
|
|
870
|
+
name: "worktree-guard",
|
|
871
|
+
displayName: "Worktree Guard",
|
|
872
|
+
description: "Warns on feature work outside managed repos worktrees and blocks commit/push from shared checkouts",
|
|
873
|
+
version: "0.1.0",
|
|
874
|
+
category: "Git Safety",
|
|
875
|
+
event: "PreToolUse",
|
|
876
|
+
matcher: "Bash|Write|Edit|MultiEdit|NotebookEdit",
|
|
877
|
+
tags: ["git", "worktree", "repos", "multi-agent", "safety"]
|
|
878
|
+
},
|
|
205
879
|
{
|
|
206
880
|
name: "checktests",
|
|
207
881
|
displayName: "Check Tests",
|
|
@@ -282,6 +956,16 @@ var HOOKS = [
|
|
|
282
956
|
matcher: "Bash",
|
|
283
957
|
tags: ["npm", "packages", "typosquatting", "supply-chain"]
|
|
284
958
|
},
|
|
959
|
+
{
|
|
960
|
+
name: "pre-bash",
|
|
961
|
+
displayName: "Pre Bash",
|
|
962
|
+
description: "Codewith Bash gate for staged secrets scans before commit/push and comms rechecks before risky operations",
|
|
963
|
+
version: "0.1.0",
|
|
964
|
+
category: "Security",
|
|
965
|
+
event: "PreToolUse",
|
|
966
|
+
matcher: "Bash",
|
|
967
|
+
tags: ["codewith", "bash", "secrets", "gitleaks", "risky-ops"]
|
|
968
|
+
},
|
|
285
969
|
{
|
|
286
970
|
name: "phonenotify",
|
|
287
971
|
displayName: "Phone Notify",
|
|
@@ -322,6 +1006,17 @@ var HOOKS = [
|
|
|
322
1006
|
matcher: "",
|
|
323
1007
|
tags: ["context", "compaction", "state", "backup"]
|
|
324
1008
|
},
|
|
1009
|
+
{
|
|
1010
|
+
name: "knowledge-context",
|
|
1011
|
+
displayName: "Knowledge Context",
|
|
1012
|
+
description: "Injects deterministic Knowledge context packs into Codewith SessionStart, UserPromptSubmit, and SubagentStart",
|
|
1013
|
+
version: "0.1.0",
|
|
1014
|
+
category: "Context Management",
|
|
1015
|
+
event: "SessionStart",
|
|
1016
|
+
events: ["SessionStart", "UserPromptSubmit", "SubagentStart"],
|
|
1017
|
+
matcher: "",
|
|
1018
|
+
tags: ["knowledge", "context", "codewith", "deterministic", "session-start", "prompt", "subagent"]
|
|
1019
|
+
},
|
|
325
1020
|
{
|
|
326
1021
|
name: "autoformat",
|
|
327
1022
|
displayName: "Auto Format",
|
|
@@ -392,6 +1087,16 @@ var HOOKS = [
|
|
|
392
1087
|
matcher: "",
|
|
393
1088
|
tags: ["prompt", "injection", "security", "validation", "guard"]
|
|
394
1089
|
},
|
|
1090
|
+
{
|
|
1091
|
+
name: "prompt-guard",
|
|
1092
|
+
displayName: "Prompt Guard (Codewith)",
|
|
1093
|
+
description: "Codewith UserPromptSubmit guard for obvious pasted prompt-injection and fake policy/freeze instructions",
|
|
1094
|
+
version: "0.1.0",
|
|
1095
|
+
category: "Permissions",
|
|
1096
|
+
event: "UserPromptSubmit",
|
|
1097
|
+
matcher: "",
|
|
1098
|
+
tags: ["codewith", "prompt", "injection", "policy", "freeze"]
|
|
1099
|
+
},
|
|
395
1100
|
{
|
|
396
1101
|
name: "desktopnotify",
|
|
397
1102
|
displayName: "Desktop Notify",
|
|
@@ -482,6 +1187,26 @@ var HOOKS = [
|
|
|
482
1187
|
matcher: "",
|
|
483
1188
|
tags: ["tasks", "completion", "gate", "quality", "agent-teams"]
|
|
484
1189
|
},
|
|
1190
|
+
{
|
|
1191
|
+
name: "session-start",
|
|
1192
|
+
displayName: "Session Start",
|
|
1193
|
+
description: "Codewith SessionStart digest for blockers, announcements, identity registration, and heartbeat context",
|
|
1194
|
+
version: "0.1.0",
|
|
1195
|
+
category: "Agent Teams",
|
|
1196
|
+
event: "SessionStart",
|
|
1197
|
+
matcher: "",
|
|
1198
|
+
tags: ["codewith", "session", "context", "conversations", "heartbeat"]
|
|
1199
|
+
},
|
|
1200
|
+
{
|
|
1201
|
+
name: "stop-sync",
|
|
1202
|
+
displayName: "Stop Sync",
|
|
1203
|
+
description: "Codewith Stop turn-end heartbeat and best-effort task evidence helper",
|
|
1204
|
+
version: "0.1.0",
|
|
1205
|
+
category: "Agent Teams",
|
|
1206
|
+
event: "Stop",
|
|
1207
|
+
matcher: "",
|
|
1208
|
+
tags: ["codewith", "stop", "heartbeat", "todos", "turn-end"]
|
|
1209
|
+
},
|
|
485
1210
|
{
|
|
486
1211
|
name: "typecheck-gate",
|
|
487
1212
|
displayName: "Typecheck Gate",
|
|
@@ -535,10 +1260,10 @@ var HOOKS = [
|
|
|
535
1260
|
{
|
|
536
1261
|
name: "announce-start",
|
|
537
1262
|
displayName: "Announce Start",
|
|
538
|
-
description: "On
|
|
539
|
-
version: "0.
|
|
1263
|
+
description: "On SessionStart, auto-registers agent, reads messages, and announces to space",
|
|
1264
|
+
version: "0.2.0",
|
|
540
1265
|
category: "Agent Teams",
|
|
541
|
-
event: "
|
|
1266
|
+
event: "SessionStart",
|
|
542
1267
|
matcher: "",
|
|
543
1268
|
tags: ["announcement", "start", "register", "messages", "agent-teams"]
|
|
544
1269
|
},
|
|
@@ -561,11 +1286,44 @@ var HOOKS = [
|
|
|
561
1286
|
event: "Notification",
|
|
562
1287
|
matcher: "",
|
|
563
1288
|
tags: ["messages", "dm", "context", "inject", "agent-teams"]
|
|
1289
|
+
},
|
|
1290
|
+
{
|
|
1291
|
+
name: "fleet-catchup",
|
|
1292
|
+
displayName: "Fleet Catchup",
|
|
1293
|
+
description: "On SessionStart, injects unread blockers, channel notifications, and the announcements digest into context (deterministic CLI reads, fail-open)",
|
|
1294
|
+
version: "0.1.0",
|
|
1295
|
+
category: "Agent Teams",
|
|
1296
|
+
event: "SessionStart",
|
|
1297
|
+
matcher: "",
|
|
1298
|
+
tags: ["fleet", "catchup", "blockers", "announcements", "context", "agent-teams"]
|
|
1299
|
+
},
|
|
1300
|
+
{
|
|
1301
|
+
name: "agent-rules-version-check",
|
|
1302
|
+
displayName: "Agent Rules Version Check",
|
|
1303
|
+
description: "On SessionStart, compares the rendered hasna:agent-operating-rules sentinel version against configs state and warns on mismatch",
|
|
1304
|
+
version: "0.1.0",
|
|
1305
|
+
category: "Agent Teams",
|
|
1306
|
+
event: "SessionStart",
|
|
1307
|
+
matcher: "",
|
|
1308
|
+
tags: ["fleet", "rules", "version", "sentinel", "configs", "drift", "agent-teams"]
|
|
1309
|
+
},
|
|
1310
|
+
{
|
|
1311
|
+
name: "fleet-blockers-gate",
|
|
1312
|
+
displayName: "Fleet Blockers Gate",
|
|
1313
|
+
description: "Before tool calls, blocks mutating tools while an unread [FREEZE] blocker is active (TTL-cached conversations check, fail-open <500ms)",
|
|
1314
|
+
version: "0.1.0",
|
|
1315
|
+
category: "Agent Teams",
|
|
1316
|
+
event: "PreToolUse",
|
|
1317
|
+
matcher: "",
|
|
1318
|
+
tags: ["fleet", "freeze", "blockers", "gate", "safety", "agent-teams"]
|
|
564
1319
|
}
|
|
565
1320
|
];
|
|
566
1321
|
function getHooksByCategory(category) {
|
|
567
1322
|
return HOOKS.filter((h) => h.category === category);
|
|
568
1323
|
}
|
|
1324
|
+
function getHookEvents(hook) {
|
|
1325
|
+
return hook.events && hook.events.length > 0 ? hook.events : [hook.event];
|
|
1326
|
+
}
|
|
569
1327
|
function searchHooks(query) {
|
|
570
1328
|
const q = query.toLowerCase();
|
|
571
1329
|
return HOOKS.filter((h) => h.name.toLowerCase().includes(q) || h.displayName.toLowerCase().includes(q) || h.description.toLowerCase().includes(q) || h.tags.some((t) => t.includes(q)));
|
|
@@ -574,21 +1332,21 @@ function getHook(name) {
|
|
|
574
1332
|
return HOOKS.find((h) => h.name === name);
|
|
575
1333
|
}
|
|
576
1334
|
// src/lib/installer.ts
|
|
577
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
578
|
-
import { join, dirname } from "path";
|
|
579
|
-
import { homedir } from "os";
|
|
1335
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
1336
|
+
import { join as join3, dirname } from "path";
|
|
1337
|
+
import { homedir as homedir3 } from "os";
|
|
580
1338
|
import { fileURLToPath } from "url";
|
|
581
1339
|
var __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
582
|
-
var HOOKS_DIR =
|
|
1340
|
+
var HOOKS_DIR = existsSync3(join3(__dirname2, "..", "..", "hooks", "hook-gitguard")) ? join3(__dirname2, "..", "..", "hooks") : join3(__dirname2, "..", "hooks");
|
|
583
1341
|
function normalizeHookName(name) {
|
|
584
1342
|
return name.startsWith("hook-") ? name : `hook-${name}`;
|
|
585
1343
|
}
|
|
586
1344
|
function shortHookName(name) {
|
|
587
|
-
return
|
|
1345
|
+
return name.startsWith("hook-") ? name.slice("hook-".length) : name;
|
|
588
1346
|
}
|
|
589
1347
|
function removeHookEntriesByName(entries, hookName) {
|
|
590
1348
|
return entries.filter((entry) => !entry.hooks?.some((h) => {
|
|
591
|
-
const match = h.command?.match(/^hooks run (\w+)/);
|
|
1349
|
+
const match = h.command?.match(/^hooks run ([\w-]+)/);
|
|
592
1350
|
return match && match[1] === hookName;
|
|
593
1351
|
}));
|
|
594
1352
|
}
|
|
@@ -597,38 +1355,67 @@ var EVENT_MAP = {
|
|
|
597
1355
|
PreToolUse: "PreToolUse",
|
|
598
1356
|
PostToolUse: "PostToolUse",
|
|
599
1357
|
Stop: "Stop",
|
|
600
|
-
Notification: "Notification"
|
|
1358
|
+
Notification: "Notification",
|
|
1359
|
+
SessionStart: "SessionStart",
|
|
1360
|
+
SessionEnd: "SessionEnd",
|
|
1361
|
+
UserPromptSubmit: null,
|
|
1362
|
+
SubagentStart: null
|
|
601
1363
|
},
|
|
602
1364
|
gemini: {
|
|
603
1365
|
PreToolUse: "BeforeTool",
|
|
604
1366
|
PostToolUse: "AfterTool",
|
|
605
1367
|
Stop: "AfterAgent",
|
|
606
|
-
Notification: "Notification"
|
|
1368
|
+
Notification: "Notification",
|
|
1369
|
+
SessionStart: null,
|
|
1370
|
+
SessionEnd: null,
|
|
1371
|
+
UserPromptSubmit: null,
|
|
1372
|
+
SubagentStart: null
|
|
1373
|
+
},
|
|
1374
|
+
codewith: {
|
|
1375
|
+
PreToolUse: "PreToolUse",
|
|
1376
|
+
PostToolUse: "PostToolUse",
|
|
1377
|
+
Stop: "Stop",
|
|
1378
|
+
Notification: null,
|
|
1379
|
+
SessionStart: "SessionStart",
|
|
1380
|
+
SessionEnd: null,
|
|
1381
|
+
UserPromptSubmit: "UserPromptSubmit",
|
|
1382
|
+
SubagentStart: "SubagentStart"
|
|
607
1383
|
}
|
|
608
1384
|
};
|
|
609
1385
|
function getTargetSettingsDir(target) {
|
|
1386
|
+
if (target === "codewith")
|
|
1387
|
+
return ".codewith";
|
|
610
1388
|
if (target === "gemini")
|
|
611
1389
|
return ".gemini";
|
|
612
1390
|
return ".claude";
|
|
613
1391
|
}
|
|
614
|
-
function getSettingsPath(scope = "global", target = "claude") {
|
|
1392
|
+
function getSettingsPath(scope = "global", target = "claude", codewithConfigPath) {
|
|
1393
|
+
if (target === "codewith" && codewithConfigPath)
|
|
1394
|
+
return codewithConfigPath;
|
|
1395
|
+
if (target === "codewith" && process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH) {
|
|
1396
|
+
return process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH;
|
|
1397
|
+
}
|
|
615
1398
|
const dir = getTargetSettingsDir(target);
|
|
616
1399
|
if (scope === "project") {
|
|
617
|
-
return
|
|
1400
|
+
return target === "codewith" ? join3(process.cwd(), dir, "config.toml") : join3(process.cwd(), dir, "settings.json");
|
|
618
1401
|
}
|
|
619
|
-
return
|
|
1402
|
+
return target === "codewith" ? join3(homedir3(), dir, "config.toml") : join3(homedir3(), dir, "settings.json");
|
|
620
1403
|
}
|
|
621
1404
|
function getHookPath(name) {
|
|
622
|
-
|
|
1405
|
+
const shortName = shortHookName(name);
|
|
1406
|
+
const direct = join3(HOOKS_DIR, shortName);
|
|
1407
|
+
if (existsSync3(direct))
|
|
1408
|
+
return direct;
|
|
1409
|
+
return join3(HOOKS_DIR, normalizeHookName(shortName));
|
|
623
1410
|
}
|
|
624
1411
|
function hookExists(name) {
|
|
625
|
-
return
|
|
1412
|
+
return existsSync3(getHookPath(name));
|
|
626
1413
|
}
|
|
627
1414
|
function readSettings(scope = "global", target = "claude") {
|
|
628
1415
|
const path = getSettingsPath(scope, target);
|
|
629
1416
|
try {
|
|
630
|
-
if (
|
|
631
|
-
return JSON.parse(
|
|
1417
|
+
if (existsSync3(path)) {
|
|
1418
|
+
return JSON.parse(readFileSync2(path, "utf-8"));
|
|
632
1419
|
}
|
|
633
1420
|
} catch (error) {
|
|
634
1421
|
console.warn(`[hooks] Failed to read settings at ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -638,25 +1425,125 @@ function readSettings(scope = "global", target = "claude") {
|
|
|
638
1425
|
function writeSettings(settings, scope = "global", target = "claude") {
|
|
639
1426
|
const path = getSettingsPath(scope, target);
|
|
640
1427
|
const dir = dirname(path);
|
|
641
|
-
if (!
|
|
642
|
-
|
|
1428
|
+
if (!existsSync3(dir)) {
|
|
1429
|
+
mkdirSync2(dir, { recursive: true });
|
|
643
1430
|
}
|
|
644
1431
|
writeFileSync(path, JSON.stringify(settings, null, 2) + `
|
|
645
1432
|
`);
|
|
646
1433
|
}
|
|
647
1434
|
function getTargetEventName(internalEvent, target) {
|
|
648
|
-
return EVENT_MAP[target]?.[internalEvent]
|
|
1435
|
+
return EVENT_MAP[target]?.[internalEvent] ?? null;
|
|
1436
|
+
}
|
|
1437
|
+
function isEventSupported(internalEvent, target) {
|
|
1438
|
+
return getTargetEventName(internalEvent, target) !== null;
|
|
1439
|
+
}
|
|
1440
|
+
function tomlString(value) {
|
|
1441
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
1442
|
+
}
|
|
1443
|
+
function codewithMatcher(matcher) {
|
|
1444
|
+
if (!matcher)
|
|
1445
|
+
return;
|
|
1446
|
+
if (matcher.startsWith("^"))
|
|
1447
|
+
return matcher;
|
|
1448
|
+
if (matcher.includes("|"))
|
|
1449
|
+
return `^(${matcher})$`;
|
|
1450
|
+
return `^${matcher}$`;
|
|
1451
|
+
}
|
|
1452
|
+
function codewithTimeout(name) {
|
|
1453
|
+
switch (name) {
|
|
1454
|
+
case "knowledge-context":
|
|
1455
|
+
return 2;
|
|
1456
|
+
case "session-start":
|
|
1457
|
+
return 8;
|
|
1458
|
+
case "pre-bash":
|
|
1459
|
+
return 20;
|
|
1460
|
+
case "prompt-guard":
|
|
1461
|
+
return 3;
|
|
1462
|
+
case "worktree-guard":
|
|
1463
|
+
case "stop-sync":
|
|
1464
|
+
return 5;
|
|
1465
|
+
default:
|
|
1466
|
+
return 10;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
function codewithStatusMessage(name) {
|
|
1470
|
+
switch (name) {
|
|
1471
|
+
case "knowledge-context":
|
|
1472
|
+
return "Loading Knowledge context";
|
|
1473
|
+
case "session-start":
|
|
1474
|
+
return "Checking Hasna session context";
|
|
1475
|
+
case "pre-bash":
|
|
1476
|
+
return "Checking Bash safety";
|
|
1477
|
+
case "prompt-guard":
|
|
1478
|
+
return "Checking prompt safety";
|
|
1479
|
+
case "worktree-guard":
|
|
1480
|
+
return "Checking worktree safety";
|
|
1481
|
+
case "stop-sync":
|
|
1482
|
+
return "Syncing turn-end heartbeat";
|
|
1483
|
+
default:
|
|
1484
|
+
return `Running ${name}`;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
function buildCodewithTomlFragment(name, profile) {
|
|
1488
|
+
const shortName = shortHookName(name);
|
|
1489
|
+
const meta = getHook(shortName);
|
|
1490
|
+
if (!meta)
|
|
1491
|
+
throw new Error(`Hook '${shortName}' not found`);
|
|
1492
|
+
const command = profile ? `hooks run ${shortName} --profile ${profile}` : `hooks run ${shortName}`;
|
|
1493
|
+
const matcher = codewithMatcher(meta.matcher);
|
|
1494
|
+
const fragments = [];
|
|
1495
|
+
for (const event of getHookEvents(meta)) {
|
|
1496
|
+
const eventKey = getTargetEventName(event, "codewith");
|
|
1497
|
+
if (!eventKey) {
|
|
1498
|
+
throw new Error(`Hook '${shortName}' uses event '${event}', which is not supported by the Codewith target`);
|
|
1499
|
+
}
|
|
1500
|
+
const lines = [
|
|
1501
|
+
`[[hooks.${eventKey}]]`
|
|
1502
|
+
];
|
|
1503
|
+
if (matcher)
|
|
1504
|
+
lines.push(`matcher = ${tomlString(matcher)}`);
|
|
1505
|
+
lines.push("", `[[hooks.${eventKey}.hooks]]`, `type = "command"`, `command = ${tomlString(command)}`, `timeout = ${codewithTimeout(shortName)}`, `statusMessage = ${tomlString(codewithStatusMessage(shortName))}`);
|
|
1506
|
+
fragments.push(lines.join(`
|
|
1507
|
+
`));
|
|
1508
|
+
}
|
|
1509
|
+
return `${fragments.join(`
|
|
1510
|
+
|
|
1511
|
+
`)}
|
|
1512
|
+
`;
|
|
1513
|
+
}
|
|
1514
|
+
function readCodewithConfig(scope, configPath) {
|
|
1515
|
+
const path = getSettingsPath(scope, "codewith", configPath);
|
|
1516
|
+
try {
|
|
1517
|
+
return existsSync3(path) ? readFileSync2(path, "utf-8") : "";
|
|
1518
|
+
} catch {
|
|
1519
|
+
return "";
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
function appendCodewithFragment(fragment, scope, configPath) {
|
|
1523
|
+
const path = getSettingsPath(scope, "codewith", configPath);
|
|
1524
|
+
const dir = dirname(path);
|
|
1525
|
+
if (!existsSync3(dir))
|
|
1526
|
+
mkdirSync2(dir, { recursive: true });
|
|
1527
|
+
const existing = existsSync3(path) ? readFileSync2(path, "utf-8") : "";
|
|
1528
|
+
const sep = existing.trim() ? `
|
|
1529
|
+
|
|
1530
|
+
` : "";
|
|
1531
|
+
writeFileSync(path, `${existing.replace(/\s*$/, "")}${sep}${fragment}`);
|
|
1532
|
+
return path;
|
|
649
1533
|
}
|
|
650
1534
|
function detectConflict(name, scope, target) {
|
|
651
1535
|
const meta = getHook(name);
|
|
652
1536
|
if (!meta || !meta.matcher)
|
|
653
1537
|
return;
|
|
1538
|
+
const events = new Set(getHookEvents(meta));
|
|
654
1539
|
const registered = getRegisteredHooksForTarget(scope, target);
|
|
655
1540
|
for (const existingName of registered) {
|
|
656
1541
|
if (existingName === name)
|
|
657
1542
|
continue;
|
|
658
1543
|
const existing = getHook(existingName);
|
|
659
|
-
if (!existing ||
|
|
1544
|
+
if (!existing || !existing.matcher)
|
|
1545
|
+
continue;
|
|
1546
|
+
if (!getHookEvents(existing).some((event) => events.has(event)))
|
|
660
1547
|
continue;
|
|
661
1548
|
const a = meta.matcher.toLowerCase();
|
|
662
1549
|
const b = existing.matcher.toLowerCase();
|
|
@@ -666,11 +1553,60 @@ function detectConflict(name, scope, target) {
|
|
|
666
1553
|
}
|
|
667
1554
|
return;
|
|
668
1555
|
}
|
|
669
|
-
function installForTarget(name, scope, overwrite, target, profile) {
|
|
1556
|
+
function installForTarget(name, scope, overwrite, target, profile, codewithMode = "fragment", codewithConfigPath) {
|
|
670
1557
|
const shortName = shortHookName(name);
|
|
671
1558
|
if (!hookExists(shortName)) {
|
|
672
1559
|
return { hook: shortName, success: false, error: `Hook '${shortName}' not found`, target };
|
|
673
1560
|
}
|
|
1561
|
+
if (target === "codewith") {
|
|
1562
|
+
try {
|
|
1563
|
+
const fragment = buildCodewithTomlFragment(shortName, profile);
|
|
1564
|
+
if (codewithMode !== "write") {
|
|
1565
|
+
return {
|
|
1566
|
+
hook: shortName,
|
|
1567
|
+
success: true,
|
|
1568
|
+
scope,
|
|
1569
|
+
target,
|
|
1570
|
+
fragment,
|
|
1571
|
+
applied: false,
|
|
1572
|
+
note: "Codewith install is fragment-only by default; open-configs should own applying this TOML."
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
if (!codewithConfigPath) {
|
|
1576
|
+
return {
|
|
1577
|
+
hook: shortName,
|
|
1578
|
+
success: false,
|
|
1579
|
+
error: "Direct Codewith writes require an explicit --codewith-config path; refusing to write default ~/.codewith/config.toml.",
|
|
1580
|
+
scope,
|
|
1581
|
+
target,
|
|
1582
|
+
fragment,
|
|
1583
|
+
applied: false
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
const existing = readCodewithConfig(scope, codewithConfigPath);
|
|
1587
|
+
if (!overwrite && new RegExp(`command\\s*=\\s*["']hooks run ${shortName}(?:\\s|["'])`).test(existing)) {
|
|
1588
|
+
return { hook: shortName, success: false, error: "Already installed. Use --overwrite to append another fragment.", scope, target };
|
|
1589
|
+
}
|
|
1590
|
+
const path = appendCodewithFragment(fragment, scope, codewithConfigPath);
|
|
1591
|
+
return {
|
|
1592
|
+
hook: shortName,
|
|
1593
|
+
success: true,
|
|
1594
|
+
scope,
|
|
1595
|
+
target,
|
|
1596
|
+
fragment,
|
|
1597
|
+
applied: true,
|
|
1598
|
+
configPath: path,
|
|
1599
|
+
note: "Direct Codewith config write was explicitly requested; prefer open-configs for managed machines."
|
|
1600
|
+
};
|
|
1601
|
+
} catch (error) {
|
|
1602
|
+
return {
|
|
1603
|
+
hook: shortName,
|
|
1604
|
+
success: false,
|
|
1605
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
1606
|
+
target
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
674
1610
|
const registered = getRegisteredHooksForTarget(scope, target);
|
|
675
1611
|
if (registered.includes(shortName) && !overwrite) {
|
|
676
1612
|
return { hook: shortName, success: false, error: "Already installed. Use --overwrite to replace.", scope, target };
|
|
@@ -689,35 +1625,92 @@ function installForTarget(name, scope, overwrite, target, profile) {
|
|
|
689
1625
|
}
|
|
690
1626
|
}
|
|
691
1627
|
function installHook(name, options = {}) {
|
|
692
|
-
const { scope = "global", overwrite = false, target = "claude", profile } = options;
|
|
1628
|
+
const { scope = "global", overwrite = false, target = "claude", profile, codewithMode = "fragment", codewithConfigPath } = options;
|
|
693
1629
|
if (target === "all") {
|
|
1630
|
+
const shortName = shortHookName(name);
|
|
1631
|
+
const meta = getHook(shortName);
|
|
1632
|
+
if (meta) {
|
|
1633
|
+
const unsupportedTargets = ["claude", "gemini", "codewith"].filter((agentTarget) => getHookEvents(meta).some((event) => !isEventSupported(event, agentTarget)));
|
|
1634
|
+
if (unsupportedTargets.length > 0) {
|
|
1635
|
+
return {
|
|
1636
|
+
hook: shortName,
|
|
1637
|
+
success: false,
|
|
1638
|
+
error: `Event(s) '${getHookEvents(meta).join(", ")}' are not supported by target(s): ${unsupportedTargets.join(", ")}`,
|
|
1639
|
+
target: "all"
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
694
1643
|
const claudeResult = installForTarget(name, scope, overwrite, "claude", profile);
|
|
695
|
-
|
|
1644
|
+
if (!claudeResult.success) {
|
|
1645
|
+
return {
|
|
1646
|
+
...claudeResult,
|
|
1647
|
+
error: `Failed for target 'claude': ${claudeResult.error}`,
|
|
1648
|
+
target: "all"
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
const geminiResult = installForTarget(name, scope, overwrite, "gemini", profile);
|
|
1652
|
+
if (!geminiResult.success) {
|
|
1653
|
+
return {
|
|
1654
|
+
...geminiResult,
|
|
1655
|
+
error: `Failed for target 'gemini': ${geminiResult.error}`,
|
|
1656
|
+
target: "all"
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
const codewithResult = installForTarget(name, scope, overwrite, "codewith", profile, codewithMode, codewithConfigPath);
|
|
1660
|
+
if (!codewithResult.success) {
|
|
1661
|
+
return {
|
|
1662
|
+
...codewithResult,
|
|
1663
|
+
error: `Failed for target 'codewith': ${codewithResult.error}`,
|
|
1664
|
+
target: "all"
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
696
1667
|
return { ...claudeResult, target: "all" };
|
|
697
1668
|
}
|
|
698
|
-
return installForTarget(name, scope, overwrite, target, profile);
|
|
1669
|
+
return installForTarget(name, scope, overwrite, target, profile, codewithMode, codewithConfigPath);
|
|
699
1670
|
}
|
|
700
1671
|
function registerHook(name, scope = "global", target = "claude", profile) {
|
|
701
1672
|
const meta = getHook(name);
|
|
702
1673
|
if (!meta)
|
|
703
1674
|
return;
|
|
1675
|
+
const eventKeys = getHookEvents(meta).map((event) => {
|
|
1676
|
+
const eventKey = getTargetEventName(event, target);
|
|
1677
|
+
if (eventKey === null) {
|
|
1678
|
+
throw new Error(`Event '${event}' is not supported by target '${target}'`);
|
|
1679
|
+
}
|
|
1680
|
+
return eventKey;
|
|
1681
|
+
});
|
|
1682
|
+
const uniqueEventKeys = [...new Set(eventKeys)];
|
|
1683
|
+
if (uniqueEventKeys.length === 0) {
|
|
1684
|
+
throw new Error(`Hook '${name}' has no installable events for target '${target}'`);
|
|
1685
|
+
}
|
|
704
1686
|
const settings = readSettings(scope, target);
|
|
705
1687
|
if (!settings.hooks)
|
|
706
1688
|
settings.hooks = {};
|
|
707
|
-
|
|
708
|
-
if (!settings.hooks[eventKey])
|
|
709
|
-
settings.hooks[eventKey] = [];
|
|
710
|
-
settings.hooks[eventKey] = removeHookEntriesByName(settings.hooks[eventKey], name);
|
|
1689
|
+
removeHookFromAllEvents(settings, name);
|
|
711
1690
|
const hookCommand = profile ? `hooks run ${name} --profile ${profile}` : `hooks run ${name}`;
|
|
712
|
-
const
|
|
713
|
-
hooks
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1691
|
+
for (const eventKey of uniqueEventKeys) {
|
|
1692
|
+
if (!settings.hooks[eventKey])
|
|
1693
|
+
settings.hooks[eventKey] = [];
|
|
1694
|
+
const entry = {
|
|
1695
|
+
hooks: [{ type: "command", command: hookCommand }]
|
|
1696
|
+
};
|
|
1697
|
+
if (meta.matcher) {
|
|
1698
|
+
entry.matcher = meta.matcher;
|
|
1699
|
+
}
|
|
1700
|
+
settings.hooks[eventKey].push(entry);
|
|
717
1701
|
}
|
|
718
|
-
settings.hooks[eventKey].push(entry);
|
|
719
1702
|
writeSettings(settings, scope, target);
|
|
720
1703
|
}
|
|
1704
|
+
function removeHookFromAllEvents(settings, name) {
|
|
1705
|
+
if (!settings.hooks)
|
|
1706
|
+
return;
|
|
1707
|
+
for (const key of Object.keys(settings.hooks)) {
|
|
1708
|
+
settings.hooks[key] = removeHookEntriesByName(settings.hooks[key], name);
|
|
1709
|
+
if (settings.hooks[key].length === 0) {
|
|
1710
|
+
delete settings.hooks[key];
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
721
1714
|
function unregisterHook(name, scope = "global", target = "claude") {
|
|
722
1715
|
const meta = getHook(name);
|
|
723
1716
|
if (!meta)
|
|
@@ -725,13 +1718,7 @@ function unregisterHook(name, scope = "global", target = "claude") {
|
|
|
725
1718
|
const settings = readSettings(scope, target);
|
|
726
1719
|
if (!settings.hooks)
|
|
727
1720
|
return;
|
|
728
|
-
|
|
729
|
-
if (!settings.hooks[eventKey])
|
|
730
|
-
return;
|
|
731
|
-
settings.hooks[eventKey] = removeHookEntriesByName(settings.hooks[eventKey], name);
|
|
732
|
-
if (settings.hooks[eventKey].length === 0) {
|
|
733
|
-
delete settings.hooks[eventKey];
|
|
734
|
-
}
|
|
1721
|
+
removeHookFromAllEvents(settings, name);
|
|
735
1722
|
if (Object.keys(settings.hooks).length === 0) {
|
|
736
1723
|
delete settings.hooks;
|
|
737
1724
|
}
|
|
@@ -741,6 +1728,16 @@ function installHooks(names, options = {}) {
|
|
|
741
1728
|
return names.map((name) => installHook(name, options));
|
|
742
1729
|
}
|
|
743
1730
|
function getRegisteredHooksForTarget(scope = "global", target = "claude") {
|
|
1731
|
+
if (target === "codewith") {
|
|
1732
|
+
const config = readCodewithConfig(scope);
|
|
1733
|
+
const registered2 = [];
|
|
1734
|
+
const re = /command\s*=\s*["']hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?["']/g;
|
|
1735
|
+
let match;
|
|
1736
|
+
while (match = re.exec(config)) {
|
|
1737
|
+
registered2.push(match[1]);
|
|
1738
|
+
}
|
|
1739
|
+
return [...new Set(registered2)];
|
|
1740
|
+
}
|
|
744
1741
|
const settings = readSettings(scope, target);
|
|
745
1742
|
if (!settings.hooks)
|
|
746
1743
|
return [];
|
|
@@ -748,8 +1745,8 @@ function getRegisteredHooksForTarget(scope = "global", target = "claude") {
|
|
|
748
1745
|
for (const eventKey of Object.keys(settings.hooks)) {
|
|
749
1746
|
for (const entry of settings.hooks[eventKey]) {
|
|
750
1747
|
for (const hook of entry.hooks || []) {
|
|
751
|
-
const newMatch = hook.command?.match(/^hooks run (\w+)(?:\s+--profile\s
|
|
752
|
-
const oldMatch = hook.command?.match(/^hook-(\w+)$/);
|
|
1748
|
+
const newMatch = hook.command?.match(/^hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?$/);
|
|
1749
|
+
const oldMatch = hook.command?.match(/^hook-([\w-]+)$/);
|
|
753
1750
|
const match = newMatch || oldMatch;
|
|
754
1751
|
if (match) {
|
|
755
1752
|
registered.push(match[1]);
|
|
@@ -770,6 +1767,9 @@ function removeHook(name, scope = "global", target = "claude") {
|
|
|
770
1767
|
const geminiRemoved = removeHookForTarget(shortName, scope, "gemini");
|
|
771
1768
|
return claudeRemoved || geminiRemoved;
|
|
772
1769
|
}
|
|
1770
|
+
if (target === "codewith") {
|
|
1771
|
+
return false;
|
|
1772
|
+
}
|
|
773
1773
|
return removeHookForTarget(shortName, scope, target);
|
|
774
1774
|
}
|
|
775
1775
|
function removeHookForTarget(name, scope, target) {
|
|
@@ -782,8 +1782,8 @@ function removeHookForTarget(name, scope, target) {
|
|
|
782
1782
|
}
|
|
783
1783
|
// src/index.ts
|
|
784
1784
|
init_profiles();
|
|
785
|
-
import { join as
|
|
786
|
-
import { existsSync as
|
|
1785
|
+
import { join as join5 } from "path";
|
|
1786
|
+
import { existsSync as existsSync5 } from "fs";
|
|
787
1787
|
function installHookForProject(name, options = {}) {
|
|
788
1788
|
return installHook(name, { ...options, scope: "project" });
|
|
789
1789
|
}
|
|
@@ -801,8 +1801,8 @@ async function runHook(name, input, options = {}) {
|
|
|
801
1801
|
if (!meta)
|
|
802
1802
|
throw new Error(`Hook '${name}' not found`);
|
|
803
1803
|
const hookDir = getHookPath(name);
|
|
804
|
-
const hookScript =
|
|
805
|
-
if (!
|
|
1804
|
+
const hookScript = join5(hookDir, "src", "hook.ts");
|
|
1805
|
+
if (!existsSync5(hookScript))
|
|
806
1806
|
throw new Error(`Hook script not found: ${hookScript}`);
|
|
807
1807
|
let hookInput = { ...input };
|
|
808
1808
|
if (options.profile) {
|
|
@@ -839,18 +1839,32 @@ async function runHook(name, input, options = {}) {
|
|
|
839
1839
|
export {
|
|
840
1840
|
updateProfile,
|
|
841
1841
|
touchProfile,
|
|
1842
|
+
storageSync,
|
|
1843
|
+
storagePush,
|
|
1844
|
+
storagePull,
|
|
842
1845
|
searchHooks,
|
|
1846
|
+
runStorageMigrations,
|
|
843
1847
|
runHook,
|
|
1848
|
+
resolveTables,
|
|
844
1849
|
removeProjectHook,
|
|
845
1850
|
removeHook,
|
|
1851
|
+
parseStorageTables,
|
|
846
1852
|
listProjectHooks,
|
|
847
1853
|
listProfiles,
|
|
1854
|
+
isEventSupported,
|
|
848
1855
|
installHooksForProject,
|
|
849
1856
|
installHooks,
|
|
850
1857
|
installHookForProject,
|
|
851
1858
|
installHook,
|
|
852
1859
|
importProfiles,
|
|
853
1860
|
hookExists,
|
|
1861
|
+
getSyncMetaAll,
|
|
1862
|
+
getStorageStatus,
|
|
1863
|
+
getStoragePg,
|
|
1864
|
+
getStorageMode,
|
|
1865
|
+
getStorageDatabaseUrl,
|
|
1866
|
+
getStorageDatabaseEnvName,
|
|
1867
|
+
getStorageDatabaseEnv,
|
|
854
1868
|
getSettingsPath,
|
|
855
1869
|
getRegisteredHooksForTarget,
|
|
856
1870
|
getRegisteredHooks,
|
|
@@ -859,10 +1873,21 @@ export {
|
|
|
859
1873
|
getInstalledHooks,
|
|
860
1874
|
getHooksByCategory,
|
|
861
1875
|
getHookPath,
|
|
1876
|
+
getHookEvents,
|
|
862
1877
|
getHook,
|
|
863
1878
|
exportProfiles,
|
|
864
1879
|
deleteProfile,
|
|
865
1880
|
createProfile,
|
|
1881
|
+
buildCodewithTomlFragment,
|
|
1882
|
+
STORAGE_TABLES,
|
|
1883
|
+
STORAGE_MODE_ENV,
|
|
1884
|
+
STORAGE_DATABASE_ENV,
|
|
1885
|
+
HOOK_EVENTS,
|
|
1886
|
+
HOOKS_STORAGE_TABLES,
|
|
1887
|
+
HOOKS_STORAGE_MODE_FALLBACK_ENV,
|
|
1888
|
+
HOOKS_STORAGE_MODE_ENV,
|
|
1889
|
+
HOOKS_STORAGE_FALLBACK_ENV,
|
|
1890
|
+
HOOKS_STORAGE_ENV,
|
|
866
1891
|
HOOKS,
|
|
867
1892
|
CATEGORIES
|
|
868
1893
|
};
|