claude-dev-env 8.33.1 → 8.34.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.
@@ -8,64 +8,94 @@ export const PSTACK_PLUGIN_HOSTS = Object.freeze(['claude', 'codex']);
8
8
  export const PSTACK_PLUGIN_OPT_OUT_FLAG = '--no-pstack';
9
9
  export const PSTACK_PLUGIN_OPT_OUT_VARIABLE = 'CDE_INSTALL_PSTACK';
10
10
 
11
- const HOST_PLANS = Object.freeze({
11
+ export const PSTACK_PLUGIN_SPEC = Object.freeze({
12
+ name: 'pstack',
13
+ label: 'Pstack',
14
+ marketplaceRepository: PSTACK_MARKETPLACE_REPOSITORY,
15
+ marketplaceAddArguments: Object.freeze([]),
16
+ pluginIdentifier: PSTACK_PLUGIN_IDENTIFIER,
17
+ hosts: PSTACK_PLUGIN_HOSTS,
18
+ optOutFlag: PSTACK_PLUGIN_OPT_OUT_FLAG,
19
+ optOutVariable: PSTACK_PLUGIN_OPT_OUT_VARIABLE,
20
+ });
21
+
22
+ export const USAGE_WRAPUP_PLUGIN_SPEC = Object.freeze({
23
+ name: 'usage-wrapup',
24
+ label: 'Usage-wrapup',
25
+ marketplaceRepository: 'jl-cmd/claude-dev-env',
26
+ marketplaceAddArguments: Object.freeze(['--sparse', '.claude-plugin']),
27
+ pluginIdentifier: 'usage-wrapup@claude-dev-env',
28
+ hosts: Object.freeze(['claude']),
29
+ optOutFlag: '--no-usage-wrapup',
30
+ optOutVariable: 'CDE_INSTALL_USAGE_WRAPUP',
31
+ });
32
+
33
+ const HOST_DEFINITIONS = Object.freeze({
12
34
  claude: Object.freeze({
13
35
  executable: 'claude',
14
36
  executableVariable: 'CDE_CLAUDE_EXECUTABLE',
15
37
  homeVariable: 'CLAUDE_CONFIG_DIR',
16
- commands: Object.freeze([
17
- Object.freeze(['plugin', 'marketplace', 'add', PSTACK_MARKETPLACE_REPOSITORY]),
18
- Object.freeze(['plugin', 'install', PSTACK_PLUGIN_IDENTIFIER]),
19
- ]),
38
+ installVerb: 'install',
20
39
  }),
21
40
  codex: Object.freeze({
22
41
  executable: 'codex',
23
42
  executableVariable: 'CDE_CODEX_EXECUTABLE',
24
43
  homeVariable: 'CODEX_HOME',
25
- commands: Object.freeze([
26
- Object.freeze(['plugin', 'marketplace', 'add', PSTACK_MARKETPLACE_REPOSITORY]),
27
- Object.freeze(['plugin', 'add', PSTACK_PLUGIN_IDENTIFIER]),
28
- ]),
44
+ installVerb: 'add',
29
45
  }),
30
46
  });
31
47
 
32
48
  /**
33
- * Read the marketplace and plugin commands one host installs pstack with.
49
+ * Read the marketplace and plugin commands one host installs a plugin with.
34
50
  *
35
- * Claude Code and Codex publish the same marketplace under different plugin
36
- * verbs, so the command list belongs to the host rather than to the caller.
51
+ * Claude Code and Codex publish a marketplace under different plugin verbs, so
52
+ * the install verb belongs to the host and the marketplace and identifier
53
+ * belong to the plugin spec. A spec whose marketplace is a large repository
54
+ * passes `--sparse .claude-plugin` among its marketplace add arguments, so the
55
+ * clone holds only the catalog and stays clear of Windows path-length limits.
37
56
  *
38
- * @param {string} host Either `claude` or `codex`.
57
+ * @param {typeof PSTACK_PLUGIN_SPEC} spec The plugin to install.
58
+ * @param {string} host A host the spec names, `claude` or `codex`.
39
59
  * @returns {{executable: string, executableVariable: string, homeVariable: string,
40
60
  * commands: ReadonlyArray<ReadonlyArray<string>>}} The host's install plan.
41
61
  */
42
- export function pstackPluginPlan(host) {
43
- const plan = HOST_PLANS[host];
44
- if (!plan) {
62
+ export function marketplacePluginPlan(spec, host) {
63
+ const hostDefinition = HOST_DEFINITIONS[host];
64
+ if (!hostDefinition || !spec.hosts.includes(host)) {
45
65
  throw new Error(
46
- `Unsupported pstack plugin host ${host}: this installer knows ${PSTACK_PLUGIN_HOSTS.join(', ')}.`,
66
+ `Unsupported ${spec.name} plugin host ${host}: this installer knows ${spec.hosts.join(', ')}.`,
47
67
  );
48
68
  }
49
- return plan;
69
+ return {
70
+ executable: hostDefinition.executable,
71
+ executableVariable: hostDefinition.executableVariable,
72
+ homeVariable: hostDefinition.homeVariable,
73
+ commands: [
74
+ ['plugin', 'marketplace', 'add', spec.marketplaceRepository, ...spec.marketplaceAddArguments],
75
+ ['plugin', hostDefinition.installVerb, spec.pluginIdentifier],
76
+ ],
77
+ };
50
78
  }
51
79
 
52
80
  /**
53
- * Decide whether a full install also installs the pstack plugin.
81
+ * Decide whether a full install also installs one marketplace plugin.
54
82
  *
55
- * The step reaches the network for the marketplace repository. `--no-pstack`
56
- * on the command line, or `CDE_INSTALL_PSTACK=0` in the environment, turns it
57
- * off for an air-gapped or offline run.
83
+ * The step reaches the network for the marketplace repository. The spec's
84
+ * opt-out flag on the command line, or its opt-out variable set to `0` in the
85
+ * environment, turns it off for an air-gapped or offline run.
58
86
  *
87
+ * @param {typeof PSTACK_PLUGIN_SPEC} spec The plugin to install.
59
88
  * @param {string[]} [argumentList] The command-line arguments after the script.
60
89
  * @param {Record<string, string|undefined>} [environment] The process environment.
61
- * @returns {boolean} True when this run installs the pstack plugin.
90
+ * @returns {boolean} True when this run installs the plugin.
62
91
  */
63
- export function shouldInstallPstackPlugin(
92
+ export function shouldInstallMarketplacePlugin(
93
+ spec,
64
94
  argumentList = process.argv.slice(2),
65
95
  environment = process.env,
66
96
  ) {
67
- if (argumentList.includes(PSTACK_PLUGIN_OPT_OUT_FLAG)) return false;
68
- return environment[PSTACK_PLUGIN_OPT_OUT_VARIABLE] !== '0';
97
+ if (argumentList.includes(spec.optOutFlag)) return false;
98
+ return environment[spec.optOutVariable] !== '0';
69
99
  }
70
100
 
71
101
  /**
@@ -126,8 +156,8 @@ function firstLine(text) {
126
156
  return trimmed.split(/\r?\n/).filter(Boolean).at(-1);
127
157
  }
128
158
 
129
- function installForHost(host, homeDirectory, environment, runCommand) {
130
- const plan = pstackPluginPlan(host);
159
+ function installForHost(spec, host, homeDirectory, environment, runCommand) {
160
+ const plan = marketplacePluginPlan(spec, host);
131
161
  const executable = environment[plan.executableVariable] || plan.executable;
132
162
  const commandEnvironment = { [plan.homeVariable]: homeDirectory };
133
163
  for (const commandArguments of plan.commands) {
@@ -139,7 +169,7 @@ function installForHost(host, homeDirectory, environment, runCommand) {
139
169
  host,
140
170
  executable,
141
171
  status: 'skipped',
142
- warning: `${executable} is not on PATH, so pstack was not installed for ${host}.`,
172
+ warning: `${executable} is not on PATH, so ${spec.name} was not installed for ${host}.`,
143
173
  };
144
174
  }
145
175
  if (outcome.status !== 0 || outcome.error) {
@@ -156,7 +186,7 @@ function installForHost(host, homeDirectory, environment, runCommand) {
156
186
  }
157
187
 
158
188
  /**
159
- * Install the pstack plugin from its marketplace into each host's own home.
189
+ * Install one plugin from its marketplace into each host's own home.
160
190
  *
161
191
  * Each host is one member of the batch. A host without its command-line tool
162
192
  * is skipped and a host whose command fails is reported, so the rules, hooks,
@@ -165,20 +195,22 @@ function installForHost(host, homeDirectory, environment, runCommand) {
165
195
  * `cmd.exe`'s command-not-found exit code on Windows, and both read as
166
196
  * skipped.
167
197
  *
198
+ * @param {typeof PSTACK_PLUGIN_SPEC} spec The plugin to install.
168
199
  * @param {object} [options] Install targets.
169
200
  * @param {string} [options.claudeRoot] The managed Claude root to install into.
170
201
  * @param {string} [options.codexHome] The Codex home to install into.
171
- * @param {string[]} [options.hosts] The hosts to install. Defaults to both.
202
+ * @param {string[]} [options.hosts] The hosts to install. Defaults to the spec's hosts.
172
203
  * @param {Record<string, string|undefined>} [options.environment] The process environment.
173
204
  * @param {object} [dependencies] Seams for the command runner.
174
205
  * @returns {{status: string, hosts: object[], warning: string|null}} The outcome per host.
175
206
  */
176
- export function installPstackPlugin(options = {}, dependencies = {}) {
207
+ export function installMarketplacePlugin(spec, options = {}, dependencies = {}) {
177
208
  const environment = options.environment ?? process.env;
178
209
  const runCommand = dependencies.runCommand ?? runHostCommand;
179
210
  const homeDirectories = { claude: options.claudeRoot, codex: options.codexHome };
180
- const hosts = options.hosts ?? PSTACK_PLUGIN_HOSTS;
211
+ const hosts = options.hosts ?? spec.hosts;
181
212
  const hostOutcomes = hosts.map(host => installForHost(
213
+ spec,
182
214
  host,
183
215
  homeDirectories[host],
184
216
  environment,
@@ -1,13 +1,16 @@
1
1
  import { test } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
+ import { readFileSync } from 'node:fs';
3
4
  import {
4
5
  PSTACK_MARKETPLACE_REPOSITORY,
5
6
  PSTACK_PLUGIN_HOSTS,
6
7
  PSTACK_PLUGIN_IDENTIFIER,
8
+ PSTACK_PLUGIN_SPEC,
9
+ USAGE_WRAPUP_PLUGIN_SPEC,
7
10
  hostCommandInvocation,
8
- installPstackPlugin,
9
- pstackPluginPlan,
10
- shouldInstallPstackPlugin,
11
+ installMarketplacePlugin,
12
+ marketplacePluginPlan,
13
+ shouldInstallMarketplacePlugin,
11
14
  } from './install-pstack-plugin.mjs';
12
15
 
13
16
  function recordingRunner(outcomeForCommand = () => ({ status: 0, stderr: '' })) {
@@ -30,7 +33,7 @@ const ROOTS = Object.freeze({
30
33
  const WINDOWS_ENVIRONMENT = Object.freeze({ ComSpec: 'C:\\Windows\\system32\\cmd.exe' });
31
34
 
32
35
  test('the Claude plan carries the two documented plugin commands', () => {
33
- const plan = pstackPluginPlan('claude');
36
+ const plan = marketplacePluginPlan(PSTACK_PLUGIN_SPEC, 'claude');
34
37
  assert.equal(plan.executable, 'claude');
35
38
  assert.deepEqual(plan.commands, [
36
39
  ['plugin', 'marketplace', 'add', PSTACK_MARKETPLACE_REPOSITORY],
@@ -39,7 +42,7 @@ test('the Claude plan carries the two documented plugin commands', () => {
39
42
  });
40
43
 
41
44
  test('the Codex plan adds the plugin with the Codex verb', () => {
42
- const plan = pstackPluginPlan('codex');
45
+ const plan = marketplacePluginPlan(PSTACK_PLUGIN_SPEC, 'codex');
43
46
  assert.equal(plan.executable, 'codex');
44
47
  assert.deepEqual(plan.commands, [
45
48
  ['plugin', 'marketplace', 'add', PSTACK_MARKETPLACE_REPOSITORY],
@@ -48,13 +51,13 @@ test('the Codex plan adds the plugin with the Codex verb', () => {
48
51
  });
49
52
 
50
53
  test('an unsupported host names the hosts this installer knows', () => {
51
- assert.throws(() => pstackPluginPlan('cursor'), /cursor/);
54
+ assert.throws(() => marketplacePluginPlan(PSTACK_PLUGIN_SPEC, 'cursor'), /cursor/);
52
55
  assert.deepEqual(PSTACK_PLUGIN_HOSTS, ['claude', 'codex']);
53
56
  });
54
57
 
55
58
  test('each host installs into the managed root this run wrote', () => {
56
59
  const runner = recordingRunner();
57
- const outcome = installPstackPlugin(ROOTS, runner);
60
+ const outcome = installMarketplacePlugin(PSTACK_PLUGIN_SPEC, ROOTS, runner);
58
61
  assert.equal(outcome.status, 'installed');
59
62
  assert.deepEqual(runner.calls.map(call => [call.executable, ...call.commandArguments]), [
60
63
  ['claude', 'plugin', 'marketplace', 'add', PSTACK_MARKETPLACE_REPOSITORY],
@@ -70,7 +73,7 @@ test('an absent host command skips that host and leaves the other installed', ()
70
73
  const runner = recordingRunner((executable) => (executable === 'codex'
71
74
  ? { status: null, error: Object.assign(new Error('spawn codex ENOENT'), { code: 'ENOENT' }) }
72
75
  : { status: 0, stderr: '' }));
73
- const outcome = installPstackPlugin(ROOTS, runner);
76
+ const outcome = installMarketplacePlugin(PSTACK_PLUGIN_SPEC, ROOTS, runner);
74
77
  assert.equal(outcome.hosts.find(host => host.host === 'claude').status, 'installed');
75
78
  const codex = outcome.hosts.find(host => host.host === 'codex');
76
79
  assert.equal(codex.status, 'skipped');
@@ -83,7 +86,7 @@ test('a failing command reports that host and still installs the next one', () =
83
86
  executable === 'claude' && commandArguments[1] === 'marketplace'
84
87
  ? { status: 1, stderr: 'marketplace unreachable' }
85
88
  : { status: 0, stderr: '' }));
86
- const outcome = installPstackPlugin(ROOTS, runner);
89
+ const outcome = installMarketplacePlugin(PSTACK_PLUGIN_SPEC, ROOTS, runner);
87
90
  assert.equal(outcome.status, 'failed');
88
91
  const claude = outcome.hosts.find(host => host.host === 'claude');
89
92
  assert.equal(claude.status, 'failed');
@@ -94,7 +97,8 @@ test('a failing command reports that host and still installs the next one', () =
94
97
 
95
98
  test('an executable override replaces the host command for that host only', () => {
96
99
  const runner = recordingRunner();
97
- installPstackPlugin(
100
+ installMarketplacePlugin(
101
+ PSTACK_PLUGIN_SPEC,
98
102
  { ...ROOTS, environment: { CDE_CODEX_EXECUTABLE: '/opt/codex/bin/codex' } },
99
103
  runner,
100
104
  );
@@ -105,15 +109,15 @@ test('an executable override replaces the host command for that host only', () =
105
109
  });
106
110
 
107
111
  test('the opt-out flag and variable both turn the step off', () => {
108
- assert.equal(shouldInstallPstackPlugin([], {}), true);
109
- assert.equal(shouldInstallPstackPlugin(['--no-pstack'], {}), false);
110
- assert.equal(shouldInstallPstackPlugin([], { CDE_INSTALL_PSTACK: '0' }), false);
111
- assert.equal(shouldInstallPstackPlugin([], { CDE_INSTALL_PSTACK: '1' }), true);
112
+ assert.equal(shouldInstallMarketplacePlugin(PSTACK_PLUGIN_SPEC, [], {}), true);
113
+ assert.equal(shouldInstallMarketplacePlugin(PSTACK_PLUGIN_SPEC, ['--no-pstack'], {}), false);
114
+ assert.equal(shouldInstallMarketplacePlugin(PSTACK_PLUGIN_SPEC, [], { CDE_INSTALL_PSTACK: '0' }), false);
115
+ assert.equal(shouldInstallMarketplacePlugin(PSTACK_PLUGIN_SPEC, [], { CDE_INSTALL_PSTACK: '1' }), true);
112
116
  });
113
117
 
114
118
  test('a selected host list installs only that host', () => {
115
119
  const runner = recordingRunner();
116
- const outcome = installPstackPlugin({ ...ROOTS, hosts: ['codex'] }, runner);
120
+ const outcome = installMarketplacePlugin(PSTACK_PLUGIN_SPEC, { ...ROOTS, hosts: ['codex'] }, runner);
117
121
  assert.deepEqual(outcome.hosts.map(host => host.host), ['codex']);
118
122
  assert.deepEqual([...new Set(runner.calls.map(call => call.executable))], ['codex']);
119
123
  });
@@ -166,7 +170,7 @@ test("cmd.exe's command-not-found exit code reads as an absent host, not a failu
166
170
  stderr: "'codex' is not recognized as an internal or external command,\noperable program or batch file.",
167
171
  }
168
172
  : { status: 0, stderr: '' }));
169
- const outcome = installPstackPlugin(ROOTS, runner);
173
+ const outcome = installMarketplacePlugin(PSTACK_PLUGIN_SPEC, ROOTS, runner);
170
174
  const codex = outcome.hosts.find(host => host.host === 'codex');
171
175
  assert.equal(codex.status, 'skipped');
172
176
  assert.equal(outcome.status, 'installed');
@@ -175,7 +179,79 @@ test("cmd.exe's command-not-found exit code reads as an absent host, not a failu
175
179
 
176
180
  test('a non-zero exit that is not command-not-found still reads as a failure', () => {
177
181
  const runner = recordingRunner(() => ({ status: 9009, stderr: 'the marketplace rejected the catalog' }));
178
- const outcome = installPstackPlugin({ ...ROOTS, hosts: ['claude'] }, runner);
182
+ const outcome = installMarketplacePlugin(PSTACK_PLUGIN_SPEC, { ...ROOTS, hosts: ['claude'] }, runner);
179
183
  assert.equal(outcome.hosts[0].status, 'failed');
180
184
  assert.match(outcome.hosts[0].warning, /rejected the catalog/);
181
185
  });
186
+
187
+ const USAGE_WRAPUP_PINNED_COMMIT = 'd78c314e5787786e866bf5c4967107365854dc78';
188
+
189
+ test('the usage-wrapup plan adds this repository marketplace and installs the plugin on Claude', () => {
190
+ const plan = marketplacePluginPlan(USAGE_WRAPUP_PLUGIN_SPEC, 'claude');
191
+ assert.equal(plan.executable, 'claude');
192
+ assert.equal(plan.homeVariable, 'CLAUDE_CONFIG_DIR');
193
+ assert.deepEqual(plan.commands, [
194
+ ['plugin', 'marketplace', 'add', 'jl-cmd/claude-dev-env', '--sparse', '.claude-plugin'],
195
+ ['plugin', 'install', 'usage-wrapup@claude-dev-env'],
196
+ ]);
197
+ });
198
+
199
+ test('usage-wrapup is a Claude-only plugin and refuses a Codex plan', () => {
200
+ assert.deepEqual(USAGE_WRAPUP_PLUGIN_SPEC.hosts, ['claude']);
201
+ assert.throws(() => marketplacePluginPlan(USAGE_WRAPUP_PLUGIN_SPEC, 'codex'), /usage-wrapup plugin host codex/);
202
+ });
203
+
204
+ test('installing usage-wrapup runs only the Claude commands, inside the managed Claude root', () => {
205
+ const runner = recordingRunner();
206
+ const outcome = installMarketplacePlugin(USAGE_WRAPUP_PLUGIN_SPEC, ROOTS, runner);
207
+ assert.equal(outcome.status, 'installed');
208
+ assert.deepEqual(outcome.hosts.map(host => host.host), ['claude']);
209
+ assert.deepEqual(runner.calls.map(call => [call.executable, ...call.commandArguments]), [
210
+ ['claude', 'plugin', 'marketplace', 'add', 'jl-cmd/claude-dev-env', '--sparse', '.claude-plugin'],
211
+ ['claude', 'plugin', 'install', 'usage-wrapup@claude-dev-env'],
212
+ ]);
213
+ for (const call of runner.calls) {
214
+ assert.deepEqual(call.environment, { CLAUDE_CONFIG_DIR: ROOTS.claudeRoot });
215
+ }
216
+ });
217
+
218
+ test('an absent claude command skips usage-wrapup and names the plugin in the warning', () => {
219
+ const runner = recordingRunner(() => ({
220
+ status: null,
221
+ error: Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }),
222
+ }));
223
+ const outcome = installMarketplacePlugin(USAGE_WRAPUP_PLUGIN_SPEC, ROOTS, runner);
224
+ assert.equal(outcome.status, 'skipped');
225
+ assert.match(outcome.warning, /usage-wrapup was not installed for claude/);
226
+ });
227
+
228
+ test('the usage-wrapup opt-out flag and variable turn off only usage-wrapup', () => {
229
+ assert.equal(shouldInstallMarketplacePlugin(USAGE_WRAPUP_PLUGIN_SPEC, [], {}), true);
230
+ assert.equal(shouldInstallMarketplacePlugin(USAGE_WRAPUP_PLUGIN_SPEC, ['--no-usage-wrapup'], {}), false);
231
+ assert.equal(
232
+ shouldInstallMarketplacePlugin(USAGE_WRAPUP_PLUGIN_SPEC, [], { CDE_INSTALL_USAGE_WRAPUP: '0' }),
233
+ false,
234
+ );
235
+ assert.equal(shouldInstallMarketplacePlugin(USAGE_WRAPUP_PLUGIN_SPEC, ['--no-pstack'], { CDE_INSTALL_PSTACK: '0' }), true);
236
+ assert.equal(
237
+ shouldInstallMarketplacePlugin(PSTACK_PLUGIN_SPEC, ['--no-usage-wrapup'], { CDE_INSTALL_USAGE_WRAPUP: '0' }),
238
+ true,
239
+ );
240
+ });
241
+
242
+ test('the repository marketplace lists usage-wrapup from the fork, pinned to the reviewed commit', () => {
243
+ const marketplace = JSON.parse(readFileSync(
244
+ new URL('../../../.claude-plugin/marketplace.json', import.meta.url),
245
+ 'utf8',
246
+ ));
247
+ const [pluginName, marketplaceName] = USAGE_WRAPUP_PLUGIN_SPEC.pluginIdentifier.split('@');
248
+ assert.equal(marketplace.name, marketplaceName);
249
+ const entry = marketplace.plugins.find(plugin => plugin.name === pluginName);
250
+ assert.ok(entry, `the marketplace lists ${pluginName}`);
251
+ assert.deepEqual(entry.source, {
252
+ source: 'github',
253
+ repo: 'jl-cmd/usage-wrapup',
254
+ ref: 'main',
255
+ sha: USAGE_WRAPUP_PINNED_COMMIT,
256
+ });
257
+ });
@@ -51,6 +51,7 @@ function runInstaller(homeDirectory, extraArguments, environmentOverrides = {})
51
51
  const installerEnvironment = {
52
52
  ...process.env,
53
53
  CDE_INSTALL_PSTACK: '0',
54
+ CDE_INSTALL_USAGE_WRAPUP: '0',
54
55
  HOME: homeDirectory,
55
56
  USERPROFILE: homeDirectory,
56
57
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -57,6 +57,7 @@ function runInstaller(homeDirectory, extraArguments) {
57
57
  env: {
58
58
  ...process.env,
59
59
  CDE_INSTALL_PSTACK: '0',
60
+ CDE_INSTALL_USAGE_WRAPUP: '0',
60
61
  HOME: homeDirectory,
61
62
  USERPROFILE: homeDirectory,
62
63
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -85,6 +86,7 @@ function scanTextWithProductionPiiScanner(scannedText) {
85
86
  env: {
86
87
  ...process.env,
87
88
  CDE_INSTALL_PSTACK: '0',
89
+ CDE_INSTALL_USAGE_WRAPUP: '0',
88
90
  PYTHONPATH: BLOCKING_HOOKS_DIRECTORY,
89
91
  CLAUDE_LOCAL_IDENTITY_PATH: join(
90
92
  temporaryDirectory,
@@ -32,6 +32,7 @@ function runInstaller(homeDirectory, extraArguments, environmentOverrides = {})
32
32
  env: {
33
33
  ...process.env,
34
34
  CDE_INSTALL_PSTACK: '0',
35
+ CDE_INSTALL_USAGE_WRAPUP: '0',
35
36
  HOME: homeDirectory,
36
37
  USERPROFILE: homeDirectory,
37
38
  CODEX_HOME: join(homeDirectory, '.codex'),
package/bin/install.mjs CHANGED
@@ -48,8 +48,10 @@ import {
48
48
  ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS,
49
49
  } from './install-constants.mjs';
50
50
  import {
51
- installPstackPlugin,
52
- shouldInstallPstackPlugin,
51
+ PSTACK_PLUGIN_SPEC,
52
+ USAGE_WRAPUP_PLUGIN_SPEC,
53
+ installMarketplacePlugin,
54
+ shouldInstallMarketplacePlugin,
53
55
  } from './install-pstack-plugin.mjs';
54
56
  import { seedCodexPstackModels } from './seed-codex-pstack-models.mjs';
55
57
  import {
@@ -404,12 +406,20 @@ export function pythonFileAndPrefixArguments(pythonCommand) {
404
406
  * @returns {{status: string, hosts: object[], warning: string|null}} The outcome.
405
407
  */
406
408
  export function installPstackPluginForHosts() {
407
- return installPstackPlugin({
409
+ return installMarketplacePlugin(PSTACK_PLUGIN_SPEC, {
408
410
  claudeRoot: CLAUDE_HOME,
409
411
  codexHome: INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
410
412
  });
411
413
  }
412
414
 
415
+ function printPluginHostOutcomes(spec, pluginOutcome) {
416
+ for (const eachHost of pluginOutcome.hosts) {
417
+ console.log(eachHost.warning
418
+ ? ` ${spec.label} (${eachHost.host}): ${eachHost.status} — ${eachHost.warning}`
419
+ : ` ${spec.label} (${eachHost.host}): ${eachHost.status}`);
420
+ }
421
+ }
422
+
413
423
  function seedSubagentModelPolicy() {
414
424
  const policyDirectory = join(AGENTS_HOME, CURSOR_RULES_DIRECTORY_NAME);
415
425
  const policyPath = join(policyDirectory, SUBAGENT_MODEL_POLICY_FILE_NAME);
@@ -2762,7 +2772,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2762
2772
  }
2763
2773
  const packageGuidanceText = selectedGroups ? '' : readFileSync(join(PACKAGE_ROOT, 'AGENTS.md'), 'utf8');
2764
2774
  const allPackageGuidancePaths = [join(CLAUDE_HOME, 'AGENTS.md'), join(AGENTS_HOME, 'AGENTS.md')];
2765
- if (!selectedGroups && shouldInstallPstackPlugin()) {
2775
+ if (!selectedGroups && shouldInstallMarketplacePlugin(PSTACK_PLUGIN_SPEC)) {
2766
2776
  const pstackPlugin = installPstackPluginForHosts();
2767
2777
  summary.pstackPlugin = pstackPlugin;
2768
2778
  if (pstackPlugin.hosts.some(host => host.host === 'codex' && host.status === 'installed')) {
@@ -2784,11 +2794,14 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2784
2794
  console.log(` \u2713 ${skillLoadGuidancePath} (Codex skill-load line)`);
2785
2795
  }
2786
2796
  }
2787
- for (const eachHost of pstackPlugin.hosts) {
2788
- console.log(eachHost.warning
2789
- ? ` Pstack (${eachHost.host}): ${eachHost.status} \u2014 ${eachHost.warning}`
2790
- : ` Pstack (${eachHost.host}): ${eachHost.status}`);
2791
- }
2797
+ printPluginHostOutcomes(PSTACK_PLUGIN_SPEC, pstackPlugin);
2798
+ }
2799
+ if (!selectedGroups && shouldInstallMarketplacePlugin(USAGE_WRAPUP_PLUGIN_SPEC)) {
2800
+ const usageWrapupPlugin = installMarketplacePlugin(USAGE_WRAPUP_PLUGIN_SPEC, {
2801
+ claudeRoot: CLAUDE_HOME,
2802
+ });
2803
+ summary.usageWrapupPlugin = usageWrapupPlugin;
2804
+ printPluginHostOutcomes(USAGE_WRAPUP_PLUGIN_SPEC, usageWrapupPlugin);
2792
2805
  }
2793
2806
  if (!selectedGroups) {
2794
2807
  const packageGuidancePath = writeCodexPackageGuidance(
@@ -3324,6 +3337,7 @@ Usage:
3324
3337
  npx ${PACKAGE_NAME} --profile ID Install into one named profile root (under the profiles root)
3325
3338
  npx ${PACKAGE_NAME} --profiles A,B Install into each selected profile (one ownership manifest per target)
3326
3339
  npx ${PACKAGE_NAME} --no-pstack Full install without the pstack plugin (also CDE_INSTALL_PSTACK=0)
3340
+ npx ${PACKAGE_NAME} --no-usage-wrapup Full install without the usage-wrapup plugin (also CDE_INSTALL_USAGE_WRAPUP=0)
3327
3341
  npx ${PACKAGE_NAME} --uninstall Remove installed files from the selected root
3328
3342
  npx ${PACKAGE_NAME} --help Show this help
3329
3343
 
@@ -260,6 +260,7 @@ function resolveInstallerInvocation(homeDirectory) {
260
260
  const childEnvironment = {
261
261
  ...process.env,
262
262
  CDE_INSTALL_PSTACK: '0',
263
+ CDE_INSTALL_USAGE_WRAPUP: '0',
263
264
  HOME: homeDirectory,
264
265
  USERPROFILE: homeDirectory,
265
266
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -23,6 +23,7 @@ function installInScratchHome(homeDirectory, argumentsList = [], extraEnvironmen
23
23
  env: {
24
24
  ...process.env,
25
25
  CDE_INSTALL_PSTACK: '0',
26
+ CDE_INSTALL_USAGE_WRAPUP: '0',
26
27
  HOME: homeDirectory,
27
28
  USERPROFILE: homeDirectory,
28
29
  CODEX_HOME: join(homeDirectory, '.codex'),
@@ -177,6 +178,7 @@ test('a successful pstack install retains its skill block and gains the question
177
178
 
178
179
  const installation = installInScratchHome(homeDirectory, [], {
179
180
  CDE_INSTALL_PSTACK: '1',
181
+ CDE_INSTALL_USAGE_WRAPUP: '0',
180
182
  CDE_CODEX_EXECUTABLE: commandPath,
181
183
  CDE_CLAUDE_EXECUTABLE: commandPath,
182
184
  });
@@ -203,6 +205,7 @@ test('enabling pstack after a pstack-off install preserves question guidance', (
203
205
 
204
206
  const secondInstallation = installInScratchHome(homeDirectory, [], {
205
207
  CDE_INSTALL_PSTACK: '1',
208
+ CDE_INSTALL_USAGE_WRAPUP: '0',
206
209
  CDE_CODEX_EXECUTABLE: commandPath,
207
210
  CDE_CLAUDE_EXECUTABLE: commandPath,
208
211
  });
@@ -230,6 +233,7 @@ test('a failed pstack transition restores question-only guidance and removes its
230
233
 
231
234
  const failedInstallation = installInScratchHome(homeDirectory, [], {
232
235
  CDE_INSTALL_PSTACK: '1',
236
+ CDE_INSTALL_USAGE_WRAPUP: '0',
233
237
  CDE_CODEX_EXECUTABLE: commandPath,
234
238
  CDE_CLAUDE_EXECUTABLE: commandPath,
235
239
  CLAUDE_DEV_ENV_INSTALL_FAULT: 'after_file_staging',
@@ -57,6 +57,7 @@ function runInstallerInSandbox(sandboxHome, installerArguments = []) {
57
57
  env: {
58
58
  ...process.env,
59
59
  CDE_INSTALL_PSTACK: '0',
60
+ CDE_INSTALL_USAGE_WRAPUP: '0',
60
61
  HOME: sandboxHome,
61
62
  USERPROFILE: sandboxHome,
62
63
  GIT_CONFIG_GLOBAL: gitConfigGlobal,
@@ -3034,6 +3034,7 @@ function runPstackInstaller(homeDirectory, extraArguments, environmentOverrides
3034
3034
  CLAUDE_CONFIG_DIR: join(homeDirectory, '.claude'),
3035
3035
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
3036
3036
  CDE_INSTALL_PSTACK: '1',
3037
+ CDE_INSTALL_USAGE_WRAPUP: '0',
3037
3038
  ...environmentOverrides,
3038
3039
  },
3039
3040
  });
@@ -3267,6 +3268,81 @@ test('a pstack install after a --no-pstack install seeds the Codex model sheet',
3267
3268
  assert.ok(codexGuidance.includes(QUESTION_PRESENTATION_BLOCK_START));
3268
3269
  });
3269
3270
 
3271
+ const USAGE_WRAPUP_CLAUDE_COMMANDS = Object.freeze([
3272
+ 'claude plugin marketplace add jl-cmd/claude-dev-env --sparse .claude-plugin',
3273
+ 'claude plugin install usage-wrapup@claude-dev-env',
3274
+ ]);
3275
+
3276
+ function usageWrapupCommands(allRecordedCommands) {
3277
+ return allRecordedCommands.filter(eachCommand => eachCommand.includes('claude-dev-env'));
3278
+ }
3279
+
3280
+ test('a full install adds this marketplace and installs usage-wrapup on Claude only', t => {
3281
+ const sandbox = pstackPluginSandbox(t);
3282
+
3283
+ const installerOutput = runPstackInstaller(sandbox.homeDirectory, [], {
3284
+ ...sandbox.environment,
3285
+ CDE_INSTALL_USAGE_WRAPUP: '1',
3286
+ });
3287
+
3288
+ assert.deepEqual(usageWrapupCommands(sandbox.recordedCommands()), USAGE_WRAPUP_CLAUDE_COMMANDS);
3289
+ assert.match(installerOutput, /Usage-wrapup \(claude\): installed/);
3290
+ assert.doesNotMatch(installerOutput, /Usage-wrapup \(codex\)/);
3291
+ assert.match(installerOutput, /Pstack \(claude\): installed/);
3292
+ });
3293
+
3294
+ for (const [caseName, extraArguments, environmentOverrides] of [
3295
+ ['--no-usage-wrapup', ['--no-usage-wrapup'], { CDE_INSTALL_USAGE_WRAPUP: '1' }],
3296
+ ['CDE_INSTALL_USAGE_WRAPUP=0', [], { CDE_INSTALL_USAGE_WRAPUP: '0' }],
3297
+ ['an --only run', ['--only', 'core'], { CDE_INSTALL_USAGE_WRAPUP: '1' }],
3298
+ ]) {
3299
+ test(`${caseName} skips usage-wrapup`, t => {
3300
+ const sandbox = pstackPluginSandbox(t);
3301
+
3302
+ const installerOutput = runPstackInstaller(sandbox.homeDirectory, extraArguments, {
3303
+ ...sandbox.environment,
3304
+ ...environmentOverrides,
3305
+ });
3306
+
3307
+ assert.deepEqual(usageWrapupCommands(sandbox.recordedCommands()), []);
3308
+ assert.doesNotMatch(installerOutput, /Usage-wrapup \(/);
3309
+ });
3310
+ }
3311
+
3312
+ test('a failing usage-wrapup command is reported and the install still succeeds', t => {
3313
+ const sandbox = pstackPluginSandbox(t);
3314
+ const failingCommandPath = join(
3315
+ sandbox.homeDirectory,
3316
+ process.platform === 'win32' ? 'failing-claude.cmd' : 'failing-claude',
3317
+ );
3318
+ writeFileSync(
3319
+ failingCommandPath,
3320
+ process.platform === 'win32'
3321
+ ? '@echo off\r\necho marketplace unreachable 1>&2\r\nexit /b 1\r\n'
3322
+ : '#!/bin/sh\necho marketplace unreachable >&2\nexit 1\n',
3323
+ { mode: 0o755 },
3324
+ );
3325
+
3326
+ const installerOutput = runPstackInstaller(sandbox.homeDirectory, ['--no-pstack'], {
3327
+ ...sandbox.environment,
3328
+ CDE_CLAUDE_EXECUTABLE: failingCommandPath,
3329
+ CDE_INSTALL_USAGE_WRAPUP: '1',
3330
+ });
3331
+
3332
+ assert.match(installerOutput, /Usage-wrapup \(claude\): failed .*marketplace unreachable/);
3333
+ assert.ok(existsSync(join(sandbox.homeDirectory, '.claude', '.claude-dev-env-manifest.json')));
3334
+ });
3335
+
3336
+ test('the help output names the usage-wrapup opt-out beside the pstack one', () => {
3337
+ const helpRun = spawnSync(process.execPath, [PSTACK_TEST_INSTALLER_PATH, '--help'], {
3338
+ encoding: 'utf8',
3339
+ env: process.env,
3340
+ });
3341
+
3342
+ assert.equal(helpRun.status, 0, helpRun.stderr);
3343
+ assert.match(helpRun.stdout, /--no-pstack .*\n.*--no-usage-wrapup .*CDE_INSTALL_USAGE_WRAPUP=0/);
3344
+ });
3345
+
3270
3346
  function continuityCommandCount(configurationPath) {
3271
3347
  if (!existsSync(configurationPath)) return 0;
3272
3348
  const configuration = JSON.parse(readFileSync(configurationPath, 'utf8'));
@@ -84,6 +84,7 @@ test('capture and restore recover settings, manifest, files, and hooksPath', ()
84
84
  const env = {
85
85
  ...process.env,
86
86
  CDE_INSTALL_PSTACK: '0',
87
+ CDE_INSTALL_USAGE_WRAPUP: '0',
87
88
  HOME: box.root,
88
89
  USERPROFILE: box.root,
89
90
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -145,6 +146,7 @@ test('capture and restore recover an additional host settings file', () => {
145
146
  env: {
146
147
  ...process.env,
147
148
  CDE_INSTALL_PSTACK: '0',
149
+ CDE_INSTALL_USAGE_WRAPUP: '0',
148
150
  HOME: box.root,
149
151
  USERPROFILE: box.root,
150
152
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -179,6 +181,7 @@ test('runWithInstallTransaction restores prior state on injected fault', () => {
179
181
  const env = {
180
182
  ...process.env,
181
183
  CDE_INSTALL_PSTACK: '0',
184
+ CDE_INSTALL_USAGE_WRAPUP: '0',
182
185
  HOME: box.root,
183
186
  USERPROFILE: box.root,
184
187
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -232,6 +235,7 @@ test('runWithInstallTransaction commits and discards journal on success', () =>
232
235
  env: {
233
236
  ...process.env,
234
237
  CDE_INSTALL_PSTACK: '0',
238
+ CDE_INSTALL_USAGE_WRAPUP: '0',
235
239
  HOME: box.root,
236
240
  USERPROFILE: box.root,
237
241
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -274,6 +278,7 @@ function runInstaller(homeDirectory, extraArguments, options = {}) {
274
278
  const childEnvironment = {
275
279
  ...process.env,
276
280
  CDE_INSTALL_PSTACK: '0',
281
+ CDE_INSTALL_USAGE_WRAPUP: '0',
277
282
  HOME: homeDirectory,
278
283
  USERPROFILE: homeDirectory,
279
284
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -308,6 +313,7 @@ test('a later install fault removes newly seeded Codex pstack files', () => {
308
313
  faultPhase: FAULT_PHASES.AFTER_MANIFEST_WRITE,
309
314
  environment: {
310
315
  CDE_INSTALL_PSTACK: '1',
316
+ CDE_INSTALL_USAGE_WRAPUP: '0',
311
317
  CDE_CODEX_EXECUTABLE: codexCommandPath,
312
318
  },
313
319
  });
@@ -450,6 +456,7 @@ test('installer fault after_git_config restores prior core.hooksPath', () => {
450
456
  env: {
451
457
  ...process.env,
452
458
  CDE_INSTALL_PSTACK: '0',
459
+ CDE_INSTALL_USAGE_WRAPUP: '0',
453
460
  HOME: homeDirectory,
454
461
  USERPROFILE: homeDirectory,
455
462
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -466,6 +473,7 @@ test('installer fault after_git_config restores prior core.hooksPath', () => {
466
473
  env: {
467
474
  ...process.env,
468
475
  CDE_INSTALL_PSTACK: '0',
476
+ CDE_INSTALL_USAGE_WRAPUP: '0',
469
477
  HOME: homeDirectory,
470
478
  USERPROFILE: homeDirectory,
471
479
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -72,6 +72,7 @@ function runInstaller(homeDirectory, extraArguments, options = {}) {
72
72
  const childEnvironment = {
73
73
  ...process.env,
74
74
  CDE_INSTALL_PSTACK: '0',
75
+ CDE_INSTALL_USAGE_WRAPUP: '0',
75
76
  HOME: homeDirectory,
76
77
  USERPROFILE: homeDirectory,
77
78
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -305,6 +306,7 @@ test('uninstall fault after_git_config restores prior core.hooksPath', () => {
305
306
  env: {
306
307
  ...process.env,
307
308
  CDE_INSTALL_PSTACK: '0',
309
+ CDE_INSTALL_USAGE_WRAPUP: '0',
308
310
  HOME: homeDirectory,
309
311
  USERPROFILE: homeDirectory,
310
312
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -59,6 +59,7 @@ try:
59
59
  ALL_FAILING_CHECK_CONCLUSIONS,
60
60
  ALL_PASSING_CHECK_CONCLUSIONS,
61
61
  CHECK_COMPLETED_STATUS,
62
+ CHECKS_NOT_REPORTED_TEXT,
62
63
  DONE_LABEL_NAME,
63
64
  GH_PR_CREATE_ACTION,
64
65
  GH_PR_SUBCOMMAND,
@@ -164,7 +165,7 @@ def _check_counts(all_pr_fields: dict[str, object]) -> dict[str, int]:
164
165
 
165
166
  def _checks_line(all_check_counts: dict[str, int]) -> str:
166
167
  if all_check_counts["total"] == 0:
167
- return "none reported"
168
+ return CHECKS_NOT_REPORTED_TEXT
168
169
  return (
169
170
  f"{all_check_counts['failing']} failing, "
170
171
  f"{all_check_counts['pending']} pending, "
@@ -185,6 +186,7 @@ def _has_done_label(all_pr_fields: dict[str, object]) -> bool:
185
186
  def _is_done(mergeable_value: str, all_check_counts: dict[str, int]) -> bool:
186
187
  return (
187
188
  mergeable_value == MERGEABLE_CLEAN_VALUE
189
+ and all_check_counts["total"] > 0
188
190
  and all_check_counts["failing"] == 0
189
191
  and all_check_counts["pending"] == 0
190
192
  )
@@ -152,6 +152,58 @@ def test_should_say_done_when_mergeable_and_checks_pass() -> None:
152
152
  assert "NOT DONE" not in context
153
153
 
154
154
 
155
+ def test_should_say_not_done_when_no_check_has_reported_on_the_head() -> None:
156
+ no_checks_yet = {**_CLEAN_PR_OBJECT, "statusCheckRollup": []}
157
+
158
+ context = pr_done_reminder.build_reminder_context(no_checks_yet)
159
+
160
+ assert "CI checks: none reported yet on this head." in context
161
+ assert "NOT DONE" in context
162
+ assert "gh pr edit 42 --add-label" not in context
163
+
164
+
165
+ def test_should_say_not_done_when_the_rollup_is_missing() -> None:
166
+ no_rollup = {key: value for key, value in _CLEAN_PR_OBJECT.items() if key != "statusCheckRollup"}
167
+
168
+ context = pr_done_reminder.build_reminder_context(no_rollup)
169
+
170
+ assert "none reported yet on this head." in context
171
+ assert "NOT DONE" in context
172
+
173
+
174
+ def test_should_say_not_done_while_a_check_is_pending_on_an_otherwise_green_head() -> None:
175
+ one_pending = {
176
+ **_CLEAN_PR_OBJECT,
177
+ "statusCheckRollup": [
178
+ {"name": "pytest", "status": "COMPLETED", "conclusion": "SUCCESS"},
179
+ {"name": "mypy", "status": "QUEUED", "conclusion": ""},
180
+ ],
181
+ }
182
+
183
+ context = pr_done_reminder.build_reminder_context(one_pending)
184
+
185
+ assert "1 pending" in context
186
+ assert "NOT DONE" in context
187
+ assert "gh pr edit 42 --add-label" not in context
188
+
189
+
190
+ def test_should_say_done_when_every_reported_check_passed() -> None:
191
+ all_green = {
192
+ **_CLEAN_PR_OBJECT,
193
+ "statusCheckRollup": [
194
+ {"name": "pytest", "status": "COMPLETED", "conclusion": "SUCCESS"},
195
+ {"name": "docs", "status": "COMPLETED", "conclusion": "SKIPPED"},
196
+ {"context": "lint", "state": "SUCCESS"},
197
+ ],
198
+ }
199
+
200
+ context = pr_done_reminder.build_reminder_context(all_green)
201
+
202
+ assert "CI checks: 0 failing, 0 pending, 3 total" in context
203
+ assert "Verdict: DONE. Add the label now:" in context
204
+ assert "NOT DONE" not in context
205
+
206
+
155
207
  def test_should_keep_the_checklist_order_verdict_then_recheck_then_footer() -> None:
156
208
  all_lines = pr_done_reminder.build_reminder_context(_CLEAN_PR_OBJECT).split("\n")
157
209
 
@@ -28,6 +28,7 @@ __all__ = [
28
28
  "REMINDER_FOOTER",
29
29
  "REMINDER_LINE_SEPARATOR",
30
30
  "ALL_REMINDER_HINTS_BY_MERGEABLE",
31
+ "CHECKS_NOT_REPORTED_TEXT",
31
32
  "VERDICT_DONE",
32
33
  "VERDICT_NOT_DONE",
33
34
  "NO_PULL_REQUEST_REMINDER",
@@ -75,6 +76,9 @@ ALL_REMINDER_HINTS_BY_MERGEABLE: dict[str, str] = {
75
76
  ),
76
77
  MERGEABLE_UNKNOWN_VALUE: "GitHub is still computing. Re-run the re-check command in a minute.",
77
78
  }
79
+ CHECKS_NOT_REPORTED_TEXT: str = (
80
+ "none reported yet on this head. Wait for the checks, then run the re-check command."
81
+ )
78
82
  VERDICT_DONE: str = "DONE. Add the label now:"
79
83
  VERDICT_NOT_DONE: str = "NOT DONE. Do not add the done label yet."
80
84
  NO_PULL_REQUEST_REMINDER: str = (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.33.1",
3
+ "version": "8.34.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {