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,280 @@
1
+ /**
2
+ * Agent registry + skill emitters.
3
+ *
4
+ * The canonical source of truth is the Claude-form skill:
5
+ * kit/skills/<skill>/SKILL.md (agent-neutral source; frontmatter: description [+ allowed-tools], body markdown)
6
+ *
7
+ * Each target agent gets its own emitter that rewrites the install path, the
8
+ * file name, and the frontmatter to that agent's native convention — while
9
+ * keeping the markdown body unchanged. Formats verified 2026-06-22 against
10
+ * each tool's docs / real repos; see docs/multi-agent.md for sources.
11
+ */
12
+
13
+ /**
14
+ * Parse a SKILL.md into ordered top-level frontmatter keys + body.
15
+ * Block scalars (description: |) and their indented continuation lines are
16
+ * kept attached to their key, so we can re-emit them verbatim.
17
+ */
18
+ export function parseSkill(content) {
19
+ const m = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
20
+ if (!m) return { keys: [], body: content, hasFrontmatter: false };
21
+
22
+ const lines = m[1].split('\n');
23
+ const keys = [];
24
+ let cur = null;
25
+ for (const line of lines) {
26
+ const km = line.match(/^([A-Za-z][\w-]*):(.*)$/);
27
+ if (km && !/^\s/.test(line)) {
28
+ cur = { key: km[1], lines: [line] };
29
+ keys.push(cur);
30
+ } else if (cur) {
31
+ cur.lines.push(line);
32
+ }
33
+ }
34
+ return { keys, body: m[2], hasFrontmatter: true };
35
+ }
36
+
37
+ function getKeyBlock(parsed, key) {
38
+ const k = parsed.keys.find((x) => x.key === key);
39
+ return k ? k.lines.join('\n') : null;
40
+ }
41
+
42
+ /** Wrap frontmatter + body back into a file. */
43
+ function compose(frontmatter, body) {
44
+ return `---\n${frontmatter}\n---\n${body}`;
45
+ }
46
+
47
+ /**
48
+ * Split a canonical skill relative path into its skill name + inner path.
49
+ * Canonical skills live in the agent-neutral `kit/skills/` (relative: `skills/`);
50
+ * each agent's emitter maps them to its own output location.
51
+ * 'skills/sp-plan/SKILL.md' -> { skill: 'sp-plan', inner: 'SKILL.md' }
52
+ * 'skills/sp-scaffold/references/x.md' -> { skill: 'sp-scaffold', inner: 'references/x.md' }
53
+ */
54
+ export function parseSkillPath(rel) {
55
+ const m = rel.replace(/\\/g, '/').match(/^skills\/([^/]+)\/(.+)$/);
56
+ if (!m) return null;
57
+ return { skill: m[1], inner: m[2] };
58
+ }
59
+
60
+ // ── Frontmatter emitters (per agent) ──────────────────────────────────────
61
+
62
+ /** Claude: identity — keep the canonical frontmatter exactly. */
63
+ function fmClaude(parsed) {
64
+ return parsed.keys.map((k) => k.lines.join('\n')).join('\n');
65
+ }
66
+
67
+ /** name + description only. allowed-tools (Claude-specific) is dropped. */
68
+ function fmNameDesc(parsed, name) {
69
+ const desc = getKeyBlock(parsed, 'description') || 'description: ""';
70
+ return `name: ${name}\n${desc}`;
71
+ }
72
+
73
+ /** Hermes adds version + a metadata.hermes.tags block. */
74
+ function fmHermes(parsed, name) {
75
+ const desc = getKeyBlock(parsed, 'description') || 'description: ""';
76
+ return [
77
+ `name: ${name}`,
78
+ desc,
79
+ 'version: 1.0.0',
80
+ 'metadata:',
81
+ ' hermes:',
82
+ ' tags: [specpipe, spec-first, tdd]',
83
+ ].join('\n');
84
+ }
85
+
86
+
87
+ // ── Agent registry ─────────────────────────────────────────────────────────
88
+
89
+ export const AGENTS = {
90
+ claude: {
91
+ label: 'Claude Code',
92
+ // Verified: code.claude.com/docs/en/skills
93
+ skillTarget: (name, inner) => `.claude/skills/${name}/${inner}`,
94
+ globalRoot: '.claude/skills',
95
+ skillFile: 'SKILL.md',
96
+ hooks: 'native',
97
+ capabilities: 'full',
98
+ emitFrontmatter: fmClaude,
99
+ },
100
+ // Antigravity + Codex share the vendor-neutral `.agents/skills/` standard, so they emit
101
+ // to the same path with identical frontmatter — computeDesired's Map dedups them (one
102
+ // emission serves the family). Intentional; `list` then shows that file under one agent.
103
+ antigravity: {
104
+ label: 'Antigravity',
105
+ // Verified: official Google Codelab — .agents/skills/<name>/SKILL.md
106
+ skillTarget: (name, inner) => `.agents/skills/${name}/${inner}`,
107
+ globalRoot: '.agents/skills',
108
+ skillFile: 'SKILL.md',
109
+ hooks: 'rules', // guards emitted to .agent/rules/ (plain markdown)
110
+ capabilities: 'router-no-hooks',
111
+ emitFrontmatter: fmNameDesc,
112
+ },
113
+ openclaw: {
114
+ label: 'OpenClaw',
115
+ // Verified: github.com/openclaw/openclaw skills/<name>/SKILL.md
116
+ skillTarget: (name, inner) => `skills/${name}/${inner}`,
117
+ globalRoot: 'skills',
118
+ skillFile: 'SKILL.md',
119
+ hooks: 'none',
120
+ capabilities: 'router-no-hooks',
121
+ emitFrontmatter: fmNameDesc,
122
+ },
123
+ hermes: {
124
+ label: 'Hermes-Agent',
125
+ // Verified: github.com/NousResearch/hermes-agent optional-skills/<cat>/<name>/SKILL.md
126
+ skillTarget: (name, inner) => `optional-skills/specpipe/${name}/${inner}`,
127
+ globalRoot: 'optional-skills/specpipe',
128
+ skillFile: 'SKILL.md',
129
+ hooks: 'none',
130
+ capabilities: 'router-no-hooks',
131
+ emitFrontmatter: fmHermes,
132
+ },
133
+ codex: {
134
+ label: 'OpenAI Codex CLI',
135
+ // Verified (developers.openai.com/codex/skills + openai/codex repo): Codex Agent
136
+ // Skills live in the vendor-neutral `.agents/skills/` (NOT `.codex/skills/`, which
137
+ // is a known non-working path — openai/codex#15136). Custom-prompts are deprecated.
138
+ skillTarget: (name, inner) => `.agents/skills/${name}/${inner}`,
139
+ globalRoot: '.agents/skills',
140
+ skillFile: 'SKILL.md',
141
+ hooks: 'agents-md', // guards fold into AGENTS.md (plain markdown, no frontmatter)
142
+ capabilities: 'router-no-hooks',
143
+ emitFrontmatter: fmNameDesc,
144
+ },
145
+ cursor: {
146
+ label: 'Cursor',
147
+ // Verified: cursor.com/help/customization/skills — Cursor has NATIVE skills at
148
+ // .cursor/skills/<name>/SKILL.md (also reads .claude/skills & .agents/skills).
149
+ // Skills are on-demand (/skill, @skill) — the correct home, not always-on .mdc rules.
150
+ // Guards stay an always-on .cursor/rules/*.mdc (see RULES.cursor).
151
+ skillTarget: (name, inner) => `.cursor/skills/${name}/${inner}`,
152
+ globalRoot: '.cursor/skills',
153
+ skillFile: 'SKILL.md',
154
+ hooks: 'rules', // advisory now; Cursor DOES support blocking hooks (.cursor/hooks.json) — roadmapped
155
+ capabilities: 'router-no-hooks',
156
+ emitFrontmatter: fmNameDesc,
157
+ },
158
+ };
159
+
160
+ export const AGENT_IDS = Object.keys(AGENTS);
161
+ export const DEFAULT_AGENT = 'claude';
162
+
163
+ /**
164
+ * Resolve a --agents string into a validated list of agent ids.
165
+ * 'all' -> every agent. Comma-separated -> those ids. Throws on unknown id.
166
+ */
167
+ export function resolveAgents(spec) {
168
+ if (!spec) return [DEFAULT_AGENT];
169
+ if (spec === 'all') return [...AGENT_IDS];
170
+ const ids = spec.split(',').map((s) => s.trim()).filter(Boolean);
171
+ const unknown = ids.filter((id) => !AGENTS[id]);
172
+ if (unknown.length) {
173
+ throw new Error(`Unknown agent(s): ${unknown.join(', ')}. Valid: ${AGENT_IDS.join(', ')}, all`);
174
+ }
175
+ return ids;
176
+ }
177
+
178
+ /**
179
+ * Emit one canonical skill file for a target agent.
180
+ * @param {string} agentId
181
+ * @param {string} canonicalRel - e.g. '.claude/skills/sp-plan/SKILL.md'
182
+ * @param {string} content - the canonical file content
183
+ * @returns {{ path: string, content: string } | null} target rel path + content, or null if not a skill file
184
+ */
185
+ export function emitSkillFile(agentId, canonicalRel, content) {
186
+ const agent = AGENTS[agentId];
187
+ if (!agent) throw new Error(`Unknown agent: ${agentId}`);
188
+
189
+ const parts = parseSkillPath(canonicalRel);
190
+ if (!parts) return null;
191
+ const { skill, inner } = parts;
192
+
193
+ const path = agent.skillTarget(skill, inner);
194
+
195
+ // Non-SKILL.md files (references, templates, examples) copy verbatim.
196
+ if (inner !== 'SKILL.md') return { path, content };
197
+
198
+ // Claude is identity — skip the parse/recompose round-trip entirely.
199
+ if (agentId === 'claude') return { path, content };
200
+
201
+ const parsed = parseSkill(content);
202
+ if (!parsed.hasFrontmatter) return { path, content };
203
+
204
+ const body = adaptBody(agentId, parsed.body, toolsOf(parsed));
205
+ return { path, content: compose(agent.emitFrontmatter(parsed, skill), body) };
206
+ }
207
+
208
+ /** Tools a canonical skill declares (from its `allowed-tools` frontmatter). */
209
+ function toolsOf(parsed) {
210
+ const block = getKeyBlock(parsed, 'allowed-tools');
211
+ if (!block) return [];
212
+ return block.replace(/^allowed-tools:\s*/, '').split(',').map((s) => s.trim()).filter(Boolean);
213
+ }
214
+
215
+ // Phrase-level rewrites that turn Claude's `AskUserQuestion` tool references into
216
+ // an explicit, mechanism-named instruction every conversational agent can follow:
217
+ // present one structured multiple-choice question in plain text and wait. Ordered
218
+ // most-specific first so the result stays grammatical (not a bare token swap).
219
+ const ASK = 'a single plain-text multiple-choice question';
220
+ const ASK_SUBS = [
221
+ [/go through the `?AskUserQuestion`? tool\s+—\s+never ask inline in text/gi, `be presented as ${ASK} (wait for the reply — don't bury choices in prose)`],
222
+ [/\bthe `?AskUserQuestion`? tool\b/gi, ASK],
223
+ [/\b(a single|one) `?AskUserQuestion`? call\b/gi, ASK],
224
+ [/`?AskUserQuestion`? call\b/gi, ASK],
225
+ [/`?AskUserQuestion`? format\b/gi, 'question format'],
226
+ [/\bEvery `?AskUserQuestion`?\b/g, 'Every question'],
227
+ [/\b(via|through|with|using) `?AskUserQuestion`?/gi, `$1 ${ASK}`],
228
+ [/\b[Uu]se `?AskUserQuestion`?/g, `ask ${ASK}`],
229
+ [/`?AskUserQuestion`?/g, ASK],
230
+ ];
231
+
232
+ function rewriteAsk(body) {
233
+ return ASK_SUBS.reduce((s, [re, to]) => s.replace(re, to), body);
234
+ }
235
+
236
+ /**
237
+ * Adapt a skill body for a non-Claude agent (Phase 3). Claude gets the body
238
+ * verbatim. For other agents:
239
+ * - AskUserQuestion references are rewritten in place into an explicit
240
+ * "plain-text multiple-choice question" instruction (mechanism named, so the
241
+ * agent knows exactly what to do — not vague prose).
242
+ * - Subagent orchestration can't be fixed by wording (it's an execution model),
243
+ * so it gets an honest caveat appended.
244
+ * - GraphAtlas already self-degrades in the body ("if GA available … else grep"),
245
+ * so it needs no adaptation.
246
+ */
247
+ function adaptBody(agentId, body, tools) {
248
+ if (agentId === 'claude') return body;
249
+
250
+ const has = (name) => tools.some((t) => t === name || t.startsWith(name));
251
+ let out = rewriteAsk(body);
252
+
253
+ if (has('Agent') || has('Task')) {
254
+ out = `${out.replace(/\s*$/, '')}\n\n---\n\n## Running on ${AGENTS[agentId].label}\n\n` +
255
+ '- **Subagents:** parts of this skill describe Claude subagent orchestration ' +
256
+ '(parallel waves, worktrees, auto-mode dispatch). If your runtime has no ' +
257
+ 'subagents, do that work yourself — one item at a time, sequentially, in this ' +
258
+ 'session — and skip the parallel/worktree mechanics.\n';
259
+ }
260
+ return out;
261
+ }
262
+
263
+ /**
264
+ * Emit ANY canonical template file for an agent.
265
+ * Skill files are transformed (see emitSkillFile); everything else (hooks,
266
+ * config, docs) is copied verbatim at its original relative path. This gives
267
+ * lifecycle commands a single way to reproduce a file's desired content.
268
+ * @returns {{ path: string, content: string }}
269
+ */
270
+ export function emitFile(agentId, templateRel, content) {
271
+ const skill = emitSkillFile(agentId, templateRel, content);
272
+ return skill || { path: templateRel, content };
273
+ }
274
+
275
+ // Guardrails (advisory rules) + enforced (blocking) hooks live in agent-guards.js;
276
+ // re-exported here so callers keep importing them from agents.js.
277
+ export {
278
+ GUARDS_BEGIN, GUARDS_END, HOOKS_SRC_DIR,
279
+ agentRulesMode, emitRules, agentHasHooks, emitHooks,
280
+ } from './agent-guards.js';
@@ -0,0 +1,183 @@
1
+ import { copyFile as fsCopyFile, mkdir, readFile, writeFile, chmod } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { log } from './logger.js';
6
+ import { getTemplateDir } from './installer.js';
7
+
8
+ // Claude's global install (~/.claude/skills, ~/.claude/hooks, ~/.claude/settings.json).
9
+ // Claude-only: it's Claude Code's own enforcement engine; other agents have no equivalent.
10
+
11
+ /** Global skills directory: ~/.claude/skills/ */
12
+ export function getGlobalSkillsDir() {
13
+ return join(homedir(), '.claude', 'skills');
14
+ }
15
+
16
+ /** Global hooks directory: ~/.claude/hooks/ */
17
+ export function getGlobalHooksDir() {
18
+ return join(homedir(), '.claude', 'hooks');
19
+ }
20
+
21
+ /**
22
+ * Copy a hook to the global ~/.claude/hooks/ directory.
23
+ * Strips the '.claude/hooks/' prefix so path-guard.sh lands at ~/.claude/hooks/path-guard.sh.
24
+ * @returns {{ result: 'copied'|'skipped'|'identical', kitHash: string }}
25
+ */
26
+ export async function installHookGlobal(hookRelPath, globalHooksDir, { force = false, globalFiles = {} } = {}) {
27
+ const stripped = hookRelPath.replace(/^\.claude\/hooks\//, '');
28
+ const src = join(getTemplateDir(), hookRelPath);
29
+ const dst = join(globalHooksDir, stripped);
30
+
31
+ const { hashFile } = await import('./hasher.js');
32
+ const srcHash = await hashFile(src);
33
+
34
+ if (existsSync(dst) && !force) {
35
+ try {
36
+ const dstHash = await hashFile(dst);
37
+ if (srcHash === dstHash) {
38
+ log.same(`~/.claude/hooks/${stripped} (identical)`);
39
+ return { result: 'identical', kitHash: srcHash };
40
+ }
41
+ const savedKitHash = globalFiles[hookRelPath]?.kitHash;
42
+ if (savedKitHash && dstHash === savedKitHash) {
43
+ // fall through to copy
44
+ } else {
45
+ log.skip(`~/.claude/hooks/${stripped} (customized — use --force to overwrite)`);
46
+ return { result: 'skipped', kitHash: srcHash };
47
+ }
48
+ } catch { /* hash failed */ }
49
+ }
50
+
51
+ await mkdir(dirname(dst), { recursive: true });
52
+ await fsCopyFile(src, dst);
53
+ await chmod(dst, 0o755);
54
+ log.copy(`~/.claude/hooks/${stripped}`);
55
+ return { result: 'copied', kitHash: srcHash };
56
+ }
57
+
58
+ /** Build hook entries for ~/.claude/settings.json pointing to globalHooksDir. */
59
+ function buildGlobalHookEntries(globalHooksDir) {
60
+ // Normalize to forward slashes — bash on all platforms (WSL, Git Bash, macOS, Linux)
61
+ // requires forward slashes even when the host OS is Windows.
62
+ const dir = globalHooksDir.replace(/\\/g, '/');
63
+ const h = (file) => `"${dir}/${file}"`;
64
+ return {
65
+ PreToolUse: [
66
+ { matcher: 'Bash', hooks: [
67
+ { type: 'command', command: `bash ${h('path-guard.sh')}` },
68
+ { type: 'command', command: `bash ${h('sensitive-guard.sh')}` },
69
+ ]},
70
+ { matcher: 'Read|Write|Edit|MultiEdit|Grep', hooks: [
71
+ { type: 'command', command: `bash ${h('sensitive-guard.sh')}` },
72
+ ]},
73
+ { matcher: 'Edit|MultiEdit', hooks: [
74
+ { type: 'command', command: `node ${h('comment-guard.js')}` },
75
+ ]},
76
+ { matcher: 'Glob', hooks: [
77
+ { type: 'command', command: `node ${h('glob-guard.js')}` },
78
+ ]},
79
+ ],
80
+ PostToolUse: [
81
+ { matcher: 'Write|Edit|MultiEdit', hooks: [
82
+ { type: 'command', command: `node ${h('file-guard.js')}` },
83
+ ]},
84
+ ],
85
+ Stop: [
86
+ { matcher: '', hooks: [
87
+ { type: 'command', command: `bash ${h('self-review.sh')}` },
88
+ ]},
89
+ ],
90
+ };
91
+ }
92
+
93
+ function isDevkitHookCommand(command) {
94
+ return command.includes('/.claude/hooks/');
95
+ }
96
+
97
+ function stripDevkitHooks(existingHooks) {
98
+ if (!existingHooks || typeof existingHooks !== 'object') return {};
99
+ const result = {};
100
+ for (const [event, matchers] of Object.entries(existingHooks)) {
101
+ if (!Array.isArray(matchers)) continue;
102
+ const kept = [];
103
+ for (const group of matchers) {
104
+ const keptHooks = (group.hooks || []).filter((h) => !isDevkitHookCommand(h.command || ''));
105
+ if (keptHooks.length > 0) kept.push({ ...group, hooks: keptHooks });
106
+ }
107
+ if (kept.length > 0) result[event] = kept;
108
+ }
109
+ return result;
110
+ }
111
+
112
+ /**
113
+ * Merge devkit hook registrations into ~/.claude/settings.json.
114
+ * Preserves any existing non-devkit hooks the user may have.
115
+ */
116
+ export async function mergeGlobalSettings(globalHooksDir) {
117
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
118
+ let existing = {};
119
+ try {
120
+ existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
121
+ } catch { /* file doesn't exist yet — start fresh */ }
122
+
123
+ const cleanedHooks = stripDevkitHooks(existing.hooks);
124
+ const newEntries = buildGlobalHookEntries(globalHooksDir);
125
+ const mergedHooks = { ...cleanedHooks };
126
+ for (const [event, entries] of Object.entries(newEntries)) {
127
+ mergedHooks[event] = [...(mergedHooks[event] || []), ...entries];
128
+ }
129
+
130
+ await mkdir(dirname(settingsPath), { recursive: true });
131
+ await writeFile(settingsPath, JSON.stringify({ ...existing, hooks: mergedHooks }, null, 2) + '\n');
132
+ }
133
+
134
+ /**
135
+ * Remove devkit hook registrations from ~/.claude/settings.json.
136
+ * Leaves any non-devkit hooks untouched.
137
+ */
138
+ export async function removeGlobalHooksFromSettings() {
139
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
140
+ let existing = {};
141
+ try {
142
+ existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
143
+ } catch { return; }
144
+
145
+ const cleanedHooks = stripDevkitHooks(existing.hooks || {});
146
+ await writeFile(settingsPath, JSON.stringify({ ...existing, hooks: cleanedHooks }, null, 2) + '\n');
147
+ }
148
+
149
+ /**
150
+ * Copy a skill to the global ~/.claude/skills/ directory.
151
+ * Strips the 'skills/' prefix so sp-plan/SKILL.md lands at ~/.claude/skills/sp-plan/SKILL.md.
152
+ * @returns {{ result: 'copied'|'skipped'|'identical', kitHash: string }}
153
+ */
154
+ export async function installSkillGlobal(skillRelPath, globalSkillsDir, { force = false, globalFiles = {} } = {}) {
155
+ const stripped = skillRelPath.replace(/^skills\//, '');
156
+ const src = join(getTemplateDir(), skillRelPath);
157
+ const dst = join(globalSkillsDir, stripped);
158
+
159
+ const { hashFile } = await import('./hasher.js');
160
+ const srcHash = await hashFile(src);
161
+
162
+ if (existsSync(dst) && !force) {
163
+ try {
164
+ const dstHash = await hashFile(dst);
165
+ if (srcHash === dstHash) {
166
+ log.same(`~/.claude/skills/${stripped} (identical)`);
167
+ return { result: 'identical', kitHash: srcHash };
168
+ }
169
+ const savedKitHash = globalFiles[skillRelPath]?.kitHash;
170
+ if (savedKitHash && dstHash === savedKitHash) {
171
+ // fall through to copy
172
+ } else {
173
+ log.skip(`~/.claude/skills/${stripped} (customized — use --force to overwrite)`);
174
+ return { result: 'skipped', kitHash: srcHash };
175
+ }
176
+ } catch { /* hash failed, treat as conflict */ }
177
+ }
178
+
179
+ await mkdir(dirname(dst), { recursive: true });
180
+ await fsCopyFile(src, dst);
181
+ log.copy(`~/.claude/skills/${stripped}`);
182
+ return { result: 'copied', kitHash: srcHash };
183
+ }
@@ -0,0 +1,93 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Auto-detect project type from marker files.
6
+ * @param {string} targetDir
7
+ * @returns {{ lang: string, framework: string, srcDir: string, testDir: string } | null}
8
+ */
9
+ export function detectProject(targetDir) {
10
+ const has = (file) => existsSync(join(targetDir, file));
11
+ const hasGlob = (ext) => {
12
+ try {
13
+ return readdirSync(targetDir).some((f) => f.endsWith(ext));
14
+ } catch {
15
+ return false;
16
+ }
17
+ };
18
+ const packageContains = (str) => {
19
+ try {
20
+ return readFileSync(join(targetDir, 'package.json'), 'utf-8').includes(str);
21
+ } catch {
22
+ return false;
23
+ }
24
+ };
25
+ const gemfileContains = (str) => {
26
+ try {
27
+ return readFileSync(join(targetDir, 'Gemfile'), 'utf-8').includes(str);
28
+ } catch {
29
+ return false;
30
+ }
31
+ };
32
+
33
+ // Swift (SPM)
34
+ if (has('Package.swift')) {
35
+ return { lang: 'Swift', framework: 'XCTest (SPM)', srcDir: 'Sources', testDir: 'Tests' };
36
+ }
37
+
38
+ // Swift (Xcode)
39
+ if (hasGlob('.xcworkspace') || hasGlob('.xcodeproj')) {
40
+ return { lang: 'Swift', framework: 'XCTest (Xcode)', srcDir: 'Sources', testDir: 'Tests' };
41
+ }
42
+
43
+ // Node.js / TypeScript
44
+ if (has('package.json')) {
45
+ let framework = 'npm test';
46
+ if (has('vitest.config.ts') || has('vitest.config.js') || has('vitest.config.mts') || packageContains('"vitest"')) {
47
+ framework = 'Vitest';
48
+ } else if (has('jest.config.ts') || has('jest.config.js') || has('jest.config.mjs') || packageContains('"jest"')) {
49
+ framework = 'Jest';
50
+ }
51
+ const testDir = existsSync(join(targetDir, '__tests__')) ? '__tests__' : 'tests';
52
+ return { lang: 'TypeScript/JavaScript', framework, srcDir: 'src', testDir };
53
+ }
54
+
55
+ // Python
56
+ if (has('pyproject.toml') || has('setup.py') || has('pytest.ini')) {
57
+ return { lang: 'Python', framework: 'pytest', srcDir: 'src', testDir: 'tests' };
58
+ }
59
+
60
+ // Rust
61
+ if (has('Cargo.toml')) {
62
+ return { lang: 'Rust', framework: 'cargo test', srcDir: 'src', testDir: 'tests' };
63
+ }
64
+
65
+ // Go
66
+ if (has('go.mod')) {
67
+ return { lang: 'Go', framework: 'go test', srcDir: '.', testDir: '.' };
68
+ }
69
+
70
+ // Java/Kotlin (Gradle)
71
+ if (has('build.gradle') || has('build.gradle.kts')) {
72
+ return { lang: 'Java/Kotlin', framework: 'Gradle', srcDir: 'src/main', testDir: 'src/test' };
73
+ }
74
+
75
+ // Java (Maven)
76
+ if (has('pom.xml')) {
77
+ return { lang: 'Java', framework: 'Maven', srcDir: 'src/main', testDir: 'src/test' };
78
+ }
79
+
80
+ // C# (.NET)
81
+ if (hasGlob('.sln')) {
82
+ return { lang: 'C#', framework: '.NET (dotnet test)', srcDir: 'src', testDir: 'tests' };
83
+ }
84
+
85
+ // Ruby
86
+ if (has('Gemfile')) {
87
+ const framework = gemfileContains('rspec') ? 'RSpec' : 'Minitest';
88
+ const testDir = framework === 'RSpec' ? 'spec' : 'test';
89
+ return { lang: 'Ruby', framework, srcDir: 'lib', testDir };
90
+ }
91
+
92
+ return null;
93
+ }
@@ -0,0 +1,21 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+
4
+ /**
5
+ * Compute SHA-256 hash of a file.
6
+ * @param {string} filePath
7
+ * @returns {Promise<string>} hex digest
8
+ */
9
+ export async function hashFile(filePath) {
10
+ const content = await readFile(filePath);
11
+ return createHash('sha256').update(content).digest('hex');
12
+ }
13
+
14
+ /**
15
+ * Compute SHA-256 hash of a string/buffer.
16
+ * @param {string|Buffer} content
17
+ * @returns {string} hex digest
18
+ */
19
+ export function hashContent(content) {
20
+ return createHash('sha256').update(content).digest('hex');
21
+ }