specpipe 1.0.1 → 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.
- 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 +40 -20
- package/src/commands/init-global.js +88 -33
- package/src/commands/init-interactive.js +71 -0
- package/src/commands/init.js +61 -22
- 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 +50 -77
- package/src/lib/hooks.js +203 -0
- package/src/lib/installer.js +73 -61
- 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,
|
|
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,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
|
-
|
|
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,
|
|
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,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
|
-
|
|
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 hookKeys = await initGlobalHooks({ force, hooks: hookSelection, _globalFiles: updatedFiles, _skipManifestWrite: true });
|
|
72
|
+
for (const k of hookKeys) installedKeys.add(k);
|
|
73
|
+
}
|
|
52
74
|
|
|
53
|
-
|
|
54
|
-
|
|
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:
|
|
60
|
-
|
|
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
|
|
79
|
-
const
|
|
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[
|
|
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
|
+
}
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
verifySettingsJson,
|
|
16
|
-
getTemplateDir, installSkillForAgent,
|
|
14
|
+
setPermissions, fillTemplate,
|
|
15
|
+
verifySettingsJson, COMPONENTS,
|
|
16
|
+
getTemplateDir, installSkillForAgent, installAgentHooks, installAgentRules, resolveSkills, skillAllowed,
|
|
17
17
|
} from '../lib/installer.js';
|
|
18
|
-
import {
|
|
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
|
-
// ---
|
|
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({
|
|
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
|
|
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 —
|
|
207
|
-
console.log(' .claude/settings.json —
|
|
208
|
-
console.log(' .claude/hooks/ —
|
|
209
|
-
console.log(' .claude/skills/ —
|
|
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
|
|
235
|
-
|
|
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
|
}
|