@miphamai/cli 0.68.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.
@@ -14,6 +14,8 @@ import { McpClient } from '../mcp/client'
14
14
  import { buildCapabilityReport } from '../core/capability-inventory'
15
15
  import { InstructionsLoader } from '../core/instructions'
16
16
  import { findDerivableSections, DERIVABLE_HINTS } from '../core/claude-md-audit'
17
+ import { fixDoctor, fixConfig, fixCache, selectRepoClaudeFiles } from '../core/fix'
18
+ import { fixCodeTarget } from '../core/fix-code'
17
19
  import { runCrsiModification, approvePending, rejectPending, hasPending } from '../core/crsi-modify'
18
20
  import {
19
21
  produceCrsiProposal,
@@ -32,7 +34,11 @@ import {
32
34
  import { prefilterProposal } from '../core/proposal-guard'
33
35
  import { runEval, appendEvalScore } from '../core/eval-harness'
34
36
  import { listRewardFns } from '../core/reward-fn'
35
- import { runTaskPerformance, measureSkillDeltaRepeated } from '../core/task-performance'
37
+ import {
38
+ runTaskPerformance,
39
+ measureSkillDeltaRepeated,
40
+ stripCodeFences,
41
+ } from '../core/task-performance'
36
42
  import { randomUUID } from 'node:crypto'
37
43
  import {
38
44
  buildImprovementReport,
@@ -45,7 +51,8 @@ import {
45
51
  } from '../core/improvement-track'
46
52
  import { NPM_UPDATE_COMMAND, PACKAGE_VERSION, COAUTHOR_TRAILER } from '../shared/index.ts'
47
53
  import { getPreference } from '../config/preferences'
48
- import { loadCrossSessionConfig } from '../config/loader'
54
+ import { loadCrossSessionConfig, tryRestoreFromBackup } from '../config/loader'
55
+ import { getMemoryManager } from '../core/memory/memory-loader'
49
56
  import { stripIndent } from './strip-indent.js'
50
57
  import { createT } from '../i18n-core/t'
51
58
  import type { TranslationMap } from '../i18n-core/types'
@@ -2358,9 +2365,9 @@ const installSkillCmd: CommandHandler = async (ctx, args) => {
2358
2365
  let result: { success: boolean; name: string; message: string }
2359
2366
 
2360
2367
  if (target.startsWith('http://') || target.startsWith('https://')) {
2361
- result = installSkillFromUrl(target, marketplaceConfig)
2368
+ result = await installSkillFromUrl(target, marketplaceConfig)
2362
2369
  } else {
2363
- result = installSkill(target, marketplaceConfig)
2370
+ result = await installSkill(target, marketplaceConfig)
2364
2371
  }
2365
2372
 
2366
2373
  return {
@@ -2382,6 +2389,83 @@ const removeSkillCmd: CommandHandler = async (_ctx, args) => {
2382
2389
  }
2383
2390
  }
2384
2391
 
2392
+ const marketplaceCmd: CommandHandler = async (_ctx, args) => {
2393
+ const {
2394
+ readMarketplaces,
2395
+ addMarketplace,
2396
+ removeMarketplace,
2397
+ isValidMarketplaceRef,
2398
+ MARKETPLACES_PATH,
2399
+ } = await import('../skills/marketplace')
2400
+ const { readFileSync, writeFileSync, existsSync, mkdirSync } = await import('node:fs')
2401
+ const { dirname } = await import('node:path')
2402
+
2403
+ const readSources = () =>
2404
+ readMarketplaces((p) => (existsSync(p) ? readFileSync(p, 'utf-8') : null))
2405
+
2406
+ const sub = args[0]
2407
+ const ref = args[1]
2408
+
2409
+ if (!sub || sub === 'list') {
2410
+ const sources = readSources()
2411
+ const lines = ['── Marketplace Sources ──', '']
2412
+ for (const s of sources) lines.push(` ${s.owner}/${s.repo}`)
2413
+ lines.push(
2414
+ '',
2415
+ 'Add: /marketplace add <owner>/<repo>',
2416
+ 'Remove: /marketplace remove <owner>/<repo>',
2417
+ )
2418
+ return { content: lines.join('\n') }
2419
+ }
2420
+
2421
+ if (sub === 'add' || sub === 'remove') {
2422
+ if (!ref) return { content: `Usage: /marketplace ${sub} <owner>/<repo>` }
2423
+ const [owner, repo] = ref.split('/')
2424
+ if (!owner || !repo || !isValidMarketplaceRef(owner, repo)) {
2425
+ return { content: `❌ Invalid marketplace ref "${ref}". Expected <owner>/<repo>.` }
2426
+ }
2427
+ const current = readSources()
2428
+ const result =
2429
+ sub === 'add' ? addMarketplace(current, owner, repo) : removeMarketplace(current, owner, repo)
2430
+ const changed = 'added' in result ? result.added : result.removed
2431
+ try {
2432
+ mkdirSync(dirname(MARKETPLACES_PATH), { recursive: true })
2433
+ writeFileSync(MARKETPLACES_PATH, JSON.stringify(result.sources, null, 2), 'utf-8')
2434
+ } catch (err) {
2435
+ return { content: `❌ Failed to save marketplaces: ${String(err)}` }
2436
+ }
2437
+ if (sub === 'add') {
2438
+ return {
2439
+ content: changed ? `✅ Added ${owner}/${repo}` : `ℹ ${owner}/${repo} already exists`,
2440
+ }
2441
+ }
2442
+ return { content: changed ? `✅ Removed ${owner}/${repo}` : `ℹ ${owner}/${repo} not found` }
2443
+ }
2444
+
2445
+ return { content: 'Usage: /marketplace add|list|remove [owner/repo]' }
2446
+ }
2447
+
2448
+ const browseMarketplaceCmd: CommandHandler = async (_ctx) => {
2449
+ const { readMarketplaces, discoverSkills, downloadFile } = await import('../skills/marketplace')
2450
+ const { readFileSync, existsSync } = await import('node:fs')
2451
+
2452
+ const sources = readMarketplaces((p) => (existsSync(p) ? readFileSync(p, 'utf-8') : null))
2453
+ const lines = ['── Marketplace Skills ──', '']
2454
+ for (const source of sources) {
2455
+ lines.push(` ${source.owner}/${source.repo}:`)
2456
+ try {
2457
+ const skills = await discoverSkills(source, globalThis.fetch, downloadFile)
2458
+ if (skills.length === 0) lines.push(' (no skills found)')
2459
+ for (const s of skills) lines.push(` ${s.name.padEnd(24)} ${s.description}`)
2460
+ } catch {
2461
+ lines.push(' (failed to discover — check network / rate limit)')
2462
+ }
2463
+ lines.push('')
2464
+ }
2465
+ lines.push('Install: /install-skill <name>')
2466
+ return { content: lines.join('\n') }
2467
+ }
2468
+
2385
2469
  // ═══════════════════════════════════════════════════════════════
2386
2470
  // Plugins — Community plugin marketplace + local management
2387
2471
  // ═══════════════════════════════════════════════════════════════
@@ -3104,6 +3188,191 @@ const doctorCmd: CommandHandler = async (ctx) => {
3104
3188
  return { content: lines.join('\n') }
3105
3189
  }
3106
3190
 
3191
+ const fixCmd: CommandHandler = async (ctx, args) => {
3192
+ const t = resolveT(ctx)
3193
+ const { readFileSync, writeFileSync } = await import('node:fs')
3194
+ const { join } = await import('node:path')
3195
+ const { homedir } = await import('node:os')
3196
+ const { parse: parseYaml } = await import('yaml')
3197
+
3198
+ const target = args.find((a) => a === 'doctor' || a === 'config' || a === 'cache' || a === 'test')
3199
+ const dryRun = args.includes('--dry-run')
3200
+ const apply = args.includes('--apply')
3201
+
3202
+ const lines: string[] = [t('commands.fix.title')]
3203
+ if (dryRun) lines.push(t('commands.fix.dryrun_banner'))
3204
+ lines.push('')
3205
+
3206
+ const readSafe = (p: string): string | null => {
3207
+ try {
3208
+ return readFileSync(p, 'utf-8')
3209
+ } catch {
3210
+ return null
3211
+ }
3212
+ }
3213
+
3214
+ if (!target || target === 'doctor') {
3215
+ const loader = new InstructionsLoader()
3216
+ loader.loadAll(process.cwd())
3217
+ const files = selectRepoClaudeFiles(loader.list())
3218
+ const result = fixDoctor(files, {
3219
+ read: readSafe,
3220
+ write: (p, c) => {
3221
+ if (!dryRun) writeFileSync(p, c, 'utf-8')
3222
+ },
3223
+ })
3224
+ if (result.fixed.length === 0) {
3225
+ lines.push(t('commands.fix.doctor_clean'))
3226
+ } else {
3227
+ for (const f of result.fixed) {
3228
+ lines.push(t('commands.fix.doctor_fixed', { path: f.path, added: f.added.join(', ') }))
3229
+ }
3230
+ }
3231
+ lines.push('')
3232
+ }
3233
+
3234
+ if (!target || target === 'config') {
3235
+ const home = homedir()
3236
+ const configPaths = [
3237
+ join(process.cwd(), '.mipham', 'config.yml'),
3238
+ join(home, '.mipham', 'config.yml'),
3239
+ ]
3240
+ const hookEngine = ctx.engine.getHookEngine?.()
3241
+ const result = fixConfig({
3242
+ configPaths,
3243
+ read: readSafe,
3244
+ parseYaml,
3245
+ restore: (p) => tryRestoreFromBackup(p),
3246
+ hookHealth: () =>
3247
+ hookEngine
3248
+ ? hookEngine.getHookHealth().map((h) => ({ key: h.key, disabled: h.health.disabled }))
3249
+ : [],
3250
+ reEnableHook: (k) => (hookEngine ? hookEngine.reEnableHook(k) : false),
3251
+ dryRun,
3252
+ })
3253
+ if (result.corruptConfigs.length === 0 && result.disabledHooks.length === 0) {
3254
+ lines.push(t('commands.fix.config_clean'))
3255
+ } else {
3256
+ for (const p of result.restoredConfigs) {
3257
+ lines.push(t('commands.fix.config_restored', { path: p }))
3258
+ }
3259
+ for (const p of result.corruptConfigs.filter((p) => !result.restoredConfigs.includes(p))) {
3260
+ lines.push(t('commands.fix.config_corrupt', { path: p }))
3261
+ }
3262
+ for (const k of result.reenabledHooks) {
3263
+ lines.push(t('commands.fix.hook_reenabled', { key: k }))
3264
+ }
3265
+ for (const k of result.disabledHooks.filter((k) => !result.reenabledHooks.includes(k))) {
3266
+ lines.push(t('commands.fix.hook_disabled', { key: k }))
3267
+ }
3268
+ }
3269
+ lines.push('')
3270
+ }
3271
+
3272
+ if (!target || target === 'cache') {
3273
+ const crsiDir = join(homedir(), '.mipham', 'crsi')
3274
+ const cacheFiles = ['eval-scores.jsonl', 'improvements.jsonl', 'prose-proposals.jsonl'].map(
3275
+ (f) => join(crsiDir, f),
3276
+ )
3277
+ const result = fixCache(
3278
+ cacheFiles,
3279
+ {
3280
+ read: readSafe,
3281
+ write: (p, c) => writeFileSync(p, c, 'utf-8'),
3282
+ },
3283
+ apply && !dryRun,
3284
+ )
3285
+ if (result.files.length === 0) {
3286
+ lines.push(t('commands.fix.cache_clean'))
3287
+ } else {
3288
+ for (const f of result.files) {
3289
+ lines.push(
3290
+ t('commands.fix.cache_found', { path: f.path, count: String(f.corruptLines.length) }),
3291
+ )
3292
+ }
3293
+ if (apply && !dryRun) {
3294
+ for (const c of result.cleaned) {
3295
+ lines.push(t('commands.fix.cache_cleaned', { path: c.path, count: String(c.removed) }))
3296
+ }
3297
+ } else {
3298
+ lines.push(t('commands.fix.cache_hint'))
3299
+ }
3300
+ }
3301
+ lines.push('')
3302
+ }
3303
+
3304
+ if (!target || target === 'test') {
3305
+ const testFile = args.find(
3306
+ (a) =>
3307
+ !a.startsWith('--') && a !== 'test' && a !== 'doctor' && a !== 'config' && a !== 'cache',
3308
+ )
3309
+ if (!testFile) {
3310
+ lines.push(t('commands.fix.test_usage'))
3311
+ } else {
3312
+ const llm = ctx.engine.getLlm() ?? ctx.engine.getRegistry()
3313
+ if (!llm) {
3314
+ lines.push(t('commands.fix.test_no_llm'))
3315
+ } else {
3316
+ const { execSync } = await import('node:child_process')
3317
+ const { dirname, resolve } = await import('node:path')
3318
+ const result = await fixCodeTarget(
3319
+ {
3320
+ runVitest: (file) => {
3321
+ try {
3322
+ const output = execSync(`pnpm vitest run '${file}'`, {
3323
+ cwd: process.cwd(),
3324
+ encoding: 'utf-8',
3325
+ stdio: ['ignore', 'pipe', 'pipe'],
3326
+ })
3327
+ return { exitCode: 0, output }
3328
+ } catch (e) {
3329
+ const err = e as { stdout?: string; stderr?: string }
3330
+ return { exitCode: 1, output: `${err.stdout ?? ''}${err.stderr ?? ''}` }
3331
+ }
3332
+ },
3333
+ readFile: readSafe,
3334
+ writeFile: (p, c) => writeFileSync(p, c, 'utf-8'),
3335
+ generateFix: async (prompt) => {
3336
+ let text = ''
3337
+ for await (const chunk of llm.chat({
3338
+ model: '',
3339
+ messages: [{ role: 'user' as const, content: prompt }],
3340
+ temperature: 0,
3341
+ })) {
3342
+ if (chunk.type === 'text' && chunk.content) text += chunk.content
3343
+ }
3344
+ return stripCodeFences(text)
3345
+ },
3346
+ resolveSourceFile: (tf, specifier) => {
3347
+ const base = resolve(dirname(tf), specifier)
3348
+ return /\.[cm]?[jt]sx?$/.test(base) ? base : `${base}.ts`
3349
+ },
3350
+ },
3351
+ testFile,
3352
+ { apply: apply && !dryRun, maxRetries: 3 },
3353
+ )
3354
+ if (result.fixed) {
3355
+ lines.push(
3356
+ t('commands.fix.test_fixed', {
3357
+ test: result.testFile,
3358
+ source: result.sourceFile ?? '-',
3359
+ attempts: String(result.attempts),
3360
+ }),
3361
+ )
3362
+ if (!apply) lines.push(t('commands.fix.test_hint'))
3363
+ } else {
3364
+ lines.push(
3365
+ t('commands.fix.test_failed', { test: result.testFile, detail: result.detail ?? '' }),
3366
+ )
3367
+ }
3368
+ }
3369
+ }
3370
+ lines.push('')
3371
+ }
3372
+
3373
+ return { content: lines.join('\n').trimEnd() }
3374
+ }
3375
+
3107
3376
  // ═══════════════════════════════════════════════════════════════
3108
3377
  // GitHub & Git Workflow Commands (Claude Code parity)
3109
3378
  // ═══════════════════════════════════════════════════════════════
@@ -3421,42 +3690,44 @@ const cdCmd: CommandHandler = async (ctx, args) => {
3421
3690
 
3422
3691
  const hooksCmd: CommandHandler = async (ctx) => {
3423
3692
  const t = resolveT(ctx)
3424
- const { existsSync, readdirSync } = await import('node:fs')
3425
- const { join } = await import('node:path')
3426
- const cwd = process.cwd()
3427
- const hooksDir = join(cwd, '.mipham', 'hooks')
3693
+ const { loadSettingsJson } = await import('../config/loader')
3694
+ const settingsJson = loadSettingsJson(process.cwd())
3695
+
3696
+ const hooks = settingsJson.hooks as Record<
3697
+ string,
3698
+ Array<{ matcher?: string; hooks: Array<{ type: string; command?: string }> }>
3699
+ >
3700
+
3701
+ const configured = Object.entries(hooks).filter(
3702
+ ([, entries]) => Array.isArray(entries) && entries.length > 0,
3703
+ )
3428
3704
 
3429
- if (!existsSync(hooksDir)) {
3705
+ if (configured.length === 0) {
3430
3706
  return { content: t('commands.hooks.no_hooks') }
3431
3707
  }
3432
3708
 
3433
- try {
3434
- const files = readdirSync(hooksDir).filter((f) => f.endsWith('.sh'))
3435
- const lines: string[] = [
3436
- t('commands.hooks.title'),
3437
- '',
3438
- t('commands.hooks.location', { path: hooksDir }),
3439
- '',
3440
- ]
3709
+ const lines: string[] = [
3710
+ t('commands.hooks.title'),
3711
+ '',
3712
+ t('commands.hooks.location', { path: '.mipham/settings.json' }),
3713
+ '',
3714
+ ]
3441
3715
 
3442
- for (const f of files) {
3443
- try {
3444
- const stat = (await import('node:fs')).statSync(join(hooksDir, f))
3445
- const isExec = (stat.mode & 0o111) !== 0
3446
- lines.push(
3447
- ` ${isExec ? '✅' : '⚠️'} ${f}${isExec ? '' : ' (not executable — run chmod +x)'}`,
3448
- )
3449
- } catch {
3450
- lines.push(` 📄 ${f}`)
3716
+ let count = 0
3717
+ for (const [eventName, entries] of configured) {
3718
+ for (const entry of entries) {
3719
+ for (const hook of entry.hooks) {
3720
+ count++
3721
+ const matcher = entry.matcher || '*'
3722
+ const cmd = hook.type === 'command' && hook.command ? hook.command : hook.type
3723
+ lines.push(` ${eventName} [${matcher}] → ${cmd}`)
3451
3724
  }
3452
3725
  }
3453
-
3454
- lines.push('')
3455
- lines.push(t('commands.hooks.found', { count: String(files.length) }))
3456
- return { content: lines.join('\n') }
3457
- } catch {
3458
- return { content: t('commands.hooks.error') }
3459
3726
  }
3727
+
3728
+ lines.push('')
3729
+ lines.push(t('commands.hooks.found', { count: String(count) }))
3730
+ return { content: lines.join('\n') }
3460
3731
  }
3461
3732
 
3462
3733
  const hooksHealthCmd: CommandHandler = (ctx) => {
@@ -3766,7 +4037,7 @@ const saveCmd: CommandHandler = (ctx, args) => {
3766
4037
  // Memory Management
3767
4038
  // ═══════════════════════════════════════════════════════════════
3768
4039
 
3769
- const memoryCmd: CommandHandler = async (ctx) => {
4040
+ const memoryCmd: CommandHandler = async (ctx, args) => {
3770
4041
  const t = resolveT(ctx)
3771
4042
  const { existsSync, readdirSync, readFileSync, statSync } = await import('node:fs')
3772
4043
  const { join } = await import('node:path')
@@ -3774,6 +4045,47 @@ const memoryCmd: CommandHandler = async (ctx) => {
3774
4045
  const home = process.env.HOME || '~'
3775
4046
  const memoryDir = join(home, '.mipham', 'memory')
3776
4047
 
4048
+ // /memory gc — 记忆卫生:归档「0 召回 + 过期」的 auto-* 记忆(手写只报告)
4049
+ if (args[0]?.toLowerCase() === 'gc') {
4050
+ const mm = getMemoryManager()
4051
+ const { archived, candidates } = mm.gc()
4052
+ const lines: string[] = [t('commands.memories.gc_title'), '']
4053
+ if (archived.length === 0 && candidates.length === 0) {
4054
+ lines.push(t('commands.memories.gc_none'))
4055
+ } else {
4056
+ if (archived.length > 0) {
4057
+ lines.push(t('commands.memories.gc_archived', { count: String(archived.length) }))
4058
+ lines.push(...archived.map((n) => ` - ${n}`))
4059
+ lines.push('')
4060
+ }
4061
+ if (candidates.length > 0) {
4062
+ lines.push(t('commands.memories.gc_candidates', { count: String(candidates.length) }))
4063
+ lines.push(...candidates.map((n) => ` - ${n}`))
4064
+ lines.push('')
4065
+ }
4066
+ lines.push(t('commands.memories.gc_done'))
4067
+ }
4068
+ return { content: lines.join('\n') }
4069
+ }
4070
+
4071
+ // /memory consolidate — 会话记忆合并:把 auto-* 聚簇成持久化 lesson-*
4072
+ if (args[0]?.toLowerCase() === 'consolidate') {
4073
+ const mm = getMemoryManager()
4074
+ const { merged, removed } = mm.consolidateAutoMemories()
4075
+ const lines: string[] = [t('commands.memories.consolidate_title'), '']
4076
+ if (removed === 0) {
4077
+ lines.push(t('commands.memories.consolidate_none'))
4078
+ } else {
4079
+ lines.push(
4080
+ t('commands.memories.consolidate_result', {
4081
+ removed: String(removed),
4082
+ merged: String(merged),
4083
+ }),
4084
+ )
4085
+ }
4086
+ return { content: lines.join('\n') }
4087
+ }
4088
+
3777
4089
  if (!existsSync(memoryDir)) {
3778
4090
  return { content: t('commands.memories.no_memories') }
3779
4091
  }
@@ -4826,6 +5138,8 @@ const commandsListCmd: CommandHandler = () => {
4826
5138
  '/reload-skills': 'Tools & Skills',
4827
5139
  '/browse-skills': 'Tools & Skills',
4828
5140
  '/install-skill': 'Tools & Skills',
5141
+ '/marketplace': 'Tools & Skills',
5142
+ '/browse-marketplace': 'Tools & Skills',
4829
5143
  '/remove-skill': 'Tools & Skills',
4830
5144
  '/mcp': 'Tools & Skills',
4831
5145
  '/plugins': 'Plugins',
@@ -4986,6 +5300,8 @@ registry.set('/skills', skillsCmd)
4986
5300
  registry.set('/reload-skills', reloadSkillsCmd)
4987
5301
  registry.set('/browse-skills', browseSkillsCmd)
4988
5302
  registry.set('/install-skill', installSkillCmd)
5303
+ registry.set('/marketplace', marketplaceCmd)
5304
+ registry.set('/browse-marketplace', browseMarketplaceCmd)
4989
5305
  registry.set('/remove-skill', removeSkillCmd)
4990
5306
  registry.set('/plugins', pluginsCmd)
4991
5307
  registry.set('/browse-plugins', browsePluginsCmd)
@@ -5037,6 +5353,7 @@ registry.set('/pr-comments', prCommentsCmd)
5037
5353
 
5038
5354
  // Session Management
5039
5355
  registry.set('/doctor', doctorCmd)
5356
+ registry.set('/fix', fixCmd)
5040
5357
  registry.set('/export', exportCmd)
5041
5358
  registry.set('/resume', resumeCmd)
5042
5359
  registry.set('/resume last', resumeLastCmd)
@@ -5141,6 +5458,7 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
5141
5458
  '/save': 'Save conversation to Obsidian wiki (skill: save-to-wiki)',
5142
5459
  '/export': 'Export conversation to file',
5143
5460
  '/doctor': 'System diagnostics',
5461
+ '/fix': 'Deterministic self-repair: doctor/config/cache',
5144
5462
  '/dream': 'Background memory consolidation',
5145
5463
  '/constitution': 'View or reload constitutional principles',
5146
5464
  '/bug-report': 'Generate diagnostic report for GitHub Issues',
@@ -5170,6 +5488,8 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
5170
5488
  '/reload-skills': 'Reload all skills',
5171
5489
  '/browse-skills': 'Browse community skill marketplace',
5172
5490
  '/install-skill': 'Install a skill by name or URL',
5491
+ '/marketplace': 'Manage marketplace sources (add/list/remove)',
5492
+ '/browse-marketplace': 'Browse skills across all marketplace sources',
5173
5493
  '/remove-skill': 'Remove an installed skill',
5174
5494
  '/mcp': 'MCP server status',
5175
5495
  '/plugins': 'List installed plugins',
@@ -5252,7 +5572,7 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
5252
5572
  '/stats': 'Show session usage statistics',
5253
5573
  '/summary': 'Generate session summary',
5254
5574
  '/cd': 'Change session working directory',
5255
- '/hooks': 'Manage lifecycle hook scripts',
5575
+ '/hooks': 'List configured hooks from settings.json',
5256
5576
  '/hooks health': 'Check hook health — see failures, disabled hooks, recovery status',
5257
5577
  '/hooks enable': 'Manually re-enable a hook that was auto-disabled after repeated failures',
5258
5578
  '/batch': 'Apply changes across multiple files',