@hasna/hooks 0.0.1 → 0.0.2
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/dist/index.js +366 -0
- package/hooks/hook-agentmessages/bin/cli.ts +125 -0
- package/package.json +2 -2
- package/hooks/hook-agentmessages/src/check-messages.ts +0 -151
- package/hooks/hook-agentmessages/src/install.ts +0 -126
- package/hooks/hook-agentmessages/src/session-start.ts +0 -255
- package/hooks/hook-agentmessages/src/uninstall.ts +0 -89
- package/hooks/hook-branchprotect/src/cli.ts +0 -126
- package/hooks/hook-branchprotect/src/hook.ts +0 -88
- package/hooks/hook-branchprotect/tsconfig.json +0 -25
- package/hooks/hook-checkbugs/src/cli.ts +0 -628
- package/hooks/hook-checkbugs/src/hook.ts +0 -335
- package/hooks/hook-checkbugs/tsconfig.json +0 -15
- package/hooks/hook-checkdocs/src/cli.ts +0 -628
- package/hooks/hook-checkdocs/src/hook.ts +0 -310
- package/hooks/hook-checkdocs/tsconfig.json +0 -15
- package/hooks/hook-checkfiles/src/cli.ts +0 -545
- package/hooks/hook-checkfiles/src/hook.ts +0 -321
- package/hooks/hook-checkfiles/tsconfig.json +0 -15
- package/hooks/hook-checklint/src/cli-patch.ts +0 -32
- package/hooks/hook-checklint/src/cli.ts +0 -667
- package/hooks/hook-checklint/src/hook.ts +0 -473
- package/hooks/hook-checklint/tsconfig.json +0 -15
- package/hooks/hook-checkpoint/src/cli.ts +0 -191
- package/hooks/hook-checkpoint/src/hook.ts +0 -207
- package/hooks/hook-checkpoint/tsconfig.json +0 -25
- package/hooks/hook-checksecurity/src/cli.ts +0 -601
- package/hooks/hook-checksecurity/src/hook.ts +0 -334
- package/hooks/hook-checksecurity/tsconfig.json +0 -15
- package/hooks/hook-checktasks/src/cli.ts +0 -578
- package/hooks/hook-checktasks/src/hook.ts +0 -308
- package/hooks/hook-checktasks/tsconfig.json +0 -20
- package/hooks/hook-checktests/src/cli.ts +0 -627
- package/hooks/hook-checktests/src/hook.ts +0 -334
- package/hooks/hook-checktests/tsconfig.json +0 -15
- package/hooks/hook-contextrefresh/src/cli.ts +0 -152
- package/hooks/hook-contextrefresh/src/hook.ts +0 -148
- package/hooks/hook-contextrefresh/tsconfig.json +0 -25
- package/hooks/hook-gitguard/src/cli.ts +0 -159
- package/hooks/hook-gitguard/src/hook.ts +0 -129
- package/hooks/hook-gitguard/tsconfig.json +0 -25
- package/hooks/hook-packageage/src/cli.ts +0 -165
- package/hooks/hook-packageage/src/hook.ts +0 -177
- package/hooks/hook-packageage/tsconfig.json +0 -25
- package/hooks/hook-phonenotify/src/cli.ts +0 -196
- package/hooks/hook-phonenotify/src/hook.ts +0 -139
- package/hooks/hook-phonenotify/tsconfig.json +0 -25
- package/hooks/hook-precompact/src/cli.ts +0 -168
- package/hooks/hook-precompact/src/hook.ts +0 -122
- package/hooks/hook-precompact/tsconfig.json +0 -25
- package/src/cli/components/App.tsx +0 -191
- package/src/cli/components/CategorySelect.tsx +0 -37
- package/src/cli/components/DataTable.tsx +0 -133
- package/src/cli/components/Header.tsx +0 -18
- package/src/cli/components/HookSelect.tsx +0 -29
- package/src/cli/components/InstallProgress.tsx +0 -105
- package/src/cli/components/SearchView.tsx +0 -86
- package/src/cli/index.tsx +0 -218
- package/src/index.ts +0 -31
- package/src/lib/installer.ts +0 -288
- package/src/lib/registry.ts +0 -205
- package/tsconfig.json +0 -17
|
@@ -1,473 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Claude Code Hook: check-lint
|
|
5
|
-
*
|
|
6
|
-
* Runs linting after every N file edits and creates tasks for errors.
|
|
7
|
-
*
|
|
8
|
-
* Configuration priority:
|
|
9
|
-
* 1. settings.json checkLintConfig (project or global)
|
|
10
|
-
* 2. Environment variables (legacy)
|
|
11
|
-
*
|
|
12
|
-
* Config options:
|
|
13
|
-
* - taskListId: task list for creating lint error tasks (default: auto-detect bugfixes list)
|
|
14
|
-
* - lintCommand: lint command to run (default: auto-detect)
|
|
15
|
-
* - editThreshold: run lint after this many edits (default: 3, range: 3-7)
|
|
16
|
-
* - keywords: keywords that trigger the check (default: ["dev"])
|
|
17
|
-
* - enabled: enable/disable the hook
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "fs";
|
|
21
|
-
import { join, dirname } from "path";
|
|
22
|
-
import { homedir } from "os";
|
|
23
|
-
import { execSync } from "child_process";
|
|
24
|
-
|
|
25
|
-
interface CheckLintConfig {
|
|
26
|
-
taskListId?: string;
|
|
27
|
-
lintCommand?: string;
|
|
28
|
-
editThreshold?: number;
|
|
29
|
-
keywords?: string[];
|
|
30
|
-
enabled?: boolean;
|
|
31
|
-
createTasks?: boolean;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface HookInput {
|
|
35
|
-
session_id: string;
|
|
36
|
-
transcript_path: string;
|
|
37
|
-
cwd: string;
|
|
38
|
-
tool_name: string;
|
|
39
|
-
tool_input: Record<string, unknown>;
|
|
40
|
-
tool_output?: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
interface LintError {
|
|
44
|
-
file: string;
|
|
45
|
-
line: number;
|
|
46
|
-
column: number;
|
|
47
|
-
message: string;
|
|
48
|
-
rule?: string;
|
|
49
|
-
severity: "error" | "warning";
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
interface SessionState {
|
|
53
|
-
editCount: number;
|
|
54
|
-
editedFiles: string[];
|
|
55
|
-
lastLintRun: number;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const CONFIG_KEY = "checkLintConfig";
|
|
59
|
-
const STATE_DIR = join(homedir(), ".claude", "hook-state");
|
|
60
|
-
const EDIT_TOOLS = ["Edit", "Write", "NotebookEdit"];
|
|
61
|
-
|
|
62
|
-
// Allowed lint commands - whitelist approach for security
|
|
63
|
-
const ALLOWED_LINT_COMMANDS = [
|
|
64
|
-
"bun lint",
|
|
65
|
-
"bun lint:check",
|
|
66
|
-
"bun eslint",
|
|
67
|
-
"bun biome check",
|
|
68
|
-
"bunx @biomejs/biome check .",
|
|
69
|
-
"bunx eslint .",
|
|
70
|
-
"npm run lint",
|
|
71
|
-
"npm run lint:check",
|
|
72
|
-
"npx eslint .",
|
|
73
|
-
"npx @biomejs/biome check .",
|
|
74
|
-
];
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Sanitize ID to prevent path traversal and injection attacks
|
|
78
|
-
*/
|
|
79
|
-
function sanitizeId(id: string): string {
|
|
80
|
-
if (!id || typeof id !== 'string') return 'default';
|
|
81
|
-
return id.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 100) || 'default';
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Validate lint command against whitelist to prevent command injection
|
|
86
|
-
*/
|
|
87
|
-
function isValidLintCommand(cmd: string): boolean {
|
|
88
|
-
if (!cmd || typeof cmd !== 'string') return false;
|
|
89
|
-
return ALLOWED_LINT_COMMANDS.some(allowed =>
|
|
90
|
-
cmd === allowed || cmd.startsWith(allowed + " ")
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function isValidRepoPattern(cwd: string): boolean {
|
|
95
|
-
const dirName = cwd.split("/").filter(Boolean).pop() || "";
|
|
96
|
-
// Match: hook-checklint, skill-installhook, iapp-mail, etc.
|
|
97
|
-
return /^[a-z]+-[a-z0-9-]+$/i.test(dirName);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function readStdinJson(): HookInput | null {
|
|
101
|
-
try {
|
|
102
|
-
const stdin = readFileSync(0, "utf-8");
|
|
103
|
-
return JSON.parse(stdin);
|
|
104
|
-
} catch {
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function readSettings(path: string): Record<string, unknown> {
|
|
110
|
-
if (!existsSync(path)) return {};
|
|
111
|
-
try {
|
|
112
|
-
return JSON.parse(readFileSync(path, "utf-8"));
|
|
113
|
-
} catch {
|
|
114
|
-
return {};
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function getConfig(cwd: string): CheckLintConfig {
|
|
119
|
-
// Try project settings first
|
|
120
|
-
const projectSettings = readSettings(join(cwd, ".claude", "settings.json"));
|
|
121
|
-
if (projectSettings[CONFIG_KEY]) {
|
|
122
|
-
return projectSettings[CONFIG_KEY] as CheckLintConfig;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Fall back to global settings
|
|
126
|
-
const globalSettings = readSettings(join(homedir(), ".claude", "settings.json"));
|
|
127
|
-
if (globalSettings[CONFIG_KEY]) {
|
|
128
|
-
return globalSettings[CONFIG_KEY] as CheckLintConfig;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Default config
|
|
132
|
-
return {
|
|
133
|
-
editThreshold: 3,
|
|
134
|
-
keywords: ["dev"],
|
|
135
|
-
enabled: true,
|
|
136
|
-
createTasks: true,
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function getStateFile(sessionId: string): string {
|
|
141
|
-
mkdirSync(STATE_DIR, { recursive: true });
|
|
142
|
-
const safeSessionId = sanitizeId(sessionId);
|
|
143
|
-
return join(STATE_DIR, `checklint-${safeSessionId}.json`);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function getSessionState(sessionId: string): SessionState {
|
|
147
|
-
const stateFile = getStateFile(sessionId);
|
|
148
|
-
if (existsSync(stateFile)) {
|
|
149
|
-
try {
|
|
150
|
-
return JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
151
|
-
} catch {
|
|
152
|
-
// Corrupted state, reset
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return { editCount: 0, editedFiles: [], lastLintRun: 0 };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function saveSessionState(sessionId: string, state: SessionState): void {
|
|
159
|
-
const stateFile = getStateFile(sessionId);
|
|
160
|
-
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function detectLintCommand(cwd: string): string | null {
|
|
164
|
-
const packageJsonPath = join(cwd, "package.json");
|
|
165
|
-
if (existsSync(packageJsonPath)) {
|
|
166
|
-
try {
|
|
167
|
-
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
168
|
-
const scripts = pkg.scripts || {};
|
|
169
|
-
|
|
170
|
-
// Check for common lint script names
|
|
171
|
-
if (scripts.lint) return "bun lint";
|
|
172
|
-
if (scripts["lint:check"]) return "bun lint:check";
|
|
173
|
-
if (scripts.eslint) return "bun eslint";
|
|
174
|
-
if (scripts.biome) return "bun biome check";
|
|
175
|
-
} catch {
|
|
176
|
-
// Ignore parse errors
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Check for config files
|
|
181
|
-
if (existsSync(join(cwd, "biome.json")) || existsSync(join(cwd, "biome.jsonc"))) {
|
|
182
|
-
return "bunx @biomejs/biome check .";
|
|
183
|
-
}
|
|
184
|
-
if (existsSync(join(cwd, ".eslintrc.json")) || existsSync(join(cwd, ".eslintrc.js")) || existsSync(join(cwd, "eslint.config.js"))) {
|
|
185
|
-
return "bunx eslint .";
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return null;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function runLint(cwd: string, command: string): { success: boolean; output: string } {
|
|
192
|
-
try {
|
|
193
|
-
const output = execSync(command, {
|
|
194
|
-
cwd,
|
|
195
|
-
encoding: "utf-8",
|
|
196
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
197
|
-
timeout: 60000, // 60s timeout
|
|
198
|
-
});
|
|
199
|
-
return { success: true, output };
|
|
200
|
-
} catch (error: unknown) {
|
|
201
|
-
const execError = error as { stdout?: string; stderr?: string };
|
|
202
|
-
const output = (execError.stdout || "") + (execError.stderr || "");
|
|
203
|
-
return { success: false, output };
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function parseLintOutput(output: string): LintError[] {
|
|
208
|
-
const errors: LintError[] = [];
|
|
209
|
-
const lines = output.split("\n");
|
|
210
|
-
|
|
211
|
-
for (const line of lines) {
|
|
212
|
-
// ESLint format: /path/file.ts:10:5: error Message [rule-name]
|
|
213
|
-
// Biome format: path/file.ts:10:5 lint/rule ERROR message
|
|
214
|
-
// Common format: file:line:col: message
|
|
215
|
-
|
|
216
|
-
// Try ESLint/common format
|
|
217
|
-
const eslintMatch = line.match(/^(.+?):(\d+):(\d+):\s*(error|warning)\s+(.+?)(?:\s+\[(.+?)\])?$/i);
|
|
218
|
-
if (eslintMatch) {
|
|
219
|
-
errors.push({
|
|
220
|
-
file: eslintMatch[1],
|
|
221
|
-
line: parseInt(eslintMatch[2], 10),
|
|
222
|
-
column: parseInt(eslintMatch[3], 10),
|
|
223
|
-
severity: eslintMatch[4].toLowerCase() as "error" | "warning",
|
|
224
|
-
message: eslintMatch[5].trim(),
|
|
225
|
-
rule: eslintMatch[6],
|
|
226
|
-
});
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Try Biome format
|
|
231
|
-
const biomeMatch = line.match(/^(.+?):(\d+):(\d+)\s+(\w+\/\w+)\s+(ERROR|WARNING|WARN)\s+(.+)$/i);
|
|
232
|
-
if (biomeMatch) {
|
|
233
|
-
errors.push({
|
|
234
|
-
file: biomeMatch[1],
|
|
235
|
-
line: parseInt(biomeMatch[2], 10),
|
|
236
|
-
column: parseInt(biomeMatch[3], 10),
|
|
237
|
-
rule: biomeMatch[4],
|
|
238
|
-
severity: biomeMatch[5].toUpperCase().startsWith("ERR") ? "error" : "warning",
|
|
239
|
-
message: biomeMatch[6].trim(),
|
|
240
|
-
});
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Generic file:line:col format
|
|
245
|
-
const genericMatch = line.match(/^(.+?):(\d+):(\d+):\s*(.+)$/);
|
|
246
|
-
if (genericMatch && !genericMatch[1].startsWith(" ")) {
|
|
247
|
-
errors.push({
|
|
248
|
-
file: genericMatch[1],
|
|
249
|
-
line: parseInt(genericMatch[2], 10),
|
|
250
|
-
column: parseInt(genericMatch[3], 10),
|
|
251
|
-
severity: line.toLowerCase().includes("error") ? "error" : "warning",
|
|
252
|
-
message: genericMatch[4].trim(),
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return errors;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function getProjectTaskList(cwd: string): string | null {
|
|
261
|
-
const tasksDir = join(homedir(), ".claude", "tasks");
|
|
262
|
-
if (!existsSync(tasksDir)) return null;
|
|
263
|
-
|
|
264
|
-
const dirName = cwd.split("/").filter(Boolean).pop() || "";
|
|
265
|
-
|
|
266
|
-
try {
|
|
267
|
-
const lists = readdirSync(tasksDir, { withFileTypes: true })
|
|
268
|
-
.filter((d) => d.isDirectory())
|
|
269
|
-
.map((d) => d.name);
|
|
270
|
-
|
|
271
|
-
// Look for a bugfixes list for this project
|
|
272
|
-
const bugfixList = lists.find((list) => {
|
|
273
|
-
const listLower = list.toLowerCase();
|
|
274
|
-
const dirLower = dirName.toLowerCase();
|
|
275
|
-
return listLower.startsWith(dirLower) && listLower.includes("bugfix");
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
if (bugfixList) return bugfixList;
|
|
279
|
-
|
|
280
|
-
// Fall back to dev list
|
|
281
|
-
const devList = lists.find((list) => {
|
|
282
|
-
const listLower = list.toLowerCase();
|
|
283
|
-
const dirLower = dirName.toLowerCase();
|
|
284
|
-
return listLower.startsWith(dirLower) && listLower.includes("dev");
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
return devList || null;
|
|
288
|
-
} catch {
|
|
289
|
-
return null;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function createTask(taskListId: string, error: LintError): void {
|
|
294
|
-
const tasksDir = join(homedir(), ".claude", "tasks", taskListId);
|
|
295
|
-
mkdirSync(tasksDir, { recursive: true });
|
|
296
|
-
|
|
297
|
-
const taskId = `lint-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
298
|
-
const severity = error.severity === "error" ? "MEDIUM" : "LOW";
|
|
299
|
-
const ruleInfo = error.rule ? ` (${error.rule})` : "";
|
|
300
|
-
|
|
301
|
-
const task = {
|
|
302
|
-
id: taskId,
|
|
303
|
-
subject: `BUG: ${severity} - Fix lint ${error.severity} in ${error.file}:${error.line}`,
|
|
304
|
-
description: `Fix lint ${error.severity} at ${error.file}:${error.line}:${error.column}\n\n**Error:** ${error.message}${ruleInfo}\n\n**File:** ${error.file}\n**Line:** ${error.line}\n**Column:** ${error.column}${error.rule ? `\n**Rule:** ${error.rule}` : ""}\n\n**Acceptance criteria:**\n- Lint error is fixed\n- No new lint errors introduced`,
|
|
305
|
-
status: "pending",
|
|
306
|
-
createdAt: new Date().toISOString(),
|
|
307
|
-
metadata: {
|
|
308
|
-
source: "hook-checklint",
|
|
309
|
-
file: error.file,
|
|
310
|
-
line: error.line,
|
|
311
|
-
column: error.column,
|
|
312
|
-
rule: error.rule,
|
|
313
|
-
severity: error.severity,
|
|
314
|
-
},
|
|
315
|
-
};
|
|
316
|
-
|
|
317
|
-
const taskFile = join(tasksDir, `${taskId}.json`);
|
|
318
|
-
writeFileSync(taskFile, JSON.stringify(task, null, 2));
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function getSessionName(transcriptPath: string): string | null {
|
|
322
|
-
if (!existsSync(transcriptPath)) return null;
|
|
323
|
-
|
|
324
|
-
try {
|
|
325
|
-
const content = readFileSync(transcriptPath, "utf-8");
|
|
326
|
-
let lastTitle: string | null = null;
|
|
327
|
-
let searchStart = 0;
|
|
328
|
-
|
|
329
|
-
while (true) {
|
|
330
|
-
const titleIndex = content.indexOf('"custom-title"', searchStart);
|
|
331
|
-
if (titleIndex === -1) break;
|
|
332
|
-
|
|
333
|
-
const lineStart = content.lastIndexOf("\n", titleIndex) + 1;
|
|
334
|
-
const lineEnd = content.indexOf("\n", titleIndex);
|
|
335
|
-
const line = content.slice(lineStart, lineEnd === -1 ? undefined : lineEnd);
|
|
336
|
-
|
|
337
|
-
try {
|
|
338
|
-
const entry = JSON.parse(line);
|
|
339
|
-
if (entry.type === "custom-title" && entry.customTitle) {
|
|
340
|
-
lastTitle = entry.customTitle;
|
|
341
|
-
}
|
|
342
|
-
} catch {
|
|
343
|
-
// Skip malformed lines
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
searchStart = titleIndex + 1;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
return lastTitle;
|
|
350
|
-
} catch {
|
|
351
|
-
return null;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
function approve() {
|
|
356
|
-
console.log(JSON.stringify({ decision: "approve" }));
|
|
357
|
-
process.exit(0);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
export function run() {
|
|
361
|
-
const hookInput = readStdinJson();
|
|
362
|
-
if (!hookInput) {
|
|
363
|
-
approve();
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
const { session_id, cwd, tool_name, tool_input, transcript_path } = hookInput;
|
|
368
|
-
|
|
369
|
-
// Only process edit tools
|
|
370
|
-
if (!EDIT_TOOLS.includes(tool_name)) {
|
|
371
|
-
approve();
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// Check repo pattern - only run for [prefix]-[name] folders
|
|
376
|
-
if (!isValidRepoPattern(cwd)) {
|
|
377
|
-
approve();
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
const config = getConfig(cwd);
|
|
382
|
-
|
|
383
|
-
// Check if hook is disabled
|
|
384
|
-
if (config.enabled === false) {
|
|
385
|
-
approve();
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// Check keywords match
|
|
390
|
-
const sessionName = transcript_path ? getSessionName(transcript_path) : null;
|
|
391
|
-
const nameToCheck = sessionName || config.taskListId || "";
|
|
392
|
-
const keywords = config.keywords || ["dev"];
|
|
393
|
-
|
|
394
|
-
const matchesKeyword = keywords.some((keyword) =>
|
|
395
|
-
nameToCheck.toLowerCase().includes(keyword.toLowerCase())
|
|
396
|
-
);
|
|
397
|
-
|
|
398
|
-
// If keywords are configured and we have a session name, check for match
|
|
399
|
-
if (keywords.length > 0 && nameToCheck && !matchesKeyword) {
|
|
400
|
-
approve();
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// Get edited file path
|
|
405
|
-
const filePath = (tool_input.file_path || tool_input.notebook_path) as string | undefined;
|
|
406
|
-
if (!filePath) {
|
|
407
|
-
approve();
|
|
408
|
-
return;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Update session state
|
|
412
|
-
const state = getSessionState(session_id);
|
|
413
|
-
state.editCount++;
|
|
414
|
-
|
|
415
|
-
if (!state.editedFiles.includes(filePath)) {
|
|
416
|
-
state.editedFiles.push(filePath);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
const threshold = Math.min(7, Math.max(3, config.editThreshold || 3));
|
|
420
|
-
|
|
421
|
-
// Check if we should run lint
|
|
422
|
-
if (state.editCount >= threshold) {
|
|
423
|
-
// Detect or use configured lint command
|
|
424
|
-
const lintCommand = config.lintCommand || detectLintCommand(cwd);
|
|
425
|
-
|
|
426
|
-
// Validate lint command against whitelist to prevent command injection
|
|
427
|
-
if (lintCommand && isValidLintCommand(lintCommand)) {
|
|
428
|
-
const { success, output } = runLint(cwd, lintCommand);
|
|
429
|
-
|
|
430
|
-
if (!success) {
|
|
431
|
-
const errors = parseLintOutput(output);
|
|
432
|
-
|
|
433
|
-
// Filter to errors in files we edited (optional - could check all)
|
|
434
|
-
const relevantErrors = errors.filter((e) =>
|
|
435
|
-
state.editedFiles.some((f) => f.endsWith(e.file) || e.file.endsWith(f.split("/").pop() || ""))
|
|
436
|
-
);
|
|
437
|
-
|
|
438
|
-
// Create tasks for lint errors if enabled
|
|
439
|
-
if (config.createTasks !== false && relevantErrors.length > 0) {
|
|
440
|
-
const taskListId = config.taskListId || getProjectTaskList(cwd);
|
|
441
|
-
|
|
442
|
-
if (taskListId) {
|
|
443
|
-
// Limit to first 5 errors to avoid task spam
|
|
444
|
-
const errorsToReport = relevantErrors.slice(0, 5);
|
|
445
|
-
for (const error of errorsToReport) {
|
|
446
|
-
createTask(taskListId, error);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// Log summary
|
|
450
|
-
console.error(
|
|
451
|
-
`[hook-checklint] Created ${errorsToReport.length} task(s) for lint errors in "${taskListId}"`
|
|
452
|
-
);
|
|
453
|
-
if (relevantErrors.length > 5) {
|
|
454
|
-
console.error(`[hook-checklint] (${relevantErrors.length - 5} more errors not reported)`);
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// Reset counter after lint run
|
|
462
|
-
state.editCount = 0;
|
|
463
|
-
state.lastLintRun = Date.now();
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
saveSessionState(session_id, state);
|
|
467
|
-
approve();
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// Allow direct execution
|
|
471
|
-
if (import.meta.main) {
|
|
472
|
-
run();
|
|
473
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ESNext",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "bundler",
|
|
6
|
-
"strict": true,
|
|
7
|
-
"esModuleInterop": true,
|
|
8
|
-
"skipLibCheck": true,
|
|
9
|
-
"outDir": "./dist",
|
|
10
|
-
"declaration": true,
|
|
11
|
-
"declarationMap": true
|
|
12
|
-
},
|
|
13
|
-
"include": ["src/**/*"],
|
|
14
|
-
"exclude": ["node_modules", "dist"]
|
|
15
|
-
}
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* CLI for hook-checkpoint
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
8
|
-
import { execSync } from "child_process";
|
|
9
|
-
import { join } from "path";
|
|
10
|
-
import { homedir } from "os";
|
|
11
|
-
|
|
12
|
-
const HOOK_NAME = "hook-checkpoint";
|
|
13
|
-
const SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
|
|
14
|
-
const CHECKPOINT_DIR = ".claude-checkpoints";
|
|
15
|
-
|
|
16
|
-
interface ClaudeSettings {
|
|
17
|
-
hooks?: {
|
|
18
|
-
PreToolUse?: Array<{
|
|
19
|
-
matcher: string;
|
|
20
|
-
hooks: Array<{ type: "command"; command: string }>;
|
|
21
|
-
}>;
|
|
22
|
-
};
|
|
23
|
-
[key: string]: unknown;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function readSettings(): ClaudeSettings {
|
|
27
|
-
try {
|
|
28
|
-
if (existsSync(SETTINGS_PATH)) {
|
|
29
|
-
return JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
|
|
30
|
-
}
|
|
31
|
-
} catch {}
|
|
32
|
-
return {};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function writeSettings(settings: ClaudeSettings): void {
|
|
36
|
-
const dir = join(homedir(), ".claude");
|
|
37
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
38
|
-
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function install(): void {
|
|
42
|
-
const settings = readSettings();
|
|
43
|
-
if (!settings.hooks) settings.hooks = {};
|
|
44
|
-
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
|
|
45
|
-
|
|
46
|
-
const existing = settings.hooks.PreToolUse.find((h) =>
|
|
47
|
-
h.hooks.some((hook) => hook.command.includes(HOOK_NAME))
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
if (existing) {
|
|
51
|
-
console.log(`${HOOK_NAME} is already installed`);
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
settings.hooks.PreToolUse.push({
|
|
56
|
-
matcher: "Write|Edit|NotebookEdit",
|
|
57
|
-
hooks: [{ type: "command", command: `bunx @hasnaxyz/${HOOK_NAME}` }],
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
writeSettings(settings);
|
|
61
|
-
console.log(`${HOOK_NAME} installed successfully`);
|
|
62
|
-
console.log("Hook will create shadow git snapshots before Write/Edit/NotebookEdit");
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function uninstall(): void {
|
|
66
|
-
const settings = readSettings();
|
|
67
|
-
if (!settings.hooks?.PreToolUse) {
|
|
68
|
-
console.log(`${HOOK_NAME} is not installed`);
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const before = settings.hooks.PreToolUse.length;
|
|
73
|
-
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
74
|
-
(h) => !h.hooks.some((hook) => hook.command.includes(HOOK_NAME))
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
if (before === settings.hooks.PreToolUse.length) {
|
|
78
|
-
console.log(`${HOOK_NAME} is not installed`);
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
writeSettings(settings);
|
|
83
|
-
console.log(`${HOOK_NAME} uninstalled successfully`);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function status(): void {
|
|
87
|
-
const settings = readSettings();
|
|
88
|
-
const installed = settings.hooks?.PreToolUse?.some((h) =>
|
|
89
|
-
h.hooks.some((hook) => hook.command.includes(HOOK_NAME))
|
|
90
|
-
);
|
|
91
|
-
console.log(`${HOOK_NAME} is ${installed ? "installed" : "not installed"}`);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function list(): void {
|
|
95
|
-
const checkpointDir = join(process.cwd(), CHECKPOINT_DIR);
|
|
96
|
-
if (!existsSync(checkpointDir)) {
|
|
97
|
-
console.log("No checkpoints found in current directory");
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
const log = execSync("git log --oneline -20", {
|
|
103
|
-
cwd: checkpointDir,
|
|
104
|
-
encoding: "utf-8",
|
|
105
|
-
});
|
|
106
|
-
console.log("Recent checkpoints:");
|
|
107
|
-
console.log(log);
|
|
108
|
-
} catch {
|
|
109
|
-
console.log("No checkpoints found");
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function restore(ref: string): void {
|
|
114
|
-
const checkpointDir = join(process.cwd(), CHECKPOINT_DIR);
|
|
115
|
-
if (!existsSync(checkpointDir)) {
|
|
116
|
-
console.log("No checkpoints found in current directory");
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
try {
|
|
121
|
-
const files = execSync(`git show ${ref} --name-only --pretty=format:""`, {
|
|
122
|
-
cwd: checkpointDir,
|
|
123
|
-
encoding: "utf-8",
|
|
124
|
-
}).trim().split("\n").filter((f) => f.startsWith("files/"));
|
|
125
|
-
|
|
126
|
-
for (const file of files) {
|
|
127
|
-
const relativePath = file.replace("files/", "");
|
|
128
|
-
try {
|
|
129
|
-
const content = execSync(`git show ${ref}:${file}`, {
|
|
130
|
-
cwd: checkpointDir,
|
|
131
|
-
});
|
|
132
|
-
const targetPath = join(process.cwd(), relativePath);
|
|
133
|
-
writeFileSync(targetPath, content);
|
|
134
|
-
console.log(`Restored: ${relativePath}`);
|
|
135
|
-
} catch {
|
|
136
|
-
console.error(`Failed to restore: ${relativePath}`);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
} catch (error) {
|
|
140
|
-
console.error(`Failed to restore from ${ref}:`, error);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function help(): void {
|
|
145
|
-
console.log(`
|
|
146
|
-
${HOOK_NAME} - Shadow git snapshots for Claude Code file modifications
|
|
147
|
-
|
|
148
|
-
Usage: ${HOOK_NAME} <command>
|
|
149
|
-
|
|
150
|
-
Commands:
|
|
151
|
-
install Install hook to Claude Code settings
|
|
152
|
-
uninstall Remove hook from Claude Code settings
|
|
153
|
-
status Check if hook is installed
|
|
154
|
-
list Show recent checkpoints in current directory
|
|
155
|
-
restore <ref> Restore files from a checkpoint (git ref)
|
|
156
|
-
help Show this help message
|
|
157
|
-
|
|
158
|
-
How it works:
|
|
159
|
-
Before any Write/Edit/NotebookEdit, the hook copies the original file
|
|
160
|
-
into a shadow git repo (.claude-checkpoints/) and commits it. This gives
|
|
161
|
-
you a full history of every file before Claude modified it.
|
|
162
|
-
`);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const command = process.argv[2];
|
|
166
|
-
|
|
167
|
-
switch (command) {
|
|
168
|
-
case "install": install(); break;
|
|
169
|
-
case "uninstall": uninstall(); break;
|
|
170
|
-
case "status": status(); break;
|
|
171
|
-
case "list": list(); break;
|
|
172
|
-
case "restore":
|
|
173
|
-
if (process.argv[3]) {
|
|
174
|
-
restore(process.argv[3]);
|
|
175
|
-
} else {
|
|
176
|
-
console.error("Usage: hook-checkpoint restore <git-ref>");
|
|
177
|
-
process.exit(1);
|
|
178
|
-
}
|
|
179
|
-
break;
|
|
180
|
-
case "help":
|
|
181
|
-
case "--help":
|
|
182
|
-
case "-h": help(); break;
|
|
183
|
-
default:
|
|
184
|
-
if (!command) {
|
|
185
|
-
import("./hook.ts").then((m) => m.run());
|
|
186
|
-
} else {
|
|
187
|
-
console.error(`Unknown command: ${command}`);
|
|
188
|
-
help();
|
|
189
|
-
process.exit(1);
|
|
190
|
-
}
|
|
191
|
-
}
|