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
package/src/lib/agent-install.js
CHANGED
|
@@ -2,8 +2,8 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { mkdir, readFile, writeFile, unlink, chmod, rmdir } from 'node:fs/promises';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { log } from './logger.js';
|
|
5
|
-
import { getTemplateDir, COMPONENTS } from './installer.js';
|
|
6
|
-
import { emitSkillFile, emitRules, emitHooks, AGENTS,
|
|
5
|
+
import { getTemplateDir, COMPONENTS, skillAllowed } from './installer.js';
|
|
6
|
+
import { emitSkillFile, emitRules, emitHooks, AGENTS, RULES_BEGIN, RULES_END } from './agents.js';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Emit one canonical skill file for a target agent and write it to targetDir.
|
|
@@ -38,10 +38,16 @@ export async function installSkillForAgent(agentId, canonicalRel, targetDir, { f
|
|
|
38
38
|
* Install the full skill set for one agent into targetDir.
|
|
39
39
|
* @returns {{ agent: string, copied: number, skipped: number, identical: number, paths: string[] }}
|
|
40
40
|
*/
|
|
41
|
-
export async function installAgentSkills(agentId, targetDir, { force = false } = {}) {
|
|
41
|
+
export async function installAgentSkills(agentId, targetDir, { force = false, skills = null } = {}) {
|
|
42
42
|
let copied = 0, skipped = 0, identical = 0;
|
|
43
43
|
const paths = [];
|
|
44
|
+
// Hermes (and any perProjectSkills:false agent) reads skills only from its global dir,
|
|
45
|
+
// never the project — emitting per-project skill files here would be dead. Skip them.
|
|
46
|
+
if (AGENTS[agentId]?.perProjectSkills === false) {
|
|
47
|
+
return { agent: agentId, label: AGENTS[agentId].label, copied, skipped, identical, paths };
|
|
48
|
+
}
|
|
44
49
|
for (const relPath of COMPONENTS.skills) {
|
|
50
|
+
if (!skillAllowed(relPath, skills)) continue;
|
|
45
51
|
const { result, path } = await installSkillForAgent(agentId, relPath, targetDir, { force });
|
|
46
52
|
if (result === 'copied') copied++;
|
|
47
53
|
else if (result === 'identical') identical++;
|
|
@@ -51,31 +57,32 @@ export async function installAgentSkills(agentId, targetDir, { force = false } =
|
|
|
51
57
|
return { agent: agentId, label: AGENTS[agentId].label, copied, skipped, identical, paths };
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
const
|
|
60
|
+
const RULES_TEMPLATE_REL = 'rules/specpipe-rules.md';
|
|
55
61
|
|
|
56
|
-
function
|
|
62
|
+
function rulesSectionRegex() {
|
|
57
63
|
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
58
|
-
return new RegExp(esc(
|
|
64
|
+
return new RegExp(esc(RULES_BEGIN) + '[\\s\\S]*?' + esc(RULES_END) + '\\n?', '');
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
/** Merge (or replace) the specpipe
|
|
62
|
-
export async function
|
|
63
|
-
const p = join(targetDir,
|
|
67
|
+
/** Merge (or replace) the specpipe rules section in a shared file (CLAUDE.md / AGENTS.md). */
|
|
68
|
+
export async function mergeRulesSection(targetDir, fileRel, section) {
|
|
69
|
+
const p = join(targetDir, fileRel);
|
|
64
70
|
let existing = '';
|
|
65
71
|
try { existing = await readFile(p, 'utf-8'); } catch { /* new file */ }
|
|
66
|
-
const re =
|
|
72
|
+
const re = rulesSectionRegex();
|
|
67
73
|
existing = re.test(existing)
|
|
68
74
|
? existing.replace(re, section)
|
|
69
75
|
: (existing.trim() ? existing.trimEnd() + '\n\n' : '') + section;
|
|
76
|
+
await mkdir(dirname(p), { recursive: true });
|
|
70
77
|
await writeFile(p, existing);
|
|
71
78
|
}
|
|
72
79
|
|
|
73
|
-
/** Remove the specpipe
|
|
74
|
-
export async function
|
|
75
|
-
const p = join(targetDir,
|
|
80
|
+
/** Remove the specpipe rules section from a shared file (deletes it if now empty). */
|
|
81
|
+
export async function stripRulesSection(targetDir, fileRel) {
|
|
82
|
+
const p = join(targetDir, fileRel);
|
|
76
83
|
let existing;
|
|
77
84
|
try { existing = await readFile(p, 'utf-8'); } catch { return false; }
|
|
78
|
-
const stripped = existing.replace(
|
|
85
|
+
const stripped = existing.replace(rulesSectionRegex(), '').trim();
|
|
79
86
|
if (stripped === existing.trim()) return false;
|
|
80
87
|
if (stripped) await writeFile(p, stripped + '\n');
|
|
81
88
|
else await unlink(p);
|
|
@@ -83,19 +90,19 @@ export async function stripAgentsMdGuards(targetDir) {
|
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
/**
|
|
86
|
-
* Install an agent's guardrails:
|
|
87
|
-
*
|
|
88
|
-
*
|
|
93
|
+
* Install an agent's guardrails: a merged section in a shared file (Claude →
|
|
94
|
+
* .claude/CLAUDE.md, Codex → AGENTS.md) or an owned rules file (Cursor/
|
|
95
|
+
* Antigravity/OpenClaw/Hermes). Returns null only if the agent has no rules entry.
|
|
89
96
|
*/
|
|
90
97
|
export async function installAgentRules(agentId, targetDir, { force = false } = {}) {
|
|
91
|
-
const body = await readFile(join(getTemplateDir(),
|
|
98
|
+
const body = await readFile(join(getTemplateDir(), RULES_TEMPLATE_REL), 'utf-8');
|
|
92
99
|
const r = emitRules(agentId, body);
|
|
93
100
|
if (!r) return null;
|
|
94
101
|
|
|
95
|
-
if (r.mode === '
|
|
96
|
-
await
|
|
102
|
+
if (r.mode === 'merge') {
|
|
103
|
+
await mergeRulesSection(targetDir, r.path, r.content);
|
|
97
104
|
log.copy(`${r.path} (specpipe operating-rules section)`);
|
|
98
|
-
return { mode: '
|
|
105
|
+
return { mode: 'merge', path: r.path };
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
const dst = join(targetDir, r.path);
|
|
@@ -117,12 +124,13 @@ export async function installAgentRules(agentId, targetDir, { force = false } =
|
|
|
117
124
|
|
|
118
125
|
/**
|
|
119
126
|
* Install an agent's ENFORCED (blocking) hooks: the guard scripts + the agent's
|
|
120
|
-
* hook config file.
|
|
127
|
+
* hook config file. Agents with a verified hook config (Claude/Codex/Cursor/
|
|
128
|
+
* Antigravity); returns null for agents without one.
|
|
121
129
|
* The hook config is specpipe-owned; if a different one already exists, we skip
|
|
122
130
|
* unless --force (don't clobber a user's hooks).
|
|
123
131
|
*/
|
|
124
|
-
export async function installAgentHooks(agentId, targetDir, { force = false } = {}) {
|
|
125
|
-
const h = emitHooks(agentId);
|
|
132
|
+
export async function installAgentHooks(agentId, targetDir, { force = false, hooks = null } = {}) {
|
|
133
|
+
const h = emitHooks(agentId, hooks);
|
|
126
134
|
if (!h) return null;
|
|
127
135
|
|
|
128
136
|
for (const { src, dst } of h.scripts) {
|
|
@@ -151,11 +159,16 @@ export async function installAgentHooks(agentId, targetDir, { force = false } =
|
|
|
151
159
|
return { configPath: h.configPath };
|
|
152
160
|
}
|
|
153
161
|
|
|
154
|
-
/** Remove an agent's enforced-hook scripts + config (+ empty hooks
|
|
162
|
+
/** Remove an agent's enforced-hook scripts + config (+ empty hooks/agent dirs). */
|
|
155
163
|
export async function removeAgentHooks(agentId, targetDir) {
|
|
156
164
|
const h = emitHooks(agentId);
|
|
157
165
|
if (!h) return;
|
|
158
166
|
for (const { dst } of h.scripts) { try { await unlink(join(targetDir, dst)); } catch { /* */ } }
|
|
159
167
|
try { await unlink(join(targetDir, h.configPath)); } catch { /* */ }
|
|
160
168
|
try { await rmdir(join(targetDir, h.hooksDir)); } catch { /* not empty / missing */ }
|
|
169
|
+
// Tidy the agent's container dir too (e.g. .codex/, .agents/) — its only specpipe
|
|
170
|
+
// content was the hook config + hooks/. rmdir is a no-op when the dir still holds
|
|
171
|
+
// other content (.claude/CLAUDE.md, .cursor/rules/, .agents/skills/ for a kept agent).
|
|
172
|
+
const agentDir = dirname(h.configPath);
|
|
173
|
+
if (agentDir && agentDir !== '.') { try { await rmdir(join(targetDir, agentDir)); } catch { /* not empty / missing */ } }
|
|
161
174
|
}
|
package/src/lib/agents.js
CHANGED
|
@@ -91,7 +91,8 @@ export const AGENTS = {
|
|
|
91
91
|
label: 'Claude Code',
|
|
92
92
|
// Verified: code.claude.com/docs/en/skills
|
|
93
93
|
skillTarget: (name, inner) => `.claude/skills/${name}/${inner}`,
|
|
94
|
-
|
|
94
|
+
// Global (user-level) skills dir, relative to home. Verified: ~/.claude/skills/.
|
|
95
|
+
globalSkillRoot: '.claude/skills',
|
|
95
96
|
skillFile: 'SKILL.md',
|
|
96
97
|
hooks: 'native',
|
|
97
98
|
capabilities: 'full',
|
|
@@ -104,9 +105,12 @@ export const AGENTS = {
|
|
|
104
105
|
label: 'Antigravity',
|
|
105
106
|
// Verified: official Google Codelab — .agents/skills/<name>/SKILL.md
|
|
106
107
|
skillTarget: (name, inner) => `.agents/skills/${name}/${inner}`,
|
|
107
|
-
|
|
108
|
+
// Global differs from the project path: Antigravity CLI reads ~/.gemini/antigravity-cli/skills/
|
|
109
|
+
// (the IDE uses ~/.gemini/config/skills/ and also reads ~/.agents/skills/). Verified: Google
|
|
110
|
+
// Antigravity codelab. We target the CLI's global dir.
|
|
111
|
+
globalSkillRoot: '.gemini/antigravity-cli/skills',
|
|
108
112
|
skillFile: 'SKILL.md',
|
|
109
|
-
hooks: 'rules', // guards emitted to .
|
|
113
|
+
hooks: 'rules', // guards emitted to .agents/rules/ (plain markdown)
|
|
110
114
|
capabilities: 'router-no-hooks',
|
|
111
115
|
emitFrontmatter: fmNameDesc,
|
|
112
116
|
},
|
|
@@ -114,7 +118,9 @@ export const AGENTS = {
|
|
|
114
118
|
label: 'OpenClaw',
|
|
115
119
|
// Verified: github.com/openclaw/openclaw skills/<name>/SKILL.md
|
|
116
120
|
skillTarget: (name, inner) => `skills/${name}/${inner}`,
|
|
117
|
-
|
|
121
|
+
// Verified (docs.openclaw.ai): global skills live in ~/.openclaw/skills/ (the `--global`
|
|
122
|
+
// target; OpenClaw also reads ~/.agents/skills/).
|
|
123
|
+
globalSkillRoot: '.openclaw/skills',
|
|
118
124
|
skillFile: 'SKILL.md',
|
|
119
125
|
hooks: 'none',
|
|
120
126
|
capabilities: 'router-no-hooks',
|
|
@@ -122,9 +128,14 @@ export const AGENTS = {
|
|
|
122
128
|
},
|
|
123
129
|
hermes: {
|
|
124
130
|
label: 'Hermes-Agent',
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
131
|
+
// Hermes discovers skills ONLY from ~/.hermes/skills/ ("the primary directory and source
|
|
132
|
+
// of truth") + explicitly-configured external_dirs — it does NOT scan the project/workspace.
|
|
133
|
+
// So per-project skill files are dead (never read); Hermes is global-only for skills. The
|
|
134
|
+
// skillTarget is still used to shape the GLOBAL emission (globalSkillRoot + name + inner).
|
|
135
|
+
// Source: hermes-agent.nousresearch.com/docs/user-guide/features/skills
|
|
136
|
+
skillTarget: (name, inner) => `${name}/${inner}`,
|
|
137
|
+
perProjectSkills: false,
|
|
138
|
+
globalSkillRoot: '.hermes/skills',
|
|
128
139
|
skillFile: 'SKILL.md',
|
|
129
140
|
hooks: 'none',
|
|
130
141
|
capabilities: 'router-no-hooks',
|
|
@@ -136,7 +147,10 @@ export const AGENTS = {
|
|
|
136
147
|
// Skills live in the vendor-neutral `.agents/skills/` (NOT `.codex/skills/`, which
|
|
137
148
|
// is a known non-working path — openai/codex#15136). Custom-prompts are deprecated.
|
|
138
149
|
skillTarget: (name, inner) => `.agents/skills/${name}/${inner}`,
|
|
139
|
-
|
|
150
|
+
// Global differs from the project path: Codex reads user skills from ~/.codex/skills/
|
|
151
|
+
// (ships system skills in ~/.codex/skills/.system), NOT ~/.agents/skills/. Verified:
|
|
152
|
+
// developers.openai.com/codex/skills.
|
|
153
|
+
globalSkillRoot: '.codex/skills',
|
|
140
154
|
skillFile: 'SKILL.md',
|
|
141
155
|
hooks: 'agents-md', // guards fold into AGENTS.md (plain markdown, no frontmatter)
|
|
142
156
|
capabilities: 'router-no-hooks',
|
|
@@ -149,7 +163,10 @@ export const AGENTS = {
|
|
|
149
163
|
// Skills are on-demand (/skill, @skill) — the correct home, not always-on .mdc rules.
|
|
150
164
|
// Guards stay an always-on .cursor/rules/*.mdc (see RULES.cursor).
|
|
151
165
|
skillTarget: (name, inner) => `.cursor/skills/${name}/${inner}`,
|
|
152
|
-
|
|
166
|
+
// Cursor reads a user-level skills dir at ~/.cursor/skills/ (verified: cursor.com/docs/skills
|
|
167
|
+
// — it loads .cursor/skills, .agents/skills, ~/.cursor/skills, ~/.agents/skills, plus
|
|
168
|
+
// .claude/.codex for compat). So `--global` installs Cursor's own global skills here.
|
|
169
|
+
globalSkillRoot: '.cursor/skills',
|
|
153
170
|
skillFile: 'SKILL.md',
|
|
154
171
|
hooks: 'rules', // advisory now; Cursor DOES support blocking hooks (.cursor/hooks.json) — roadmapped
|
|
155
172
|
capabilities: 'router-no-hooks',
|
|
@@ -205,6 +222,26 @@ export function emitSkillFile(agentId, canonicalRel, content) {
|
|
|
205
222
|
return { path, content: compose(agent.emitFrontmatter(parsed, skill), body) };
|
|
206
223
|
}
|
|
207
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Emit one skill file for an agent's GLOBAL (user-level) install. Same per-agent
|
|
227
|
+
* content transformation as emitSkillFile, but the returned path is rooted at the
|
|
228
|
+
* agent's home-relative globalSkillRoot — which differs from the project skillTarget
|
|
229
|
+
* for Codex (~/.codex/skills), Antigravity (~/.gemini/antigravity-cli/skills), etc.
|
|
230
|
+
* Returns null when the agent has no native global skills dir (Cursor) or the input
|
|
231
|
+
* isn't a skill file.
|
|
232
|
+
* @returns {{ path: string, content: string } | null} path is relative to the user's home dir
|
|
233
|
+
*/
|
|
234
|
+
export function emitSkillFileGlobal(agentId, canonicalRel, content) {
|
|
235
|
+
const agent = AGENTS[agentId];
|
|
236
|
+
if (!agent) throw new Error(`Unknown agent: ${agentId}`);
|
|
237
|
+
if (!agent.globalSkillRoot) return null;
|
|
238
|
+
const parts = parseSkillPath(canonicalRel);
|
|
239
|
+
if (!parts) return null;
|
|
240
|
+
const emitted = emitSkillFile(agentId, canonicalRel, content);
|
|
241
|
+
if (!emitted) return null;
|
|
242
|
+
return { path: `${agent.globalSkillRoot}/${parts.skill}/${parts.inner}`, content: emitted.content };
|
|
243
|
+
}
|
|
244
|
+
|
|
208
245
|
/** Tools a canonical skill declares (from its `allowed-tools` frontmatter). */
|
|
209
246
|
function toolsOf(parsed) {
|
|
210
247
|
const block = getKeyBlock(parsed, 'allowed-tools');
|
|
@@ -251,7 +288,12 @@ function adaptBody(agentId, body, tools) {
|
|
|
251
288
|
let out = rewriteAsk(body);
|
|
252
289
|
|
|
253
290
|
if (has('Agent') || has('Task')) {
|
|
254
|
-
|
|
291
|
+
// Header is agent-NEUTRAL on purpose: codex and antigravity share the same
|
|
292
|
+
// emit path (.agents/skills/<name>/), so the caveat must be byte-identical
|
|
293
|
+
// between them or computeDesired's Map sees divergent content and a clean
|
|
294
|
+
// install false-flags the file as "customized". The caveat text is already
|
|
295
|
+
// runtime-agnostic, so a fixed heading loses nothing.
|
|
296
|
+
out = `${out.replace(/\s*$/, '')}\n\n---\n\n## Running outside Claude Code\n\n` +
|
|
255
297
|
'- **Subagents:** parts of this skill describe Claude subagent orchestration ' +
|
|
256
298
|
'(parallel waves, worktrees, auto-mode dispatch). If your runtime has no ' +
|
|
257
299
|
'subagents, do that work yourself — one item at a time, sequentially, in this ' +
|
|
@@ -275,6 +317,6 @@ export function emitFile(agentId, templateRel, content) {
|
|
|
275
317
|
// Guardrails (advisory rules) + enforced (blocking) hooks live in agent-guards.js;
|
|
276
318
|
// re-exported here so callers keep importing them from agents.js.
|
|
277
319
|
export {
|
|
278
|
-
|
|
320
|
+
RULES_BEGIN, RULES_END, HOOKS_SRC_DIR,
|
|
279
321
|
agentRulesMode, emitRules, agentHasHooks, emitHooks,
|
|
280
322
|
} from './agent-guards.js';
|
package/src/lib/claude-global.js
CHANGED
|
@@ -4,14 +4,12 @@ import { join, dirname } from 'node:path';
|
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { log } from './logger.js';
|
|
6
6
|
import { getTemplateDir } from './installer.js';
|
|
7
|
+
import { emitSkillFileGlobal } from './agents.js';
|
|
8
|
+
import { buildHookConfig } from './hooks.js';
|
|
9
|
+
import { hashContent } from './hasher.js';
|
|
7
10
|
|
|
8
|
-
//
|
|
9
|
-
// Claude-only
|
|
10
|
-
|
|
11
|
-
/** Global skills directory: ~/.claude/skills/ */
|
|
12
|
-
export function getGlobalSkillsDir() {
|
|
13
|
-
return join(homedir(), '.claude', 'skills');
|
|
14
|
-
}
|
|
11
|
+
// Global install. Skills install per-agent via installSkillGlobalForAgent (below);
|
|
12
|
+
// hooks + settings stay Claude-only (Claude Code's native enforcement engine).
|
|
15
13
|
|
|
16
14
|
/** Global hooks directory: ~/.claude/hooks/ */
|
|
17
15
|
export function getGlobalHooksDir() {
|
|
@@ -19,14 +17,14 @@ export function getGlobalHooksDir() {
|
|
|
19
17
|
}
|
|
20
18
|
|
|
21
19
|
/**
|
|
22
|
-
* Copy
|
|
23
|
-
*
|
|
20
|
+
* Copy one guard script (kit-relative src, e.g. 'hooks/specpipe-shell-guard.sh') into
|
|
21
|
+
* the global ~/.claude/hooks/ dir. `key` is the home-relative manifest key.
|
|
24
22
|
* @returns {{ result: 'copied'|'skipped'|'identical', kitHash: string }}
|
|
25
23
|
*/
|
|
26
|
-
export async function installHookGlobal(
|
|
27
|
-
const
|
|
28
|
-
const src = join(getTemplateDir(),
|
|
29
|
-
const dst = join(globalHooksDir,
|
|
24
|
+
export async function installHookGlobal(srcRel, globalHooksDir, { force = false, globalFiles = {}, key } = {}) {
|
|
25
|
+
const base = srcRel.split('/').pop();
|
|
26
|
+
const src = join(getTemplateDir(), srcRel);
|
|
27
|
+
const dst = join(globalHooksDir, base);
|
|
30
28
|
|
|
31
29
|
const { hashFile } = await import('./hasher.js');
|
|
32
30
|
const srcHash = await hashFile(src);
|
|
@@ -35,14 +33,15 @@ export async function installHookGlobal(hookRelPath, globalHooksDir, { force = f
|
|
|
35
33
|
try {
|
|
36
34
|
const dstHash = await hashFile(dst);
|
|
37
35
|
if (srcHash === dstHash) {
|
|
38
|
-
log.same(`~/.claude/hooks/${
|
|
36
|
+
log.same(`~/.claude/hooks/${base} (identical)`);
|
|
39
37
|
return { result: 'identical', kitHash: srcHash };
|
|
40
38
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
// Overwrite only when the on-disk file is one WE wrote (matches the kit hash
|
|
40
|
+
// recorded in the manifest) — i.e. a stale specpipe version, safe to update.
|
|
41
|
+
// Otherwise the user changed it (or we never tracked it) → preserve.
|
|
42
|
+
const savedKitHash = globalFiles[key]?.kitHash;
|
|
43
|
+
if (!(savedKitHash && dstHash === savedKitHash)) {
|
|
44
|
+
log.skip(`~/.claude/hooks/${base} (customized — use --force to overwrite)`);
|
|
46
45
|
return { result: 'skipped', kitHash: srcHash };
|
|
47
46
|
}
|
|
48
47
|
} catch { /* hash failed */ }
|
|
@@ -51,43 +50,15 @@ export async function installHookGlobal(hookRelPath, globalHooksDir, { force = f
|
|
|
51
50
|
await mkdir(dirname(dst), { recursive: true });
|
|
52
51
|
await fsCopyFile(src, dst);
|
|
53
52
|
await chmod(dst, 0o755);
|
|
54
|
-
log.copy(`~/.claude/hooks/${
|
|
53
|
+
log.copy(`~/.claude/hooks/${base}`);
|
|
55
54
|
return { result: 'copied', kitHash: srcHash };
|
|
56
55
|
}
|
|
57
56
|
|
|
58
|
-
/**
|
|
59
|
-
function buildGlobalHookEntries(globalHooksDir) {
|
|
60
|
-
//
|
|
61
|
-
// requires forward slashes even when the host OS is Windows.
|
|
57
|
+
/** Claude settings.json hook entries pointing to the absolute global hooks dir. */
|
|
58
|
+
function buildGlobalHookEntries(globalHooksDir, hooksSet) {
|
|
59
|
+
// Forward slashes — bash needs them on every host (WSL, Git Bash, macOS, Linux).
|
|
62
60
|
const dir = globalHooksDir.replace(/\\/g, '/');
|
|
63
|
-
|
|
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
|
-
};
|
|
61
|
+
return buildHookConfig('claude', dir, hooksSet)?.hooks || {};
|
|
91
62
|
}
|
|
92
63
|
|
|
93
64
|
function isDevkitHookCommand(command) {
|
|
@@ -113,7 +84,7 @@ function stripDevkitHooks(existingHooks) {
|
|
|
113
84
|
* Merge devkit hook registrations into ~/.claude/settings.json.
|
|
114
85
|
* Preserves any existing non-devkit hooks the user may have.
|
|
115
86
|
*/
|
|
116
|
-
export async function mergeGlobalSettings(globalHooksDir) {
|
|
87
|
+
export async function mergeGlobalSettings(globalHooksDir, hooksSet = null) {
|
|
117
88
|
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
|
118
89
|
let existing = {};
|
|
119
90
|
try {
|
|
@@ -121,7 +92,7 @@ export async function mergeGlobalSettings(globalHooksDir) {
|
|
|
121
92
|
} catch { /* file doesn't exist yet — start fresh */ }
|
|
122
93
|
|
|
123
94
|
const cleanedHooks = stripDevkitHooks(existing.hooks);
|
|
124
|
-
const newEntries = buildGlobalHookEntries(globalHooksDir);
|
|
95
|
+
const newEntries = buildGlobalHookEntries(globalHooksDir, hooksSet);
|
|
125
96
|
const mergedHooks = { ...cleanedHooks };
|
|
126
97
|
for (const [event, entries] of Object.entries(newEntries)) {
|
|
127
98
|
mergedHooks[event] = [...(mergedHooks[event] || []), ...entries];
|
|
@@ -147,37 +118,44 @@ export async function removeGlobalHooksFromSettings() {
|
|
|
147
118
|
}
|
|
148
119
|
|
|
149
120
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
121
|
+
* Install one skill file into an agent's GLOBAL (user-level) dir, with the agent's
|
|
122
|
+
* own content transformation (frontmatter, AskUserQuestion rewrite, subagent caveat).
|
|
123
|
+
* Works for every agent with a globalSkillRoot — Claude emits identity content, others
|
|
124
|
+
* get their own frontmatter. Idempotency is keyed on the EMITTED content (which differs
|
|
125
|
+
* from the kit source for non-Claude agents). The manifest key is the home-relative
|
|
126
|
+
* emitted path, unique per agent.
|
|
127
|
+
* @returns {{ result: 'copied'|'skipped'|'identical', kitHash: string, key: string } | null}
|
|
128
|
+
* null when the agent has no global dir (Cursor) or the path isn't a skill file.
|
|
153
129
|
*/
|
|
154
|
-
export async function
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
|
|
130
|
+
export async function installSkillGlobalForAgent(agentId, skillRelPath, { force = false, globalFiles = {} } = {}) {
|
|
131
|
+
const srcContent = await readFile(join(getTemplateDir(), skillRelPath), 'utf-8');
|
|
132
|
+
const emitted = emitSkillFileGlobal(agentId, skillRelPath, srcContent);
|
|
133
|
+
if (!emitted) return null;
|
|
158
134
|
|
|
159
|
-
const
|
|
160
|
-
const
|
|
135
|
+
const dst = join(homedir(), ...emitted.path.split('/'));
|
|
136
|
+
const display = `~/${emitted.path}`;
|
|
137
|
+
const key = emitted.path;
|
|
138
|
+
const srcHash = hashContent(emitted.content);
|
|
161
139
|
|
|
162
140
|
if (existsSync(dst) && !force) {
|
|
163
141
|
try {
|
|
164
|
-
const dstHash = await
|
|
165
|
-
if (
|
|
166
|
-
log.same(
|
|
167
|
-
return { result: 'identical', kitHash: srcHash };
|
|
142
|
+
const dstHash = hashContent(await readFile(dst, 'utf-8'));
|
|
143
|
+
if (dstHash === srcHash) {
|
|
144
|
+
log.same(`${display} (identical)`);
|
|
145
|
+
return { result: 'identical', kitHash: srcHash, key };
|
|
168
146
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
log.skip(
|
|
174
|
-
return { result: 'skipped', kitHash: srcHash };
|
|
147
|
+
// Overwrite only a stale version we wrote (disk matches the recorded kit hash);
|
|
148
|
+
// otherwise the user customized it (or it's untracked) → preserve.
|
|
149
|
+
const savedKitHash = globalFiles[key]?.kitHash;
|
|
150
|
+
if (!(savedKitHash && dstHash === savedKitHash)) {
|
|
151
|
+
log.skip(`${display} (customized — use --force to overwrite)`);
|
|
152
|
+
return { result: 'skipped', kitHash: srcHash, key };
|
|
175
153
|
}
|
|
176
|
-
} catch { /* hash failed, treat as conflict */ }
|
|
154
|
+
} catch { /* hash failed, treat as conflict → overwrite below */ }
|
|
177
155
|
}
|
|
178
156
|
|
|
179
157
|
await mkdir(dirname(dst), { recursive: true });
|
|
180
|
-
await
|
|
181
|
-
log.copy(
|
|
182
|
-
return { result: 'copied', kitHash: srcHash };
|
|
158
|
+
await writeFile(dst, emitted.content);
|
|
159
|
+
log.copy(display);
|
|
160
|
+
return { result: 'copied', kitHash: srcHash, key };
|
|
183
161
|
}
|