@miphamai/cli 0.69.0 → 0.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/loop-scaffold.ts +15 -44
- package/src/config/loader.ts +56 -1
- package/src/core/claude-md-fix.ts +28 -0
- package/src/core/fix-code.ts +167 -0
- package/src/core/fix.ts +176 -0
- package/src/core/hooks-config.ts +10 -5
- package/src/core/hooks-executor.ts +84 -5
- package/src/core/instructions.ts +2 -2
- package/src/i18n-core/locales/en-US.json +21 -1
- package/src/i18n-core/locales/zh-CN.json +21 -1
- package/src/index.tsx +12 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +2 -0
- package/src/skills/community-registry.json +16 -0
- package/src/skills/marketplace.ts +233 -0
- package/src/skills/registry.ts +77 -63
- package/src/ui/commands.ts +311 -33
package/package.json
CHANGED
|
@@ -64,10 +64,6 @@ function writeTemplate(
|
|
|
64
64
|
* ├── .mipham/
|
|
65
65
|
* │ ├── CLAUDE.md
|
|
66
66
|
* │ ├── settings.json
|
|
67
|
-
* │ ├── hooks/
|
|
68
|
-
* │ │ ├── pre-tool-use.sh
|
|
69
|
-
* │ │ ├── post-tool-use.sh
|
|
70
|
-
* │ │ └── stop.sh
|
|
71
67
|
* │ ├── agents/
|
|
72
68
|
* │ │ └── verifier.md
|
|
73
69
|
* │ └── skills/
|
|
@@ -103,13 +99,6 @@ export function scaffoldLoopKit(basePath: string): ScaffoldResult {
|
|
|
103
99
|
// ── .mipham/settings.json ──
|
|
104
100
|
writeTemplate(join(miphamDir, 'settings.json'), TEMPLATES.settingsJson, false, created, skipped)
|
|
105
101
|
|
|
106
|
-
// ── .mipham/hooks/ ──
|
|
107
|
-
const hooksDir = join(miphamDir, 'hooks')
|
|
108
|
-
ensureDir(hooksDir, created, skipped)
|
|
109
|
-
writeTemplate(join(hooksDir, 'pre-tool-use.sh'), TEMPLATES.preToolUse, true, created, skipped)
|
|
110
|
-
writeTemplate(join(hooksDir, 'post-tool-use.sh'), TEMPLATES.postToolUse, true, created, skipped)
|
|
111
|
-
writeTemplate(join(hooksDir, 'stop.sh'), TEMPLATES.stopHook, true, created, skipped)
|
|
112
|
-
|
|
113
102
|
// ── .mipham/agents/ ──
|
|
114
103
|
const agentsDir = join(miphamDir, 'agents')
|
|
115
104
|
ensureDir(agentsDir, created, skipped)
|
|
@@ -180,7 +169,20 @@ const TEMPLATES = {
|
|
|
180
169
|
deny: [],
|
|
181
170
|
},
|
|
182
171
|
hooks: {
|
|
183
|
-
PreToolUse
|
|
172
|
+
// 示例:PreToolUse 拦截 Bash。脚本从 stdin 读 JSON(tool_name/tool_input),
|
|
173
|
+
// 输出 hookSpecificOutput JSON 决定 allow/deny;exit 2 = 拦截(stderr 作理由)。
|
|
174
|
+
PreToolUse: [
|
|
175
|
+
{
|
|
176
|
+
matcher: 'Bash',
|
|
177
|
+
hooks: [
|
|
178
|
+
{
|
|
179
|
+
type: 'command',
|
|
180
|
+
command: 'your-hook-script.sh',
|
|
181
|
+
timeout: 60,
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
],
|
|
184
186
|
PostToolUse: [],
|
|
185
187
|
Stop: [],
|
|
186
188
|
SessionStart: [],
|
|
@@ -192,37 +194,6 @@ const TEMPLATES = {
|
|
|
192
194
|
2,
|
|
193
195
|
) + '\n',
|
|
194
196
|
|
|
195
|
-
preToolUse: `#!/bin/bash
|
|
196
|
-
# PreToolUse hook — runs before each tool execution.
|
|
197
|
-
# Tool name passed as \$1, input JSON as \$2.
|
|
198
|
-
# Exit non-zero to block the tool.
|
|
199
|
-
# Write JSON to stdout to modify the tool input.
|
|
200
|
-
|
|
201
|
-
TOOL_NAME="\$1"
|
|
202
|
-
TOOL_INPUT="\$2"
|
|
203
|
-
|
|
204
|
-
echo "{\\"decision\\": \\"allow\\"}" >&2
|
|
205
|
-
exit 0
|
|
206
|
-
`,
|
|
207
|
-
|
|
208
|
-
postToolUse: `#!/bin/bash
|
|
209
|
-
# PostToolUse hook — runs after each tool execution.
|
|
210
|
-
# Tool name passed as \$1, result JSON as \$2.
|
|
211
|
-
|
|
212
|
-
TOOL_NAME="\$1"
|
|
213
|
-
TOOL_RESULT="\$2"
|
|
214
|
-
|
|
215
|
-
exit 0
|
|
216
|
-
`,
|
|
217
|
-
|
|
218
|
-
stopHook: `#!/bin/bash
|
|
219
|
-
# Stop hook — runs when the AI session ends.
|
|
220
|
-
# Use for cleanup, notifications, or saving state.
|
|
221
|
-
|
|
222
|
-
echo "Session ended at \$(date)" >&2
|
|
223
|
-
exit 0
|
|
224
|
-
`,
|
|
225
|
-
|
|
226
197
|
verifierAgent: `# Verifier Agent
|
|
227
198
|
|
|
228
199
|
> Pre-commit audit specialist. Dispatched BEFORE committing to verify changes.
|
|
@@ -290,7 +261,7 @@ Verify staged changes against coding standards, security rules, and best practic
|
|
|
290
261
|
|
|
291
262
|
## Structure
|
|
292
263
|
|
|
293
|
-
- \`.mipham/\` — Mipham Code configuration (CLAUDE.md, settings,
|
|
264
|
+
- \`.mipham/\` — Mipham Code configuration (CLAUDE.md, settings.json, agents, skills)
|
|
294
265
|
- \`.mcp.json\` — MCP server configuration
|
|
295
266
|
- \`MEMORY.md\` — AI persistent memory
|
|
296
267
|
- \`run.sh\` — Project launcher
|
package/src/config/loader.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
DEFAULT_CROSS_SESSION_CONFIG,
|
|
29
29
|
} from './defaults'
|
|
30
30
|
import { getCredentialKey, encryptApiKey, decryptApiKey, ENC_PREFIX } from './credential-crypto'
|
|
31
|
+
import type { SettingsHooks } from '../core/hooks-config'
|
|
31
32
|
|
|
32
33
|
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
33
34
|
const BACKUP_PREFIX = 'config.backup-'
|
|
@@ -139,7 +140,7 @@ function backupConfig(configPath: string): void {
|
|
|
139
140
|
* Try to restore config from the most recent backup.
|
|
140
141
|
* Returns true if restored successfully.
|
|
141
142
|
*/
|
|
142
|
-
function tryRestoreFromBackup(configPath: string): boolean {
|
|
143
|
+
export function tryRestoreFromBackup(configPath: string): boolean {
|
|
143
144
|
try {
|
|
144
145
|
if (!existsSync(MIPHAM_HOME)) return false
|
|
145
146
|
const files = readdirSync(MIPHAM_HOME)
|
|
@@ -216,6 +217,60 @@ function loadMcpJson(cwd: string): McpServerConfig[] {
|
|
|
216
217
|
return servers
|
|
217
218
|
}
|
|
218
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Parsed `settings.json` (Claude Code convention): hooks + permissions.
|
|
222
|
+
* Hooks are additive across levels; permissions allow/deny are deduped unions.
|
|
223
|
+
*/
|
|
224
|
+
export interface SettingsJson {
|
|
225
|
+
hooks: SettingsHooks
|
|
226
|
+
permissions: { allow: string[]; deny: string[] }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Load `settings.json` — project-level `.mipham/settings.json` then user-level
|
|
231
|
+
* `~/.mipham/settings.json`. Mirrors the Claude Code convention (hooks additive,
|
|
232
|
+
* permissions merged), so users can migrate their Claude settings unchanged.
|
|
233
|
+
*/
|
|
234
|
+
export function loadSettingsJson(cwd: string = process.cwd()): SettingsJson {
|
|
235
|
+
const hooks: SettingsHooks = {}
|
|
236
|
+
const permissions = { allow: [] as string[], deny: [] as string[] }
|
|
237
|
+
|
|
238
|
+
const searchPaths = [join(cwd, '.mipham', 'settings.json'), join(MIPHAM_HOME, 'settings.json')]
|
|
239
|
+
|
|
240
|
+
for (const path of searchPaths) {
|
|
241
|
+
try {
|
|
242
|
+
if (!existsSync(path)) continue
|
|
243
|
+
const raw = readFileSync(path, 'utf-8')
|
|
244
|
+
const parsed = JSON.parse(raw) as {
|
|
245
|
+
hooks?: Record<string, unknown>
|
|
246
|
+
permissions?: { allow?: unknown; deny?: unknown }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (parsed.hooks && typeof parsed.hooks === 'object') {
|
|
250
|
+
for (const [eventName, entries] of Object.entries(parsed.hooks)) {
|
|
251
|
+
if (!Array.isArray(entries)) continue
|
|
252
|
+
const bucket = (hooks as Record<string, unknown[]>)[eventName]
|
|
253
|
+
;(hooks as Record<string, unknown[]>)[eventName] = [...(bucket ?? []), ...entries]
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (parsed.permissions) {
|
|
258
|
+
for (const key of ['allow', 'deny'] as const) {
|
|
259
|
+
const list = parsed.permissions[key]
|
|
260
|
+
if (!Array.isArray(list)) continue
|
|
261
|
+
for (const p of list) {
|
|
262
|
+
if (typeof p === 'string' && !permissions[key].includes(p)) permissions[key].push(p)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
// Silently skip malformed or missing settings.json files
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { hooks, permissions }
|
|
272
|
+
}
|
|
273
|
+
|
|
219
274
|
export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
|
|
220
275
|
const configPath = join(cwd, '.mipham', 'config.yml')
|
|
221
276
|
const userConfigPath = join(MIPHAM_HOME, 'config.yml')
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLAUDE.md fix — write the derivable-section headings that `findDerivableSections`
|
|
3
|
+
* flags into the document's `prompt-exclude` frontmatter, so `stripSections` stops
|
|
4
|
+
* injecting them into the system prompt every session.
|
|
5
|
+
*/
|
|
6
|
+
import { stringify as stringifyYaml } from 'yaml'
|
|
7
|
+
import { parseFrontmatter, parsePromptExclude } from './instructions'
|
|
8
|
+
|
|
9
|
+
export interface ApplyPromptExcludeResult {
|
|
10
|
+
content: string
|
|
11
|
+
added: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Merge `headings` into the document's `prompt-exclude` frontmatter (creating the
|
|
16
|
+
* frontmatter block if absent). Preserves existing exclusions and other frontmatter
|
|
17
|
+
* fields; reports only the headings that were actually new.
|
|
18
|
+
*/
|
|
19
|
+
export function applyPromptExclude(content: string, headings: string[]): ApplyPromptExcludeResult {
|
|
20
|
+
const { data, content: body } = parseFrontmatter(content)
|
|
21
|
+
const existing = parsePromptExclude(data['prompt-exclude'])
|
|
22
|
+
const added = [...new Set(headings)].filter((h) => !existing.includes(h))
|
|
23
|
+
if (added.length === 0) return { content, added: [] }
|
|
24
|
+
|
|
25
|
+
data['prompt-exclude'] = [...existing, ...added]
|
|
26
|
+
const frontmatter = stringifyYaml(data)
|
|
27
|
+
return { content: `---\n${frontmatter}---\n${body}`, added }
|
|
28
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/fix test` — LLM-driven repair of failing tests in the real repo.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the `/crsi bench` closed loop (LLM generates → a frozen test judges the
|
|
5
|
+
* result) but applied to the caller's own codebase: the failing test is the frozen
|
|
6
|
+
* ground truth, and the LLM may only fix the *source*, never the test.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract failing test-file paths from a `vitest run` output stream. Matches the
|
|
11
|
+
* ` FAIL test/xxx.test.ts > describe > it` lines (vitest 4 default reporter).
|
|
12
|
+
*/
|
|
13
|
+
export function parseVitestFailures(output: string): string[] {
|
|
14
|
+
const files = new Set<string>()
|
|
15
|
+
const re = /^\s*FAIL\s+(\S+)/gm
|
|
16
|
+
let m
|
|
17
|
+
while ((m = re.exec(output))) {
|
|
18
|
+
files.add(m[1]!)
|
|
19
|
+
}
|
|
20
|
+
return [...files]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Extract relative import specifiers (`./foo`, `../bar`) from a test file — these
|
|
25
|
+
* are the candidate source files the failing test depends on. Package imports
|
|
26
|
+
* (`vitest`, `lodash`) and node builtins (`node:fs`) are ignored.
|
|
27
|
+
*/
|
|
28
|
+
export function collectLocalImports(testContent: string): string[] {
|
|
29
|
+
const imports: string[] = []
|
|
30
|
+
const re = /from\s+['"](\.[^'"]*)['"]/g
|
|
31
|
+
let m
|
|
32
|
+
while ((m = re.exec(testContent))) {
|
|
33
|
+
imports.push(m[1]!)
|
|
34
|
+
}
|
|
35
|
+
return imports
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build the LLM prompt that asks for a source-only fix. The test is declared
|
|
40
|
+
* frozen ground truth; the model must return the corrected source file contents.
|
|
41
|
+
*/
|
|
42
|
+
export function buildFixPrompt(opts: {
|
|
43
|
+
testPath: string
|
|
44
|
+
testContent: string
|
|
45
|
+
sourcePath: string
|
|
46
|
+
sourceContent: string
|
|
47
|
+
failure: string
|
|
48
|
+
}): string {
|
|
49
|
+
const { testPath, testContent, sourcePath, sourceContent, failure } = opts
|
|
50
|
+
return [
|
|
51
|
+
'You are fixing a failing test in a real codebase.',
|
|
52
|
+
'',
|
|
53
|
+
'RULES:',
|
|
54
|
+
'- The test file is FROZEN ground truth. Do NOT modify the test.',
|
|
55
|
+
'- Only fix the SOURCE file so the test passes.',
|
|
56
|
+
'- Output ONLY the corrected source file contents — no markdown fences, no explanation.',
|
|
57
|
+
'',
|
|
58
|
+
`Failing test: ${testPath}`,
|
|
59
|
+
'```',
|
|
60
|
+
testContent,
|
|
61
|
+
'```',
|
|
62
|
+
'',
|
|
63
|
+
`Source to fix: ${sourcePath}`,
|
|
64
|
+
'```',
|
|
65
|
+
sourceContent,
|
|
66
|
+
'```',
|
|
67
|
+
'',
|
|
68
|
+
'Failure:',
|
|
69
|
+
failure,
|
|
70
|
+
].join('\n')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface FixCodeDeps {
|
|
74
|
+
runVitest: (testFile: string) => { exitCode: number; output: string }
|
|
75
|
+
readFile: (path: string) => string | null
|
|
76
|
+
writeFile: (path: string, content: string) => void
|
|
77
|
+
generateFix: (prompt: string) => Promise<string>
|
|
78
|
+
resolveSourceFile: (testFile: string, specifier: string) => string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface FixTargetResult {
|
|
82
|
+
testFile: string
|
|
83
|
+
sourceFile: string | null
|
|
84
|
+
fixed: boolean
|
|
85
|
+
attempts: number
|
|
86
|
+
detail?: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Repair one failing test: locate the first local source the test imports, ask
|
|
91
|
+
* the LLM for a source-only fix, verify it against the frozen test, and apply it
|
|
92
|
+
* (or restore the original in dry-run). Retries up to `maxRetries`, feeding each
|
|
93
|
+
* failure back into the next prompt.
|
|
94
|
+
*/
|
|
95
|
+
export async function fixCodeTarget(
|
|
96
|
+
deps: FixCodeDeps,
|
|
97
|
+
testFile: string,
|
|
98
|
+
opts?: { apply?: boolean; maxRetries?: number },
|
|
99
|
+
): Promise<FixTargetResult> {
|
|
100
|
+
const apply = opts?.apply ?? false
|
|
101
|
+
const maxRetries = opts?.maxRetries ?? 3
|
|
102
|
+
|
|
103
|
+
const initial = deps.runVitest(testFile)
|
|
104
|
+
if (initial.exitCode === 0) {
|
|
105
|
+
return { testFile, sourceFile: null, fixed: true, attempts: 0, detail: 'test already passes' }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const testContent = deps.readFile(testFile)
|
|
109
|
+
if (testContent === null) {
|
|
110
|
+
return {
|
|
111
|
+
testFile,
|
|
112
|
+
sourceFile: null,
|
|
113
|
+
fixed: false,
|
|
114
|
+
attempts: 0,
|
|
115
|
+
detail: 'cannot read test file',
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const specifiers = collectLocalImports(testContent)
|
|
120
|
+
if (specifiers.length === 0) {
|
|
121
|
+
return {
|
|
122
|
+
testFile,
|
|
123
|
+
sourceFile: null,
|
|
124
|
+
fixed: false,
|
|
125
|
+
attempts: 0,
|
|
126
|
+
detail: 'no local imports to locate a source file',
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const sourceFile = deps.resolveSourceFile(testFile, specifiers[0]!)
|
|
131
|
+
const sourceContent = deps.readFile(sourceFile)
|
|
132
|
+
if (sourceContent === null) {
|
|
133
|
+
return { testFile, sourceFile, fixed: false, attempts: 0, detail: 'cannot read source file' }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let failure = initial.output
|
|
137
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
138
|
+
const prompt = buildFixPrompt({
|
|
139
|
+
testPath: testFile,
|
|
140
|
+
testContent,
|
|
141
|
+
sourcePath: sourceFile,
|
|
142
|
+
sourceContent,
|
|
143
|
+
failure,
|
|
144
|
+
})
|
|
145
|
+
const fixedContent = await deps.generateFix(prompt)
|
|
146
|
+
if (!fixedContent) continue
|
|
147
|
+
|
|
148
|
+
deps.writeFile(sourceFile, fixedContent)
|
|
149
|
+
const result = deps.runVitest(testFile)
|
|
150
|
+
|
|
151
|
+
if (result.exitCode === 0) {
|
|
152
|
+
if (!apply) deps.writeFile(sourceFile, sourceContent)
|
|
153
|
+
return { testFile, sourceFile, fixed: true, attempts: attempt }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
deps.writeFile(sourceFile, sourceContent)
|
|
157
|
+
failure = result.output
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
testFile,
|
|
162
|
+
sourceFile,
|
|
163
|
+
fixed: false,
|
|
164
|
+
attempts: maxRetries,
|
|
165
|
+
detail: 'fix never passed the frozen test',
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/core/fix.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/fix` — deterministic self-repair (no LLM). Orchestrates three repair targets:
|
|
3
|
+
* - doctor: write derivable CLAUDE.md sections into `prompt-exclude` frontmatter
|
|
4
|
+
* - config: restore a corrupted config.yml from backup, re-enable disabled hooks
|
|
5
|
+
* - cache: detect corrupt JSONL state lines under ~/.mipham (dry-run by default)
|
|
6
|
+
*
|
|
7
|
+
* The pure decision functions live here for testability; file I/O and engine calls
|
|
8
|
+
* are injected so each target is testable without touching the real filesystem.
|
|
9
|
+
*/
|
|
10
|
+
import { findDerivableSections } from './claude-md-audit'
|
|
11
|
+
import { applyPromptExclude } from './claude-md-fix'
|
|
12
|
+
|
|
13
|
+
export interface DoctorFix {
|
|
14
|
+
content: string
|
|
15
|
+
added: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compute the repaired content for one CLAUDE.md document, or null when there is
|
|
20
|
+
* nothing to change (no derivable sections, or all of them already excluded).
|
|
21
|
+
*/
|
|
22
|
+
export function computeDoctorFix(content: string): DoctorFix | null {
|
|
23
|
+
const sections = findDerivableSections(content)
|
|
24
|
+
if (sections.length === 0) return null
|
|
25
|
+
const { content: fixed, added } = applyPromptExclude(
|
|
26
|
+
content,
|
|
27
|
+
sections.map((s) => s.heading),
|
|
28
|
+
)
|
|
29
|
+
return added.length > 0 ? { content: fixed, added } : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Return the 0-based line numbers of a JSONL document whose non-blank lines are not
|
|
34
|
+
* valid JSON. Blank/whitespace-only lines are ignored.
|
|
35
|
+
*/
|
|
36
|
+
export function findCorruptJsonlLines(content: string): number[] {
|
|
37
|
+
const corrupt: number[] = []
|
|
38
|
+
content.split('\n').forEach((line, i) => {
|
|
39
|
+
const trimmed = line.trim()
|
|
40
|
+
if (trimmed === '') return
|
|
41
|
+
try {
|
|
42
|
+
JSON.parse(trimmed)
|
|
43
|
+
} catch {
|
|
44
|
+
corrupt.push(i)
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
return corrupt
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Drop the given line numbers from a document, keeping everything else verbatim. */
|
|
51
|
+
export function removeCorruptLines(content: string, corrupt: number[]): string {
|
|
52
|
+
const corruptSet = new Set(corrupt)
|
|
53
|
+
return content
|
|
54
|
+
.split('\n')
|
|
55
|
+
.filter((_, i) => !corruptSet.has(i))
|
|
56
|
+
.join('\n')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Select the CLAUDE.md files that live inside the current repository (project and
|
|
61
|
+
* directory levels), excluding group/company/user policy files outside the repo.
|
|
62
|
+
*/
|
|
63
|
+
export function selectRepoClaudeFiles(
|
|
64
|
+
files: Array<{ path: string; level: string }>,
|
|
65
|
+
): Array<{ path: string }> {
|
|
66
|
+
return files
|
|
67
|
+
.filter(
|
|
68
|
+
(f) => f.path.endsWith('CLAUDE.md') && (f.level === 'project' || f.level === 'directory'),
|
|
69
|
+
)
|
|
70
|
+
.map((f) => ({ path: f.path }))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface DoctorReport {
|
|
74
|
+
fixed: Array<{ path: string; added: string[] }>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Read each CLAUDE.md, apply the derivable-section fix, and write it back. */
|
|
78
|
+
export function fixDoctor(
|
|
79
|
+
files: Array<{ path: string }>,
|
|
80
|
+
io: { read: (path: string) => string | null; write: (path: string, content: string) => void },
|
|
81
|
+
): DoctorReport {
|
|
82
|
+
const fixed: DoctorReport['fixed'] = []
|
|
83
|
+
for (const f of files) {
|
|
84
|
+
const raw = io.read(f.path)
|
|
85
|
+
if (raw === null) continue
|
|
86
|
+
const result = computeDoctorFix(raw)
|
|
87
|
+
if (result) {
|
|
88
|
+
io.write(f.path, result.content)
|
|
89
|
+
fixed.push({ path: f.path, added: result.added })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { fixed }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ConfigFixReport {
|
|
96
|
+
/** Config files whose YAML failed to parse (detected). */
|
|
97
|
+
corruptConfigs: string[]
|
|
98
|
+
/** Corrupt config files actually restored from backup. */
|
|
99
|
+
restoredConfigs: string[]
|
|
100
|
+
/** Hooks currently disabled (detected). */
|
|
101
|
+
disabledHooks: string[]
|
|
102
|
+
/** Disabled hooks actually re-enabled. */
|
|
103
|
+
reenabledHooks: string[]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Detect corrupt config.yml files and disabled hooks. Repairs (restore from
|
|
108
|
+
* backup / re-enable) only happen when `dryRun` is false; in dry-run the report
|
|
109
|
+
* still lists what was detected without mutating anything.
|
|
110
|
+
*/
|
|
111
|
+
export function fixConfig(deps: {
|
|
112
|
+
configPaths: string[]
|
|
113
|
+
read: (path: string) => string | null
|
|
114
|
+
parseYaml: (raw: string) => unknown
|
|
115
|
+
restore: (path: string) => boolean
|
|
116
|
+
hookHealth: () => Array<{ key: string; disabled: boolean }>
|
|
117
|
+
reEnableHook: (key: string) => boolean
|
|
118
|
+
dryRun?: boolean
|
|
119
|
+
}): ConfigFixReport {
|
|
120
|
+
const report: ConfigFixReport = {
|
|
121
|
+
corruptConfigs: [],
|
|
122
|
+
restoredConfigs: [],
|
|
123
|
+
disabledHooks: [],
|
|
124
|
+
reenabledHooks: [],
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const p of deps.configPaths) {
|
|
128
|
+
const raw = deps.read(p)
|
|
129
|
+
if (raw === null) continue
|
|
130
|
+
let corrupt = false
|
|
131
|
+
try {
|
|
132
|
+
deps.parseYaml(raw)
|
|
133
|
+
} catch {
|
|
134
|
+
corrupt = true
|
|
135
|
+
}
|
|
136
|
+
if (!corrupt) continue
|
|
137
|
+
report.corruptConfigs.push(p)
|
|
138
|
+
if (!deps.dryRun && deps.restore(p)) report.restoredConfigs.push(p)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const h of deps.hookHealth()) {
|
|
142
|
+
if (!h.disabled) continue
|
|
143
|
+
report.disabledHooks.push(h.key)
|
|
144
|
+
if (!deps.dryRun && deps.reEnableHook(h.key)) report.reenabledHooks.push(h.key)
|
|
145
|
+
}
|
|
146
|
+
return report
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface CacheFixReport {
|
|
150
|
+
files: Array<{ path: string; corruptLines: number[] }>
|
|
151
|
+
cleaned: Array<{ path: string; removed: number }>
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Scan JSONL state files for corrupt lines. Reports them always; only rewrites the
|
|
156
|
+
* file (dropping corrupt lines) when `apply` is true.
|
|
157
|
+
*/
|
|
158
|
+
export function fixCache(
|
|
159
|
+
files: string[],
|
|
160
|
+
io: { read: (path: string) => string | null; write: (path: string, content: string) => void },
|
|
161
|
+
apply: boolean,
|
|
162
|
+
): CacheFixReport {
|
|
163
|
+
const report: CacheFixReport = { files: [], cleaned: [] }
|
|
164
|
+
for (const path of files) {
|
|
165
|
+
const raw = io.read(path)
|
|
166
|
+
if (raw === null) continue
|
|
167
|
+
const corrupt = findCorruptJsonlLines(raw)
|
|
168
|
+
if (corrupt.length === 0) continue
|
|
169
|
+
report.files.push({ path, corruptLines: corrupt })
|
|
170
|
+
if (apply) {
|
|
171
|
+
io.write(path, removeCorruptLines(raw, corrupt))
|
|
172
|
+
report.cleaned.push({ path, removed: corrupt.length })
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return report
|
|
176
|
+
}
|
package/src/core/hooks-config.ts
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
import type { HookConfig, HookEvent, HookDefinition, HookContext } from '../shared/index.ts'
|
|
2
2
|
import { executeHook } from './hooks-executor'
|
|
3
3
|
|
|
4
|
-
interface HookConfigEntry {
|
|
4
|
+
export interface HookConfigEntry {
|
|
5
5
|
matcher: string
|
|
6
6
|
hooks: HookConfig[]
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/** `settings.json` `hooks` section — one matcher-group list per event. */
|
|
10
|
+
export interface SettingsHooks {
|
|
10
11
|
PreToolUse?: HookConfigEntry[]
|
|
11
12
|
PostToolUse?: HookConfigEntry[]
|
|
13
|
+
PostToolUseFailure?: HookConfigEntry[]
|
|
14
|
+
SessionStart?: HookConfigEntry[]
|
|
15
|
+
SessionEnd?: HookConfigEntry[]
|
|
16
|
+
Notification?: HookConfigEntry[]
|
|
12
17
|
Stop?: HookConfigEntry[]
|
|
13
18
|
UserPromptSubmit?: HookConfigEntry[]
|
|
14
19
|
PreCompact?: HookConfigEntry[]
|
|
15
20
|
PostCompact?: HookConfigEntry[]
|
|
16
|
-
SessionStart?: HookConfigEntry[]
|
|
17
|
-
SessionEnd?: HookConfigEntry[]
|
|
18
|
-
Notification?: HookConfigEntry[]
|
|
19
21
|
ConfigChange?: HookConfigEntry[]
|
|
22
|
+
SubagentStart?: HookConfigEntry[]
|
|
23
|
+
SubagentStop?: HookConfigEntry[]
|
|
24
|
+
PreInference?: HookConfigEntry[]
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
/**
|
|
@@ -30,22 +30,101 @@ function substituteVars(template: string, ctx: HookContext): string {
|
|
|
30
30
|
.replace(/\$SESSION_ID/g, ctx.sessionId)
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Build the Claude Code protocol stdin JSON for a hook script. Mirrors the
|
|
35
|
+
* fields Claude Code passes (session_id / hook_event_name / cwd / tool_name /
|
|
36
|
+
* tool_input / tool_response) so hand-written Claude hooks can migrate
|
|
37
|
+
* unchanged.
|
|
38
|
+
*/
|
|
39
|
+
export function buildHookStdin(ctx: HookContext, cwd: string): Record<string, unknown> {
|
|
40
|
+
const payload: Record<string, unknown> = {
|
|
41
|
+
session_id: ctx.sessionId,
|
|
42
|
+
hook_event_name: ctx.event,
|
|
43
|
+
cwd,
|
|
44
|
+
}
|
|
45
|
+
if (ctx.toolName) payload.tool_name = ctx.toolName
|
|
46
|
+
if (ctx.toolInput) payload.tool_input = ctx.toolInput
|
|
47
|
+
if (ctx.toolResult) payload.tool_response = ctx.toolResult
|
|
48
|
+
return payload
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse a hook script's stdout JSON into a HookResult, following the Claude
|
|
53
|
+
* Code output contract. Supports the modern `hookSpecificOutput` carrier
|
|
54
|
+
* (permissionDecision / updatedInput / additionalContext) plus the legacy
|
|
55
|
+
* root-level `decision` and `continue` fields. Non-JSON or empty stdout = allow.
|
|
56
|
+
*/
|
|
57
|
+
export function parseHookStdout(stdout: string | null | undefined, _ctx: HookContext): HookResult {
|
|
58
|
+
if (!stdout) return { allowed: true }
|
|
59
|
+
|
|
60
|
+
let parsed: Record<string, unknown>
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(stdout) as Record<string, unknown>
|
|
63
|
+
} catch {
|
|
64
|
+
return { allowed: true }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const hso = parsed.hookSpecificOutput as Record<string, unknown> | undefined
|
|
68
|
+
if (hso) {
|
|
69
|
+
const decision = hso.permissionDecision as string | undefined
|
|
70
|
+
const reason = hso.permissionDecisionReason as string | undefined
|
|
71
|
+
const additionalContext = hso.additionalContext as string | undefined
|
|
72
|
+
const updatedInput = hso.updatedInput as Record<string, unknown> | undefined
|
|
73
|
+
|
|
74
|
+
if (decision === 'deny') {
|
|
75
|
+
return { allowed: false, reason: reason ?? 'Denied by hook', additionalContext }
|
|
76
|
+
}
|
|
77
|
+
if (decision === 'allow') {
|
|
78
|
+
return {
|
|
79
|
+
allowed: true,
|
|
80
|
+
permissionDecision: 'allow',
|
|
81
|
+
modifiedInput: updatedInput,
|
|
82
|
+
additionalContext,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (decision === 'ask') {
|
|
86
|
+
return { allowed: true, permissionDecision: 'ask', additionalContext }
|
|
87
|
+
}
|
|
88
|
+
if (decision === 'defer') {
|
|
89
|
+
return { allowed: true, permissionDecision: 'defer', additionalContext }
|
|
90
|
+
}
|
|
91
|
+
if (additionalContext) {
|
|
92
|
+
return { allowed: true, additionalContext }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Legacy root-level decision: block / approve
|
|
97
|
+
if (parsed.decision === 'block') {
|
|
98
|
+
return { allowed: false, reason: (parsed.reason as string) ?? 'Blocked by hook' }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Stop-style events: continue:false
|
|
102
|
+
if (parsed.continue === false) {
|
|
103
|
+
return { allowed: false, reason: (parsed.stopReason as string) ?? 'Stopped by hook' }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { allowed: true }
|
|
107
|
+
}
|
|
108
|
+
|
|
33
109
|
function executeCommand(cfg: HookConfig, ctx: HookContext): HookResult {
|
|
34
110
|
if (!cfg.command) return { allowed: true }
|
|
35
111
|
|
|
36
112
|
try {
|
|
37
113
|
const args = cfg.args ? cfg.args.map((a) => substituteVars(a, ctx)) : []
|
|
38
114
|
|
|
39
|
-
// Use spawnSync with array args — no shell, no command injection
|
|
115
|
+
// Use spawnSync with array args — no shell, no command injection.
|
|
116
|
+
// Pass the Claude-protocol stdin JSON so scripts can read structured context.
|
|
117
|
+
const input = JSON.stringify(buildHookStdin(ctx, process.cwd()))
|
|
40
118
|
const result = spawnSync(cfg.command, args, {
|
|
41
|
-
timeout:
|
|
119
|
+
timeout: (cfg.timeout ?? 60) * 1000,
|
|
42
120
|
encoding: 'utf-8',
|
|
43
|
-
stdio: ['
|
|
121
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
122
|
+
input,
|
|
44
123
|
})
|
|
45
124
|
|
|
46
|
-
// Exit code 0 = success
|
|
125
|
+
// Exit code 0 = success — parse the stdout JSON for structured decisions.
|
|
47
126
|
if (result.status === 0) {
|
|
48
|
-
return
|
|
127
|
+
return parseHookStdout(result.stdout, ctx)
|
|
49
128
|
}
|
|
50
129
|
|
|
51
130
|
// Non-zero exit: check for block signal (exit code 2)
|