claude-dev-env 7.2.1 → 7.4.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.
package/bin/pstack.mjs ADDED
@@ -0,0 +1,350 @@
1
+ #!/usr/bin/env node
2
+ import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { execFileSync, spawnSync } from 'node:child_process';
5
+ import { homedir } from 'node:os';
6
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
+ const bundledLockPath = join(packageRoot, 'scripts', 'pstack.lock.json');
11
+ const hosts = ['claude', 'codex', 'cursor'];
12
+ const digest = value => createHash('sha256').update(value).digest('hex');
13
+ const readJson = path => JSON.parse(readFileSync(path, 'utf8'));
14
+ const json = value => JSON.stringify(value, null, 2) + '\n';
15
+ const entryExists = path => { try { lstatSync(path); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; } };
16
+
17
+ export function validateLock(lock) {
18
+ if (lock?.schemaVersion !== 1 || lock?.adapterVersion !== 1
19
+ || lock.repository !== 'https://github.com/cursor/plugins.git'
20
+ || !/^[0-9a-f]{40}$/.test(lock.commit ?? '')
21
+ || JSON.stringify(lock.components) !== JSON.stringify(['pstack', 'cursor-team-kit'])) {
22
+ throw new Error('Unsupported pstack lock. Update claude-dev-env for a newer adapter.');
23
+ }
24
+ const required = { pstack: ['poteto-mode'], 'cursor-team-kit': ['deslop', 'control-cli', 'control-ui'] };
25
+ for (const [component, names] of Object.entries(required)) {
26
+ const declared = lock.requiredSkills?.[component];
27
+ if (!Array.isArray(declared) || declared.some(name => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))
28
+ || names.some(name => !declared.includes(name))) throw new Error(`Invalid ${component} dependencies`);
29
+ }
30
+ return lock;
31
+ }
32
+
33
+ function filesUnder(root) {
34
+ return readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).flatMap(entry => {
35
+ const path = join(root, entry.name);
36
+ if (entry.isSymbolicLink()) throw new Error(`Upstream symlinks require review: ${path}`);
37
+ if (entry.isDirectory()) return filesUnder(path);
38
+ if (!entry.isFile()) throw new Error(`Unsupported upstream file: ${path}`);
39
+ return [path];
40
+ });
41
+ }
42
+
43
+ function runGit(args, cwd) {
44
+ return execFileSync('git', args, {
45
+ cwd, encoding: 'utf8', timeout: 40000, maxBuffer: 16 * 1024 * 1024,
46
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ }).trim();
49
+ }
50
+
51
+ export function fetchUpstream(lock, destination) {
52
+ mkdirSync(destination, { recursive: true });
53
+ runGit(['init', '--quiet'], destination);
54
+ runGit(['config', 'core.autocrlf', 'false'], destination);
55
+ runGit(['config', 'core.hooksPath', join(destination, '.disabled-hooks')], destination);
56
+ runGit(['remote', 'add', 'origin', lock.repository], destination);
57
+ runGit(['config', 'core.sparseCheckout', 'true'], destination);
58
+ writeFileSync(join(destination, '.git', 'info', 'sparse-checkout'), '/pstack/\n/cursor-team-kit/\n/LICENSE\n');
59
+ runGit(['fetch', '--quiet', '--depth=1', '--filter=blob:none', 'origin', lock.commit], destination);
60
+ if (runGit(['rev-parse', 'FETCH_HEAD'], destination) !== lock.commit) throw new Error('Upstream commit mismatch');
61
+ runGit(['checkout', '--quiet', '--detach', 'FETCH_HEAD'], destination);
62
+ }
63
+
64
+ function adapterFiles(root) {
65
+ const adapters = join(root, 'scripts', 'pstack-adapters');
66
+ return Object.fromEntries([
67
+ ...['common.md', ...hosts.map(host => `host-${host}.md`), 'create-skill.md'].map(name => [name, join(adapters, name)]),
68
+ ...['pstack-host-mapping.md', 'pstack-models.md'].map(name => [name, join(root, 'rules', name)]),
69
+ ['select_pstack_models.mjs', join(root, 'scripts', 'select_pstack_models.mjs')],
70
+ ].map(([name, path]) => [name, readFileSync(path, 'utf8')]));
71
+ }
72
+
73
+ function adaptSkill(text, name, releaseRoot, skillRoot) {
74
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/);
75
+ if (!match || !/^description:\s*\S/m.test(match[1])) throw new Error(`Missing skill metadata: ${name}`);
76
+ const frontmatter = match[1].replace(/^name:.*$/m, `name: ${name}`);
77
+ if (!/^name:/m.test(frontmatter)) throw new Error(`Missing skill name: ${name}`);
78
+ const safeFrontmatter = frontmatter.replace(/^(?:context|agent|model):.*\r?\n?/gm, '');
79
+ const fork = /^context:\s*fork\s*$/m.test(frontmatter)
80
+ ? 'The upstream entry requests a fork. Delegate through the native host after reading the compatibility mapping.\n' : '';
81
+ return `---\n${safeFrontmatter}\n---\n\n## Host compatibility\n\n`
82
+ + `Read ${JSON.stringify(join(releaseRoot, 'compat', 'common.md'))} and the matching host file before following this workflow.\n`
83
+ + `Resolve this skill's relative paths from ${JSON.stringify(skillRoot)}. Read ${JSON.stringify(join(releaseRoot, 'release.json'))} for skill and agent paths.\n`
84
+ + fork + '\n' + match[2].trimStart();
85
+ }
86
+
87
+ export function prepareRelease(checkout, stage, finalRoot, lock, adapters) {
88
+ const skills = [];
89
+ const agents = [];
90
+ mkdirSync(join(stage, 'compat'), { recursive: true });
91
+ for (const [name, content] of Object.entries(adapters)) writeFileSync(join(stage, 'compat', name), content);
92
+ for (const component of lock.components) {
93
+ const source = join(checkout, component);
94
+ filesUnder(source);
95
+ cpSync(source, join(stage, 'upstream', component), { recursive: true });
96
+ cpSync(source, join(stage, 'runtime', component), { recursive: true });
97
+ rmSync(join(stage, 'runtime', component, '.cursor-plugin', 'plugin.json'), { force: true });
98
+ const skillHome = join(source, 'skills');
99
+ const names = readdirSync(skillHome).filter(name => existsSync(join(skillHome, name, 'SKILL.md'))).sort();
100
+ for (const name of lock.requiredSkills[component]) {
101
+ if (!names.includes(name)) throw new Error(`Missing dependency: ${component}:${name}`);
102
+ }
103
+ for (const slug of names) {
104
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) throw new Error(`Invalid skill directory: ${slug}`);
105
+ const name = `${component}-${slug}`;
106
+ const path = join('runtime', component, 'skills', slug);
107
+ const text = readFileSync(join(source, 'skills', slug, 'SKILL.md'), 'utf8');
108
+ writeFileSync(join(stage, path, 'SKILL.md'), adaptSkill(text, name, finalRoot, join(finalRoot, path)));
109
+ skills.push({ name, component, slug, path });
110
+ }
111
+ const agentHome = join(source, 'agents');
112
+ if (existsSync(agentHome)) {
113
+ for (const path of filesUnder(agentHome)) agents.push({ component, path: join('runtime', component, 'agents', relative(agentHome, path)) });
114
+ }
115
+ }
116
+ if (existsSync(join(checkout, 'LICENSE'))) cpSync(join(checkout, 'LICENSE'), join(stage, 'upstream', 'LICENSE'));
117
+ const creatorPath = join('runtime', 'cde-create-skill');
118
+ mkdirSync(join(stage, creatorPath), { recursive: true });
119
+ writeFileSync(join(stage, creatorPath, 'SKILL.md'), adapters['create-skill.md']);
120
+ skills.push({ name: 'cde-create-skill', component: 'cde', slug: 'create-skill', path: creatorPath });
121
+ const knownNames = new Set(skills.map(skill => `${skill.component}:${skill.slug}`));
122
+ for (const path of filesUnder(join(stage, 'upstream')).filter(path => path.endsWith('.md') && (path.includes(sep + 'skills' + sep) || path.includes(sep + 'agents' + sep)))) {
123
+ const references = readFileSync(path, 'utf8').matchAll(/\b(pstack|cursor-team-kit):([a-z][a-z0-9-]*)/g);
124
+ for (const [, component, slug] of references) {
125
+ if (!knownNames.has(`${component}:${slug}`)) throw new Error(`Unresolved skill dependency: ${component}:${slug}`);
126
+ }
127
+ }
128
+ const fileDigests = Object.fromEntries(filesUnder(stage).map(path => [relative(stage, path), digest(readFileSync(path))]));
129
+ const managedRoot = dirname(dirname(dirname(finalRoot)));
130
+ const agentsRoot = basename(managedRoot) === '.claude' ? join(dirname(managedRoot), '.agents') : managedRoot + '.agents';
131
+ const release = { preferencesDirectory: join(agentsRoot, 'rules'), selectorPath: join(finalRoot, 'compat', 'select_pstack_models.mjs'), schemaVersion: 1, commit: lock.commit, adapterVersion: lock.adapterVersion, skills, agents, fileDigests, verification: 'filesystem-only' };
132
+ writeFileSync(join(stage, 'release.json'), json(release));
133
+ return release;
134
+ }
135
+
136
+ function assertContained(root, path) {
137
+ const rel = relative(root, resolve(root, path));
138
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith('..' + sep) || resolve(root, path) === resolve(root)) throw new Error('Invalid release path');
139
+ return resolve(root, path);
140
+ }
141
+
142
+ export function verifyRelease(root) {
143
+ const release = readJson(join(root, 'release.json'));
144
+ if (release.schemaVersion !== 1 || !Array.isArray(release.skills) || !release.skills.length || !release.fileDigests) throw new Error('Invalid release manifest');
145
+ for (const [path, hash] of Object.entries(release.fileDigests)) {
146
+ if (digest(readFileSync(assertContained(root, path))) !== hash) throw new Error(`Changed installed file: ${path}`);
147
+ }
148
+ for (const skill of release.skills) {
149
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(skill.name)) throw new Error('Invalid installed skill name');
150
+ const path = join(assertContained(root, skill.path), 'SKILL.md');
151
+ if (!Object.hasOwn(release.fileDigests, relative(root, path)) || !existsSync(path)) throw new Error(`Missing entry: ${skill.name}`);
152
+ }
153
+ return release;
154
+ }
155
+
156
+ function layout(options) {
157
+ const root = resolve(options.root ?? (options.project ? join(options.project, '.claude') : process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')));
158
+ const agents = basename(root) === '.claude' ? join(dirname(root), '.agents') : root + '.agents';
159
+ return { root, store: join(root, 'pstack'), skillHomes: [join(root, 'skills'), join(agents, 'skills')] };
160
+ }
161
+
162
+ function loadState(store) {
163
+ const path = join(store, 'installed.json');
164
+ return existsSync(path) ? readJson(path) : null;
165
+ }
166
+
167
+ export function verifyInstallation(options = {}) {
168
+ const { store } = layout(options);
169
+ const state = loadState(store);
170
+ if (!state) throw new Error('Pstack is not installed');
171
+ const release = verifyRelease(assertContained(join(store, 'releases'), state.release));
172
+ for (const [path, target] of Object.entries(state.links)) {
173
+ if (!lstatSync(path).isSymbolicLink() || realpathSync(path) !== realpathSync(target)) throw new Error(`Changed skill pointer: ${path}`);
174
+ }
175
+ return { ...state, skillCount: release.skills.length, verification: release.verification };
176
+ }
177
+
178
+ function publish(releaseRoot, release, homes, prior) {
179
+ const uniqueHomes = [...new Set(homes.map(home => { mkdirSync(home, { recursive: true }); return realpathSync(home); }))];
180
+ const links = Object.fromEntries(uniqueHomes.flatMap(home => release.skills.map(skill => [join(home, skill.name), join(releaseRoot, skill.path)])));
181
+ const paths = new Set([...Object.keys(prior?.links ?? {}), ...Object.keys(links)]);
182
+ const before = new Map();
183
+ for (const path of paths) {
184
+ if (entryExists(path)) {
185
+ if (!lstatSync(path).isSymbolicLink() || !prior?.links[path]
186
+ || resolve(dirname(path), readlinkSync(path)) !== resolve(prior.links[path])) {
187
+ throw new Error(`Keep existing unmanaged path: ${path}`);
188
+ }
189
+ before.set(path, readlinkSync(path));
190
+ } else before.set(path, null);
191
+ }
192
+ const restore = () => {
193
+ for (const [path, target] of before) {
194
+ if (entryExists(path)) unlinkSync(path);
195
+ if (target !== null) symlinkSync(target, path, process.platform === 'win32' ? 'junction' : 'dir');
196
+ }
197
+ };
198
+ try {
199
+ for (const path of paths) {
200
+ if (entryExists(path)) unlinkSync(path);
201
+ if (links[path]) symlinkSync(links[path], path, process.platform === 'win32' ? 'junction' : 'dir');
202
+ }
203
+ } catch (error) { restore(); throw error; }
204
+ return { links, restore };
205
+ }
206
+
207
+ function liveLeases(store) {
208
+ const root = join(store, 'sessions');
209
+ if (!existsSync(root)) return false;
210
+ return readdirSync(root).some(name => {
211
+ const pid = Number(name);
212
+ if (!Number.isSafeInteger(pid) || pid < 1) throw new Error('Invalid session lease');
213
+ try { process.kill(pid, 0); return true; } catch (error) {
214
+ if (error.code !== 'ESRCH') return true;
215
+ unlinkSync(join(root, name));
216
+ return false;
217
+ }
218
+ });
219
+ }
220
+
221
+ function readCentralLock(temporaryRoot) {
222
+ const repository = join(temporaryRoot, 'record');
223
+ mkdirSync(repository);
224
+ runGit(['init', '--quiet'], repository);
225
+ runGit(['fetch', '--quiet', '--depth=1', '--filter=blob:none', 'https://github.com/jl-cmd/claude-dev-env.git', 'main'], repository);
226
+ return JSON.parse(runGit(['show', 'FETCH_HEAD:packages/claude-dev-env/scripts/pstack.lock.json'], repository));
227
+ }
228
+
229
+ export function installPstack(options = {}, dependencies = {}) {
230
+ const { store, skillHomes } = layout(options);
231
+ mkdirSync(store, { recursive: true });
232
+ const lockDirectory = join(store, '.install-lock');
233
+ mkdirSync(lockDirectory);
234
+ let temporaryRoot;
235
+ const finish = installation => {
236
+ if (options.reserveSession) {
237
+ const sessions = join(store, 'sessions');
238
+ mkdirSync(sessions, { recursive: true });
239
+ writeFileSync(join(sessions, String(process.pid)), installation.release);
240
+ }
241
+ return installation;
242
+ };
243
+ try {
244
+ const prior = loadState(store);
245
+ if ((options.offline || liveLeases(store)) && prior) return finish({ ...verifyInstallation(options), status: 'retained' });
246
+ if (options.offline) throw new Error('Offline install needs an existing verified release');
247
+ temporaryRoot = mkdtempSync(join(store, '.prepare-'));
248
+ const now = dependencies.now ?? Date.now();
249
+ const interval = options.intervalMs ?? 3600000;
250
+ if (!Number.isFinite(interval) || interval < 0) throw new Error('Invalid update interval');
251
+ const bundled = options.lock ?? prior?.lock ?? readJson(bundledLockPath);
252
+ let lock = bundled;
253
+ let checkedAt = prior?.checkedAt ?? 0;
254
+ if (options.refresh && (options.force || now - checkedAt >= interval)) {
255
+ lock = (dependencies.readCentralLock ?? readCentralLock)(temporaryRoot);
256
+ checkedAt = now;
257
+ } else if (options.refresh && prior?.lock) lock = prior.lock;
258
+ validateLock(lock);
259
+ const adapters = adapterFiles(options.packageRoot ?? packageRoot);
260
+ const installerSource = readFileSync(fileURLToPath(import.meta.url), 'utf8').replace(/\r\n/g, '\n');
261
+ const adapterDigest = digest(json(adapters) + installerSource);
262
+ const id = `${lock.commit}-${adapterDigest.slice(0, 16)}`;
263
+ const releaseRoot = join(store, 'releases', id);
264
+ mkdirSync(dirname(releaseRoot), { recursive: true });
265
+ if (!existsSync(releaseRoot)) {
266
+ const checkout = join(temporaryRoot, 'source');
267
+ (dependencies.fetchSource ?? fetchUpstream)(lock, checkout);
268
+ const stage = join(temporaryRoot, 'release');
269
+ prepareRelease(checkout, stage, releaseRoot, lock, adapters);
270
+ verifyRelease(stage);
271
+ renameSync(stage, releaseRoot);
272
+ }
273
+ const release = verifyRelease(releaseRoot);
274
+ const publication = publish(releaseRoot, release, skillHomes, prior);
275
+ const state = { release: id, commit: lock.commit, adapterVersion: lock.adapterVersion, adapterDigest, checkedAt, lock, links: publication.links };
276
+ try {
277
+ writeFileSync(join(store, 'installed.next.json'), json(state));
278
+ renameSync(join(store, 'installed.next.json'), join(store, 'installed.json'));
279
+ } catch (error) { publication.restore(); throw error; }
280
+ return finish({ ...verifyInstallation(options), status: prior?.release === id ? 'unchanged' : 'installed' });
281
+ } catch (error) {
282
+ if (!options.strict) {
283
+ try { return finish({ ...verifyInstallation(options), status: 'retained', warning: error.message }); } catch { }
284
+ }
285
+ throw error;
286
+ } finally {
287
+ if (temporaryRoot) rmSync(temporaryRoot, { recursive: true, force: true });
288
+ rmSync(lockDirectory, { recursive: true, force: true });
289
+ }
290
+ }
291
+
292
+ function parseArguments(args) {
293
+ const options = {};
294
+ const tailIndex = args.indexOf('--');
295
+ const tokens = tailIndex < 0 ? args : args.slice(0, tailIndex);
296
+ const forwarded = tailIndex < 0 ? [] : args.slice(tailIndex + 1);
297
+ for (let index = 0; index < tokens.length; index++) {
298
+ const flag = tokens[index];
299
+ if (['--offline', '--refresh', '--force', '--strict'].includes(flag)) options[flag.slice(2)] = true;
300
+ else if (['--root', '--project', '--host', '--lock', '--interval-ms'].includes(flag)) {
301
+ const argument = tokens[++index];
302
+ if (!argument || argument.startsWith('--')) throw new Error(`${flag} requires a value`);
303
+ if (flag === '--lock') options.lock = readJson(resolve(argument));
304
+ else if (flag === '--interval-ms') options.intervalMs = Number(argument);
305
+ else options[flag.slice(2)] = argument;
306
+ } else throw new Error(`Unknown option: ${flag}`);
307
+ }
308
+ if (options.host && !hosts.includes(options.host)) throw new Error('Host must be claude, codex, or cursor');
309
+ if (options.root && options.project) throw new Error('Choose --root or --project');
310
+ return { options, forwarded };
311
+ }
312
+
313
+ export function main(args = process.argv.slice(2)) {
314
+ try {
315
+ const [command = 'help', ...rest] = args;
316
+ if (['help', '--help', '-h'].includes(command)) {
317
+ console.log('Usage: cde-pstack <install|verify|launch|hook> [--root PATH|--project PATH] [--refresh] [--offline] [--strict] [--lock PATH] [--host claude|codex|cursor] [-- agent arguments]');
318
+ return 0;
319
+ }
320
+ const { options, forwarded } = parseArguments(rest);
321
+ if (!['install', 'verify', 'launch', 'hook'].includes(command)) throw new Error(`Unknown command: ${command}`);
322
+ let hookInput;
323
+ if (command === 'hook') {
324
+ hookInput = JSON.parse(readFileSync(0, 'utf8') || '{}');
325
+ options.project ??= process.env.CLAUDE_PROJECT_DIR || process.cwd();
326
+ options.offline = hookInput.source !== 'startup';
327
+ options.refresh = !options.offline && loadState(layout(options).store) !== null;
328
+ }
329
+ if (command === 'launch' && !options.host) throw new Error('launch requires --host');
330
+ if (command === 'launch') { options.refresh = !options.offline && !options.lock; options.reserveSession = true; }
331
+ const installation = command === 'verify' ? verifyInstallation(options) : installPstack(options);
332
+ if (installation.warning) console.error(`Pstack update failed; keeping ${installation.commit}: ${installation.warning}`);
333
+ if (command === 'hook') {
334
+ const { store } = layout(options);
335
+ const release = join(store, 'releases', installation.release);
336
+ console.log(json({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `Pstack ${installation.commit} is installed. Use the immutable release recorded by session-continuity or the loaded skill entry for this session. Read common.md and host-claude.md in that release's compat directory. New sessions can use the installed inventory at ${join(release, 'release.json')}. ${installation.warning ?? ''}` } }));
337
+ } else if (command === 'launch') {
338
+ const sessions = join(layout(options).store, 'sessions');
339
+ const lease = join(sessions, String(process.pid));
340
+ try {
341
+ const child = spawnSync(options.host, forwarded, { stdio: 'inherit', shell: false, env: { ...process.env, CDE_PSTACK_RELEASE: join(layout(options).store, 'releases', installation.release) } });
342
+ if (child.error) throw child.error;
343
+ return child.status ?? 1;
344
+ } finally { unlinkSync(lease); }
345
+ } else console.log(json(installation));
346
+ return 0;
347
+ } catch (error) { console.error(`Pstack: ${error.message}`); return 1; }
348
+ }
349
+
350
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main();
@@ -0,0 +1,289 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { installPstack, prepareRelease, validateLock, verifyInstallation, verifyRelease } from './pstack.mjs';
9
+
10
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
11
+ const baseLock = JSON.parse(readFileSync(join(packageRoot, 'scripts', 'pstack.lock.json'), 'utf8'));
12
+
13
+ function put(path, content) {
14
+ mkdirSync(dirname(path), { recursive: true });
15
+ writeFileSync(path, content);
16
+ }
17
+
18
+ function fixture(t) {
19
+ const temporary = mkdtempSync(join(tmpdir(), 'cde-pstack-'));
20
+ t.after(() => rmSync(temporary, { recursive: true, force: true }));
21
+ const checkout = join(temporary, 'checkout');
22
+ for (const [component, names] of Object.entries(baseLock.requiredSkills)) {
23
+ for (const name of names) put(join(checkout, component, 'skills', name, 'SKILL.md'), `---\nname: ${name}\ndescription: Test ${name}.\n---\n\nRun ${name}.\n`);
24
+ put(join(checkout, component, 'LICENSE'), `${component} license\n`);
25
+ put(join(checkout, component, '.cursor-plugin', 'plugin.json'), JSON.stringify({ name: component, skills: './skills' }));
26
+ }
27
+ put(join(checkout, 'pstack', 'skills', 'poteto-mode', 'playbooks', 'feature.md'), 'Build a small task, delegate, and verify it.\n');
28
+ put(join(checkout, 'pstack', 'agents', 'poteto-agent.md'), 'Read poteto-mode and report complete findings.\n');
29
+ put(join(checkout, 'pstack', 'scripts', 'proof.sh'), '#!/bin/sh\nprintf proof\n');
30
+ chmodSync(join(checkout, 'pstack', 'scripts', 'proof.sh'), 0o755);
31
+ put(join(checkout, 'pstack', 'assets', 'example.png'), Buffer.from([137, 80, 78, 71, 0, 255]));
32
+ const project = join(temporary, 'project with spaces');
33
+ const options = { project, lock: structuredClone(baseLock), packageRoot };
34
+ const dependencies = { fetchSource: (_lock, destination) => cpSync(checkout, destination, { recursive: true }), now: 10000000 };
35
+ return { temporary, checkout, options, dependencies, store: join(project, '.claude', 'pstack') };
36
+ }
37
+
38
+ for (const host of ['claude', 'codex', 'cursor']) {
39
+ test(`clean ${host} filesystem install publishes all entry points and supporting files`, t => {
40
+ const f = fixture(t);
41
+ const result = installPstack({ ...f.options, host }, f.dependencies);
42
+ assert.equal(result.status, 'installed');
43
+ assert.equal(result.skillCount, 5);
44
+ assert.equal(result.commit, '27e2a62ff94f9af4b5e68435e41cdceacadb840c');
45
+ assert.equal(result.verification, 'filesystem-only');
46
+ const release = join(f.store, 'releases', result.release);
47
+ const expected = ['cde-create-skill', 'cursor-team-kit-control-cli', 'cursor-team-kit-control-ui', 'cursor-team-kit-deslop', 'pstack-poteto-mode'];
48
+ for (const home of ['.claude', '.agents']) {
49
+ assert.deepEqual(readdirSync(join(f.options.project, home, 'skills')).sort(), expected);
50
+ const entry = join(f.options.project, home, 'skills', 'pstack-poteto-mode');
51
+ assert.equal(realpathSync(entry), join(release, 'runtime', 'pstack', 'skills', 'poteto-mode'));
52
+ const text = readFileSync(join(entry, 'SKILL.md'), 'utf8');
53
+ assert.match(text, /name: pstack-poteto-mode/);
54
+ assert.ok(text.includes(JSON.stringify(join(release, 'compat', 'common.md'))));
55
+ assert.equal(readFileSync(join(entry, 'playbooks', 'feature.md'), 'utf8'), 'Build a small task, delegate, and verify it.\n');
56
+ }
57
+ assert.equal(existsSync(join(f.options.project, '.cursor')), false);
58
+ for (const component of baseLock.components) {
59
+ assert.equal(existsSync(join(release, 'runtime', component, '.cursor-plugin', 'plugin.json')), false);
60
+ assert.equal(readFileSync(join(release, 'upstream', component, '.cursor-plugin', 'plugin.json'), 'utf8'),
61
+ readFileSync(join(f.checkout, component, '.cursor-plugin', 'plugin.json'), 'utf8'));
62
+ }
63
+ assert.equal(readFileSync(join(release, 'upstream', 'pstack', 'skills', 'poteto-mode', 'SKILL.md'), 'utf8'), readFileSync(join(f.checkout, 'pstack', 'skills', 'poteto-mode', 'SKILL.md'), 'utf8'));
64
+ assert.deepEqual(readFileSync(join(release, 'upstream', 'pstack', 'assets', 'example.png')), Buffer.from([137, 80, 78, 71, 0, 255]));
65
+ assert.equal(readFileSync(join(release, 'runtime', 'pstack', 'agents', 'poteto-agent.md'), 'utf8'), 'Read poteto-mode and report complete findings.\n');
66
+ assert.equal(readFileSync(join(release, 'upstream', 'cursor-team-kit', 'LICENSE'), 'utf8'), 'cursor-team-kit license\n');
67
+ if (process.platform !== 'win32') assert.equal(statSync(join(release, 'runtime', 'pstack', 'scripts', 'proof.sh')).mode & 0o777, 0o755);
68
+ });
69
+ }
70
+
71
+ test('unchanged install avoids another upstream fetch', t => {
72
+ const f = fixture(t);
73
+ const first = installPstack(f.options, f.dependencies);
74
+ const second = installPstack(f.options, { fetchSource: () => assert.fail('unexpected fetch') });
75
+ assert.equal(second.status, 'unchanged');
76
+ assert.equal(second.release, first.release);
77
+ assert.deepEqual(second.links, first.links);
78
+ });
79
+
80
+ test('changed, added, and removed skills converge while old releases remain readable', t => {
81
+ const f = fixture(t);
82
+ put(join(f.checkout, 'pstack', 'skills', 'old', 'SKILL.md'), '---\nname: old\ndescription: Old skill.\n---\nOld body.\n');
83
+ const before = installPstack(f.options, f.dependencies);
84
+ const oldRelease = join(f.store, 'releases', before.release);
85
+ const firstBody = readFileSync(join(oldRelease, 'runtime', 'pstack', 'skills', 'poteto-mode', 'SKILL.md'), 'utf8');
86
+ rmSync(join(f.checkout, 'pstack', 'skills', 'old'), { recursive: true });
87
+ put(join(f.checkout, 'pstack', 'skills', 'new', 'SKILL.md'), '---\nname: new\ndescription: New skill.\n---\nNew body.\n');
88
+ put(join(f.checkout, 'pstack', 'skills', 'poteto-mode', 'playbooks', 'feature.md'), 'Changed playbook.\n');
89
+ const after = installPstack({ ...f.options, lock: { ...baseLock, commit: 'b'.repeat(40) } }, f.dependencies);
90
+ assert.equal(after.skillCount, 6);
91
+ for (const home of ['.claude', '.agents']) {
92
+ const path = join(f.options.project, home, 'skills');
93
+ assert.equal(existsSync(join(path, 'pstack-old')), false);
94
+ assert.equal(readFileSync(join(path, 'pstack-new', 'SKILL.md'), 'utf8').endsWith('New body.\n'), true);
95
+ assert.equal(readFileSync(join(path, 'pstack-poteto-mode', 'playbooks', 'feature.md'), 'utf8'), 'Changed playbook.\n');
96
+ }
97
+ assert.equal(readFileSync(join(oldRelease, 'runtime', 'pstack', 'skills', 'poteto-mode', 'SKILL.md'), 'utf8'), firstBody);
98
+ });
99
+
100
+ test('missing required dependency retains the previous complete installation', t => {
101
+ const f = fixture(t);
102
+ const first = installPstack(f.options, f.dependencies);
103
+ const priorBytes = readFileSync(join(f.store, 'installed.json'), 'utf8');
104
+ rmSync(join(f.checkout, 'cursor-team-kit', 'skills', 'control-ui'), { recursive: true });
105
+ const result = installPstack({ ...f.options, lock: { ...baseLock, commit: 'c'.repeat(40) } }, f.dependencies);
106
+ assert.equal(result.status, 'retained');
107
+ assert.match(result.warning, /Missing dependency: cursor-team-kit:control-ui/);
108
+ assert.equal(result.release, first.release);
109
+ assert.equal(readFileSync(join(f.store, 'installed.json'), 'utf8'), priorBytes);
110
+ assert.equal(verifyInstallation(f.options).skillCount, 5);
111
+ });
112
+
113
+ test('a failed first install returns failure and publishes no skills', t => {
114
+ const f = fixture(t);
115
+ assert.throws(() => installPstack(f.options, { fetchSource: () => { throw new Error('network unavailable'); } }), /network unavailable/);
116
+ assert.equal(existsSync(join(f.options.project, '.claude', 'skills')), false);
117
+ assert.equal(existsSync(join(f.store, '.install-lock')), false);
118
+ });
119
+
120
+ test('unmanaged skill collision preserves user files', t => {
121
+ const f = fixture(t);
122
+ const userFile = join(f.options.project, '.claude', 'skills', 'pstack-poteto-mode', 'SKILL.md');
123
+ put(userFile, 'Personal workflow\n');
124
+ assert.throws(() => installPstack(f.options, f.dependencies), /Keep existing unmanaged path/);
125
+ assert.equal(readFileSync(userFile, 'utf8'), 'Personal workflow\n');
126
+ assert.equal(existsSync(join(f.store, 'installed.json')), false);
127
+ });
128
+
129
+ test('existing shared skills-directory pointer is preserved', t => {
130
+ const f = fixture(t);
131
+ const agents = join(f.options.project, '.agents', 'skills');
132
+ mkdirSync(agents, { recursive: true });
133
+ mkdirSync(join(f.options.project, '.claude'), { recursive: true });
134
+ symlinkSync(agents, join(f.options.project, '.claude', 'skills'), process.platform === 'win32' ? 'junction' : 'dir');
135
+ const before = readlinkSync(join(f.options.project, '.claude', 'skills'));
136
+ const result = installPstack(f.options, f.dependencies);
137
+ assert.equal(Object.keys(result.links).length, 5);
138
+ assert.equal(readlinkSync(join(f.options.project, '.claude', 'skills')), before);
139
+ });
140
+
141
+ test('offline reuse checks installed bytes and rejects corruption', t => {
142
+ const f = fixture(t);
143
+ const result = installPstack(f.options, f.dependencies);
144
+ assert.equal(installPstack({ ...f.options, offline: true }).release, result.release);
145
+ put(join(f.store, 'releases', result.release, 'runtime', 'pstack', 'skills', 'poteto-mode', 'SKILL.md'), 'corrupted');
146
+ assert.throws(() => installPstack({ ...f.options, offline: true }), /Changed installed file/);
147
+ });
148
+
149
+ test('unknown namespaced upstream dependency requires adapter review', t => {
150
+ const f = fixture(t);
151
+ put(join(f.checkout, 'pstack', 'skills', 'poteto-mode', 'reference.md'), 'Invoke cursor-team-kit:missing-skill.\n');
152
+ assert.throws(() => installPstack(f.options, f.dependencies), /Unresolved skill dependency/);
153
+ });
154
+
155
+ test('source symlinks are rejected before publication', t => {
156
+ const f = fixture(t);
157
+ const outside = join(f.temporary, 'outside-source');
158
+ mkdirSync(outside);
159
+ put(join(outside, 'personal.txt'), 'Retain this file.');
160
+ const dependencies = { ...f.dependencies, fetchSource: (lock, destination) => {
161
+ f.dependencies.fetchSource(lock, destination);
162
+ const link = join(destination, 'pstack', 'escape');
163
+ symlinkSync(outside, link, process.platform === 'win32' ? 'junction' : 'dir');
164
+ assert.equal(lstatSync(link).isSymbolicLink(), true);
165
+ } };
166
+ assert.throws(() => installPstack(f.options, dependencies), /symlinks require review/);
167
+ assert.equal(readFileSync(join(outside, 'personal.txt'), 'utf8'), 'Retain this file.');
168
+ });
169
+
170
+ test('update checks use the shared record and respect the interval', t => {
171
+ const f = fixture(t);
172
+ const shared = { ...baseLock, commit: 'd'.repeat(40) };
173
+ let calls = 0;
174
+ const dependencies = { ...f.dependencies, readCentralLock: () => { calls++; return shared; } };
175
+ const first = installPstack({ ...f.options, refresh: true }, dependencies);
176
+ assert.equal(first.commit, 'd'.repeat(40));
177
+ const second = installPstack({ ...f.options, refresh: true }, { ...dependencies, now: dependencies.now + 100 });
178
+ assert.equal(second.commit, 'd'.repeat(40));
179
+ assert.equal(calls, 1);
180
+ installPstack({ ...f.options, refresh: true }, { ...dependencies, now: dependencies.now + 3600001 });
181
+ assert.equal(calls, 2);
182
+ });
183
+
184
+ test('failed shared-record refresh retains and reports the previous revision', t => {
185
+ const f = fixture(t);
186
+ const first = installPstack(f.options, f.dependencies);
187
+ const result = installPstack({ ...f.options, refresh: true }, { ...f.dependencies, readCentralLock: () => { throw new Error('record unavailable'); } });
188
+ assert.equal(result.release, first.release);
189
+ assert.equal(result.warning, 'record unavailable');
190
+ });
191
+
192
+ test('an active launcher lease defers updating shared instructions', t => {
193
+ const f = fixture(t);
194
+ const first = installPstack(f.options, f.dependencies);
195
+ put(join(f.store, 'sessions', String(process.pid)), first.release);
196
+ const result = installPstack({ ...f.options, lock: { ...baseLock, commit: 'e'.repeat(40) } }, { fetchSource: () => assert.fail('updated during an active session') });
197
+ assert.equal(result.status, 'retained');
198
+ assert.equal(result.release, first.release);
199
+ });
200
+
201
+ test('unsupported lock input fails before source fetch', t => {
202
+ const f = fixture(t);
203
+ for (const change of [{ commit: 'main' }, { adapterVersion: 2 }, { repository: 'https://example.test/repo' }, { components: ['../outside'] }, { requiredSkills: {} }]) {
204
+ assert.throws(() => installPstack({ ...f.options, lock: { ...baseLock, ...change } }, { fetchSource: () => assert.fail('unexpected fetch') }));
205
+ }
206
+ assert.equal(validateLock(baseLock), baseLock);
207
+ });
208
+
209
+ test('CLI exposes deterministic verification and rejects unknown flags', t => {
210
+ const f = fixture(t);
211
+ installPstack(f.options, f.dependencies);
212
+ const command = join(packageRoot, 'bin', 'pstack.mjs');
213
+ const verified = spawnSync(process.execPath, [command, 'verify', '--project', f.options.project], { encoding: 'utf8' });
214
+ assert.equal(verified.status, 0, verified.stderr);
215
+ assert.equal(JSON.parse(verified.stdout).skillCount, 5);
216
+ const invalid = spawnSync(process.execPath, [command, 'install', '--host', 'unknown'], { encoding: 'utf8' });
217
+ assert.equal(invalid.status, 1);
218
+ assert.match(invalid.stderr, /Host must be/);
219
+ const help = spawnSync(process.execPath, [command, '--help'], { encoding: 'utf8' });
220
+ assert.equal(help.status, 0);
221
+ assert.match(help.stdout, /cde-pstack/);
222
+ });
223
+
224
+ test('resumed Claude hook retains the cached release and emits native hook JSON', t => {
225
+ const f = fixture(t);
226
+ const first = installPstack(f.options, f.dependencies);
227
+ const result = spawnSync(process.execPath, [join(packageRoot, 'bin', 'pstack.mjs'), 'hook', '--project', f.options.project], {
228
+ encoding: 'utf8', input: JSON.stringify({ source: 'resume' }),
229
+ });
230
+ assert.equal(result.status, 0, result.stderr);
231
+ const output = JSON.parse(result.stdout);
232
+ assert.equal(output.hookSpecificOutput.hookEventName, 'SessionStart');
233
+ assert.ok(output.hookSpecificOutput.additionalContext.includes(first.commit));
234
+ assert.ok(output.hookSpecificOutput.additionalContext.includes('common.md'));
235
+ });
236
+
237
+ test('release validation catches missing files and escaping manifest paths', t => {
238
+ const f = fixture(t);
239
+ const result = installPstack(f.options, f.dependencies);
240
+ const root = join(f.store, 'releases', result.release);
241
+ assert.equal(verifyRelease(root).skills.length, 5);
242
+ const manifest = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8'));
243
+ manifest.fileDigests['../../outside'] = 'bad';
244
+ put(join(root, 'release.json'), JSON.stringify(manifest));
245
+ assert.throws(() => verifyRelease(root), /Invalid release path/);
246
+ });
247
+
248
+ test('state-write failure restores the old published pointers', t => {
249
+ const f = fixture(t);
250
+ const first = installPstack(f.options, f.dependencies);
251
+ const priorBytes = readFileSync(join(f.store, 'installed.json'), 'utf8');
252
+ mkdirSync(join(f.store, 'installed.next.json'));
253
+ const result = installPstack({ ...f.options, lock: { ...baseLock, commit: 'f'.repeat(40) } }, f.dependencies);
254
+ assert.equal(result.status, 'retained');
255
+ assert.equal(result.release, first.release);
256
+ assert.equal(readFileSync(join(f.store, 'installed.json'), 'utf8'), priorBytes);
257
+ assert.equal(verifyInstallation(f.options).commit, first.commit);
258
+ });
259
+
260
+ test('npm-style linked CLI runs its entry point', t => {
261
+ const f = fixture(t);
262
+ if (process.platform === 'win32') return;
263
+ const link = join(f.temporary, 'cde-pstack');
264
+ symlinkSync(join(packageRoot, 'bin', 'pstack.mjs'), link);
265
+ const result = spawnSync(process.execPath, [link, '--help'], { encoding: 'utf8' });
266
+ assert.equal(result.status, 0);
267
+ assert.match(result.stdout, /Usage: cde-pstack/);
268
+ });
269
+
270
+ test('session reservation is recorded before install releases its lock', t => {
271
+ const f = fixture(t);
272
+ const result = installPstack({ ...f.options, reserveSession: true }, f.dependencies);
273
+ assert.equal(readFileSync(join(f.store, 'sessions', String(process.pid)), 'utf8'), result.release);
274
+ assert.equal(existsSync(join(f.store, '.install-lock')), false);
275
+ });
276
+
277
+
278
+ test('a changed installer creates a fresh generation for the same upstream pin', async t => {
279
+ const f = fixture(t);
280
+ const first = installPstack(f.options, f.dependencies);
281
+ const copied = join(f.temporary, 'updated-installer', 'bin', 'pstack.mjs');
282
+ put(copied, readFileSync(join(packageRoot, 'bin', 'pstack.mjs'), 'utf8') + '\n');
283
+ const updated = await import(pathToFileURL(copied).href);
284
+ const second = updated.installPstack(f.options, f.dependencies);
285
+ assert.equal(second.commit, first.commit);
286
+ assert.notEqual(second.adapterDigest, first.adapterDigest);
287
+ assert.notEqual(second.release, first.release);
288
+ assert.equal(verifyInstallation(f.options).release, second.release);
289
+ });
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "7.2.1",
3
+ "version": "7.4.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "claude-dev-env": "bin/install.mjs",
8
+ "cde-pstack": "bin/pstack.mjs",
8
9
  "codex-compat": "bin/codex-compat.mjs",
9
10
  "cde": "bin/cde.mjs"
10
11
  },