@hasna/hooks 0.2.20 → 0.3.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/README.md +45 -2
- package/bin/index.js +1157 -84
- 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 +1042 -58
- package/dist/lib/installer.d.ts +25 -5
- package/dist/lib/registry.d.ts +3 -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/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,670 @@ 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
|
+
];
|
|
162
825
|
var CATEGORIES = [
|
|
163
826
|
"Git Safety",
|
|
164
827
|
"Code Quality",
|
|
@@ -202,6 +865,16 @@ var HOOKS = [
|
|
|
202
865
|
matcher: "Write|Edit|NotebookEdit",
|
|
203
866
|
tags: ["git", "snapshot", "rollback", "backup"]
|
|
204
867
|
},
|
|
868
|
+
{
|
|
869
|
+
name: "worktree-guard",
|
|
870
|
+
displayName: "Worktree Guard",
|
|
871
|
+
description: "Warns on feature work outside managed repos worktrees and blocks commit/push from shared checkouts",
|
|
872
|
+
version: "0.1.0",
|
|
873
|
+
category: "Git Safety",
|
|
874
|
+
event: "PreToolUse",
|
|
875
|
+
matcher: "Bash|Write|Edit|MultiEdit|NotebookEdit",
|
|
876
|
+
tags: ["git", "worktree", "repos", "multi-agent", "safety"]
|
|
877
|
+
},
|
|
205
878
|
{
|
|
206
879
|
name: "checktests",
|
|
207
880
|
displayName: "Check Tests",
|
|
@@ -282,6 +955,16 @@ var HOOKS = [
|
|
|
282
955
|
matcher: "Bash",
|
|
283
956
|
tags: ["npm", "packages", "typosquatting", "supply-chain"]
|
|
284
957
|
},
|
|
958
|
+
{
|
|
959
|
+
name: "pre-bash",
|
|
960
|
+
displayName: "Pre Bash",
|
|
961
|
+
description: "Codewith Bash gate for staged secrets scans before commit/push and comms rechecks before risky operations",
|
|
962
|
+
version: "0.1.0",
|
|
963
|
+
category: "Security",
|
|
964
|
+
event: "PreToolUse",
|
|
965
|
+
matcher: "Bash",
|
|
966
|
+
tags: ["codewith", "bash", "secrets", "gitleaks", "risky-ops"]
|
|
967
|
+
},
|
|
285
968
|
{
|
|
286
969
|
name: "phonenotify",
|
|
287
970
|
displayName: "Phone Notify",
|
|
@@ -392,6 +1075,16 @@ var HOOKS = [
|
|
|
392
1075
|
matcher: "",
|
|
393
1076
|
tags: ["prompt", "injection", "security", "validation", "guard"]
|
|
394
1077
|
},
|
|
1078
|
+
{
|
|
1079
|
+
name: "prompt-guard",
|
|
1080
|
+
displayName: "Prompt Guard (Codewith)",
|
|
1081
|
+
description: "Codewith UserPromptSubmit guard for obvious pasted prompt-injection and fake policy/freeze instructions",
|
|
1082
|
+
version: "0.1.0",
|
|
1083
|
+
category: "Permissions",
|
|
1084
|
+
event: "UserPromptSubmit",
|
|
1085
|
+
matcher: "",
|
|
1086
|
+
tags: ["codewith", "prompt", "injection", "policy", "freeze"]
|
|
1087
|
+
},
|
|
395
1088
|
{
|
|
396
1089
|
name: "desktopnotify",
|
|
397
1090
|
displayName: "Desktop Notify",
|
|
@@ -482,6 +1175,26 @@ var HOOKS = [
|
|
|
482
1175
|
matcher: "",
|
|
483
1176
|
tags: ["tasks", "completion", "gate", "quality", "agent-teams"]
|
|
484
1177
|
},
|
|
1178
|
+
{
|
|
1179
|
+
name: "session-start",
|
|
1180
|
+
displayName: "Session Start",
|
|
1181
|
+
description: "Codewith SessionStart digest for blockers, announcements, identity registration, and heartbeat context",
|
|
1182
|
+
version: "0.1.0",
|
|
1183
|
+
category: "Agent Teams",
|
|
1184
|
+
event: "SessionStart",
|
|
1185
|
+
matcher: "",
|
|
1186
|
+
tags: ["codewith", "session", "context", "conversations", "heartbeat"]
|
|
1187
|
+
},
|
|
1188
|
+
{
|
|
1189
|
+
name: "stop-sync",
|
|
1190
|
+
displayName: "Stop Sync",
|
|
1191
|
+
description: "Codewith Stop turn-end heartbeat and best-effort task evidence helper",
|
|
1192
|
+
version: "0.1.0",
|
|
1193
|
+
category: "Agent Teams",
|
|
1194
|
+
event: "Stop",
|
|
1195
|
+
matcher: "",
|
|
1196
|
+
tags: ["codewith", "stop", "heartbeat", "todos", "turn-end"]
|
|
1197
|
+
},
|
|
485
1198
|
{
|
|
486
1199
|
name: "typecheck-gate",
|
|
487
1200
|
displayName: "Typecheck Gate",
|
|
@@ -535,10 +1248,10 @@ var HOOKS = [
|
|
|
535
1248
|
{
|
|
536
1249
|
name: "announce-start",
|
|
537
1250
|
displayName: "Announce Start",
|
|
538
|
-
description: "On
|
|
539
|
-
version: "0.
|
|
1251
|
+
description: "On SessionStart, auto-registers agent, reads messages, and announces to space",
|
|
1252
|
+
version: "0.2.0",
|
|
540
1253
|
category: "Agent Teams",
|
|
541
|
-
event: "
|
|
1254
|
+
event: "SessionStart",
|
|
542
1255
|
matcher: "",
|
|
543
1256
|
tags: ["announcement", "start", "register", "messages", "agent-teams"]
|
|
544
1257
|
},
|
|
@@ -561,6 +1274,36 @@ var HOOKS = [
|
|
|
561
1274
|
event: "Notification",
|
|
562
1275
|
matcher: "",
|
|
563
1276
|
tags: ["messages", "dm", "context", "inject", "agent-teams"]
|
|
1277
|
+
},
|
|
1278
|
+
{
|
|
1279
|
+
name: "fleet-catchup",
|
|
1280
|
+
displayName: "Fleet Catchup",
|
|
1281
|
+
description: "On SessionStart, injects unread blockers, channel notifications, and the announcements digest into context (deterministic CLI reads, fail-open)",
|
|
1282
|
+
version: "0.1.0",
|
|
1283
|
+
category: "Agent Teams",
|
|
1284
|
+
event: "SessionStart",
|
|
1285
|
+
matcher: "",
|
|
1286
|
+
tags: ["fleet", "catchup", "blockers", "announcements", "context", "agent-teams"]
|
|
1287
|
+
},
|
|
1288
|
+
{
|
|
1289
|
+
name: "agent-rules-version-check",
|
|
1290
|
+
displayName: "Agent Rules Version Check",
|
|
1291
|
+
description: "On SessionStart, compares the rendered hasna:agent-operating-rules sentinel version against configs state and warns on mismatch",
|
|
1292
|
+
version: "0.1.0",
|
|
1293
|
+
category: "Agent Teams",
|
|
1294
|
+
event: "SessionStart",
|
|
1295
|
+
matcher: "",
|
|
1296
|
+
tags: ["fleet", "rules", "version", "sentinel", "configs", "drift", "agent-teams"]
|
|
1297
|
+
},
|
|
1298
|
+
{
|
|
1299
|
+
name: "fleet-blockers-gate",
|
|
1300
|
+
displayName: "Fleet Blockers Gate",
|
|
1301
|
+
description: "Before tool calls, blocks mutating tools while an unread [FREEZE] blocker is active (TTL-cached conversations check, fail-open <500ms)",
|
|
1302
|
+
version: "0.1.0",
|
|
1303
|
+
category: "Agent Teams",
|
|
1304
|
+
event: "PreToolUse",
|
|
1305
|
+
matcher: "",
|
|
1306
|
+
tags: ["fleet", "freeze", "blockers", "gate", "safety", "agent-teams"]
|
|
564
1307
|
}
|
|
565
1308
|
];
|
|
566
1309
|
function getHooksByCategory(category) {
|
|
@@ -574,21 +1317,21 @@ function getHook(name) {
|
|
|
574
1317
|
return HOOKS.find((h) => h.name === name);
|
|
575
1318
|
}
|
|
576
1319
|
// src/lib/installer.ts
|
|
577
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
578
|
-
import { join, dirname } from "path";
|
|
579
|
-
import { homedir } from "os";
|
|
1320
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
1321
|
+
import { join as join3, dirname } from "path";
|
|
1322
|
+
import { homedir as homedir3 } from "os";
|
|
580
1323
|
import { fileURLToPath } from "url";
|
|
581
1324
|
var __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
582
|
-
var HOOKS_DIR =
|
|
1325
|
+
var HOOKS_DIR = existsSync3(join3(__dirname2, "..", "..", "hooks", "hook-gitguard")) ? join3(__dirname2, "..", "..", "hooks") : join3(__dirname2, "..", "hooks");
|
|
583
1326
|
function normalizeHookName(name) {
|
|
584
1327
|
return name.startsWith("hook-") ? name : `hook-${name}`;
|
|
585
1328
|
}
|
|
586
1329
|
function shortHookName(name) {
|
|
587
|
-
return
|
|
1330
|
+
return name.startsWith("hook-") ? name.slice("hook-".length) : name;
|
|
588
1331
|
}
|
|
589
1332
|
function removeHookEntriesByName(entries, hookName) {
|
|
590
1333
|
return entries.filter((entry) => !entry.hooks?.some((h) => {
|
|
591
|
-
const match = h.command?.match(/^hooks run (\w+)/);
|
|
1334
|
+
const match = h.command?.match(/^hooks run ([\w-]+)/);
|
|
592
1335
|
return match && match[1] === hookName;
|
|
593
1336
|
}));
|
|
594
1337
|
}
|
|
@@ -597,38 +1340,64 @@ var EVENT_MAP = {
|
|
|
597
1340
|
PreToolUse: "PreToolUse",
|
|
598
1341
|
PostToolUse: "PostToolUse",
|
|
599
1342
|
Stop: "Stop",
|
|
600
|
-
Notification: "Notification"
|
|
1343
|
+
Notification: "Notification",
|
|
1344
|
+
SessionStart: "SessionStart",
|
|
1345
|
+
SessionEnd: "SessionEnd",
|
|
1346
|
+
UserPromptSubmit: null
|
|
601
1347
|
},
|
|
602
1348
|
gemini: {
|
|
603
1349
|
PreToolUse: "BeforeTool",
|
|
604
1350
|
PostToolUse: "AfterTool",
|
|
605
1351
|
Stop: "AfterAgent",
|
|
606
|
-
Notification: "Notification"
|
|
1352
|
+
Notification: "Notification",
|
|
1353
|
+
SessionStart: null,
|
|
1354
|
+
SessionEnd: null,
|
|
1355
|
+
UserPromptSubmit: null
|
|
1356
|
+
},
|
|
1357
|
+
codewith: {
|
|
1358
|
+
PreToolUse: "PreToolUse",
|
|
1359
|
+
PostToolUse: "PostToolUse",
|
|
1360
|
+
Stop: "Stop",
|
|
1361
|
+
Notification: null,
|
|
1362
|
+
SessionStart: "SessionStart",
|
|
1363
|
+
SessionEnd: null,
|
|
1364
|
+
UserPromptSubmit: "UserPromptSubmit"
|
|
607
1365
|
}
|
|
608
1366
|
};
|
|
609
1367
|
function getTargetSettingsDir(target) {
|
|
1368
|
+
if (target === "codewith")
|
|
1369
|
+
return ".codewith";
|
|
610
1370
|
if (target === "gemini")
|
|
611
1371
|
return ".gemini";
|
|
612
1372
|
return ".claude";
|
|
613
1373
|
}
|
|
614
|
-
function getSettingsPath(scope = "global", target = "claude") {
|
|
1374
|
+
function getSettingsPath(scope = "global", target = "claude", codewithConfigPath) {
|
|
1375
|
+
if (target === "codewith" && codewithConfigPath)
|
|
1376
|
+
return codewithConfigPath;
|
|
1377
|
+
if (target === "codewith" && process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH) {
|
|
1378
|
+
return process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH;
|
|
1379
|
+
}
|
|
615
1380
|
const dir = getTargetSettingsDir(target);
|
|
616
1381
|
if (scope === "project") {
|
|
617
|
-
return
|
|
1382
|
+
return target === "codewith" ? join3(process.cwd(), dir, "config.toml") : join3(process.cwd(), dir, "settings.json");
|
|
618
1383
|
}
|
|
619
|
-
return
|
|
1384
|
+
return target === "codewith" ? join3(homedir3(), dir, "config.toml") : join3(homedir3(), dir, "settings.json");
|
|
620
1385
|
}
|
|
621
1386
|
function getHookPath(name) {
|
|
622
|
-
|
|
1387
|
+
const shortName = shortHookName(name);
|
|
1388
|
+
const direct = join3(HOOKS_DIR, shortName);
|
|
1389
|
+
if (existsSync3(direct))
|
|
1390
|
+
return direct;
|
|
1391
|
+
return join3(HOOKS_DIR, normalizeHookName(shortName));
|
|
623
1392
|
}
|
|
624
1393
|
function hookExists(name) {
|
|
625
|
-
return
|
|
1394
|
+
return existsSync3(getHookPath(name));
|
|
626
1395
|
}
|
|
627
1396
|
function readSettings(scope = "global", target = "claude") {
|
|
628
1397
|
const path = getSettingsPath(scope, target);
|
|
629
1398
|
try {
|
|
630
|
-
if (
|
|
631
|
-
return JSON.parse(
|
|
1399
|
+
if (existsSync3(path)) {
|
|
1400
|
+
return JSON.parse(readFileSync2(path, "utf-8"));
|
|
632
1401
|
}
|
|
633
1402
|
} catch (error) {
|
|
634
1403
|
console.warn(`[hooks] Failed to read settings at ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -638,14 +1407,101 @@ function readSettings(scope = "global", target = "claude") {
|
|
|
638
1407
|
function writeSettings(settings, scope = "global", target = "claude") {
|
|
639
1408
|
const path = getSettingsPath(scope, target);
|
|
640
1409
|
const dir = dirname(path);
|
|
641
|
-
if (!
|
|
642
|
-
|
|
1410
|
+
if (!existsSync3(dir)) {
|
|
1411
|
+
mkdirSync2(dir, { recursive: true });
|
|
643
1412
|
}
|
|
644
1413
|
writeFileSync(path, JSON.stringify(settings, null, 2) + `
|
|
645
1414
|
`);
|
|
646
1415
|
}
|
|
647
1416
|
function getTargetEventName(internalEvent, target) {
|
|
648
|
-
return EVENT_MAP[target]?.[internalEvent]
|
|
1417
|
+
return EVENT_MAP[target]?.[internalEvent] ?? null;
|
|
1418
|
+
}
|
|
1419
|
+
function isEventSupported(internalEvent, target) {
|
|
1420
|
+
return getTargetEventName(internalEvent, target) !== null;
|
|
1421
|
+
}
|
|
1422
|
+
function tomlString(value) {
|
|
1423
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
1424
|
+
}
|
|
1425
|
+
function codewithMatcher(matcher) {
|
|
1426
|
+
if (!matcher)
|
|
1427
|
+
return;
|
|
1428
|
+
if (matcher.startsWith("^"))
|
|
1429
|
+
return matcher;
|
|
1430
|
+
if (matcher.includes("|"))
|
|
1431
|
+
return `^(${matcher})$`;
|
|
1432
|
+
return `^${matcher}$`;
|
|
1433
|
+
}
|
|
1434
|
+
function codewithTimeout(name) {
|
|
1435
|
+
switch (name) {
|
|
1436
|
+
case "session-start":
|
|
1437
|
+
return 8;
|
|
1438
|
+
case "pre-bash":
|
|
1439
|
+
return 20;
|
|
1440
|
+
case "prompt-guard":
|
|
1441
|
+
return 3;
|
|
1442
|
+
case "worktree-guard":
|
|
1443
|
+
case "stop-sync":
|
|
1444
|
+
return 5;
|
|
1445
|
+
default:
|
|
1446
|
+
return 10;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
function codewithStatusMessage(name) {
|
|
1450
|
+
switch (name) {
|
|
1451
|
+
case "session-start":
|
|
1452
|
+
return "Checking Hasna session context";
|
|
1453
|
+
case "pre-bash":
|
|
1454
|
+
return "Checking Bash safety";
|
|
1455
|
+
case "prompt-guard":
|
|
1456
|
+
return "Checking prompt safety";
|
|
1457
|
+
case "worktree-guard":
|
|
1458
|
+
return "Checking worktree safety";
|
|
1459
|
+
case "stop-sync":
|
|
1460
|
+
return "Syncing turn-end heartbeat";
|
|
1461
|
+
default:
|
|
1462
|
+
return `Running ${name}`;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
function buildCodewithTomlFragment(name, profile) {
|
|
1466
|
+
const shortName = shortHookName(name);
|
|
1467
|
+
const meta = getHook(shortName);
|
|
1468
|
+
if (!meta)
|
|
1469
|
+
throw new Error(`Hook '${shortName}' not found`);
|
|
1470
|
+
const eventKey = getTargetEventName(meta.event, "codewith");
|
|
1471
|
+
if (!eventKey) {
|
|
1472
|
+
throw new Error(`Hook '${shortName}' uses event '${meta.event}', which is not supported by the Codewith target`);
|
|
1473
|
+
}
|
|
1474
|
+
const command = profile ? `hooks run ${shortName} --profile ${profile}` : `hooks run ${shortName}`;
|
|
1475
|
+
const matcher = codewithMatcher(meta.matcher);
|
|
1476
|
+
const lines = [
|
|
1477
|
+
`[[hooks.${eventKey}]]`
|
|
1478
|
+
];
|
|
1479
|
+
if (matcher)
|
|
1480
|
+
lines.push(`matcher = ${tomlString(matcher)}`);
|
|
1481
|
+
lines.push("", `[[hooks.${eventKey}.hooks]]`, `type = "command"`, `command = ${tomlString(command)}`, `timeout = ${codewithTimeout(shortName)}`, `statusMessage = ${tomlString(codewithStatusMessage(shortName))}`);
|
|
1482
|
+
return `${lines.join(`
|
|
1483
|
+
`)}
|
|
1484
|
+
`;
|
|
1485
|
+
}
|
|
1486
|
+
function readCodewithConfig(scope, configPath) {
|
|
1487
|
+
const path = getSettingsPath(scope, "codewith", configPath);
|
|
1488
|
+
try {
|
|
1489
|
+
return existsSync3(path) ? readFileSync2(path, "utf-8") : "";
|
|
1490
|
+
} catch {
|
|
1491
|
+
return "";
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
function appendCodewithFragment(fragment, scope, configPath) {
|
|
1495
|
+
const path = getSettingsPath(scope, "codewith", configPath);
|
|
1496
|
+
const dir = dirname(path);
|
|
1497
|
+
if (!existsSync3(dir))
|
|
1498
|
+
mkdirSync2(dir, { recursive: true });
|
|
1499
|
+
const existing = existsSync3(path) ? readFileSync2(path, "utf-8") : "";
|
|
1500
|
+
const sep = existing.trim() ? `
|
|
1501
|
+
|
|
1502
|
+
` : "";
|
|
1503
|
+
writeFileSync(path, `${existing.replace(/\s*$/, "")}${sep}${fragment}`);
|
|
1504
|
+
return path;
|
|
649
1505
|
}
|
|
650
1506
|
function detectConflict(name, scope, target) {
|
|
651
1507
|
const meta = getHook(name);
|
|
@@ -666,11 +1522,60 @@ function detectConflict(name, scope, target) {
|
|
|
666
1522
|
}
|
|
667
1523
|
return;
|
|
668
1524
|
}
|
|
669
|
-
function installForTarget(name, scope, overwrite, target, profile) {
|
|
1525
|
+
function installForTarget(name, scope, overwrite, target, profile, codewithMode = "fragment", codewithConfigPath) {
|
|
670
1526
|
const shortName = shortHookName(name);
|
|
671
1527
|
if (!hookExists(shortName)) {
|
|
672
1528
|
return { hook: shortName, success: false, error: `Hook '${shortName}' not found`, target };
|
|
673
1529
|
}
|
|
1530
|
+
if (target === "codewith") {
|
|
1531
|
+
try {
|
|
1532
|
+
const fragment = buildCodewithTomlFragment(shortName, profile);
|
|
1533
|
+
if (codewithMode !== "write") {
|
|
1534
|
+
return {
|
|
1535
|
+
hook: shortName,
|
|
1536
|
+
success: true,
|
|
1537
|
+
scope,
|
|
1538
|
+
target,
|
|
1539
|
+
fragment,
|
|
1540
|
+
applied: false,
|
|
1541
|
+
note: "Codewith install is fragment-only by default; open-configs should own applying this TOML."
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
if (!codewithConfigPath) {
|
|
1545
|
+
return {
|
|
1546
|
+
hook: shortName,
|
|
1547
|
+
success: false,
|
|
1548
|
+
error: "Direct Codewith writes require an explicit --codewith-config path; refusing to write default ~/.codewith/config.toml.",
|
|
1549
|
+
scope,
|
|
1550
|
+
target,
|
|
1551
|
+
fragment,
|
|
1552
|
+
applied: false
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
const existing = readCodewithConfig(scope, codewithConfigPath);
|
|
1556
|
+
if (!overwrite && new RegExp(`command\\s*=\\s*["']hooks run ${shortName}(?:\\s|["'])`).test(existing)) {
|
|
1557
|
+
return { hook: shortName, success: false, error: "Already installed. Use --overwrite to append another fragment.", scope, target };
|
|
1558
|
+
}
|
|
1559
|
+
const path = appendCodewithFragment(fragment, scope, codewithConfigPath);
|
|
1560
|
+
return {
|
|
1561
|
+
hook: shortName,
|
|
1562
|
+
success: true,
|
|
1563
|
+
scope,
|
|
1564
|
+
target,
|
|
1565
|
+
fragment,
|
|
1566
|
+
applied: true,
|
|
1567
|
+
configPath: path,
|
|
1568
|
+
note: "Direct Codewith config write was explicitly requested; prefer open-configs for managed machines."
|
|
1569
|
+
};
|
|
1570
|
+
} catch (error) {
|
|
1571
|
+
return {
|
|
1572
|
+
hook: shortName,
|
|
1573
|
+
success: false,
|
|
1574
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
1575
|
+
target
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
674
1579
|
const registered = getRegisteredHooksForTarget(scope, target);
|
|
675
1580
|
if (registered.includes(shortName) && !overwrite) {
|
|
676
1581
|
return { hook: shortName, success: false, error: "Already installed. Use --overwrite to replace.", scope, target };
|
|
@@ -689,25 +1594,63 @@ function installForTarget(name, scope, overwrite, target, profile) {
|
|
|
689
1594
|
}
|
|
690
1595
|
}
|
|
691
1596
|
function installHook(name, options = {}) {
|
|
692
|
-
const { scope = "global", overwrite = false, target = "claude", profile } = options;
|
|
1597
|
+
const { scope = "global", overwrite = false, target = "claude", profile, codewithMode = "fragment", codewithConfigPath } = options;
|
|
693
1598
|
if (target === "all") {
|
|
1599
|
+
const shortName = shortHookName(name);
|
|
1600
|
+
const meta = getHook(shortName);
|
|
1601
|
+
if (meta) {
|
|
1602
|
+
const unsupportedTargets = ["claude", "gemini", "codewith"].filter((agentTarget) => !isEventSupported(meta.event, agentTarget));
|
|
1603
|
+
if (unsupportedTargets.length > 0) {
|
|
1604
|
+
return {
|
|
1605
|
+
hook: shortName,
|
|
1606
|
+
success: false,
|
|
1607
|
+
error: `Event '${meta.event}' is not supported by target(s): ${unsupportedTargets.join(", ")}`,
|
|
1608
|
+
target: "all"
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
694
1612
|
const claudeResult = installForTarget(name, scope, overwrite, "claude", profile);
|
|
695
|
-
|
|
1613
|
+
if (!claudeResult.success) {
|
|
1614
|
+
return {
|
|
1615
|
+
...claudeResult,
|
|
1616
|
+
error: `Failed for target 'claude': ${claudeResult.error}`,
|
|
1617
|
+
target: "all"
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
const geminiResult = installForTarget(name, scope, overwrite, "gemini", profile);
|
|
1621
|
+
if (!geminiResult.success) {
|
|
1622
|
+
return {
|
|
1623
|
+
...geminiResult,
|
|
1624
|
+
error: `Failed for target 'gemini': ${geminiResult.error}`,
|
|
1625
|
+
target: "all"
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
const codewithResult = installForTarget(name, scope, overwrite, "codewith", profile, codewithMode, codewithConfigPath);
|
|
1629
|
+
if (!codewithResult.success) {
|
|
1630
|
+
return {
|
|
1631
|
+
...codewithResult,
|
|
1632
|
+
error: `Failed for target 'codewith': ${codewithResult.error}`,
|
|
1633
|
+
target: "all"
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
696
1636
|
return { ...claudeResult, target: "all" };
|
|
697
1637
|
}
|
|
698
|
-
return installForTarget(name, scope, overwrite, target, profile);
|
|
1638
|
+
return installForTarget(name, scope, overwrite, target, profile, codewithMode, codewithConfigPath);
|
|
699
1639
|
}
|
|
700
1640
|
function registerHook(name, scope = "global", target = "claude", profile) {
|
|
701
1641
|
const meta = getHook(name);
|
|
702
1642
|
if (!meta)
|
|
703
1643
|
return;
|
|
1644
|
+
const eventKey = getTargetEventName(meta.event, target);
|
|
1645
|
+
if (eventKey === null) {
|
|
1646
|
+
throw new Error(`Event '${meta.event}' is not supported by target '${target}'`);
|
|
1647
|
+
}
|
|
704
1648
|
const settings = readSettings(scope, target);
|
|
705
1649
|
if (!settings.hooks)
|
|
706
1650
|
settings.hooks = {};
|
|
707
|
-
|
|
1651
|
+
removeHookFromAllEvents(settings, name);
|
|
708
1652
|
if (!settings.hooks[eventKey])
|
|
709
1653
|
settings.hooks[eventKey] = [];
|
|
710
|
-
settings.hooks[eventKey] = removeHookEntriesByName(settings.hooks[eventKey], name);
|
|
711
1654
|
const hookCommand = profile ? `hooks run ${name} --profile ${profile}` : `hooks run ${name}`;
|
|
712
1655
|
const entry = {
|
|
713
1656
|
hooks: [{ type: "command", command: hookCommand }]
|
|
@@ -718,6 +1661,16 @@ function registerHook(name, scope = "global", target = "claude", profile) {
|
|
|
718
1661
|
settings.hooks[eventKey].push(entry);
|
|
719
1662
|
writeSettings(settings, scope, target);
|
|
720
1663
|
}
|
|
1664
|
+
function removeHookFromAllEvents(settings, name) {
|
|
1665
|
+
if (!settings.hooks)
|
|
1666
|
+
return;
|
|
1667
|
+
for (const key of Object.keys(settings.hooks)) {
|
|
1668
|
+
settings.hooks[key] = removeHookEntriesByName(settings.hooks[key], name);
|
|
1669
|
+
if (settings.hooks[key].length === 0) {
|
|
1670
|
+
delete settings.hooks[key];
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
721
1674
|
function unregisterHook(name, scope = "global", target = "claude") {
|
|
722
1675
|
const meta = getHook(name);
|
|
723
1676
|
if (!meta)
|
|
@@ -725,13 +1678,7 @@ function unregisterHook(name, scope = "global", target = "claude") {
|
|
|
725
1678
|
const settings = readSettings(scope, target);
|
|
726
1679
|
if (!settings.hooks)
|
|
727
1680
|
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
|
-
}
|
|
1681
|
+
removeHookFromAllEvents(settings, name);
|
|
735
1682
|
if (Object.keys(settings.hooks).length === 0) {
|
|
736
1683
|
delete settings.hooks;
|
|
737
1684
|
}
|
|
@@ -741,6 +1688,16 @@ function installHooks(names, options = {}) {
|
|
|
741
1688
|
return names.map((name) => installHook(name, options));
|
|
742
1689
|
}
|
|
743
1690
|
function getRegisteredHooksForTarget(scope = "global", target = "claude") {
|
|
1691
|
+
if (target === "codewith") {
|
|
1692
|
+
const config = readCodewithConfig(scope);
|
|
1693
|
+
const registered2 = [];
|
|
1694
|
+
const re = /command\s*=\s*["']hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?["']/g;
|
|
1695
|
+
let match;
|
|
1696
|
+
while (match = re.exec(config)) {
|
|
1697
|
+
registered2.push(match[1]);
|
|
1698
|
+
}
|
|
1699
|
+
return [...new Set(registered2)];
|
|
1700
|
+
}
|
|
744
1701
|
const settings = readSettings(scope, target);
|
|
745
1702
|
if (!settings.hooks)
|
|
746
1703
|
return [];
|
|
@@ -748,8 +1705,8 @@ function getRegisteredHooksForTarget(scope = "global", target = "claude") {
|
|
|
748
1705
|
for (const eventKey of Object.keys(settings.hooks)) {
|
|
749
1706
|
for (const entry of settings.hooks[eventKey]) {
|
|
750
1707
|
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+)$/);
|
|
1708
|
+
const newMatch = hook.command?.match(/^hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?$/);
|
|
1709
|
+
const oldMatch = hook.command?.match(/^hook-([\w-]+)$/);
|
|
753
1710
|
const match = newMatch || oldMatch;
|
|
754
1711
|
if (match) {
|
|
755
1712
|
registered.push(match[1]);
|
|
@@ -770,6 +1727,9 @@ function removeHook(name, scope = "global", target = "claude") {
|
|
|
770
1727
|
const geminiRemoved = removeHookForTarget(shortName, scope, "gemini");
|
|
771
1728
|
return claudeRemoved || geminiRemoved;
|
|
772
1729
|
}
|
|
1730
|
+
if (target === "codewith") {
|
|
1731
|
+
return false;
|
|
1732
|
+
}
|
|
773
1733
|
return removeHookForTarget(shortName, scope, target);
|
|
774
1734
|
}
|
|
775
1735
|
function removeHookForTarget(name, scope, target) {
|
|
@@ -782,8 +1742,8 @@ function removeHookForTarget(name, scope, target) {
|
|
|
782
1742
|
}
|
|
783
1743
|
// src/index.ts
|
|
784
1744
|
init_profiles();
|
|
785
|
-
import { join as
|
|
786
|
-
import { existsSync as
|
|
1745
|
+
import { join as join5 } from "path";
|
|
1746
|
+
import { existsSync as existsSync5 } from "fs";
|
|
787
1747
|
function installHookForProject(name, options = {}) {
|
|
788
1748
|
return installHook(name, { ...options, scope: "project" });
|
|
789
1749
|
}
|
|
@@ -801,8 +1761,8 @@ async function runHook(name, input, options = {}) {
|
|
|
801
1761
|
if (!meta)
|
|
802
1762
|
throw new Error(`Hook '${name}' not found`);
|
|
803
1763
|
const hookDir = getHookPath(name);
|
|
804
|
-
const hookScript =
|
|
805
|
-
if (!
|
|
1764
|
+
const hookScript = join5(hookDir, "src", "hook.ts");
|
|
1765
|
+
if (!existsSync5(hookScript))
|
|
806
1766
|
throw new Error(`Hook script not found: ${hookScript}`);
|
|
807
1767
|
let hookInput = { ...input };
|
|
808
1768
|
if (options.profile) {
|
|
@@ -839,18 +1799,32 @@ async function runHook(name, input, options = {}) {
|
|
|
839
1799
|
export {
|
|
840
1800
|
updateProfile,
|
|
841
1801
|
touchProfile,
|
|
1802
|
+
storageSync,
|
|
1803
|
+
storagePush,
|
|
1804
|
+
storagePull,
|
|
842
1805
|
searchHooks,
|
|
1806
|
+
runStorageMigrations,
|
|
843
1807
|
runHook,
|
|
1808
|
+
resolveTables,
|
|
844
1809
|
removeProjectHook,
|
|
845
1810
|
removeHook,
|
|
1811
|
+
parseStorageTables,
|
|
846
1812
|
listProjectHooks,
|
|
847
1813
|
listProfiles,
|
|
1814
|
+
isEventSupported,
|
|
848
1815
|
installHooksForProject,
|
|
849
1816
|
installHooks,
|
|
850
1817
|
installHookForProject,
|
|
851
1818
|
installHook,
|
|
852
1819
|
importProfiles,
|
|
853
1820
|
hookExists,
|
|
1821
|
+
getSyncMetaAll,
|
|
1822
|
+
getStorageStatus,
|
|
1823
|
+
getStoragePg,
|
|
1824
|
+
getStorageMode,
|
|
1825
|
+
getStorageDatabaseUrl,
|
|
1826
|
+
getStorageDatabaseEnvName,
|
|
1827
|
+
getStorageDatabaseEnv,
|
|
854
1828
|
getSettingsPath,
|
|
855
1829
|
getRegisteredHooksForTarget,
|
|
856
1830
|
getRegisteredHooks,
|
|
@@ -863,6 +1837,16 @@ export {
|
|
|
863
1837
|
exportProfiles,
|
|
864
1838
|
deleteProfile,
|
|
865
1839
|
createProfile,
|
|
1840
|
+
buildCodewithTomlFragment,
|
|
1841
|
+
STORAGE_TABLES,
|
|
1842
|
+
STORAGE_MODE_ENV,
|
|
1843
|
+
STORAGE_DATABASE_ENV,
|
|
1844
|
+
HOOK_EVENTS,
|
|
1845
|
+
HOOKS_STORAGE_TABLES,
|
|
1846
|
+
HOOKS_STORAGE_MODE_FALLBACK_ENV,
|
|
1847
|
+
HOOKS_STORAGE_MODE_ENV,
|
|
1848
|
+
HOOKS_STORAGE_FALLBACK_ENV,
|
|
1849
|
+
HOOKS_STORAGE_ENV,
|
|
866
1850
|
HOOKS,
|
|
867
1851
|
CATEGORIES
|
|
868
1852
|
};
|