harveyz-skill 0.7.0 → 0.9.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/CHANGELOG.md +26 -0
- package/bin/cli.js +311 -50
- package/lib/bundles.js +43 -11
- package/lib/installer.js +293 -5
- package/lib/targets.js +9 -0
- package/package.json +3 -2
- package/skills/harness/git-workflow-init/SKILL.md +30 -113
- package/skills/harness/git-workflow-init/references/acceptance-test.md +133 -0
- package/skills/harness/git-workflow-init/references/conflict-analysis.md +126 -0
- package/skills/harness/git-workflow-init/references/hook-templates.md +4 -4
- package/skills/meta/contribute-skill/SKILL.md +301 -0
- package/skills-index.json +5 -3
- package/tools/p-launch/__pycache__/p_launch.cpython-314.pyc +0 -0
- package/tools/p-launch/p-launch.py +6 -0
- package/tools/p-launch/p-launch.sh +25 -291
- package/tools/p-launch/p_launch.py +585 -0
- package/tools/p-launch/tests/__pycache__/test_p_launch.cpython-314-pytest-9.0.2.pyc +0 -0
- package/tools/p-launch/tests/e2e.bats +212 -7
- package/tools/p-launch/tests/test_p_launch.py +239 -0
- package/tools/p-launch/tests/unit.bats +107 -196
- package/tools/p-launch/tool.json +11 -1
- package/skills/harness/git-workflow-init/evals/evals.json +0 -104
package/lib/installer.js
CHANGED
|
@@ -53,20 +53,60 @@ export async function installTools(tools, targetDir, force = false) {
|
|
|
53
53
|
|
|
54
54
|
const destExists = await fs.pathExists(destPath)
|
|
55
55
|
if (destExists && !force) {
|
|
56
|
+
// Version-aware upgrade logic (mirrors skill behavior)
|
|
57
|
+
const home = os.homedir()
|
|
58
|
+
const dataDir = path.join(home, '.local', 'share', 'hskill', 'tools')
|
|
59
|
+
const installedMeta = path.join(dataDir, `${toolName}.json`)
|
|
60
|
+
const sourceMeta = path.join(srcPath, 'tool.json')
|
|
61
|
+
|
|
62
|
+
let installedVersion = '—'
|
|
63
|
+
let sourceVersion = '—'
|
|
64
|
+
try { installedVersion = (await fs.readJson(installedMeta)).version ?? '—' } catch { /* missing */ }
|
|
65
|
+
try { sourceVersion = (await fs.readJson(sourceMeta)).version ?? '—' } catch { /* missing */ }
|
|
66
|
+
|
|
67
|
+
const isUpToDate = installedVersion !== '—' && sourceVersion !== '—' && installedVersion === sourceVersion
|
|
68
|
+
|
|
69
|
+
if (isUpToDate) {
|
|
70
|
+
skipped.push({ name: toolName, reason: 'up-to-date', version: installedVersion })
|
|
71
|
+
console.error(chalk.dim(` · Skipped ${toolName} (up-to-date ${installedVersion})`))
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Outdated: prompt in TTY, skip in non-TTY
|
|
56
76
|
if (!process.stdout.isTTY) {
|
|
57
|
-
skipped.push({ name: toolName, reason: '
|
|
58
|
-
console.error(chalk.dim(` · Skipped ${toolName} (
|
|
77
|
+
skipped.push({ name: toolName, reason: 'outdated', installed: installedVersion, available: sourceVersion })
|
|
78
|
+
console.error(chalk.dim(` · Skipped ${toolName} (outdated ${installedVersion} → ${sourceVersion}, use --force to overwrite)`))
|
|
59
79
|
continue
|
|
60
80
|
}
|
|
61
|
-
|
|
81
|
+
|
|
82
|
+
const ok = await confirm({
|
|
83
|
+
message: `${toolName} ${installedVersion} → ${sourceVersion}. Overwrite?`,
|
|
84
|
+
default: true,
|
|
85
|
+
})
|
|
62
86
|
if (!ok) {
|
|
63
|
-
skipped.push({ name: toolName, reason: '
|
|
87
|
+
skipped.push({ name: toolName, reason: 'outdated', installed: installedVersion, available: sourceVersion })
|
|
64
88
|
console.error(chalk.dim(` · Skipped ${toolName}`))
|
|
65
89
|
continue
|
|
66
90
|
}
|
|
67
91
|
}
|
|
68
92
|
|
|
69
93
|
try {
|
|
94
|
+
// Clean up uninstallPaths declared in tool.json (e.g. venv) before overwriting
|
|
95
|
+
try {
|
|
96
|
+
const meta = await fs.readJson(path.join(srcPath, 'tool.json'))
|
|
97
|
+
const home = os.homedir()
|
|
98
|
+
for (const p of (meta.uninstallPaths ?? [])) {
|
|
99
|
+
const resolved = p.replace(/^~/, home)
|
|
100
|
+
if (await fs.pathExists(resolved)) {
|
|
101
|
+
await fs.remove(resolved)
|
|
102
|
+
console.error(chalk.green(` ✓ Removed ${p}`))
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if ((meta.uninstallPaths ?? []).length > 0) {
|
|
106
|
+
console.error(chalk.dim(` · venv will be recreated on next launch`))
|
|
107
|
+
}
|
|
108
|
+
} catch { /* tool.json missing or no uninstallPaths — skip silently */ }
|
|
109
|
+
|
|
70
110
|
const varDefs = await loadVarDefs(srcPath)
|
|
71
111
|
let varsMap = {}
|
|
72
112
|
if (varDefs.length > 0) {
|
|
@@ -90,13 +130,22 @@ export async function installTools(tools, targetDir, force = false) {
|
|
|
90
130
|
const content = await fs.readFile(scriptSrc, 'utf-8')
|
|
91
131
|
await fs.writeFile(destPath, substituteVars(content, varsMap), { encoding: 'utf-8', mode: 0o755 })
|
|
92
132
|
|
|
133
|
+
const dataDir = path.join(os.homedir(), '.local', 'share', 'hskill', 'tools')
|
|
134
|
+
|
|
93
135
|
const toolJsonSrc = path.join(srcPath, 'tool.json')
|
|
94
136
|
if (await fs.pathExists(toolJsonSrc)) {
|
|
95
|
-
const dataDir = path.join(os.homedir(), '.local', 'share', 'hskill', 'tools')
|
|
96
137
|
await fs.ensureDir(dataDir)
|
|
97
138
|
await fs.copy(toolJsonSrc, path.join(dataDir, `${toolName}.json`))
|
|
98
139
|
}
|
|
99
140
|
|
|
141
|
+
// Copy companion Python module (e.g. p_launch.py) when present
|
|
142
|
+
const pyModSrc = path.join(srcPath, `${toolName.replace('-', '_')}.py`)
|
|
143
|
+
if (await fs.pathExists(pyModSrc)) {
|
|
144
|
+
await fs.ensureDir(dataDir)
|
|
145
|
+
await fs.copy(pyModSrc, path.join(dataDir, `${toolName}.py`))
|
|
146
|
+
console.error(chalk.dim(` · Installed ${toolName}.py`))
|
|
147
|
+
}
|
|
148
|
+
|
|
100
149
|
installed.push(toolName)
|
|
101
150
|
await _patchZshrc(srcPath, toolName)
|
|
102
151
|
} catch (err) {
|
|
@@ -255,6 +304,53 @@ function _hookCommand(hookName, scope) {
|
|
|
255
304
|
: `bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/.claude/hooks/${hookName}.sh"`
|
|
256
305
|
}
|
|
257
306
|
|
|
307
|
+
function _codexHooksDir(scope, projectDir) {
|
|
308
|
+
return scope === 'user'
|
|
309
|
+
? path.join(os.homedir(), '.codex', 'hooks')
|
|
310
|
+
: path.join(projectDir, '.codex', 'hooks')
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function _codexHooksJsonPath(scope, projectDir) {
|
|
314
|
+
return scope === 'user'
|
|
315
|
+
? path.join(os.homedir(), '.codex', 'hooks.json')
|
|
316
|
+
: path.join(projectDir, '.codex', 'hooks.json')
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function _patchCodexHooks(hooksJsonPath, hook, resolvedCommand, force) {
|
|
320
|
+
let data = { hooks: {} }
|
|
321
|
+
try {
|
|
322
|
+
data = JSON.parse(await fs.readFile(hooksJsonPath, 'utf-8'))
|
|
323
|
+
} catch { /* file doesn't exist yet */ }
|
|
324
|
+
|
|
325
|
+
if (!data.hooks) data.hooks = {}
|
|
326
|
+
if (!data.hooks[hook.event]) data.hooks[hook.event] = []
|
|
327
|
+
|
|
328
|
+
const alreadyRegistered = data.hooks[hook.event].some(entry =>
|
|
329
|
+
Array.isArray(entry.hooks) && entry.hooks.some(h => h.command === resolvedCommand)
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if (alreadyRegistered && !force) return false
|
|
333
|
+
if (alreadyRegistered && force) {
|
|
334
|
+
data.hooks[hook.event] = data.hooks[hook.event].filter(entry =>
|
|
335
|
+
!Array.isArray(entry.hooks) || !entry.hooks.some(h => h.command === resolvedCommand)
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Codex format: no "type" field, absolute path
|
|
340
|
+
const hookEntry = { command: resolvedCommand }
|
|
341
|
+
if (hook.timeout) hookEntry.timeout = hook.timeout
|
|
342
|
+
if (hook.statusMessage) hookEntry.statusMessage = hook.statusMessage
|
|
343
|
+
|
|
344
|
+
data.hooks[hook.event].push({
|
|
345
|
+
matcher: hook.matcher ?? '',
|
|
346
|
+
hooks: [hookEntry],
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
await fs.ensureDir(path.dirname(hooksJsonPath))
|
|
350
|
+
await fs.writeFile(hooksJsonPath, JSON.stringify(data, null, 2) + '\n', 'utf-8')
|
|
351
|
+
return true
|
|
352
|
+
}
|
|
353
|
+
|
|
258
354
|
async function _patchSettings(settingsPath, hook, scope, force) {
|
|
259
355
|
let settings = {}
|
|
260
356
|
try {
|
|
@@ -361,6 +457,198 @@ export async function installHooks(hooks, scope, projectDir, force = false) {
|
|
|
361
457
|
return { installed, skipped, failed }
|
|
362
458
|
}
|
|
363
459
|
|
|
460
|
+
// Install hooks for a specific target ('claude' or 'codex').
|
|
461
|
+
// Returns { installed, skipped, failed } — same shape as installHooks.
|
|
462
|
+
export async function installHooksForTarget(hooks, target, scope, projectDir, force = false) {
|
|
463
|
+
if (target === 'claude') {
|
|
464
|
+
return installHooks(hooks, scope, projectDir, force)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// target === 'codex'
|
|
468
|
+
const hooksDir = _codexHooksDir(scope, projectDir)
|
|
469
|
+
const hooksJsonPath = _codexHooksJsonPath(scope, projectDir)
|
|
470
|
+
|
|
471
|
+
await fs.ensureDir(hooksDir)
|
|
472
|
+
|
|
473
|
+
const installed = []
|
|
474
|
+
const skipped = []
|
|
475
|
+
const failed = []
|
|
476
|
+
|
|
477
|
+
for (const hook of hooks) {
|
|
478
|
+
const destScript = path.join(hooksDir, `${hook.name}.sh`)
|
|
479
|
+
const scriptExists = await fs.pathExists(destScript)
|
|
480
|
+
|
|
481
|
+
if (scriptExists && !force) {
|
|
482
|
+
const installedVersion = readHookVersion(destScript)
|
|
483
|
+
const availableVersion = hook.version
|
|
484
|
+
|
|
485
|
+
if (availableVersion && installedVersion === availableVersion) {
|
|
486
|
+
skipped.push({ name: hook.name, reason: 'up-to-date', version: installedVersion })
|
|
487
|
+
console.error(chalk.dim(` · Skipped ${hook.name} (up-to-date ${installedVersion})`))
|
|
488
|
+
continue
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (!process.stdout.isTTY) {
|
|
492
|
+
skipped.push({ name: hook.name, reason: 'outdated', installed: installedVersion, available: availableVersion ?? '—' })
|
|
493
|
+
console.error(chalk.dim(` · Skipped ${hook.name} (outdated ${installedVersion} → ${availableVersion}, use --force)`))
|
|
494
|
+
continue
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const ok = await confirm({ message: `${hook.name} ${installedVersion} → ${availableVersion}. Overwrite?`, default: false })
|
|
498
|
+
if (!ok) {
|
|
499
|
+
skipped.push({ name: hook.name, reason: 'outdated', installed: installedVersion, available: availableVersion ?? '—' })
|
|
500
|
+
console.error(chalk.dim(` · Skipped ${hook.name}`))
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
if (!await fs.pathExists(hook.srcPath)) {
|
|
507
|
+
failed.push({ name: hook.name, reason: 'source_not_found' })
|
|
508
|
+
console.error(chalk.red(` ✗ Source not found: ${hook.srcPath}`))
|
|
509
|
+
continue
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
await fs.copy(hook.srcPath, destScript, { overwrite: true })
|
|
513
|
+
await fs.chmod(destScript, 0o755)
|
|
514
|
+
|
|
515
|
+
const resolvedCommand = destScript // absolute path for Codex
|
|
516
|
+
await _patchCodexHooks(hooksJsonPath, hook, resolvedCommand, force)
|
|
517
|
+
|
|
518
|
+
installed.push(hook.name)
|
|
519
|
+
console.error(chalk.green(` ✓ ${hook.name} → ${destScript}`))
|
|
520
|
+
} catch (err) {
|
|
521
|
+
failed.push({ name: hook.name, reason: 'copy_failed', detail: err.message })
|
|
522
|
+
console.error(chalk.red(` ✗ Failed to install ${hook.name}: ${err.message}`))
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return { installed, skipped, failed }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── Tool uninstall ────────────────────────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Uninstall a shell tool and clean up all associated files.
|
|
533
|
+
* @param {string} toolName
|
|
534
|
+
* @param {{ yes?: boolean }} opts - yes: skip configPaths confirmation
|
|
535
|
+
*/
|
|
536
|
+
export async function uninstallTool(toolName, opts = {}) {
|
|
537
|
+
const { yes = false } = opts
|
|
538
|
+
const home = os.homedir()
|
|
539
|
+
const binPath = path.join(home, '.local', 'bin', toolName)
|
|
540
|
+
const dataDir = path.join(home, '.local', 'share', 'hskill', 'tools')
|
|
541
|
+
const jsonPath = path.join(dataDir, `${toolName}.json`)
|
|
542
|
+
const pyPath = path.join(dataDir, `${toolName}.py`)
|
|
543
|
+
|
|
544
|
+
// Check if installed
|
|
545
|
+
if (!await fs.pathExists(binPath) && !await fs.pathExists(jsonPath)) {
|
|
546
|
+
console.error(chalk.dim(` · ${toolName} is not installed`))
|
|
547
|
+
return { removed: [], skipped: [], failed: [] }
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const removed = []
|
|
551
|
+
const skipped = []
|
|
552
|
+
const failed = []
|
|
553
|
+
|
|
554
|
+
// Read tool.json for extended paths (before removing it)
|
|
555
|
+
let uninstallPaths = []
|
|
556
|
+
let configPaths = []
|
|
557
|
+
try {
|
|
558
|
+
const meta = JSON.parse(await fs.readFile(jsonPath, 'utf-8'))
|
|
559
|
+
uninstallPaths = (meta.uninstallPaths ?? []).map(p => p.replace(/^~/, home))
|
|
560
|
+
configPaths = (meta.configPaths ?? []).map(p => p.replace(/^~/, home))
|
|
561
|
+
} catch { /* tool.json missing or unreadable — proceed with standard paths only */ }
|
|
562
|
+
|
|
563
|
+
// Helper: remove a path (file or dir)
|
|
564
|
+
async function removePath(p) {
|
|
565
|
+
if (!await fs.pathExists(p)) return
|
|
566
|
+
try {
|
|
567
|
+
await fs.remove(p)
|
|
568
|
+
console.error(chalk.green(` ✓ Removed ${p.replace(home, '~')}`))
|
|
569
|
+
removed.push(p)
|
|
570
|
+
} catch (err) {
|
|
571
|
+
console.error(chalk.red(` ✗ Failed to remove ${p.replace(home, '~')}: ${err.message}`))
|
|
572
|
+
failed.push(p)
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// 1. Standard paths
|
|
577
|
+
await removePath(binPath)
|
|
578
|
+
await removePath(pyPath)
|
|
579
|
+
await removePath(jsonPath)
|
|
580
|
+
|
|
581
|
+
// 2. uninstallPaths (always remove)
|
|
582
|
+
for (const p of uninstallPaths) await removePath(p)
|
|
583
|
+
|
|
584
|
+
// 3. configPaths (ask or skip)
|
|
585
|
+
for (const p of configPaths) {
|
|
586
|
+
if (!await fs.pathExists(p)) continue
|
|
587
|
+
if (yes) {
|
|
588
|
+
await removePath(p)
|
|
589
|
+
} else if (process.stdout.isTTY) {
|
|
590
|
+
const ok = await confirm({
|
|
591
|
+
message: `Remove ${p.replace(home, '~')}? (user config)`,
|
|
592
|
+
default: false,
|
|
593
|
+
})
|
|
594
|
+
if (ok) {
|
|
595
|
+
await removePath(p)
|
|
596
|
+
} else {
|
|
597
|
+
skipped.push(p)
|
|
598
|
+
console.error(chalk.dim(` · Kept ${p.replace(home, '~')}`))
|
|
599
|
+
}
|
|
600
|
+
} else {
|
|
601
|
+
skipped.push(p)
|
|
602
|
+
console.error(chalk.dim(` · Kept ${p.replace(home, '~')} (remove manually if needed)`))
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// 4. Remove zshrc snippet
|
|
607
|
+
const zshrcPath = path.join(home, '.zshrc')
|
|
608
|
+
const startMarker = `# >>> ${toolName}`
|
|
609
|
+
const endMarker = `# <<< ${toolName}`
|
|
610
|
+
try {
|
|
611
|
+
const content = await fs.readFile(zshrcPath, 'utf-8')
|
|
612
|
+
const startIdx = content.indexOf(startMarker)
|
|
613
|
+
const endIdx = content.indexOf(endMarker)
|
|
614
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
615
|
+
const cleaned = content.slice(0, startIdx) + content.slice(endIdx + endMarker.length + 1)
|
|
616
|
+
await fs.writeFile(zshrcPath, cleaned, 'utf-8')
|
|
617
|
+
console.error(chalk.green(` ✓ Removed snippet from ~/.zshrc`))
|
|
618
|
+
removed.push('~/.zshrc snippet')
|
|
619
|
+
}
|
|
620
|
+
} catch { /* .zshrc doesn't exist or not readable */ }
|
|
621
|
+
|
|
622
|
+
return { removed, skipped, failed }
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Uninstall a skill from a specific target directory.
|
|
627
|
+
* @param {string} skillName
|
|
628
|
+
* @param {string} targetDir - full path to target skills dir (e.g. ~/.claude/skills)
|
|
629
|
+
*/
|
|
630
|
+
export async function uninstallSkill(skillName, targetDir) {
|
|
631
|
+
const skillPath = path.join(targetDir, skillName)
|
|
632
|
+
const removed = [], skipped = [], failed = []
|
|
633
|
+
|
|
634
|
+
if (!await fs.pathExists(skillPath)) {
|
|
635
|
+
console.error(chalk.dim(` · ${skillName} not installed in ${targetDir.replace(os.homedir(), '~')}`))
|
|
636
|
+
skipped.push(skillName)
|
|
637
|
+
return { removed, skipped, failed }
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
await fs.remove(skillPath)
|
|
642
|
+
console.error(chalk.green(` ✓ Removed ${skillPath.replace(os.homedir(), '~')}`))
|
|
643
|
+
removed.push(skillName)
|
|
644
|
+
} catch (err) {
|
|
645
|
+
console.error(chalk.red(` ✗ Failed to remove ${skillPath.replace(os.homedir(), '~')}: ${err.message}`))
|
|
646
|
+
failed.push(skillName)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return { removed, skipped, failed }
|
|
650
|
+
}
|
|
651
|
+
|
|
364
652
|
export async function uninstallHook(hookName, scope, projectDir) {
|
|
365
653
|
const hooksDir = _hooksDir(scope, projectDir)
|
|
366
654
|
const settingsPath = _settingsPath(scope, projectDir)
|
package/lib/targets.js
CHANGED
|
@@ -42,3 +42,12 @@ export function resolveTargets(selected, scope = 'user') {
|
|
|
42
42
|
return { name, dir: skillDir(name, scope) }
|
|
43
43
|
})
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
export const HOOK_TARGETS = ['claude', 'codex']
|
|
47
|
+
|
|
48
|
+
export function buildHookTargetChoices() {
|
|
49
|
+
return [
|
|
50
|
+
{ name: `${'claude'.padEnd(8)} (~/.claude/hooks/)`, value: 'claude' },
|
|
51
|
+
{ name: `${'codex'.padEnd(8)} (~/.codex/hooks/)`, value: 'codex' },
|
|
52
|
+
]
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "harveyz-skill",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Skill manager for Claude Code, Cursor, and Codex",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"prepack": "node scripts/generate-npmignore.js",
|
|
11
|
-
"test": "bats tests/ tools/p-launch
|
|
11
|
+
"test": "bats tests/ && cd tools/p-launch && python3 -m pytest tests/test_p_launch.py -v && cd ../.. && bash scripts/run-skill-tests.sh"
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"bin/",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"skills/writing/mermaid-diagram/",
|
|
32
32
|
"skills/design/sync-design-html/",
|
|
33
33
|
"skills/analysis/git-cleanup/",
|
|
34
|
+
"skills/meta/contribute-skill/",
|
|
34
35
|
"tools/p-launch/",
|
|
35
36
|
"hooks/check-similar-branch/"
|
|
36
37
|
],
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: git-workflow-init
|
|
3
3
|
description: 初始化或更新 git 分支管理规范:读取项目的 workflow-config.yml,审核配置,差量生成并部署 git hooks(pre-commit、commit-msg、pre-push、post-checkout),生成工作流文档,可选写入 AI 配置文件引用。支持差量更新、MANAGED 块 hash 校验(检测用户手改)、lock 文件 diff(检测配置变更)、conflict scanner(检测用户代码与新配置的交叉冲突)。触发时机:初始化新 git 仓库、新项目首次配置 git、用户要求设置/更新分支保护或分支规范、安装或重新部署 git hooks、skill 或模板更新后需要同步、或问到分支命名规范。只要项目需要配置或更新 git 工作流,就应使用此 skill。
|
|
4
4
|
user_invocable: true
|
|
5
|
-
version: "4.
|
|
5
|
+
version: "4.1.0"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Git 工作流初始化
|
|
@@ -66,114 +66,33 @@ echo "" | grep -E "<pattern>" > /dev/null 2>&1; echo $?
|
|
|
66
66
|
|
|
67
67
|
**首次安装时跳过此步骤,直接进入 Step 5。**
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
目标:写入任何文件之前,全面收集需要用户决策的冲突,在 Step 5 一次性呈现。实现细节见 `references/conflict-analysis.md`。
|
|
70
70
|
|
|
71
|
-
#### 4a. Lock 文件 diff
|
|
71
|
+
#### 4a. Lock 文件 diff
|
|
72
|
+
读取 `.githooks/.workflow-config.lock.yml`(不存在则视为全量新增,跳过)。对比上次配置与当前配置,生成 +/~/- 变更摘要。**提取 YAML 分支名时必须用 python3 精确解析,不得用 `grep "name:"`**(见 reference 中的脚本)。
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
#### 4b. MANAGED 块 hash 校验
|
|
75
|
+
读取每个 hook 的 `BEGIN MANAGED`/`END MANAGED` 块,重新计算 hash,与块头标记对比。不一致 → 用户手改,记录为类型 C 冲突(附 diff)。
|
|
74
76
|
|
|
75
|
-
|
|
77
|
+
#### 4c. 外部代码扫描
|
|
78
|
+
提取每个 hook 中 MANAGED 块外的用户手写代码,识别其中引用的分支名、提交类型、pattern、外部脚本调用。
|
|
76
79
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
+ branches.protected 新增: develop(merge_from: feature/*)
|
|
80
|
-
~ commit_message.conventional.types 修改: 新增 wip,移除 perf
|
|
81
|
-
- tags.allowed_patterns 删除: ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
**YAML 分支名提取规则(重要):**
|
|
85
|
-
|
|
86
|
-
不要用宽泛的 `grep "name:"` 提取分支名——YAML 多个层级都可能含有 `name:` 字段,会导致误匹配。用 `python3` 精确解析,进入 `protected:` 块后再匹配 `- name:`,遇到非缩进行则停止:
|
|
87
|
-
|
|
88
|
-
```bash
|
|
89
|
-
# FILE 传入要解析的文件路径(workflow-config.yml 或 lock 文件均适用)
|
|
90
|
-
python3 -c "
|
|
91
|
-
import re, sys
|
|
92
|
-
content = open(sys.argv[1]).read()
|
|
93
|
-
in_protected = False
|
|
94
|
-
for line in content.splitlines():
|
|
95
|
-
if 'protected:' in line:
|
|
96
|
-
in_protected = True; continue
|
|
97
|
-
if in_protected:
|
|
98
|
-
m = re.match(r'\s+- name:\s+(\S+)', line)
|
|
99
|
-
if m: print(m.group(1))
|
|
100
|
-
elif line.strip() and not line.startswith(' '): break
|
|
101
|
-
" "$FILE"
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
这份摘要用于后续冲突检测的输入。
|
|
105
|
-
|
|
106
|
-
#### 4b. MANAGED 块 hash 校验 — 用户是否手改了生成的代码
|
|
107
|
-
|
|
108
|
-
读取每个 hook 文件,提取所有 `BEGIN MANAGED` / `END MANAGED` 块。
|
|
109
|
-
|
|
110
|
-
MANAGED 块格式:
|
|
111
|
-
```sh
|
|
112
|
-
# --- BEGIN MANAGED: <block-id> (hash:<8位hex>) ---
|
|
113
|
-
<generated content>
|
|
114
|
-
# --- END MANAGED: <block-id> ---
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
对每个块:重新计算块内容(两行标记之间的部分,去首尾空白)的 hash:
|
|
118
|
-
```bash
|
|
119
|
-
actual_hash=$(printf '%s' "<block_content>" | git hash-object --stdin | cut -c1-8)
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
若 `actual_hash` 与标记里记录的 hash 不一致 → 用户手改了此块,记录为冲突:
|
|
123
|
-
|
|
124
|
-
```
|
|
125
|
-
[手改冲突] .githooks/pre-commit 的 MANAGED 块 branches.protected/main 被手动修改
|
|
126
|
-
原始生成内容 hash: a3f9c2b1
|
|
127
|
-
当前内容 hash: d7e42f0c
|
|
128
|
-
差异:(展示 diff)
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
#### 4c. 外部代码扫描 — 用户在 MANAGED 块外添加了什么
|
|
132
|
-
|
|
133
|
-
提取每个 hook 文件中 MANAGED 块之外的所有代码(即用户手写区)。
|
|
134
|
-
|
|
135
|
-
从用户代码中提取可解析的引用:
|
|
136
|
-
|
|
137
|
-
| 提取目标 | 扫描方式 |
|
|
138
|
-
|---------|---------|
|
|
139
|
-
| 分支名 | `"$BRANCH" = "name"`、`case "name"`、`branch = "name"` |
|
|
140
|
-
| 提交类型 | `grep -qE "...(type\|...)"`、字符串字面量 `wip`、`hotfix` |
|
|
141
|
-
| Tag/分支 pattern | `grep -qE "pattern"` |
|
|
142
|
-
| 外部脚本调用 | `./`、`bash `、`sh `、`npm run`、`make ` 开头的行 |
|
|
143
|
-
|
|
144
|
-
#### 4d. 冲突检测 — 用户代码 × 新配置的交叉分析
|
|
145
|
-
|
|
146
|
-
综合 4a(配置变更)、4b(手改块)、4c(用户代码)的结果,识别以下冲突类型:
|
|
147
|
-
|
|
148
|
-
**冲突类型 A:用户代码覆盖了与新配置相同的条件**
|
|
149
|
-
- 用户代码处理了分支 X,新配置也将 X 加入 `branches.protected`
|
|
150
|
-
- 两段代码同时执行,可能产生矛盾行为
|
|
151
|
-
|
|
152
|
-
**冲突类型 B:用户代码引用了配置中已删除的内容**
|
|
153
|
-
- 用户代码里有对类型 `wip` 的处理,新配置删除了 `wip`
|
|
154
|
-
- 用户代码逻辑失去对应的配置支撑
|
|
155
|
-
|
|
156
|
-
**冲突类型 C:MANAGED 块被手动修改**
|
|
157
|
-
- 来自 4b 的结果
|
|
158
|
-
- 重新生成会覆盖用户修改
|
|
159
|
-
|
|
160
|
-
**冲突类型 D:用户代码引用了新配置新增的内容(信息提示,非阻断)**
|
|
161
|
-
- 用户已手写了对 `develop` 的处理,新配置也打算生成同名 MANAGED 块
|
|
162
|
-
- 不一定冲突,但值得用户确认
|
|
80
|
+
#### 4d. 冲突检测
|
|
81
|
+
综合 4a/4b/4c,识别四种冲突类型(A 条件重叠、B 引用断裂、C 手改冲突、D 新增重叠)。类型定义与选项见 `references/conflict-analysis.md`。
|
|
163
82
|
|
|
164
83
|
---
|
|
165
84
|
|
|
166
85
|
### Step 5 — 冲突解决(一次性汇总,用户逐条决策)
|
|
167
86
|
|
|
168
|
-
将 Step 4
|
|
87
|
+
将 Step 4 发现的所有冲突**一次性呈现**,每条附带类型标签和选项,用户逐条决策后进入 Step 6。无冲突则直接进入 Step 6。
|
|
169
88
|
|
|
170
|
-
|
|
89
|
+
呈现格式(以类型 C 手改冲突为例):
|
|
171
90
|
|
|
172
91
|
```
|
|
173
|
-
发现
|
|
92
|
+
发现 N 处需要决策的冲突,请逐条确认:
|
|
174
93
|
|
|
175
94
|
─────────────────────────────────────────────────────────
|
|
176
|
-
[1/
|
|
95
|
+
[1/N] 手改冲突(类型 C)
|
|
177
96
|
文件: .githooks/pre-commit,块: branches.protected/main
|
|
178
97
|
用户对生成代码做了如下修改:
|
|
179
98
|
- echo "❌ 禁止直接在 main 上提交。"
|
|
@@ -182,26 +101,9 @@ actual_hash=$(printf '%s' "<block_content>" | git hash-object --stdin | cut -c1-
|
|
|
182
101
|
A) 保留用户修改(此块不重新生成)
|
|
183
102
|
B) 用新配置覆盖(丢弃用户修改)
|
|
184
103
|
─────────────────────────────────────────────────────────
|
|
185
|
-
[2/3] 条件重叠(类型 A)
|
|
186
|
-
文件: .githooks/pre-commit,用户代码第 58 行
|
|
187
|
-
用户手写了 develop 分支的保护逻辑
|
|
188
|
-
新配置将 develop 加入 branches.protected,也会生成对应 MANAGED 块
|
|
189
|
-
选项:
|
|
190
|
-
A) 保留用户代码,跳过生成 develop 的 MANAGED 块
|
|
191
|
-
B) 用新配置生成 MANAGED 块,删除用户手写的重复代码
|
|
192
|
-
C) 两者都保留(会重复执行,请确认逻辑不矛盾)
|
|
193
|
-
─────────────────────────────────────────────────────────
|
|
194
|
-
[3/3] 引用断裂(类型 B)
|
|
195
|
-
文件: .githooks/commit-msg,用户代码第 34 行
|
|
196
|
-
用户代码引用了 commit 类型 wip,但新配置已从类型列表中移除 wip
|
|
197
|
-
选项:
|
|
198
|
-
A) 保留用户代码(wip 检查逻辑继续生效,但配置层不再管理)
|
|
199
|
-
B) 删除用户代码中对 wip 的引用
|
|
200
|
-
─────────────────────────────────────────────────────────
|
|
201
104
|
```
|
|
202
105
|
|
|
203
|
-
|
|
204
|
-
若无任何冲突,直接进入 Step 6。
|
|
106
|
+
各冲突类型的完整选项定义见 `references/conflict-analysis.md`。
|
|
205
107
|
|
|
206
108
|
---
|
|
207
109
|
|
|
@@ -237,6 +139,19 @@ git config merge.ff false
|
|
|
237
139
|
|
|
238
140
|
---
|
|
239
141
|
|
|
142
|
+
### Step 6.5 — 验收测试(可选)
|
|
143
|
+
|
|
144
|
+
完成 hooks 部署后,询问用户:
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
✅ Hooks 已部署完毕。是否运行验收测试?(在当前 repo 中验证 hooks 实际拦截行为)[y/N]
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
- 用户选 **N** → 直接进入 Step 7。
|
|
151
|
+
- 用户选 **Y** → 读取 `references/acceptance-test.md`,按其中说明逐 hook 执行验收场景,展示结果表格。全通过则继续 Step 7;有失败则展示详情并询问用户是否仍继续。
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
240
155
|
### Step 7 — 从配置渲染并写入工作流文档(差量)
|
|
241
156
|
|
|
242
157
|
每次运行都根据当前 `workflow-config.yml` 的最终状态重新渲染文档,确保文档与配置始终一致。
|
|
@@ -315,7 +230,9 @@ CLAUDE.md 已有引用,跳过
|
|
|
315
230
|
| 文件 | 说明 | 读取时机 |
|
|
316
231
|
|------|------|---------|
|
|
317
232
|
| `references/workflow-config.yml` | 配置文件模板 | Step 2:用户无配置时复制 |
|
|
233
|
+
| `references/conflict-analysis.md` | 4a–4d 实现脚本、冲突类型 A/B/C/D 完整定义与选项、Step 5 呈现格式 | Step 4/5:执行分析前读取 |
|
|
318
234
|
| `references/hook-templates.md` | 4 个 hook 的代码模板、块 ID 规范、hash 计算、多行写入技巧 | Step 6:生成 hooks 前 |
|
|
235
|
+
| `references/acceptance-test.md` | 验收场景 bash 脚本、期望结果、结果表格格式 | Step 6.5:用户选 Y 时读取 |
|
|
319
236
|
| `references/lock-file-format.md` | lock 文件 YAML 格式 | Step 9:写入 lock 文件前 |
|
|
320
237
|
| `references/git-workflow-template.md` | 工作流文档模板(含占位符) | Step 7:由 render_docs.py 读取 |
|
|
321
238
|
| `references/render_docs.py` | 文档渲染脚本 | Step 7:直接执行 |
|