@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/bin/index.js
CHANGED
|
@@ -861,7 +861,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
|
|
|
861
861
|
this._exitCallback = (err) => {
|
|
862
862
|
if (err.code !== "commander.executeSubCommandAsync") {
|
|
863
863
|
throw err;
|
|
864
|
-
}
|
|
864
|
+
} else {}
|
|
865
865
|
};
|
|
866
866
|
}
|
|
867
867
|
return this;
|
|
@@ -1942,6 +1942,16 @@ var init_registry = __esm(() => {
|
|
|
1942
1942
|
matcher: "Write|Edit|NotebookEdit",
|
|
1943
1943
|
tags: ["git", "snapshot", "rollback", "backup"]
|
|
1944
1944
|
},
|
|
1945
|
+
{
|
|
1946
|
+
name: "worktree-guard",
|
|
1947
|
+
displayName: "Worktree Guard",
|
|
1948
|
+
description: "Warns on feature work outside managed repos worktrees and blocks commit/push from shared checkouts",
|
|
1949
|
+
version: "0.1.0",
|
|
1950
|
+
category: "Git Safety",
|
|
1951
|
+
event: "PreToolUse",
|
|
1952
|
+
matcher: "Bash|Write|Edit|MultiEdit|NotebookEdit",
|
|
1953
|
+
tags: ["git", "worktree", "repos", "multi-agent", "safety"]
|
|
1954
|
+
},
|
|
1945
1955
|
{
|
|
1946
1956
|
name: "checktests",
|
|
1947
1957
|
displayName: "Check Tests",
|
|
@@ -2022,6 +2032,16 @@ var init_registry = __esm(() => {
|
|
|
2022
2032
|
matcher: "Bash",
|
|
2023
2033
|
tags: ["npm", "packages", "typosquatting", "supply-chain"]
|
|
2024
2034
|
},
|
|
2035
|
+
{
|
|
2036
|
+
name: "pre-bash",
|
|
2037
|
+
displayName: "Pre Bash",
|
|
2038
|
+
description: "Codewith Bash gate for staged secrets scans before commit/push and comms rechecks before risky operations",
|
|
2039
|
+
version: "0.1.0",
|
|
2040
|
+
category: "Security",
|
|
2041
|
+
event: "PreToolUse",
|
|
2042
|
+
matcher: "Bash",
|
|
2043
|
+
tags: ["codewith", "bash", "secrets", "gitleaks", "risky-ops"]
|
|
2044
|
+
},
|
|
2025
2045
|
{
|
|
2026
2046
|
name: "phonenotify",
|
|
2027
2047
|
displayName: "Phone Notify",
|
|
@@ -2132,6 +2152,16 @@ var init_registry = __esm(() => {
|
|
|
2132
2152
|
matcher: "",
|
|
2133
2153
|
tags: ["prompt", "injection", "security", "validation", "guard"]
|
|
2134
2154
|
},
|
|
2155
|
+
{
|
|
2156
|
+
name: "prompt-guard",
|
|
2157
|
+
displayName: "Prompt Guard (Codewith)",
|
|
2158
|
+
description: "Codewith UserPromptSubmit guard for obvious pasted prompt-injection and fake policy/freeze instructions",
|
|
2159
|
+
version: "0.1.0",
|
|
2160
|
+
category: "Permissions",
|
|
2161
|
+
event: "UserPromptSubmit",
|
|
2162
|
+
matcher: "",
|
|
2163
|
+
tags: ["codewith", "prompt", "injection", "policy", "freeze"]
|
|
2164
|
+
},
|
|
2135
2165
|
{
|
|
2136
2166
|
name: "desktopnotify",
|
|
2137
2167
|
displayName: "Desktop Notify",
|
|
@@ -2222,6 +2252,26 @@ var init_registry = __esm(() => {
|
|
|
2222
2252
|
matcher: "",
|
|
2223
2253
|
tags: ["tasks", "completion", "gate", "quality", "agent-teams"]
|
|
2224
2254
|
},
|
|
2255
|
+
{
|
|
2256
|
+
name: "session-start",
|
|
2257
|
+
displayName: "Session Start",
|
|
2258
|
+
description: "Codewith SessionStart digest for blockers, announcements, identity registration, and heartbeat context",
|
|
2259
|
+
version: "0.1.0",
|
|
2260
|
+
category: "Agent Teams",
|
|
2261
|
+
event: "SessionStart",
|
|
2262
|
+
matcher: "",
|
|
2263
|
+
tags: ["codewith", "session", "context", "conversations", "heartbeat"]
|
|
2264
|
+
},
|
|
2265
|
+
{
|
|
2266
|
+
name: "stop-sync",
|
|
2267
|
+
displayName: "Stop Sync",
|
|
2268
|
+
description: "Codewith Stop turn-end heartbeat and best-effort task evidence helper",
|
|
2269
|
+
version: "0.1.0",
|
|
2270
|
+
category: "Agent Teams",
|
|
2271
|
+
event: "Stop",
|
|
2272
|
+
matcher: "",
|
|
2273
|
+
tags: ["codewith", "stop", "heartbeat", "todos", "turn-end"]
|
|
2274
|
+
},
|
|
2225
2275
|
{
|
|
2226
2276
|
name: "typecheck-gate",
|
|
2227
2277
|
displayName: "Typecheck Gate",
|
|
@@ -2275,10 +2325,10 @@ var init_registry = __esm(() => {
|
|
|
2275
2325
|
{
|
|
2276
2326
|
name: "announce-start",
|
|
2277
2327
|
displayName: "Announce Start",
|
|
2278
|
-
description: "On
|
|
2279
|
-
version: "0.
|
|
2328
|
+
description: "On SessionStart, auto-registers agent, reads messages, and announces to space",
|
|
2329
|
+
version: "0.2.0",
|
|
2280
2330
|
category: "Agent Teams",
|
|
2281
|
-
event: "
|
|
2331
|
+
event: "SessionStart",
|
|
2282
2332
|
matcher: "",
|
|
2283
2333
|
tags: ["announcement", "start", "register", "messages", "agent-teams"]
|
|
2284
2334
|
},
|
|
@@ -2301,6 +2351,36 @@ var init_registry = __esm(() => {
|
|
|
2301
2351
|
event: "Notification",
|
|
2302
2352
|
matcher: "",
|
|
2303
2353
|
tags: ["messages", "dm", "context", "inject", "agent-teams"]
|
|
2354
|
+
},
|
|
2355
|
+
{
|
|
2356
|
+
name: "fleet-catchup",
|
|
2357
|
+
displayName: "Fleet Catchup",
|
|
2358
|
+
description: "On SessionStart, injects unread blockers, channel notifications, and the announcements digest into context (deterministic CLI reads, fail-open)",
|
|
2359
|
+
version: "0.1.0",
|
|
2360
|
+
category: "Agent Teams",
|
|
2361
|
+
event: "SessionStart",
|
|
2362
|
+
matcher: "",
|
|
2363
|
+
tags: ["fleet", "catchup", "blockers", "announcements", "context", "agent-teams"]
|
|
2364
|
+
},
|
|
2365
|
+
{
|
|
2366
|
+
name: "agent-rules-version-check",
|
|
2367
|
+
displayName: "Agent Rules Version Check",
|
|
2368
|
+
description: "On SessionStart, compares the rendered hasna:agent-operating-rules sentinel version against configs state and warns on mismatch",
|
|
2369
|
+
version: "0.1.0",
|
|
2370
|
+
category: "Agent Teams",
|
|
2371
|
+
event: "SessionStart",
|
|
2372
|
+
matcher: "",
|
|
2373
|
+
tags: ["fleet", "rules", "version", "sentinel", "configs", "drift", "agent-teams"]
|
|
2374
|
+
},
|
|
2375
|
+
{
|
|
2376
|
+
name: "fleet-blockers-gate",
|
|
2377
|
+
displayName: "Fleet Blockers Gate",
|
|
2378
|
+
description: "Before tool calls, blocks mutating tools while an unread [FREEZE] blocker is active (TTL-cached conversations check, fail-open <500ms)",
|
|
2379
|
+
version: "0.1.0",
|
|
2380
|
+
category: "Agent Teams",
|
|
2381
|
+
event: "PreToolUse",
|
|
2382
|
+
matcher: "",
|
|
2383
|
+
tags: ["fleet", "freeze", "blockers", "gate", "safety", "agent-teams"]
|
|
2304
2384
|
}
|
|
2305
2385
|
];
|
|
2306
2386
|
});
|
|
@@ -3954,28 +4034,39 @@ function normalizeHookName(name) {
|
|
|
3954
4034
|
return name.startsWith("hook-") ? name : `hook-${name}`;
|
|
3955
4035
|
}
|
|
3956
4036
|
function shortHookName(name) {
|
|
3957
|
-
return
|
|
4037
|
+
return name.startsWith("hook-") ? name.slice("hook-".length) : name;
|
|
3958
4038
|
}
|
|
3959
4039
|
function removeHookEntriesByName(entries, hookName) {
|
|
3960
4040
|
return entries.filter((entry) => !entry.hooks?.some((h) => {
|
|
3961
|
-
const match = h.command?.match(/^hooks run (\w+)/);
|
|
4041
|
+
const match = h.command?.match(/^hooks run ([\w-]+)/);
|
|
3962
4042
|
return match && match[1] === hookName;
|
|
3963
4043
|
}));
|
|
3964
4044
|
}
|
|
3965
4045
|
function getTargetSettingsDir(target) {
|
|
4046
|
+
if (target === "codewith")
|
|
4047
|
+
return ".codewith";
|
|
3966
4048
|
if (target === "gemini")
|
|
3967
4049
|
return ".gemini";
|
|
3968
4050
|
return ".claude";
|
|
3969
4051
|
}
|
|
3970
|
-
function getSettingsPath(scope = "global", target = "claude") {
|
|
4052
|
+
function getSettingsPath(scope = "global", target = "claude", codewithConfigPath) {
|
|
4053
|
+
if (target === "codewith" && codewithConfigPath)
|
|
4054
|
+
return codewithConfigPath;
|
|
4055
|
+
if (target === "codewith" && process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH) {
|
|
4056
|
+
return process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH;
|
|
4057
|
+
}
|
|
3971
4058
|
const dir = getTargetSettingsDir(target);
|
|
3972
4059
|
if (scope === "project") {
|
|
3973
|
-
return join2(process.cwd(), dir, "settings.json");
|
|
4060
|
+
return target === "codewith" ? join2(process.cwd(), dir, "config.toml") : join2(process.cwd(), dir, "settings.json");
|
|
3974
4061
|
}
|
|
3975
|
-
return join2(homedir2(), dir, "settings.json");
|
|
4062
|
+
return target === "codewith" ? join2(homedir2(), dir, "config.toml") : join2(homedir2(), dir, "settings.json");
|
|
3976
4063
|
}
|
|
3977
4064
|
function getHookPath(name) {
|
|
3978
|
-
|
|
4065
|
+
const shortName = shortHookName(name);
|
|
4066
|
+
const direct = join2(HOOKS_DIR, shortName);
|
|
4067
|
+
if (existsSync2(direct))
|
|
4068
|
+
return direct;
|
|
4069
|
+
return join2(HOOKS_DIR, normalizeHookName(shortName));
|
|
3979
4070
|
}
|
|
3980
4071
|
function hookExists(name) {
|
|
3981
4072
|
return existsSync2(getHookPath(name));
|
|
@@ -4001,7 +4092,94 @@ function writeSettings(settings, scope = "global", target = "claude") {
|
|
|
4001
4092
|
`);
|
|
4002
4093
|
}
|
|
4003
4094
|
function getTargetEventName(internalEvent, target) {
|
|
4004
|
-
return EVENT_MAP[target]?.[internalEvent]
|
|
4095
|
+
return EVENT_MAP[target]?.[internalEvent] ?? null;
|
|
4096
|
+
}
|
|
4097
|
+
function isEventSupported(internalEvent, target) {
|
|
4098
|
+
return getTargetEventName(internalEvent, target) !== null;
|
|
4099
|
+
}
|
|
4100
|
+
function tomlString(value) {
|
|
4101
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
4102
|
+
}
|
|
4103
|
+
function codewithMatcher(matcher) {
|
|
4104
|
+
if (!matcher)
|
|
4105
|
+
return;
|
|
4106
|
+
if (matcher.startsWith("^"))
|
|
4107
|
+
return matcher;
|
|
4108
|
+
if (matcher.includes("|"))
|
|
4109
|
+
return `^(${matcher})$`;
|
|
4110
|
+
return `^${matcher}$`;
|
|
4111
|
+
}
|
|
4112
|
+
function codewithTimeout(name) {
|
|
4113
|
+
switch (name) {
|
|
4114
|
+
case "session-start":
|
|
4115
|
+
return 8;
|
|
4116
|
+
case "pre-bash":
|
|
4117
|
+
return 20;
|
|
4118
|
+
case "prompt-guard":
|
|
4119
|
+
return 3;
|
|
4120
|
+
case "worktree-guard":
|
|
4121
|
+
case "stop-sync":
|
|
4122
|
+
return 5;
|
|
4123
|
+
default:
|
|
4124
|
+
return 10;
|
|
4125
|
+
}
|
|
4126
|
+
}
|
|
4127
|
+
function codewithStatusMessage(name) {
|
|
4128
|
+
switch (name) {
|
|
4129
|
+
case "session-start":
|
|
4130
|
+
return "Checking Hasna session context";
|
|
4131
|
+
case "pre-bash":
|
|
4132
|
+
return "Checking Bash safety";
|
|
4133
|
+
case "prompt-guard":
|
|
4134
|
+
return "Checking prompt safety";
|
|
4135
|
+
case "worktree-guard":
|
|
4136
|
+
return "Checking worktree safety";
|
|
4137
|
+
case "stop-sync":
|
|
4138
|
+
return "Syncing turn-end heartbeat";
|
|
4139
|
+
default:
|
|
4140
|
+
return `Running ${name}`;
|
|
4141
|
+
}
|
|
4142
|
+
}
|
|
4143
|
+
function buildCodewithTomlFragment(name, profile) {
|
|
4144
|
+
const shortName = shortHookName(name);
|
|
4145
|
+
const meta = getHook(shortName);
|
|
4146
|
+
if (!meta)
|
|
4147
|
+
throw new Error(`Hook '${shortName}' not found`);
|
|
4148
|
+
const eventKey = getTargetEventName(meta.event, "codewith");
|
|
4149
|
+
if (!eventKey) {
|
|
4150
|
+
throw new Error(`Hook '${shortName}' uses event '${meta.event}', which is not supported by the Codewith target`);
|
|
4151
|
+
}
|
|
4152
|
+
const command = profile ? `hooks run ${shortName} --profile ${profile}` : `hooks run ${shortName}`;
|
|
4153
|
+
const matcher = codewithMatcher(meta.matcher);
|
|
4154
|
+
const lines = [
|
|
4155
|
+
`[[hooks.${eventKey}]]`
|
|
4156
|
+
];
|
|
4157
|
+
if (matcher)
|
|
4158
|
+
lines.push(`matcher = ${tomlString(matcher)}`);
|
|
4159
|
+
lines.push("", `[[hooks.${eventKey}.hooks]]`, `type = "command"`, `command = ${tomlString(command)}`, `timeout = ${codewithTimeout(shortName)}`, `statusMessage = ${tomlString(codewithStatusMessage(shortName))}`);
|
|
4160
|
+
return `${lines.join(`
|
|
4161
|
+
`)}
|
|
4162
|
+
`;
|
|
4163
|
+
}
|
|
4164
|
+
function readCodewithConfig(scope, configPath) {
|
|
4165
|
+
const path = getSettingsPath(scope, "codewith", configPath);
|
|
4166
|
+
try {
|
|
4167
|
+
return existsSync2(path) ? readFileSync(path, "utf-8") : "";
|
|
4168
|
+
} catch {
|
|
4169
|
+
return "";
|
|
4170
|
+
}
|
|
4171
|
+
}
|
|
4172
|
+
function appendCodewithFragment(fragment, scope, configPath) {
|
|
4173
|
+
const path = getSettingsPath(scope, "codewith", configPath);
|
|
4174
|
+
const dir = dirname(path);
|
|
4175
|
+
if (!existsSync2(dir))
|
|
4176
|
+
mkdirSync(dir, { recursive: true });
|
|
4177
|
+
const existing = existsSync2(path) ? readFileSync(path, "utf-8") : "";
|
|
4178
|
+
const sep = existing.trim() ? `
|
|
4179
|
+
|
|
4180
|
+
` : "";
|
|
4181
|
+
writeFileSync(path, `${existing.replace(/\s*$/, "")}${sep}${fragment}`);
|
|
4182
|
+
return path;
|
|
4005
4183
|
}
|
|
4006
4184
|
function detectConflict(name, scope, target) {
|
|
4007
4185
|
const meta = getHook(name);
|
|
@@ -4022,11 +4200,60 @@ function detectConflict(name, scope, target) {
|
|
|
4022
4200
|
}
|
|
4023
4201
|
return;
|
|
4024
4202
|
}
|
|
4025
|
-
function installForTarget(name, scope, overwrite, target, profile) {
|
|
4203
|
+
function installForTarget(name, scope, overwrite, target, profile, codewithMode = "fragment", codewithConfigPath) {
|
|
4026
4204
|
const shortName = shortHookName(name);
|
|
4027
4205
|
if (!hookExists(shortName)) {
|
|
4028
4206
|
return { hook: shortName, success: false, error: `Hook '${shortName}' not found`, target };
|
|
4029
4207
|
}
|
|
4208
|
+
if (target === "codewith") {
|
|
4209
|
+
try {
|
|
4210
|
+
const fragment = buildCodewithTomlFragment(shortName, profile);
|
|
4211
|
+
if (codewithMode !== "write") {
|
|
4212
|
+
return {
|
|
4213
|
+
hook: shortName,
|
|
4214
|
+
success: true,
|
|
4215
|
+
scope,
|
|
4216
|
+
target,
|
|
4217
|
+
fragment,
|
|
4218
|
+
applied: false,
|
|
4219
|
+
note: "Codewith install is fragment-only by default; open-configs should own applying this TOML."
|
|
4220
|
+
};
|
|
4221
|
+
}
|
|
4222
|
+
if (!codewithConfigPath) {
|
|
4223
|
+
return {
|
|
4224
|
+
hook: shortName,
|
|
4225
|
+
success: false,
|
|
4226
|
+
error: "Direct Codewith writes require an explicit --codewith-config path; refusing to write default ~/.codewith/config.toml.",
|
|
4227
|
+
scope,
|
|
4228
|
+
target,
|
|
4229
|
+
fragment,
|
|
4230
|
+
applied: false
|
|
4231
|
+
};
|
|
4232
|
+
}
|
|
4233
|
+
const existing = readCodewithConfig(scope, codewithConfigPath);
|
|
4234
|
+
if (!overwrite && new RegExp(`command\\s*=\\s*["']hooks run ${shortName}(?:\\s|["'])`).test(existing)) {
|
|
4235
|
+
return { hook: shortName, success: false, error: "Already installed. Use --overwrite to append another fragment.", scope, target };
|
|
4236
|
+
}
|
|
4237
|
+
const path = appendCodewithFragment(fragment, scope, codewithConfigPath);
|
|
4238
|
+
return {
|
|
4239
|
+
hook: shortName,
|
|
4240
|
+
success: true,
|
|
4241
|
+
scope,
|
|
4242
|
+
target,
|
|
4243
|
+
fragment,
|
|
4244
|
+
applied: true,
|
|
4245
|
+
configPath: path,
|
|
4246
|
+
note: "Direct Codewith config write was explicitly requested; prefer open-configs for managed machines."
|
|
4247
|
+
};
|
|
4248
|
+
} catch (error) {
|
|
4249
|
+
return {
|
|
4250
|
+
hook: shortName,
|
|
4251
|
+
success: false,
|
|
4252
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
4253
|
+
target
|
|
4254
|
+
};
|
|
4255
|
+
}
|
|
4256
|
+
}
|
|
4030
4257
|
const registered = getRegisteredHooksForTarget(scope, target);
|
|
4031
4258
|
if (registered.includes(shortName) && !overwrite) {
|
|
4032
4259
|
return { hook: shortName, success: false, error: "Already installed. Use --overwrite to replace.", scope, target };
|
|
@@ -4045,25 +4272,63 @@ function installForTarget(name, scope, overwrite, target, profile) {
|
|
|
4045
4272
|
}
|
|
4046
4273
|
}
|
|
4047
4274
|
function installHook(name, options = {}) {
|
|
4048
|
-
const { scope = "global", overwrite = false, target = "claude", profile } = options;
|
|
4275
|
+
const { scope = "global", overwrite = false, target = "claude", profile, codewithMode = "fragment", codewithConfigPath } = options;
|
|
4049
4276
|
if (target === "all") {
|
|
4277
|
+
const shortName = shortHookName(name);
|
|
4278
|
+
const meta = getHook(shortName);
|
|
4279
|
+
if (meta) {
|
|
4280
|
+
const unsupportedTargets = ["claude", "gemini", "codewith"].filter((agentTarget) => !isEventSupported(meta.event, agentTarget));
|
|
4281
|
+
if (unsupportedTargets.length > 0) {
|
|
4282
|
+
return {
|
|
4283
|
+
hook: shortName,
|
|
4284
|
+
success: false,
|
|
4285
|
+
error: `Event '${meta.event}' is not supported by target(s): ${unsupportedTargets.join(", ")}`,
|
|
4286
|
+
target: "all"
|
|
4287
|
+
};
|
|
4288
|
+
}
|
|
4289
|
+
}
|
|
4050
4290
|
const claudeResult = installForTarget(name, scope, overwrite, "claude", profile);
|
|
4051
|
-
|
|
4291
|
+
if (!claudeResult.success) {
|
|
4292
|
+
return {
|
|
4293
|
+
...claudeResult,
|
|
4294
|
+
error: `Failed for target 'claude': ${claudeResult.error}`,
|
|
4295
|
+
target: "all"
|
|
4296
|
+
};
|
|
4297
|
+
}
|
|
4298
|
+
const geminiResult = installForTarget(name, scope, overwrite, "gemini", profile);
|
|
4299
|
+
if (!geminiResult.success) {
|
|
4300
|
+
return {
|
|
4301
|
+
...geminiResult,
|
|
4302
|
+
error: `Failed for target 'gemini': ${geminiResult.error}`,
|
|
4303
|
+
target: "all"
|
|
4304
|
+
};
|
|
4305
|
+
}
|
|
4306
|
+
const codewithResult = installForTarget(name, scope, overwrite, "codewith", profile, codewithMode, codewithConfigPath);
|
|
4307
|
+
if (!codewithResult.success) {
|
|
4308
|
+
return {
|
|
4309
|
+
...codewithResult,
|
|
4310
|
+
error: `Failed for target 'codewith': ${codewithResult.error}`,
|
|
4311
|
+
target: "all"
|
|
4312
|
+
};
|
|
4313
|
+
}
|
|
4052
4314
|
return { ...claudeResult, target: "all" };
|
|
4053
4315
|
}
|
|
4054
|
-
return installForTarget(name, scope, overwrite, target, profile);
|
|
4316
|
+
return installForTarget(name, scope, overwrite, target, profile, codewithMode, codewithConfigPath);
|
|
4055
4317
|
}
|
|
4056
4318
|
function registerHook(name, scope = "global", target = "claude", profile) {
|
|
4057
4319
|
const meta = getHook(name);
|
|
4058
4320
|
if (!meta)
|
|
4059
4321
|
return;
|
|
4322
|
+
const eventKey = getTargetEventName(meta.event, target);
|
|
4323
|
+
if (eventKey === null) {
|
|
4324
|
+
throw new Error(`Event '${meta.event}' is not supported by target '${target}'`);
|
|
4325
|
+
}
|
|
4060
4326
|
const settings = readSettings(scope, target);
|
|
4061
4327
|
if (!settings.hooks)
|
|
4062
4328
|
settings.hooks = {};
|
|
4063
|
-
|
|
4329
|
+
removeHookFromAllEvents(settings, name);
|
|
4064
4330
|
if (!settings.hooks[eventKey])
|
|
4065
4331
|
settings.hooks[eventKey] = [];
|
|
4066
|
-
settings.hooks[eventKey] = removeHookEntriesByName(settings.hooks[eventKey], name);
|
|
4067
4332
|
const hookCommand = profile ? `hooks run ${name} --profile ${profile}` : `hooks run ${name}`;
|
|
4068
4333
|
const entry = {
|
|
4069
4334
|
hooks: [{ type: "command", command: hookCommand }]
|
|
@@ -4074,6 +4339,16 @@ function registerHook(name, scope = "global", target = "claude", profile) {
|
|
|
4074
4339
|
settings.hooks[eventKey].push(entry);
|
|
4075
4340
|
writeSettings(settings, scope, target);
|
|
4076
4341
|
}
|
|
4342
|
+
function removeHookFromAllEvents(settings, name) {
|
|
4343
|
+
if (!settings.hooks)
|
|
4344
|
+
return;
|
|
4345
|
+
for (const key of Object.keys(settings.hooks)) {
|
|
4346
|
+
settings.hooks[key] = removeHookEntriesByName(settings.hooks[key], name);
|
|
4347
|
+
if (settings.hooks[key].length === 0) {
|
|
4348
|
+
delete settings.hooks[key];
|
|
4349
|
+
}
|
|
4350
|
+
}
|
|
4351
|
+
}
|
|
4077
4352
|
function unregisterHook(name, scope = "global", target = "claude") {
|
|
4078
4353
|
const meta = getHook(name);
|
|
4079
4354
|
if (!meta)
|
|
@@ -4081,19 +4356,23 @@ function unregisterHook(name, scope = "global", target = "claude") {
|
|
|
4081
4356
|
const settings = readSettings(scope, target);
|
|
4082
4357
|
if (!settings.hooks)
|
|
4083
4358
|
return;
|
|
4084
|
-
|
|
4085
|
-
if (!settings.hooks[eventKey])
|
|
4086
|
-
return;
|
|
4087
|
-
settings.hooks[eventKey] = removeHookEntriesByName(settings.hooks[eventKey], name);
|
|
4088
|
-
if (settings.hooks[eventKey].length === 0) {
|
|
4089
|
-
delete settings.hooks[eventKey];
|
|
4090
|
-
}
|
|
4359
|
+
removeHookFromAllEvents(settings, name);
|
|
4091
4360
|
if (Object.keys(settings.hooks).length === 0) {
|
|
4092
4361
|
delete settings.hooks;
|
|
4093
4362
|
}
|
|
4094
4363
|
writeSettings(settings, scope, target);
|
|
4095
4364
|
}
|
|
4096
4365
|
function getRegisteredHooksForTarget(scope = "global", target = "claude") {
|
|
4366
|
+
if (target === "codewith") {
|
|
4367
|
+
const config = readCodewithConfig(scope);
|
|
4368
|
+
const registered2 = [];
|
|
4369
|
+
const re = /command\s*=\s*["']hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?["']/g;
|
|
4370
|
+
let match;
|
|
4371
|
+
while (match = re.exec(config)) {
|
|
4372
|
+
registered2.push(match[1]);
|
|
4373
|
+
}
|
|
4374
|
+
return [...new Set(registered2)];
|
|
4375
|
+
}
|
|
4097
4376
|
const settings = readSettings(scope, target);
|
|
4098
4377
|
if (!settings.hooks)
|
|
4099
4378
|
return [];
|
|
@@ -4101,8 +4380,8 @@ function getRegisteredHooksForTarget(scope = "global", target = "claude") {
|
|
|
4101
4380
|
for (const eventKey of Object.keys(settings.hooks)) {
|
|
4102
4381
|
for (const entry of settings.hooks[eventKey]) {
|
|
4103
4382
|
for (const hook of entry.hooks || []) {
|
|
4104
|
-
const newMatch = hook.command?.match(/^hooks run (\w+)(?:\s+--profile\s
|
|
4105
|
-
const oldMatch = hook.command?.match(/^hook-(\w+)$/);
|
|
4383
|
+
const newMatch = hook.command?.match(/^hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?$/);
|
|
4384
|
+
const oldMatch = hook.command?.match(/^hook-([\w-]+)$/);
|
|
4106
4385
|
const match = newMatch || oldMatch;
|
|
4107
4386
|
if (match) {
|
|
4108
4387
|
registered.push(match[1]);
|
|
@@ -4122,6 +4401,9 @@ function removeHook(name, scope = "global", target = "claude") {
|
|
|
4122
4401
|
const geminiRemoved = removeHookForTarget(shortName, scope, "gemini");
|
|
4123
4402
|
return claudeRemoved || geminiRemoved;
|
|
4124
4403
|
}
|
|
4404
|
+
if (target === "codewith") {
|
|
4405
|
+
return false;
|
|
4406
|
+
}
|
|
4125
4407
|
return removeHookForTarget(shortName, scope, target);
|
|
4126
4408
|
}
|
|
4127
4409
|
function removeHookForTarget(name, scope, target) {
|
|
@@ -4142,13 +4424,28 @@ var init_installer = __esm(() => {
|
|
|
4142
4424
|
PreToolUse: "PreToolUse",
|
|
4143
4425
|
PostToolUse: "PostToolUse",
|
|
4144
4426
|
Stop: "Stop",
|
|
4145
|
-
Notification: "Notification"
|
|
4427
|
+
Notification: "Notification",
|
|
4428
|
+
SessionStart: "SessionStart",
|
|
4429
|
+
SessionEnd: "SessionEnd",
|
|
4430
|
+
UserPromptSubmit: null
|
|
4146
4431
|
},
|
|
4147
4432
|
gemini: {
|
|
4148
4433
|
PreToolUse: "BeforeTool",
|
|
4149
4434
|
PostToolUse: "AfterTool",
|
|
4150
4435
|
Stop: "AfterAgent",
|
|
4151
|
-
Notification: "Notification"
|
|
4436
|
+
Notification: "Notification",
|
|
4437
|
+
SessionStart: null,
|
|
4438
|
+
SessionEnd: null,
|
|
4439
|
+
UserPromptSubmit: null
|
|
4440
|
+
},
|
|
4441
|
+
codewith: {
|
|
4442
|
+
PreToolUse: "PreToolUse",
|
|
4443
|
+
PostToolUse: "PostToolUse",
|
|
4444
|
+
Stop: "Stop",
|
|
4445
|
+
Notification: null,
|
|
4446
|
+
SessionStart: "SessionStart",
|
|
4447
|
+
SessionEnd: null,
|
|
4448
|
+
UserPromptSubmit: "UserPromptSubmit"
|
|
4152
4449
|
}
|
|
4153
4450
|
};
|
|
4154
4451
|
getInstalledHooks = getRegisteredHooks;
|
|
@@ -4266,7 +4563,7 @@ var CREATE_HOOK_EVENTS_TABLE = `
|
|
|
4266
4563
|
timestamp TEXT NOT NULL,
|
|
4267
4564
|
session_id TEXT NOT NULL,
|
|
4268
4565
|
hook_name TEXT NOT NULL,
|
|
4269
|
-
event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification')),
|
|
4566
|
+
event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit')),
|
|
4270
4567
|
tool_name TEXT,
|
|
4271
4568
|
tool_input TEXT,
|
|
4272
4569
|
result TEXT CHECK (result IN ('continue', 'block', NULL)),
|
|
@@ -4297,6 +4594,64 @@ var init_001_initial = __esm(() => {
|
|
|
4297
4594
|
init_schema();
|
|
4298
4595
|
});
|
|
4299
4596
|
|
|
4597
|
+
// src/db/migrations/002_session_events.ts
|
|
4598
|
+
function up2(db) {
|
|
4599
|
+
const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
|
|
4600
|
+
if (!row?.sql)
|
|
4601
|
+
return;
|
|
4602
|
+
if (row.sql.includes("SessionStart"))
|
|
4603
|
+
return;
|
|
4604
|
+
db.exec("BEGIN");
|
|
4605
|
+
try {
|
|
4606
|
+
db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
|
|
4607
|
+
db.exec(CREATE_HOOK_EVENTS_TABLE);
|
|
4608
|
+
db.exec(`INSERT INTO hook_events
|
|
4609
|
+
(id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
|
|
4610
|
+
SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
|
|
4611
|
+
FROM hook_events_old`);
|
|
4612
|
+
db.exec("DROP TABLE hook_events_old");
|
|
4613
|
+
for (const idx of CREATE_INDEXES) {
|
|
4614
|
+
db.exec(idx);
|
|
4615
|
+
}
|
|
4616
|
+
db.exec("COMMIT");
|
|
4617
|
+
} catch (err) {
|
|
4618
|
+
db.exec("ROLLBACK");
|
|
4619
|
+
throw err;
|
|
4620
|
+
}
|
|
4621
|
+
}
|
|
4622
|
+
var init_002_session_events = __esm(() => {
|
|
4623
|
+
init_schema();
|
|
4624
|
+
});
|
|
4625
|
+
|
|
4626
|
+
// src/db/migrations/003_user_prompt_submit_event.ts
|
|
4627
|
+
function up3(db) {
|
|
4628
|
+
const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
|
|
4629
|
+
if (!row?.sql)
|
|
4630
|
+
return;
|
|
4631
|
+
if (row.sql.includes("UserPromptSubmit"))
|
|
4632
|
+
return;
|
|
4633
|
+
db.exec("BEGIN");
|
|
4634
|
+
try {
|
|
4635
|
+
db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
|
|
4636
|
+
db.exec(CREATE_HOOK_EVENTS_TABLE);
|
|
4637
|
+
db.exec(`INSERT INTO hook_events
|
|
4638
|
+
(id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
|
|
4639
|
+
SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
|
|
4640
|
+
FROM hook_events_old`);
|
|
4641
|
+
db.exec("DROP TABLE hook_events_old");
|
|
4642
|
+
for (const idx of CREATE_INDEXES) {
|
|
4643
|
+
db.exec(idx);
|
|
4644
|
+
}
|
|
4645
|
+
db.exec("COMMIT");
|
|
4646
|
+
} catch (err) {
|
|
4647
|
+
db.exec("ROLLBACK");
|
|
4648
|
+
throw err;
|
|
4649
|
+
}
|
|
4650
|
+
}
|
|
4651
|
+
var init_003_user_prompt_submit_event = __esm(() => {
|
|
4652
|
+
init_schema();
|
|
4653
|
+
});
|
|
4654
|
+
|
|
4300
4655
|
// src/db/migrations/index.ts
|
|
4301
4656
|
function ensureMigrationsTable(db) {
|
|
4302
4657
|
db.exec(`
|
|
@@ -4326,7 +4681,13 @@ function runMigrations(db) {
|
|
|
4326
4681
|
var MIGRATIONS;
|
|
4327
4682
|
var init_migrations = __esm(() => {
|
|
4328
4683
|
init_001_initial();
|
|
4329
|
-
|
|
4684
|
+
init_002_session_events();
|
|
4685
|
+
init_003_user_prompt_submit_event();
|
|
4686
|
+
MIGRATIONS = [
|
|
4687
|
+
{ version: "001_initial", up },
|
|
4688
|
+
{ version: "002_session_events", up: up2 },
|
|
4689
|
+
{ version: "003_user_prompt_submit_event", up: up3 }
|
|
4690
|
+
];
|
|
4330
4691
|
});
|
|
4331
4692
|
|
|
4332
4693
|
// src/db/legacy-import.ts
|
|
@@ -4538,6 +4899,396 @@ var init_db = __esm(() => {
|
|
|
4538
4899
|
init_legacy_import();
|
|
4539
4900
|
});
|
|
4540
4901
|
|
|
4902
|
+
// src/db/pg-migrations.ts
|
|
4903
|
+
var PG_MIGRATIONS;
|
|
4904
|
+
var init_pg_migrations = __esm(() => {
|
|
4905
|
+
PG_MIGRATIONS = [
|
|
4906
|
+
`CREATE TABLE IF NOT EXISTS hook_events (
|
|
4907
|
+
id TEXT PRIMARY KEY,
|
|
4908
|
+
timestamp TEXT NOT NULL,
|
|
4909
|
+
session_id TEXT NOT NULL,
|
|
4910
|
+
hook_name TEXT NOT NULL,
|
|
4911
|
+
event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit')),
|
|
4912
|
+
tool_name TEXT,
|
|
4913
|
+
tool_input TEXT,
|
|
4914
|
+
result TEXT CHECK (result IN ('continue', 'block', NULL)),
|
|
4915
|
+
error TEXT,
|
|
4916
|
+
duration_ms INTEGER,
|
|
4917
|
+
project_dir TEXT,
|
|
4918
|
+
metadata TEXT
|
|
4919
|
+
)`,
|
|
4920
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_timestamp ON hook_events (timestamp)`,
|
|
4921
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_session_id ON hook_events (session_id)`,
|
|
4922
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_hook_name ON hook_events (hook_name)`,
|
|
4923
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_event_type ON hook_events (event_type)`,
|
|
4924
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_errors ON hook_events (timestamp) WHERE error IS NOT NULL`,
|
|
4925
|
+
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
4926
|
+
version TEXT PRIMARY KEY,
|
|
4927
|
+
applied_at TEXT NOT NULL
|
|
4928
|
+
)`,
|
|
4929
|
+
`CREATE TABLE IF NOT EXISTS _meta (
|
|
4930
|
+
key TEXT PRIMARY KEY,
|
|
4931
|
+
value TEXT NOT NULL
|
|
4932
|
+
)`,
|
|
4933
|
+
`CREATE TABLE IF NOT EXISTS feedback (
|
|
4934
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
4935
|
+
message TEXT NOT NULL,
|
|
4936
|
+
email TEXT,
|
|
4937
|
+
category TEXT DEFAULT 'general',
|
|
4938
|
+
version TEXT,
|
|
4939
|
+
machine_id TEXT,
|
|
4940
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text
|
|
4941
|
+
)`,
|
|
4942
|
+
`ALTER TABLE hook_events DROP CONSTRAINT IF EXISTS hook_events_event_type_check`,
|
|
4943
|
+
`ALTER TABLE hook_events ADD CONSTRAINT hook_events_event_type_check
|
|
4944
|
+
CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit'))`
|
|
4945
|
+
];
|
|
4946
|
+
});
|
|
4947
|
+
|
|
4948
|
+
// src/db/remote-storage.ts
|
|
4949
|
+
import pg from "pg";
|
|
4950
|
+
function translatePlaceholders(sql) {
|
|
4951
|
+
let index = 0;
|
|
4952
|
+
return sql.replace(/\?/g, () => `$${++index}`);
|
|
4953
|
+
}
|
|
4954
|
+
function normalizeParams(params) {
|
|
4955
|
+
const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
|
|
4956
|
+
return flat.map((value) => value === undefined ? null : value);
|
|
4957
|
+
}
|
|
4958
|
+
function sslConfigFor(connectionString) {
|
|
4959
|
+
return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
|
|
4960
|
+
}
|
|
4961
|
+
|
|
4962
|
+
class PgAdapterAsync {
|
|
4963
|
+
pool;
|
|
4964
|
+
constructor(connectionString) {
|
|
4965
|
+
this.pool = new pg.Pool({ connectionString, ssl: sslConfigFor(connectionString) });
|
|
4966
|
+
}
|
|
4967
|
+
async run(sql, ...params) {
|
|
4968
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
4969
|
+
return { changes: result.rowCount ?? 0 };
|
|
4970
|
+
}
|
|
4971
|
+
async all(sql, ...params) {
|
|
4972
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
4973
|
+
return result.rows;
|
|
4974
|
+
}
|
|
4975
|
+
async close() {
|
|
4976
|
+
await this.pool.end();
|
|
4977
|
+
}
|
|
4978
|
+
}
|
|
4979
|
+
var init_remote_storage = () => {};
|
|
4980
|
+
|
|
4981
|
+
// src/db/storage-sync.ts
|
|
4982
|
+
function readEnv(name) {
|
|
4983
|
+
const value = process.env[name]?.trim();
|
|
4984
|
+
return value || undefined;
|
|
4985
|
+
}
|
|
4986
|
+
function normalizeStorageMode(value) {
|
|
4987
|
+
const normalized = value?.trim().toLowerCase();
|
|
4988
|
+
if (normalized === "local" || normalized === "hybrid" || normalized === "remote")
|
|
4989
|
+
return normalized;
|
|
4990
|
+
return;
|
|
4991
|
+
}
|
|
4992
|
+
function getStorageDatabaseEnvName() {
|
|
4993
|
+
for (const name of STORAGE_DATABASE_ENV) {
|
|
4994
|
+
if (readEnv(name))
|
|
4995
|
+
return name;
|
|
4996
|
+
}
|
|
4997
|
+
return null;
|
|
4998
|
+
}
|
|
4999
|
+
function getStorageDatabaseEnv() {
|
|
5000
|
+
const name = getStorageDatabaseEnvName();
|
|
5001
|
+
return name ? { name } : null;
|
|
5002
|
+
}
|
|
5003
|
+
function getStorageDatabaseUrl() {
|
|
5004
|
+
const env = getStorageDatabaseEnv();
|
|
5005
|
+
return env ? readEnv(env.name) ?? null : null;
|
|
5006
|
+
}
|
|
5007
|
+
function getStorageMode() {
|
|
5008
|
+
const mode = normalizeStorageMode(readEnv(HOOKS_STORAGE_MODE_ENV)) ?? normalizeStorageMode(readEnv(HOOKS_STORAGE_MODE_FALLBACK_ENV));
|
|
5009
|
+
if (mode)
|
|
5010
|
+
return mode;
|
|
5011
|
+
return getStorageDatabaseUrl() ? "hybrid" : "local";
|
|
5012
|
+
}
|
|
5013
|
+
async function getStoragePg() {
|
|
5014
|
+
const url = getStorageDatabaseUrl();
|
|
5015
|
+
if (!url) {
|
|
5016
|
+
throw new Error("Missing HASNA_HOOKS_DATABASE_URL or HOOKS_DATABASE_URL");
|
|
5017
|
+
}
|
|
5018
|
+
return new PgAdapterAsync(url);
|
|
5019
|
+
}
|
|
5020
|
+
async function runStorageMigrations(remote) {
|
|
5021
|
+
await remote.run("CREATE EXTENSION IF NOT EXISTS pgcrypto");
|
|
5022
|
+
for (const sql of PG_MIGRATIONS)
|
|
5023
|
+
await remote.run(sql);
|
|
5024
|
+
}
|
|
5025
|
+
async function storagePush(options) {
|
|
5026
|
+
const remote = await getStoragePg();
|
|
5027
|
+
const db = getDb();
|
|
5028
|
+
try {
|
|
5029
|
+
await runStorageMigrations(remote);
|
|
5030
|
+
const results = [];
|
|
5031
|
+
for (const table of resolveTables(options?.tables)) {
|
|
5032
|
+
results.push(await pushTable(db, remote, table));
|
|
5033
|
+
}
|
|
5034
|
+
recordSyncMeta(db, "push", results);
|
|
5035
|
+
return results;
|
|
5036
|
+
} finally {
|
|
5037
|
+
await remote.close();
|
|
5038
|
+
}
|
|
5039
|
+
}
|
|
5040
|
+
async function storagePull(options) {
|
|
5041
|
+
const remote = await getStoragePg();
|
|
5042
|
+
const db = getDb();
|
|
5043
|
+
try {
|
|
5044
|
+
await runStorageMigrations(remote);
|
|
5045
|
+
const results = [];
|
|
5046
|
+
for (const table of resolveTables(options?.tables)) {
|
|
5047
|
+
results.push(await pullTable(remote, db, table));
|
|
5048
|
+
}
|
|
5049
|
+
recordSyncMeta(db, "pull", results);
|
|
5050
|
+
return results;
|
|
5051
|
+
} finally {
|
|
5052
|
+
await remote.close();
|
|
5053
|
+
}
|
|
5054
|
+
}
|
|
5055
|
+
async function storageSync(options) {
|
|
5056
|
+
const pull = await storagePull(options);
|
|
5057
|
+
const push = await storagePush(options);
|
|
5058
|
+
return { pull, push };
|
|
5059
|
+
}
|
|
5060
|
+
function getSyncMetaAll() {
|
|
5061
|
+
const db = getDb();
|
|
5062
|
+
ensureSyncMetaTable(db);
|
|
5063
|
+
return db.query("SELECT table_name, last_synced_at, direction FROM _hooks_sync_meta ORDER BY table_name, direction").all();
|
|
5064
|
+
}
|
|
5065
|
+
function getStorageStatus() {
|
|
5066
|
+
const activeEnv = getStorageDatabaseEnv();
|
|
5067
|
+
return {
|
|
5068
|
+
configured: Boolean(activeEnv),
|
|
5069
|
+
mode: getStorageMode(),
|
|
5070
|
+
env: STORAGE_DATABASE_ENV,
|
|
5071
|
+
activeEnv: activeEnv?.name ?? null,
|
|
5072
|
+
service: "hooks",
|
|
5073
|
+
tables: STORAGE_TABLES,
|
|
5074
|
+
sync: getSyncMetaAll()
|
|
5075
|
+
};
|
|
5076
|
+
}
|
|
5077
|
+
function resolveTables(tables) {
|
|
5078
|
+
if (!tables || tables.length === 0)
|
|
5079
|
+
return [...STORAGE_TABLES];
|
|
5080
|
+
const allowed = new Set(STORAGE_TABLES);
|
|
5081
|
+
const requested = tables.map((table) => table.trim()).filter(Boolean);
|
|
5082
|
+
const invalid = requested.filter((table) => !allowed.has(table));
|
|
5083
|
+
if (invalid.length > 0)
|
|
5084
|
+
throw new Error(`Unknown hooks sync table(s): ${invalid.join(", ")}`);
|
|
5085
|
+
return requested;
|
|
5086
|
+
}
|
|
5087
|
+
function parseStorageTables(value) {
|
|
5088
|
+
if (!value)
|
|
5089
|
+
return;
|
|
5090
|
+
return resolveTables(Array.isArray(value) ? value : value.split(","));
|
|
5091
|
+
}
|
|
5092
|
+
async function pushTable(db, remote, table) {
|
|
5093
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
5094
|
+
try {
|
|
5095
|
+
if (!tableExists(db, table))
|
|
5096
|
+
return result;
|
|
5097
|
+
const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all();
|
|
5098
|
+
result.rowsRead = rows.length;
|
|
5099
|
+
if (rows.length === 0)
|
|
5100
|
+
return result;
|
|
5101
|
+
const remoteColumns = await getRemoteColumns(remote, table);
|
|
5102
|
+
const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
|
|
5103
|
+
result.rowsWritten = await upsertPg(remote, table, columns, rows, remoteColumns);
|
|
5104
|
+
} catch (error) {
|
|
5105
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
5106
|
+
}
|
|
5107
|
+
return result;
|
|
5108
|
+
}
|
|
5109
|
+
async function pullTable(remote, db, table) {
|
|
5110
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
5111
|
+
try {
|
|
5112
|
+
if (!tableExists(db, table))
|
|
5113
|
+
return result;
|
|
5114
|
+
const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`);
|
|
5115
|
+
result.rowsRead = rows.length;
|
|
5116
|
+
if (rows.length === 0)
|
|
5117
|
+
return result;
|
|
5118
|
+
const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
|
|
5119
|
+
result.rowsWritten = upsertSqlite(db, table, columns, rows);
|
|
5120
|
+
} catch (error) {
|
|
5121
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
5122
|
+
}
|
|
5123
|
+
return result;
|
|
5124
|
+
}
|
|
5125
|
+
async function getRemoteColumns(remote, table) {
|
|
5126
|
+
const rows = await remote.all("SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?", table);
|
|
5127
|
+
return new Map(rows.map((row) => [row.column_name, row.data_type]));
|
|
5128
|
+
}
|
|
5129
|
+
function filterRemoteColumns(remoteColumns, columns) {
|
|
5130
|
+
if (remoteColumns.size === 0)
|
|
5131
|
+
return columns;
|
|
5132
|
+
return columns.filter((column) => remoteColumns.has(column));
|
|
5133
|
+
}
|
|
5134
|
+
function filterLocalColumns(db, table, columns) {
|
|
5135
|
+
const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all();
|
|
5136
|
+
const allowed = new Set(rows.map((row) => row.name));
|
|
5137
|
+
return columns.filter((column) => allowed.has(column));
|
|
5138
|
+
}
|
|
5139
|
+
async function upsertPg(remote, table, columns, rows, remoteColumns) {
|
|
5140
|
+
if (columns.length === 0)
|
|
5141
|
+
return 0;
|
|
5142
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
5143
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
5144
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
5145
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
5146
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
5147
|
+
const fallbackKey = primaryKeys[0];
|
|
5148
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
|
|
5149
|
+
for (const row of rows) {
|
|
5150
|
+
await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
|
|
5151
|
+
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`, ...columns.map((column) => coerceForPg(row[column], remoteColumns.get(column))));
|
|
5152
|
+
}
|
|
5153
|
+
return rows.length;
|
|
5154
|
+
}
|
|
5155
|
+
function upsertSqlite(db, table, columns, rows) {
|
|
5156
|
+
if (columns.length === 0)
|
|
5157
|
+
return 0;
|
|
5158
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
5159
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
5160
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
5161
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
5162
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
5163
|
+
const fallbackKey = primaryKeys[0];
|
|
5164
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`;
|
|
5165
|
+
const statement = db.query(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
|
|
5166
|
+
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`);
|
|
5167
|
+
const insert = db.transaction((batch) => {
|
|
5168
|
+
for (const row of batch)
|
|
5169
|
+
statement.run(...columns.map((column) => coerceForSqlite(row[column])));
|
|
5170
|
+
});
|
|
5171
|
+
insert(rows);
|
|
5172
|
+
return rows.length;
|
|
5173
|
+
}
|
|
5174
|
+
function recordSyncMeta(db, direction, results) {
|
|
5175
|
+
ensureSyncMetaTable(db);
|
|
5176
|
+
const now2 = new Date().toISOString();
|
|
5177
|
+
const statement = db.query(`
|
|
5178
|
+
INSERT INTO _hooks_sync_meta (table_name, last_synced_at, direction)
|
|
5179
|
+
VALUES (?, ?, ?)
|
|
5180
|
+
ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at
|
|
5181
|
+
`);
|
|
5182
|
+
for (const result of results) {
|
|
5183
|
+
if (result.errors.length > 0)
|
|
5184
|
+
continue;
|
|
5185
|
+
statement.run(result.table, now2, direction);
|
|
5186
|
+
}
|
|
5187
|
+
}
|
|
5188
|
+
function ensureSyncMetaTable(db) {
|
|
5189
|
+
db.exec(`
|
|
5190
|
+
CREATE TABLE IF NOT EXISTS _hooks_sync_meta (
|
|
5191
|
+
table_name TEXT NOT NULL,
|
|
5192
|
+
last_synced_at TEXT,
|
|
5193
|
+
direction TEXT NOT NULL CHECK(direction IN ('push', 'pull')),
|
|
5194
|
+
PRIMARY KEY (table_name, direction)
|
|
5195
|
+
)
|
|
5196
|
+
`);
|
|
5197
|
+
}
|
|
5198
|
+
function tableExists(db, table) {
|
|
5199
|
+
const row = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
5200
|
+
return Boolean(row);
|
|
5201
|
+
}
|
|
5202
|
+
function quoteIdent(identifier) {
|
|
5203
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
5204
|
+
}
|
|
5205
|
+
function coerceForPg(value, dataType) {
|
|
5206
|
+
if (value === undefined || value === null)
|
|
5207
|
+
return null;
|
|
5208
|
+
if (dataType === "boolean") {
|
|
5209
|
+
if (typeof value === "boolean")
|
|
5210
|
+
return value;
|
|
5211
|
+
if (typeof value === "number")
|
|
5212
|
+
return value !== 0;
|
|
5213
|
+
if (typeof value === "string")
|
|
5214
|
+
return value === "1" || value.toLowerCase() === "true";
|
|
5215
|
+
}
|
|
5216
|
+
if (value instanceof Date)
|
|
5217
|
+
return value.toISOString();
|
|
5218
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
5219
|
+
return value;
|
|
5220
|
+
if (typeof value === "object")
|
|
5221
|
+
return JSON.stringify(value);
|
|
5222
|
+
return value;
|
|
5223
|
+
}
|
|
5224
|
+
function coerceForSqlite(value) {
|
|
5225
|
+
if (value === undefined || value === null)
|
|
5226
|
+
return null;
|
|
5227
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean")
|
|
5228
|
+
return value;
|
|
5229
|
+
if (value instanceof Date)
|
|
5230
|
+
return value.toISOString();
|
|
5231
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
5232
|
+
return value;
|
|
5233
|
+
if (typeof value === "object")
|
|
5234
|
+
return JSON.stringify(value);
|
|
5235
|
+
return String(value);
|
|
5236
|
+
}
|
|
5237
|
+
var STORAGE_TABLES, HOOKS_STORAGE_TABLES, HOOKS_STORAGE_ENV = "HASNA_HOOKS_DATABASE_URL", HOOKS_STORAGE_FALLBACK_ENV = "HOOKS_DATABASE_URL", HOOKS_STORAGE_MODE_ENV = "HASNA_HOOKS_STORAGE_MODE", HOOKS_STORAGE_MODE_FALLBACK_ENV = "HOOKS_STORAGE_MODE", STORAGE_DATABASE_ENV, STORAGE_MODE_ENV, PRIMARY_KEYS;
|
|
5238
|
+
var init_storage_sync = __esm(() => {
|
|
5239
|
+
init_db();
|
|
5240
|
+
init_pg_migrations();
|
|
5241
|
+
init_remote_storage();
|
|
5242
|
+
STORAGE_TABLES = [
|
|
5243
|
+
"hook_events",
|
|
5244
|
+
"schema_migrations",
|
|
5245
|
+
"_meta",
|
|
5246
|
+
"feedback"
|
|
5247
|
+
];
|
|
5248
|
+
HOOKS_STORAGE_TABLES = STORAGE_TABLES;
|
|
5249
|
+
STORAGE_DATABASE_ENV = [HOOKS_STORAGE_ENV, HOOKS_STORAGE_FALLBACK_ENV];
|
|
5250
|
+
STORAGE_MODE_ENV = [HOOKS_STORAGE_MODE_ENV, HOOKS_STORAGE_MODE_FALLBACK_ENV];
|
|
5251
|
+
PRIMARY_KEYS = {
|
|
5252
|
+
hook_events: ["id"],
|
|
5253
|
+
schema_migrations: ["version"],
|
|
5254
|
+
_meta: ["key"],
|
|
5255
|
+
feedback: ["id"]
|
|
5256
|
+
};
|
|
5257
|
+
});
|
|
5258
|
+
|
|
5259
|
+
// src/storage.ts
|
|
5260
|
+
var exports_storage = {};
|
|
5261
|
+
__export(exports_storage, {
|
|
5262
|
+
storageSync: () => storageSync,
|
|
5263
|
+
storagePush: () => storagePush,
|
|
5264
|
+
storagePull: () => storagePull,
|
|
5265
|
+
runStorageMigrations: () => runStorageMigrations,
|
|
5266
|
+
resolveTables: () => resolveTables,
|
|
5267
|
+
parseStorageTables: () => parseStorageTables,
|
|
5268
|
+
getSyncMetaAll: () => getSyncMetaAll,
|
|
5269
|
+
getStorageStatus: () => getStorageStatus,
|
|
5270
|
+
getStoragePg: () => getStoragePg,
|
|
5271
|
+
getStorageMode: () => getStorageMode,
|
|
5272
|
+
getStorageDatabaseUrl: () => getStorageDatabaseUrl,
|
|
5273
|
+
getStorageDatabaseEnvName: () => getStorageDatabaseEnvName,
|
|
5274
|
+
getStorageDatabaseEnv: () => getStorageDatabaseEnv,
|
|
5275
|
+
STORAGE_TABLES: () => STORAGE_TABLES,
|
|
5276
|
+
STORAGE_MODE_ENV: () => STORAGE_MODE_ENV,
|
|
5277
|
+
STORAGE_DATABASE_ENV: () => STORAGE_DATABASE_ENV,
|
|
5278
|
+
PgAdapterAsync: () => PgAdapterAsync,
|
|
5279
|
+
PG_MIGRATIONS: () => PG_MIGRATIONS,
|
|
5280
|
+
HOOKS_STORAGE_TABLES: () => HOOKS_STORAGE_TABLES,
|
|
5281
|
+
HOOKS_STORAGE_MODE_FALLBACK_ENV: () => HOOKS_STORAGE_MODE_FALLBACK_ENV,
|
|
5282
|
+
HOOKS_STORAGE_MODE_ENV: () => HOOKS_STORAGE_MODE_ENV,
|
|
5283
|
+
HOOKS_STORAGE_FALLBACK_ENV: () => HOOKS_STORAGE_FALLBACK_ENV,
|
|
5284
|
+
HOOKS_STORAGE_ENV: () => HOOKS_STORAGE_ENV
|
|
5285
|
+
});
|
|
5286
|
+
var init_storage = __esm(() => {
|
|
5287
|
+
init_storage_sync();
|
|
5288
|
+
init_remote_storage();
|
|
5289
|
+
init_pg_migrations();
|
|
5290
|
+
});
|
|
5291
|
+
|
|
4541
5292
|
// src/mcp/server.ts
|
|
4542
5293
|
var exports_server = {};
|
|
4543
5294
|
__export(exports_server, {
|
|
@@ -4671,7 +5422,7 @@ function createHooksServer() {
|
|
|
4671
5422
|
const settings = JSON.parse(readFileSync4(settingsPath, "utf-8"));
|
|
4672
5423
|
const eventHooks = settings.hooks?.[meta.event] || [];
|
|
4673
5424
|
const found = eventHooks.some((entry) => entry.hooks?.some((h) => {
|
|
4674
|
-
const match = h.command?.match(/^hooks run (\w+)/);
|
|
5425
|
+
const match = h.command?.match(/^hooks run ([\w-]+)/);
|
|
4675
5426
|
return match && match[1] === name;
|
|
4676
5427
|
}));
|
|
4677
5428
|
if (!found) {
|
|
@@ -4714,8 +5465,10 @@ function createHooksServer() {
|
|
|
4714
5465
|
events: {
|
|
4715
5466
|
PreToolUse: "Fires before a tool executes. Can block the operation.",
|
|
4716
5467
|
PostToolUse: "Fires after a tool executes. Runs asynchronously.",
|
|
4717
|
-
Stop: "Fires when
|
|
4718
|
-
Notification: "Fires on notification events like context compaction."
|
|
5468
|
+
Stop: "Fires when the agent finishes responding. Useful for notifications.",
|
|
5469
|
+
Notification: "Fires on notification events like context compaction.",
|
|
5470
|
+
SessionStart: "Fires when a session starts or resumes. Can inject context.",
|
|
5471
|
+
SessionEnd: "Fires when a session terminates. Useful for cleanup and final announcements."
|
|
4719
5472
|
},
|
|
4720
5473
|
commands: {
|
|
4721
5474
|
install: "hooks install <name>",
|
|
@@ -5129,6 +5882,10 @@ function createHooksServer() {
|
|
|
5129
5882
|
}]
|
|
5130
5883
|
};
|
|
5131
5884
|
});
|
|
5885
|
+
server.tool("storage_status", "Show hooks storage sync configuration and local sync history.", {}, async () => ({ content: [{ type: "text", text: JSON.stringify(getStorageStatus()) }] }));
|
|
5886
|
+
server.tool("storage_push", "Push local hook data to storage PostgreSQL.", { tables: z.array(z.string()).optional() }, async (params) => ({ content: [{ type: "text", text: JSON.stringify(await storagePush(params.tables ? { tables: params.tables } : undefined)) }] }));
|
|
5887
|
+
server.tool("storage_pull", "Pull hook data from storage PostgreSQL to local SQLite.", { tables: z.array(z.string()).optional() }, async (params) => ({ content: [{ type: "text", text: JSON.stringify(await storagePull(params.tables ? { tables: params.tables } : undefined)) }] }));
|
|
5888
|
+
server.tool("storage_sync", "Bidirectional hooks storage sync: pull then push.", { tables: z.array(z.string()).optional() }, async (params) => ({ content: [{ type: "text", text: JSON.stringify(await storageSync(params.tables ? { tables: params.tables } : undefined)) }] }));
|
|
5132
5889
|
server.tool("send_feedback", "Send feedback about this service", {
|
|
5133
5890
|
message: z.string(),
|
|
5134
5891
|
email: z.string().optional(),
|
|
@@ -5236,6 +5993,7 @@ var init_server = __esm(() => {
|
|
|
5236
5993
|
init_registry();
|
|
5237
5994
|
init_installer();
|
|
5238
5995
|
init_profiles();
|
|
5996
|
+
init_storage();
|
|
5239
5997
|
__dirname3 = dirname2(fileURLToPath2(import.meta.url));
|
|
5240
5998
|
pkg = { name: "@hasna/hooks", version: "0.0.0" };
|
|
5241
5999
|
try {
|
|
@@ -5326,29 +6084,83 @@ function getPathValue(input, path) {
|
|
|
5326
6084
|
return;
|
|
5327
6085
|
}, input);
|
|
5328
6086
|
}
|
|
5329
|
-
function
|
|
5330
|
-
const
|
|
5331
|
-
|
|
6087
|
+
function getFieldValues(input, path) {
|
|
6088
|
+
const values = [];
|
|
6089
|
+
const push = (value) => {
|
|
6090
|
+
if (!values.some((item) => Object.is(item, value)))
|
|
6091
|
+
values.push(value);
|
|
6092
|
+
};
|
|
6093
|
+
if (path.includes(".") && path in input)
|
|
6094
|
+
push(input[path]);
|
|
6095
|
+
const nestedValue = getPathValue(input, path);
|
|
6096
|
+
if (nestedValue !== undefined || !path.includes("."))
|
|
6097
|
+
push(nestedValue);
|
|
6098
|
+
return values;
|
|
5332
6099
|
}
|
|
5333
|
-
function
|
|
6100
|
+
function wildcardToRegExp(pattern, options = {}) {
|
|
6101
|
+
let body = "";
|
|
6102
|
+
for (let index = 0;index < pattern.length; index += 1) {
|
|
6103
|
+
const char = pattern[index];
|
|
6104
|
+
if (char === "*") {
|
|
6105
|
+
if (pattern[index + 1] === "*") {
|
|
6106
|
+
body += ".*";
|
|
6107
|
+
index += 1;
|
|
6108
|
+
} else {
|
|
6109
|
+
body += options.segmentSafe ? "[^/]*" : ".*";
|
|
6110
|
+
}
|
|
6111
|
+
} else {
|
|
6112
|
+
body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
6113
|
+
}
|
|
6114
|
+
}
|
|
6115
|
+
return new RegExp(`^${body}$`);
|
|
6116
|
+
}
|
|
6117
|
+
function matchString(value, matcher, options = {}) {
|
|
5334
6118
|
if (matcher === undefined)
|
|
5335
6119
|
return true;
|
|
5336
6120
|
if (value === undefined)
|
|
5337
6121
|
return false;
|
|
5338
6122
|
const matchers = Array.isArray(matcher) ? matcher : [matcher];
|
|
5339
|
-
return matchers.some((item) => wildcardToRegExp(item).test(value));
|
|
6123
|
+
return matchers.some((item) => wildcardToRegExp(item, options).test(value));
|
|
5340
6124
|
}
|
|
5341
6125
|
function matchRecord(input, matcher) {
|
|
5342
6126
|
if (!matcher)
|
|
5343
6127
|
return true;
|
|
5344
6128
|
return Object.entries(matcher).every(([path, expected]) => {
|
|
5345
|
-
const
|
|
5346
|
-
|
|
5347
|
-
return matchString(actual === undefined ? undefined : String(actual), expected);
|
|
5348
|
-
}
|
|
5349
|
-
return actual === expected;
|
|
6129
|
+
const actualValues = getFieldValues(input, path);
|
|
6130
|
+
return matchField(actualValues, expected, path);
|
|
5350
6131
|
});
|
|
5351
6132
|
}
|
|
6133
|
+
function matchField(actualValues, expected, path) {
|
|
6134
|
+
if (isNegativeMatcher(expected)) {
|
|
6135
|
+
return !actualValues.some((actual) => matchPositiveField(actual, expected.not, path));
|
|
6136
|
+
}
|
|
6137
|
+
return actualValues.some((actual) => matchPositiveField(actual, expected, path));
|
|
6138
|
+
}
|
|
6139
|
+
function matchPositiveField(actual, expected, path) {
|
|
6140
|
+
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
6141
|
+
return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
|
|
6142
|
+
segmentSafe: path.endsWith("_path") || path.endsWith(".path")
|
|
6143
|
+
}));
|
|
6144
|
+
}
|
|
6145
|
+
if (Array.isArray(actual)) {
|
|
6146
|
+
return actual.some((item) => item === expected);
|
|
6147
|
+
}
|
|
6148
|
+
return actual === expected;
|
|
6149
|
+
}
|
|
6150
|
+
function stringCandidates(actual) {
|
|
6151
|
+
if (actual === undefined)
|
|
6152
|
+
return [];
|
|
6153
|
+
if (Array.isArray(actual)) {
|
|
6154
|
+
return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
|
|
6155
|
+
}
|
|
6156
|
+
return [String(actual)];
|
|
6157
|
+
}
|
|
6158
|
+
function isPrimitiveFieldValue(value) {
|
|
6159
|
+
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
6160
|
+
}
|
|
6161
|
+
function isNegativeMatcher(value) {
|
|
6162
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
|
|
6163
|
+
}
|
|
5352
6164
|
function eventMatchesFilter(event, filter) {
|
|
5353
6165
|
return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
|
|
5354
6166
|
}
|
|
@@ -5364,6 +6176,13 @@ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
|
5364
6176
|
function getEventsDataDir(override) {
|
|
5365
6177
|
return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
|
|
5366
6178
|
}
|
|
6179
|
+
function getActiveEventsDirEnv() {
|
|
6180
|
+
if (process.env[HASNA_EVENTS_DIR_ENV])
|
|
6181
|
+
return HASNA_EVENTS_DIR_ENV;
|
|
6182
|
+
if (process.env[HASNA_EVENTS_HOME_ENV])
|
|
6183
|
+
return HASNA_EVENTS_HOME_ENV;
|
|
6184
|
+
return null;
|
|
6185
|
+
}
|
|
5367
6186
|
|
|
5368
6187
|
class JsonEventsStore {
|
|
5369
6188
|
dataDir;
|
|
@@ -5476,6 +6295,52 @@ class JsonEventsStore {
|
|
|
5476
6295
|
});
|
|
5477
6296
|
}
|
|
5478
6297
|
}
|
|
6298
|
+
async function getEventsStatus(dataDir) {
|
|
6299
|
+
const store = new JsonEventsStore(dataDir);
|
|
6300
|
+
await store.init();
|
|
6301
|
+
const [channels, events, deliveries] = await Promise.all([
|
|
6302
|
+
store.listChannels(),
|
|
6303
|
+
store.listEvents(),
|
|
6304
|
+
store.listDeliveries()
|
|
6305
|
+
]);
|
|
6306
|
+
const transports = channels.reduce((counts, channel) => {
|
|
6307
|
+
counts[channel.transport] = (counts[channel.transport] ?? 0) + 1;
|
|
6308
|
+
return counts;
|
|
6309
|
+
}, {});
|
|
6310
|
+
return {
|
|
6311
|
+
service: "events",
|
|
6312
|
+
schemaVersion: "1.0",
|
|
6313
|
+
dataDir: store.dataDir,
|
|
6314
|
+
env: {
|
|
6315
|
+
primary: HASNA_EVENTS_DIR_ENV,
|
|
6316
|
+
fallback: HASNA_EVENTS_HOME_ENV,
|
|
6317
|
+
active: getActiveEventsDirEnv()
|
|
6318
|
+
},
|
|
6319
|
+
files: {
|
|
6320
|
+
channels: statusFile(store.dataDir, "channels.json", channels.length),
|
|
6321
|
+
events: statusFile(store.dataDir, "events.json", events.length),
|
|
6322
|
+
deliveries: statusFile(store.dataDir, "deliveries.json", deliveries.length)
|
|
6323
|
+
},
|
|
6324
|
+
counts: {
|
|
6325
|
+
channels: channels.length,
|
|
6326
|
+
enabledChannels: channels.filter((channel) => channel.enabled).length,
|
|
6327
|
+
disabledChannels: channels.filter((channel) => !channel.enabled).length,
|
|
6328
|
+
events: events.length,
|
|
6329
|
+
deliveries: deliveries.length
|
|
6330
|
+
},
|
|
6331
|
+
transports,
|
|
6332
|
+
safety: {
|
|
6333
|
+
includesEventPayloads: false,
|
|
6334
|
+
includesWebhookSecrets: false,
|
|
6335
|
+
listOutputsRedactSecrets: true,
|
|
6336
|
+
statusOutputIsMetadataOnly: true
|
|
6337
|
+
}
|
|
6338
|
+
};
|
|
6339
|
+
}
|
|
6340
|
+
function statusFile(dataDir, fileName, records) {
|
|
6341
|
+
const path = join(dataDir, fileName);
|
|
6342
|
+
return { path, exists: existsSync(path), records };
|
|
6343
|
+
}
|
|
5479
6344
|
var DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
5480
6345
|
function buildSignatureBase(timestamp, body) {
|
|
5481
6346
|
return `${timestamp}.${body}`;
|
|
@@ -5701,7 +6566,7 @@ class EventsClient {
|
|
|
5701
6566
|
}
|
|
5702
6567
|
return deliveries;
|
|
5703
6568
|
}
|
|
5704
|
-
async
|
|
6569
|
+
async matchChannel(id, input = {}) {
|
|
5705
6570
|
const channel = await this.store.getChannel(id);
|
|
5706
6571
|
if (!channel)
|
|
5707
6572
|
throw new Error(`Channel not found: ${id}`);
|
|
@@ -5718,6 +6583,34 @@ class EventsClient {
|
|
|
5718
6583
|
time: input.time,
|
|
5719
6584
|
id: input.id
|
|
5720
6585
|
});
|
|
6586
|
+
const matched = channelMatchesEvent(channel, event);
|
|
6587
|
+
return {
|
|
6588
|
+
channelId: channel.id,
|
|
6589
|
+
matched,
|
|
6590
|
+
event,
|
|
6591
|
+
filters: channel.filters,
|
|
6592
|
+
reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
|
|
6593
|
+
};
|
|
6594
|
+
}
|
|
6595
|
+
async testChannel(id, input = {}, options = {}) {
|
|
6596
|
+
const channel = await this.store.getChannel(id);
|
|
6597
|
+
if (!channel)
|
|
6598
|
+
throw new Error(`Channel not found: ${id}`);
|
|
6599
|
+
const match = await this.matchChannel(id, input);
|
|
6600
|
+
const event = match.event;
|
|
6601
|
+
if (options.honorFilters && !match.matched) {
|
|
6602
|
+
const timestamp = new Date().toISOString();
|
|
6603
|
+
const result2 = createDeliveryResult(event, channel, [{
|
|
6604
|
+
attempt: 1,
|
|
6605
|
+
status: "skipped",
|
|
6606
|
+
startedAt: timestamp,
|
|
6607
|
+
completedAt: timestamp,
|
|
6608
|
+
error: match.reason
|
|
6609
|
+
}]);
|
|
6610
|
+
result2.metadata = { reason: "filter_mismatch" };
|
|
6611
|
+
await this.store.appendDelivery(result2);
|
|
6612
|
+
return result2;
|
|
6613
|
+
}
|
|
5721
6614
|
const eventForChannel = await this.applyRedaction(event, channel);
|
|
5722
6615
|
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
5723
6616
|
await this.store.appendDelivery(result);
|
|
@@ -5828,6 +6721,76 @@ function normalizeRetryPolicy(policy) {
|
|
|
5828
6721
|
multiplier: Math.max(1, policy?.multiplier ?? 2)
|
|
5829
6722
|
};
|
|
5830
6723
|
}
|
|
6724
|
+
function parseFieldMatchers(values, label, typed = false) {
|
|
6725
|
+
if (!values?.length)
|
|
6726
|
+
return;
|
|
6727
|
+
const result = {};
|
|
6728
|
+
for (const value of values) {
|
|
6729
|
+
const parsed = parseMatcherExpression(value, label);
|
|
6730
|
+
const path = parsed.path;
|
|
6731
|
+
if (path in result)
|
|
6732
|
+
throw new Error(`Duplicate ${label} filter path: ${path}`);
|
|
6733
|
+
const matcherValue = typed ? parseTypedMatcherValue(parsed.rawValue, label) : parsed.rawValue;
|
|
6734
|
+
result[path] = parsed.negated ? { not: matcherValue } : matcherValue;
|
|
6735
|
+
}
|
|
6736
|
+
return result;
|
|
6737
|
+
}
|
|
6738
|
+
function parseFilterOptions(options) {
|
|
6739
|
+
const filter2 = {};
|
|
6740
|
+
if (options.source)
|
|
6741
|
+
filter2.source = options.source;
|
|
6742
|
+
if (options.type)
|
|
6743
|
+
filter2.type = options.type;
|
|
6744
|
+
if (options.subject)
|
|
6745
|
+
filter2.subject = options.subject;
|
|
6746
|
+
if (options.severity)
|
|
6747
|
+
filter2.severity = options.severity;
|
|
6748
|
+
const data = mergeMatchers(parseFieldMatchers(options.data, "data"), parseFieldMatchers(options.dataJson, "data-json", true));
|
|
6749
|
+
const metadata = mergeMatchers(parseFieldMatchers(options.metadata, "metadata"), parseFieldMatchers(options.metadataJson, "metadata-json", true));
|
|
6750
|
+
if (Object.keys(data).length > 0)
|
|
6751
|
+
filter2.data = data;
|
|
6752
|
+
if (Object.keys(metadata).length > 0)
|
|
6753
|
+
filter2.metadata = metadata;
|
|
6754
|
+
return Object.keys(filter2).length > 0 ? [filter2] : undefined;
|
|
6755
|
+
}
|
|
6756
|
+
function mergeMatchers(...records) {
|
|
6757
|
+
const result = {};
|
|
6758
|
+
for (const record of records) {
|
|
6759
|
+
if (!record)
|
|
6760
|
+
continue;
|
|
6761
|
+
for (const [path, value] of Object.entries(record)) {
|
|
6762
|
+
if (path in result)
|
|
6763
|
+
throw new Error(`Duplicate filter path: ${path}`);
|
|
6764
|
+
result[path] = value;
|
|
6765
|
+
}
|
|
6766
|
+
}
|
|
6767
|
+
return result;
|
|
6768
|
+
}
|
|
6769
|
+
function parseTypedMatcherValue(value, label) {
|
|
6770
|
+
const parsed = JSON.parse(value);
|
|
6771
|
+
if (parsed === null || typeof parsed === "string" || typeof parsed === "number" || typeof parsed === "boolean" || Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
|
|
6772
|
+
return parsed;
|
|
6773
|
+
}
|
|
6774
|
+
throw new Error(`${label} filter JSON values must be string, string[], number, boolean, or null`);
|
|
6775
|
+
}
|
|
6776
|
+
function parseMatcherExpression(value, label) {
|
|
6777
|
+
const negativeSeparator = value.indexOf("!=");
|
|
6778
|
+
if (negativeSeparator > 0) {
|
|
6779
|
+
return {
|
|
6780
|
+
path: value.slice(0, negativeSeparator),
|
|
6781
|
+
rawValue: value.slice(negativeSeparator + 2),
|
|
6782
|
+
negated: true
|
|
6783
|
+
};
|
|
6784
|
+
}
|
|
6785
|
+
const separator = value.indexOf("=");
|
|
6786
|
+
if (separator <= 0)
|
|
6787
|
+
throw new Error(`Invalid ${label} filter, expected path=value or path!=value: ${value}`);
|
|
6788
|
+
return {
|
|
6789
|
+
path: value.slice(0, separator),
|
|
6790
|
+
rawValue: value.slice(separator + 1),
|
|
6791
|
+
negated: false
|
|
6792
|
+
};
|
|
6793
|
+
}
|
|
5831
6794
|
function parseJsonObject(value, fallback) {
|
|
5832
6795
|
if (!value)
|
|
5833
6796
|
return fallback;
|
|
@@ -5849,18 +6812,6 @@ function parseHeaders(values) {
|
|
|
5849
6812
|
}
|
|
5850
6813
|
return headers;
|
|
5851
6814
|
}
|
|
5852
|
-
function parseFilter(options) {
|
|
5853
|
-
const filter2 = {};
|
|
5854
|
-
if (options.source)
|
|
5855
|
-
filter2.source = options.source;
|
|
5856
|
-
if (options.type)
|
|
5857
|
-
filter2.type = options.type;
|
|
5858
|
-
if (options.subject)
|
|
5859
|
-
filter2.subject = options.subject;
|
|
5860
|
-
if (options.severity)
|
|
5861
|
-
filter2.severity = options.severity;
|
|
5862
|
-
return Object.keys(filter2).length > 0 ? [filter2] : undefined;
|
|
5863
|
-
}
|
|
5864
6815
|
function createClient(options) {
|
|
5865
6816
|
if (options.createClient)
|
|
5866
6817
|
return options.createClient();
|
|
@@ -5878,16 +6829,16 @@ function hasJsonOption(options) {
|
|
|
5878
6829
|
function wantsJson(actionOptions, command) {
|
|
5879
6830
|
return hasJsonOption(actionOptions) || hasJsonOption(command);
|
|
5880
6831
|
}
|
|
5881
|
-
function
|
|
5882
|
-
const
|
|
5883
|
-
|
|
6832
|
+
function registerChannelCommands(program, options) {
|
|
6833
|
+
const channels = program.command(options.channelsCommandName ?? "channels").description("Manage Hasna event channels");
|
|
6834
|
+
channels.command("add").description("Add or replace a channel").argument("<target>", "Webhook URL or command binary").requiredOption("--id <id>", "Channel identifier").option("--transport <kind>", "Transport kind: webhook or command", "webhook").option("--name <name>", "Display name").option("--type <pattern>", "Event type filter, e.g. todos.task.*").option("--source <pattern>", "Event source filter").option("--subject <pattern>", "Event subject filter").option("--severity <pattern>", "Event severity filter").option("--data <path=value...>", "Event data field filter; string values, path!=value negatives, array-member matching, dot paths, * segment wildcard, ** recursive wildcard", collectValues, []).option("--metadata <path=value...>", "Event metadata field filter; string values, path!=value negatives, array-member matching, dot paths, * segment wildcard, ** recursive wildcard", collectValues, []).option("--data-json <path=json...>", "Event data field filter with typed JSON value; path!=json negatives supported", collectValues, []).option("--metadata-json <path=json...>", "Event metadata field filter with typed JSON value; path!=json negatives supported", collectValues, []).option("--secret <secret>", "Webhook HMAC secret").option("--header <name=value...>", "Webhook header", collectValues, []).option("--arg <arg...>", "Command argument", collectValues, []).option("--timeout-ms <ms>", "Transport timeout in milliseconds", parseNumber).option("--retry-attempts <n>", "Maximum delivery attempts", parseNumber).option("--retry-backoff-ms <ms>", "Initial retry backoff in milliseconds", parseNumber).option("--redact <path...>", "Event field path to redact before delivery", collectValues, []).option("--disabled", "Create channel disabled", false).option("-j, --json", "Print JSON output", false).action(async (target, actionOptions, command) => {
|
|
5884
6835
|
const timestamp = new Date().toISOString();
|
|
5885
6836
|
const channel = {
|
|
5886
6837
|
id: actionOptions.id,
|
|
5887
6838
|
name: actionOptions.name,
|
|
5888
6839
|
enabled: !actionOptions.disabled,
|
|
5889
6840
|
transport: actionOptions.transport,
|
|
5890
|
-
filters:
|
|
6841
|
+
filters: parseFilterOptions(actionOptions),
|
|
5891
6842
|
retry: actionOptions.retryAttempts || actionOptions.retryBackoffMs ? { maxAttempts: actionOptions.retryAttempts, backoffMs: actionOptions.retryBackoffMs } : undefined,
|
|
5892
6843
|
redact: actionOptions.redact?.length ? { paths: actionOptions.redact } : undefined,
|
|
5893
6844
|
createdAt: timestamp,
|
|
@@ -5903,35 +6854,51 @@ function registerWebhookCommands(program, options) {
|
|
|
5903
6854
|
const saved = await createClient(options).addChannel(channel);
|
|
5904
6855
|
print(sanitizeChannelForOutput(saved), wantsJson(actionOptions, command), `Added ${saved.transport} channel ${saved.id}`);
|
|
5905
6856
|
});
|
|
5906
|
-
|
|
5907
|
-
const
|
|
6857
|
+
channels.command("list").description("List configured channels").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
|
|
6858
|
+
const channels2 = await createClient(options).listChannels();
|
|
5908
6859
|
if (wantsJson(actionOptions, command)) {
|
|
5909
|
-
console.log(JSON.stringify(sanitizeChannelsForOutput(
|
|
6860
|
+
console.log(JSON.stringify(sanitizeChannelsForOutput(channels2), null, 2));
|
|
5910
6861
|
return;
|
|
5911
6862
|
}
|
|
5912
|
-
if (!
|
|
6863
|
+
if (!channels2.length) {
|
|
5913
6864
|
console.log("No channels configured.");
|
|
5914
6865
|
return;
|
|
5915
6866
|
}
|
|
5916
|
-
for (const channel of
|
|
6867
|
+
for (const channel of channels2) {
|
|
5917
6868
|
console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
|
|
5918
6869
|
}
|
|
5919
6870
|
});
|
|
5920
|
-
|
|
6871
|
+
channels.command("status").description("Show events channel storage status").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
|
|
6872
|
+
const status = await getEventsStatus(options.dataDir);
|
|
6873
|
+
print(status, wantsJson(actionOptions, command), `events dataDir: ${status.dataDir}`);
|
|
6874
|
+
});
|
|
6875
|
+
channels.command("remove").description("Remove a channel").argument("<id>", "Channel identifier").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
|
|
5921
6876
|
const removed = await createClient(options).removeChannel(id);
|
|
5922
6877
|
print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
|
|
5923
6878
|
});
|
|
5924
|
-
|
|
6879
|
+
channels.command("test").description("Send a test event to one channel").argument("<id>", "Channel identifier").option("--source <source>", "Event source override").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("--honor-filters", "Skip delivery when the sample event does not match channel filters", false).option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
|
|
5925
6880
|
const result = await createClient(options).testChannel(id, {
|
|
5926
|
-
source: options.source,
|
|
6881
|
+
source: actionOptions.source ?? options.source,
|
|
5927
6882
|
type: actionOptions.type,
|
|
5928
6883
|
subject: actionOptions.subject ?? id,
|
|
5929
6884
|
message: actionOptions.message,
|
|
5930
|
-
data: parseJsonObject(actionOptions.data, { test: true })
|
|
5931
|
-
|
|
6885
|
+
data: parseJsonObject(actionOptions.data, { test: true }),
|
|
6886
|
+
metadata: parseJsonObject(actionOptions.metadata, {})
|
|
6887
|
+
}, { honorFilters: actionOptions.honorFilters });
|
|
5932
6888
|
print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
|
|
5933
6889
|
});
|
|
5934
|
-
|
|
6890
|
+
channels.command("match").description("Check whether a sample event matches one channel without delivering").argument("<id>", "Channel identifier").option("--source <source>", "Event source override").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events match preview").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
|
|
6891
|
+
const result = await createClient(options).matchChannel(id, {
|
|
6892
|
+
source: actionOptions.source ?? options.source,
|
|
6893
|
+
type: actionOptions.type,
|
|
6894
|
+
subject: actionOptions.subject ?? id,
|
|
6895
|
+
message: actionOptions.message,
|
|
6896
|
+
data: parseJsonObject(actionOptions.data, { test: true }),
|
|
6897
|
+
metadata: parseJsonObject(actionOptions.metadata, {})
|
|
6898
|
+
});
|
|
6899
|
+
print(result, wantsJson(actionOptions, command), `${result.matched ? "matched" : "skipped"}: ${result.channelId}`);
|
|
6900
|
+
});
|
|
6901
|
+
return channels;
|
|
5935
6902
|
}
|
|
5936
6903
|
function registerEventCommands(program, options) {
|
|
5937
6904
|
const events = program.command(options.eventsCommandName ?? "events").description("Emit, list, and replay Hasna events");
|
|
@@ -5979,7 +6946,7 @@ function registerEventCommands(program, options) {
|
|
|
5979
6946
|
return events;
|
|
5980
6947
|
}
|
|
5981
6948
|
function registerEventsCommands(program, options) {
|
|
5982
|
-
|
|
6949
|
+
registerChannelCommands(program, options);
|
|
5983
6950
|
registerEventCommands(program, options);
|
|
5984
6951
|
}
|
|
5985
6952
|
function parseNumber(value) {
|
|
@@ -7204,6 +8171,8 @@ function resolveScope(options) {
|
|
|
7204
8171
|
function resolveTarget(options) {
|
|
7205
8172
|
if (options.target === "gemini")
|
|
7206
8173
|
return "gemini";
|
|
8174
|
+
if (options.target === "codewith")
|
|
8175
|
+
return "codewith";
|
|
7207
8176
|
if (options.target === "all")
|
|
7208
8177
|
return "all";
|
|
7209
8178
|
return "claude";
|
|
@@ -7302,7 +8271,7 @@ program2.command("run").argument("<hook>", "Hook to run").option("--profile <id>
|
|
|
7302
8271
|
process.stderr.write(stderr);
|
|
7303
8272
|
process.exit(exitCode);
|
|
7304
8273
|
});
|
|
7305
|
-
program2.command("install").alias("add").argument("[hooks...]", "Hooks to install").option("-o, --overwrite", "Overwrite existing hooks", false).option("-a, --all", "Install all available hooks", false).option("-c, --category <category>", "Install all hooks in a category").option("-g, --global", "Install globally (~/.claude/settings.json)", false).option("-p, --project", "Install for current project (.claude/settings.json)", false).option("-t, --target <target>", "Agent target: claude, gemini, all (default: claude)", "claude").option("--profile <id>", "Agent profile ID to scope hooks to").option("--dry-run", "Preview what would be installed without writing to settings", false).option("-j, --json", "Output as JSON", false).description("Install one or more hooks").action((hooks, options) => {
|
|
8274
|
+
program2.command("install").alias("add").argument("[hooks...]", "Hooks to install").option("-o, --overwrite", "Overwrite existing hooks", false).option("-a, --all", "Install all available hooks", false).option("-c, --category <category>", "Install all hooks in a category").option("-g, --global", "Install globally (~/.claude/settings.json)", false).option("-p, --project", "Install for current project (.claude/settings.json)", false).option("-t, --target <target>", "Agent target: claude, gemini, codewith, all (default: claude)", "claude").option("--profile <id>", "Agent profile ID to scope hooks to").option("--dry-run", "Preview what would be installed without writing to settings", false).option("--apply-codewith", "Explicitly append Codewith TOML to a config file (prefer open-configs for managed configs)", false).option("--codewith-config <path>", "Explicit Codewith config path required with --apply-codewith").option("-j, --json", "Output as JSON", false).description("Install one or more hooks").action((hooks, options) => {
|
|
7306
8275
|
const scope = resolveScope(options);
|
|
7307
8276
|
const target = resolveTarget(options);
|
|
7308
8277
|
let toInstall = hooks;
|
|
@@ -7325,11 +8294,20 @@ program2.command("install").alias("add").argument("[hooks...]", "Hooks to instal
|
|
|
7325
8294
|
render(/* @__PURE__ */ jsxDEV8(App, {}, undefined, false, undefined, this));
|
|
7326
8295
|
return;
|
|
7327
8296
|
}
|
|
8297
|
+
if (options.applyCodewith && (target === "codewith" || target === "all") && !options.codewithConfig) {
|
|
8298
|
+
const message = "--apply-codewith requires --codewith-config <path>; refusing to write default ~/.codewith/config.toml.";
|
|
8299
|
+
if (options.json) {
|
|
8300
|
+
console.log(JSON.stringify({ error: message, scope, target, applied: false }));
|
|
8301
|
+
} else {
|
|
8302
|
+
console.log(chalk2.red(message));
|
|
8303
|
+
}
|
|
8304
|
+
return;
|
|
8305
|
+
}
|
|
7328
8306
|
if (options.dryRun) {
|
|
7329
8307
|
const known = toInstall.filter((n) => getHook(n));
|
|
7330
8308
|
const unknown = toInstall.filter((n) => !getHook(n));
|
|
7331
8309
|
if (options.json) {
|
|
7332
|
-
console.log(JSON.stringify({ dryRun: true, would_install: known, unknown, scope, target }));
|
|
8310
|
+
console.log(JSON.stringify({ dryRun: true, would_install: known, unknown, scope, target, mode: target === "codewith" ? "fragment" : "write" }));
|
|
7333
8311
|
return;
|
|
7334
8312
|
}
|
|
7335
8313
|
console.log(chalk2.bold(`
|
|
@@ -7356,21 +8334,30 @@ Dry run \u2014 would install (${scope}, ${target}):
|
|
|
7356
8334
|
results.push({ hook: name, success: false, error: `Hook '${name}' not found${hint}` });
|
|
7357
8335
|
continue;
|
|
7358
8336
|
}
|
|
7359
|
-
const result = installHook(name, {
|
|
8337
|
+
const result = installHook(name, {
|
|
8338
|
+
scope,
|
|
8339
|
+
overwrite: options.overwrite,
|
|
8340
|
+
target,
|
|
8341
|
+
profile: options.profile,
|
|
8342
|
+
codewithMode: options.applyCodewith ? "write" : "fragment",
|
|
8343
|
+
codewithConfigPath: options.codewithConfig
|
|
8344
|
+
});
|
|
7360
8345
|
results.push(result);
|
|
7361
8346
|
}
|
|
7362
8347
|
if (options.json) {
|
|
7363
8348
|
console.log(JSON.stringify({
|
|
7364
8349
|
installed: results.filter((r) => r.success).map((r) => r.hook),
|
|
7365
8350
|
failed: results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error })),
|
|
8351
|
+
fragments: results.filter((r) => r.success && r.fragment).map((r) => ({ hook: r.hook, fragment: r.fragment, applied: r.applied, configPath: r.configPath, note: r.note })),
|
|
7366
8352
|
total: results.length,
|
|
7367
8353
|
success: results.filter((r) => r.success).length,
|
|
7368
8354
|
scope,
|
|
7369
|
-
target
|
|
8355
|
+
target,
|
|
8356
|
+
applied: results.some((r) => r.applied)
|
|
7370
8357
|
}));
|
|
7371
8358
|
return;
|
|
7372
8359
|
}
|
|
7373
|
-
const settingsFile = scope === "project" ? ".claude/settings.json" : "~/.claude/settings.json";
|
|
8360
|
+
const settingsFile = target === "codewith" ? options.applyCodewith ? options.codewithConfig : "TOML fragment only (open-configs should apply)" : scope === "project" ? ".claude/settings.json" : "~/.claude/settings.json";
|
|
7374
8361
|
console.log(chalk2.bold(`
|
|
7375
8362
|
Installing hooks (${scope}, ${target})...
|
|
7376
8363
|
`));
|
|
@@ -7384,6 +8371,14 @@ Installing hooks (${scope}, ${target})...
|
|
|
7384
8371
|
if (result.conflict) {
|
|
7385
8372
|
console.log(chalk2.yellow(` \u26A0 Warning: ${result.conflict}`));
|
|
7386
8373
|
}
|
|
8374
|
+
if (result.fragment && target === "codewith") {
|
|
8375
|
+
console.log(chalk2.dim(" Codewith TOML fragment:"));
|
|
8376
|
+
console.log(chalk2.cyan(result.fragment.trimEnd().split(`
|
|
8377
|
+
`).map((line) => ` ${line}`).join(`
|
|
8378
|
+
`)));
|
|
8379
|
+
if (result.note)
|
|
8380
|
+
console.log(chalk2.yellow(` \u26A0 ${result.note}`));
|
|
8381
|
+
}
|
|
7387
8382
|
} else {
|
|
7388
8383
|
console.log(chalk2.red(`\u2717 ${result.hook}: ${result.error}`));
|
|
7389
8384
|
}
|
|
@@ -7391,10 +8386,10 @@ Installing hooks (${scope}, ${target})...
|
|
|
7391
8386
|
console.log(chalk2.dim(`
|
|
7392
8387
|
Registered in ${settingsFile}`));
|
|
7393
8388
|
});
|
|
7394
|
-
program2.command("list").alias("ls").option("-c, --category <category>", "Filter by category").option("-a, --all", "Show all available hooks", false).option("-i, --installed", "Show only installed hooks", false).option("-r, --registered", "Show registered hooks", false).option("-g, --global", "Check global settings", false).option("-p, --project", "Check project settings", false).option("-t, --target <target>", "Agent target: claude, gemini (default: claude)", "claude").option("-j, --json", "Output as JSON", false).description("List available or installed hooks").action((options) => {
|
|
8389
|
+
program2.command("list").alias("ls").option("-c, --category <category>", "Filter by category").option("-a, --all", "Show all available hooks", false).option("-i, --installed", "Show only installed hooks", false).option("-r, --registered", "Show registered hooks", false).option("-g, --global", "Check global settings", false).option("-p, --project", "Check project settings", false).option("-t, --target <target>", "Agent target: claude, gemini, codewith (default: claude)", "claude").option("-j, --json", "Output as JSON", false).description("List available or installed hooks").action((options) => {
|
|
7395
8390
|
const scope = resolveScope(options);
|
|
7396
8391
|
if (options.registered || options.installed) {
|
|
7397
|
-
const target = options.target === "gemini" ? "gemini" : "claude";
|
|
8392
|
+
const target = options.target === "gemini" ? "gemini" : options.target === "codewith" ? "codewith" : "claude";
|
|
7398
8393
|
const registered = getRegisteredHooksForTarget(scope, target);
|
|
7399
8394
|
if (options.json) {
|
|
7400
8395
|
console.log(JSON.stringify(registered.map((name) => {
|
|
@@ -7478,7 +8473,7 @@ Found ${results.length} hook(s):
|
|
|
7478
8473
|
console.log(` ${h.description}`);
|
|
7479
8474
|
}
|
|
7480
8475
|
});
|
|
7481
|
-
program2.command("remove").alias("rm").argument("<hook>", "Hook to remove").option("-g, --global", "Remove from global settings", false).option("-p, --project", "Remove from project settings", false).option("-t, --target <target>", "Agent target: claude, gemini, all (default: claude)", "claude").option("-j, --json", "Output as JSON", false).description("Remove an installed hook").action((hook, options) => {
|
|
8476
|
+
program2.command("remove").alias("rm").argument("<hook>", "Hook to remove").option("-g, --global", "Remove from global settings", false).option("-p, --project", "Remove from project settings", false).option("-t, --target <target>", "Agent target: claude, gemini, codewith, all (default: claude)", "claude").option("-j, --json", "Output as JSON", false).description("Remove an installed hook").action((hook, options) => {
|
|
7482
8477
|
const scope = resolveScope(options);
|
|
7483
8478
|
const target = resolveTarget(options);
|
|
7484
8479
|
if (!getHook(hook)) {
|
|
@@ -7589,7 +8584,7 @@ program2.command("doctor").option("-g, --global", "Check global settings", false
|
|
|
7589
8584
|
const settings = JSON.parse(readFileSync5(settingsPath, "utf-8"));
|
|
7590
8585
|
const eventHooks = settings.hooks?.[meta.event] || [];
|
|
7591
8586
|
const found = eventHooks.some((entry) => entry.hooks?.some((h) => {
|
|
7592
|
-
const match = h.command?.match(/^hooks run (\w+)/);
|
|
8587
|
+
const match = h.command?.match(/^hooks run ([\w-]+)/);
|
|
7593
8588
|
return match && match[1] === name;
|
|
7594
8589
|
}));
|
|
7595
8590
|
if (!found) {
|
|
@@ -7720,14 +8715,18 @@ ${meta.displayName} v${meta.version}
|
|
|
7720
8715
|
const generalDocs = {
|
|
7721
8716
|
overview: "Hooks are scripts that run at specific points in an AI coding agent session. Install @hasna/hooks globally, then register hooks \u2014 no files are copied to your project.",
|
|
7722
8717
|
events: {
|
|
8718
|
+
SessionStart: "Fires when a session starts or resumes. Codewith can inject context via hookSpecificOutput.additionalContext.",
|
|
8719
|
+
UserPromptSubmit: "Codewith-native event when a user prompt is submitted; can block obvious injection attempts.",
|
|
7723
8720
|
PreToolUse: 'Fires before a tool executes. Can block the operation by returning { "decision": "block" }.',
|
|
7724
8721
|
PostToolUse: "Fires after a tool executes. Runs asynchronously, cannot block.",
|
|
7725
|
-
Stop: "Fires when
|
|
7726
|
-
Notification: "Fires on notification events like context compaction."
|
|
8722
|
+
Stop: "Fires at turn end in Codewith and when other agents finish responding. Useful for notifications and cleanup.",
|
|
8723
|
+
Notification: "Fires on notification events like context compaction.",
|
|
8724
|
+
SessionEnd: "Fires when a session terminates. Useful for cleanup and final announcements."
|
|
7727
8725
|
},
|
|
7728
8726
|
installation: {
|
|
7729
8727
|
global: "hooks install gitguard",
|
|
7730
8728
|
project: "hooks install gitguard --project",
|
|
8729
|
+
codewith: "hooks install session-start --target codewith # emits TOML for open-configs to apply",
|
|
7731
8730
|
category: 'hooks install --category "Git Safety"',
|
|
7732
8731
|
all: "hooks install --all"
|
|
7733
8732
|
},
|
|
@@ -7743,7 +8742,7 @@ ${meta.displayName} v${meta.version}
|
|
|
7743
8742
|
},
|
|
7744
8743
|
howItWorks: {
|
|
7745
8744
|
install: "bun install -g @hasna/hooks",
|
|
7746
|
-
register: "hooks install gitguard \u2192 writes to ~/.claude/settings.json",
|
|
8745
|
+
register: "hooks install gitguard \u2192 writes to ~/.claude/settings.json; hooks install session-start --target codewith emits a TOML fragment",
|
|
7747
8746
|
execution: "Agent runs 'hooks run gitguard' \u2192 executes hook from global package",
|
|
7748
8747
|
noFileCopy: "No files are copied to your project. Hooks run from the global @hasna/hooks package."
|
|
7749
8748
|
}
|
|
@@ -8050,6 +9049,80 @@ logCmd.command("clear").description("Delete hook event logs").option("--hook <na
|
|
|
8050
9049
|
}
|
|
8051
9050
|
console.log(chalk2.green(`\u2713 Cleared ${count} event(s).`));
|
|
8052
9051
|
});
|
|
9052
|
+
var storageCmd = program2.command("storage").description("Sync local hook data with storage PostgreSQL");
|
|
9053
|
+
storageCmd.command("status").description("Show storage sync status").option("-j, --json", "Output as JSON", false).action(async (options) => {
|
|
9054
|
+
const { getStorageStatus: getStorageStatus2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
|
|
9055
|
+
const status = getStorageStatus2();
|
|
9056
|
+
if (options.json) {
|
|
9057
|
+
console.log(JSON.stringify(status, null, 2));
|
|
9058
|
+
return;
|
|
9059
|
+
}
|
|
9060
|
+
console.log(chalk2.bold(`
|
|
9061
|
+
Storage Status
|
|
9062
|
+
`));
|
|
9063
|
+
console.log(` Configured: ${status.configured ? chalk2.green(`yes (${status.activeEnv})`) : chalk2.red("no")}`);
|
|
9064
|
+
console.log(` Mode: ${status.mode}`);
|
|
9065
|
+
console.log(` Tables: ${status.tables.join(", ")}`);
|
|
9066
|
+
console.log(` Sync rows: ${status.sync.length}`);
|
|
9067
|
+
});
|
|
9068
|
+
storageCmd.command("push").description("Push local hook data to storage PostgreSQL").option("-t, --tables <tables>", "Comma-separated table names").option("-j, --json", "Output as JSON", false).action(async (options) => {
|
|
9069
|
+
try {
|
|
9070
|
+
const { parseStorageTables: parseStorageTables2, storagePush: storagePush2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
|
|
9071
|
+
const results = await storagePush2({ tables: parseStorageTables2(options.tables) });
|
|
9072
|
+
if (options.json) {
|
|
9073
|
+
console.log(JSON.stringify(results, null, 2));
|
|
9074
|
+
return;
|
|
9075
|
+
}
|
|
9076
|
+
const written = results.reduce((sum, result) => sum + result.rowsWritten, 0);
|
|
9077
|
+
console.log(chalk2.green(`\u2713 Pushed ${written} row(s)`));
|
|
9078
|
+
} catch (error) {
|
|
9079
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
9080
|
+
if (options.json)
|
|
9081
|
+
console.log(JSON.stringify({ error: message }));
|
|
9082
|
+
else
|
|
9083
|
+
console.error(chalk2.red(`\u2717 ${message}`));
|
|
9084
|
+
process.exitCode = 1;
|
|
9085
|
+
}
|
|
9086
|
+
});
|
|
9087
|
+
storageCmd.command("pull").description("Pull hook data from storage PostgreSQL to local SQLite").option("-t, --tables <tables>", "Comma-separated table names").option("-j, --json", "Output as JSON", false).action(async (options) => {
|
|
9088
|
+
try {
|
|
9089
|
+
const { parseStorageTables: parseStorageTables2, storagePull: storagePull2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
|
|
9090
|
+
const results = await storagePull2({ tables: parseStorageTables2(options.tables) });
|
|
9091
|
+
if (options.json) {
|
|
9092
|
+
console.log(JSON.stringify(results, null, 2));
|
|
9093
|
+
return;
|
|
9094
|
+
}
|
|
9095
|
+
const written = results.reduce((sum, result) => sum + result.rowsWritten, 0);
|
|
9096
|
+
console.log(chalk2.green(`\u2713 Pulled ${written} row(s)`));
|
|
9097
|
+
} catch (error) {
|
|
9098
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
9099
|
+
if (options.json)
|
|
9100
|
+
console.log(JSON.stringify({ error: message }));
|
|
9101
|
+
else
|
|
9102
|
+
console.error(chalk2.red(`\u2717 ${message}`));
|
|
9103
|
+
process.exitCode = 1;
|
|
9104
|
+
}
|
|
9105
|
+
});
|
|
9106
|
+
storageCmd.command("sync").description("Bidirectional storage sync: pull then push").option("-t, --tables <tables>", "Comma-separated table names").option("-j, --json", "Output as JSON", false).action(async (options) => {
|
|
9107
|
+
try {
|
|
9108
|
+
const { parseStorageTables: parseStorageTables2, storageSync: storageSync2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
|
|
9109
|
+
const result = await storageSync2({ tables: parseStorageTables2(options.tables) });
|
|
9110
|
+
if (options.json) {
|
|
9111
|
+
console.log(JSON.stringify(result, null, 2));
|
|
9112
|
+
return;
|
|
9113
|
+
}
|
|
9114
|
+
const pulled = result.pull.reduce((sum, entry) => sum + entry.rowsWritten, 0);
|
|
9115
|
+
const pushed = result.push.reduce((sum, entry) => sum + entry.rowsWritten, 0);
|
|
9116
|
+
console.log(chalk2.green(`\u2713 Synced ${pulled} pulled row(s), ${pushed} pushed row(s)`));
|
|
9117
|
+
} catch (error) {
|
|
9118
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
9119
|
+
if (options.json)
|
|
9120
|
+
console.log(JSON.stringify({ error: message }));
|
|
9121
|
+
else
|
|
9122
|
+
console.error(chalk2.red(`\u2717 ${message}`));
|
|
9123
|
+
process.exitCode = 1;
|
|
9124
|
+
}
|
|
9125
|
+
});
|
|
8053
9126
|
program2.command("mcp").option("-s, --stdio", "Use stdio transport (one process per agent)", false).option("--sse", "Use legacy SSE transport (port 39427)", false).option("--http", "Use Streamable HTTP transport (explicit; this is also the default)", false).option("-p, --port <port>", "Port for HTTP/SSE transport (defaults to 8847 for HTTP, 39427 for SSE)").description("Start MCP server for AI agent integration (default: shared Streamable HTTP)").action(async (options) => {
|
|
8054
9127
|
if (options.stdio) {
|
|
8055
9128
|
const { startStdioServer: startStdioServer2 } = await Promise.resolve().then(() => (init_server(), exports_server));
|