specpipe 1.0.0 → 1.0.2

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 (46) hide show
  1. package/README.md +116 -1220
  2. package/package.json +3 -2
  3. package/src/cli.js +16 -6
  4. package/src/commands/diff.js +1 -1
  5. package/src/commands/init-agents.js +40 -20
  6. package/src/commands/init-global.js +88 -33
  7. package/src/commands/init-interactive.js +71 -0
  8. package/src/commands/init.js +61 -22
  9. package/src/commands/remove.js +159 -49
  10. package/src/commands/upgrade.js +21 -56
  11. package/src/lib/agent-guards.js +34 -78
  12. package/src/lib/agent-install.js +38 -25
  13. package/src/lib/agents.js +53 -11
  14. package/src/lib/claude-global.js +50 -77
  15. package/src/lib/hooks.js +203 -0
  16. package/src/lib/installer.js +73 -61
  17. package/src/lib/reconcile.js +13 -8
  18. package/templates/{.claude/hooks → hooks}/file-guard.js +26 -21
  19. package/templates/hooks/specpipe-read-guard.sh +94 -21
  20. package/templates/hooks/specpipe-shell-guard.sh +121 -29
  21. package/templates/rules/specpipe-rules.md +77 -0
  22. package/templates/skills/sp-build/SKILL.md +101 -1
  23. package/templates/skills/sp-build-behavior-matrix/SKILL.md +876 -0
  24. package/templates/skills/sp-challenge/SKILL.md +34 -0
  25. package/templates/skills/sp-challenge-behavior-matrix/SKILL.md +289 -0
  26. package/templates/skills/sp-explore/SKILL.md +132 -0
  27. package/templates/skills/sp-explore-behavior-matrix/SKILL.md +862 -0
  28. package/templates/skills/sp-fix/SKILL.md +73 -1
  29. package/templates/skills/sp-fix-behavior-matrix/SKILL.md +338 -0
  30. package/templates/skills/sp-investigate/SKILL.md +70 -0
  31. package/templates/skills/sp-investigate-behavior-matrix/SKILL.md +718 -0
  32. package/templates/skills/sp-plan/SKILL.md +90 -0
  33. package/templates/skills/sp-plan-behavior-matrix/SKILL.md +1037 -0
  34. package/templates/skills/sp-review/SKILL.md +29 -3
  35. package/templates/skills/sp-review-behavior-matrix/SKILL.md +294 -0
  36. package/templates/.claude/CLAUDE.md +0 -79
  37. package/templates/.claude/hooks/path-guard.sh +0 -118
  38. package/templates/.claude/hooks/self-review.sh +0 -27
  39. package/templates/.claude/hooks/sensitive-guard.sh +0 -227
  40. package/templates/.claude/settings.json +0 -68
  41. package/templates/docs/WORKFLOW.md +0 -325
  42. package/templates/docs/specs/.gitkeep +0 -0
  43. package/templates/rules/specpipe-guards.md +0 -40
  44. package/templates/scripts/test-hooks.sh +0 -66
  45. /package/templates/{.claude/hooks → hooks}/comment-guard.js +0 -0
  46. /package/templates/{.claude/hooks → hooks}/glob-guard.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specpipe",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Spec-first development toolkit for agentic AI coding agents — installs skills + guardrails for Claude Code, Codex, Cursor, Antigravity, and more.",
5
5
  "bin": {
6
6
  "specpipe": "./bin/devkit.js",
@@ -15,6 +15,7 @@
15
15
  "node": ">=18"
16
16
  },
17
17
  "dependencies": {
18
+ "@clack/prompts": "^1.6.0",
18
19
  "chalk": "^5.4.1",
19
20
  "commander": "^13.1.0"
20
21
  },
@@ -51,7 +52,7 @@
51
52
  "url": "git+https://github.com/microvn/specpipe.git",
52
53
  "directory": "cli"
53
54
  },
54
- "homepage": "https://github.com/microvn/specpipe#readme",
55
+ "homepage": "https://specpipe.vercel.app",
55
56
  "bugs": {
56
57
  "url": "https://github.com/microvn/specpipe/issues"
57
58
  },
package/src/cli.js CHANGED
@@ -18,21 +18,29 @@ export function cli(argv) {
18
18
  .command('init [path]')
19
19
  .description('Initialize a project with the dev-kit')
20
20
  .option('-f, --force', 'Overwrite existing files')
21
- .option('-g, --global', 'Install skills globally to ~/.claude/skills/ (available in all projects)')
21
+ .option('-g, --global', 'Install skills globally (per-agent user-level dirs, all projects); honors --agents, defaults to claude')
22
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)')
23
+ .option('--skills <list>', 'Skills to install: all | core | comma list e.g. sp-build,sp-fix (default: all)')
24
+ .option('--hooks <list>', 'Guard hooks to install: all | none | comma list e.g. shell,read (default: all)')
25
+ .option('-y, --yes', 'Skip interactive prompts and use defaults (per-project, claude, all skills)')
26
+ .option('--only <components>', 'Install only specific components (comma-separated: hooks,skills,docs,config)')
24
27
  .option('--adopt', 'Adopt existing kit files without overwriting (migration from setup.sh)')
25
28
  .option('--dry-run', 'Show what would be done without making changes')
26
29
  .action(async (path, opts) => {
27
- const { initCommand } = await import('./commands/init.js');
28
- await initCommand(path || '.', opts);
30
+ try {
31
+ const { initCommand } = await import('./commands/init.js');
32
+ await initCommand(path || '.', opts);
33
+ } catch (err) {
34
+ console.error(`Error: ${err.message}`);
35
+ process.exit(1);
36
+ }
29
37
  });
30
38
 
31
39
  program
32
40
  .command('upgrade [path]')
33
41
  .description('Smart upgrade — preserves customized files')
34
42
  .option('-f, --force', 'Overwrite even customized files')
35
- .option('-g, --global', 'Upgrade skills globally in ~/.claude/skills/')
43
+ .option('-g, --global', 'Upgrade the global install (every agent installed globally)')
36
44
  .option('--dry-run', 'Show what would be done without making changes')
37
45
  .action(async (path, opts) => {
38
46
  const { upgradeCommand } = await import('./commands/upgrade.js');
@@ -66,7 +74,9 @@ export function cli(argv) {
66
74
  program
67
75
  .command('remove [path]')
68
76
  .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)')
77
+ .option('-g, --global', 'Remove global install (per-agent global sp-* skill dirs + Claude hooks/settings)')
78
+ .option('--agents <list>', 'Remove only these agent(s), keeping the rest (e.g. codex,cursor); shared files are kept while any remaining agent needs them')
79
+ .option('--dry-run', 'Show what would be removed without deleting anything')
70
80
  .action(async (path, opts) => {
71
81
  const { removeCommand } = await import('./commands/remove.js');
72
82
  await removeCommand(path || '.', opts);
@@ -16,7 +16,7 @@ export async function diffCommand(path) {
16
16
  process.exit(1);
17
17
  }
18
18
 
19
- const desired = await computeDesired(getAgents(manifest));
19
+ const desired = await computeDesired(getAgents(manifest), manifest.skills ? new Set(manifest.skills) : null);
20
20
  let hasDiffs = false;
21
21
 
22
22
  for (const [file, d] of desired) {
@@ -7,10 +7,12 @@ import { detectProject } from '../lib/detector.js';
7
7
  import { createManifest, writeManifest, setFileEntry, readManifest, mergeAgents } from '../lib/manifest.js';
8
8
  import { hashContent } from '../lib/hasher.js';
9
9
  import {
10
- COMPONENTS, PLACEHOLDER_DIRS, installFile, ensurePlaceholderDir,
11
- setPermissions, fillTemplate, installAgentSkills, installAgentRules, installAgentHooks,
10
+ COMPONENTS,
11
+ fillTemplate, installAgentSkills, installAgentRules, installAgentHooks,
12
+ resolveSkills,
12
13
  } from '../lib/installer.js';
13
- import { resolveAgents, AGENTS } from '../lib/agents.js';
14
+ import { resolveAgents, AGENTS, agentHasHooks } from '../lib/agents.js';
15
+ import { resolveHooks } from '../lib/hooks.js';
14
16
  import { computeDesired } from '../lib/reconcile.js';
15
17
 
16
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -36,6 +38,15 @@ export async function initMultiAgent(targetDir, opts, warnings = 0) {
36
38
  const claudeSelected = agents.includes('claude');
37
39
  const labels = agents.map((a) => AGENTS[a].label).join(', ');
38
40
 
41
+ // --only selects static COMPONENTS files, which only the single-agent (claude-
42
+ // only) path installs. Multi-agent emits skills/rules/hooks per agent and the
43
+ // reconcile model (computeDesired) has no component dimension, so honoring --only
44
+ // here wouldn't survive the next upgrade. Scope multi-agent installs with
45
+ // --skills / --hooks instead. Warn rather than silently ignore.
46
+ if (opts.only) {
47
+ log.warn(`--only is ignored with --agents (component selection applies to single-agent installs only). Use --skills / --hooks to scope a multi-agent install.`);
48
+ }
49
+
39
50
  if (opts.dryRun) {
40
51
  log.info('Dry run — no changes will be made');
41
52
  log.info(`Target agents: ${labels}`);
@@ -45,29 +56,29 @@ export async function initMultiAgent(targetDir, opts, warnings = 0) {
45
56
  log.blank();
46
57
  console.log(`--- Installing for: ${labels} ---`);
47
58
 
59
+ const skills = resolveSkills(opts.skills);
60
+ const hooks = resolveHooks(opts.hooks);
48
61
  const manifest = createManifest(pkg.version, null, Object.keys(COMPONENTS));
49
62
  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
- }
63
+ if (skills) manifest.skills = [...skills];
64
+ if (hooks) manifest.hooks = [...hooks];
61
65
 
62
66
  // Skills + guardrails, emitted per agent into each agent's native location.
67
+ // (No static "Claude base" to copy — hooks/config/docs COMPONENTS are empty;
68
+ // everything is emitted from the registry in the per-agent loop below.)
63
69
  const results = [];
64
70
  for (const agent of agents) {
65
71
  log.blank();
66
72
  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)
73
+ results.push(await installAgentSkills(agent, targetDir, { force: opts.force, skills }));
74
+ // Option A: `--hooks none` turns guardrails off entirely — no enforced hooks AND
75
+ // no always-on advisory rules. A subset/all still installs the advisory rules.
76
+ const noGuards = hooks && hooks.size === 0;
77
+ if (!noGuards) {
78
+ const rules = await installAgentRules(agent, targetDir, { force: opts.force });
79
+ if (rules?.mode === 'merge') manifest.agentsMdGuards = true;
80
+ }
81
+ await installAgentHooks(agent, targetDir, { force: opts.force, hooks }); // enforced hooks (Claude/Codex/Cursor/Antigravity)
71
82
  }
72
83
 
73
84
  // Project detection only fills Claude's CLAUDE.md template.
@@ -77,13 +88,14 @@ export async function initMultiAgent(targetDir, opts, warnings = 0) {
77
88
  manifest.projectType = { lang: projectInfo.lang, framework: projectInfo.framework };
78
89
  await fillTemplate(targetDir, projectInfo);
79
90
  } else {
91
+ log.warn('Could not auto-detect project type — fill the Project Info section in .claude/CLAUDE.md manually.');
80
92
  warnings++;
81
93
  }
82
94
  }
83
95
 
84
96
  // Record every installed file (all agents) keyed by on-disk path, with the
85
97
  // hash of what's actually on disk so customization/skip is detected later.
86
- const desired = await computeDesired(agents);
98
+ const desired = await computeDesired(agents, skills);
87
99
  for (const [relPath, d] of desired) {
88
100
  let installedHash = d.kitHash;
89
101
  try { installedHash = hashContent(await readFile(resolve(targetDir, relPath), 'utf-8')); } catch { /* skipped/missing */ }
@@ -106,7 +118,15 @@ export async function initMultiAgent(targetDir, opts, warnings = 0) {
106
118
  log.blank();
107
119
  console.log(' Claude hooks active via .claude/settings.json.');
108
120
  }
109
- const noHook = agents.filter((a) => AGENTS[a].hooks !== 'native');
121
+ // Enforced = Claude's native .claude/hooks OR an agent with its own blocking
122
+ // hook config (Codex .codex/hooks.json, Cursor .cursor/hooks.json, Antigravity
123
+ // .agents/hooks.json). The rest get guards as always-on advisory rules only.
124
+ const enforced = agents.filter((a) => AGENTS[a].hooks === 'native' || agentHasHooks(a));
125
+ if (enforced.length) {
126
+ log.blank();
127
+ console.log(` Guards hook-enforced (blocking) for: ${enforced.map((a) => AGENTS[a].label).join(', ')}.`);
128
+ }
129
+ const noHook = agents.filter((a) => AGENTS[a].hooks !== 'native' && !agentHasHooks(a));
110
130
  if (noHook.length) {
111
131
  log.blank();
112
132
  log.warn(`${noHook.map((a) => AGENTS[a].label).join(', ')}: guards installed as always-on rules (advisory — not hook-enforced like Claude).`);
@@ -1,13 +1,17 @@
1
- import { join } from 'node:path';
1
+ import { join, dirname } from 'node:path';
2
2
  import { homedir } from 'node:os';
3
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { mkdir, readFile, writeFile, unlink, rmdir } from 'node:fs/promises';
4
4
  import { log } from '../lib/logger.js';
5
5
  import {
6
- COMPONENTS, installSkillGlobal, getGlobalSkillsDir,
6
+ COMPONENTS, installSkillGlobalForAgent, skillAllowed,
7
7
  installHookGlobal, getGlobalHooksDir, mergeGlobalSettings,
8
8
  } from '../lib/installer.js';
9
+ import { AGENTS } from '../lib/agents.js';
10
+ import { hookScriptsFor } from '../lib/hooks.js';
9
11
 
10
- // Global Claude install (~/.claude/skills + hooks) and its manifest.
12
+ // Global install. Skills go per-agent into each agent's user-level dir (Claude
13
+ // ~/.claude/skills, Codex ~/.codex/skills, …). Global hooks remain Claude-only —
14
+ // Claude Code's native enforcement engine; other agents enforce per-project.
11
15
 
12
16
  const GLOBAL_MANIFEST = join(homedir(), '.claude', '.devkit-manifest.json');
13
17
 
@@ -24,66 +28,116 @@ export async function writeGlobalManifest(data) {
24
28
  await writeFile(GLOBAL_MANIFEST, JSON.stringify(data, null, 2) + '\n');
25
29
  }
26
30
 
27
- export async function initGlobal({ force = false, hooks = false } = {}) {
28
- const globalSkillsDir = getGlobalSkillsDir();
29
- await mkdir(globalSkillsDir, { recursive: true });
30
-
31
+ export async function initGlobal({ agents = ['claude'], skills = null, hookSelection = null, force = false, hooks = false } = {}) {
31
32
  const existing = await readGlobalManifest() || {};
32
33
  const globalFiles = existing.files || {};
33
34
  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 };
35
+ const installedAgents = new Set(existing.globalAgents || (existing.globalInstalled ? ['claude'] : []));
36
+ const installedKeys = new Set(); // every file present after this run (for orphan pruning)
37
+
38
+ for (const agent of agents) {
39
+ const { label, globalSkillRoot } = AGENTS[agent];
40
+ if (!globalSkillRoot) {
41
+ // Cursor has no user-level skills dir; it reads ~/.claude/skills & ~/.codex/skills.
42
+ log.blank();
43
+ log.warn(`${label}: no user-level skills directory — install Claude or Codex globally and ${label} reads those. Skipping its global install.`);
44
+ continue;
45
+ }
46
+ log.blank();
47
+ console.log(`--- Installing global skills: ${label} (~/${globalSkillRoot}/) ---`);
48
+
49
+ let copied = 0; let skipped = 0; let identical = 0;
50
+ for (const relPath of COMPONENTS.skills) {
51
+ if (!skillAllowed(relPath, skills)) continue;
52
+ const r = await installSkillGlobalForAgent(agent, relPath, { force, globalFiles });
53
+ if (!r) continue;
54
+ if (r.result === 'copied') copied++;
55
+ else if (r.result === 'identical') identical++;
56
+ else skipped++;
57
+ installedKeys.add(r.key);
58
+ updatedFiles[r.key] = { kitHash: r.kitHash, agent };
59
+ }
60
+
61
+ const parts = [`${copied} copied`];
62
+ if (identical > 0) parts.push(`${identical} identical`);
63
+ if (skipped > 0) parts.push(`${skipped} customized (use --force to overwrite)`);
64
+ log.pass(`${label} global skills: ${parts.join(', ')} — available in all projects.`);
65
+ installedAgents.add(agent);
45
66
  }
46
67
 
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/');
68
+ // Global hooks are Claude-only (its native enforcement engine).
69
+ const wantHooks = hooks && agents.includes('claude');
70
+ if (wantHooks) {
71
+ const hookKeys = await initGlobalHooks({ force, hooks: hookSelection, _globalFiles: updatedFiles, _skipManifestWrite: true });
72
+ for (const k of hookKeys) installedKeys.add(k);
73
+ }
52
74
 
53
- if (hooks) {
54
- await initGlobalHooks({ force, _globalFiles: updatedFiles, _skipManifestWrite: true });
75
+ // Prune orphans: files from a previous global install that are no longer desired
76
+ // (a removed/renamed hook like self-review.sh, or a deselected skill), but only
77
+ // within the scopes we just refreshed — never touch an agent we didn't reinstall.
78
+ let pruned = 0;
79
+ const prunedDirs = new Set();
80
+ for (const [key, entry] of Object.entries(globalFiles)) {
81
+ if (installedKeys.has(key)) continue;
82
+ const isHook = key.startsWith('.claude/hooks/');
83
+ const owner = entry.agent || (isHook ? 'claude' : null);
84
+ const inScope = isHook ? wantHooks : (owner && agents.includes(owner));
85
+ if (!inScope) continue;
86
+ const abs = join(homedir(), ...key.split('/'));
87
+ try {
88
+ await unlink(abs);
89
+ log.del(`~/${key} (no longer in kit)`);
90
+ } catch { /* already gone */ }
91
+ delete updatedFiles[key];
92
+ prunedDirs.add(dirname(abs));
93
+ pruned++;
94
+ }
95
+ // Remove dirs left empty by pruning (deepest first; rmdir no-ops on non-empty).
96
+ for (let d of [...prunedDirs].sort((a, b) => b.length - a.length)) {
97
+ while (d.startsWith(homedir()) && d !== homedir()) {
98
+ try { await rmdir(d); } catch { break; }
99
+ d = dirname(d);
100
+ }
55
101
  }
102
+ if (pruned) log.info(`Pruned ${pruned} stale global file(s).`);
56
103
 
57
104
  await writeGlobalManifest({
58
105
  ...existing,
59
- globalInstalled: true,
60
- globalHooksInstalled: hooks || existing.globalHooksInstalled || false,
106
+ globalInstalled: installedAgents.has('claude') || existing.globalInstalled || false,
107
+ globalAgents: [...installedAgents],
108
+ skills: skills ? [...skills] : existing.skills,
109
+ hooks: hookSelection ? [...hookSelection] : existing.hooks,
110
+ globalHooksInstalled: wantHooks || existing.globalHooksInstalled || false,
61
111
  files: updatedFiles,
62
112
  updatedAt: new Date().toISOString(),
63
113
  });
64
114
  }
65
115
 
66
- export async function initGlobalHooks({ force = false, _globalFiles, _skipManifestWrite = false } = {}) {
116
+ export async function initGlobalHooks({ force = false, hooks = null, _globalFiles, _skipManifestWrite = false } = {}) {
67
117
  const globalHooksDir = getGlobalHooksDir();
68
118
  await mkdir(globalHooksDir, { recursive: true });
69
119
 
70
120
  const existing = _skipManifestWrite ? null : (await readGlobalManifest() || {});
71
121
  const globalFiles = _globalFiles || existing?.files || {};
72
122
  const updatedFiles = { ...globalFiles };
123
+ const keys = []; // home-relative keys installed this run (for the caller's orphan-prune)
73
124
 
74
125
  log.blank();
75
126
  console.log('--- Installing global hooks ---');
76
127
 
77
128
  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 });
129
+ for (const s of hookScriptsFor('claude', globalHooksDir, hooks)) {
130
+ const base = s.src.split('/').pop();
131
+ const key = `.claude/hooks/${base}`;
132
+ const { result, kitHash } = await installHookGlobal(s.src, globalHooksDir, { force, globalFiles, key });
80
133
  if (result === 'copied') copied++;
81
134
  else if (result === 'identical') identical++;
82
135
  else skipped++;
83
- if (result !== 'skipped') updatedFiles[relPath] = { kitHash };
136
+ if (result !== 'skipped') updatedFiles[key] = { kitHash };
137
+ keys.push(key);
84
138
  }
85
139
 
86
- await mergeGlobalSettings(globalHooksDir);
140
+ await mergeGlobalSettings(globalHooksDir, hooks);
87
141
 
88
142
  const parts = [`${copied} copied`];
89
143
  if (identical > 0) parts.push(`${identical} identical`);
@@ -99,4 +153,5 @@ export async function initGlobalHooks({ force = false, _globalFiles, _skipManife
99
153
  updatedAt: new Date().toISOString(),
100
154
  });
101
155
  }
156
+ return keys;
102
157
  }
@@ -0,0 +1,71 @@
1
+ import { intro, outro, select, multiselect, isCancel, cancel } from '@clack/prompts';
2
+ import { AGENT_IDS, AGENTS } from '../lib/agents.js';
3
+ import { ALL_SKILL_NAMES, OPTIONAL_SKILLS } from '../lib/installer.js';
4
+ import { HOOK_IDS, HOOKS } from '../lib/hooks.js';
5
+
6
+ /**
7
+ * Interactive `specpipe init` picker (clack). Three questions: scope, agents, skills.
8
+ * Returns { scope: 'project'|'global', agents: string[], skills: string|null } where
9
+ * skills is a comma list, or null when every skill is selected (= the `all` default).
10
+ * Returns null if the user cancels (Ctrl-C / Esc).
11
+ */
12
+ export async function runInteractiveInit() {
13
+ intro('specpipe init');
14
+
15
+ const scope = await select({
16
+ message: 'Install where?',
17
+ options: [
18
+ { value: 'project', label: 'This project', hint: 'default — ./.claude, ./.agents, …' },
19
+ { value: 'global', label: 'Globally', hint: 'available in every project (~/.claude/skills, …)' },
20
+ ],
21
+ initialValue: 'project',
22
+ });
23
+ if (isCancel(scope)) { cancel('Cancelled — nothing installed.'); return null; }
24
+
25
+ const agents = await multiselect({
26
+ message: 'Which agents? (space to toggle, enter to confirm)',
27
+ options: AGENT_IDS.map((id) => ({ value: id, label: AGENTS[id].label })),
28
+ initialValues: ['claude'],
29
+ required: true,
30
+ });
31
+ if (isCancel(agents)) { cancel('Cancelled — nothing installed.'); return null; }
32
+
33
+ const skills = await multiselect({
34
+ message: 'Which skills? (all on by default; deselect what you don\'t need)',
35
+ options: ALL_SKILL_NAMES.map((name) => ({
36
+ value: name,
37
+ label: name,
38
+ hint: OPTIONAL_SKILLS.includes(name) ? 'optional' : undefined,
39
+ })),
40
+ initialValues: [...ALL_SKILL_NAMES],
41
+ required: true,
42
+ });
43
+ if (isCancel(skills)) { cancel('Cancelled — nothing installed.'); return null; }
44
+
45
+ // Only offer guards at least one selected agent can hook-ENFORCE. Most guards are
46
+ // Claude-only (they hook Claude tool events like Edit/Glob/Write); Codex & Antigravity
47
+ // enforce shell-guard only, Cursor shell+read. The unsupported guards still reach a
48
+ // non-Claude agent as advisory rules in its rules file — just not as a blocking hook.
49
+ const enforceable = HOOK_IDS.filter((id) => agents.some((a) => !!HOOKS[id].wiring[a]));
50
+ const claudeOnly = HOOK_IDS.filter((id) => !enforceable.includes(id));
51
+ const hookMsg = claudeOnly.length
52
+ ? `Which guard hooks to ENFORCE for ${agents.map((a) => AGENTS[a].label).join(', ')}? (${claudeOnly.join(', ')} are Claude-only — they ship as advisory rules for the others)`
53
+ : 'Which guard hooks? (block secrets + wasteful-dir exploration; clear all to disable guardrails)';
54
+ const hooks = await multiselect({
55
+ message: hookMsg,
56
+ options: enforceable.map((id) => ({ value: id, label: id, hint: HOOKS[id].desc })),
57
+ initialValues: [...enforceable],
58
+ required: false,
59
+ });
60
+ if (isCancel(hooks)) { cancel('Cancelled — nothing installed.'); return null; }
61
+
62
+ const allSkills = skills.length === ALL_SKILL_NAMES.length;
63
+ // 'none' = explicitly cleared; null = every enforceable guard selected (the default);
64
+ // else the explicit subset. resolveHooks then re-filters per agent at emit time.
65
+ const hooksArg = hooks.length === 0 ? 'none'
66
+ : hooks.length === enforceable.length ? null
67
+ : hooks.join(',');
68
+ outro(`Installing ${skills.length} skill(s) + ${hooks.length} enforced hook(s) for ${agents.map((a) => AGENTS[a].label).join(', ')} (${scope}).`);
69
+
70
+ return { scope, agents, skills: allSkills ? null : skills.join(','), hooks: hooksArg };
71
+ }
@@ -11,11 +11,12 @@ import { readFile } from 'node:fs/promises';
11
11
  import { computeDesired } from '../lib/reconcile.js';
12
12
  import {
13
13
  getAllFiles, getFilesForComponents, installFile,
14
- ensurePlaceholderDir, setPermissions, fillTemplate,
15
- verifySettingsJson, PLACEHOLDER_DIRS, COMPONENTS,
16
- getTemplateDir, installSkillForAgent,
14
+ setPermissions, fillTemplate,
15
+ verifySettingsJson, COMPONENTS,
16
+ getTemplateDir, installSkillForAgent, installAgentHooks, installAgentRules, resolveSkills, skillAllowed,
17
17
  } from '../lib/installer.js';
18
- import { AGENTS, parseSkillPath } from '../lib/agents.js';
18
+ import { resolveHooks } from '../lib/hooks.js';
19
+ import { AGENTS, parseSkillPath, resolveAgents } from '../lib/agents.js';
19
20
  import { initMultiAgent } from './init-agents.js';
20
21
  import { adoptExisting } from './init-adopt.js';
21
22
  import { readGlobalManifest, writeGlobalManifest, initGlobal } from './init-global.js';
@@ -36,9 +37,29 @@ export async function initCommand(path, opts) {
36
37
  log.info(`Target: ${targetDir}`);
37
38
  log.blank();
38
39
 
39
- // --- Global mode ---
40
+ // --- Interactive picker --- (TTY only, and only when no selection flags were
41
+ // passed; -y / any of --global/--agents/--skills/--only/--adopt/--dry-run skips it)
42
+ const wantsInteractive = process.stdin.isTTY && !opts.yes && !opts.global
43
+ && !opts.agents && !opts.skills && !opts.only && !opts.adopt && !opts.dryRun;
44
+ if (wantsInteractive) {
45
+ const { runInteractiveInit } = await import('./init-interactive.js');
46
+ const choice = await runInteractiveInit();
47
+ if (!choice) return; // cancelled
48
+ if (choice.scope === 'global') opts.global = true;
49
+ opts.agents = choice.agents.join(',');
50
+ if (choice.skills) opts.skills = choice.skills;
51
+ if (choice.hooks) opts.hooks = choice.hooks;
52
+ }
53
+
54
+ // --- Global mode --- (honors --agents + --skills; defaults to claude + all skills)
40
55
  if (opts.global) {
41
- await initGlobal({ force: opts.force, hooks: true });
56
+ await initGlobal({
57
+ agents: resolveAgents(opts.agents),
58
+ skills: resolveSkills(opts.skills),
59
+ hookSelection: resolveHooks(opts.hooks),
60
+ force: opts.force,
61
+ hooks: true,
62
+ });
42
63
  return;
43
64
  }
44
65
 
@@ -85,7 +106,10 @@ export async function initCommand(path, opts) {
85
106
  }
86
107
  }
87
108
 
88
- const files = opts.only ? getFilesForComponents(components) : getAllFiles();
109
+ const skillsSet = resolveSkills(opts.skills);
110
+ const hooksSet = resolveHooks(opts.hooks);
111
+ const files = (opts.only ? getFilesForComponents(components) : getAllFiles())
112
+ .filter((f) => skillAllowed(f, skillsSet));
89
113
 
90
114
  // --- Dry run ---
91
115
  if (opts.dryRun) {
@@ -108,6 +132,8 @@ export async function initCommand(path, opts) {
108
132
  console.log('--- Installing ---');
109
133
 
110
134
  const manifest = createManifest(pkg.version, null, components);
135
+ if (skillsSet) manifest.skills = [...skillsSet];
136
+ if (hooksSet) manifest.hooks = [...hooksSet];
111
137
  let copied = 0;
112
138
  let skipped = 0;
113
139
  let identical = 0;
@@ -139,11 +165,24 @@ export async function initCommand(path, opts) {
139
165
  setFileEntry(manifest, outPath, kitHash, installedHash, { agent: 'claude', templateRel: file });
140
166
  }
141
167
 
168
+ // Enforced guard hooks + generated .claude/settings.json — emitted from the hook
169
+ // registry (not static files). Only when the hooks component is in scope.
170
+ if (components.includes('hooks')) {
171
+ await installAgentHooks('claude', targetDir, { force: opts.force, hooks: hooksSet });
172
+ }
173
+
174
+ // Claude's rules hub (CLAUDE.md) is a marked section emitted from the single rules
175
+ // source. It's part of the 'config' component; --hooks none (option A) skips it too.
176
+ const noGuards = hooksSet && hooksSet.size === 0;
177
+ if (!noGuards && components.includes('config')) {
178
+ await installAgentRules('claude', targetDir, { force: opts.force });
179
+ }
180
+
142
181
  // Accumulate: keep agents installed by an earlier run so a plain `init` doesn't
143
182
  // orphan files from a prior `init --agents …`. Record their on-disk files too.
144
183
  const prior = await readManifest(targetDir);
145
184
  manifest.agents = mergeAgents(prior?.agents, ['claude']);
146
- for (const [relPath, d] of await computeDesired(manifest.agents.filter((a) => a !== 'claude'))) {
185
+ for (const [relPath, d] of await computeDesired(manifest.agents.filter((a) => a !== 'claude'), skillsSet)) {
147
186
  if (manifest.files[relPath]) continue;
148
187
  try {
149
188
  const installedHash = hashContent(await readFile(resolve(targetDir, relPath), 'utf-8'));
@@ -151,11 +190,6 @@ export async function initCommand(path, opts) {
151
190
  } catch { /* prior agent's file not on disk — don't record a phantom */ }
152
191
  }
153
192
 
154
- // Placeholder directories
155
- for (const dir of PLACEHOLDER_DIRS) {
156
- await ensurePlaceholderDir(dir, targetDir);
157
- }
158
-
159
193
  // --- Permissions ---
160
194
  await setPermissions(targetDir);
161
195
 
@@ -203,11 +237,10 @@ export async function initCommand(path, opts) {
203
237
  console.log('=== Setup Complete ===');
204
238
  log.blank();
205
239
  console.log('Installed:');
206
- console.log(' .claude/CLAUDE.md — Project rules (review and customize)');
207
- console.log(' .claude/settings.json — Hook configuration');
208
- console.log(' .claude/hooks/ — 6 guards (file, path, glob, comment, sensitive, self-review)');
209
- console.log(' .claude/skills/ — /sp-plan, /sp-challenge, /sp-build, /sp-fix, /sp-review, /sp-commit, /sp-voices');
210
- console.log(' docs/WORKFLOW.md — Workflow reference');
240
+ console.log(' .claude/CLAUDE.md — rules hub (workflow + guardrails + project info)');
241
+ console.log(' .claude/settings.json — hook configuration');
242
+ console.log(' .claude/hooks/ — 5 guards (shell, read, comment, glob, file)');
243
+ console.log(' .claude/skills/ — sp-* skills (/sp-explore /sp-commit, /sp-voices)');
211
244
  log.blank();
212
245
  const parts = [`${copied} copied`];
213
246
  if (identical > 0) parts.push(`${identical} identical`);
@@ -225,14 +258,20 @@ export async function initCommand(path, opts) {
225
258
  console.log(`⚠ ${warnings} warning(s) above — review before proceeding.`);
226
259
  }
227
260
 
228
- // --- Global install prompt (first-time only) ---
261
+ // --- Global install prompt (first-time only; never on -y or non-TTY) ---
229
262
  if (!opts.global) {
230
263
  const globalMeta = await readGlobalManifest();
231
- if (globalMeta?.globalInstalled === undefined) {
264
+ if (globalMeta?.globalInstalled === undefined && process.stdin.isTTY && !opts.yes) {
232
265
  await promptGlobalInstall(opts);
233
266
  } else if (globalMeta?.globalInstalled === true) {
234
- // Auto-upgrade global on init if previously installed
235
- await initGlobal({ force: opts.force });
267
+ // Auto-upgrade global on init for every agent previously installed globally,
268
+ // preserving the skill selection recorded at global install time.
269
+ await initGlobal({
270
+ agents: globalMeta.globalAgents || ['claude'],
271
+ skills: globalMeta.skills ? new Set(globalMeta.skills) : null,
272
+ hookSelection: globalMeta.hooks ? new Set(globalMeta.hooks) : null,
273
+ force: opts.force,
274
+ });
236
275
  }
237
276
  }
238
277
  }