claude-dev-env 7.2.1 → 7.3.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.
@@ -0,0 +1,79 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { createInterface } from 'node:readline';
7
+ import test from 'node:test';
8
+ import { verifyInstallation } from './pstack.mjs';
9
+
10
+ const executable = process.env.CDE_CODEX_EXECUTABLE;
11
+ const project = process.env.CDE_PSTACK_PROOF_PROJECT;
12
+
13
+ test('native Codex discovers every installed pstack skill and its real entry path', {
14
+ skip: !executable || !project ? 'Set CDE_CODEX_EXECUTABLE and CDE_PSTACK_PROOF_PROJECT for native discovery' : false,
15
+ timeout: 60000,
16
+ }, async t => {
17
+ const installation = verifyInstallation({ project });
18
+ const release = join(resolve(project), '.claude', 'pstack', 'releases', installation.release);
19
+ const manifest = JSON.parse(readFileSync(join(release, 'release.json'), 'utf8'));
20
+ const home = mkdtempSync(join(tmpdir(), 'pstack codex home '));
21
+ const codexHome = join(home, '.codex');
22
+ mkdirSync(codexHome);
23
+ const env = { PATH: process.env.PATH, HOME: home, CODEX_HOME: codexHome };
24
+ const version = spawnSync(executable, ['--version'], { env, encoding: 'utf8', timeout: 15000 });
25
+ assert.equal(version.status, 0, version.stderr || version.error?.message);
26
+ const child = spawn(executable, ['app-server'], { cwd: project, env, stdio: ['pipe', 'pipe', 'pipe'] });
27
+ const lines = createInterface({ input: child.stdout });
28
+ let stderr = '';
29
+ child.stderr.on('data', chunk => { stderr += chunk; });
30
+ child.stdin.on('error', () => {});
31
+ t.after(async () => {
32
+ lines.close();
33
+ const stopped = child.exitCode !== null || child.signalCode !== null
34
+ ? Promise.resolve() : new Promise(accept => child.once('close', accept));
35
+ child.kill();
36
+ await stopped;
37
+ rmSync(home, { recursive: true, force: true });
38
+ });
39
+ const transcript = [];
40
+ const send = message => child.stdin.write(JSON.stringify(message) + '\n');
41
+ const request = (id, method, params) => new Promise((accept, reject) => {
42
+ const timer = setTimeout(() => finish(new Error(`Codex ${method} timed out: ${stderr}`)), 15000);
43
+ const failed = error => finish(error);
44
+ const closed = code => finish(new Error(`Codex exited ${code}: ${stderr}`));
45
+ const receive = line => {
46
+ let message;
47
+ try { message = JSON.parse(line); } catch (error) { finish(error); return; }
48
+ transcript.push(message);
49
+ if (message.id === id) finish(message.error ? new Error(JSON.stringify(message.error)) : null, message.result);
50
+ };
51
+ const finish = (error, response) => {
52
+ clearTimeout(timer);
53
+ child.off('error', failed);
54
+ child.off('close', closed);
55
+ lines.off('line', receive);
56
+ if (error) reject(error); else accept(response);
57
+ };
58
+ child.once('error', failed);
59
+ child.once('close', closed);
60
+ lines.on('line', receive);
61
+ send({ id, method, params });
62
+ });
63
+ await request(0, 'initialize', { clientInfo: { name: 'skillcheck', version: '1.0.0' } });
64
+ send({ method: 'initialized', params: {} });
65
+ const discovered = await request(1, 'skills/list', { cwds: [resolve(project)], forceReload: true });
66
+ const evidence = { verification: 'native-codex-skill-discovery', codexVersion: version.stdout.trim(),
67
+ upstreamCommit: installation.commit, adapterDigest: installation.adapterDigest, transcript };
68
+ if (process.env.CDE_PSTACK_DISCOVERY_REPORT) writeFileSync(process.env.CDE_PSTACK_DISCOVERY_REPORT, JSON.stringify(evidence, null, 2) + '\n');
69
+ const workspace = discovered.data.find(entry => resolve(entry.cwd) === resolve(project));
70
+ assert.ok(workspace, 'Codex returned the requested workspace');
71
+ assert.deepEqual(workspace.errors, []);
72
+ for (const expected of manifest.skills) {
73
+ const found = workspace.skills.filter(skill => skill.name === expected.name);
74
+ assert.equal(found.length, 1, `Codex discovers ${expected.name} once`);
75
+ assert.equal(found[0].enabled, true, expected.name);
76
+ assert.equal(realpathSync(found[0].path), realpathSync(join(release, expected.path, 'SKILL.md')));
77
+ }
78
+ t.diagnostic(`${version.stdout.trim()}: ${manifest.skills.length} installed skills discovered through Codex`);
79
+ });
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.3.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
  },
@@ -0,0 +1,17 @@
1
+ # Pstack compatibility
2
+
3
+ Apply this mapping to the upstream workflow below. User instructions and host permissions stay in charge.
4
+
5
+ Determine the active host from the session's native tools. Read host-claude.md, host-codex.md, or host-cursor.md beside this file for the active host. Read pstack-host-mapping.md and pstack-models.md beside this file before delegation. Those files are copied from claude-dev-env's existing rules into this immutable release. Run the selectorPath recorded in release.json and supply its preferencesDirectory in the selector input. This overrides the old global selector path. Model preferences remain outside the release.
6
+
7
+ Installed skill names have a component prefix. Read the installed inventory in release.json. A pstack request for how, /how, or pstack:how means pstack-how. A cursor-team-kit request for deslop means cursor-team-kit-deslop. Resolve all other skill names through that inventory. Read each leaf skill in full when it applies. Keep each skill's scripts, references, playbooks, and agent definitions at its recorded source root.
8
+
9
+ Every delegation prompt must include this compatibility file's absolute path, the active host file's absolute path, the selected skill's absolute path, and the requested upstream agent definition's absolute path when present. Tell the child to read them before working and to put the complete findings and evidence in its final message. The child receives the same host and model mapping as the parent.
10
+
11
+ Treat upstream model identifiers as role preferences. Use only models and arguments accepted by the native tool in this session. Preserve required independence and model diversity. Report a missing capability when the session cannot supply the required independent panel. Use the native tool's supported concurrency and wait operations.
12
+
13
+ For setup-pstack, configure only the current host's model preference file described in pstack-models.md. Keep that file outside this release. The Cursor .mdc output is a Cursor rule, not a cross-host model configuration.
14
+
15
+ For create-skill, use Cursor's built-in skill on Cursor and cde-create-skill on Claude or Codex. For control-cli and control-ui, load the corresponding installed cursor-team-kit skill and use native terminal or browser tools. Check that the required CLI, browser, credentials, and permissions exist before executing its steps. State an unavailable dependency explicitly. Never claim an unattended browser or remote session ran from a filesystem check.
16
+
17
+ Updates are prepared outside the active release. Follow absolute paths into the release named in this entry point for the rest of this task. Preserve supporting files and scripts, including executable modes and licenses.
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: cde-create-skill
3
+ description: Write or update a portable SKILL.md workflow when pstack requests create-skill on a host without Cursor's built-in skill.
4
+ ---
5
+
6
+ # Create a portable skill
7
+
8
+ State the user-visible outcome and the exact trigger first. Inspect applicable repository instructions and existing skills before adding a new skill.
9
+
10
+ Create a lowercase, hyphenated skill directory under the project's .claude/skills. Give SKILL.md YAML frontmatter with a matching name and a one-line description that says when to use it. Put the workflow after the frontmatter. Keep supporting scripts and references together with the skill, and use relative links inside it. Add a Codex .agents/skills directory link where needed, preserving existing files.
11
+
12
+ Use native host tools and capability checks. Give each step a concrete input, action, and observable result. Separate human choices from facts the agent can inspect. Read referenced instructions before using them.
13
+
14
+ Verify metadata, links, and script behavior. Then invoke the installed skill in the intended host and record the actual result. Distinguish a filesystem check from a native invocation. Keep the scope to the requested workflow.
@@ -0,0 +1,7 @@
1
+ # Claude Code
2
+
3
+ Use Claude Code's native Skill mechanism to load installed skills. Use Agent for Task requests, with only arguments the session exposes. For an upstream poteto-agent or another unavailable agent type, use an available general-purpose agent and include the upstream definition and compatibility files in its prompt. A file on disk does not register a native agent type.
4
+
5
+ Use AskUserQuestion for a required human choice. Use Read, Glob, Grep, Edit, Write, Bash, and the available browser tools for their corresponding work. Read native tool schemas rather than forwarding Cursor-only arguments. Use background execution only when the current tool supports it.
6
+
7
+ Choose model aliases accepted by this session. Omit the model argument for permitted parent inheritance. Keep host preferences in the profile's agents-home rules/pstack-model-preferences.claude.json. Use the current session's transcript location, or state that transcripts are unavailable.
@@ -0,0 +1,11 @@
1
+ # Codex
2
+
3
+ Load SKILL.md with the native skill mechanism or read its complete file with the available file tool. Codex does not need a Claude Skill tool.
4
+
5
+ Map Task or Agent to the native spawn_agent, wait, send_input, and close_agent operations that this session exposes. Field names vary by host version. Inspect the live schema. For poteto-agent, include the upstream agent definition, skill entry, and compatibility files in the child prompt. Select an available native agent type. Do not pass subagent_type, run_in_background, or a model argument unless that tool accepts it.
6
+
7
+ Use the native shell for commands, apply_patch or the native editor for edits, and the available browser tool for UI work. Ask the user through the current session's supported interaction mechanism for a genuine preference decision.
8
+
9
+ A multi-agent feature setting in a CLI profile does not establish subagent support in a cloud session. Check available tools. Required independent or cross-model review stops with a clear capability report when that session cannot provide it. Keep configured model preferences in the profile's agents-home rules/pstack-model-preferences.codex.json. Read the current transcript location from host context, not ~/.cursor/projects.
10
+
11
+ The selector's availableModelIds contains only models selectable through this session's delegation tool. A tool without a model argument supplies an empty list and uses permitted parent inheritance. A provider's general model catalog is not proof of per-delegation model selection.
@@ -0,0 +1,7 @@
1
+ # Cursor
2
+
3
+ Use Cursor's available native skill and delegation tools. The installed component-prefixed names in release.json replace upstream plugin short names. Keep project skills under .claude/skills and use the .agents/skills links for shared discovery. The installer writes no skills under .cursor.
4
+
5
+ For an upstream custom agent type absent from the live tool inventory, use an available native agent type and include that upstream agent definition, the skill entry, and compatibility files in the child prompt. Use the session's supported model identifiers and delegation fields. Preserve required panel independence and model diversity.
6
+
7
+ Use Cursor's built-in create-skill when present. Otherwise read cde-create-skill. Keep Cursor-specific preferences separate from Claude and Codex preferences. Resolve transcripts from the current workspace and session context.
@@ -0,0 +1,11 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "adapterVersion": 1,
4
+ "repository": "https://github.com/cursor/plugins.git",
5
+ "commit": "27e2a62ff94f9af4b5e68435e41cdceacadb840c",
6
+ "components": ["pstack", "cursor-team-kit"],
7
+ "requiredSkills": {
8
+ "pstack": ["poteto-mode"],
9
+ "cursor-team-kit": ["deslop", "control-cli", "control-ui"]
10
+ }
11
+ }