specpipe 1.0.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.
Files changed (60) hide show
  1. package/README.md +1319 -0
  2. package/bin/devkit.js +3 -0
  3. package/package.json +61 -0
  4. package/src/cli.js +76 -0
  5. package/src/commands/check.js +33 -0
  6. package/src/commands/diff.js +84 -0
  7. package/src/commands/init-adopt.js +54 -0
  8. package/src/commands/init-agents.js +118 -0
  9. package/src/commands/init-global.js +102 -0
  10. package/src/commands/init.js +311 -0
  11. package/src/commands/list.js +54 -0
  12. package/src/commands/remove.js +133 -0
  13. package/src/commands/upgrade.js +215 -0
  14. package/src/lib/agent-guards.js +100 -0
  15. package/src/lib/agent-install.js +161 -0
  16. package/src/lib/agents.js +280 -0
  17. package/src/lib/claude-global.js +183 -0
  18. package/src/lib/detector.js +93 -0
  19. package/src/lib/hasher.js +21 -0
  20. package/src/lib/installer.js +213 -0
  21. package/src/lib/logger.js +16 -0
  22. package/src/lib/manifest.js +102 -0
  23. package/src/lib/reconcile.js +56 -0
  24. package/templates/.claude/CLAUDE.md +79 -0
  25. package/templates/.claude/hooks/comment-guard.js +126 -0
  26. package/templates/.claude/hooks/file-guard.js +216 -0
  27. package/templates/.claude/hooks/glob-guard.js +104 -0
  28. package/templates/.claude/hooks/path-guard.sh +118 -0
  29. package/templates/.claude/hooks/self-review.sh +27 -0
  30. package/templates/.claude/hooks/sensitive-guard.sh +227 -0
  31. package/templates/.claude/settings.json +68 -0
  32. package/templates/docs/WORKFLOW.md +325 -0
  33. package/templates/docs/specs/.gitkeep +0 -0
  34. package/templates/hooks/specpipe-read-guard.sh +42 -0
  35. package/templates/hooks/specpipe-shell-guard.sh +65 -0
  36. package/templates/rules/specpipe-guards.md +40 -0
  37. package/templates/scripts/test-hooks.sh +66 -0
  38. package/templates/skills/sp-build/SKILL.md +776 -0
  39. package/templates/skills/sp-challenge/SKILL.md +255 -0
  40. package/templates/skills/sp-commit/SKILL.md +174 -0
  41. package/templates/skills/sp-explore/SKILL.md +730 -0
  42. package/templates/skills/sp-fix/SKILL.md +266 -0
  43. package/templates/skills/sp-humanize/SKILL.md +212 -0
  44. package/templates/skills/sp-investigate/SKILL.md +648 -0
  45. package/templates/skills/sp-md-render/SKILL.md +200 -0
  46. package/templates/skills/sp-md-render/components.md +415 -0
  47. package/templates/skills/sp-md-render/template.html +283 -0
  48. package/templates/skills/sp-plan/SKILL.md +947 -0
  49. package/templates/skills/sp-review/SKILL.md +268 -0
  50. package/templates/skills/sp-scaffold/SKILL.md +237 -0
  51. package/templates/skills/sp-scaffold/references/ARCHITECTURE.md.tmpl +228 -0
  52. package/templates/skills/sp-scaffold/references/DESIGN.md.tmpl +113 -0
  53. package/templates/skills/sp-scaffold/references/adr/NNNN-template.md +92 -0
  54. package/templates/skills/sp-scaffold/references/stack-profiles/react.md +36 -0
  55. package/templates/skills/sp-spec-render/SKILL.md +254 -0
  56. package/templates/skills/sp-spec-render/components.md +418 -0
  57. package/templates/skills/sp-spec-render/examples/user-auth.html +749 -0
  58. package/templates/skills/sp-spec-render/examples/user-auth.md +114 -0
  59. package/templates/skills/sp-spec-render/template.html +222 -0
  60. package/templates/skills/sp-voices/SKILL.md +1184 -0
package/bin/devkit.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { cli } from '../src/cli.js';
3
+ cli(process.argv);
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "specpipe",
3
+ "version": "1.0.0",
4
+ "description": "Spec-first development toolkit for agentic AI coding agents — installs skills + guardrails for Claude Code, Codex, Cursor, Antigravity, and more.",
5
+ "bin": {
6
+ "specpipe": "./bin/devkit.js",
7
+ "sp": "./bin/devkit.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "templates/"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "dependencies": {
18
+ "chalk": "^5.4.1",
19
+ "commander": "^13.1.0"
20
+ },
21
+ "devDependencies": {
22
+ "@eslint/js": "^9.17.0",
23
+ "eslint": "^9.17.0",
24
+ "globals": "^15.14.0",
25
+ "prettier": "^3.4.2"
26
+ },
27
+ "keywords": [
28
+ "ai",
29
+ "ai-agents",
30
+ "coding-agent",
31
+ "claude-code",
32
+ "codex",
33
+ "cursor",
34
+ "antigravity",
35
+ "spec-first",
36
+ "tdd",
37
+ "skills",
38
+ "guardrails",
39
+ "developer-tools"
40
+ ],
41
+ "scripts": {
42
+ "test": "node ../test/agents.mjs && bash ../test/cli.sh && bash ../test/hooks.sh && bash ../test/coverage-gate.sh",
43
+ "lint": "eslint src bin",
44
+ "format": "prettier --write \"src/**/*.js\" \"bin/**/*.js\"",
45
+ "prepublishOnly": "rm -rf templates && cp -r ../kit templates && cp ../README.md README.md",
46
+ "postpublish": "rm -rf templates README.md",
47
+ "dev": "node bin/devkit.js"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/microvn/specpipe.git",
52
+ "directory": "cli"
53
+ },
54
+ "homepage": "https://github.com/microvn/specpipe#readme",
55
+ "bugs": {
56
+ "url": "https://github.com/microvn/specpipe/issues"
57
+ },
58
+ "author": "Microvn <microvn.gm@gmail.com>",
59
+ "license": "MIT",
60
+ "type": "module"
61
+ }
package/src/cli.js ADDED
@@ -0,0 +1,76 @@
1
+ import { Command } from 'commander';
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
8
+
9
+ export function cli(argv) {
10
+ const program = new Command();
11
+
12
+ program
13
+ .name('specpipe')
14
+ .description('CLI toolkit for spec-first development with agentic AI coding agents')
15
+ .version(pkg.version);
16
+
17
+ program
18
+ .command('init [path]')
19
+ .description('Initialize a project with the dev-kit')
20
+ .option('-f, --force', 'Overwrite existing files')
21
+ .option('-g, --global', 'Install skills globally to ~/.claude/skills/ (available in all projects)')
22
+ .option('--agents <list>', 'Target agent(s): claude,codex,cursor,antigravity,openclaw,hermes or "all" (default: claude)')
23
+ .option('--only <components>', 'Install only specific components (comma-separated: hooks,skills,scripts,docs,config)')
24
+ .option('--adopt', 'Adopt existing kit files without overwriting (migration from setup.sh)')
25
+ .option('--dry-run', 'Show what would be done without making changes')
26
+ .action(async (path, opts) => {
27
+ const { initCommand } = await import('./commands/init.js');
28
+ await initCommand(path || '.', opts);
29
+ });
30
+
31
+ program
32
+ .command('upgrade [path]')
33
+ .description('Smart upgrade — preserves customized files')
34
+ .option('-f, --force', 'Overwrite even customized files')
35
+ .option('-g, --global', 'Upgrade skills globally in ~/.claude/skills/')
36
+ .option('--dry-run', 'Show what would be done without making changes')
37
+ .action(async (path, opts) => {
38
+ const { upgradeCommand } = await import('./commands/upgrade.js');
39
+ await upgradeCommand(path || '.', opts);
40
+ });
41
+
42
+ program
43
+ .command('check [path]')
44
+ .description('Check if an update is available')
45
+ .action(async (path) => {
46
+ const { checkCommand } = await import('./commands/check.js');
47
+ await checkCommand(path || '.');
48
+ });
49
+
50
+ program
51
+ .command('list [path]')
52
+ .description('List installed components and their status')
53
+ .action(async (path) => {
54
+ const { listCommand } = await import('./commands/list.js');
55
+ await listCommand(path || '.');
56
+ });
57
+
58
+ program
59
+ .command('diff [path]')
60
+ .description('Show differences between installed and latest kit files')
61
+ .action(async (path) => {
62
+ const { diffCommand } = await import('./commands/diff.js');
63
+ await diffCommand(path || '.');
64
+ });
65
+
66
+ program
67
+ .command('remove [path]')
68
+ .description('Uninstall dev-kit (preserves CLAUDE.md and docs/)')
69
+ .option('-g, --global', 'Remove global install (~/.claude/skills/, ~/.claude/hooks/, hook entries from ~/.claude/settings.json)')
70
+ .action(async (path, opts) => {
71
+ const { removeCommand } = await import('./commands/remove.js');
72
+ await removeCommand(path || '.', opts);
73
+ });
74
+
75
+ program.parse(argv);
76
+ }
@@ -0,0 +1,33 @@
1
+ import { resolve } from 'node:path';
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { log } from '../lib/logger.js';
6
+ import { readManifest } from '../lib/manifest.js';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8'));
10
+
11
+ export async function checkCommand(path) {
12
+ const targetDir = resolve(path);
13
+ const manifest = await readManifest(targetDir);
14
+
15
+ if (!manifest) {
16
+ log.fail('No manifest found. Run `specpipe init` first.');
17
+ process.exit(1);
18
+ }
19
+
20
+ const installed = manifest.version;
21
+ const latest = pkg.version;
22
+
23
+ log.info(`Installed: ${installed}`);
24
+ log.info(`Latest: ${latest}`);
25
+ log.blank();
26
+
27
+ if (installed === latest) {
28
+ log.pass('Up to date.');
29
+ } else {
30
+ log.warn(`Update available: ${installed} → ${latest}`);
31
+ console.log('Run: npx specpipe upgrade');
32
+ }
33
+ }
@@ -0,0 +1,84 @@
1
+ import { resolve } from 'node:path';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import chalk from 'chalk';
5
+ import { log } from '../lib/logger.js';
6
+ import { readManifest, getAgents } from '../lib/manifest.js';
7
+ import { hashContent } from '../lib/hasher.js';
8
+ import { computeDesired } from '../lib/reconcile.js';
9
+
10
+ export async function diffCommand(path) {
11
+ const targetDir = resolve(path);
12
+ const manifest = await readManifest(targetDir);
13
+
14
+ if (!manifest) {
15
+ log.fail('No manifest found. Run `specpipe init` first.');
16
+ process.exit(1);
17
+ }
18
+
19
+ const desired = await computeDesired(getAgents(manifest));
20
+ let hasDiffs = false;
21
+
22
+ for (const [file, d] of desired) {
23
+ const installedPath = resolve(targetDir, file);
24
+
25
+ if (!existsSync(installedPath)) {
26
+ // File in kit but not installed
27
+ console.log(chalk.cyan(`\n${file} (new in kit — not installed)`));
28
+ hasDiffs = true;
29
+ continue;
30
+ }
31
+
32
+ const installedContent = await readFile(installedPath, 'utf-8');
33
+ if (hashContent(installedContent) === d.kitHash) continue;
34
+
35
+ hasDiffs = true;
36
+
37
+ // Check if kit changed or user changed
38
+ const entry = manifest.files[file];
39
+ const kitChanged = entry && d.kitHash !== entry.kitHash;
40
+ const userChanged = entry && entry.customized;
41
+
42
+ let label = '';
43
+ if (kitChanged && userChanged) label = 'both kit and local changed';
44
+ else if (kitChanged) label = 'kit updated';
45
+ else if (userChanged) label = 'locally customized';
46
+ else label = 'differs';
47
+
48
+ console.log(chalk.bold(`\n${file}`) + chalk.gray(` (${label})`));
49
+ console.log('─'.repeat(60));
50
+
51
+ // Simple line-by-line diff (desired kit content vs installed)
52
+ const kitLines = d.content.split('\n');
53
+ const installedLines = installedContent.split('\n');
54
+
55
+ // Show a simplified diff: lines only in kit (green +), only in installed (red -)
56
+ const kitSet = new Set(kitLines);
57
+ const installedSet = new Set(installedLines);
58
+
59
+ const removed = installedLines.filter((l) => !kitSet.has(l) && l.trim());
60
+ const added = kitLines.filter((l) => !installedSet.has(l) && l.trim());
61
+
62
+ for (const line of removed.slice(0, 10)) {
63
+ console.log(chalk.red(` - ${line}`));
64
+ }
65
+ for (const line of added.slice(0, 10)) {
66
+ console.log(chalk.green(` + ${line}`));
67
+ }
68
+ if (removed.length > 10 || added.length > 10) {
69
+ console.log(chalk.gray(` ... and ${Math.max(removed.length - 10, 0) + Math.max(added.length - 10, 0)} more lines`));
70
+ }
71
+ }
72
+
73
+ // Check for files in manifest no longer desired (removed from kit / agent)
74
+ for (const file of Object.keys(manifest.files)) {
75
+ if (!desired.has(file)) {
76
+ console.log(chalk.yellow(`\n${file} (removed from kit)`));
77
+ hasDiffs = true;
78
+ }
79
+ }
80
+
81
+ if (!hasDiffs) {
82
+ log.pass('All files match the kit. No differences.');
83
+ }
84
+ }
@@ -0,0 +1,54 @@
1
+ import { resolve, dirname } from 'node:path';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { log } from '../lib/logger.js';
5
+ import { detectProject } from '../lib/detector.js';
6
+ import { createManifest, writeManifest, setFileEntry } from '../lib/manifest.js';
7
+ import { hashFile } from '../lib/hasher.js';
8
+ import { getAllFiles, COMPONENTS, getTemplateDir } from '../lib/installer.js';
9
+ import { AGENTS, parseSkillPath } from '../lib/agents.js';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8'));
13
+
14
+ /**
15
+ * Adopt existing kit files (migration from setup.sh).
16
+ * Scans for existing files and generates a manifest without overwriting.
17
+ */
18
+ export async function adoptExisting(targetDir) {
19
+ log.info('Adopting existing kit files...');
20
+ log.blank();
21
+
22
+ const manifest = createManifest(pkg.version, null, Object.keys(COMPONENTS));
23
+ let adopted = 0;
24
+
25
+ for (const file of getAllFiles()) {
26
+ // Skills live at the Claude output path (.claude/skills/) on disk; map from canonical skills/.
27
+ const sk = parseSkillPath(file);
28
+ const outRel = sk ? AGENTS.claude.skillTarget(sk.skill, sk.inner) : file;
29
+ const installedPath = resolve(targetDir, outRel);
30
+
31
+ if (!existsSync(installedPath)) continue;
32
+
33
+ const installedHash = await hashFile(installedPath);
34
+ let kitHash;
35
+ try {
36
+ kitHash = await hashFile(resolve(getTemplateDir(), file));
37
+ } catch {
38
+ kitHash = installedHash; // Template doesn't exist, treat as matching
39
+ }
40
+ setFileEntry(manifest, outRel, kitHash, installedHash, { agent: 'claude', templateRel: file });
41
+ log.adopt(outRel);
42
+ adopted++;
43
+ }
44
+
45
+ const projectInfo = detectProject(targetDir);
46
+ if (projectInfo) {
47
+ manifest.projectType = { lang: projectInfo.lang, framework: projectInfo.framework };
48
+ log.info(`Detected: ${projectInfo.lang} (${projectInfo.framework})`);
49
+ }
50
+
51
+ await writeManifest(targetDir, manifest);
52
+ log.blank();
53
+ log.pass(`Manifest created for ${adopted} existing files. Future upgrades will work.`);
54
+ }
@@ -0,0 +1,118 @@
1
+ import { resolve, dirname } from 'node:path';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { log } from '../lib/logger.js';
6
+ import { detectProject } from '../lib/detector.js';
7
+ import { createManifest, writeManifest, setFileEntry, readManifest, mergeAgents } from '../lib/manifest.js';
8
+ import { hashContent } from '../lib/hasher.js';
9
+ import {
10
+ COMPONENTS, PLACEHOLDER_DIRS, installFile, ensurePlaceholderDir,
11
+ setPermissions, fillTemplate, installAgentSkills, installAgentRules, installAgentHooks,
12
+ } from '../lib/installer.js';
13
+ import { resolveAgents, AGENTS } from '../lib/agents.js';
14
+ import { computeDesired } from '../lib/reconcile.js';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8'));
18
+
19
+ /**
20
+ * Multi-agent install. Each selected agent gets its skill set emitted to its
21
+ * native path + frontmatter. Claude additionally gets the full base
22
+ * (hooks, config, docs) since it's the only agent with a native hook system.
23
+ */
24
+ export async function initMultiAgent(targetDir, opts, warnings = 0) {
25
+ let requested;
26
+ try {
27
+ requested = resolveAgents(opts.agents);
28
+ } catch (e) {
29
+ log.fail(e.message);
30
+ process.exit(1);
31
+ }
32
+ // Accumulate: keep agents already installed (per the existing manifest) and add the
33
+ // requested ones, so `init --agents X` then `--agents Y` ends up with both, not just Y.
34
+ const existing = await readManifest(targetDir);
35
+ const agents = mergeAgents(existing?.agents, requested);
36
+ const claudeSelected = agents.includes('claude');
37
+ const labels = agents.map((a) => AGENTS[a].label).join(', ');
38
+
39
+ if (opts.dryRun) {
40
+ log.info('Dry run — no changes will be made');
41
+ log.info(`Target agents: ${labels}`);
42
+ return;
43
+ }
44
+
45
+ log.blank();
46
+ console.log(`--- Installing for: ${labels} ---`);
47
+
48
+ const manifest = createManifest(pkg.version, null, Object.keys(COMPONENTS));
49
+ manifest.agents = agents;
50
+
51
+ // Claude base: hooks + config + docs (only Claude has a native hook system).
52
+ if (claudeSelected) {
53
+ log.blank();
54
+ console.log(' Claude base (hooks, config, docs):');
55
+ for (const file of [...COMPONENTS.hooks, ...COMPONENTS.config, ...COMPONENTS.docs]) {
56
+ await installFile(file, targetDir, { force: opts.force });
57
+ }
58
+ for (const dir of PLACEHOLDER_DIRS) await ensurePlaceholderDir(dir, targetDir);
59
+ await setPermissions(targetDir);
60
+ }
61
+
62
+ // Skills + guardrails, emitted per agent into each agent's native location.
63
+ const results = [];
64
+ for (const agent of agents) {
65
+ log.blank();
66
+ console.log(` ${AGENTS[agent].label} skills:`);
67
+ results.push(await installAgentSkills(agent, targetDir, { force: opts.force }));
68
+ const rules = await installAgentRules(agent, targetDir, { force: opts.force });
69
+ if (rules?.mode === 'agents-md') manifest.agentsMdGuards = true;
70
+ await installAgentHooks(agent, targetDir, { force: opts.force }); // enforced hooks (Codex/Cursor)
71
+ }
72
+
73
+ // Project detection only fills Claude's CLAUDE.md template.
74
+ if (claudeSelected) {
75
+ const projectInfo = detectProject(targetDir);
76
+ if (projectInfo) {
77
+ manifest.projectType = { lang: projectInfo.lang, framework: projectInfo.framework };
78
+ await fillTemplate(targetDir, projectInfo);
79
+ } else {
80
+ warnings++;
81
+ }
82
+ }
83
+
84
+ // Record every installed file (all agents) keyed by on-disk path, with the
85
+ // hash of what's actually on disk so customization/skip is detected later.
86
+ const desired = await computeDesired(agents);
87
+ for (const [relPath, d] of desired) {
88
+ let installedHash = d.kitHash;
89
+ try { installedHash = hashContent(await readFile(resolve(targetDir, relPath), 'utf-8')); } catch { /* skipped/missing */ }
90
+ setFileEntry(manifest, relPath, d.kitHash, installedHash, { agent: d.agent, templateRel: d.templateRel });
91
+ }
92
+
93
+ await writeManifest(targetDir, manifest);
94
+
95
+ // Summary
96
+ log.blank();
97
+ console.log('=== Setup Complete ===');
98
+ log.blank();
99
+ for (const r of results) {
100
+ const parts = [`${r.copied} copied`];
101
+ if (r.identical > 0) parts.push(`${r.identical} identical`);
102
+ if (r.skipped > 0) parts.push(`${r.skipped} conflicted`);
103
+ console.log(` ${r.label}: ${parts.join(', ')}`);
104
+ }
105
+ if (claudeSelected) {
106
+ log.blank();
107
+ console.log(' Claude hooks active via .claude/settings.json.');
108
+ }
109
+ const noHook = agents.filter((a) => AGENTS[a].hooks !== 'native');
110
+ if (noHook.length) {
111
+ log.blank();
112
+ log.warn(`${noHook.map((a) => AGENTS[a].label).join(', ')}: guards installed as always-on rules (advisory — not hook-enforced like Claude).`);
113
+ }
114
+ if (warnings > 0) {
115
+ log.blank();
116
+ console.log(`⚠ ${warnings} warning(s) above — review before proceeding.`);
117
+ }
118
+ }
@@ -0,0 +1,102 @@
1
+ import { join } from 'node:path';
2
+ import { homedir } from 'node:os';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { log } from '../lib/logger.js';
5
+ import {
6
+ COMPONENTS, installSkillGlobal, getGlobalSkillsDir,
7
+ installHookGlobal, getGlobalHooksDir, mergeGlobalSettings,
8
+ } from '../lib/installer.js';
9
+
10
+ // Global Claude install (~/.claude/skills + hooks) and its manifest.
11
+
12
+ const GLOBAL_MANIFEST = join(homedir(), '.claude', '.devkit-manifest.json');
13
+
14
+ export async function readGlobalManifest() {
15
+ try {
16
+ return JSON.parse(await readFile(GLOBAL_MANIFEST, 'utf-8'));
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export async function writeGlobalManifest(data) {
23
+ await mkdir(join(homedir(), '.claude'), { recursive: true });
24
+ await writeFile(GLOBAL_MANIFEST, JSON.stringify(data, null, 2) + '\n');
25
+ }
26
+
27
+ export async function initGlobal({ force = false, hooks = false } = {}) {
28
+ const globalSkillsDir = getGlobalSkillsDir();
29
+ await mkdir(globalSkillsDir, { recursive: true });
30
+
31
+ const existing = await readGlobalManifest() || {};
32
+ const globalFiles = existing.files || {};
33
+ const updatedFiles = { ...globalFiles };
34
+
35
+ log.blank();
36
+ console.log('--- Installing global skills ---');
37
+
38
+ let copied = 0; let skipped = 0; let identical = 0;
39
+ for (const relPath of COMPONENTS.skills) {
40
+ const { result, kitHash } = await installSkillGlobal(relPath, globalSkillsDir, { force, globalFiles });
41
+ if (result === 'copied') copied++;
42
+ else if (result === 'identical') identical++;
43
+ else skipped++;
44
+ if (result !== 'skipped') updatedFiles[relPath] = { kitHash };
45
+ }
46
+
47
+ const parts = [`${copied} copied`];
48
+ if (identical > 0) parts.push(`${identical} identical`);
49
+ if (skipped > 0) parts.push(`${skipped} customized (use --force to overwrite)`);
50
+ log.pass(`Global skills: ${parts.join(', ')}`);
51
+ log.info('Skills available in all projects via ~/.claude/skills/');
52
+
53
+ if (hooks) {
54
+ await initGlobalHooks({ force, _globalFiles: updatedFiles, _skipManifestWrite: true });
55
+ }
56
+
57
+ await writeGlobalManifest({
58
+ ...existing,
59
+ globalInstalled: true,
60
+ globalHooksInstalled: hooks || existing.globalHooksInstalled || false,
61
+ files: updatedFiles,
62
+ updatedAt: new Date().toISOString(),
63
+ });
64
+ }
65
+
66
+ export async function initGlobalHooks({ force = false, _globalFiles, _skipManifestWrite = false } = {}) {
67
+ const globalHooksDir = getGlobalHooksDir();
68
+ await mkdir(globalHooksDir, { recursive: true });
69
+
70
+ const existing = _skipManifestWrite ? null : (await readGlobalManifest() || {});
71
+ const globalFiles = _globalFiles || existing?.files || {};
72
+ const updatedFiles = { ...globalFiles };
73
+
74
+ log.blank();
75
+ console.log('--- Installing global hooks ---');
76
+
77
+ let copied = 0; let skipped = 0; let identical = 0;
78
+ for (const relPath of COMPONENTS.hooks) {
79
+ const { result, kitHash } = await installHookGlobal(relPath, globalHooksDir, { force, globalFiles });
80
+ if (result === 'copied') copied++;
81
+ else if (result === 'identical') identical++;
82
+ else skipped++;
83
+ if (result !== 'skipped') updatedFiles[relPath] = { kitHash };
84
+ }
85
+
86
+ await mergeGlobalSettings(globalHooksDir);
87
+
88
+ const parts = [`${copied} copied`];
89
+ if (identical > 0) parts.push(`${identical} identical`);
90
+ if (skipped > 0) parts.push(`${skipped} customized (use --force to overwrite)`);
91
+ log.pass(`Global hooks: ${parts.join(', ')}`);
92
+ log.info('Hooks registered in ~/.claude/settings.json — active in all projects');
93
+
94
+ if (!_skipManifestWrite) {
95
+ await writeGlobalManifest({
96
+ ...existing,
97
+ globalHooksInstalled: true,
98
+ files: updatedFiles,
99
+ updatedAt: new Date().toISOString(),
100
+ });
101
+ }
102
+ }