specpipe 1.0.1 → 1.0.3

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 +111 -311
  2. package/package.json +2 -1
  3. package/src/cli.js +16 -6
  4. package/src/commands/diff.js +1 -1
  5. package/src/commands/init-agents.js +48 -20
  6. package/src/commands/init-global.js +104 -33
  7. package/src/commands/init-interactive.js +71 -0
  8. package/src/commands/init.js +68 -20
  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 +55 -77
  15. package/src/lib/hooks.js +203 -0
  16. package/src/lib/installer.js +104 -62
  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
@@ -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, pruneOrphans,
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,19 +88,28 @@ 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 */ }
90
102
  setFileEntry(manifest, relPath, d.kitHash, installedHash, { agent: d.agent, templateRel: d.templateRel });
91
103
  }
92
104
 
105
+ // Migration: prune predecessor files (mf-* / ap-*, renamed hooks) a prior manifest
106
+ // tracked but this install no longer wants — only manifest-tracked paths, so a
107
+ // user's own files are never touched.
108
+ if (existing?.files) {
109
+ const n = await pruneOrphans(targetDir, existing.files, new Set(Object.keys(manifest.files)));
110
+ if (n) log.info(`Migrated: removed ${n} superseded file(s) from a previous version.`);
111
+ }
112
+
93
113
  await writeManifest(targetDir, manifest);
94
114
 
95
115
  // Summary
@@ -106,7 +126,15 @@ export async function initMultiAgent(targetDir, opts, warnings = 0) {
106
126
  log.blank();
107
127
  console.log(' Claude hooks active via .claude/settings.json.');
108
128
  }
109
- const noHook = agents.filter((a) => AGENTS[a].hooks !== 'native');
129
+ // Enforced = Claude's native .claude/hooks OR an agent with its own blocking
130
+ // hook config (Codex .codex/hooks.json, Cursor .cursor/hooks.json, Antigravity
131
+ // .agents/hooks.json). The rest get guards as always-on advisory rules only.
132
+ const enforced = agents.filter((a) => AGENTS[a].hooks === 'native' || agentHasHooks(a));
133
+ if (enforced.length) {
134
+ log.blank();
135
+ console.log(` Guards hook-enforced (blocking) for: ${enforced.map((a) => AGENTS[a].label).join(', ')}.`);
136
+ }
137
+ const noHook = agents.filter((a) => AGENTS[a].hooks !== 'native' && !agentHasHooks(a));
110
138
  if (noHook.length) {
111
139
  log.blank();
112
140
  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,132 @@ 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 { keys: hookKeys, entries: hookEntries } = await initGlobalHooks({ force, hooks: hookSelection, _globalFiles: updatedFiles, _skipManifestWrite: true });
72
+ for (const k of hookKeys) installedKeys.add(k);
73
+ Object.assign(updatedFiles, hookEntries); // persist hook kitHashes in the global manifest
74
+ }
52
75
 
53
- if (hooks) {
54
- await initGlobalHooks({ force, _globalFiles: updatedFiles, _skipManifestWrite: true });
76
+ // Prune orphans: files from a previous global install that are no longer desired
77
+ // (a removed/renamed hook like self-review.sh, or a deselected skill), but only
78
+ // within the scopes we just refreshed — never touch an agent we didn't reinstall.
79
+ let pruned = 0;
80
+ const prunedDirs = new Set();
81
+ for (const [key, entry] of Object.entries(globalFiles)) {
82
+ if (installedKeys.has(key)) continue;
83
+ const isHook = key.startsWith('.claude/hooks/');
84
+ const owner = entry.agent || (isHook ? 'claude' : null);
85
+ const inScope = isHook ? wantHooks : (owner && agents.includes(owner));
86
+ if (!inScope) continue;
87
+ const abs = join(homedir(), ...key.split('/'));
88
+ try {
89
+ await unlink(abs);
90
+ log.del(`~/${key} (no longer in kit)`);
91
+ } catch { /* already gone */ }
92
+ delete updatedFiles[key];
93
+ prunedDirs.add(dirname(abs));
94
+ pruned++;
95
+ }
96
+ // Remove dirs left empty by pruning (deepest first; rmdir no-ops on non-empty).
97
+ for (let d of [...prunedDirs].sort((a, b) => b.length - a.length)) {
98
+ while (d.startsWith(homedir()) && d !== homedir()) {
99
+ try { await rmdir(d); } catch { break; }
100
+ d = dirname(d);
101
+ }
102
+ }
103
+ if (pruned) log.info(`Pruned ${pruned} stale global file(s).`);
104
+
105
+ // Legacy migration: older claude-devkit installs left ~/.claude/scripts/build-test.sh
106
+ // (no longer in the kit, untracked by the manifest). Sweep it up on install too —
107
+ // not just on remove — so upgrading from the old tool leaves nothing behind.
108
+ if (wantHooks) {
109
+ const legacyScript = join(homedir(), '.claude', 'scripts', 'build-test.sh');
110
+ try {
111
+ await unlink(legacyScript);
112
+ log.del('~/.claude/scripts/build-test.sh (legacy)');
113
+ try { await rmdir(join(homedir(), '.claude', 'scripts')); } catch { /* keep if other scripts */ }
114
+ } catch { /* not present — fine */ }
55
115
  }
56
116
 
57
117
  await writeGlobalManifest({
58
118
  ...existing,
59
- globalInstalled: true,
60
- globalHooksInstalled: hooks || existing.globalHooksInstalled || false,
119
+ globalInstalled: installedAgents.has('claude') || existing.globalInstalled || false,
120
+ globalAgents: [...installedAgents],
121
+ skills: skills ? [...skills] : existing.skills,
122
+ hooks: hookSelection ? [...hookSelection] : existing.hooks,
123
+ globalHooksInstalled: wantHooks || existing.globalHooksInstalled || false,
61
124
  files: updatedFiles,
62
125
  updatedAt: new Date().toISOString(),
63
126
  });
64
127
  }
65
128
 
66
- export async function initGlobalHooks({ force = false, _globalFiles, _skipManifestWrite = false } = {}) {
129
+ export async function initGlobalHooks({ force = false, hooks = null, _globalFiles, _skipManifestWrite = false } = {}) {
67
130
  const globalHooksDir = getGlobalHooksDir();
68
131
  await mkdir(globalHooksDir, { recursive: true });
69
132
 
70
133
  const existing = _skipManifestWrite ? null : (await readGlobalManifest() || {});
71
134
  const globalFiles = _globalFiles || existing?.files || {};
72
135
  const updatedFiles = { ...globalFiles };
136
+ const keys = []; // home-relative keys installed this run (for the caller's orphan-prune)
137
+ const entries = {}; // kitHash entries written this run — the caller persists these so
138
+ // hooks are TRACKED (else savedKitHash is always undefined → every
139
+ // version bump looks "customized" and stale hooks never auto-update)
73
140
 
74
141
  log.blank();
75
142
  console.log('--- Installing global hooks ---');
76
143
 
77
144
  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 });
145
+ for (const s of hookScriptsFor('claude', globalHooksDir, hooks)) {
146
+ const base = s.src.split('/').pop();
147
+ const key = `.claude/hooks/${base}`;
148
+ const { result, kitHash } = await installHookGlobal(s.src, globalHooksDir, { force, globalFiles, key });
80
149
  if (result === 'copied') copied++;
81
150
  else if (result === 'identical') identical++;
82
151
  else skipped++;
83
- if (result !== 'skipped') updatedFiles[relPath] = { kitHash };
152
+ if (result !== 'skipped') { updatedFiles[key] = { kitHash }; entries[key] = { kitHash }; }
153
+ keys.push(key);
84
154
  }
85
155
 
86
- await mergeGlobalSettings(globalHooksDir);
156
+ await mergeGlobalSettings(globalHooksDir, hooks);
87
157
 
88
158
  const parts = [`${copied} copied`];
89
159
  if (identical > 0) parts.push(`${identical} identical`);
@@ -99,4 +169,5 @@ export async function initGlobalHooks({ force = false, _globalFiles, _skipManife
99
169
  updatedAt: new Date().toISOString(),
100
170
  });
101
171
  }
172
+ return { keys, entries };
102
173
  }
@@ -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,13 @@ 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
+ pruneOrphans,
17
18
  } from '../lib/installer.js';
18
- import { AGENTS, parseSkillPath } from '../lib/agents.js';
19
+ import { resolveHooks } from '../lib/hooks.js';
20
+ import { AGENTS, parseSkillPath, resolveAgents } from '../lib/agents.js';
19
21
  import { initMultiAgent } from './init-agents.js';
20
22
  import { adoptExisting } from './init-adopt.js';
21
23
  import { readGlobalManifest, writeGlobalManifest, initGlobal } from './init-global.js';
@@ -36,9 +38,29 @@ export async function initCommand(path, opts) {
36
38
  log.info(`Target: ${targetDir}`);
37
39
  log.blank();
38
40
 
39
- // --- Global mode ---
41
+ // --- Interactive picker --- (TTY only, and only when no selection flags were
42
+ // passed; -y / any of --global/--agents/--skills/--only/--adopt/--dry-run skips it)
43
+ const wantsInteractive = process.stdin.isTTY && !opts.yes && !opts.global
44
+ && !opts.agents && !opts.skills && !opts.only && !opts.adopt && !opts.dryRun;
45
+ if (wantsInteractive) {
46
+ const { runInteractiveInit } = await import('./init-interactive.js');
47
+ const choice = await runInteractiveInit();
48
+ if (!choice) return; // cancelled
49
+ if (choice.scope === 'global') opts.global = true;
50
+ opts.agents = choice.agents.join(',');
51
+ if (choice.skills) opts.skills = choice.skills;
52
+ if (choice.hooks) opts.hooks = choice.hooks;
53
+ }
54
+
55
+ // --- Global mode --- (honors --agents + --skills; defaults to claude + all skills)
40
56
  if (opts.global) {
41
- await initGlobal({ force: opts.force, hooks: true });
57
+ await initGlobal({
58
+ agents: resolveAgents(opts.agents),
59
+ skills: resolveSkills(opts.skills),
60
+ hookSelection: resolveHooks(opts.hooks),
61
+ force: opts.force,
62
+ hooks: true,
63
+ });
42
64
  return;
43
65
  }
44
66
 
@@ -85,7 +107,10 @@ export async function initCommand(path, opts) {
85
107
  }
86
108
  }
87
109
 
88
- const files = opts.only ? getFilesForComponents(components) : getAllFiles();
110
+ const skillsSet = resolveSkills(opts.skills);
111
+ const hooksSet = resolveHooks(opts.hooks);
112
+ const files = (opts.only ? getFilesForComponents(components) : getAllFiles())
113
+ .filter((f) => skillAllowed(f, skillsSet));
89
114
 
90
115
  // --- Dry run ---
91
116
  if (opts.dryRun) {
@@ -108,6 +133,8 @@ export async function initCommand(path, opts) {
108
133
  console.log('--- Installing ---');
109
134
 
110
135
  const manifest = createManifest(pkg.version, null, components);
136
+ if (skillsSet) manifest.skills = [...skillsSet];
137
+ if (hooksSet) manifest.hooks = [...hooksSet];
111
138
  let copied = 0;
112
139
  let skipped = 0;
113
140
  let identical = 0;
@@ -139,11 +166,24 @@ export async function initCommand(path, opts) {
139
166
  setFileEntry(manifest, outPath, kitHash, installedHash, { agent: 'claude', templateRel: file });
140
167
  }
141
168
 
169
+ // Enforced guard hooks + generated .claude/settings.json — emitted from the hook
170
+ // registry (not static files). Only when the hooks component is in scope.
171
+ if (components.includes('hooks')) {
172
+ await installAgentHooks('claude', targetDir, { force: opts.force, hooks: hooksSet });
173
+ }
174
+
175
+ // Claude's rules hub (CLAUDE.md) is a marked section emitted from the single rules
176
+ // source. It's part of the 'config' component; --hooks none (option A) skips it too.
177
+ const noGuards = hooksSet && hooksSet.size === 0;
178
+ if (!noGuards && components.includes('config')) {
179
+ await installAgentRules('claude', targetDir, { force: opts.force });
180
+ }
181
+
142
182
  // Accumulate: keep agents installed by an earlier run so a plain `init` doesn't
143
183
  // orphan files from a prior `init --agents …`. Record their on-disk files too.
144
184
  const prior = await readManifest(targetDir);
145
185
  manifest.agents = mergeAgents(prior?.agents, ['claude']);
146
- for (const [relPath, d] of await computeDesired(manifest.agents.filter((a) => a !== 'claude'))) {
186
+ for (const [relPath, d] of await computeDesired(manifest.agents.filter((a) => a !== 'claude'), skillsSet)) {
147
187
  if (manifest.files[relPath]) continue;
148
188
  try {
149
189
  const installedHash = hashContent(await readFile(resolve(targetDir, relPath), 'utf-8'));
@@ -151,9 +191,12 @@ export async function initCommand(path, opts) {
151
191
  } catch { /* prior agent's file not on disk — don't record a phantom */ }
152
192
  }
153
193
 
154
- // Placeholder directories
155
- for (const dir of PLACEHOLDER_DIRS) {
156
- await ensurePlaceholderDir(dir, targetDir);
194
+ // Migration: drop predecessor files a prior manifest tracked but we no longer
195
+ // install (mf-* / ap-* skills, renamed hooks) so an install over an old version
196
+ // doesn't leave orphaned /mf-* commands beside the new /sp-* ones.
197
+ if (prior?.files) {
198
+ const n = await pruneOrphans(targetDir, prior.files, new Set(Object.keys(manifest.files)));
199
+ if (n) log.info(`Migrated: removed ${n} superseded file(s) from a previous version.`);
157
200
  }
158
201
 
159
202
  // --- Permissions ---
@@ -203,11 +246,10 @@ export async function initCommand(path, opts) {
203
246
  console.log('=== Setup Complete ===');
204
247
  log.blank();
205
248
  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');
249
+ console.log(' .claude/CLAUDE.md — rules hub (workflow + guardrails + project info)');
250
+ console.log(' .claude/settings.json — hook configuration');
251
+ console.log(' .claude/hooks/ — 5 guards (shell, read, comment, glob, file)');
252
+ console.log(' .claude/skills/ — sp-* skills (/sp-explore /sp-commit, /sp-voices)');
211
253
  log.blank();
212
254
  const parts = [`${copied} copied`];
213
255
  if (identical > 0) parts.push(`${identical} identical`);
@@ -225,14 +267,20 @@ export async function initCommand(path, opts) {
225
267
  console.log(`⚠ ${warnings} warning(s) above — review before proceeding.`);
226
268
  }
227
269
 
228
- // --- Global install prompt (first-time only) ---
270
+ // --- Global install prompt (first-time only; never on -y or non-TTY) ---
229
271
  if (!opts.global) {
230
272
  const globalMeta = await readGlobalManifest();
231
- if (globalMeta?.globalInstalled === undefined) {
273
+ if (globalMeta?.globalInstalled === undefined && process.stdin.isTTY && !opts.yes) {
232
274
  await promptGlobalInstall(opts);
233
275
  } else if (globalMeta?.globalInstalled === true) {
234
- // Auto-upgrade global on init if previously installed
235
- await initGlobal({ force: opts.force });
276
+ // Auto-upgrade global on init for every agent previously installed globally,
277
+ // preserving the skill selection recorded at global install time.
278
+ await initGlobal({
279
+ agents: globalMeta.globalAgents || ['claude'],
280
+ skills: globalMeta.skills ? new Set(globalMeta.skills) : null,
281
+ hookSelection: globalMeta.hooks ? new Set(globalMeta.hooks) : null,
282
+ force: opts.force,
283
+ });
236
284
  }
237
285
  }
238
286
  }