specpipe 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +1319 -0
  2. package/bin/devkit.js +3 -0
  3. package/package.json +61 -0
  4. package/src/cli.js +76 -0
  5. package/src/commands/check.js +33 -0
  6. package/src/commands/diff.js +84 -0
  7. package/src/commands/init-adopt.js +54 -0
  8. package/src/commands/init-agents.js +118 -0
  9. package/src/commands/init-global.js +102 -0
  10. package/src/commands/init.js +311 -0
  11. package/src/commands/list.js +54 -0
  12. package/src/commands/remove.js +133 -0
  13. package/src/commands/upgrade.js +215 -0
  14. package/src/lib/agent-guards.js +100 -0
  15. package/src/lib/agent-install.js +161 -0
  16. package/src/lib/agents.js +280 -0
  17. package/src/lib/claude-global.js +183 -0
  18. package/src/lib/detector.js +93 -0
  19. package/src/lib/hasher.js +21 -0
  20. package/src/lib/installer.js +213 -0
  21. package/src/lib/logger.js +16 -0
  22. package/src/lib/manifest.js +102 -0
  23. package/src/lib/reconcile.js +56 -0
  24. package/templates/.claude/CLAUDE.md +79 -0
  25. package/templates/.claude/hooks/comment-guard.js +126 -0
  26. package/templates/.claude/hooks/file-guard.js +216 -0
  27. package/templates/.claude/hooks/glob-guard.js +104 -0
  28. package/templates/.claude/hooks/path-guard.sh +118 -0
  29. package/templates/.claude/hooks/self-review.sh +27 -0
  30. package/templates/.claude/hooks/sensitive-guard.sh +227 -0
  31. package/templates/.claude/settings.json +68 -0
  32. package/templates/docs/WORKFLOW.md +325 -0
  33. package/templates/docs/specs/.gitkeep +0 -0
  34. package/templates/hooks/specpipe-read-guard.sh +42 -0
  35. package/templates/hooks/specpipe-shell-guard.sh +65 -0
  36. package/templates/rules/specpipe-guards.md +40 -0
  37. package/templates/scripts/test-hooks.sh +66 -0
  38. package/templates/skills/sp-build/SKILL.md +776 -0
  39. package/templates/skills/sp-challenge/SKILL.md +255 -0
  40. package/templates/skills/sp-commit/SKILL.md +174 -0
  41. package/templates/skills/sp-explore/SKILL.md +730 -0
  42. package/templates/skills/sp-fix/SKILL.md +266 -0
  43. package/templates/skills/sp-humanize/SKILL.md +212 -0
  44. package/templates/skills/sp-investigate/SKILL.md +648 -0
  45. package/templates/skills/sp-md-render/SKILL.md +200 -0
  46. package/templates/skills/sp-md-render/components.md +415 -0
  47. package/templates/skills/sp-md-render/template.html +283 -0
  48. package/templates/skills/sp-plan/SKILL.md +947 -0
  49. package/templates/skills/sp-review/SKILL.md +268 -0
  50. package/templates/skills/sp-scaffold/SKILL.md +237 -0
  51. package/templates/skills/sp-scaffold/references/ARCHITECTURE.md.tmpl +228 -0
  52. package/templates/skills/sp-scaffold/references/DESIGN.md.tmpl +113 -0
  53. package/templates/skills/sp-scaffold/references/adr/NNNN-template.md +92 -0
  54. package/templates/skills/sp-scaffold/references/stack-profiles/react.md +36 -0
  55. package/templates/skills/sp-spec-render/SKILL.md +254 -0
  56. package/templates/skills/sp-spec-render/components.md +418 -0
  57. package/templates/skills/sp-spec-render/examples/user-auth.html +749 -0
  58. package/templates/skills/sp-spec-render/examples/user-auth.md +114 -0
  59. package/templates/skills/sp-spec-render/template.html +222 -0
  60. package/templates/skills/sp-voices/SKILL.md +1184 -0
@@ -0,0 +1,215 @@
1
+ import { resolve, dirname, join } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { homedir } from 'node:os';
7
+ import { log } from '../lib/logger.js';
8
+ import { readManifest, writeManifest, setFileEntry, refreshCustomizationStatus, getAgents } from '../lib/manifest.js';
9
+ import { setPermissions, COMPONENTS, installSkillGlobal, getGlobalSkillsDir, installHookGlobal, getGlobalHooksDir, mergeGlobalSettings, installAgentRules, installAgentHooks } from '../lib/installer.js';
10
+ import { agentRulesMode, agentHasHooks } from '../lib/agents.js';
11
+ import { computeDesired } from '../lib/reconcile.js';
12
+ import { unlink } from 'node:fs/promises';
13
+
14
+ const GLOBAL_MANIFEST = join(homedir(), '.claude', '.devkit-manifest.json');
15
+
16
+ async function readGlobalManifest() {
17
+ try { return JSON.parse(await readFile(GLOBAL_MANIFEST, 'utf-8')); } catch { return null; }
18
+ }
19
+ async function writeGlobalManifest(data) {
20
+ await mkdir(join(homedir(), '.claude'), { recursive: true });
21
+ await writeFile(GLOBAL_MANIFEST, JSON.stringify(data, null, 2) + '\n');
22
+ }
23
+
24
+ export async function upgradeGlobal({ force = false } = {}) {
25
+ const globalSkillsDir = getGlobalSkillsDir();
26
+ await mkdir(globalSkillsDir, { recursive: true });
27
+
28
+ const meta = await readGlobalManifest() || {};
29
+ const globalFiles = meta.files || {};
30
+ const updatedFiles = { ...globalFiles };
31
+
32
+ log.blank();
33
+ console.log('--- Upgrading global skills ---');
34
+ let updated = 0; let skipped = 0; let identical = 0;
35
+
36
+ for (const relPath of COMPONENTS.skills) {
37
+ const { result, kitHash } = await installSkillGlobal(relPath, globalSkillsDir, { force, globalFiles });
38
+ if (result === 'copied') updated++;
39
+ else if (result === 'identical') identical++;
40
+ else skipped++;
41
+ if (result !== 'skipped') updatedFiles[relPath] = { kitHash };
42
+ }
43
+
44
+ let skillParts = [`${updated} updated`, `${identical} unchanged`];
45
+ if (skipped > 0) skillParts.push(`${skipped} customized (use --force to overwrite)`);
46
+ log.pass(`Global skills: ${skillParts.join(', ')}`);
47
+
48
+ // Upgrade hooks if previously installed globally
49
+ if (meta.globalHooksInstalled) {
50
+ const globalHooksDir = getGlobalHooksDir();
51
+ await mkdir(globalHooksDir, { recursive: true });
52
+
53
+ log.blank();
54
+ console.log('--- Upgrading global hooks ---');
55
+ let hUpdated = 0; let hSkipped = 0; let hIdentical = 0;
56
+
57
+ for (const relPath of COMPONENTS.hooks) {
58
+ const { result, kitHash } = await installHookGlobal(relPath, globalHooksDir, { force, globalFiles });
59
+ if (result === 'copied') hUpdated++;
60
+ else if (result === 'identical') hIdentical++;
61
+ else hSkipped++;
62
+ if (result !== 'skipped') updatedFiles[relPath] = { kitHash };
63
+ }
64
+
65
+ await mergeGlobalSettings(globalHooksDir);
66
+
67
+ let hookParts = [`${hUpdated} updated`, `${hIdentical} unchanged`];
68
+ if (hSkipped > 0) hookParts.push(`${hSkipped} customized (use --force to overwrite)`);
69
+ log.pass(`Global hooks: ${hookParts.join(', ')}`);
70
+ }
71
+
72
+ await writeGlobalManifest({ ...meta, globalInstalled: true, files: updatedFiles, updatedAt: new Date().toISOString() });
73
+
74
+ // Warn about per-project skills that shadow global
75
+ const projects = meta.projects || [];
76
+ const projectsWithSkills = projects.filter((p) => existsSync(join(p, '.claude/skills')));
77
+ if (projectsWithSkills.length > 0) {
78
+ log.blank();
79
+ log.info(`Found per-project skills in ${projectsWithSkills.length} project(s):`);
80
+ for (const p of projectsWithSkills) log.info(` ${p}`);
81
+ log.info('Per-project skills take precedence over global. Remove them to use global instead.');
82
+ log.info('Run `specpipe remove <path>` in each project to remove per-project install.');
83
+ }
84
+ }
85
+
86
+ const __dirname = dirname(fileURLToPath(import.meta.url));
87
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8'));
88
+
89
+ export async function upgradeCommand(path, opts) {
90
+ // --- Global mode ---
91
+ if (opts.global) {
92
+ await upgradeGlobal({ force: opts.force });
93
+ return;
94
+ }
95
+
96
+ const targetDir = resolve(path);
97
+ const manifest = await readManifest(targetDir);
98
+
99
+ if (!manifest) {
100
+ log.fail('No manifest found. Run `specpipe init` first, or `specpipe init --adopt` to adopt existing files.');
101
+ process.exit(1);
102
+ }
103
+
104
+ // Refresh customization status by re-hashing installed files
105
+ await refreshCustomizationStatus(targetDir, manifest);
106
+
107
+ log.info(`specpipe upgrade: ${manifest.version} → ${pkg.version}`);
108
+ log.blank();
109
+
110
+ if (opts.dryRun) {
111
+ log.info('Dry run — no changes will be made');
112
+ log.blank();
113
+ }
114
+
115
+ // Desired installed state for every agent this project targets.
116
+ const agents = getAgents(manifest);
117
+ const desired = await computeDesired(agents);
118
+
119
+ let updated = 0;
120
+ let skippedCustomized = 0;
121
+ let added = 0;
122
+ let unchanged = 0;
123
+
124
+ for (const [relPath, d] of desired) {
125
+ const installedPath = resolve(targetDir, relPath);
126
+ const entry = manifest.files[relPath];
127
+
128
+ if (!entry) {
129
+ // New file (new kit file, or an agent added since install) — install it.
130
+ if (!opts.dryRun) {
131
+ await mkdir(dirname(installedPath), { recursive: true });
132
+ await writeFile(installedPath, d.content);
133
+ setFileEntry(manifest, relPath, d.kitHash, d.kitHash, { agent: d.agent, templateRel: d.templateRel });
134
+ }
135
+ log.copy(`${relPath} (new)`);
136
+ added++;
137
+ continue;
138
+ }
139
+
140
+ if (d.kitHash === entry.kitHash) {
141
+ log.same(relPath);
142
+ unchanged++;
143
+ continue;
144
+ }
145
+
146
+ if (entry.customized && !opts.force) {
147
+ log.skip(`${relPath} (customized — use --force to overwrite)`);
148
+ skippedCustomized++;
149
+ continue;
150
+ }
151
+
152
+ // Kit changed, user hasn't customized (or --force) → update.
153
+ if (!opts.dryRun) {
154
+ await mkdir(dirname(installedPath), { recursive: true });
155
+ await writeFile(installedPath, d.content);
156
+ setFileEntry(manifest, relPath, d.kitHash, d.kitHash, { agent: d.agent, templateRel: d.templateRel });
157
+ }
158
+ log.copy(relPath);
159
+ updated++;
160
+ }
161
+
162
+ // Remove files in manifest that are no longer desired (dropped kit file or agent).
163
+ let removed = 0;
164
+ for (const relPath of Object.keys(manifest.files)) {
165
+ if (desired.has(relPath)) continue;
166
+ const filePath = resolve(targetDir, relPath);
167
+ if (!opts.dryRun) {
168
+ try {
169
+ await unlink(filePath);
170
+ delete manifest.files[relPath];
171
+ removed++;
172
+ log.del(relPath);
173
+ } catch {
174
+ log.warn(`${relPath} — no longer in kit (could not delete)`);
175
+ }
176
+ } else {
177
+ log.del(`${relPath} (would remove)`);
178
+ removed++;
179
+ }
180
+ }
181
+
182
+ // Refresh guardrails that aren't owned files: Codex's shared AGENTS.md section is
183
+ // merged (not reconciled via computeDesired), so re-merge it here to pick up kit
184
+ // changes. Owned rule files were already handled by the reconcile loop above.
185
+ if (!opts.dryRun) {
186
+ for (const agent of agents) {
187
+ if (agentRulesMode(agent) === 'agents-md') await installAgentRules(agent, targetDir, { force: opts.force });
188
+ if (agentHasHooks(agent)) await installAgentHooks(agent, targetDir, { force: opts.force });
189
+ }
190
+ }
191
+
192
+ // Update manifest
193
+ if (!opts.dryRun) {
194
+ manifest.version = pkg.version;
195
+ manifest.updatedAt = new Date().toISOString();
196
+ await setPermissions(targetDir);
197
+ await writeManifest(targetDir, manifest);
198
+ }
199
+
200
+ // Summary
201
+ log.blank();
202
+ const parts = [`Updated ${updated}`, `added ${added}`, `removed ${removed}`, `unchanged ${unchanged}`];
203
+ if (skippedCustomized > 0) parts.push(`skipped ${skippedCustomized} customized`);
204
+ log.pass(parts.join(', ') + '.');
205
+
206
+ if (skippedCustomized > 0) {
207
+ log.warn(`${skippedCustomized} customized file(s) skipped. Run with --force to overwrite.`);
208
+ }
209
+
210
+ // --- Auto-upgrade global if previously installed ---
211
+ const globalMeta = await readGlobalManifest();
212
+ if (globalMeta?.globalInstalled === true) {
213
+ await upgradeGlobal({ force: opts.force });
214
+ }
215
+ }
@@ -0,0 +1,100 @@
1
+ // Guardrails (advisory rules) + enforced (blocking) hooks per agent.
2
+ // Claude enforces guards via its native .claude/hooks; every other agent gets the
3
+ // same intent as an always-on RULE. Codex/Cursor additionally support blocking
4
+ // hooks (verified payloads), so they also get enforced guard scripts.
5
+
6
+ const RULES = {
7
+ cursor: {
8
+ mode: 'file',
9
+ path: '.cursor/rules/specpipe-guards.mdc',
10
+ frontmatter: 'description: specpipe operating rules — spec-first cycle, guardrails, testing, conventions\nglobs:\nalwaysApply: true',
11
+ },
12
+ // Antigravity rules are plain markdown (no documented trigger/glob frontmatter);
13
+ // official Google DevRel uses the singular `.agent/rules/` path.
14
+ antigravity: { mode: 'doc', path: '.agent/rules/specpipe-guards.md' },
15
+ codex: { mode: 'agents-md', path: 'AGENTS.md' },
16
+ openclaw: { mode: 'doc', path: 'SPECPIPE-GUARDS.md' },
17
+ hermes: { mode: 'doc', path: 'SPECPIPE-GUARDS.md' },
18
+ };
19
+
20
+ export const GUARDS_BEGIN = '<!-- specpipe:guards:begin -->';
21
+ export const GUARDS_END = '<!-- specpipe:guards:end -->';
22
+
23
+ /** How an agent carries guardrails: 'file' | 'doc' | 'agents-md' | null (native hooks). */
24
+ export function agentRulesMode(agentId) {
25
+ return RULES[agentId]?.mode || null;
26
+ }
27
+
28
+ /**
29
+ * Emit the guardrails artifact for an agent from the canonical guards body.
30
+ * @returns {{ mode, path, content } | null} null for Claude (native hooks).
31
+ */
32
+ export function emitRules(agentId, body) {
33
+ const r = RULES[agentId];
34
+ if (!r) return null;
35
+ if (r.mode === 'file') return { mode: 'file', path: r.path, content: `---\n${r.frontmatter}\n---\n${body}` };
36
+ if (r.mode === 'doc') return { mode: 'doc', path: r.path, content: `# specpipe — operating rules\n\n${body}` };
37
+ // agents-md: a marked section merged into a shared AGENTS.md
38
+ return { mode: 'agents-md', path: r.path, content: `${GUARDS_BEGIN}\n## specpipe — operating rules\n\n${body}${GUARDS_END}\n` };
39
+ }
40
+
41
+ // ── Enforced hooks (block tool calls, not just advise) ──────────────────────
42
+ // Agents whose hook payloads + block primitive (exit 2) are verified compatible
43
+ // with the shared guard scripts (kit/hooks/specpipe-*.sh). Claude has its own
44
+ // .claude/hooks; Antigravity/Hermes lack a usable blocking-hook surface → omitted.
45
+ const SHELL_GUARD = 'specpipe-shell-guard.sh';
46
+ const READ_GUARD = 'specpipe-read-guard.sh';
47
+
48
+ const EHOOKS = {
49
+ // Codex PreToolUse payload == Claude's (.tool_input.command); exit 2 blocks.
50
+ // Verified matcher: "Bash". Read/Edit tool names unverified → shell guard only.
51
+ codex: {
52
+ dir: '.codex/hooks',
53
+ scripts: [SHELL_GUARD],
54
+ configPath: '.codex/hooks.json',
55
+ config: {
56
+ hooks: {
57
+ PreToolUse: [
58
+ { matcher: 'Bash', hooks: [{ type: 'command', command: `bash .codex/hooks/${SHELL_GUARD}` }] },
59
+ ],
60
+ },
61
+ },
62
+ },
63
+ // Cursor: beforeShellExecution (.command) + beforeReadFile (.file_path) verified;
64
+ // fail-open by default, so failClosed: true to actually enforce.
65
+ cursor: {
66
+ dir: '.cursor/hooks',
67
+ scripts: [SHELL_GUARD, READ_GUARD],
68
+ configPath: '.cursor/hooks.json',
69
+ config: {
70
+ version: 1,
71
+ hooks: {
72
+ beforeShellExecution: [{ command: `./.cursor/hooks/${SHELL_GUARD}`, failClosed: true }],
73
+ beforeReadFile: [{ command: `./.cursor/hooks/${READ_GUARD}`, failClosed: true }],
74
+ },
75
+ },
76
+ },
77
+ };
78
+
79
+ /** Kit-relative source path for a guard script. */
80
+ export const HOOKS_SRC_DIR = 'hooks';
81
+
82
+ /** Whether an agent gets enforced (blocking) hooks beyond advisory rules. */
83
+ export function agentHasHooks(agentId) {
84
+ return !!EHOOKS[agentId];
85
+ }
86
+
87
+ /**
88
+ * Emit an agent's enforced-hook artifacts (Codex/Cursor). Returns the scripts to
89
+ * copy (kit-relative src + on-disk dst) and the hook config file to write, or null.
90
+ */
91
+ export function emitHooks(agentId) {
92
+ const h = EHOOKS[agentId];
93
+ if (!h) return null;
94
+ return {
95
+ hooksDir: h.dir,
96
+ scripts: h.scripts.map((name) => ({ src: `${HOOKS_SRC_DIR}/${name}`, dst: `${h.dir}/${name}` })),
97
+ configPath: h.configPath,
98
+ configContent: JSON.stringify(h.config, null, 2) + '\n',
99
+ };
100
+ }
@@ -0,0 +1,161 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, writeFile, unlink, chmod, rmdir } from 'node:fs/promises';
3
+ import { join, dirname } from 'node:path';
4
+ import { log } from './logger.js';
5
+ import { getTemplateDir, COMPONENTS } from './installer.js';
6
+ import { emitSkillFile, emitRules, emitHooks, AGENTS, GUARDS_BEGIN, GUARDS_END } from './agents.js';
7
+
8
+ /**
9
+ * Emit one canonical skill file for a target agent and write it to targetDir.
10
+ * Transforms path + frontmatter per the agent's convention (see agents.js).
11
+ * @returns {{ result: 'copied'|'skipped'|'identical', path?: string }}
12
+ */
13
+ export async function installSkillForAgent(agentId, canonicalRel, targetDir, { force = false } = {}) {
14
+ const src = join(getTemplateDir(), canonicalRel);
15
+ const content = await readFile(src, 'utf-8');
16
+ const emitted = emitSkillFile(agentId, canonicalRel, content);
17
+ if (!emitted) return { result: 'skipped' };
18
+
19
+ const dst = join(targetDir, emitted.path);
20
+ if (existsSync(dst) && !force) {
21
+ try {
22
+ if ((await readFile(dst, 'utf-8')) === emitted.content) {
23
+ log.same(`${emitted.path} (identical)`);
24
+ return { result: 'identical', path: emitted.path };
25
+ }
26
+ } catch { /* unreadable — treat as conflict */ }
27
+ log.warn(`${emitted.path} (exists with different content — use --force to overwrite)`);
28
+ return { result: 'skipped', path: emitted.path };
29
+ }
30
+
31
+ await mkdir(dirname(dst), { recursive: true });
32
+ await writeFile(dst, emitted.content);
33
+ log.copy(emitted.path);
34
+ return { result: 'copied', path: emitted.path };
35
+ }
36
+
37
+ /**
38
+ * Install the full skill set for one agent into targetDir.
39
+ * @returns {{ agent: string, copied: number, skipped: number, identical: number, paths: string[] }}
40
+ */
41
+ export async function installAgentSkills(agentId, targetDir, { force = false } = {}) {
42
+ let copied = 0, skipped = 0, identical = 0;
43
+ const paths = [];
44
+ for (const relPath of COMPONENTS.skills) {
45
+ const { result, path } = await installSkillForAgent(agentId, relPath, targetDir, { force });
46
+ if (result === 'copied') copied++;
47
+ else if (result === 'identical') identical++;
48
+ else skipped++;
49
+ if (path) paths.push(path);
50
+ }
51
+ return { agent: agentId, label: AGENTS[agentId].label, copied, skipped, identical, paths };
52
+ }
53
+
54
+ const GUARDS_TEMPLATE_REL = 'rules/specpipe-guards.md';
55
+
56
+ function guardsSectionRegex() {
57
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
58
+ return new RegExp(esc(GUARDS_BEGIN) + '[\\s\\S]*?' + esc(GUARDS_END) + '\\n?', '');
59
+ }
60
+
61
+ /** Merge (or replace) the specpipe guards section in a shared AGENTS.md. */
62
+ export async function mergeAgentsMdGuards(targetDir, section) {
63
+ const p = join(targetDir, 'AGENTS.md');
64
+ let existing = '';
65
+ try { existing = await readFile(p, 'utf-8'); } catch { /* new file */ }
66
+ const re = guardsSectionRegex();
67
+ existing = re.test(existing)
68
+ ? existing.replace(re, section)
69
+ : (existing.trim() ? existing.trimEnd() + '\n\n' : '') + section;
70
+ await writeFile(p, existing);
71
+ }
72
+
73
+ /** Remove the specpipe guards section from AGENTS.md (deletes file if now empty). */
74
+ export async function stripAgentsMdGuards(targetDir) {
75
+ const p = join(targetDir, 'AGENTS.md');
76
+ let existing;
77
+ try { existing = await readFile(p, 'utf-8'); } catch { return false; }
78
+ const stripped = existing.replace(guardsSectionRegex(), '').trim();
79
+ if (stripped === existing.trim()) return false;
80
+ if (stripped) await writeFile(p, stripped + '\n');
81
+ else await unlink(p);
82
+ return true;
83
+ }
84
+
85
+ /**
86
+ * Install an agent's guardrails: an owned rules file (Cursor/Antigravity/
87
+ * OpenClaw/Hermes) or a merged section in a shared AGENTS.md (Codex).
88
+ * Claude returns null — it uses native hooks instead.
89
+ */
90
+ export async function installAgentRules(agentId, targetDir, { force = false } = {}) {
91
+ const body = await readFile(join(getTemplateDir(), GUARDS_TEMPLATE_REL), 'utf-8');
92
+ const r = emitRules(agentId, body);
93
+ if (!r) return null;
94
+
95
+ if (r.mode === 'agents-md') {
96
+ await mergeAgentsMdGuards(targetDir, r.content);
97
+ log.copy(`${r.path} (specpipe operating-rules section)`);
98
+ return { mode: 'agents-md', path: r.path };
99
+ }
100
+
101
+ const dst = join(targetDir, r.path);
102
+ if (existsSync(dst) && !force) {
103
+ try {
104
+ if ((await readFile(dst, 'utf-8')) === r.content) {
105
+ log.same(`${r.path} (identical)`);
106
+ return { mode: r.mode, path: r.path };
107
+ }
108
+ } catch { /* unreadable — treat as conflict */ }
109
+ log.warn(`${r.path} (exists with different content — use --force to overwrite)`);
110
+ return { mode: r.mode, path: r.path };
111
+ }
112
+ await mkdir(dirname(dst), { recursive: true });
113
+ await writeFile(dst, r.content);
114
+ log.copy(r.path);
115
+ return { mode: r.mode, path: r.path };
116
+ }
117
+
118
+ /**
119
+ * Install an agent's ENFORCED (blocking) hooks: the guard scripts + the agent's
120
+ * hook config file. Codex/Cursor only (verified payloads). Returns null otherwise.
121
+ * The hook config is specpipe-owned; if a different one already exists, we skip
122
+ * unless --force (don't clobber a user's hooks).
123
+ */
124
+ export async function installAgentHooks(agentId, targetDir, { force = false } = {}) {
125
+ const h = emitHooks(agentId);
126
+ if (!h) return null;
127
+
128
+ for (const { src, dst } of h.scripts) {
129
+ const content = await readFile(join(getTemplateDir(), src), 'utf-8');
130
+ const dstAbs = join(targetDir, dst);
131
+ await mkdir(dirname(dstAbs), { recursive: true });
132
+ await writeFile(dstAbs, content);
133
+ await chmod(dstAbs, 0o755);
134
+ log.copy(dst);
135
+ }
136
+
137
+ const cfgAbs = join(targetDir, h.configPath);
138
+ if (existsSync(cfgAbs) && !force) {
139
+ try {
140
+ if ((await readFile(cfgAbs, 'utf-8')) === h.configContent) {
141
+ log.same(`${h.configPath} (identical)`);
142
+ return { configPath: h.configPath };
143
+ }
144
+ } catch { /* unreadable */ }
145
+ log.warn(`${h.configPath} (exists — use --force to install specpipe enforced hooks)`);
146
+ return { configPath: h.configPath };
147
+ }
148
+ await mkdir(dirname(cfgAbs), { recursive: true });
149
+ await writeFile(cfgAbs, h.configContent);
150
+ log.copy(h.configPath);
151
+ return { configPath: h.configPath };
152
+ }
153
+
154
+ /** Remove an agent's enforced-hook scripts + config (+ empty hooks dir). */
155
+ export async function removeAgentHooks(agentId, targetDir) {
156
+ const h = emitHooks(agentId);
157
+ if (!h) return;
158
+ for (const { dst } of h.scripts) { try { await unlink(join(targetDir, dst)); } catch { /* */ } }
159
+ try { await unlink(join(targetDir, h.configPath)); } catch { /* */ }
160
+ try { await rmdir(join(targetDir, h.hooksDir)); } catch { /* not empty / missing */ }
161
+ }