specpipe 1.0.2 → 1.0.4
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specpipe",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Spec-first development toolkit for agentic AI coding agents — installs skills + guardrails for Claude Code, Codex, Cursor, Antigravity, and more.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"specpipe": "./bin/devkit.js",
|
|
@@ -9,7 +9,7 @@ import { hashContent } from '../lib/hasher.js';
|
|
|
9
9
|
import {
|
|
10
10
|
COMPONENTS,
|
|
11
11
|
fillTemplate, installAgentSkills, installAgentRules, installAgentHooks,
|
|
12
|
-
resolveSkills,
|
|
12
|
+
resolveSkills, pruneOrphans,
|
|
13
13
|
} from '../lib/installer.js';
|
|
14
14
|
import { resolveAgents, AGENTS, agentHasHooks } from '../lib/agents.js';
|
|
15
15
|
import { resolveHooks } from '../lib/hooks.js';
|
|
@@ -102,6 +102,14 @@ export async function initMultiAgent(targetDir, opts, warnings = 0) {
|
|
|
102
102
|
setFileEntry(manifest, relPath, d.kitHash, installedHash, { agent: d.agent, templateRel: d.templateRel });
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// Migration: prune predecessor files (mf-* / ap-*, renamed hooks) a prior manifest
|
|
106
|
+
// tracked but this install no longer wants — only manifest-tracked paths, so a
|
|
107
|
+
// user's own files are never touched.
|
|
108
|
+
if (existing?.files) {
|
|
109
|
+
const n = await pruneOrphans(targetDir, existing.files, new Set(Object.keys(manifest.files)));
|
|
110
|
+
if (n) log.info(`Migrated: removed ${n} superseded file(s) from a previous version.`);
|
|
111
|
+
}
|
|
112
|
+
|
|
105
113
|
await writeManifest(targetDir, manifest);
|
|
106
114
|
|
|
107
115
|
// Summary
|
|
@@ -15,6 +15,14 @@ import { hookScriptsFor } from '../lib/hooks.js';
|
|
|
15
15
|
|
|
16
16
|
const GLOBAL_MANIFEST = join(homedir(), '.claude', '.devkit-manifest.json');
|
|
17
17
|
|
|
18
|
+
/** Which agent a home-relative global key belongs to, by its skill-root prefix. */
|
|
19
|
+
function ownerFromGlobalKey(key) {
|
|
20
|
+
for (const [id, a] of Object.entries(AGENTS)) {
|
|
21
|
+
if (a.globalSkillRoot && key.startsWith(a.globalSkillRoot + '/')) return id;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
export async function readGlobalManifest() {
|
|
19
27
|
try {
|
|
20
28
|
return JSON.parse(await readFile(GLOBAL_MANIFEST, 'utf-8'));
|
|
@@ -68,8 +76,9 @@ export async function initGlobal({ agents = ['claude'], skills = null, hookSelec
|
|
|
68
76
|
// Global hooks are Claude-only (its native enforcement engine).
|
|
69
77
|
const wantHooks = hooks && agents.includes('claude');
|
|
70
78
|
if (wantHooks) {
|
|
71
|
-
const hookKeys = await initGlobalHooks({ force, hooks: hookSelection, _globalFiles: updatedFiles, _skipManifestWrite: true });
|
|
79
|
+
const { keys: hookKeys, entries: hookEntries } = await initGlobalHooks({ force, hooks: hookSelection, _globalFiles: updatedFiles, _skipManifestWrite: true });
|
|
72
80
|
for (const k of hookKeys) installedKeys.add(k);
|
|
81
|
+
Object.assign(updatedFiles, hookEntries); // persist hook kitHashes in the global manifest
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
// Prune orphans: files from a previous global install that are no longer desired
|
|
@@ -80,7 +89,10 @@ export async function initGlobal({ agents = ['claude'], skills = null, hookSelec
|
|
|
80
89
|
for (const [key, entry] of Object.entries(globalFiles)) {
|
|
81
90
|
if (installedKeys.has(key)) continue;
|
|
82
91
|
const isHook = key.startsWith('.claude/hooks/');
|
|
83
|
-
|
|
92
|
+
// Legacy claude-devkit / agentpipe manifests recorded skills with NO `agent` field;
|
|
93
|
+
// derive the owner from the key's global-skill-root prefix (e.g. .claude/skills/ →
|
|
94
|
+
// claude) so those predecessor mf-*/ap-* skills get pruned, not silently skipped.
|
|
95
|
+
const owner = entry.agent || (isHook ? 'claude' : ownerFromGlobalKey(key));
|
|
84
96
|
const inScope = isHook ? wantHooks : (owner && agents.includes(owner));
|
|
85
97
|
if (!inScope) continue;
|
|
86
98
|
const abs = join(homedir(), ...key.split('/'));
|
|
@@ -101,6 +113,18 @@ export async function initGlobal({ agents = ['claude'], skills = null, hookSelec
|
|
|
101
113
|
}
|
|
102
114
|
if (pruned) log.info(`Pruned ${pruned} stale global file(s).`);
|
|
103
115
|
|
|
116
|
+
// Legacy migration: older claude-devkit installs left ~/.claude/scripts/build-test.sh
|
|
117
|
+
// (no longer in the kit, untracked by the manifest). Sweep it up on install too —
|
|
118
|
+
// not just on remove — so upgrading from the old tool leaves nothing behind.
|
|
119
|
+
if (wantHooks) {
|
|
120
|
+
const legacyScript = join(homedir(), '.claude', 'scripts', 'build-test.sh');
|
|
121
|
+
try {
|
|
122
|
+
await unlink(legacyScript);
|
|
123
|
+
log.del('~/.claude/scripts/build-test.sh (legacy)');
|
|
124
|
+
try { await rmdir(join(homedir(), '.claude', 'scripts')); } catch { /* keep if other scripts */ }
|
|
125
|
+
} catch { /* not present — fine */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
104
128
|
await writeGlobalManifest({
|
|
105
129
|
...existing,
|
|
106
130
|
globalInstalled: installedAgents.has('claude') || existing.globalInstalled || false,
|
|
@@ -121,6 +145,9 @@ export async function initGlobalHooks({ force = false, hooks = null, _globalFile
|
|
|
121
145
|
const globalFiles = _globalFiles || existing?.files || {};
|
|
122
146
|
const updatedFiles = { ...globalFiles };
|
|
123
147
|
const keys = []; // home-relative keys installed this run (for the caller's orphan-prune)
|
|
148
|
+
const entries = {}; // kitHash entries written this run — the caller persists these so
|
|
149
|
+
// hooks are TRACKED (else savedKitHash is always undefined → every
|
|
150
|
+
// version bump looks "customized" and stale hooks never auto-update)
|
|
124
151
|
|
|
125
152
|
log.blank();
|
|
126
153
|
console.log('--- Installing global hooks ---');
|
|
@@ -133,7 +160,7 @@ export async function initGlobalHooks({ force = false, hooks = null, _globalFile
|
|
|
133
160
|
if (result === 'copied') copied++;
|
|
134
161
|
else if (result === 'identical') identical++;
|
|
135
162
|
else skipped++;
|
|
136
|
-
if (result !== 'skipped') updatedFiles[key] = { kitHash };
|
|
163
|
+
if (result !== 'skipped') { updatedFiles[key] = { kitHash }; entries[key] = { kitHash }; }
|
|
137
164
|
keys.push(key);
|
|
138
165
|
}
|
|
139
166
|
|
|
@@ -153,5 +180,5 @@ export async function initGlobalHooks({ force = false, hooks = null, _globalFile
|
|
|
153
180
|
updatedAt: new Date().toISOString(),
|
|
154
181
|
});
|
|
155
182
|
}
|
|
156
|
-
return keys;
|
|
183
|
+
return { keys, entries };
|
|
157
184
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
setPermissions, fillTemplate,
|
|
15
15
|
verifySettingsJson, COMPONENTS,
|
|
16
16
|
getTemplateDir, installSkillForAgent, installAgentHooks, installAgentRules, resolveSkills, skillAllowed,
|
|
17
|
+
pruneOrphans,
|
|
17
18
|
} from '../lib/installer.js';
|
|
18
19
|
import { resolveHooks } from '../lib/hooks.js';
|
|
19
20
|
import { AGENTS, parseSkillPath, resolveAgents } from '../lib/agents.js';
|
|
@@ -190,6 +191,14 @@ export async function initCommand(path, opts) {
|
|
|
190
191
|
} catch { /* prior agent's file not on disk — don't record a phantom */ }
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
// Migration: drop predecessor files a prior manifest tracked but we no longer
|
|
195
|
+
// install (mf-* / ap-* skills, renamed hooks) so an install over an old version
|
|
196
|
+
// doesn't leave orphaned /mf-* commands beside the new /sp-* ones.
|
|
197
|
+
if (prior?.files) {
|
|
198
|
+
const n = await pruneOrphans(targetDir, prior.files, new Set(Object.keys(manifest.files)));
|
|
199
|
+
if (n) log.info(`Migrated: removed ${n} superseded file(s) from a previous version.`);
|
|
200
|
+
}
|
|
201
|
+
|
|
193
202
|
// --- Permissions ---
|
|
194
203
|
await setPermissions(targetDir);
|
|
195
204
|
|
package/src/lib/claude-global.js
CHANGED
|
@@ -36,6 +36,9 @@ export async function installHookGlobal(srcRel, globalHooksDir, { force = false,
|
|
|
36
36
|
log.same(`~/.claude/hooks/${base} (identical)`);
|
|
37
37
|
return { result: 'identical', kitHash: srcHash };
|
|
38
38
|
}
|
|
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.
|
|
39
42
|
const savedKitHash = globalFiles[key]?.kitHash;
|
|
40
43
|
if (!(savedKitHash && dstHash === savedKitHash)) {
|
|
41
44
|
log.skip(`~/.claude/hooks/${base} (customized — use --force to overwrite)`);
|
|
@@ -141,6 +144,8 @@ export async function installSkillGlobalForAgent(agentId, skillRelPath, { force
|
|
|
141
144
|
log.same(`${display} (identical)`);
|
|
142
145
|
return { result: 'identical', kitHash: srcHash, key };
|
|
143
146
|
}
|
|
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.
|
|
144
149
|
const savedKitHash = globalFiles[key]?.kitHash;
|
|
145
150
|
if (!(savedKitHash && dstHash === savedKitHash)) {
|
|
146
151
|
log.skip(`${display} (customized — use --force to overwrite)`);
|
package/src/lib/installer.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { copyFile as fsCopyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { copyFile as fsCopyFile, mkdir, readFile, writeFile, unlink, rmdir } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { join, dirname, resolve } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
@@ -150,6 +150,36 @@ export async function installFile(relativePath, targetDir, { force = false } = {
|
|
|
150
150
|
return 'copied';
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Migration prune: delete files a PRIOR manifest tracked that the new install no
|
|
155
|
+
* longer wants — e.g. the predecessor `mf-*` (claude-devkit) / `ap-*` (agentpipe)
|
|
156
|
+
* skills, or renamed/removed hooks. Safe because it only touches paths the kit
|
|
157
|
+
* itself recorded as installed; a user's own files (e.g. a personal `mf-commit`
|
|
158
|
+
* skill that was never in our manifest) are never in `priorFiles`, so untouched.
|
|
159
|
+
* Skips preserved paths and the user's docs/. Cleans up emptied dirs.
|
|
160
|
+
* @returns {Promise<number>} count pruned
|
|
161
|
+
*/
|
|
162
|
+
export async function pruneOrphans(targetDir, priorFiles, keepSet, { preserve = ['.claude/CLAUDE.md'] } = {}) {
|
|
163
|
+
let pruned = 0;
|
|
164
|
+
const dirs = new Set();
|
|
165
|
+
for (const rel of Object.keys(priorFiles || {})) {
|
|
166
|
+
if (keepSet.has(rel) || preserve.includes(rel) || rel.startsWith('docs/')) continue;
|
|
167
|
+
const p = join(targetDir, rel);
|
|
168
|
+
if (!existsSync(p)) continue;
|
|
169
|
+
try {
|
|
170
|
+
await unlink(p);
|
|
171
|
+
log.del(`${rel} (legacy — superseded, removed)`);
|
|
172
|
+
pruned++;
|
|
173
|
+
let d = dirname(rel);
|
|
174
|
+
while (d && d !== '.' && d !== '/') { dirs.add(d); d = dirname(d); }
|
|
175
|
+
} catch { /* ignore */ }
|
|
176
|
+
}
|
|
177
|
+
for (const d of [...dirs].sort((a, b) => b.split('/').length - a.split('/').length)) {
|
|
178
|
+
try { await rmdir(join(targetDir, d)); } catch { /* not empty / missing */ }
|
|
179
|
+
}
|
|
180
|
+
return pruned;
|
|
181
|
+
}
|
|
182
|
+
|
|
153
183
|
// Per-agent install (emit skills + guardrails) lives in agent-install.js;
|
|
154
184
|
// re-exported here so callers keep importing from installer.js.
|
|
155
185
|
export {
|