@hasna/hooks 0.0.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/.npmrc.example +2 -0
- package/AGENTS.md +54 -0
- package/CLAUDE.md +70 -0
- package/CONTRIBUTING.md +45 -0
- package/README.md +232 -0
- package/bin/index.js +5171 -0
- package/hooks/hook-agentmessages/CLAUDE.md +79 -0
- package/hooks/hook-agentmessages/LICENSE +21 -0
- package/hooks/hook-agentmessages/README.md +107 -0
- package/hooks/hook-agentmessages/package.json +31 -0
- package/hooks/hook-agentmessages/src/check-messages.ts +151 -0
- package/hooks/hook-agentmessages/src/install.ts +126 -0
- package/hooks/hook-agentmessages/src/session-start.ts +255 -0
- package/hooks/hook-agentmessages/src/uninstall.ts +89 -0
- package/hooks/hook-branchprotect/CLAUDE.md +23 -0
- package/hooks/hook-branchprotect/README.md +25 -0
- package/hooks/hook-branchprotect/package.json +42 -0
- package/hooks/hook-branchprotect/src/cli.ts +126 -0
- package/hooks/hook-branchprotect/src/hook.ts +88 -0
- package/hooks/hook-branchprotect/tsconfig.json +25 -0
- package/hooks/hook-checkbugs/LICENSE +21 -0
- package/hooks/hook-checkbugs/README.md +140 -0
- package/hooks/hook-checkbugs/package.json +58 -0
- package/hooks/hook-checkbugs/src/cli.ts +628 -0
- package/hooks/hook-checkbugs/src/hook.ts +335 -0
- package/hooks/hook-checkbugs/tsconfig.json +15 -0
- package/hooks/hook-checkdocs/README.md +137 -0
- package/hooks/hook-checkdocs/package.json +57 -0
- package/hooks/hook-checkdocs/src/cli.ts +628 -0
- package/hooks/hook-checkdocs/src/hook.ts +310 -0
- package/hooks/hook-checkdocs/tsconfig.json +15 -0
- package/hooks/hook-checkfiles/LICENSE +21 -0
- package/hooks/hook-checkfiles/README.md +141 -0
- package/hooks/hook-checkfiles/package.json +56 -0
- package/hooks/hook-checkfiles/src/cli.ts +545 -0
- package/hooks/hook-checkfiles/src/hook.ts +321 -0
- package/hooks/hook-checkfiles/tsconfig.json +15 -0
- package/hooks/hook-checklint/LICENSE +21 -0
- package/hooks/hook-checklint/README.md +147 -0
- package/hooks/hook-checklint/package.json +57 -0
- package/hooks/hook-checklint/src/cli-patch.ts +32 -0
- package/hooks/hook-checklint/src/cli.ts +667 -0
- package/hooks/hook-checklint/src/hook.ts +473 -0
- package/hooks/hook-checklint/tsconfig.json +15 -0
- package/hooks/hook-checkpoint/CLAUDE.md +23 -0
- package/hooks/hook-checkpoint/README.md +37 -0
- package/hooks/hook-checkpoint/package.json +58 -0
- package/hooks/hook-checkpoint/src/cli.ts +191 -0
- package/hooks/hook-checkpoint/src/hook.ts +207 -0
- package/hooks/hook-checkpoint/tsconfig.json +25 -0
- package/hooks/hook-checksecurity/LICENSE +21 -0
- package/hooks/hook-checksecurity/README.md +158 -0
- package/hooks/hook-checksecurity/package.json +57 -0
- package/hooks/hook-checksecurity/src/cli.ts +601 -0
- package/hooks/hook-checksecurity/src/hook.ts +334 -0
- package/hooks/hook-checksecurity/tsconfig.json +15 -0
- package/hooks/hook-checktasks/README.md +144 -0
- package/hooks/hook-checktasks/package.json +55 -0
- package/hooks/hook-checktasks/src/cli.ts +578 -0
- package/hooks/hook-checktasks/src/hook.ts +308 -0
- package/hooks/hook-checktasks/tsconfig.json +20 -0
- package/hooks/hook-checktests/LICENSE +21 -0
- package/hooks/hook-checktests/README.md +137 -0
- package/hooks/hook-checktests/package.json +57 -0
- package/hooks/hook-checktests/src/cli.ts +627 -0
- package/hooks/hook-checktests/src/hook.ts +334 -0
- package/hooks/hook-checktests/tsconfig.json +15 -0
- package/hooks/hook-contextrefresh/CLAUDE.md +23 -0
- package/hooks/hook-contextrefresh/README.md +42 -0
- package/hooks/hook-contextrefresh/package.json +42 -0
- package/hooks/hook-contextrefresh/src/cli.ts +152 -0
- package/hooks/hook-contextrefresh/src/hook.ts +148 -0
- package/hooks/hook-contextrefresh/tsconfig.json +25 -0
- package/hooks/hook-gitguard/CLAUDE.md +22 -0
- package/hooks/hook-gitguard/README.md +30 -0
- package/hooks/hook-gitguard/package.json +57 -0
- package/hooks/hook-gitguard/src/cli.ts +159 -0
- package/hooks/hook-gitguard/src/hook.ts +129 -0
- package/hooks/hook-gitguard/tsconfig.json +25 -0
- package/hooks/hook-packageage/CLAUDE.md +23 -0
- package/hooks/hook-packageage/README.md +33 -0
- package/hooks/hook-packageage/package.json +42 -0
- package/hooks/hook-packageage/src/cli.ts +165 -0
- package/hooks/hook-packageage/src/hook.ts +177 -0
- package/hooks/hook-packageage/tsconfig.json +25 -0
- package/hooks/hook-phonenotify/CLAUDE.md +25 -0
- package/hooks/hook-phonenotify/README.md +44 -0
- package/hooks/hook-phonenotify/package.json +42 -0
- package/hooks/hook-phonenotify/src/cli.ts +196 -0
- package/hooks/hook-phonenotify/src/hook.ts +139 -0
- package/hooks/hook-phonenotify/tsconfig.json +25 -0
- package/hooks/hook-precompact/CLAUDE.md +23 -0
- package/hooks/hook-precompact/README.md +36 -0
- package/hooks/hook-precompact/package.json +42 -0
- package/hooks/hook-precompact/src/cli.ts +168 -0
- package/hooks/hook-precompact/src/hook.ts +122 -0
- package/hooks/hook-precompact/tsconfig.json +25 -0
- package/package.json +61 -0
- package/src/cli/components/App.tsx +191 -0
- package/src/cli/components/CategorySelect.tsx +37 -0
- package/src/cli/components/DataTable.tsx +133 -0
- package/src/cli/components/Header.tsx +18 -0
- package/src/cli/components/HookSelect.tsx +29 -0
- package/src/cli/components/InstallProgress.tsx +105 -0
- package/src/cli/components/SearchView.tsx +86 -0
- package/src/cli/index.tsx +218 -0
- package/src/index.ts +31 -0
- package/src/lib/installer.ts +288 -0
- package/src/lib/registry.ts +205 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,473 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
## hook-checkpoint
|
|
4
|
+
|
|
5
|
+
A PreToolUse hook that creates shadow git snapshots before file modifications.
|
|
6
|
+
|
|
7
|
+
### Key Files
|
|
8
|
+
|
|
9
|
+
| File | Purpose |
|
|
10
|
+
|------|---------|
|
|
11
|
+
| `src/hook.ts` | Main hook logic — reads stdin, creates checkpoints |
|
|
12
|
+
| `src/cli.ts` | CLI — install/uninstall/status/list/restore |
|
|
13
|
+
|
|
14
|
+
### Hook Events
|
|
15
|
+
|
|
16
|
+
- **PreToolUse** (matcher: `Write|Edit|NotebookEdit`)
|
|
17
|
+
|
|
18
|
+
### Behavior
|
|
19
|
+
|
|
20
|
+
- Creates `.claude-checkpoints/` shadow git repo in project root
|
|
21
|
+
- Copies original files before modification and commits them
|
|
22
|
+
- Never blocks operations — checkpoint failures are logged but ignored
|
|
23
|
+
- Auto-adds `.claude-checkpoints/` to `.gitignore`
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# hook-checkpoint
|
|
2
|
+
|
|
3
|
+
Claude Code hook that creates shadow git snapshots before file modifications for easy rollback.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Before any `Write`, `Edit`, or `NotebookEdit` tool execution, this hook copies the original file into a shadow git repository (`.claude-checkpoints/`). This gives you a full history of every file before Claude modified it, without cluttering your main git history.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun install -g @hasnaxyz/hook-checkpoint
|
|
13
|
+
hook-checkpoint install
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
hook-checkpoint install # Install to Claude Code settings
|
|
20
|
+
hook-checkpoint uninstall # Remove from Claude Code settings
|
|
21
|
+
hook-checkpoint status # Check installation status
|
|
22
|
+
hook-checkpoint list # Show recent checkpoints
|
|
23
|
+
hook-checkpoint restore <ref> # Restore files from a checkpoint
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## How It Works
|
|
27
|
+
|
|
28
|
+
1. Hook intercepts `Write`/`Edit`/`NotebookEdit` calls (PreToolUse)
|
|
29
|
+
2. Copies the original file into `.claude-checkpoints/files/`
|
|
30
|
+
3. Commits to a shadow git repo with metadata
|
|
31
|
+
4. Always approves the operation (non-blocking)
|
|
32
|
+
|
|
33
|
+
The `.claude-checkpoints/` directory is automatically added to `.gitignore`.
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
MIT
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hasnaxyz/hook-checkpoint",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code hook that creates shadow git snapshots before file modifications for easy rollback",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hook-checkpoint": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/hook.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./dist/hook.js",
|
|
13
|
+
"types": "./dist/hook.d.ts"
|
|
14
|
+
},
|
|
15
|
+
"./cli": {
|
|
16
|
+
"import": "./dist/cli.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "bun build ./src/cli.ts ./src/hook.ts --outdir ./dist --target node",
|
|
25
|
+
"prepublishOnly": "bun run build",
|
|
26
|
+
"typecheck": "tsc --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"claude-code",
|
|
30
|
+
"claude",
|
|
31
|
+
"hook",
|
|
32
|
+
"checkpoint",
|
|
33
|
+
"git",
|
|
34
|
+
"snapshot",
|
|
35
|
+
"rollback",
|
|
36
|
+
"safety",
|
|
37
|
+
"cli"
|
|
38
|
+
],
|
|
39
|
+
"author": "Hasna",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "https://github.com/hasnaxyz/hook-checkpoint.git"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "restricted",
|
|
47
|
+
"registry": "https://registry.npmjs.org/"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=18",
|
|
51
|
+
"bun": ">=1.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/bun": "^1.3.8",
|
|
55
|
+
"@types/node": "^20",
|
|
56
|
+
"typescript": "^5.0.0"
|
|
57
|
+
}
|
|
58
|
+
}
|