claude-dev-env 7.3.0 → 7.4.1
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/.agents/skills/session-continuity/continuity.mjs +1 -3
- package/.agents/skills/session-continuity/continuity.test.mjs +12 -4
- package/AGENTS.md +12 -2
- package/bin/install.agents-home.test.mjs +1 -0
- package/bin/install.codex-rules.test.mjs +2 -0
- package/bin/install.cursor-rules.test.mjs +1 -0
- package/bin/install.mjs +57 -10
- package/bin/install.prune.test.mjs +1 -0
- package/bin/install.pstack-plugin.test.mjs +1 -0
- package/bin/install.settings-defaults.test.mjs +1 -0
- package/bin/install.test.mjs +118 -4
- package/bin/install.transaction.test.mjs +6 -0
- package/bin/install.uninstall-transaction.test.mjs +2 -0
- package/bin/pstack.mjs +24 -4
- package/bin/pstack.test.mjs +20 -10
- package/hooks/blocking/env_var_table_code_drift_blocker.py +11 -99
- package/hooks/blocking/env_var_table_rows.py +130 -0
- package/hooks/blocking/pii_scanner.py +19 -29
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +81 -3
- package/hooks/blocking/test_env_var_table_rows.py +100 -0
- package/hooks/blocking/test_pii_scanner.py +34 -24
- package/hooks/hooks_constants/env_var_table_code_drift_constants.py +12 -4
- package/hooks/hooks_constants/hardcoded_user_path_constants.py +12 -10
- package/hooks/hooks_constants/pii_prevention_constants.py +10 -25
- package/package.json +1 -1
- package/scripts/pstack-adapters/common.md +1 -1
- package/scripts/repository_checks/config/constants.py +7 -17
- package/scripts/repository_checks/config/email_exemptions.py +10 -0
- package/scripts/repository_checks/email_exemptions.py +96 -0
- package/scripts/repository_checks/env_var_documentation.py +1 -9
- package/scripts/repository_checks/tracked_secrets.py +10 -4
- package/scripts/tests/test_email_exemptions.py +121 -0
- package/scripts/tests/test_env_var_documentation.py +26 -0
- package/scripts/tests/test_rules.py +25 -0
- package/scripts/tests/test_tracked_secrets.py +13 -0
|
@@ -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)
|
|
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}:
|
|
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:
|
|
289
|
-
const started = f.fire('SessionStart', '
|
|
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('
|
|
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
|
|
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
|
|
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,
|
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'),
|
package/bin/install.test.mjs
CHANGED
|
@@ -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
|
|
308
|
+
test('pythonCandidatesForPlatform uses Windows Python launchers on win32', () => {
|
|
305
309
|
const commands = pythonCandidatesForPlatform('win32').map(candidate => candidate.command);
|
|
306
|
-
assert.
|
|
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
|
|
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,114 @@ 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 pstackRoot = join(fixture.managedRoot, 'skills', 'pstack');
|
|
3051
|
+
const entryPath = join(pstackRoot, 'poteto-mode');
|
|
3052
|
+
assert.equal(lstatSync(pstackRoot).isSymbolicLink(), true, 'the pstack root is a pointer into the release');
|
|
3053
|
+
assert.equal(existsSync(join(entryPath, 'SKILL.md')), true, 'the nested entry resolves to a skill');
|
|
3054
|
+
});
|
|
3055
|
+
|
|
3056
|
+
test('an unreachable pstack upstream reports a warning and keeps the base install going', t => {
|
|
3057
|
+
const fixture = pstackUpstreamFixture(t);
|
|
3058
|
+
|
|
3059
|
+
const outcome = installPstackRelease(fixture.options, {
|
|
3060
|
+
fetchSource: () => { throw new Error('network unavailable'); },
|
|
3061
|
+
});
|
|
3062
|
+
|
|
3063
|
+
assert.equal(outcome.status, 'failed');
|
|
3064
|
+
assert.equal(outcome.release, null);
|
|
3065
|
+
assert.match(outcome.warning, /network unavailable/);
|
|
3066
|
+
});
|
|
3067
|
+
|
|
3068
|
+
test('--no-pstack and CDE_INSTALL_PSTACK=0 each turn the pstack step off', () => {
|
|
3069
|
+
assert.equal(shouldInstallPstackRelease([], {}), true);
|
|
3070
|
+
assert.equal(shouldInstallPstackRelease(['--no-pstack'], {}), false);
|
|
3071
|
+
assert.equal(shouldInstallPstackRelease([], { CDE_INSTALL_PSTACK: '0' }), false);
|
|
3072
|
+
assert.equal(shouldInstallPstackRelease([], { CDE_INSTALL_PSTACK: '1' }), true);
|
|
3073
|
+
});
|
|
3074
|
+
|
|
3075
|
+
test('a full install runs the pstack step and reports an upstream failure without stopping', t => {
|
|
3076
|
+
withPstackTemporaryHome(t, homeDirectory => {
|
|
3077
|
+
const installerOutput = runPstackInstaller(homeDirectory, []);
|
|
3078
|
+
|
|
3079
|
+
assert.match(installerOutput, /Pstack:/, 'the run reports the pstack step');
|
|
3080
|
+
assert.equal(existsSync(join(homeDirectory, '.claude', 'pstack')), true);
|
|
3081
|
+
});
|
|
3082
|
+
});
|
|
3083
|
+
|
|
3084
|
+
test('--no-pstack leaves the pstack store absent', t => {
|
|
3085
|
+
withPstackTemporaryHome(t, homeDirectory => {
|
|
3086
|
+
const installerOutput = runPstackInstaller(homeDirectory, ['--no-pstack']);
|
|
3087
|
+
|
|
3088
|
+
assert.doesNotMatch(installerOutput, /Pstack:/);
|
|
3089
|
+
assert.equal(existsSync(join(homeDirectory, '.claude', 'pstack')), false);
|
|
3090
|
+
});
|
|
3091
|
+
});
|
|
@@ -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,
|
package/bin/pstack.mjs
CHANGED
|
@@ -84,6 +84,17 @@ function adaptSkill(text, name, releaseRoot, skillRoot) {
|
|
|
84
84
|
+ fork + '\n' + match[2].trimStart();
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function writePstackPluginManifest(stage, skills) {
|
|
88
|
+
const manifest = {
|
|
89
|
+
$schema: 'https://anthropic.com/claude-code/plugin.schema.json',
|
|
90
|
+
name: 'pstack',
|
|
91
|
+
skills: skills.filter(skill => skill.component === 'pstack').map(skill => `./${skill.slug}`),
|
|
92
|
+
};
|
|
93
|
+
const pluginRoot = join(stage, 'runtime', 'pstack', 'skills');
|
|
94
|
+
mkdirSync(join(pluginRoot, '.claude-plugin'), { recursive: true });
|
|
95
|
+
writeFileSync(join(pluginRoot, '.claude-plugin', 'plugin.json'), json(manifest));
|
|
96
|
+
}
|
|
97
|
+
|
|
87
98
|
export function prepareRelease(checkout, stage, finalRoot, lock, adapters) {
|
|
88
99
|
const skills = [];
|
|
89
100
|
const agents = [];
|
|
@@ -102,10 +113,11 @@ export function prepareRelease(checkout, stage, finalRoot, lock, adapters) {
|
|
|
102
113
|
}
|
|
103
114
|
for (const slug of names) {
|
|
104
115
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) throw new Error(`Invalid skill directory: ${slug}`);
|
|
105
|
-
const name = `${component}-${slug}`;
|
|
116
|
+
const name = component === 'pstack' ? `pstack:${slug}` : `${component}-${slug}`;
|
|
117
|
+
const frontmatterName = component === 'pstack' ? slug : name;
|
|
106
118
|
const path = join('runtime', component, 'skills', slug);
|
|
107
119
|
const text = readFileSync(join(source, 'skills', slug, 'SKILL.md'), 'utf8');
|
|
108
|
-
writeFileSync(join(stage, path, 'SKILL.md'), adaptSkill(text,
|
|
120
|
+
writeFileSync(join(stage, path, 'SKILL.md'), adaptSkill(text, frontmatterName, finalRoot, join(finalRoot, path)));
|
|
109
121
|
skills.push({ name, component, slug, path });
|
|
110
122
|
}
|
|
111
123
|
const agentHome = join(source, 'agents');
|
|
@@ -118,6 +130,7 @@ export function prepareRelease(checkout, stage, finalRoot, lock, adapters) {
|
|
|
118
130
|
mkdirSync(join(stage, creatorPath), { recursive: true });
|
|
119
131
|
writeFileSync(join(stage, creatorPath, 'SKILL.md'), adapters['create-skill.md']);
|
|
120
132
|
skills.push({ name: 'cde-create-skill', component: 'cde', slug: 'create-skill', path: creatorPath });
|
|
133
|
+
writePstackPluginManifest(stage, skills);
|
|
121
134
|
const knownNames = new Set(skills.map(skill => `${skill.component}:${skill.slug}`));
|
|
122
135
|
for (const path of filesUnder(join(stage, 'upstream')).filter(path => path.endsWith('.md') && (path.includes(sep + 'skills' + sep) || path.includes(sep + 'agents' + sep)))) {
|
|
123
136
|
const references = readFileSync(path, 'utf8').matchAll(/\b(pstack|cursor-team-kit):([a-z][a-z0-9-]*)/g);
|
|
@@ -146,7 +159,9 @@ export function verifyRelease(root) {
|
|
|
146
159
|
if (digest(readFileSync(assertContained(root, path))) !== hash) throw new Error(`Changed installed file: ${path}`);
|
|
147
160
|
}
|
|
148
161
|
for (const skill of release.skills) {
|
|
149
|
-
|
|
162
|
+
const validPart = value => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
163
|
+
const expectedName = skill.component === 'pstack' ? `pstack:${skill.slug}` : `${skill.component}-${skill.slug}`;
|
|
164
|
+
if (!validPart(skill.component) || !validPart(skill.slug) || skill.name !== expectedName) throw new Error('Invalid installed skill name');
|
|
150
165
|
const path = join(assertContained(root, skill.path), 'SKILL.md');
|
|
151
166
|
if (!Object.hasOwn(release.fileDigests, relative(root, path)) || !existsSync(path)) throw new Error(`Missing entry: ${skill.name}`);
|
|
152
167
|
}
|
|
@@ -177,7 +192,12 @@ export function verifyInstallation(options = {}) {
|
|
|
177
192
|
|
|
178
193
|
function publish(releaseRoot, release, homes, prior) {
|
|
179
194
|
const uniqueHomes = [...new Set(homes.map(home => { mkdirSync(home, { recursive: true }); return realpathSync(home); }))];
|
|
180
|
-
const
|
|
195
|
+
const pstackSkill = release.skills.find(skill => skill.component === 'pstack');
|
|
196
|
+
const directSkills = release.skills.filter(skill => skill.component !== 'pstack');
|
|
197
|
+
const links = Object.fromEntries(uniqueHomes.flatMap(home => [
|
|
198
|
+
...(pstackSkill ? [[join(home, 'pstack'), join(releaseRoot, dirname(pstackSkill.path))]] : []),
|
|
199
|
+
...directSkills.map(skill => [join(home, skill.name), join(releaseRoot, skill.path)]),
|
|
200
|
+
]));
|
|
181
201
|
const paths = new Set([...Object.keys(prior?.links ?? {}), ...Object.keys(links)]);
|
|
182
202
|
const before = new Map();
|
|
183
203
|
for (const path of paths) {
|
package/bin/pstack.test.mjs
CHANGED
|
@@ -44,15 +44,23 @@ for (const host of ['claude', 'codex', 'cursor']) {
|
|
|
44
44
|
assert.equal(result.commit, '27e2a62ff94f9af4b5e68435e41cdceacadb840c');
|
|
45
45
|
assert.equal(result.verification, 'filesystem-only');
|
|
46
46
|
const release = join(f.store, 'releases', result.release);
|
|
47
|
-
const
|
|
47
|
+
const releaseManifest = JSON.parse(readFileSync(join(release, 'release.json'), 'utf8'));
|
|
48
|
+
assert.equal(releaseManifest.skills.find(skill => skill.component === 'pstack').name, 'pstack:poteto-mode');
|
|
49
|
+
const expected = ['cde-create-skill', 'cursor-team-kit-control-cli', 'cursor-team-kit-control-ui', 'cursor-team-kit-deslop', 'pstack'];
|
|
48
50
|
for (const home of ['.claude', '.agents']) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
const skillsHome = join(f.options.project, home, 'skills');
|
|
52
|
+
assert.deepEqual(readdirSync(skillsHome).sort(), expected);
|
|
53
|
+
const pstackRoot = join(skillsHome, 'pstack');
|
|
54
|
+
assert.equal(lstatSync(pstackRoot).isSymbolicLink(), true);
|
|
55
|
+
const entry = join(pstackRoot, 'poteto-mode');
|
|
51
56
|
assert.equal(realpathSync(entry), join(release, 'runtime', 'pstack', 'skills', 'poteto-mode'));
|
|
52
57
|
const text = readFileSync(join(entry, 'SKILL.md'), 'utf8');
|
|
53
|
-
assert.match(text, /name:
|
|
58
|
+
assert.match(text, /name: poteto-mode/);
|
|
54
59
|
assert.ok(text.includes(JSON.stringify(join(release, 'compat', 'common.md'))));
|
|
55
60
|
assert.equal(readFileSync(join(entry, 'playbooks', 'feature.md'), 'utf8'), 'Build a small task, delegate, and verify it.\n');
|
|
61
|
+
const manifest = JSON.parse(readFileSync(join(pstackRoot, '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
62
|
+
assert.equal(manifest.name, 'pstack');
|
|
63
|
+
assert.deepEqual(manifest.skills, ['./poteto-mode']);
|
|
56
64
|
}
|
|
57
65
|
assert.equal(existsSync(join(f.options.project, '.cursor')), false);
|
|
58
66
|
for (const component of baseLock.components) {
|
|
@@ -89,10 +97,12 @@ test('changed, added, and removed skills converge while old releases remain read
|
|
|
89
97
|
const after = installPstack({ ...f.options, lock: { ...baseLock, commit: 'b'.repeat(40) } }, f.dependencies);
|
|
90
98
|
assert.equal(after.skillCount, 6);
|
|
91
99
|
for (const home of ['.claude', '.agents']) {
|
|
92
|
-
const
|
|
93
|
-
assert.equal(existsSync(join(
|
|
94
|
-
assert.equal(readFileSync(join(
|
|
95
|
-
assert.equal(readFileSync(join(
|
|
100
|
+
const pstackRoot = join(f.options.project, home, 'skills', 'pstack');
|
|
101
|
+
assert.equal(existsSync(join(pstackRoot, 'old')), false);
|
|
102
|
+
assert.equal(readFileSync(join(pstackRoot, 'new', 'SKILL.md'), 'utf8').endsWith('New body.\n'), true);
|
|
103
|
+
assert.equal(readFileSync(join(pstackRoot, 'poteto-mode', 'playbooks', 'feature.md'), 'utf8'), 'Changed playbook.\n');
|
|
104
|
+
const manifest = JSON.parse(readFileSync(join(pstackRoot, '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
105
|
+
assert.deepEqual(manifest.skills, ['./new', './poteto-mode']);
|
|
96
106
|
}
|
|
97
107
|
assert.equal(readFileSync(join(oldRelease, 'runtime', 'pstack', 'skills', 'poteto-mode', 'SKILL.md'), 'utf8'), firstBody);
|
|
98
108
|
});
|
|
@@ -117,9 +127,9 @@ test('a failed first install returns failure and publishes no skills', t => {
|
|
|
117
127
|
assert.equal(existsSync(join(f.store, '.install-lock')), false);
|
|
118
128
|
});
|
|
119
129
|
|
|
120
|
-
test('unmanaged
|
|
130
|
+
test('unmanaged pstack root collision preserves user files', t => {
|
|
121
131
|
const f = fixture(t);
|
|
122
|
-
const userFile = join(f.options.project, '.claude', 'skills', 'pstack
|
|
132
|
+
const userFile = join(f.options.project, '.claude', 'skills', 'pstack', 'poteto-mode', 'SKILL.md');
|
|
123
133
|
put(userFile, 'Personal workflow\n');
|
|
124
134
|
assert.throws(() => installPstack(f.options, f.dependencies), /Keep existing unmanaged path/);
|
|
125
135
|
assert.equal(readFileSync(userFile, 'utf8'), 'Personal workflow\n');
|