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.
- package/README.md +111 -311
- package/package.json +2 -1
- package/src/cli.js +16 -6
- package/src/commands/diff.js +1 -1
- package/src/commands/init-agents.js +48 -20
- package/src/commands/init-global.js +104 -33
- package/src/commands/init-interactive.js +71 -0
- package/src/commands/init.js +68 -20
- package/src/commands/remove.js +159 -49
- package/src/commands/upgrade.js +21 -56
- package/src/lib/agent-guards.js +34 -78
- package/src/lib/agent-install.js +38 -25
- package/src/lib/agents.js +53 -11
- package/src/lib/claude-global.js +55 -77
- package/src/lib/hooks.js +203 -0
- package/src/lib/installer.js +104 -62
- package/src/lib/reconcile.js +13 -8
- package/templates/{.claude/hooks → hooks}/file-guard.js +26 -21
- package/templates/hooks/specpipe-read-guard.sh +94 -21
- package/templates/hooks/specpipe-shell-guard.sh +121 -29
- package/templates/rules/specpipe-rules.md +77 -0
- package/templates/skills/sp-build/SKILL.md +101 -1
- package/templates/skills/sp-build-behavior-matrix/SKILL.md +876 -0
- package/templates/skills/sp-challenge/SKILL.md +34 -0
- package/templates/skills/sp-challenge-behavior-matrix/SKILL.md +289 -0
- package/templates/skills/sp-explore/SKILL.md +132 -0
- package/templates/skills/sp-explore-behavior-matrix/SKILL.md +862 -0
- package/templates/skills/sp-fix/SKILL.md +73 -1
- package/templates/skills/sp-fix-behavior-matrix/SKILL.md +338 -0
- package/templates/skills/sp-investigate/SKILL.md +70 -0
- package/templates/skills/sp-investigate-behavior-matrix/SKILL.md +718 -0
- package/templates/skills/sp-plan/SKILL.md +90 -0
- package/templates/skills/sp-plan-behavior-matrix/SKILL.md +1037 -0
- package/templates/skills/sp-review/SKILL.md +29 -3
- package/templates/skills/sp-review-behavior-matrix/SKILL.md +294 -0
- package/templates/.claude/CLAUDE.md +0 -79
- package/templates/.claude/hooks/path-guard.sh +0 -118
- package/templates/.claude/hooks/self-review.sh +0 -27
- package/templates/.claude/hooks/sensitive-guard.sh +0 -227
- package/templates/.claude/settings.json +0 -68
- package/templates/docs/WORKFLOW.md +0 -325
- package/templates/docs/specs/.gitkeep +0 -0
- package/templates/rules/specpipe-guards.md +0 -40
- package/templates/scripts/test-hooks.sh +0 -66
- /package/templates/{.claude/hooks → hooks}/comment-guard.js +0 -0
- /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,
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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:
|
|
60
|
-
|
|
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
|
|
79
|
-
const
|
|
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[
|
|
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
|
+
}
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
verifySettingsJson,
|
|
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 {
|
|
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
|
-
// ---
|
|
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({
|
|
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
|
|
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
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
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 —
|
|
207
|
-
console.log(' .claude/settings.json —
|
|
208
|
-
console.log(' .claude/hooks/ —
|
|
209
|
-
console.log(' .claude/skills/ —
|
|
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
|
|
235
|
-
|
|
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
|
}
|