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.
@@ -101,9 +101,7 @@ function potetoSource() {
101
101
  const found = [...new Set(candidates.filter(existsSync).map(path => realpathSync(path)))];
102
102
  if (found.length > 1) throw new Error('Multiple Poteto sources found. Set CDE_POTETO_SOURCE to the installed source used by this host.');
103
103
  const source = resolve(found[0] || candidates[0]);
104
- if (existsSync(source) && !/^name:\s*["']?Poteto Mode["']?\s*$/m.test(readFileSync(source, 'utf8'))) {
105
- throw new Error(`The selected source does not declare Poteto Mode: ${source}`);
106
- }
104
+ if (!existsSync(source)) throw new Error(`Poteto source not found: ${source}`);
107
105
  return source;
108
106
  }
109
107
 
@@ -283,12 +283,20 @@ for (const host of ['claude', 'codex']) {
283
283
  assert.deepEqual(f.fire('SessionStart', 'auto', { source: 'startup' }), [{}]);
284
284
  });
285
285
 
286
- test(`${host}: an unavailable Poteto source stops automatic activation without writing a record`, t => {
286
+ test(`${host}: session start accepts an unrelated display name from the canonical Poteto path`, t => {
287
287
  const f = fixture(t, host);
288
- writeFileSync(f.poteto, '---\nname: Something Else\n---\n');
289
- const started = f.fire('SessionStart', 'broken', { source: 'startup' })[0];
288
+ writeFileSync(f.poteto, '---\nname: Concise Session Style\n---\nPOTETO_SOURCE_EXPECTATION: prove the requested result.\n');
289
+ const started = f.fire('SessionStart', 'unrelated-display-name', { source: 'startup' })[0];
290
+ assert.ok(started.hookSpecificOutput, started.systemMessage);
291
+ assert.ok(started.hookSpecificOutput.additionalContext.includes('POTETO_SOURCE_EXPECTATION'));
292
+ });
293
+
294
+ test(`${host}: a missing Poteto source stops automatic activation without writing a record`, t => {
295
+ const f = fixture(t, host);
296
+ unlinkSync(f.poteto);
297
+ const started = f.fire('SessionStart', 'missing-source', { source: 'startup' })[0];
290
298
  assert.match(started.systemMessage, /did not activate Poteto Mode/);
291
- assert.equal(existsSync(f.pathFor('broken')), false);
299
+ assert.equal(existsSync(f.pathFor('missing-source')), false);
292
300
  });
293
301
  }
294
302
 
package/AGENTS.md CHANGED
@@ -12,6 +12,16 @@ Use direct outcome-focused wording. Omit filler, failed attempts, alternatives c
12
12
 
13
13
  Cut AI tells from any writing. Must always apply.
14
14
 
15
+ Banned word: real
16
+
17
+ Never write real, really, or real-world. Not in chat, not in a commit message, not in a pull request body, not in a comment, not in documentation, not in a heading, not in a variable name. This ban has no exception. Emphasis is not an exception. Contrast with a test, a mock, a fixture, or a hypothetical is not an exception. Insisting that something is genuine is not an exception.
18
+
19
+ Every sentence carrying real says the same thing without it. "One real failure" is "one failure". "The real cause" is "the cause". "Really fast" is "fast", or the measured number. "Real users" is "users". "A real bug, not a flake" is "a bug", followed by the evidence that rules out a flake.
20
+
21
+ Delete the word, then read the sentence. When it still says what you meant, you are done. When something is missing, the missing part is evidence, so name the evidence. The failing check. The log line. The measured number. The file and the line.
22
+
23
+ Swapping in actual, actually, genuine, or true is the same move, and each is banned with it. So is the invented contrast that invites the word back, such as "not a hypothetical problem but a problem".
24
+
15
25
  Edit text to remove AI patterns and add human voice.
16
26
 
17
27
  Process
@@ -37,7 +47,7 @@ Content
37
47
 
38
48
  Puffery. "pivotal moment", "testament to", "evolving landscape", "setting the stage for", "indelible mark", "deeply rooted". Cut puffery, state what happened.
39
49
  Name-dropping. Listing media outlets without context. Pick one, say what was said.
40
- Superficial -ing phrases. "highlighting...", "ensuring...", "reflecting...", "showcasing...", "fostering...". Delete or expand with real sources.
50
+ Superficial -ing phrases. "highlighting...", "ensuring...", "reflecting...", "showcasing...", "fostering...". Delete or expand with named sources.
41
51
  Promotional language. "nestled", "vibrant", "breathtaking", "groundbreaking", "renowned", "stunning", "must-visit". Use neutral descriptions.
42
52
  Vague attributions. "Experts believe", "Industry reports suggest", "Some critics argue". Name the source or delete.
43
53
  Formulaic challenges. "Despite challenges... continues to thrive." Replace with specific facts.
@@ -78,7 +88,7 @@ Generic conclusions. "The future looks bright." State specific plans or facts.
78
88
 
79
89
 
80
90
  Jargon
81
- Abstract metaphor nouns. Substrate, wedge, vector, locus, vantage, nexus, primitive (as noun), harness (as metaphor), surface (as in "API surface"), bedrock, scaffolding (as metaphor), modality, paradigm, gold-plating, ratchet (as metaphor), evacuate (for moving code), endgame, north star, flywheel. These read as technical but usually have a plainer concrete word. "Substrate" becomes "base". "Wedge in" becomes "add". "Vector" becomes "way" or "method". "Gold-plating" becomes "more than the job needs". "Ratchet" becomes the mechanism's real name or "a limit that only tightens". "Evacuate" becomes "move out". "Endgame" becomes "the last phase". Pick the concrete word.
91
+ Abstract metaphor nouns. Substrate, wedge, vector, locus, vantage, nexus, primitive (as noun), harness (as metaphor), surface (as in "API surface"), bedrock, scaffolding (as metaphor), modality, paradigm, gold-plating, ratchet (as metaphor), evacuate (for moving code), endgame, north star, flywheel. These read as technical but usually have a plainer concrete word. "Substrate" becomes "base". "Wedge in" becomes "add". "Vector" becomes "way" or "method". "Gold-plating" becomes "more than the job needs". "Ratchet" becomes the mechanism's own name or "a limit that only tightens". "Evacuate" becomes "move out". "Endgame" becomes "the last phase". Pick the concrete word.
82
92
 
83
93
 
84
94
  Plain speech
@@ -82,6 +82,7 @@ const PREFLIGHT_PROPOSAL_FILE_NAME = 'preflight-proposal.md';
82
82
  function runInstaller(homeDirectory, extraArguments, environmentOverrides = {}) {
83
83
  const installerEnvironment = {
84
84
  ...process.env,
85
+ CDE_INSTALL_PSTACK: '0',
85
86
  HOME: homeDirectory,
86
87
  USERPROFILE: homeDirectory,
87
88
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -56,6 +56,7 @@ function runInstaller(homeDirectory, extraArguments) {
56
56
  encoding: 'utf8',
57
57
  env: {
58
58
  ...process.env,
59
+ CDE_INSTALL_PSTACK: '0',
59
60
  HOME: homeDirectory,
60
61
  USERPROFILE: homeDirectory,
61
62
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -83,6 +84,7 @@ function scanTextWithProductionPiiScanner(scannedText) {
83
84
  encoding: 'utf8',
84
85
  env: {
85
86
  ...process.env,
87
+ CDE_INSTALL_PSTACK: '0',
86
88
  PYTHONPATH: BLOCKING_HOOKS_DIRECTORY,
87
89
  CLAUDE_LOCAL_IDENTITY_PATH: join(
88
90
  temporaryDirectory,
@@ -31,6 +31,7 @@ function runInstaller(homeDirectory, extraArguments, environmentOverrides = {})
31
31
  encoding: 'utf8',
32
32
  env: {
33
33
  ...process.env,
34
+ CDE_INSTALL_PSTACK: '0',
34
35
  HOME: homeDirectory,
35
36
  USERPROFILE: homeDirectory,
36
37
  CODEX_HOME: join(homeDirectory, '.codex'),
package/bin/install.mjs CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  PSTACK_PLUGIN_MANIFEST_RELATIVE_PATH,
44
44
  refreshPstackPluginManifest,
45
45
  } from '../scripts/refresh_pstack_plugin_skills.mjs';
46
+ import { installPstack } from './pstack.mjs';
46
47
  import {
47
48
  resolveInstallRoot,
48
49
  parseExplicitTargetFromArgv,
@@ -326,19 +327,9 @@ export const INSTALL_GROUPS = {
326
327
  ...dependencyDiscovery.groups,
327
328
  };
328
329
 
329
- /**
330
- * Returns the ordered python interpreter candidates to probe for the given
331
- * platform. On win32 the `py -3` launcher is probed first because it resolves
332
- * through the Windows registry and is immune to the Microsoft Store
333
- * `python.exe` App Execution Alias that otherwise gets baked into settings.json.
334
- *
335
- * @param {string} platform A value from `process.platform` (e.g. 'win32', 'linux').
336
- * @returns {{command: string, versionFlag: string}[]} Candidates in probe order.
337
- */
338
330
  export function pythonCandidatesForPlatform(platform) {
339
331
  const windowsOrder = [
340
332
  { command: 'py -3', versionFlag: '--version' },
341
- { command: 'python3', versionFlag: '--version' },
342
333
  { command: 'python', versionFlag: '--version' },
343
334
  ];
344
335
  const defaultOrder = [
@@ -432,6 +423,54 @@ export function runCursorRuleSync(pythonCommand, scriptPath, claudeRoot, cursorR
432
423
  );
433
424
  }
434
425
 
426
+ const PSTACK_RELEASE_OPT_OUT_FLAG = '--no-pstack';
427
+ const PSTACK_RELEASE_OPT_OUT_VARIABLE = 'CDE_INSTALL_PSTACK';
428
+ const PSTACK_RELEASE_OPT_OUT_VALUE = '0';
429
+
430
+ /**
431
+ * Decide whether a full install also installs the pstack release.
432
+ *
433
+ * The step reaches the network for the pinned upstream commit. `--no-pstack`
434
+ * on the command line, or `CDE_INSTALL_PSTACK=0` in the environment, turns it
435
+ * off for an air-gapped or offline run.
436
+ *
437
+ * @param {string[]} [argumentList] The command-line arguments after the script.
438
+ * @param {Record<string, string|undefined>} [environment] The process environment.
439
+ * @returns {boolean} True when this run installs the pstack release.
440
+ */
441
+ export function shouldInstallPstackRelease(
442
+ argumentList = process.argv.slice(2),
443
+ environment = process.env,
444
+ ) {
445
+ if (argumentList.includes(PSTACK_RELEASE_OPT_OUT_FLAG)) return false;
446
+ return environment[PSTACK_RELEASE_OPT_OUT_VARIABLE] !== PSTACK_RELEASE_OPT_OUT_VALUE;
447
+ }
448
+
449
+ /**
450
+ * Install the pstack release into a managed root without failing the run.
451
+ *
452
+ * The pstack store keeps its own state and its own skill pointers, so this
453
+ * step reports its outcome rather than adding paths to the install manifest.
454
+ * A network failure returns a warning so the rules, hooks, and skills this
455
+ * run already wrote still reach their durable places.
456
+ *
457
+ * @param {object} [options] Options for `installPstack`, including `root`.
458
+ * @param {object} [dependencies] Seams `installPstack` accepts, such as `fetchSource`.
459
+ * @returns {{status: string, release: string|null, warning: string|null}} The outcome.
460
+ */
461
+ export function installPstackRelease(options = {}, dependencies = {}) {
462
+ try {
463
+ const installation = installPstack(options, dependencies);
464
+ return {
465
+ status: installation.status,
466
+ release: installation.release,
467
+ warning: installation.warning ?? null,
468
+ };
469
+ } catch (installError) {
470
+ return { status: 'failed', release: null, warning: installError.message };
471
+ }
472
+ }
473
+
435
474
  /**
436
475
  * Refresh the pstack plugin manifest Claude Code reads, when this home has one.
437
476
  *
@@ -2753,6 +2792,13 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2753
2792
  }
2754
2793
  summary.skills = { created: skillsCreated, updated: skillsUpdated, pruned: 0, paths: skillPaths };
2755
2794
  allInstalledFiles.push(...skillPaths);
2795
+ if (!selectedGroups && shouldInstallPstackRelease()) {
2796
+ const pstackRelease = installPstackRelease({ root: CLAUDE_HOME });
2797
+ summary.pstackRelease = pstackRelease;
2798
+ console.log(pstackRelease.warning
2799
+ ? ` Pstack: ${pstackRelease.status} \u2014 ${pstackRelease.warning}`
2800
+ : ` Pstack: ${pstackRelease.status} (${pstackRelease.release})`);
2801
+ }
2756
2802
  const pstackManifestPath = refreshInstalledPstackPluginManifest();
2757
2803
  if (pstackManifestPath) allInstalledFiles.push(pstackManifestPath);
2758
2804
  summary.pstackPlugin = { manifestPath: pstackManifestPath };
@@ -3240,6 +3286,7 @@ Usage:
3240
3286
  npx ${PACKAGE_NAME} --target DIR Install into DIR instead of ~/.claude (overrides CLAUDE_CONFIG_DIR)
3241
3287
  npx ${PACKAGE_NAME} --profile ID Install into one named profile root (under the profiles root)
3242
3288
  npx ${PACKAGE_NAME} --profiles A,B Install into each selected profile (one ownership manifest per target)
3289
+ npx ${PACKAGE_NAME} --no-pstack Full install without the pinned pstack release (also CDE_INSTALL_PSTACK=0)
3243
3290
  npx ${PACKAGE_NAME} --uninstall Remove installed files from the selected root
3244
3291
  npx ${PACKAGE_NAME} --help Show this help
3245
3292
 
@@ -258,6 +258,7 @@ function runInstaller(homeDirectory, extraArguments) {
258
258
  function resolveInstallerInvocation(homeDirectory) {
259
259
  const childEnvironment = {
260
260
  ...process.env,
261
+ CDE_INSTALL_PSTACK: '0',
261
262
  HOME: homeDirectory,
262
263
  USERPROFILE: homeDirectory,
263
264
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -25,6 +25,7 @@ function runInstaller(homeDirectory, extraArguments) {
25
25
  encoding: 'utf8',
26
26
  env: {
27
27
  ...process.env,
28
+ CDE_INSTALL_PSTACK: '0',
28
29
  HOME: homeDirectory,
29
30
  USERPROFILE: homeDirectory,
30
31
  CODEX_HOME: join(homeDirectory, '.codex'),
@@ -49,6 +49,7 @@ function runInstallerInSandbox(sandboxHome, installerArguments = []) {
49
49
  encoding: 'utf8',
50
50
  env: {
51
51
  ...process.env,
52
+ CDE_INSTALL_PSTACK: '0',
52
53
  HOME: sandboxHome,
53
54
  USERPROFILE: sandboxHome,
54
55
  GIT_CONFIG_GLOBAL: gitConfigGlobal,
@@ -11,6 +11,8 @@ import {
11
11
  readdirSync,
12
12
  existsSync,
13
13
  copyFileSync,
14
+ cpSync,
15
+ lstatSync,
14
16
  } from 'node:fs';
15
17
  import { tmpdir } from 'node:os';
16
18
  import { join, dirname } from 'node:path';
@@ -18,6 +20,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
18
20
 
19
21
  import {
20
22
  collectPackageSourceConflicts,
23
+ installPstackRelease,
24
+ shouldInstallPstackRelease,
21
25
  CONTENT_DIRECTORIES,
22
26
  CORE_INCLUDE_DIRECTORIES,
23
27
  CORE_SKILLS,
@@ -301,10 +305,9 @@ test('collectPackageSourceConflicts surfaces both-added and deleted-by-them entr
301
305
  });
302
306
 
303
307
 
304
- test('pythonCandidatesForPlatform prefers py -3 ahead of python on win32 so the Microsoft Store stub is never probed first', () => {
308
+ test('pythonCandidatesForPlatform uses Windows Python launchers on win32', () => {
305
309
  const commands = pythonCandidatesForPlatform('win32').map(candidate => candidate.command);
306
- assert.equal(commands[0], 'py -3');
307
- assert.ok(commands.indexOf('py -3') < commands.indexOf('python'));
310
+ assert.deepEqual(commands, ['py -3', 'python']);
308
311
  });
309
312
 
310
313
 
@@ -314,7 +317,7 @@ test('pythonCandidatesForPlatform keeps python3 first on non-Windows platforms',
314
317
  });
315
318
 
316
319
 
317
- test('pythonCandidatesForPlatform still offers python as a win32 fallback when py -3 and python3 are absent', () => {
320
+ test('pythonCandidatesForPlatform still offers python as a win32 fallback when py -3 is absent', () => {
318
321
  const commands = pythonCandidatesForPlatform('win32').map(candidate => candidate.command);
319
322
  assert.ok(commands.includes('python'));
320
323
  });
@@ -2975,3 +2978,113 @@ test('pruneRetiredHookEntriesFromSettings warns and returns 0 when the host conf
2975
2978
  rmSync(sandboxRoot, { recursive: true, force: true });
2976
2979
  }
2977
2980
  });
2981
+
2982
+ const PSTACK_TEST_PACKAGE_ROOT = dirname(fileURLToPath(new URL('./install.mjs', import.meta.url)));
2983
+ const PSTACK_TEST_INSTALLER_PATH = fileURLToPath(new URL('./install.mjs', import.meta.url));
2984
+ const PSTACK_TEST_LOCK = JSON.parse(readFileSync(
2985
+ new URL('../scripts/pstack.lock.json', import.meta.url),
2986
+ 'utf8',
2987
+ ));
2988
+
2989
+ function writePstackFixtureFile(filePath, fileContent) {
2990
+ mkdirSync(dirname(filePath), { recursive: true });
2991
+ writeFileSync(filePath, fileContent);
2992
+ }
2993
+
2994
+ function pstackUpstreamFixture(t) {
2995
+ const temporaryRoot = mkdtempSync(join(tmpdir(), 'cdev-install-pstack-'));
2996
+ t.after(() => rmSync(temporaryRoot, { recursive: true, force: true }));
2997
+ const checkout = join(temporaryRoot, 'checkout');
2998
+ for (const [component, allSkillNames] of Object.entries(PSTACK_TEST_LOCK.requiredSkills)) {
2999
+ for (const skillName of allSkillNames) {
3000
+ writePstackFixtureFile(
3001
+ join(checkout, component, 'skills', skillName, 'SKILL.md'),
3002
+ `---\nname: ${skillName}\ndescription: Test ${skillName}.\n---\n\nRun ${skillName}.\n`,
3003
+ );
3004
+ }
3005
+ }
3006
+ const managedRoot = join(temporaryRoot, 'home', '.claude');
3007
+ return {
3008
+ managedRoot,
3009
+ options: {
3010
+ root: managedRoot,
3011
+ lock: structuredClone(PSTACK_TEST_LOCK),
3012
+ packageRoot: dirname(PSTACK_TEST_PACKAGE_ROOT),
3013
+ },
3014
+ dependencies: {
3015
+ fetchSource: (_lock, destination) => cpSync(checkout, destination, { recursive: true }),
3016
+ },
3017
+ };
3018
+ }
3019
+
3020
+ function runPstackInstaller(homeDirectory, extraArguments) {
3021
+ return execFileSync('node', [PSTACK_TEST_INSTALLER_PATH, ...extraArguments], {
3022
+ cwd: dirname(PSTACK_TEST_PACKAGE_ROOT),
3023
+ encoding: 'utf8',
3024
+ env: {
3025
+ ...process.env,
3026
+ HOME: homeDirectory,
3027
+ USERPROFILE: homeDirectory,
3028
+ CODEX_HOME: join(homeDirectory, '.codex'),
3029
+ CLAUDE_CONFIG_DIR: join(homeDirectory, '.claude'),
3030
+ GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
3031
+ CDE_INSTALL_PSTACK: '1',
3032
+ GIT_ALLOW_PROTOCOL: 'none',
3033
+ },
3034
+ });
3035
+ }
3036
+
3037
+ function withPstackTemporaryHome(t, runAssertions) {
3038
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-install-pstack-run-'));
3039
+ t.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
3040
+ runAssertions(homeDirectory);
3041
+ }
3042
+
3043
+ test('the base install publishes the pstack poteto-mode entry into the managed skills home', t => {
3044
+ const fixture = pstackUpstreamFixture(t);
3045
+
3046
+ const outcome = installPstackRelease(fixture.options, fixture.dependencies);
3047
+
3048
+ assert.equal(outcome.status, 'installed');
3049
+ assert.equal(outcome.warning, null);
3050
+ const entryPath = join(fixture.managedRoot, 'skills', 'pstack-poteto-mode');
3051
+ assert.equal(lstatSync(entryPath).isSymbolicLink(), true, 'the entry is a pointer into the release');
3052
+ assert.equal(existsSync(join(entryPath, 'SKILL.md')), true, 'the pointer resolves to a skill');
3053
+ });
3054
+
3055
+ test('an unreachable pstack upstream reports a warning and keeps the base install going', t => {
3056
+ const fixture = pstackUpstreamFixture(t);
3057
+
3058
+ const outcome = installPstackRelease(fixture.options, {
3059
+ fetchSource: () => { throw new Error('network unavailable'); },
3060
+ });
3061
+
3062
+ assert.equal(outcome.status, 'failed');
3063
+ assert.equal(outcome.release, null);
3064
+ assert.match(outcome.warning, /network unavailable/);
3065
+ });
3066
+
3067
+ test('--no-pstack and CDE_INSTALL_PSTACK=0 each turn the pstack step off', () => {
3068
+ assert.equal(shouldInstallPstackRelease([], {}), true);
3069
+ assert.equal(shouldInstallPstackRelease(['--no-pstack'], {}), false);
3070
+ assert.equal(shouldInstallPstackRelease([], { CDE_INSTALL_PSTACK: '0' }), false);
3071
+ assert.equal(shouldInstallPstackRelease([], { CDE_INSTALL_PSTACK: '1' }), true);
3072
+ });
3073
+
3074
+ test('a full install runs the pstack step and reports an upstream failure without stopping', t => {
3075
+ withPstackTemporaryHome(t, homeDirectory => {
3076
+ const installerOutput = runPstackInstaller(homeDirectory, []);
3077
+
3078
+ assert.match(installerOutput, /Pstack:/, 'the run reports the pstack step');
3079
+ assert.equal(existsSync(join(homeDirectory, '.claude', 'pstack')), true);
3080
+ });
3081
+ });
3082
+
3083
+ test('--no-pstack leaves the pstack store absent', t => {
3084
+ withPstackTemporaryHome(t, homeDirectory => {
3085
+ const installerOutput = runPstackInstaller(homeDirectory, ['--no-pstack']);
3086
+
3087
+ assert.doesNotMatch(installerOutput, /Pstack:/);
3088
+ assert.equal(existsSync(join(homeDirectory, '.claude', 'pstack')), false);
3089
+ });
3090
+ });
@@ -83,6 +83,7 @@ test('capture and restore recover settings, manifest, files, and hooksPath', ()
83
83
  mkdirSync(priorHooksDirectory, { recursive: true });
84
84
  const env = {
85
85
  ...process.env,
86
+ CDE_INSTALL_PSTACK: '0',
86
87
  HOME: box.root,
87
88
  USERPROFILE: box.root,
88
89
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -139,6 +140,7 @@ test('runWithInstallTransaction restores prior state on injected fault', () => {
139
140
  writeFileSync(gitConfigPath, '');
140
141
  const env = {
141
142
  ...process.env,
143
+ CDE_INSTALL_PSTACK: '0',
142
144
  HOME: box.root,
143
145
  USERPROFILE: box.root,
144
146
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -191,6 +193,7 @@ test('runWithInstallTransaction commits and discards journal on success', () =>
191
193
  const io = {
192
194
  env: {
193
195
  ...process.env,
196
+ CDE_INSTALL_PSTACK: '0',
194
197
  HOME: box.root,
195
198
  USERPROFILE: box.root,
196
199
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -239,6 +242,7 @@ test('runWithInstallTransaction commits and discards journal on success', () =>
239
242
  function runInstaller(homeDirectory, extraArguments, options = {}) {
240
243
  const childEnvironment = {
241
244
  ...process.env,
245
+ CDE_INSTALL_PSTACK: '0',
242
246
  HOME: homeDirectory,
243
247
  USERPROFILE: homeDirectory,
244
248
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -356,6 +360,7 @@ test('installer fault after_git_config restores prior core.hooksPath', () => {
356
360
  writeGlobalCoreHooksPath(priorHooksPath, {
357
361
  env: {
358
362
  ...process.env,
363
+ CDE_INSTALL_PSTACK: '0',
359
364
  HOME: homeDirectory,
360
365
  USERPROFILE: homeDirectory,
361
366
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -371,6 +376,7 @@ test('installer fault after_git_config restores prior core.hooksPath', () => {
371
376
  const restored = readGlobalCoreHooksPath({
372
377
  env: {
373
378
  ...process.env,
379
+ CDE_INSTALL_PSTACK: '0',
374
380
  HOME: homeDirectory,
375
381
  USERPROFILE: homeDirectory,
376
382
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -71,6 +71,7 @@ function writeFileWithParents(filePath, contents) {
71
71
  function runInstaller(homeDirectory, extraArguments, options = {}) {
72
72
  const childEnvironment = {
73
73
  ...process.env,
74
+ CDE_INSTALL_PSTACK: '0',
74
75
  HOME: homeDirectory,
75
76
  USERPROFILE: homeDirectory,
76
77
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -303,6 +304,7 @@ test('uninstall fault after_git_config restores prior core.hooksPath', () => {
303
304
  const io = {
304
305
  env: {
305
306
  ...process.env,
307
+ CDE_INSTALL_PSTACK: '0',
306
308
  HOME: homeDirectory,
307
309
  USERPROFILE: homeDirectory,
308
310
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -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
+ });