sapper-ai 0.7.0 → 0.8.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.
Files changed (39) hide show
  1. package/dist/cli.d.ts +3 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +280 -5
  4. package/dist/guard/ScanCache.d.ts +42 -0
  5. package/dist/guard/ScanCache.d.ts.map +1 -0
  6. package/dist/guard/ScanCache.js +261 -0
  7. package/dist/guard/WarningStore.d.ts +33 -0
  8. package/dist/guard/WarningStore.d.ts.map +1 -0
  9. package/dist/guard/WarningStore.js +305 -0
  10. package/dist/guard/getDefaultPolicy.d.ts +3 -0
  11. package/dist/guard/getDefaultPolicy.d.ts.map +1 -0
  12. package/dist/guard/getDefaultPolicy.js +23 -0
  13. package/dist/guard/hooks/guardCheck.d.ts +12 -0
  14. package/dist/guard/hooks/guardCheck.d.ts.map +1 -0
  15. package/dist/guard/hooks/guardCheck.js +137 -0
  16. package/dist/guard/hooks/guardScan.d.ts +27 -0
  17. package/dist/guard/hooks/guardScan.d.ts.map +1 -0
  18. package/dist/guard/hooks/guardScan.js +242 -0
  19. package/dist/guard/scanSingleSkill.d.ts +11 -0
  20. package/dist/guard/scanSingleSkill.d.ts.map +1 -0
  21. package/dist/guard/scanSingleSkill.js +82 -0
  22. package/dist/guard/setup.d.ts +44 -0
  23. package/dist/guard/setup.d.ts.map +1 -0
  24. package/dist/guard/setup.js +296 -0
  25. package/dist/guard/types.d.ts +43 -0
  26. package/dist/guard/types.d.ts.map +1 -0
  27. package/dist/guard/types.js +2 -0
  28. package/dist/harden.d.ts.map +1 -1
  29. package/dist/harden.js +2 -6
  30. package/dist/postinstall.d.ts.map +1 -1
  31. package/dist/postinstall.js +40 -1
  32. package/dist/scan.d.ts.map +1 -1
  33. package/dist/scan.js +7 -2
  34. package/dist/utils/fs.d.ts.map +1 -1
  35. package/dist/utils/fs.js +7 -1
  36. package/dist/utils/interactive.d.ts +15 -0
  37. package/dist/utils/interactive.d.ts.map +1 -0
  38. package/dist/utils/interactive.js +29 -0
  39. package/package.json +3 -3
@@ -0,0 +1,242 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.guardScan = guardScan;
4
+ const promises_1 = require("node:fs/promises");
5
+ const node_crypto_1 = require("node:crypto");
6
+ const node_process_1 = require("node:process");
7
+ const node_os_1 = require("node:os");
8
+ const node_path_1 = require("node:path");
9
+ const ScanCache_1 = require("../ScanCache");
10
+ const WarningStore_1 = require("../WarningStore");
11
+ const scanSingleSkill_1 = require("../scanSingleSkill");
12
+ function hashContent(content) {
13
+ return (0, node_crypto_1.createHash)('sha256').update(content).digest('hex');
14
+ }
15
+ function normalizeWatchPaths(options) {
16
+ if (Array.isArray(options.watchPaths) && options.watchPaths.length > 0) {
17
+ return Array.from(new Set(options.watchPaths.map((path) => (0, node_path_1.resolve)(path))));
18
+ }
19
+ const currentHomeDir = options.homeDir ?? (0, node_os_1.homedir)();
20
+ const currentWorkingDirectory = options.currentWorkingDirectory ?? (0, node_process_1.cwd)();
21
+ return [
22
+ (0, node_path_1.join)(currentHomeDir, '.claude', 'plugins'),
23
+ (0, node_path_1.join)(currentHomeDir, '.claude', 'skills'),
24
+ (0, node_path_1.join)(currentWorkingDirectory, '.claude', 'skills'),
25
+ ];
26
+ }
27
+ async function isDirectory(path, statFn) {
28
+ try {
29
+ const info = await statFn(path);
30
+ return info.isDirectory();
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ function isSubpath(parent, candidate) {
37
+ const rel = (0, node_path_1.relative)(parent, candidate);
38
+ if (rel === '') {
39
+ return true;
40
+ }
41
+ if (rel === '..' || rel.startsWith(`..${node_path_1.sep}`)) {
42
+ return false;
43
+ }
44
+ if (rel.includes(':')) {
45
+ return false;
46
+ }
47
+ return true;
48
+ }
49
+ async function collectMarkdownFiles(rootPath, readDirFn, statFn, resolvePath) {
50
+ const files = new Set();
51
+ const seenDirectories = new Set();
52
+ const resolvedRootPath = await resolvePath(rootPath).catch(() => rootPath);
53
+ const stack = [rootPath];
54
+ while (stack.length > 0) {
55
+ const current = stack.pop();
56
+ if (!current) {
57
+ continue;
58
+ }
59
+ let resolvedCurrent;
60
+ try {
61
+ resolvedCurrent = await resolvePath(current);
62
+ }
63
+ catch {
64
+ resolvedCurrent = current;
65
+ }
66
+ if (!isSubpath(resolvedRootPath, resolvedCurrent)) {
67
+ continue;
68
+ }
69
+ if (seenDirectories.has(resolvedCurrent)) {
70
+ continue;
71
+ }
72
+ seenDirectories.add(resolvedCurrent);
73
+ let entries;
74
+ try {
75
+ entries = await readDirFn(current, { withFileTypes: true, encoding: 'utf8' });
76
+ }
77
+ catch {
78
+ continue;
79
+ }
80
+ for (const entry of entries) {
81
+ const fullPath = (0, node_path_1.join)(current, entry.name);
82
+ if (entry.isDirectory()) {
83
+ stack.push(fullPath);
84
+ continue;
85
+ }
86
+ if (entry.isSymbolicLink()) {
87
+ try {
88
+ const resolvedSymlinkPath = await resolvePath(fullPath);
89
+ if (!isSubpath(resolvedRootPath, resolvedSymlinkPath)) {
90
+ continue;
91
+ }
92
+ const info = await statFn(fullPath);
93
+ if (info.isDirectory()) {
94
+ stack.push(fullPath);
95
+ continue;
96
+ }
97
+ if (info.isFile() && entry.name.toLowerCase().endsWith('.md')) {
98
+ files.add(fullPath);
99
+ }
100
+ }
101
+ catch {
102
+ continue;
103
+ }
104
+ continue;
105
+ }
106
+ if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
107
+ files.add(fullPath);
108
+ }
109
+ }
110
+ }
111
+ return Array.from(files).sort();
112
+ }
113
+ function renderSummary(summary) {
114
+ if (summary.totalSkills === 0) {
115
+ return 'SapperAI: no skill files found.';
116
+ }
117
+ if (summary.scanned === 0 && summary.errors === 0) {
118
+ return `SapperAI: checked ${summary.totalSkills} skill files, no content changes.`;
119
+ }
120
+ const parts = [
121
+ `SapperAI: checked ${summary.totalSkills} skill files`,
122
+ `${summary.scanned} scanned`,
123
+ `${summary.cached} cached`,
124
+ ];
125
+ if (summary.suspicious > 0) {
126
+ parts.push(`${summary.suspicious} suspicious`);
127
+ }
128
+ if (summary.errors > 0) {
129
+ parts.push(`${summary.errors} errors`);
130
+ }
131
+ return parts.join(', ') + '.';
132
+ }
133
+ function writeJson(writer, payload) {
134
+ writer.write(`${JSON.stringify(payload)}\n`);
135
+ }
136
+ function writeError(stderr, message) {
137
+ stderr.write(`[sapper-ai] ${message}\n`);
138
+ }
139
+ async function guardScan(options = {}) {
140
+ const stdout = options.stdout ?? process.stdout;
141
+ const stderr = options.stderr ?? process.stderr;
142
+ const scanCache = options.scanCache ?? new ScanCache_1.ScanCache({ homeDir: options.homeDir });
143
+ const warningStore = options.warningStore ?? new WarningStore_1.WarningStore({ homeDir: options.homeDir });
144
+ const scanSkill = options.scanSkillFn ?? ((path) => (0, scanSingleSkill_1.scanSingleSkill)(path));
145
+ const read = options.readFileFn ?? promises_1.readFile;
146
+ const resolvePath = options.realpathFn ?? promises_1.realpath;
147
+ const readDir = options.readdirFn ??
148
+ ((filePath, readOptions) => (0, promises_1.readdir)(filePath, readOptions));
149
+ const statPath = options.statFn ?? promises_1.stat;
150
+ const now = options.now ?? Date.now;
151
+ const summary = {
152
+ watchPathCount: 0,
153
+ totalSkills: 0,
154
+ scanned: 0,
155
+ cached: 0,
156
+ suspicious: 0,
157
+ errors: 0,
158
+ cacheValid: true,
159
+ };
160
+ try {
161
+ const verifyResult = await scanCache.verify();
162
+ summary.cacheValid = verifyResult.valid;
163
+ const watchPaths = normalizeWatchPaths(options);
164
+ const seenSkills = new Set();
165
+ for (const watchPath of watchPaths) {
166
+ const exists = await isDirectory(watchPath, statPath);
167
+ if (!exists) {
168
+ continue;
169
+ }
170
+ summary.watchPathCount += 1;
171
+ const files = await collectMarkdownFiles(watchPath, readDir, statPath, resolvePath);
172
+ for (const filePath of files) {
173
+ let resolvedFilePath = filePath;
174
+ try {
175
+ resolvedFilePath = await resolvePath(filePath);
176
+ }
177
+ catch {
178
+ resolvedFilePath = filePath;
179
+ }
180
+ if (seenSkills.has(resolvedFilePath)) {
181
+ continue;
182
+ }
183
+ seenSkills.add(resolvedFilePath);
184
+ summary.totalSkills += 1;
185
+ try {
186
+ const content = await read(resolvedFilePath, 'utf8');
187
+ const contentHash = hashContent(content);
188
+ if (await scanCache.has(contentHash)) {
189
+ summary.cached += 1;
190
+ continue;
191
+ }
192
+ const scanResult = await scanSkill(resolvedFilePath);
193
+ summary.scanned += 1;
194
+ await scanCache.set(scanResult.contentHash, {
195
+ path: scanResult.skillPath,
196
+ skillName: scanResult.skillName,
197
+ decision: scanResult.decision,
198
+ risk: scanResult.risk,
199
+ reasons: [...scanResult.reasons],
200
+ scannedAt: new Date(now()).toISOString(),
201
+ });
202
+ if (scanResult.decision === 'suspicious') {
203
+ summary.suspicious += 1;
204
+ await warningStore.addPending({
205
+ skillName: scanResult.skillName,
206
+ skillPath: scanResult.skillPath,
207
+ contentHash: scanResult.contentHash,
208
+ risk: scanResult.risk,
209
+ reasons: [...scanResult.reasons],
210
+ detectedAt: new Date(now()).toISOString(),
211
+ });
212
+ }
213
+ }
214
+ catch (error) {
215
+ summary.errors += 1;
216
+ const reason = error instanceof Error ? error.message : String(error);
217
+ writeError(stderr, `guard scan failed for ${resolvedFilePath}: ${reason}`);
218
+ }
219
+ }
220
+ }
221
+ }
222
+ catch (error) {
223
+ summary.errors += 1;
224
+ const reason = error instanceof Error ? error.message : String(error);
225
+ writeError(stderr, `guard scan failed: ${reason}`);
226
+ }
227
+ const payload = {
228
+ suppressPrompt: false,
229
+ message: renderSummary(summary),
230
+ summary: {
231
+ watchPathCount: summary.watchPathCount,
232
+ totalSkills: summary.totalSkills,
233
+ scanned: summary.scanned,
234
+ cached: summary.cached,
235
+ suspicious: summary.suspicious,
236
+ errors: summary.errors,
237
+ cacheValid: summary.cacheValid,
238
+ },
239
+ };
240
+ writeJson(stdout, payload);
241
+ return payload;
242
+ }
@@ -0,0 +1,11 @@
1
+ import type { Policy } from '@sapper-ai/types';
2
+ import type { SingleSkillScanResult } from './types';
3
+ export interface ScanSingleSkillOptions {
4
+ policy?: Policy;
5
+ suspiciousThreshold?: number;
6
+ readFileFn?: (filePath: string, encoding: BufferEncoding) => Promise<string>;
7
+ realpathFn?: (filePath: string) => Promise<string>;
8
+ now?: () => number;
9
+ }
10
+ export declare function scanSingleSkill(filePath: string, options?: ScanSingleSkillOptions): Promise<SingleSkillScanResult>;
11
+ //# sourceMappingURL=scanSingleSkill.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scanSingleSkill.d.ts","sourceRoot":"","sources":["../../src/guard/scanSingleSkill.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAqB,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAGjE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAA;AAIpD,MAAM,WAAW,sBAAsB;IACrC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,cAAc,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;IAC5E,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;IAClD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AA+CD,wBAAsB,eAAe,CACnC,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,qBAAqB,CAAC,CAoDhC"}
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scanSingleSkill = scanSingleSkill;
4
+ const promises_1 = require("node:fs/promises");
5
+ const node_crypto_1 = require("node:crypto");
6
+ const node_path_1 = require("node:path");
7
+ const core_1 = require("@sapper-ai/core");
8
+ const getDefaultPolicy_1 = require("./getDefaultPolicy");
9
+ const DEFAULT_SUSPICIOUS_THRESHOLD = 0.7;
10
+ function sha256(value) {
11
+ return (0, node_crypto_1.createHash)('sha256').update(value).digest('hex');
12
+ }
13
+ function fallbackSkillName(filePath) {
14
+ const fileName = (0, node_path_1.basename)(filePath);
15
+ return fileName.toLowerCase().endsWith('.md') ? fileName.slice(0, -3) : fileName;
16
+ }
17
+ function resolveThreshold(policy, override) {
18
+ if (typeof override === 'number') {
19
+ return override;
20
+ }
21
+ const threshold = policy.thresholds?.riskThreshold;
22
+ return typeof threshold === 'number' ? threshold : DEFAULT_SUSPICIOUS_THRESHOLD;
23
+ }
24
+ function toAssessmentContext(skillName, skillPath, policy, body, metadata, now) {
25
+ return {
26
+ kind: 'install_scan',
27
+ policy,
28
+ toolCall: {
29
+ toolName: `skill:${skillName}`,
30
+ arguments: {
31
+ content: body,
32
+ metadata,
33
+ },
34
+ },
35
+ meta: {
36
+ source: 'skill-guard',
37
+ sourcePath: skillPath,
38
+ sourceType: 'skill',
39
+ timestamp: now,
40
+ },
41
+ };
42
+ }
43
+ async function scanSingleSkill(filePath, options = {}) {
44
+ const resolvePath = options.realpathFn ?? promises_1.realpath;
45
+ const read = options.readFileFn ?? promises_1.readFile;
46
+ const now = options.now ?? Date.now;
47
+ const resolvedPath = await resolvePath(filePath);
48
+ const content = await read(resolvedPath, 'utf8');
49
+ const contentHash = sha256(content);
50
+ const policy = options.policy ?? (0, getDefaultPolicy_1.getDefaultPolicy)();
51
+ const suspiciousThreshold = resolveThreshold(policy, options.suspiciousThreshold);
52
+ let parsed;
53
+ try {
54
+ parsed = core_1.SkillParser.parse(content);
55
+ }
56
+ catch (error) {
57
+ const reason = error instanceof Error ? error.message : String(error);
58
+ return {
59
+ skillName: fallbackSkillName(resolvedPath),
60
+ skillPath: resolvedPath,
61
+ contentHash,
62
+ decision: 'suspicious',
63
+ risk: 1,
64
+ reasons: [`Parse error: ${reason}`],
65
+ };
66
+ }
67
+ const skillName = typeof parsed.metadata.name === 'string' && parsed.metadata.name.length > 0
68
+ ? parsed.metadata.name
69
+ : fallbackSkillName(resolvedPath);
70
+ const context = toAssessmentContext(skillName, resolvedPath, policy, parsed.body, parsed.metadata, now());
71
+ const detectors = (0, core_1.createDetectors)({ policy, preferredDetectors: ['rules'] });
72
+ const engine = new core_1.DecisionEngine(detectors);
73
+ const decision = await engine.assess(context);
74
+ return {
75
+ skillName,
76
+ skillPath: resolvedPath,
77
+ contentHash,
78
+ decision: decision.risk >= suspiciousThreshold ? 'suspicious' : 'safe',
79
+ risk: decision.risk,
80
+ reasons: [...decision.reasons],
81
+ };
82
+ }
@@ -0,0 +1,44 @@
1
+ export declare const SESSION_START_COMMAND = "sapper-ai guard scan";
2
+ export declare const USER_PROMPT_SUBMIT_COMMAND = "sapper-ai guard check";
3
+ type HookEvent = 'SessionStart' | 'UserPromptSubmit';
4
+ type SetupAction = 'registered' | 'already_registered' | 'removed' | 'not_registered' | 'missing_claude_dir';
5
+ export interface SetupPathOptions {
6
+ homeDir?: string;
7
+ claudeDirPath?: string;
8
+ settingsPath?: string;
9
+ }
10
+ export interface SetupCommandStatus {
11
+ event: HookEvent;
12
+ command: string;
13
+ timeoutSeconds: number;
14
+ registered: boolean;
15
+ matchCount: number;
16
+ }
17
+ export interface SetupStatusResult {
18
+ claudeDirPath: string;
19
+ settingsPath: string;
20
+ claudeDirExists: boolean;
21
+ settingsExists: boolean;
22
+ fullyRegistered: boolean;
23
+ commands: SetupCommandStatus[];
24
+ }
25
+ export interface RegisterHooksResult {
26
+ ok: boolean;
27
+ action: Extract<SetupAction, 'registered' | 'already_registered' | 'missing_claude_dir'>;
28
+ changed: boolean;
29
+ added: HookEvent[];
30
+ status: SetupStatusResult;
31
+ }
32
+ export interface RemoveHooksResult {
33
+ ok: boolean;
34
+ action: Extract<SetupAction, 'removed' | 'not_registered' | 'missing_claude_dir'>;
35
+ changed: boolean;
36
+ removedCount: number;
37
+ removedByEvent: Record<HookEvent, number>;
38
+ status: SetupStatusResult;
39
+ }
40
+ export declare function getStatus(options?: SetupPathOptions): SetupStatusResult;
41
+ export declare function registerHooks(options?: SetupPathOptions): RegisterHooksResult;
42
+ export declare function removeHooks(options?: SetupPathOptions): RemoveHooksResult;
43
+ export {};
44
+ //# sourceMappingURL=setup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setup.d.ts","sourceRoot":"","sources":["../../src/guard/setup.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,qBAAqB,yBAAyB,CAAA;AAC3D,eAAO,MAAM,0BAA0B,0BAA0B,CAAA;AAKjE,KAAK,SAAS,GAAG,cAAc,GAAG,kBAAkB,CAAA;AAmBpD,KAAK,WAAW,GAAG,YAAY,GAAG,oBAAoB,GAAG,SAAS,GAAG,gBAAgB,GAAG,oBAAoB,CAAA;AAE5G,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,SAAS,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;IACf,cAAc,EAAE,MAAM,CAAA;IACtB,UAAU,EAAE,OAAO,CAAA;IACnB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,aAAa,EAAE,MAAM,CAAA;IACrB,YAAY,EAAE,MAAM,CAAA;IACpB,eAAe,EAAE,OAAO,CAAA;IACxB,cAAc,EAAE,OAAO,CAAA;IACvB,eAAe,EAAE,OAAO,CAAA;IACxB,QAAQ,EAAE,kBAAkB,EAAE,CAAA;CAC/B;AAED,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,OAAO,CAAA;IACX,MAAM,EAAE,OAAO,CAAC,WAAW,EAAE,YAAY,GAAG,oBAAoB,GAAG,oBAAoB,CAAC,CAAA;IACxF,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,SAAS,EAAE,CAAA;IAClB,MAAM,EAAE,iBAAiB,CAAA;CAC1B;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,OAAO,CAAA;IACX,MAAM,EAAE,OAAO,CAAC,WAAW,EAAE,SAAS,GAAG,gBAAgB,GAAG,oBAAoB,CAAC,CAAA;IACjF,OAAO,EAAE,OAAO,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,cAAc,EAAE,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,CAAA;IACzC,MAAM,EAAE,iBAAiB,CAAA;CAC1B;AAwQD,wBAAgB,SAAS,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,iBAAiB,CAUvE;AAED,wBAAgB,aAAa,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,mBAAmB,CA+B7E;AAED,wBAAgB,WAAW,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,iBAAiB,CAyCzE"}
@@ -0,0 +1,296 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.USER_PROMPT_SUBMIT_COMMAND = exports.SESSION_START_COMMAND = void 0;
4
+ exports.getStatus = getStatus;
5
+ exports.registerHooks = registerHooks;
6
+ exports.removeHooks = removeHooks;
7
+ const node_fs_1 = require("node:fs");
8
+ const node_os_1 = require("node:os");
9
+ const node_path_1 = require("node:path");
10
+ exports.SESSION_START_COMMAND = 'sapper-ai guard scan';
11
+ exports.USER_PROMPT_SUBMIT_COMMAND = 'sapper-ai guard check';
12
+ const SESSION_START_TIMEOUT_SECONDS = 30;
13
+ const USER_PROMPT_SUBMIT_TIMEOUT_SECONDS = 5;
14
+ const MANAGED_HOOKS = [
15
+ {
16
+ event: 'SessionStart',
17
+ command: exports.SESSION_START_COMMAND,
18
+ timeoutSeconds: SESSION_START_TIMEOUT_SECONDS,
19
+ },
20
+ {
21
+ event: 'UserPromptSubmit',
22
+ command: exports.USER_PROMPT_SUBMIT_COMMAND,
23
+ timeoutSeconds: USER_PROMPT_SUBMIT_TIMEOUT_SECONDS,
24
+ },
25
+ ];
26
+ function isRecord(value) {
27
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
28
+ }
29
+ function resolvePaths(options) {
30
+ const homePath = options?.homeDir ?? (0, node_os_1.homedir)();
31
+ const claudeDirPath = options?.claudeDirPath ?? (0, node_path_1.join)(homePath, '.claude');
32
+ const settingsPath = options?.settingsPath ?? (0, node_path_1.join)(claudeDirPath, 'settings.json');
33
+ return { claudeDirPath, settingsPath };
34
+ }
35
+ function readSettings(settingsPath) {
36
+ if (!(0, node_fs_1.existsSync)(settingsPath)) {
37
+ return { settings: {}, settingsExists: false };
38
+ }
39
+ const raw = (0, node_fs_1.readFileSync)(settingsPath, 'utf8');
40
+ if (raw.trim().length === 0) {
41
+ return { settings: {}, settingsExists: true };
42
+ }
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(raw);
46
+ }
47
+ catch {
48
+ throw new Error(`Failed to parse ${settingsPath}: invalid JSON`);
49
+ }
50
+ if (!isRecord(parsed)) {
51
+ throw new Error(`${settingsPath} must contain a JSON object`);
52
+ }
53
+ return { settings: parsed, settingsExists: true };
54
+ }
55
+ function getHooksSection(settings, createIfMissing) {
56
+ const existing = settings.hooks;
57
+ if (isRecord(existing)) {
58
+ return existing;
59
+ }
60
+ if (!createIfMissing) {
61
+ return undefined;
62
+ }
63
+ const created = {};
64
+ settings.hooks = created;
65
+ return created;
66
+ }
67
+ function toMutableEventEntries(hooksSection, event) {
68
+ const existing = hooksSection[event];
69
+ if (Array.isArray(existing)) {
70
+ return existing;
71
+ }
72
+ if (existing === undefined) {
73
+ const created = [];
74
+ hooksSection[event] = created;
75
+ return created;
76
+ }
77
+ const converted = [existing];
78
+ hooksSection[event] = converted;
79
+ return converted;
80
+ }
81
+ function countCommandMatches(entries, command) {
82
+ let matches = 0;
83
+ for (const entry of entries) {
84
+ if (!isRecord(entry))
85
+ continue;
86
+ const nestedHooks = entry.hooks;
87
+ if (!Array.isArray(nestedHooks))
88
+ continue;
89
+ for (const hook of nestedHooks) {
90
+ if (!isRecord(hook))
91
+ continue;
92
+ if (hook.type !== 'command')
93
+ continue;
94
+ if (hook.command === command) {
95
+ matches += 1;
96
+ }
97
+ }
98
+ }
99
+ return matches;
100
+ }
101
+ function countCommandInEvent(settings, event, command) {
102
+ const hooksSection = getHooksSection(settings, false);
103
+ if (!hooksSection)
104
+ return 0;
105
+ const eventEntries = hooksSection[event];
106
+ if (!Array.isArray(eventEntries))
107
+ return 0;
108
+ return countCommandMatches(eventEntries, command);
109
+ }
110
+ function getStatusFromSettings(settings, settingsExists, claudeDirPath, settingsPath, claudeDirExists) {
111
+ const commands = MANAGED_HOOKS.map((managedHook) => {
112
+ const matchCount = countCommandInEvent(settings, managedHook.event, managedHook.command);
113
+ return {
114
+ event: managedHook.event,
115
+ command: managedHook.command,
116
+ timeoutSeconds: managedHook.timeoutSeconds,
117
+ registered: matchCount > 0,
118
+ matchCount,
119
+ };
120
+ });
121
+ return {
122
+ claudeDirPath,
123
+ settingsPath,
124
+ claudeDirExists,
125
+ settingsExists,
126
+ fullyRegistered: commands.every((entry) => entry.registered),
127
+ commands,
128
+ };
129
+ }
130
+ function writeSettingsAtomic(settingsPath, settings) {
131
+ const directory = (0, node_path_1.dirname)(settingsPath);
132
+ (0, node_fs_1.mkdirSync)(directory, { recursive: true });
133
+ const temporaryPath = `${settingsPath}.tmp-${process.pid}-${Date.now()}`;
134
+ try {
135
+ (0, node_fs_1.writeFileSync)(temporaryPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
136
+ (0, node_fs_1.renameSync)(temporaryPath, settingsPath);
137
+ }
138
+ catch (error) {
139
+ (0, node_fs_1.rmSync)(temporaryPath, { force: true });
140
+ throw error;
141
+ }
142
+ }
143
+ function addManagedHooks(settings) {
144
+ const hooksSection = getHooksSection(settings, true);
145
+ const addedEvents = [];
146
+ for (const managedHook of MANAGED_HOOKS) {
147
+ const eventEntries = toMutableEventEntries(hooksSection, managedHook.event);
148
+ const alreadyRegistered = countCommandMatches(eventEntries, managedHook.command) > 0;
149
+ if (alreadyRegistered) {
150
+ continue;
151
+ }
152
+ eventEntries.push({
153
+ matcher: '*',
154
+ hooks: [
155
+ {
156
+ type: 'command',
157
+ command: managedHook.command,
158
+ timeout: managedHook.timeoutSeconds,
159
+ },
160
+ ],
161
+ });
162
+ addedEvents.push(managedHook.event);
163
+ }
164
+ return addedEvents;
165
+ }
166
+ function removeManagedHooks(settings) {
167
+ const hooksSection = getHooksSection(settings, false);
168
+ const removedByEvent = {
169
+ SessionStart: 0,
170
+ UserPromptSubmit: 0,
171
+ };
172
+ if (!hooksSection) {
173
+ return { changed: false, removedByEvent, removedCount: 0 };
174
+ }
175
+ let changed = false;
176
+ for (const managedHook of MANAGED_HOOKS) {
177
+ const eventEntries = hooksSection[managedHook.event];
178
+ if (!Array.isArray(eventEntries)) {
179
+ continue;
180
+ }
181
+ const nextEntries = [];
182
+ let removedForEvent = 0;
183
+ for (const entry of eventEntries) {
184
+ if (!isRecord(entry)) {
185
+ nextEntries.push(entry);
186
+ continue;
187
+ }
188
+ const nestedHooks = entry.hooks;
189
+ if (!Array.isArray(nestedHooks)) {
190
+ nextEntries.push(entry);
191
+ continue;
192
+ }
193
+ const retainedHooks = [];
194
+ for (const hook of nestedHooks) {
195
+ if (isRecord(hook) &&
196
+ hook.type === 'command' &&
197
+ hook.command === managedHook.command) {
198
+ removedForEvent += 1;
199
+ changed = true;
200
+ continue;
201
+ }
202
+ retainedHooks.push(hook);
203
+ }
204
+ if (retainedHooks.length === 0) {
205
+ continue;
206
+ }
207
+ if (retainedHooks.length !== nestedHooks.length) {
208
+ nextEntries.push({ ...entry, hooks: retainedHooks });
209
+ continue;
210
+ }
211
+ nextEntries.push(entry);
212
+ }
213
+ removedByEvent[managedHook.event] = removedForEvent;
214
+ if (nextEntries.length === 0) {
215
+ delete hooksSection[managedHook.event];
216
+ }
217
+ else {
218
+ hooksSection[managedHook.event] = nextEntries;
219
+ }
220
+ }
221
+ if (Object.keys(hooksSection).length === 0) {
222
+ delete settings.hooks;
223
+ }
224
+ return {
225
+ changed,
226
+ removedByEvent,
227
+ removedCount: removedByEvent.SessionStart + removedByEvent.UserPromptSubmit,
228
+ };
229
+ }
230
+ function getStatus(options) {
231
+ const { claudeDirPath, settingsPath } = resolvePaths(options);
232
+ const claudeDirExists = (0, node_fs_1.existsSync)(claudeDirPath);
233
+ if (!claudeDirExists) {
234
+ return getStatusFromSettings({}, false, claudeDirPath, settingsPath, false);
235
+ }
236
+ const { settings, settingsExists } = readSettings(settingsPath);
237
+ return getStatusFromSettings(settings, settingsExists, claudeDirPath, settingsPath, true);
238
+ }
239
+ function registerHooks(options) {
240
+ const { claudeDirPath, settingsPath } = resolvePaths(options);
241
+ const claudeDirExists = (0, node_fs_1.existsSync)(claudeDirPath);
242
+ if (!claudeDirExists) {
243
+ return {
244
+ ok: false,
245
+ action: 'missing_claude_dir',
246
+ changed: false,
247
+ added: [],
248
+ status: getStatusFromSettings({}, false, claudeDirPath, settingsPath, false),
249
+ };
250
+ }
251
+ const { settings, settingsExists } = readSettings(settingsPath);
252
+ const added = addManagedHooks(settings);
253
+ const changed = added.length > 0;
254
+ if (changed) {
255
+ writeSettingsAtomic(settingsPath, settings);
256
+ }
257
+ const status = getStatusFromSettings(settings, settingsExists || changed, claudeDirPath, settingsPath, true);
258
+ return {
259
+ ok: true,
260
+ action: changed ? 'registered' : 'already_registered',
261
+ changed,
262
+ added,
263
+ status,
264
+ };
265
+ }
266
+ function removeHooks(options) {
267
+ const { claudeDirPath, settingsPath } = resolvePaths(options);
268
+ const claudeDirExists = (0, node_fs_1.existsSync)(claudeDirPath);
269
+ if (!claudeDirExists) {
270
+ return {
271
+ ok: false,
272
+ action: 'missing_claude_dir',
273
+ changed: false,
274
+ removedCount: 0,
275
+ removedByEvent: {
276
+ SessionStart: 0,
277
+ UserPromptSubmit: 0,
278
+ },
279
+ status: getStatusFromSettings({}, false, claudeDirPath, settingsPath, false),
280
+ };
281
+ }
282
+ const { settings, settingsExists } = readSettings(settingsPath);
283
+ const removal = removeManagedHooks(settings);
284
+ if (removal.changed) {
285
+ writeSettingsAtomic(settingsPath, settings);
286
+ }
287
+ const status = getStatusFromSettings(settings, settingsExists || removal.changed, claudeDirPath, settingsPath, true);
288
+ return {
289
+ ok: true,
290
+ action: removal.changed ? 'removed' : 'not_registered',
291
+ changed: removal.changed,
292
+ removedCount: removal.removedCount,
293
+ removedByEvent: removal.removedByEvent,
294
+ status,
295
+ };
296
+ }