@hasna/hooks 0.2.20 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -2
- package/bin/index.js +1205 -93
- package/dist/db/migrations/002_session_events.d.ts +10 -0
- package/dist/db/migrations/003_user_prompt_submit_event.d.ts +10 -0
- package/dist/db/pg-migrations.d.ts +1 -1
- package/dist/db/remote-storage.d.ts +9 -0
- package/dist/db/schema.d.ts +4 -3
- package/dist/db/storage-sync.d.ts +57 -0
- package/dist/index.d.ts +16 -3
- package/dist/index.js +1092 -67
- package/dist/lib/installer.d.ts +26 -5
- package/dist/lib/registry.d.ts +5 -1
- package/dist/storage.d.ts +4 -0
- package/dist/storage.js +696 -0
- package/hooks/codewith-native-common.test.ts +61 -0
- package/hooks/codewith-native-common.ts +478 -0
- package/hooks/hook-agent-rules-version-check/LICENSE +191 -0
- package/hooks/hook-agent-rules-version-check/README.md +50 -0
- package/hooks/hook-agent-rules-version-check/package.json +53 -0
- package/hooks/hook-agent-rules-version-check/src/hook.test.ts +104 -0
- package/hooks/hook-agent-rules-version-check/src/hook.ts +215 -0
- package/hooks/hook-agent-rules-version-check/tsconfig.json +25 -0
- package/hooks/hook-announce-start/README.md +7 -5
- package/hooks/hook-announce-start/package.json +2 -2
- package/hooks/hook-announce-start/src/hook.ts +33 -14
- package/hooks/hook-fleet-blockers-gate/LICENSE +191 -0
- package/hooks/hook-fleet-blockers-gate/README.md +39 -0
- package/hooks/hook-fleet-blockers-gate/package.json +52 -0
- package/hooks/hook-fleet-blockers-gate/src/hook.test.ts +106 -0
- package/hooks/hook-fleet-blockers-gate/src/hook.ts +242 -0
- package/hooks/hook-fleet-blockers-gate/tsconfig.json +25 -0
- package/hooks/hook-fleet-catchup/LICENSE +191 -0
- package/hooks/hook-fleet-catchup/README.md +40 -0
- package/hooks/hook-fleet-catchup/package.json +52 -0
- package/hooks/hook-fleet-catchup/src/hook.test.ts +156 -0
- package/hooks/hook-fleet-catchup/src/hook.ts +283 -0
- package/hooks/hook-fleet-catchup/tsconfig.json +25 -0
- package/hooks/hook-knowledge-context/README.md +53 -0
- package/hooks/hook-knowledge-context/package.json +52 -0
- package/hooks/hook-knowledge-context/src/hook.test.ts +168 -0
- package/hooks/hook-knowledge-context/src/hook.ts +355 -0
- package/hooks/hook-knowledge-context/tsconfig.json +25 -0
- package/hooks/pre-bash/README.md +17 -0
- package/hooks/pre-bash/package.json +12 -0
- package/hooks/pre-bash/src/hook.ts +103 -0
- package/hooks/prompt-guard/README.md +17 -0
- package/hooks/prompt-guard/package.json +12 -0
- package/hooks/prompt-guard/src/hook.ts +76 -0
- package/hooks/session-start/README.md +17 -0
- package/hooks/session-start/package.json +12 -0
- package/hooks/session-start/src/hook.ts +150 -0
- package/hooks/stop-sync/README.md +17 -0
- package/hooks/stop-sync/package.json +12 -0
- package/hooks/stop-sync/src/hook.ts +69 -0
- package/hooks/worktree-guard/README.md +17 -0
- package/hooks/worktree-guard/package.json +12 -0
- package/hooks/worktree-guard/src/hook.ts +88 -0
- package/package.json +14 -6
- package/dist/cli/components/App.d.ts +0 -7
- package/dist/cli/components/CategorySelect.d.ts +0 -7
- package/dist/cli/components/DataTable.d.ts +0 -11
- package/dist/cli/components/Header.d.ts +0 -7
- package/dist/cli/components/HookSelect.d.ts +0 -11
- package/dist/cli/components/InstallProgress.d.ts +0 -9
- package/dist/cli/components/SearchView.d.ts +0 -9
- package/dist/cli/index.d.ts +0 -2
- package/dist/lib/db-writer.d.ts +0 -9
- package/dist/mcp/http.d.ts +0 -16
- package/dist/mcp/server.d.ts +0 -17
package/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;
|
|
@@ -1890,6 +1890,9 @@ var require_commander = __commonJS((exports) => {
|
|
|
1890
1890
|
function getHooksByCategory(category) {
|
|
1891
1891
|
return HOOKS.filter((h) => h.category === category);
|
|
1892
1892
|
}
|
|
1893
|
+
function getHookEvents(hook) {
|
|
1894
|
+
return hook.events && hook.events.length > 0 ? hook.events : [hook.event];
|
|
1895
|
+
}
|
|
1893
1896
|
function searchHooks(query) {
|
|
1894
1897
|
const q = query.toLowerCase();
|
|
1895
1898
|
return HOOKS.filter((h) => h.name.toLowerCase().includes(q) || h.displayName.toLowerCase().includes(q) || h.description.toLowerCase().includes(q) || h.tags.some((t) => t.includes(q)));
|
|
@@ -1942,6 +1945,16 @@ var init_registry = __esm(() => {
|
|
|
1942
1945
|
matcher: "Write|Edit|NotebookEdit",
|
|
1943
1946
|
tags: ["git", "snapshot", "rollback", "backup"]
|
|
1944
1947
|
},
|
|
1948
|
+
{
|
|
1949
|
+
name: "worktree-guard",
|
|
1950
|
+
displayName: "Worktree Guard",
|
|
1951
|
+
description: "Warns on feature work outside managed repos worktrees and blocks commit/push from shared checkouts",
|
|
1952
|
+
version: "0.1.0",
|
|
1953
|
+
category: "Git Safety",
|
|
1954
|
+
event: "PreToolUse",
|
|
1955
|
+
matcher: "Bash|Write|Edit|MultiEdit|NotebookEdit",
|
|
1956
|
+
tags: ["git", "worktree", "repos", "multi-agent", "safety"]
|
|
1957
|
+
},
|
|
1945
1958
|
{
|
|
1946
1959
|
name: "checktests",
|
|
1947
1960
|
displayName: "Check Tests",
|
|
@@ -2022,6 +2035,16 @@ var init_registry = __esm(() => {
|
|
|
2022
2035
|
matcher: "Bash",
|
|
2023
2036
|
tags: ["npm", "packages", "typosquatting", "supply-chain"]
|
|
2024
2037
|
},
|
|
2038
|
+
{
|
|
2039
|
+
name: "pre-bash",
|
|
2040
|
+
displayName: "Pre Bash",
|
|
2041
|
+
description: "Codewith Bash gate for staged secrets scans before commit/push and comms rechecks before risky operations",
|
|
2042
|
+
version: "0.1.0",
|
|
2043
|
+
category: "Security",
|
|
2044
|
+
event: "PreToolUse",
|
|
2045
|
+
matcher: "Bash",
|
|
2046
|
+
tags: ["codewith", "bash", "secrets", "gitleaks", "risky-ops"]
|
|
2047
|
+
},
|
|
2025
2048
|
{
|
|
2026
2049
|
name: "phonenotify",
|
|
2027
2050
|
displayName: "Phone Notify",
|
|
@@ -2062,6 +2085,17 @@ var init_registry = __esm(() => {
|
|
|
2062
2085
|
matcher: "",
|
|
2063
2086
|
tags: ["context", "compaction", "state", "backup"]
|
|
2064
2087
|
},
|
|
2088
|
+
{
|
|
2089
|
+
name: "knowledge-context",
|
|
2090
|
+
displayName: "Knowledge Context",
|
|
2091
|
+
description: "Injects deterministic Knowledge context packs into Codewith SessionStart, UserPromptSubmit, and SubagentStart",
|
|
2092
|
+
version: "0.1.0",
|
|
2093
|
+
category: "Context Management",
|
|
2094
|
+
event: "SessionStart",
|
|
2095
|
+
events: ["SessionStart", "UserPromptSubmit", "SubagentStart"],
|
|
2096
|
+
matcher: "",
|
|
2097
|
+
tags: ["knowledge", "context", "codewith", "deterministic", "session-start", "prompt", "subagent"]
|
|
2098
|
+
},
|
|
2065
2099
|
{
|
|
2066
2100
|
name: "autoformat",
|
|
2067
2101
|
displayName: "Auto Format",
|
|
@@ -2132,6 +2166,16 @@ var init_registry = __esm(() => {
|
|
|
2132
2166
|
matcher: "",
|
|
2133
2167
|
tags: ["prompt", "injection", "security", "validation", "guard"]
|
|
2134
2168
|
},
|
|
2169
|
+
{
|
|
2170
|
+
name: "prompt-guard",
|
|
2171
|
+
displayName: "Prompt Guard (Codewith)",
|
|
2172
|
+
description: "Codewith UserPromptSubmit guard for obvious pasted prompt-injection and fake policy/freeze instructions",
|
|
2173
|
+
version: "0.1.0",
|
|
2174
|
+
category: "Permissions",
|
|
2175
|
+
event: "UserPromptSubmit",
|
|
2176
|
+
matcher: "",
|
|
2177
|
+
tags: ["codewith", "prompt", "injection", "policy", "freeze"]
|
|
2178
|
+
},
|
|
2135
2179
|
{
|
|
2136
2180
|
name: "desktopnotify",
|
|
2137
2181
|
displayName: "Desktop Notify",
|
|
@@ -2222,6 +2266,26 @@ var init_registry = __esm(() => {
|
|
|
2222
2266
|
matcher: "",
|
|
2223
2267
|
tags: ["tasks", "completion", "gate", "quality", "agent-teams"]
|
|
2224
2268
|
},
|
|
2269
|
+
{
|
|
2270
|
+
name: "session-start",
|
|
2271
|
+
displayName: "Session Start",
|
|
2272
|
+
description: "Codewith SessionStart digest for blockers, announcements, identity registration, and heartbeat context",
|
|
2273
|
+
version: "0.1.0",
|
|
2274
|
+
category: "Agent Teams",
|
|
2275
|
+
event: "SessionStart",
|
|
2276
|
+
matcher: "",
|
|
2277
|
+
tags: ["codewith", "session", "context", "conversations", "heartbeat"]
|
|
2278
|
+
},
|
|
2279
|
+
{
|
|
2280
|
+
name: "stop-sync",
|
|
2281
|
+
displayName: "Stop Sync",
|
|
2282
|
+
description: "Codewith Stop turn-end heartbeat and best-effort task evidence helper",
|
|
2283
|
+
version: "0.1.0",
|
|
2284
|
+
category: "Agent Teams",
|
|
2285
|
+
event: "Stop",
|
|
2286
|
+
matcher: "",
|
|
2287
|
+
tags: ["codewith", "stop", "heartbeat", "todos", "turn-end"]
|
|
2288
|
+
},
|
|
2225
2289
|
{
|
|
2226
2290
|
name: "typecheck-gate",
|
|
2227
2291
|
displayName: "Typecheck Gate",
|
|
@@ -2275,10 +2339,10 @@ var init_registry = __esm(() => {
|
|
|
2275
2339
|
{
|
|
2276
2340
|
name: "announce-start",
|
|
2277
2341
|
displayName: "Announce Start",
|
|
2278
|
-
description: "On
|
|
2279
|
-
version: "0.
|
|
2342
|
+
description: "On SessionStart, auto-registers agent, reads messages, and announces to space",
|
|
2343
|
+
version: "0.2.0",
|
|
2280
2344
|
category: "Agent Teams",
|
|
2281
|
-
event: "
|
|
2345
|
+
event: "SessionStart",
|
|
2282
2346
|
matcher: "",
|
|
2283
2347
|
tags: ["announcement", "start", "register", "messages", "agent-teams"]
|
|
2284
2348
|
},
|
|
@@ -2301,6 +2365,36 @@ var init_registry = __esm(() => {
|
|
|
2301
2365
|
event: "Notification",
|
|
2302
2366
|
matcher: "",
|
|
2303
2367
|
tags: ["messages", "dm", "context", "inject", "agent-teams"]
|
|
2368
|
+
},
|
|
2369
|
+
{
|
|
2370
|
+
name: "fleet-catchup",
|
|
2371
|
+
displayName: "Fleet Catchup",
|
|
2372
|
+
description: "On SessionStart, injects unread blockers, channel notifications, and the announcements digest into context (deterministic CLI reads, fail-open)",
|
|
2373
|
+
version: "0.1.0",
|
|
2374
|
+
category: "Agent Teams",
|
|
2375
|
+
event: "SessionStart",
|
|
2376
|
+
matcher: "",
|
|
2377
|
+
tags: ["fleet", "catchup", "blockers", "announcements", "context", "agent-teams"]
|
|
2378
|
+
},
|
|
2379
|
+
{
|
|
2380
|
+
name: "agent-rules-version-check",
|
|
2381
|
+
displayName: "Agent Rules Version Check",
|
|
2382
|
+
description: "On SessionStart, compares the rendered hasna:agent-operating-rules sentinel version against configs state and warns on mismatch",
|
|
2383
|
+
version: "0.1.0",
|
|
2384
|
+
category: "Agent Teams",
|
|
2385
|
+
event: "SessionStart",
|
|
2386
|
+
matcher: "",
|
|
2387
|
+
tags: ["fleet", "rules", "version", "sentinel", "configs", "drift", "agent-teams"]
|
|
2388
|
+
},
|
|
2389
|
+
{
|
|
2390
|
+
name: "fleet-blockers-gate",
|
|
2391
|
+
displayName: "Fleet Blockers Gate",
|
|
2392
|
+
description: "Before tool calls, blocks mutating tools while an unread [FREEZE] blocker is active (TTL-cached conversations check, fail-open <500ms)",
|
|
2393
|
+
version: "0.1.0",
|
|
2394
|
+
category: "Agent Teams",
|
|
2395
|
+
event: "PreToolUse",
|
|
2396
|
+
matcher: "",
|
|
2397
|
+
tags: ["fleet", "freeze", "blockers", "gate", "safety", "agent-teams"]
|
|
2304
2398
|
}
|
|
2305
2399
|
];
|
|
2306
2400
|
});
|
|
@@ -3954,28 +4048,39 @@ function normalizeHookName(name) {
|
|
|
3954
4048
|
return name.startsWith("hook-") ? name : `hook-${name}`;
|
|
3955
4049
|
}
|
|
3956
4050
|
function shortHookName(name) {
|
|
3957
|
-
return
|
|
4051
|
+
return name.startsWith("hook-") ? name.slice("hook-".length) : name;
|
|
3958
4052
|
}
|
|
3959
4053
|
function removeHookEntriesByName(entries, hookName) {
|
|
3960
4054
|
return entries.filter((entry) => !entry.hooks?.some((h) => {
|
|
3961
|
-
const match = h.command?.match(/^hooks run (\w+)/);
|
|
4055
|
+
const match = h.command?.match(/^hooks run ([\w-]+)/);
|
|
3962
4056
|
return match && match[1] === hookName;
|
|
3963
4057
|
}));
|
|
3964
4058
|
}
|
|
3965
4059
|
function getTargetSettingsDir(target) {
|
|
4060
|
+
if (target === "codewith")
|
|
4061
|
+
return ".codewith";
|
|
3966
4062
|
if (target === "gemini")
|
|
3967
4063
|
return ".gemini";
|
|
3968
4064
|
return ".claude";
|
|
3969
4065
|
}
|
|
3970
|
-
function getSettingsPath(scope = "global", target = "claude") {
|
|
4066
|
+
function getSettingsPath(scope = "global", target = "claude", codewithConfigPath) {
|
|
4067
|
+
if (target === "codewith" && codewithConfigPath)
|
|
4068
|
+
return codewithConfigPath;
|
|
4069
|
+
if (target === "codewith" && process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH) {
|
|
4070
|
+
return process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH;
|
|
4071
|
+
}
|
|
3971
4072
|
const dir = getTargetSettingsDir(target);
|
|
3972
4073
|
if (scope === "project") {
|
|
3973
|
-
return join2(process.cwd(), dir, "settings.json");
|
|
4074
|
+
return target === "codewith" ? join2(process.cwd(), dir, "config.toml") : join2(process.cwd(), dir, "settings.json");
|
|
3974
4075
|
}
|
|
3975
|
-
return join2(homedir2(), dir, "settings.json");
|
|
4076
|
+
return target === "codewith" ? join2(homedir2(), dir, "config.toml") : join2(homedir2(), dir, "settings.json");
|
|
3976
4077
|
}
|
|
3977
4078
|
function getHookPath(name) {
|
|
3978
|
-
|
|
4079
|
+
const shortName = shortHookName(name);
|
|
4080
|
+
const direct = join2(HOOKS_DIR, shortName);
|
|
4081
|
+
if (existsSync2(direct))
|
|
4082
|
+
return direct;
|
|
4083
|
+
return join2(HOOKS_DIR, normalizeHookName(shortName));
|
|
3979
4084
|
}
|
|
3980
4085
|
function hookExists(name) {
|
|
3981
4086
|
return existsSync2(getHookPath(name));
|
|
@@ -4001,18 +4106,118 @@ function writeSettings(settings, scope = "global", target = "claude") {
|
|
|
4001
4106
|
`);
|
|
4002
4107
|
}
|
|
4003
4108
|
function getTargetEventName(internalEvent, target) {
|
|
4004
|
-
return EVENT_MAP[target]?.[internalEvent]
|
|
4109
|
+
return EVENT_MAP[target]?.[internalEvent] ?? null;
|
|
4110
|
+
}
|
|
4111
|
+
function isEventSupported(internalEvent, target) {
|
|
4112
|
+
return getTargetEventName(internalEvent, target) !== null;
|
|
4113
|
+
}
|
|
4114
|
+
function tomlString(value) {
|
|
4115
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
4116
|
+
}
|
|
4117
|
+
function codewithMatcher(matcher) {
|
|
4118
|
+
if (!matcher)
|
|
4119
|
+
return;
|
|
4120
|
+
if (matcher.startsWith("^"))
|
|
4121
|
+
return matcher;
|
|
4122
|
+
if (matcher.includes("|"))
|
|
4123
|
+
return `^(${matcher})$`;
|
|
4124
|
+
return `^${matcher}$`;
|
|
4125
|
+
}
|
|
4126
|
+
function codewithTimeout(name) {
|
|
4127
|
+
switch (name) {
|
|
4128
|
+
case "knowledge-context":
|
|
4129
|
+
return 2;
|
|
4130
|
+
case "session-start":
|
|
4131
|
+
return 8;
|
|
4132
|
+
case "pre-bash":
|
|
4133
|
+
return 20;
|
|
4134
|
+
case "prompt-guard":
|
|
4135
|
+
return 3;
|
|
4136
|
+
case "worktree-guard":
|
|
4137
|
+
case "stop-sync":
|
|
4138
|
+
return 5;
|
|
4139
|
+
default:
|
|
4140
|
+
return 10;
|
|
4141
|
+
}
|
|
4142
|
+
}
|
|
4143
|
+
function codewithStatusMessage(name) {
|
|
4144
|
+
switch (name) {
|
|
4145
|
+
case "knowledge-context":
|
|
4146
|
+
return "Loading Knowledge context";
|
|
4147
|
+
case "session-start":
|
|
4148
|
+
return "Checking Hasna session context";
|
|
4149
|
+
case "pre-bash":
|
|
4150
|
+
return "Checking Bash safety";
|
|
4151
|
+
case "prompt-guard":
|
|
4152
|
+
return "Checking prompt safety";
|
|
4153
|
+
case "worktree-guard":
|
|
4154
|
+
return "Checking worktree safety";
|
|
4155
|
+
case "stop-sync":
|
|
4156
|
+
return "Syncing turn-end heartbeat";
|
|
4157
|
+
default:
|
|
4158
|
+
return `Running ${name}`;
|
|
4159
|
+
}
|
|
4160
|
+
}
|
|
4161
|
+
function buildCodewithTomlFragment(name, profile) {
|
|
4162
|
+
const shortName = shortHookName(name);
|
|
4163
|
+
const meta = getHook(shortName);
|
|
4164
|
+
if (!meta)
|
|
4165
|
+
throw new Error(`Hook '${shortName}' not found`);
|
|
4166
|
+
const command = profile ? `hooks run ${shortName} --profile ${profile}` : `hooks run ${shortName}`;
|
|
4167
|
+
const matcher = codewithMatcher(meta.matcher);
|
|
4168
|
+
const fragments = [];
|
|
4169
|
+
for (const event of getHookEvents(meta)) {
|
|
4170
|
+
const eventKey = getTargetEventName(event, "codewith");
|
|
4171
|
+
if (!eventKey) {
|
|
4172
|
+
throw new Error(`Hook '${shortName}' uses event '${event}', which is not supported by the Codewith target`);
|
|
4173
|
+
}
|
|
4174
|
+
const lines = [
|
|
4175
|
+
`[[hooks.${eventKey}]]`
|
|
4176
|
+
];
|
|
4177
|
+
if (matcher)
|
|
4178
|
+
lines.push(`matcher = ${tomlString(matcher)}`);
|
|
4179
|
+
lines.push("", `[[hooks.${eventKey}.hooks]]`, `type = "command"`, `command = ${tomlString(command)}`, `timeout = ${codewithTimeout(shortName)}`, `statusMessage = ${tomlString(codewithStatusMessage(shortName))}`);
|
|
4180
|
+
fragments.push(lines.join(`
|
|
4181
|
+
`));
|
|
4182
|
+
}
|
|
4183
|
+
return `${fragments.join(`
|
|
4184
|
+
|
|
4185
|
+
`)}
|
|
4186
|
+
`;
|
|
4187
|
+
}
|
|
4188
|
+
function readCodewithConfig(scope, configPath) {
|
|
4189
|
+
const path = getSettingsPath(scope, "codewith", configPath);
|
|
4190
|
+
try {
|
|
4191
|
+
return existsSync2(path) ? readFileSync(path, "utf-8") : "";
|
|
4192
|
+
} catch {
|
|
4193
|
+
return "";
|
|
4194
|
+
}
|
|
4195
|
+
}
|
|
4196
|
+
function appendCodewithFragment(fragment, scope, configPath) {
|
|
4197
|
+
const path = getSettingsPath(scope, "codewith", configPath);
|
|
4198
|
+
const dir = dirname(path);
|
|
4199
|
+
if (!existsSync2(dir))
|
|
4200
|
+
mkdirSync(dir, { recursive: true });
|
|
4201
|
+
const existing = existsSync2(path) ? readFileSync(path, "utf-8") : "";
|
|
4202
|
+
const sep = existing.trim() ? `
|
|
4203
|
+
|
|
4204
|
+
` : "";
|
|
4205
|
+
writeFileSync(path, `${existing.replace(/\s*$/, "")}${sep}${fragment}`);
|
|
4206
|
+
return path;
|
|
4005
4207
|
}
|
|
4006
4208
|
function detectConflict(name, scope, target) {
|
|
4007
4209
|
const meta = getHook(name);
|
|
4008
4210
|
if (!meta || !meta.matcher)
|
|
4009
4211
|
return;
|
|
4212
|
+
const events = new Set(getHookEvents(meta));
|
|
4010
4213
|
const registered = getRegisteredHooksForTarget(scope, target);
|
|
4011
4214
|
for (const existingName of registered) {
|
|
4012
4215
|
if (existingName === name)
|
|
4013
4216
|
continue;
|
|
4014
4217
|
const existing = getHook(existingName);
|
|
4015
|
-
if (!existing ||
|
|
4218
|
+
if (!existing || !existing.matcher)
|
|
4219
|
+
continue;
|
|
4220
|
+
if (!getHookEvents(existing).some((event) => events.has(event)))
|
|
4016
4221
|
continue;
|
|
4017
4222
|
const a = meta.matcher.toLowerCase();
|
|
4018
4223
|
const b = existing.matcher.toLowerCase();
|
|
@@ -4022,11 +4227,60 @@ function detectConflict(name, scope, target) {
|
|
|
4022
4227
|
}
|
|
4023
4228
|
return;
|
|
4024
4229
|
}
|
|
4025
|
-
function installForTarget(name, scope, overwrite, target, profile) {
|
|
4230
|
+
function installForTarget(name, scope, overwrite, target, profile, codewithMode = "fragment", codewithConfigPath) {
|
|
4026
4231
|
const shortName = shortHookName(name);
|
|
4027
4232
|
if (!hookExists(shortName)) {
|
|
4028
4233
|
return { hook: shortName, success: false, error: `Hook '${shortName}' not found`, target };
|
|
4029
4234
|
}
|
|
4235
|
+
if (target === "codewith") {
|
|
4236
|
+
try {
|
|
4237
|
+
const fragment = buildCodewithTomlFragment(shortName, profile);
|
|
4238
|
+
if (codewithMode !== "write") {
|
|
4239
|
+
return {
|
|
4240
|
+
hook: shortName,
|
|
4241
|
+
success: true,
|
|
4242
|
+
scope,
|
|
4243
|
+
target,
|
|
4244
|
+
fragment,
|
|
4245
|
+
applied: false,
|
|
4246
|
+
note: "Codewith install is fragment-only by default; open-configs should own applying this TOML."
|
|
4247
|
+
};
|
|
4248
|
+
}
|
|
4249
|
+
if (!codewithConfigPath) {
|
|
4250
|
+
return {
|
|
4251
|
+
hook: shortName,
|
|
4252
|
+
success: false,
|
|
4253
|
+
error: "Direct Codewith writes require an explicit --codewith-config path; refusing to write default ~/.codewith/config.toml.",
|
|
4254
|
+
scope,
|
|
4255
|
+
target,
|
|
4256
|
+
fragment,
|
|
4257
|
+
applied: false
|
|
4258
|
+
};
|
|
4259
|
+
}
|
|
4260
|
+
const existing = readCodewithConfig(scope, codewithConfigPath);
|
|
4261
|
+
if (!overwrite && new RegExp(`command\\s*=\\s*["']hooks run ${shortName}(?:\\s|["'])`).test(existing)) {
|
|
4262
|
+
return { hook: shortName, success: false, error: "Already installed. Use --overwrite to append another fragment.", scope, target };
|
|
4263
|
+
}
|
|
4264
|
+
const path = appendCodewithFragment(fragment, scope, codewithConfigPath);
|
|
4265
|
+
return {
|
|
4266
|
+
hook: shortName,
|
|
4267
|
+
success: true,
|
|
4268
|
+
scope,
|
|
4269
|
+
target,
|
|
4270
|
+
fragment,
|
|
4271
|
+
applied: true,
|
|
4272
|
+
configPath: path,
|
|
4273
|
+
note: "Direct Codewith config write was explicitly requested; prefer open-configs for managed machines."
|
|
4274
|
+
};
|
|
4275
|
+
} catch (error) {
|
|
4276
|
+
return {
|
|
4277
|
+
hook: shortName,
|
|
4278
|
+
success: false,
|
|
4279
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
4280
|
+
target
|
|
4281
|
+
};
|
|
4282
|
+
}
|
|
4283
|
+
}
|
|
4030
4284
|
const registered = getRegisteredHooksForTarget(scope, target);
|
|
4031
4285
|
if (registered.includes(shortName) && !overwrite) {
|
|
4032
4286
|
return { hook: shortName, success: false, error: "Already installed. Use --overwrite to replace.", scope, target };
|
|
@@ -4045,35 +4299,92 @@ function installForTarget(name, scope, overwrite, target, profile) {
|
|
|
4045
4299
|
}
|
|
4046
4300
|
}
|
|
4047
4301
|
function installHook(name, options = {}) {
|
|
4048
|
-
const { scope = "global", overwrite = false, target = "claude", profile } = options;
|
|
4302
|
+
const { scope = "global", overwrite = false, target = "claude", profile, codewithMode = "fragment", codewithConfigPath } = options;
|
|
4049
4303
|
if (target === "all") {
|
|
4304
|
+
const shortName = shortHookName(name);
|
|
4305
|
+
const meta = getHook(shortName);
|
|
4306
|
+
if (meta) {
|
|
4307
|
+
const unsupportedTargets = ["claude", "gemini", "codewith"].filter((agentTarget) => getHookEvents(meta).some((event) => !isEventSupported(event, agentTarget)));
|
|
4308
|
+
if (unsupportedTargets.length > 0) {
|
|
4309
|
+
return {
|
|
4310
|
+
hook: shortName,
|
|
4311
|
+
success: false,
|
|
4312
|
+
error: `Event(s) '${getHookEvents(meta).join(", ")}' are not supported by target(s): ${unsupportedTargets.join(", ")}`,
|
|
4313
|
+
target: "all"
|
|
4314
|
+
};
|
|
4315
|
+
}
|
|
4316
|
+
}
|
|
4050
4317
|
const claudeResult = installForTarget(name, scope, overwrite, "claude", profile);
|
|
4051
|
-
|
|
4318
|
+
if (!claudeResult.success) {
|
|
4319
|
+
return {
|
|
4320
|
+
...claudeResult,
|
|
4321
|
+
error: `Failed for target 'claude': ${claudeResult.error}`,
|
|
4322
|
+
target: "all"
|
|
4323
|
+
};
|
|
4324
|
+
}
|
|
4325
|
+
const geminiResult = installForTarget(name, scope, overwrite, "gemini", profile);
|
|
4326
|
+
if (!geminiResult.success) {
|
|
4327
|
+
return {
|
|
4328
|
+
...geminiResult,
|
|
4329
|
+
error: `Failed for target 'gemini': ${geminiResult.error}`,
|
|
4330
|
+
target: "all"
|
|
4331
|
+
};
|
|
4332
|
+
}
|
|
4333
|
+
const codewithResult = installForTarget(name, scope, overwrite, "codewith", profile, codewithMode, codewithConfigPath);
|
|
4334
|
+
if (!codewithResult.success) {
|
|
4335
|
+
return {
|
|
4336
|
+
...codewithResult,
|
|
4337
|
+
error: `Failed for target 'codewith': ${codewithResult.error}`,
|
|
4338
|
+
target: "all"
|
|
4339
|
+
};
|
|
4340
|
+
}
|
|
4052
4341
|
return { ...claudeResult, target: "all" };
|
|
4053
4342
|
}
|
|
4054
|
-
return installForTarget(name, scope, overwrite, target, profile);
|
|
4343
|
+
return installForTarget(name, scope, overwrite, target, profile, codewithMode, codewithConfigPath);
|
|
4055
4344
|
}
|
|
4056
4345
|
function registerHook(name, scope = "global", target = "claude", profile) {
|
|
4057
4346
|
const meta = getHook(name);
|
|
4058
4347
|
if (!meta)
|
|
4059
4348
|
return;
|
|
4349
|
+
const eventKeys = getHookEvents(meta).map((event) => {
|
|
4350
|
+
const eventKey = getTargetEventName(event, target);
|
|
4351
|
+
if (eventKey === null) {
|
|
4352
|
+
throw new Error(`Event '${event}' is not supported by target '${target}'`);
|
|
4353
|
+
}
|
|
4354
|
+
return eventKey;
|
|
4355
|
+
});
|
|
4356
|
+
const uniqueEventKeys = [...new Set(eventKeys)];
|
|
4357
|
+
if (uniqueEventKeys.length === 0) {
|
|
4358
|
+
throw new Error(`Hook '${name}' has no installable events for target '${target}'`);
|
|
4359
|
+
}
|
|
4060
4360
|
const settings = readSettings(scope, target);
|
|
4061
4361
|
if (!settings.hooks)
|
|
4062
4362
|
settings.hooks = {};
|
|
4063
|
-
|
|
4064
|
-
if (!settings.hooks[eventKey])
|
|
4065
|
-
settings.hooks[eventKey] = [];
|
|
4066
|
-
settings.hooks[eventKey] = removeHookEntriesByName(settings.hooks[eventKey], name);
|
|
4363
|
+
removeHookFromAllEvents(settings, name);
|
|
4067
4364
|
const hookCommand = profile ? `hooks run ${name} --profile ${profile}` : `hooks run ${name}`;
|
|
4068
|
-
const
|
|
4069
|
-
hooks
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4365
|
+
for (const eventKey of uniqueEventKeys) {
|
|
4366
|
+
if (!settings.hooks[eventKey])
|
|
4367
|
+
settings.hooks[eventKey] = [];
|
|
4368
|
+
const entry = {
|
|
4369
|
+
hooks: [{ type: "command", command: hookCommand }]
|
|
4370
|
+
};
|
|
4371
|
+
if (meta.matcher) {
|
|
4372
|
+
entry.matcher = meta.matcher;
|
|
4373
|
+
}
|
|
4374
|
+
settings.hooks[eventKey].push(entry);
|
|
4073
4375
|
}
|
|
4074
|
-
settings.hooks[eventKey].push(entry);
|
|
4075
4376
|
writeSettings(settings, scope, target);
|
|
4076
4377
|
}
|
|
4378
|
+
function removeHookFromAllEvents(settings, name) {
|
|
4379
|
+
if (!settings.hooks)
|
|
4380
|
+
return;
|
|
4381
|
+
for (const key of Object.keys(settings.hooks)) {
|
|
4382
|
+
settings.hooks[key] = removeHookEntriesByName(settings.hooks[key], name);
|
|
4383
|
+
if (settings.hooks[key].length === 0) {
|
|
4384
|
+
delete settings.hooks[key];
|
|
4385
|
+
}
|
|
4386
|
+
}
|
|
4387
|
+
}
|
|
4077
4388
|
function unregisterHook(name, scope = "global", target = "claude") {
|
|
4078
4389
|
const meta = getHook(name);
|
|
4079
4390
|
if (!meta)
|
|
@@ -4081,19 +4392,23 @@ function unregisterHook(name, scope = "global", target = "claude") {
|
|
|
4081
4392
|
const settings = readSettings(scope, target);
|
|
4082
4393
|
if (!settings.hooks)
|
|
4083
4394
|
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
|
-
}
|
|
4395
|
+
removeHookFromAllEvents(settings, name);
|
|
4091
4396
|
if (Object.keys(settings.hooks).length === 0) {
|
|
4092
4397
|
delete settings.hooks;
|
|
4093
4398
|
}
|
|
4094
4399
|
writeSettings(settings, scope, target);
|
|
4095
4400
|
}
|
|
4096
4401
|
function getRegisteredHooksForTarget(scope = "global", target = "claude") {
|
|
4402
|
+
if (target === "codewith") {
|
|
4403
|
+
const config = readCodewithConfig(scope);
|
|
4404
|
+
const registered2 = [];
|
|
4405
|
+
const re = /command\s*=\s*["']hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?["']/g;
|
|
4406
|
+
let match;
|
|
4407
|
+
while (match = re.exec(config)) {
|
|
4408
|
+
registered2.push(match[1]);
|
|
4409
|
+
}
|
|
4410
|
+
return [...new Set(registered2)];
|
|
4411
|
+
}
|
|
4097
4412
|
const settings = readSettings(scope, target);
|
|
4098
4413
|
if (!settings.hooks)
|
|
4099
4414
|
return [];
|
|
@@ -4101,8 +4416,8 @@ function getRegisteredHooksForTarget(scope = "global", target = "claude") {
|
|
|
4101
4416
|
for (const eventKey of Object.keys(settings.hooks)) {
|
|
4102
4417
|
for (const entry of settings.hooks[eventKey]) {
|
|
4103
4418
|
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+)$/);
|
|
4419
|
+
const newMatch = hook.command?.match(/^hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?$/);
|
|
4420
|
+
const oldMatch = hook.command?.match(/^hook-([\w-]+)$/);
|
|
4106
4421
|
const match = newMatch || oldMatch;
|
|
4107
4422
|
if (match) {
|
|
4108
4423
|
registered.push(match[1]);
|
|
@@ -4122,6 +4437,9 @@ function removeHook(name, scope = "global", target = "claude") {
|
|
|
4122
4437
|
const geminiRemoved = removeHookForTarget(shortName, scope, "gemini");
|
|
4123
4438
|
return claudeRemoved || geminiRemoved;
|
|
4124
4439
|
}
|
|
4440
|
+
if (target === "codewith") {
|
|
4441
|
+
return false;
|
|
4442
|
+
}
|
|
4125
4443
|
return removeHookForTarget(shortName, scope, target);
|
|
4126
4444
|
}
|
|
4127
4445
|
function removeHookForTarget(name, scope, target) {
|
|
@@ -4142,13 +4460,31 @@ var init_installer = __esm(() => {
|
|
|
4142
4460
|
PreToolUse: "PreToolUse",
|
|
4143
4461
|
PostToolUse: "PostToolUse",
|
|
4144
4462
|
Stop: "Stop",
|
|
4145
|
-
Notification: "Notification"
|
|
4463
|
+
Notification: "Notification",
|
|
4464
|
+
SessionStart: "SessionStart",
|
|
4465
|
+
SessionEnd: "SessionEnd",
|
|
4466
|
+
UserPromptSubmit: null,
|
|
4467
|
+
SubagentStart: null
|
|
4146
4468
|
},
|
|
4147
4469
|
gemini: {
|
|
4148
4470
|
PreToolUse: "BeforeTool",
|
|
4149
4471
|
PostToolUse: "AfterTool",
|
|
4150
4472
|
Stop: "AfterAgent",
|
|
4151
|
-
Notification: "Notification"
|
|
4473
|
+
Notification: "Notification",
|
|
4474
|
+
SessionStart: null,
|
|
4475
|
+
SessionEnd: null,
|
|
4476
|
+
UserPromptSubmit: null,
|
|
4477
|
+
SubagentStart: null
|
|
4478
|
+
},
|
|
4479
|
+
codewith: {
|
|
4480
|
+
PreToolUse: "PreToolUse",
|
|
4481
|
+
PostToolUse: "PostToolUse",
|
|
4482
|
+
Stop: "Stop",
|
|
4483
|
+
Notification: null,
|
|
4484
|
+
SessionStart: "SessionStart",
|
|
4485
|
+
SessionEnd: null,
|
|
4486
|
+
UserPromptSubmit: "UserPromptSubmit",
|
|
4487
|
+
SubagentStart: "SubagentStart"
|
|
4152
4488
|
}
|
|
4153
4489
|
};
|
|
4154
4490
|
getInstalledHooks = getRegisteredHooks;
|
|
@@ -4266,7 +4602,7 @@ var CREATE_HOOK_EVENTS_TABLE = `
|
|
|
4266
4602
|
timestamp TEXT NOT NULL,
|
|
4267
4603
|
session_id TEXT NOT NULL,
|
|
4268
4604
|
hook_name TEXT NOT NULL,
|
|
4269
|
-
event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification')),
|
|
4605
|
+
event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit')),
|
|
4270
4606
|
tool_name TEXT,
|
|
4271
4607
|
tool_input TEXT,
|
|
4272
4608
|
result TEXT CHECK (result IN ('continue', 'block', NULL)),
|
|
@@ -4297,6 +4633,64 @@ var init_001_initial = __esm(() => {
|
|
|
4297
4633
|
init_schema();
|
|
4298
4634
|
});
|
|
4299
4635
|
|
|
4636
|
+
// src/db/migrations/002_session_events.ts
|
|
4637
|
+
function up2(db) {
|
|
4638
|
+
const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
|
|
4639
|
+
if (!row?.sql)
|
|
4640
|
+
return;
|
|
4641
|
+
if (row.sql.includes("SessionStart"))
|
|
4642
|
+
return;
|
|
4643
|
+
db.exec("BEGIN");
|
|
4644
|
+
try {
|
|
4645
|
+
db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
|
|
4646
|
+
db.exec(CREATE_HOOK_EVENTS_TABLE);
|
|
4647
|
+
db.exec(`INSERT INTO hook_events
|
|
4648
|
+
(id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
|
|
4649
|
+
SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
|
|
4650
|
+
FROM hook_events_old`);
|
|
4651
|
+
db.exec("DROP TABLE hook_events_old");
|
|
4652
|
+
for (const idx of CREATE_INDEXES) {
|
|
4653
|
+
db.exec(idx);
|
|
4654
|
+
}
|
|
4655
|
+
db.exec("COMMIT");
|
|
4656
|
+
} catch (err) {
|
|
4657
|
+
db.exec("ROLLBACK");
|
|
4658
|
+
throw err;
|
|
4659
|
+
}
|
|
4660
|
+
}
|
|
4661
|
+
var init_002_session_events = __esm(() => {
|
|
4662
|
+
init_schema();
|
|
4663
|
+
});
|
|
4664
|
+
|
|
4665
|
+
// src/db/migrations/003_user_prompt_submit_event.ts
|
|
4666
|
+
function up3(db) {
|
|
4667
|
+
const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
|
|
4668
|
+
if (!row?.sql)
|
|
4669
|
+
return;
|
|
4670
|
+
if (row.sql.includes("UserPromptSubmit"))
|
|
4671
|
+
return;
|
|
4672
|
+
db.exec("BEGIN");
|
|
4673
|
+
try {
|
|
4674
|
+
db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
|
|
4675
|
+
db.exec(CREATE_HOOK_EVENTS_TABLE);
|
|
4676
|
+
db.exec(`INSERT INTO hook_events
|
|
4677
|
+
(id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
|
|
4678
|
+
SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
|
|
4679
|
+
FROM hook_events_old`);
|
|
4680
|
+
db.exec("DROP TABLE hook_events_old");
|
|
4681
|
+
for (const idx of CREATE_INDEXES) {
|
|
4682
|
+
db.exec(idx);
|
|
4683
|
+
}
|
|
4684
|
+
db.exec("COMMIT");
|
|
4685
|
+
} catch (err) {
|
|
4686
|
+
db.exec("ROLLBACK");
|
|
4687
|
+
throw err;
|
|
4688
|
+
}
|
|
4689
|
+
}
|
|
4690
|
+
var init_003_user_prompt_submit_event = __esm(() => {
|
|
4691
|
+
init_schema();
|
|
4692
|
+
});
|
|
4693
|
+
|
|
4300
4694
|
// src/db/migrations/index.ts
|
|
4301
4695
|
function ensureMigrationsTable(db) {
|
|
4302
4696
|
db.exec(`
|
|
@@ -4326,7 +4720,13 @@ function runMigrations(db) {
|
|
|
4326
4720
|
var MIGRATIONS;
|
|
4327
4721
|
var init_migrations = __esm(() => {
|
|
4328
4722
|
init_001_initial();
|
|
4329
|
-
|
|
4723
|
+
init_002_session_events();
|
|
4724
|
+
init_003_user_prompt_submit_event();
|
|
4725
|
+
MIGRATIONS = [
|
|
4726
|
+
{ version: "001_initial", up },
|
|
4727
|
+
{ version: "002_session_events", up: up2 },
|
|
4728
|
+
{ version: "003_user_prompt_submit_event", up: up3 }
|
|
4729
|
+
];
|
|
4330
4730
|
});
|
|
4331
4731
|
|
|
4332
4732
|
// src/db/legacy-import.ts
|
|
@@ -4538,6 +4938,396 @@ var init_db = __esm(() => {
|
|
|
4538
4938
|
init_legacy_import();
|
|
4539
4939
|
});
|
|
4540
4940
|
|
|
4941
|
+
// src/db/pg-migrations.ts
|
|
4942
|
+
var PG_MIGRATIONS;
|
|
4943
|
+
var init_pg_migrations = __esm(() => {
|
|
4944
|
+
PG_MIGRATIONS = [
|
|
4945
|
+
`CREATE TABLE IF NOT EXISTS hook_events (
|
|
4946
|
+
id TEXT PRIMARY KEY,
|
|
4947
|
+
timestamp TEXT NOT NULL,
|
|
4948
|
+
session_id TEXT NOT NULL,
|
|
4949
|
+
hook_name TEXT NOT NULL,
|
|
4950
|
+
event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit')),
|
|
4951
|
+
tool_name TEXT,
|
|
4952
|
+
tool_input TEXT,
|
|
4953
|
+
result TEXT CHECK (result IN ('continue', 'block', NULL)),
|
|
4954
|
+
error TEXT,
|
|
4955
|
+
duration_ms INTEGER,
|
|
4956
|
+
project_dir TEXT,
|
|
4957
|
+
metadata TEXT
|
|
4958
|
+
)`,
|
|
4959
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_timestamp ON hook_events (timestamp)`,
|
|
4960
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_session_id ON hook_events (session_id)`,
|
|
4961
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_hook_name ON hook_events (hook_name)`,
|
|
4962
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_event_type ON hook_events (event_type)`,
|
|
4963
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_errors ON hook_events (timestamp) WHERE error IS NOT NULL`,
|
|
4964
|
+
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
4965
|
+
version TEXT PRIMARY KEY,
|
|
4966
|
+
applied_at TEXT NOT NULL
|
|
4967
|
+
)`,
|
|
4968
|
+
`CREATE TABLE IF NOT EXISTS _meta (
|
|
4969
|
+
key TEXT PRIMARY KEY,
|
|
4970
|
+
value TEXT NOT NULL
|
|
4971
|
+
)`,
|
|
4972
|
+
`CREATE TABLE IF NOT EXISTS feedback (
|
|
4973
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
4974
|
+
message TEXT NOT NULL,
|
|
4975
|
+
email TEXT,
|
|
4976
|
+
category TEXT DEFAULT 'general',
|
|
4977
|
+
version TEXT,
|
|
4978
|
+
machine_id TEXT,
|
|
4979
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text
|
|
4980
|
+
)`,
|
|
4981
|
+
`ALTER TABLE hook_events DROP CONSTRAINT IF EXISTS hook_events_event_type_check`,
|
|
4982
|
+
`ALTER TABLE hook_events ADD CONSTRAINT hook_events_event_type_check
|
|
4983
|
+
CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit'))`
|
|
4984
|
+
];
|
|
4985
|
+
});
|
|
4986
|
+
|
|
4987
|
+
// src/db/remote-storage.ts
|
|
4988
|
+
import pg from "pg";
|
|
4989
|
+
function translatePlaceholders(sql) {
|
|
4990
|
+
let index = 0;
|
|
4991
|
+
return sql.replace(/\?/g, () => `$${++index}`);
|
|
4992
|
+
}
|
|
4993
|
+
function normalizeParams(params) {
|
|
4994
|
+
const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
|
|
4995
|
+
return flat.map((value) => value === undefined ? null : value);
|
|
4996
|
+
}
|
|
4997
|
+
function sslConfigFor(connectionString) {
|
|
4998
|
+
return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
|
|
4999
|
+
}
|
|
5000
|
+
|
|
5001
|
+
class PgAdapterAsync {
|
|
5002
|
+
pool;
|
|
5003
|
+
constructor(connectionString) {
|
|
5004
|
+
this.pool = new pg.Pool({ connectionString, ssl: sslConfigFor(connectionString) });
|
|
5005
|
+
}
|
|
5006
|
+
async run(sql, ...params) {
|
|
5007
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
5008
|
+
return { changes: result.rowCount ?? 0 };
|
|
5009
|
+
}
|
|
5010
|
+
async all(sql, ...params) {
|
|
5011
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
5012
|
+
return result.rows;
|
|
5013
|
+
}
|
|
5014
|
+
async close() {
|
|
5015
|
+
await this.pool.end();
|
|
5016
|
+
}
|
|
5017
|
+
}
|
|
5018
|
+
var init_remote_storage = () => {};
|
|
5019
|
+
|
|
5020
|
+
// src/db/storage-sync.ts
|
|
5021
|
+
function readEnv(name) {
|
|
5022
|
+
const value = process.env[name]?.trim();
|
|
5023
|
+
return value || undefined;
|
|
5024
|
+
}
|
|
5025
|
+
function normalizeStorageMode(value) {
|
|
5026
|
+
const normalized = value?.trim().toLowerCase();
|
|
5027
|
+
if (normalized === "local" || normalized === "hybrid" || normalized === "remote")
|
|
5028
|
+
return normalized;
|
|
5029
|
+
return;
|
|
5030
|
+
}
|
|
5031
|
+
function getStorageDatabaseEnvName() {
|
|
5032
|
+
for (const name of STORAGE_DATABASE_ENV) {
|
|
5033
|
+
if (readEnv(name))
|
|
5034
|
+
return name;
|
|
5035
|
+
}
|
|
5036
|
+
return null;
|
|
5037
|
+
}
|
|
5038
|
+
function getStorageDatabaseEnv() {
|
|
5039
|
+
const name = getStorageDatabaseEnvName();
|
|
5040
|
+
return name ? { name } : null;
|
|
5041
|
+
}
|
|
5042
|
+
function getStorageDatabaseUrl() {
|
|
5043
|
+
const env = getStorageDatabaseEnv();
|
|
5044
|
+
return env ? readEnv(env.name) ?? null : null;
|
|
5045
|
+
}
|
|
5046
|
+
function getStorageMode() {
|
|
5047
|
+
const mode = normalizeStorageMode(readEnv(HOOKS_STORAGE_MODE_ENV)) ?? normalizeStorageMode(readEnv(HOOKS_STORAGE_MODE_FALLBACK_ENV));
|
|
5048
|
+
if (mode)
|
|
5049
|
+
return mode;
|
|
5050
|
+
return getStorageDatabaseUrl() ? "hybrid" : "local";
|
|
5051
|
+
}
|
|
5052
|
+
async function getStoragePg() {
|
|
5053
|
+
const url = getStorageDatabaseUrl();
|
|
5054
|
+
if (!url) {
|
|
5055
|
+
throw new Error("Missing HASNA_HOOKS_DATABASE_URL or HOOKS_DATABASE_URL");
|
|
5056
|
+
}
|
|
5057
|
+
return new PgAdapterAsync(url);
|
|
5058
|
+
}
|
|
5059
|
+
async function runStorageMigrations(remote) {
|
|
5060
|
+
await remote.run("CREATE EXTENSION IF NOT EXISTS pgcrypto");
|
|
5061
|
+
for (const sql of PG_MIGRATIONS)
|
|
5062
|
+
await remote.run(sql);
|
|
5063
|
+
}
|
|
5064
|
+
async function storagePush(options) {
|
|
5065
|
+
const remote = await getStoragePg();
|
|
5066
|
+
const db = getDb();
|
|
5067
|
+
try {
|
|
5068
|
+
await runStorageMigrations(remote);
|
|
5069
|
+
const results = [];
|
|
5070
|
+
for (const table of resolveTables(options?.tables)) {
|
|
5071
|
+
results.push(await pushTable(db, remote, table));
|
|
5072
|
+
}
|
|
5073
|
+
recordSyncMeta(db, "push", results);
|
|
5074
|
+
return results;
|
|
5075
|
+
} finally {
|
|
5076
|
+
await remote.close();
|
|
5077
|
+
}
|
|
5078
|
+
}
|
|
5079
|
+
async function storagePull(options) {
|
|
5080
|
+
const remote = await getStoragePg();
|
|
5081
|
+
const db = getDb();
|
|
5082
|
+
try {
|
|
5083
|
+
await runStorageMigrations(remote);
|
|
5084
|
+
const results = [];
|
|
5085
|
+
for (const table of resolveTables(options?.tables)) {
|
|
5086
|
+
results.push(await pullTable(remote, db, table));
|
|
5087
|
+
}
|
|
5088
|
+
recordSyncMeta(db, "pull", results);
|
|
5089
|
+
return results;
|
|
5090
|
+
} finally {
|
|
5091
|
+
await remote.close();
|
|
5092
|
+
}
|
|
5093
|
+
}
|
|
5094
|
+
async function storageSync(options) {
|
|
5095
|
+
const pull = await storagePull(options);
|
|
5096
|
+
const push = await storagePush(options);
|
|
5097
|
+
return { pull, push };
|
|
5098
|
+
}
|
|
5099
|
+
function getSyncMetaAll() {
|
|
5100
|
+
const db = getDb();
|
|
5101
|
+
ensureSyncMetaTable(db);
|
|
5102
|
+
return db.query("SELECT table_name, last_synced_at, direction FROM _hooks_sync_meta ORDER BY table_name, direction").all();
|
|
5103
|
+
}
|
|
5104
|
+
function getStorageStatus() {
|
|
5105
|
+
const activeEnv = getStorageDatabaseEnv();
|
|
5106
|
+
return {
|
|
5107
|
+
configured: Boolean(activeEnv),
|
|
5108
|
+
mode: getStorageMode(),
|
|
5109
|
+
env: STORAGE_DATABASE_ENV,
|
|
5110
|
+
activeEnv: activeEnv?.name ?? null,
|
|
5111
|
+
service: "hooks",
|
|
5112
|
+
tables: STORAGE_TABLES,
|
|
5113
|
+
sync: getSyncMetaAll()
|
|
5114
|
+
};
|
|
5115
|
+
}
|
|
5116
|
+
function resolveTables(tables) {
|
|
5117
|
+
if (!tables || tables.length === 0)
|
|
5118
|
+
return [...STORAGE_TABLES];
|
|
5119
|
+
const allowed = new Set(STORAGE_TABLES);
|
|
5120
|
+
const requested = tables.map((table) => table.trim()).filter(Boolean);
|
|
5121
|
+
const invalid = requested.filter((table) => !allowed.has(table));
|
|
5122
|
+
if (invalid.length > 0)
|
|
5123
|
+
throw new Error(`Unknown hooks sync table(s): ${invalid.join(", ")}`);
|
|
5124
|
+
return requested;
|
|
5125
|
+
}
|
|
5126
|
+
function parseStorageTables(value) {
|
|
5127
|
+
if (!value)
|
|
5128
|
+
return;
|
|
5129
|
+
return resolveTables(Array.isArray(value) ? value : value.split(","));
|
|
5130
|
+
}
|
|
5131
|
+
async function pushTable(db, remote, table) {
|
|
5132
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
5133
|
+
try {
|
|
5134
|
+
if (!tableExists(db, table))
|
|
5135
|
+
return result;
|
|
5136
|
+
const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all();
|
|
5137
|
+
result.rowsRead = rows.length;
|
|
5138
|
+
if (rows.length === 0)
|
|
5139
|
+
return result;
|
|
5140
|
+
const remoteColumns = await getRemoteColumns(remote, table);
|
|
5141
|
+
const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
|
|
5142
|
+
result.rowsWritten = await upsertPg(remote, table, columns, rows, remoteColumns);
|
|
5143
|
+
} catch (error) {
|
|
5144
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
5145
|
+
}
|
|
5146
|
+
return result;
|
|
5147
|
+
}
|
|
5148
|
+
async function pullTable(remote, db, table) {
|
|
5149
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
5150
|
+
try {
|
|
5151
|
+
if (!tableExists(db, table))
|
|
5152
|
+
return result;
|
|
5153
|
+
const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`);
|
|
5154
|
+
result.rowsRead = rows.length;
|
|
5155
|
+
if (rows.length === 0)
|
|
5156
|
+
return result;
|
|
5157
|
+
const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
|
|
5158
|
+
result.rowsWritten = upsertSqlite(db, table, columns, rows);
|
|
5159
|
+
} catch (error) {
|
|
5160
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
5161
|
+
}
|
|
5162
|
+
return result;
|
|
5163
|
+
}
|
|
5164
|
+
async function getRemoteColumns(remote, table) {
|
|
5165
|
+
const rows = await remote.all("SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?", table);
|
|
5166
|
+
return new Map(rows.map((row) => [row.column_name, row.data_type]));
|
|
5167
|
+
}
|
|
5168
|
+
function filterRemoteColumns(remoteColumns, columns) {
|
|
5169
|
+
if (remoteColumns.size === 0)
|
|
5170
|
+
return columns;
|
|
5171
|
+
return columns.filter((column) => remoteColumns.has(column));
|
|
5172
|
+
}
|
|
5173
|
+
function filterLocalColumns(db, table, columns) {
|
|
5174
|
+
const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all();
|
|
5175
|
+
const allowed = new Set(rows.map((row) => row.name));
|
|
5176
|
+
return columns.filter((column) => allowed.has(column));
|
|
5177
|
+
}
|
|
5178
|
+
async function upsertPg(remote, table, columns, rows, remoteColumns) {
|
|
5179
|
+
if (columns.length === 0)
|
|
5180
|
+
return 0;
|
|
5181
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
5182
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
5183
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
5184
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
5185
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
5186
|
+
const fallbackKey = primaryKeys[0];
|
|
5187
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
|
|
5188
|
+
for (const row of rows) {
|
|
5189
|
+
await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
|
|
5190
|
+
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`, ...columns.map((column) => coerceForPg(row[column], remoteColumns.get(column))));
|
|
5191
|
+
}
|
|
5192
|
+
return rows.length;
|
|
5193
|
+
}
|
|
5194
|
+
function upsertSqlite(db, table, columns, rows) {
|
|
5195
|
+
if (columns.length === 0)
|
|
5196
|
+
return 0;
|
|
5197
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
5198
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
5199
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
5200
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
5201
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
5202
|
+
const fallbackKey = primaryKeys[0];
|
|
5203
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`;
|
|
5204
|
+
const statement = db.query(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
|
|
5205
|
+
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`);
|
|
5206
|
+
const insert = db.transaction((batch) => {
|
|
5207
|
+
for (const row of batch)
|
|
5208
|
+
statement.run(...columns.map((column) => coerceForSqlite(row[column])));
|
|
5209
|
+
});
|
|
5210
|
+
insert(rows);
|
|
5211
|
+
return rows.length;
|
|
5212
|
+
}
|
|
5213
|
+
function recordSyncMeta(db, direction, results) {
|
|
5214
|
+
ensureSyncMetaTable(db);
|
|
5215
|
+
const now2 = new Date().toISOString();
|
|
5216
|
+
const statement = db.query(`
|
|
5217
|
+
INSERT INTO _hooks_sync_meta (table_name, last_synced_at, direction)
|
|
5218
|
+
VALUES (?, ?, ?)
|
|
5219
|
+
ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at
|
|
5220
|
+
`);
|
|
5221
|
+
for (const result of results) {
|
|
5222
|
+
if (result.errors.length > 0)
|
|
5223
|
+
continue;
|
|
5224
|
+
statement.run(result.table, now2, direction);
|
|
5225
|
+
}
|
|
5226
|
+
}
|
|
5227
|
+
function ensureSyncMetaTable(db) {
|
|
5228
|
+
db.exec(`
|
|
5229
|
+
CREATE TABLE IF NOT EXISTS _hooks_sync_meta (
|
|
5230
|
+
table_name TEXT NOT NULL,
|
|
5231
|
+
last_synced_at TEXT,
|
|
5232
|
+
direction TEXT NOT NULL CHECK(direction IN ('push', 'pull')),
|
|
5233
|
+
PRIMARY KEY (table_name, direction)
|
|
5234
|
+
)
|
|
5235
|
+
`);
|
|
5236
|
+
}
|
|
5237
|
+
function tableExists(db, table) {
|
|
5238
|
+
const row = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
5239
|
+
return Boolean(row);
|
|
5240
|
+
}
|
|
5241
|
+
function quoteIdent(identifier) {
|
|
5242
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
5243
|
+
}
|
|
5244
|
+
function coerceForPg(value, dataType) {
|
|
5245
|
+
if (value === undefined || value === null)
|
|
5246
|
+
return null;
|
|
5247
|
+
if (dataType === "boolean") {
|
|
5248
|
+
if (typeof value === "boolean")
|
|
5249
|
+
return value;
|
|
5250
|
+
if (typeof value === "number")
|
|
5251
|
+
return value !== 0;
|
|
5252
|
+
if (typeof value === "string")
|
|
5253
|
+
return value === "1" || value.toLowerCase() === "true";
|
|
5254
|
+
}
|
|
5255
|
+
if (value instanceof Date)
|
|
5256
|
+
return value.toISOString();
|
|
5257
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
5258
|
+
return value;
|
|
5259
|
+
if (typeof value === "object")
|
|
5260
|
+
return JSON.stringify(value);
|
|
5261
|
+
return value;
|
|
5262
|
+
}
|
|
5263
|
+
function coerceForSqlite(value) {
|
|
5264
|
+
if (value === undefined || value === null)
|
|
5265
|
+
return null;
|
|
5266
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean")
|
|
5267
|
+
return value;
|
|
5268
|
+
if (value instanceof Date)
|
|
5269
|
+
return value.toISOString();
|
|
5270
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
5271
|
+
return value;
|
|
5272
|
+
if (typeof value === "object")
|
|
5273
|
+
return JSON.stringify(value);
|
|
5274
|
+
return String(value);
|
|
5275
|
+
}
|
|
5276
|
+
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;
|
|
5277
|
+
var init_storage_sync = __esm(() => {
|
|
5278
|
+
init_db();
|
|
5279
|
+
init_pg_migrations();
|
|
5280
|
+
init_remote_storage();
|
|
5281
|
+
STORAGE_TABLES = [
|
|
5282
|
+
"hook_events",
|
|
5283
|
+
"schema_migrations",
|
|
5284
|
+
"_meta",
|
|
5285
|
+
"feedback"
|
|
5286
|
+
];
|
|
5287
|
+
HOOKS_STORAGE_TABLES = STORAGE_TABLES;
|
|
5288
|
+
STORAGE_DATABASE_ENV = [HOOKS_STORAGE_ENV, HOOKS_STORAGE_FALLBACK_ENV];
|
|
5289
|
+
STORAGE_MODE_ENV = [HOOKS_STORAGE_MODE_ENV, HOOKS_STORAGE_MODE_FALLBACK_ENV];
|
|
5290
|
+
PRIMARY_KEYS = {
|
|
5291
|
+
hook_events: ["id"],
|
|
5292
|
+
schema_migrations: ["version"],
|
|
5293
|
+
_meta: ["key"],
|
|
5294
|
+
feedback: ["id"]
|
|
5295
|
+
};
|
|
5296
|
+
});
|
|
5297
|
+
|
|
5298
|
+
// src/storage.ts
|
|
5299
|
+
var exports_storage = {};
|
|
5300
|
+
__export(exports_storage, {
|
|
5301
|
+
storageSync: () => storageSync,
|
|
5302
|
+
storagePush: () => storagePush,
|
|
5303
|
+
storagePull: () => storagePull,
|
|
5304
|
+
runStorageMigrations: () => runStorageMigrations,
|
|
5305
|
+
resolveTables: () => resolveTables,
|
|
5306
|
+
parseStorageTables: () => parseStorageTables,
|
|
5307
|
+
getSyncMetaAll: () => getSyncMetaAll,
|
|
5308
|
+
getStorageStatus: () => getStorageStatus,
|
|
5309
|
+
getStoragePg: () => getStoragePg,
|
|
5310
|
+
getStorageMode: () => getStorageMode,
|
|
5311
|
+
getStorageDatabaseUrl: () => getStorageDatabaseUrl,
|
|
5312
|
+
getStorageDatabaseEnvName: () => getStorageDatabaseEnvName,
|
|
5313
|
+
getStorageDatabaseEnv: () => getStorageDatabaseEnv,
|
|
5314
|
+
STORAGE_TABLES: () => STORAGE_TABLES,
|
|
5315
|
+
STORAGE_MODE_ENV: () => STORAGE_MODE_ENV,
|
|
5316
|
+
STORAGE_DATABASE_ENV: () => STORAGE_DATABASE_ENV,
|
|
5317
|
+
PgAdapterAsync: () => PgAdapterAsync,
|
|
5318
|
+
PG_MIGRATIONS: () => PG_MIGRATIONS,
|
|
5319
|
+
HOOKS_STORAGE_TABLES: () => HOOKS_STORAGE_TABLES,
|
|
5320
|
+
HOOKS_STORAGE_MODE_FALLBACK_ENV: () => HOOKS_STORAGE_MODE_FALLBACK_ENV,
|
|
5321
|
+
HOOKS_STORAGE_MODE_ENV: () => HOOKS_STORAGE_MODE_ENV,
|
|
5322
|
+
HOOKS_STORAGE_FALLBACK_ENV: () => HOOKS_STORAGE_FALLBACK_ENV,
|
|
5323
|
+
HOOKS_STORAGE_ENV: () => HOOKS_STORAGE_ENV
|
|
5324
|
+
});
|
|
5325
|
+
var init_storage = __esm(() => {
|
|
5326
|
+
init_storage_sync();
|
|
5327
|
+
init_remote_storage();
|
|
5328
|
+
init_pg_migrations();
|
|
5329
|
+
});
|
|
5330
|
+
|
|
4541
5331
|
// src/mcp/server.ts
|
|
4542
5332
|
var exports_server = {};
|
|
4543
5333
|
__export(exports_server, {
|
|
@@ -4671,7 +5461,7 @@ function createHooksServer() {
|
|
|
4671
5461
|
const settings = JSON.parse(readFileSync4(settingsPath, "utf-8"));
|
|
4672
5462
|
const eventHooks = settings.hooks?.[meta.event] || [];
|
|
4673
5463
|
const found = eventHooks.some((entry) => entry.hooks?.some((h) => {
|
|
4674
|
-
const match = h.command?.match(/^hooks run (\w+)/);
|
|
5464
|
+
const match = h.command?.match(/^hooks run ([\w-]+)/);
|
|
4675
5465
|
return match && match[1] === name;
|
|
4676
5466
|
}));
|
|
4677
5467
|
if (!found) {
|
|
@@ -4714,8 +5504,10 @@ function createHooksServer() {
|
|
|
4714
5504
|
events: {
|
|
4715
5505
|
PreToolUse: "Fires before a tool executes. Can block the operation.",
|
|
4716
5506
|
PostToolUse: "Fires after a tool executes. Runs asynchronously.",
|
|
4717
|
-
Stop: "Fires when
|
|
4718
|
-
Notification: "Fires on notification events like context compaction."
|
|
5507
|
+
Stop: "Fires when the agent finishes responding. Useful for notifications.",
|
|
5508
|
+
Notification: "Fires on notification events like context compaction.",
|
|
5509
|
+
SessionStart: "Fires when a session starts or resumes. Can inject context.",
|
|
5510
|
+
SessionEnd: "Fires when a session terminates. Useful for cleanup and final announcements."
|
|
4719
5511
|
},
|
|
4720
5512
|
commands: {
|
|
4721
5513
|
install: "hooks install <name>",
|
|
@@ -5129,6 +5921,10 @@ function createHooksServer() {
|
|
|
5129
5921
|
}]
|
|
5130
5922
|
};
|
|
5131
5923
|
});
|
|
5924
|
+
server.tool("storage_status", "Show hooks storage sync configuration and local sync history.", {}, async () => ({ content: [{ type: "text", text: JSON.stringify(getStorageStatus()) }] }));
|
|
5925
|
+
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)) }] }));
|
|
5926
|
+
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)) }] }));
|
|
5927
|
+
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
5928
|
server.tool("send_feedback", "Send feedback about this service", {
|
|
5133
5929
|
message: z.string(),
|
|
5134
5930
|
email: z.string().optional(),
|
|
@@ -5236,6 +6032,7 @@ var init_server = __esm(() => {
|
|
|
5236
6032
|
init_registry();
|
|
5237
6033
|
init_installer();
|
|
5238
6034
|
init_profiles();
|
|
6035
|
+
init_storage();
|
|
5239
6036
|
__dirname3 = dirname2(fileURLToPath2(import.meta.url));
|
|
5240
6037
|
pkg = { name: "@hasna/hooks", version: "0.0.0" };
|
|
5241
6038
|
try {
|
|
@@ -5326,29 +6123,83 @@ function getPathValue(input, path) {
|
|
|
5326
6123
|
return;
|
|
5327
6124
|
}, input);
|
|
5328
6125
|
}
|
|
5329
|
-
function
|
|
5330
|
-
const
|
|
5331
|
-
|
|
6126
|
+
function getFieldValues(input, path) {
|
|
6127
|
+
const values = [];
|
|
6128
|
+
const push = (value) => {
|
|
6129
|
+
if (!values.some((item) => Object.is(item, value)))
|
|
6130
|
+
values.push(value);
|
|
6131
|
+
};
|
|
6132
|
+
if (path.includes(".") && path in input)
|
|
6133
|
+
push(input[path]);
|
|
6134
|
+
const nestedValue = getPathValue(input, path);
|
|
6135
|
+
if (nestedValue !== undefined || !path.includes("."))
|
|
6136
|
+
push(nestedValue);
|
|
6137
|
+
return values;
|
|
5332
6138
|
}
|
|
5333
|
-
function
|
|
6139
|
+
function wildcardToRegExp(pattern, options = {}) {
|
|
6140
|
+
let body = "";
|
|
6141
|
+
for (let index = 0;index < pattern.length; index += 1) {
|
|
6142
|
+
const char = pattern[index];
|
|
6143
|
+
if (char === "*") {
|
|
6144
|
+
if (pattern[index + 1] === "*") {
|
|
6145
|
+
body += ".*";
|
|
6146
|
+
index += 1;
|
|
6147
|
+
} else {
|
|
6148
|
+
body += options.segmentSafe ? "[^/]*" : ".*";
|
|
6149
|
+
}
|
|
6150
|
+
} else {
|
|
6151
|
+
body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
6152
|
+
}
|
|
6153
|
+
}
|
|
6154
|
+
return new RegExp(`^${body}$`);
|
|
6155
|
+
}
|
|
6156
|
+
function matchString(value, matcher, options = {}) {
|
|
5334
6157
|
if (matcher === undefined)
|
|
5335
6158
|
return true;
|
|
5336
6159
|
if (value === undefined)
|
|
5337
6160
|
return false;
|
|
5338
6161
|
const matchers = Array.isArray(matcher) ? matcher : [matcher];
|
|
5339
|
-
return matchers.some((item) => wildcardToRegExp(item).test(value));
|
|
6162
|
+
return matchers.some((item) => wildcardToRegExp(item, options).test(value));
|
|
5340
6163
|
}
|
|
5341
6164
|
function matchRecord(input, matcher) {
|
|
5342
6165
|
if (!matcher)
|
|
5343
6166
|
return true;
|
|
5344
6167
|
return Object.entries(matcher).every(([path, expected]) => {
|
|
5345
|
-
const
|
|
5346
|
-
|
|
5347
|
-
return matchString(actual === undefined ? undefined : String(actual), expected);
|
|
5348
|
-
}
|
|
5349
|
-
return actual === expected;
|
|
6168
|
+
const actualValues = getFieldValues(input, path);
|
|
6169
|
+
return matchField(actualValues, expected, path);
|
|
5350
6170
|
});
|
|
5351
6171
|
}
|
|
6172
|
+
function matchField(actualValues, expected, path) {
|
|
6173
|
+
if (isNegativeMatcher(expected)) {
|
|
6174
|
+
return !actualValues.some((actual) => matchPositiveField(actual, expected.not, path));
|
|
6175
|
+
}
|
|
6176
|
+
return actualValues.some((actual) => matchPositiveField(actual, expected, path));
|
|
6177
|
+
}
|
|
6178
|
+
function matchPositiveField(actual, expected, path) {
|
|
6179
|
+
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
6180
|
+
return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
|
|
6181
|
+
segmentSafe: path.endsWith("_path") || path.endsWith(".path")
|
|
6182
|
+
}));
|
|
6183
|
+
}
|
|
6184
|
+
if (Array.isArray(actual)) {
|
|
6185
|
+
return actual.some((item) => item === expected);
|
|
6186
|
+
}
|
|
6187
|
+
return actual === expected;
|
|
6188
|
+
}
|
|
6189
|
+
function stringCandidates(actual) {
|
|
6190
|
+
if (actual === undefined)
|
|
6191
|
+
return [];
|
|
6192
|
+
if (Array.isArray(actual)) {
|
|
6193
|
+
return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
|
|
6194
|
+
}
|
|
6195
|
+
return [String(actual)];
|
|
6196
|
+
}
|
|
6197
|
+
function isPrimitiveFieldValue(value) {
|
|
6198
|
+
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
6199
|
+
}
|
|
6200
|
+
function isNegativeMatcher(value) {
|
|
6201
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
|
|
6202
|
+
}
|
|
5352
6203
|
function eventMatchesFilter(event, filter) {
|
|
5353
6204
|
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
6205
|
}
|
|
@@ -5364,6 +6215,13 @@ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
|
5364
6215
|
function getEventsDataDir(override) {
|
|
5365
6216
|
return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
|
|
5366
6217
|
}
|
|
6218
|
+
function getActiveEventsDirEnv() {
|
|
6219
|
+
if (process.env[HASNA_EVENTS_DIR_ENV])
|
|
6220
|
+
return HASNA_EVENTS_DIR_ENV;
|
|
6221
|
+
if (process.env[HASNA_EVENTS_HOME_ENV])
|
|
6222
|
+
return HASNA_EVENTS_HOME_ENV;
|
|
6223
|
+
return null;
|
|
6224
|
+
}
|
|
5367
6225
|
|
|
5368
6226
|
class JsonEventsStore {
|
|
5369
6227
|
dataDir;
|
|
@@ -5476,6 +6334,52 @@ class JsonEventsStore {
|
|
|
5476
6334
|
});
|
|
5477
6335
|
}
|
|
5478
6336
|
}
|
|
6337
|
+
async function getEventsStatus(dataDir) {
|
|
6338
|
+
const store = new JsonEventsStore(dataDir);
|
|
6339
|
+
await store.init();
|
|
6340
|
+
const [channels, events, deliveries] = await Promise.all([
|
|
6341
|
+
store.listChannels(),
|
|
6342
|
+
store.listEvents(),
|
|
6343
|
+
store.listDeliveries()
|
|
6344
|
+
]);
|
|
6345
|
+
const transports = channels.reduce((counts, channel) => {
|
|
6346
|
+
counts[channel.transport] = (counts[channel.transport] ?? 0) + 1;
|
|
6347
|
+
return counts;
|
|
6348
|
+
}, {});
|
|
6349
|
+
return {
|
|
6350
|
+
service: "events",
|
|
6351
|
+
schemaVersion: "1.0",
|
|
6352
|
+
dataDir: store.dataDir,
|
|
6353
|
+
env: {
|
|
6354
|
+
primary: HASNA_EVENTS_DIR_ENV,
|
|
6355
|
+
fallback: HASNA_EVENTS_HOME_ENV,
|
|
6356
|
+
active: getActiveEventsDirEnv()
|
|
6357
|
+
},
|
|
6358
|
+
files: {
|
|
6359
|
+
channels: statusFile(store.dataDir, "channels.json", channels.length),
|
|
6360
|
+
events: statusFile(store.dataDir, "events.json", events.length),
|
|
6361
|
+
deliveries: statusFile(store.dataDir, "deliveries.json", deliveries.length)
|
|
6362
|
+
},
|
|
6363
|
+
counts: {
|
|
6364
|
+
channels: channels.length,
|
|
6365
|
+
enabledChannels: channels.filter((channel) => channel.enabled).length,
|
|
6366
|
+
disabledChannels: channels.filter((channel) => !channel.enabled).length,
|
|
6367
|
+
events: events.length,
|
|
6368
|
+
deliveries: deliveries.length
|
|
6369
|
+
},
|
|
6370
|
+
transports,
|
|
6371
|
+
safety: {
|
|
6372
|
+
includesEventPayloads: false,
|
|
6373
|
+
includesWebhookSecrets: false,
|
|
6374
|
+
listOutputsRedactSecrets: true,
|
|
6375
|
+
statusOutputIsMetadataOnly: true
|
|
6376
|
+
}
|
|
6377
|
+
};
|
|
6378
|
+
}
|
|
6379
|
+
function statusFile(dataDir, fileName, records) {
|
|
6380
|
+
const path = join(dataDir, fileName);
|
|
6381
|
+
return { path, exists: existsSync(path), records };
|
|
6382
|
+
}
|
|
5479
6383
|
var DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
5480
6384
|
function buildSignatureBase(timestamp, body) {
|
|
5481
6385
|
return `${timestamp}.${body}`;
|
|
@@ -5701,7 +6605,7 @@ class EventsClient {
|
|
|
5701
6605
|
}
|
|
5702
6606
|
return deliveries;
|
|
5703
6607
|
}
|
|
5704
|
-
async
|
|
6608
|
+
async matchChannel(id, input = {}) {
|
|
5705
6609
|
const channel = await this.store.getChannel(id);
|
|
5706
6610
|
if (!channel)
|
|
5707
6611
|
throw new Error(`Channel not found: ${id}`);
|
|
@@ -5718,6 +6622,34 @@ class EventsClient {
|
|
|
5718
6622
|
time: input.time,
|
|
5719
6623
|
id: input.id
|
|
5720
6624
|
});
|
|
6625
|
+
const matched = channelMatchesEvent(channel, event);
|
|
6626
|
+
return {
|
|
6627
|
+
channelId: channel.id,
|
|
6628
|
+
matched,
|
|
6629
|
+
event,
|
|
6630
|
+
filters: channel.filters,
|
|
6631
|
+
reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
|
|
6632
|
+
};
|
|
6633
|
+
}
|
|
6634
|
+
async testChannel(id, input = {}, options = {}) {
|
|
6635
|
+
const channel = await this.store.getChannel(id);
|
|
6636
|
+
if (!channel)
|
|
6637
|
+
throw new Error(`Channel not found: ${id}`);
|
|
6638
|
+
const match = await this.matchChannel(id, input);
|
|
6639
|
+
const event = match.event;
|
|
6640
|
+
if (options.honorFilters && !match.matched) {
|
|
6641
|
+
const timestamp = new Date().toISOString();
|
|
6642
|
+
const result2 = createDeliveryResult(event, channel, [{
|
|
6643
|
+
attempt: 1,
|
|
6644
|
+
status: "skipped",
|
|
6645
|
+
startedAt: timestamp,
|
|
6646
|
+
completedAt: timestamp,
|
|
6647
|
+
error: match.reason
|
|
6648
|
+
}]);
|
|
6649
|
+
result2.metadata = { reason: "filter_mismatch" };
|
|
6650
|
+
await this.store.appendDelivery(result2);
|
|
6651
|
+
return result2;
|
|
6652
|
+
}
|
|
5721
6653
|
const eventForChannel = await this.applyRedaction(event, channel);
|
|
5722
6654
|
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
5723
6655
|
await this.store.appendDelivery(result);
|
|
@@ -5828,6 +6760,76 @@ function normalizeRetryPolicy(policy) {
|
|
|
5828
6760
|
multiplier: Math.max(1, policy?.multiplier ?? 2)
|
|
5829
6761
|
};
|
|
5830
6762
|
}
|
|
6763
|
+
function parseFieldMatchers(values, label, typed = false) {
|
|
6764
|
+
if (!values?.length)
|
|
6765
|
+
return;
|
|
6766
|
+
const result = {};
|
|
6767
|
+
for (const value of values) {
|
|
6768
|
+
const parsed = parseMatcherExpression(value, label);
|
|
6769
|
+
const path = parsed.path;
|
|
6770
|
+
if (path in result)
|
|
6771
|
+
throw new Error(`Duplicate ${label} filter path: ${path}`);
|
|
6772
|
+
const matcherValue = typed ? parseTypedMatcherValue(parsed.rawValue, label) : parsed.rawValue;
|
|
6773
|
+
result[path] = parsed.negated ? { not: matcherValue } : matcherValue;
|
|
6774
|
+
}
|
|
6775
|
+
return result;
|
|
6776
|
+
}
|
|
6777
|
+
function parseFilterOptions(options) {
|
|
6778
|
+
const filter2 = {};
|
|
6779
|
+
if (options.source)
|
|
6780
|
+
filter2.source = options.source;
|
|
6781
|
+
if (options.type)
|
|
6782
|
+
filter2.type = options.type;
|
|
6783
|
+
if (options.subject)
|
|
6784
|
+
filter2.subject = options.subject;
|
|
6785
|
+
if (options.severity)
|
|
6786
|
+
filter2.severity = options.severity;
|
|
6787
|
+
const data = mergeMatchers(parseFieldMatchers(options.data, "data"), parseFieldMatchers(options.dataJson, "data-json", true));
|
|
6788
|
+
const metadata = mergeMatchers(parseFieldMatchers(options.metadata, "metadata"), parseFieldMatchers(options.metadataJson, "metadata-json", true));
|
|
6789
|
+
if (Object.keys(data).length > 0)
|
|
6790
|
+
filter2.data = data;
|
|
6791
|
+
if (Object.keys(metadata).length > 0)
|
|
6792
|
+
filter2.metadata = metadata;
|
|
6793
|
+
return Object.keys(filter2).length > 0 ? [filter2] : undefined;
|
|
6794
|
+
}
|
|
6795
|
+
function mergeMatchers(...records) {
|
|
6796
|
+
const result = {};
|
|
6797
|
+
for (const record of records) {
|
|
6798
|
+
if (!record)
|
|
6799
|
+
continue;
|
|
6800
|
+
for (const [path, value] of Object.entries(record)) {
|
|
6801
|
+
if (path in result)
|
|
6802
|
+
throw new Error(`Duplicate filter path: ${path}`);
|
|
6803
|
+
result[path] = value;
|
|
6804
|
+
}
|
|
6805
|
+
}
|
|
6806
|
+
return result;
|
|
6807
|
+
}
|
|
6808
|
+
function parseTypedMatcherValue(value, label) {
|
|
6809
|
+
const parsed = JSON.parse(value);
|
|
6810
|
+
if (parsed === null || typeof parsed === "string" || typeof parsed === "number" || typeof parsed === "boolean" || Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
|
|
6811
|
+
return parsed;
|
|
6812
|
+
}
|
|
6813
|
+
throw new Error(`${label} filter JSON values must be string, string[], number, boolean, or null`);
|
|
6814
|
+
}
|
|
6815
|
+
function parseMatcherExpression(value, label) {
|
|
6816
|
+
const negativeSeparator = value.indexOf("!=");
|
|
6817
|
+
if (negativeSeparator > 0) {
|
|
6818
|
+
return {
|
|
6819
|
+
path: value.slice(0, negativeSeparator),
|
|
6820
|
+
rawValue: value.slice(negativeSeparator + 2),
|
|
6821
|
+
negated: true
|
|
6822
|
+
};
|
|
6823
|
+
}
|
|
6824
|
+
const separator = value.indexOf("=");
|
|
6825
|
+
if (separator <= 0)
|
|
6826
|
+
throw new Error(`Invalid ${label} filter, expected path=value or path!=value: ${value}`);
|
|
6827
|
+
return {
|
|
6828
|
+
path: value.slice(0, separator),
|
|
6829
|
+
rawValue: value.slice(separator + 1),
|
|
6830
|
+
negated: false
|
|
6831
|
+
};
|
|
6832
|
+
}
|
|
5831
6833
|
function parseJsonObject(value, fallback) {
|
|
5832
6834
|
if (!value)
|
|
5833
6835
|
return fallback;
|
|
@@ -5849,18 +6851,6 @@ function parseHeaders(values) {
|
|
|
5849
6851
|
}
|
|
5850
6852
|
return headers;
|
|
5851
6853
|
}
|
|
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
6854
|
function createClient(options) {
|
|
5865
6855
|
if (options.createClient)
|
|
5866
6856
|
return options.createClient();
|
|
@@ -5878,16 +6868,16 @@ function hasJsonOption(options) {
|
|
|
5878
6868
|
function wantsJson(actionOptions, command) {
|
|
5879
6869
|
return hasJsonOption(actionOptions) || hasJsonOption(command);
|
|
5880
6870
|
}
|
|
5881
|
-
function
|
|
5882
|
-
const
|
|
5883
|
-
|
|
6871
|
+
function registerChannelCommands(program, options) {
|
|
6872
|
+
const channels = program.command(options.channelsCommandName ?? "channels").description("Manage Hasna event channels");
|
|
6873
|
+
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
6874
|
const timestamp = new Date().toISOString();
|
|
5885
6875
|
const channel = {
|
|
5886
6876
|
id: actionOptions.id,
|
|
5887
6877
|
name: actionOptions.name,
|
|
5888
6878
|
enabled: !actionOptions.disabled,
|
|
5889
6879
|
transport: actionOptions.transport,
|
|
5890
|
-
filters:
|
|
6880
|
+
filters: parseFilterOptions(actionOptions),
|
|
5891
6881
|
retry: actionOptions.retryAttempts || actionOptions.retryBackoffMs ? { maxAttempts: actionOptions.retryAttempts, backoffMs: actionOptions.retryBackoffMs } : undefined,
|
|
5892
6882
|
redact: actionOptions.redact?.length ? { paths: actionOptions.redact } : undefined,
|
|
5893
6883
|
createdAt: timestamp,
|
|
@@ -5903,35 +6893,51 @@ function registerWebhookCommands(program, options) {
|
|
|
5903
6893
|
const saved = await createClient(options).addChannel(channel);
|
|
5904
6894
|
print(sanitizeChannelForOutput(saved), wantsJson(actionOptions, command), `Added ${saved.transport} channel ${saved.id}`);
|
|
5905
6895
|
});
|
|
5906
|
-
|
|
5907
|
-
const
|
|
6896
|
+
channels.command("list").description("List configured channels").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
|
|
6897
|
+
const channels2 = await createClient(options).listChannels();
|
|
5908
6898
|
if (wantsJson(actionOptions, command)) {
|
|
5909
|
-
console.log(JSON.stringify(sanitizeChannelsForOutput(
|
|
6899
|
+
console.log(JSON.stringify(sanitizeChannelsForOutput(channels2), null, 2));
|
|
5910
6900
|
return;
|
|
5911
6901
|
}
|
|
5912
|
-
if (!
|
|
6902
|
+
if (!channels2.length) {
|
|
5913
6903
|
console.log("No channels configured.");
|
|
5914
6904
|
return;
|
|
5915
6905
|
}
|
|
5916
|
-
for (const channel of
|
|
6906
|
+
for (const channel of channels2) {
|
|
5917
6907
|
console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
|
|
5918
6908
|
}
|
|
5919
6909
|
});
|
|
5920
|
-
|
|
6910
|
+
channels.command("status").description("Show events channel storage status").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
|
|
6911
|
+
const status = await getEventsStatus(options.dataDir);
|
|
6912
|
+
print(status, wantsJson(actionOptions, command), `events dataDir: ${status.dataDir}`);
|
|
6913
|
+
});
|
|
6914
|
+
channels.command("remove").description("Remove a channel").argument("<id>", "Channel identifier").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
|
|
5921
6915
|
const removed = await createClient(options).removeChannel(id);
|
|
5922
6916
|
print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
|
|
5923
6917
|
});
|
|
5924
|
-
|
|
6918
|
+
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
6919
|
const result = await createClient(options).testChannel(id, {
|
|
5926
|
-
source: options.source,
|
|
6920
|
+
source: actionOptions.source ?? options.source,
|
|
5927
6921
|
type: actionOptions.type,
|
|
5928
6922
|
subject: actionOptions.subject ?? id,
|
|
5929
6923
|
message: actionOptions.message,
|
|
5930
|
-
data: parseJsonObject(actionOptions.data, { test: true })
|
|
5931
|
-
|
|
6924
|
+
data: parseJsonObject(actionOptions.data, { test: true }),
|
|
6925
|
+
metadata: parseJsonObject(actionOptions.metadata, {})
|
|
6926
|
+
}, { honorFilters: actionOptions.honorFilters });
|
|
5932
6927
|
print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
|
|
5933
6928
|
});
|
|
5934
|
-
|
|
6929
|
+
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) => {
|
|
6930
|
+
const result = await createClient(options).matchChannel(id, {
|
|
6931
|
+
source: actionOptions.source ?? options.source,
|
|
6932
|
+
type: actionOptions.type,
|
|
6933
|
+
subject: actionOptions.subject ?? id,
|
|
6934
|
+
message: actionOptions.message,
|
|
6935
|
+
data: parseJsonObject(actionOptions.data, { test: true }),
|
|
6936
|
+
metadata: parseJsonObject(actionOptions.metadata, {})
|
|
6937
|
+
});
|
|
6938
|
+
print(result, wantsJson(actionOptions, command), `${result.matched ? "matched" : "skipped"}: ${result.channelId}`);
|
|
6939
|
+
});
|
|
6940
|
+
return channels;
|
|
5935
6941
|
}
|
|
5936
6942
|
function registerEventCommands(program, options) {
|
|
5937
6943
|
const events = program.command(options.eventsCommandName ?? "events").description("Emit, list, and replay Hasna events");
|
|
@@ -5979,7 +6985,7 @@ function registerEventCommands(program, options) {
|
|
|
5979
6985
|
return events;
|
|
5980
6986
|
}
|
|
5981
6987
|
function registerEventsCommands(program, options) {
|
|
5982
|
-
|
|
6988
|
+
registerChannelCommands(program, options);
|
|
5983
6989
|
registerEventCommands(program, options);
|
|
5984
6990
|
}
|
|
5985
6991
|
function parseNumber(value) {
|
|
@@ -7204,6 +8210,8 @@ function resolveScope(options) {
|
|
|
7204
8210
|
function resolveTarget(options) {
|
|
7205
8211
|
if (options.target === "gemini")
|
|
7206
8212
|
return "gemini";
|
|
8213
|
+
if (options.target === "codewith")
|
|
8214
|
+
return "codewith";
|
|
7207
8215
|
if (options.target === "all")
|
|
7208
8216
|
return "all";
|
|
7209
8217
|
return "claude";
|
|
@@ -7302,7 +8310,7 @@ program2.command("run").argument("<hook>", "Hook to run").option("--profile <id>
|
|
|
7302
8310
|
process.stderr.write(stderr);
|
|
7303
8311
|
process.exit(exitCode);
|
|
7304
8312
|
});
|
|
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) => {
|
|
8313
|
+
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
8314
|
const scope = resolveScope(options);
|
|
7307
8315
|
const target = resolveTarget(options);
|
|
7308
8316
|
let toInstall = hooks;
|
|
@@ -7325,11 +8333,20 @@ program2.command("install").alias("add").argument("[hooks...]", "Hooks to instal
|
|
|
7325
8333
|
render(/* @__PURE__ */ jsxDEV8(App, {}, undefined, false, undefined, this));
|
|
7326
8334
|
return;
|
|
7327
8335
|
}
|
|
8336
|
+
if (options.applyCodewith && (target === "codewith" || target === "all") && !options.codewithConfig) {
|
|
8337
|
+
const message = "--apply-codewith requires --codewith-config <path>; refusing to write default ~/.codewith/config.toml.";
|
|
8338
|
+
if (options.json) {
|
|
8339
|
+
console.log(JSON.stringify({ error: message, scope, target, applied: false }));
|
|
8340
|
+
} else {
|
|
8341
|
+
console.log(chalk2.red(message));
|
|
8342
|
+
}
|
|
8343
|
+
return;
|
|
8344
|
+
}
|
|
7328
8345
|
if (options.dryRun) {
|
|
7329
8346
|
const known = toInstall.filter((n) => getHook(n));
|
|
7330
8347
|
const unknown = toInstall.filter((n) => !getHook(n));
|
|
7331
8348
|
if (options.json) {
|
|
7332
|
-
console.log(JSON.stringify({ dryRun: true, would_install: known, unknown, scope, target }));
|
|
8349
|
+
console.log(JSON.stringify({ dryRun: true, would_install: known, unknown, scope, target, mode: target === "codewith" ? "fragment" : "write" }));
|
|
7333
8350
|
return;
|
|
7334
8351
|
}
|
|
7335
8352
|
console.log(chalk2.bold(`
|
|
@@ -7356,21 +8373,30 @@ Dry run \u2014 would install (${scope}, ${target}):
|
|
|
7356
8373
|
results.push({ hook: name, success: false, error: `Hook '${name}' not found${hint}` });
|
|
7357
8374
|
continue;
|
|
7358
8375
|
}
|
|
7359
|
-
const result = installHook(name, {
|
|
8376
|
+
const result = installHook(name, {
|
|
8377
|
+
scope,
|
|
8378
|
+
overwrite: options.overwrite,
|
|
8379
|
+
target,
|
|
8380
|
+
profile: options.profile,
|
|
8381
|
+
codewithMode: options.applyCodewith ? "write" : "fragment",
|
|
8382
|
+
codewithConfigPath: options.codewithConfig
|
|
8383
|
+
});
|
|
7360
8384
|
results.push(result);
|
|
7361
8385
|
}
|
|
7362
8386
|
if (options.json) {
|
|
7363
8387
|
console.log(JSON.stringify({
|
|
7364
8388
|
installed: results.filter((r) => r.success).map((r) => r.hook),
|
|
7365
8389
|
failed: results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error })),
|
|
8390
|
+
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
8391
|
total: results.length,
|
|
7367
8392
|
success: results.filter((r) => r.success).length,
|
|
7368
8393
|
scope,
|
|
7369
|
-
target
|
|
8394
|
+
target,
|
|
8395
|
+
applied: results.some((r) => r.applied)
|
|
7370
8396
|
}));
|
|
7371
8397
|
return;
|
|
7372
8398
|
}
|
|
7373
|
-
const settingsFile = scope === "project" ? ".claude/settings.json" : "~/.claude/settings.json";
|
|
8399
|
+
const settingsFile = target === "codewith" ? options.applyCodewith ? options.codewithConfig : "TOML fragment only (open-configs should apply)" : scope === "project" ? ".claude/settings.json" : "~/.claude/settings.json";
|
|
7374
8400
|
console.log(chalk2.bold(`
|
|
7375
8401
|
Installing hooks (${scope}, ${target})...
|
|
7376
8402
|
`));
|
|
@@ -7384,6 +8410,14 @@ Installing hooks (${scope}, ${target})...
|
|
|
7384
8410
|
if (result.conflict) {
|
|
7385
8411
|
console.log(chalk2.yellow(` \u26A0 Warning: ${result.conflict}`));
|
|
7386
8412
|
}
|
|
8413
|
+
if (result.fragment && target === "codewith") {
|
|
8414
|
+
console.log(chalk2.dim(" Codewith TOML fragment:"));
|
|
8415
|
+
console.log(chalk2.cyan(result.fragment.trimEnd().split(`
|
|
8416
|
+
`).map((line) => ` ${line}`).join(`
|
|
8417
|
+
`)));
|
|
8418
|
+
if (result.note)
|
|
8419
|
+
console.log(chalk2.yellow(` \u26A0 ${result.note}`));
|
|
8420
|
+
}
|
|
7387
8421
|
} else {
|
|
7388
8422
|
console.log(chalk2.red(`\u2717 ${result.hook}: ${result.error}`));
|
|
7389
8423
|
}
|
|
@@ -7391,10 +8425,10 @@ Installing hooks (${scope}, ${target})...
|
|
|
7391
8425
|
console.log(chalk2.dim(`
|
|
7392
8426
|
Registered in ${settingsFile}`));
|
|
7393
8427
|
});
|
|
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) => {
|
|
8428
|
+
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
8429
|
const scope = resolveScope(options);
|
|
7396
8430
|
if (options.registered || options.installed) {
|
|
7397
|
-
const target = options.target === "gemini" ? "gemini" : "claude";
|
|
8431
|
+
const target = options.target === "gemini" ? "gemini" : options.target === "codewith" ? "codewith" : "claude";
|
|
7398
8432
|
const registered = getRegisteredHooksForTarget(scope, target);
|
|
7399
8433
|
if (options.json) {
|
|
7400
8434
|
console.log(JSON.stringify(registered.map((name) => {
|
|
@@ -7478,7 +8512,7 @@ Found ${results.length} hook(s):
|
|
|
7478
8512
|
console.log(` ${h.description}`);
|
|
7479
8513
|
}
|
|
7480
8514
|
});
|
|
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) => {
|
|
8515
|
+
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
8516
|
const scope = resolveScope(options);
|
|
7483
8517
|
const target = resolveTarget(options);
|
|
7484
8518
|
if (!getHook(hook)) {
|
|
@@ -7589,7 +8623,7 @@ program2.command("doctor").option("-g, --global", "Check global settings", false
|
|
|
7589
8623
|
const settings = JSON.parse(readFileSync5(settingsPath, "utf-8"));
|
|
7590
8624
|
const eventHooks = settings.hooks?.[meta.event] || [];
|
|
7591
8625
|
const found = eventHooks.some((entry) => entry.hooks?.some((h) => {
|
|
7592
|
-
const match = h.command?.match(/^hooks run (\w+)/);
|
|
8626
|
+
const match = h.command?.match(/^hooks run ([\w-]+)/);
|
|
7593
8627
|
return match && match[1] === name;
|
|
7594
8628
|
}));
|
|
7595
8629
|
if (!found) {
|
|
@@ -7720,14 +8754,18 @@ ${meta.displayName} v${meta.version}
|
|
|
7720
8754
|
const generalDocs = {
|
|
7721
8755
|
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
8756
|
events: {
|
|
8757
|
+
SessionStart: "Fires when a session starts or resumes. Codewith can inject context via hookSpecificOutput.additionalContext.",
|
|
8758
|
+
UserPromptSubmit: "Codewith-native event when a user prompt is submitted; can block obvious injection attempts.",
|
|
7723
8759
|
PreToolUse: 'Fires before a tool executes. Can block the operation by returning { "decision": "block" }.',
|
|
7724
8760
|
PostToolUse: "Fires after a tool executes. Runs asynchronously, cannot block.",
|
|
7725
|
-
Stop: "Fires when
|
|
7726
|
-
Notification: "Fires on notification events like context compaction."
|
|
8761
|
+
Stop: "Fires at turn end in Codewith and when other agents finish responding. Useful for notifications and cleanup.",
|
|
8762
|
+
Notification: "Fires on notification events like context compaction.",
|
|
8763
|
+
SessionEnd: "Fires when a session terminates. Useful for cleanup and final announcements."
|
|
7727
8764
|
},
|
|
7728
8765
|
installation: {
|
|
7729
8766
|
global: "hooks install gitguard",
|
|
7730
8767
|
project: "hooks install gitguard --project",
|
|
8768
|
+
codewith: "hooks install session-start --target codewith # emits TOML for open-configs to apply",
|
|
7731
8769
|
category: 'hooks install --category "Git Safety"',
|
|
7732
8770
|
all: "hooks install --all"
|
|
7733
8771
|
},
|
|
@@ -7743,7 +8781,7 @@ ${meta.displayName} v${meta.version}
|
|
|
7743
8781
|
},
|
|
7744
8782
|
howItWorks: {
|
|
7745
8783
|
install: "bun install -g @hasna/hooks",
|
|
7746
|
-
register: "hooks install gitguard \u2192 writes to ~/.claude/settings.json",
|
|
8784
|
+
register: "hooks install gitguard \u2192 writes to ~/.claude/settings.json; hooks install session-start --target codewith emits a TOML fragment",
|
|
7747
8785
|
execution: "Agent runs 'hooks run gitguard' \u2192 executes hook from global package",
|
|
7748
8786
|
noFileCopy: "No files are copied to your project. Hooks run from the global @hasna/hooks package."
|
|
7749
8787
|
}
|
|
@@ -8050,6 +9088,80 @@ logCmd.command("clear").description("Delete hook event logs").option("--hook <na
|
|
|
8050
9088
|
}
|
|
8051
9089
|
console.log(chalk2.green(`\u2713 Cleared ${count} event(s).`));
|
|
8052
9090
|
});
|
|
9091
|
+
var storageCmd = program2.command("storage").description("Sync local hook data with storage PostgreSQL");
|
|
9092
|
+
storageCmd.command("status").description("Show storage sync status").option("-j, --json", "Output as JSON", false).action(async (options) => {
|
|
9093
|
+
const { getStorageStatus: getStorageStatus2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
|
|
9094
|
+
const status = getStorageStatus2();
|
|
9095
|
+
if (options.json) {
|
|
9096
|
+
console.log(JSON.stringify(status, null, 2));
|
|
9097
|
+
return;
|
|
9098
|
+
}
|
|
9099
|
+
console.log(chalk2.bold(`
|
|
9100
|
+
Storage Status
|
|
9101
|
+
`));
|
|
9102
|
+
console.log(` Configured: ${status.configured ? chalk2.green(`yes (${status.activeEnv})`) : chalk2.red("no")}`);
|
|
9103
|
+
console.log(` Mode: ${status.mode}`);
|
|
9104
|
+
console.log(` Tables: ${status.tables.join(", ")}`);
|
|
9105
|
+
console.log(` Sync rows: ${status.sync.length}`);
|
|
9106
|
+
});
|
|
9107
|
+
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) => {
|
|
9108
|
+
try {
|
|
9109
|
+
const { parseStorageTables: parseStorageTables2, storagePush: storagePush2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
|
|
9110
|
+
const results = await storagePush2({ tables: parseStorageTables2(options.tables) });
|
|
9111
|
+
if (options.json) {
|
|
9112
|
+
console.log(JSON.stringify(results, null, 2));
|
|
9113
|
+
return;
|
|
9114
|
+
}
|
|
9115
|
+
const written = results.reduce((sum, result) => sum + result.rowsWritten, 0);
|
|
9116
|
+
console.log(chalk2.green(`\u2713 Pushed ${written} 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
|
+
});
|
|
9126
|
+
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) => {
|
|
9127
|
+
try {
|
|
9128
|
+
const { parseStorageTables: parseStorageTables2, storagePull: storagePull2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
|
|
9129
|
+
const results = await storagePull2({ tables: parseStorageTables2(options.tables) });
|
|
9130
|
+
if (options.json) {
|
|
9131
|
+
console.log(JSON.stringify(results, null, 2));
|
|
9132
|
+
return;
|
|
9133
|
+
}
|
|
9134
|
+
const written = results.reduce((sum, result) => sum + result.rowsWritten, 0);
|
|
9135
|
+
console.log(chalk2.green(`\u2713 Pulled ${written} row(s)`));
|
|
9136
|
+
} catch (error) {
|
|
9137
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
9138
|
+
if (options.json)
|
|
9139
|
+
console.log(JSON.stringify({ error: message }));
|
|
9140
|
+
else
|
|
9141
|
+
console.error(chalk2.red(`\u2717 ${message}`));
|
|
9142
|
+
process.exitCode = 1;
|
|
9143
|
+
}
|
|
9144
|
+
});
|
|
9145
|
+
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) => {
|
|
9146
|
+
try {
|
|
9147
|
+
const { parseStorageTables: parseStorageTables2, storageSync: storageSync2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
|
|
9148
|
+
const result = await storageSync2({ tables: parseStorageTables2(options.tables) });
|
|
9149
|
+
if (options.json) {
|
|
9150
|
+
console.log(JSON.stringify(result, null, 2));
|
|
9151
|
+
return;
|
|
9152
|
+
}
|
|
9153
|
+
const pulled = result.pull.reduce((sum, entry) => sum + entry.rowsWritten, 0);
|
|
9154
|
+
const pushed = result.push.reduce((sum, entry) => sum + entry.rowsWritten, 0);
|
|
9155
|
+
console.log(chalk2.green(`\u2713 Synced ${pulled} pulled row(s), ${pushed} pushed row(s)`));
|
|
9156
|
+
} catch (error) {
|
|
9157
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
9158
|
+
if (options.json)
|
|
9159
|
+
console.log(JSON.stringify({ error: message }));
|
|
9160
|
+
else
|
|
9161
|
+
console.error(chalk2.red(`\u2717 ${message}`));
|
|
9162
|
+
process.exitCode = 1;
|
|
9163
|
+
}
|
|
9164
|
+
});
|
|
8053
9165
|
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
9166
|
if (options.stdio) {
|
|
8055
9167
|
const { startStdioServer: startStdioServer2 } = await Promise.resolve().then(() => (init_server(), exports_server));
|