claude-dev-env 8.42.6 → 8.43.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.
@@ -1,6 +1,8 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readdirSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
2
4
 
3
- export const PSTACK_MARKETPLACE_REPOSITORY = 'michael-denyer/pstack-claude';
5
+ export const PSTACK_MARKETPLACE_REPOSITORY = 'jl-cmd/pstack-claude';
4
6
  export const PSTACK_MARKETPLACE_NAME = 'pstack-claude';
5
7
  export const PSTACK_PLUGIN_IDENTIFIER = `pstack@${PSTACK_MARKETPLACE_NAME}`;
6
8
  export const PSTACK_PLUGIN_HOSTS = Object.freeze(['claude', 'codex']);
@@ -12,6 +14,7 @@ export const PSTACK_PLUGIN_SPEC = Object.freeze({
12
14
  name: 'pstack',
13
15
  label: 'Pstack',
14
16
  marketplaceRepository: PSTACK_MARKETPLACE_REPOSITORY,
17
+ marketplaceName: PSTACK_MARKETPLACE_NAME,
15
18
  marketplaceAddArguments: Object.freeze([]),
16
19
  pluginIdentifier: PSTACK_PLUGIN_IDENTIFIER,
17
20
  hosts: PSTACK_PLUGIN_HOSTS,
@@ -23,6 +26,7 @@ export const USAGE_WRAPUP_PLUGIN_SPEC = Object.freeze({
23
26
  name: 'usage-wrapup',
24
27
  label: 'Usage-wrapup',
25
28
  marketplaceRepository: 'jl-cmd/claude-dev-env',
29
+ marketplaceName: 'claude-dev-env',
26
30
  marketplaceAddArguments: Object.freeze(['--sparse', '.claude-plugin']),
27
31
  pluginIdentifier: 'usage-wrapup@claude-dev-env',
28
32
  hosts: Object.freeze(['claude']),
@@ -35,19 +39,28 @@ const HOST_DEFINITIONS = Object.freeze({
35
39
  executable: 'claude',
36
40
  executableVariable: 'CDE_CLAUDE_EXECUTABLE',
37
41
  homeVariable: 'CLAUDE_CONFIG_DIR',
42
+ orphanedVersionsDirectory: (homeDirectory, spec) => join(
43
+ homeDirectory, 'plugins', 'cache', spec.marketplaceName, spec.name,
44
+ ),
38
45
  installVerb: 'install',
46
+ uninstallVerb: 'uninstall',
39
47
  }),
40
48
  codex: Object.freeze({
41
49
  executable: 'codex',
42
50
  executableVariable: 'CDE_CODEX_EXECUTABLE',
43
51
  homeVariable: 'CODEX_HOME',
44
52
  installVerb: 'add',
53
+ uninstallVerb: 'remove',
45
54
  }),
46
55
  });
47
56
 
48
57
  /**
49
58
  * Read the marketplace and plugin commands one host installs a plugin with.
50
59
  *
60
+ * The removal commands run first and take out the installed plugin, its
61
+ * cached versions, and the marketplace clone, so the install commands that
62
+ * follow fetch the newest published version into an empty slot.
63
+ *
51
64
  * Claude Code and Codex publish a marketplace under different plugin verbs, so
52
65
  * the install verb belongs to the host and the marketplace and identifier
53
66
  * belong to the plugin spec. A spec whose marketplace is a large repository
@@ -57,6 +70,7 @@ const HOST_DEFINITIONS = Object.freeze({
57
70
  * @param {typeof PSTACK_PLUGIN_SPEC} spec The plugin to install.
58
71
  * @param {string} host A host the spec names, `claude` or `codex`.
59
72
  * @returns {{executable: string, executableVariable: string, homeVariable: string,
73
+ * removalCommands: ReadonlyArray<ReadonlyArray<string>>,
60
74
  * commands: ReadonlyArray<ReadonlyArray<string>>}} The host's install plan.
61
75
  */
62
76
  export function marketplacePluginPlan(spec, host) {
@@ -70,6 +84,10 @@ export function marketplacePluginPlan(spec, host) {
70
84
  executable: hostDefinition.executable,
71
85
  executableVariable: hostDefinition.executableVariable,
72
86
  homeVariable: hostDefinition.homeVariable,
87
+ removalCommands: [
88
+ ['plugin', hostDefinition.uninstallVerb, spec.pluginIdentifier],
89
+ ['plugin', 'marketplace', 'remove', spec.marketplaceName],
90
+ ],
73
91
  commands: [
74
92
  ['plugin', 'marketplace', 'add', spec.marketplaceRepository, ...spec.marketplaceAddArguments],
75
93
  ['plugin', hostDefinition.installVerb, spec.pluginIdentifier],
@@ -156,22 +174,54 @@ function firstLine(text) {
156
174
  return trimmed.split(/\r?\n/).filter(Boolean).at(-1);
157
175
  }
158
176
 
177
+ const ORPHANED_VERSION_MARKER = '.orphaned_at';
178
+
179
+ /**
180
+ * Delete each cached plugin version the host marked as orphaned.
181
+ *
182
+ * Claude Code keeps an uninstalled version in its plugin cache with an
183
+ * `.orphaned_at` marker, so the clean install deletes those versions and
184
+ * leaves the version it just installed.
185
+ *
186
+ * @param {string|undefined} versionsDirectory The plugin's cache directory.
187
+ * @returns {string[]} The version directories removed.
188
+ */
189
+ export function removeOrphanedPluginVersions(versionsDirectory) {
190
+ if (!versionsDirectory || !existsSync(versionsDirectory)) return [];
191
+ const removedPaths = [];
192
+ for (const versionName of readdirSync(versionsDirectory)) {
193
+ const versionPath = join(versionsDirectory, versionName);
194
+ if (!existsSync(join(versionPath, ORPHANED_VERSION_MARKER))) continue;
195
+ rmSync(versionPath, { recursive: true, force: true });
196
+ removedPaths.push(versionPath);
197
+ }
198
+ return removedPaths;
199
+ }
200
+
201
+ function absentHostOutcome(spec, host, executable) {
202
+ return {
203
+ host,
204
+ executable,
205
+ status: 'skipped',
206
+ warning: `${executable} is not on PATH, so ${spec.name} was not installed for ${host}.`,
207
+ };
208
+ }
209
+
159
210
  function installForHost(spec, host, homeDirectory, environment, runCommand) {
160
211
  const plan = marketplacePluginPlan(spec, host);
161
212
  const executable = environment[plan.executableVariable] || plan.executable;
162
213
  const commandEnvironment = { [plan.homeVariable]: homeDirectory };
214
+ for (const commandArguments of plan.removalCommands) {
215
+ const outcome = runCommand(executable, [...commandArguments], {
216
+ environment: commandEnvironment,
217
+ });
218
+ if (namesAnAbsentCommand(outcome)) return absentHostOutcome(spec, host, executable);
219
+ }
163
220
  for (const commandArguments of plan.commands) {
164
221
  const outcome = runCommand(executable, [...commandArguments], {
165
222
  environment: commandEnvironment,
166
223
  });
167
- if (namesAnAbsentCommand(outcome)) {
168
- return {
169
- host,
170
- executable,
171
- status: 'skipped',
172
- warning: `${executable} is not on PATH, so ${spec.name} was not installed for ${host}.`,
173
- };
174
- }
224
+ if (namesAnAbsentCommand(outcome)) return absentHostOutcome(spec, host, executable);
175
225
  if (outcome.status !== 0 || outcome.error) {
176
226
  const detail = firstLine(outcome.stderr) || outcome.error?.message || `exit ${outcome.status}`;
177
227
  return {
@@ -182,12 +232,20 @@ function installForHost(spec, host, homeDirectory, environment, runCommand) {
182
232
  };
183
233
  }
184
234
  }
235
+ const hostDefinition = HOST_DEFINITIONS[host];
236
+ if (hostDefinition.orphanedVersionsDirectory && homeDirectory) {
237
+ removeOrphanedPluginVersions(hostDefinition.orphanedVersionsDirectory(homeDirectory, spec));
238
+ }
185
239
  return { host, executable, status: 'installed', warning: null };
186
240
  }
187
241
 
188
242
  /**
189
243
  * Install one plugin from its marketplace into each host's own home.
190
244
  *
245
+ * Each host first removes the plugin and its marketplace, so an older
246
+ * version never survives beside the new one. A removal that finds nothing
247
+ * to remove exits non-zero on a fresh home, and the install goes on.
248
+ *
191
249
  * Each host is one member of the batch. A host without its command-line tool
192
250
  * is skipped and a host whose command fails is reported, so the rules, hooks,
193
251
  * and skills this run already wrote still reach their durable places. An
@@ -1,6 +1,8 @@
1
1
  import { test } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
- import { readFileSync } from 'node:fs';
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
4
6
  import {
5
7
  PSTACK_MARKETPLACE_REPOSITORY,
6
8
  PSTACK_PLUGIN_HOSTS,
@@ -10,6 +12,7 @@ import {
10
12
  hostCommandInvocation,
11
13
  installMarketplacePlugin,
12
14
  marketplacePluginPlan,
15
+ removeOrphanedPluginVersions,
13
16
  shouldInstallMarketplacePlugin,
14
17
  } from './install-pstack-plugin.mjs';
15
18
 
@@ -60,13 +63,38 @@ test('each host installs into the managed root this run wrote', () => {
60
63
  const outcome = installMarketplacePlugin(PSTACK_PLUGIN_SPEC, ROOTS, runner);
61
64
  assert.equal(outcome.status, 'installed');
62
65
  assert.deepEqual(runner.calls.map(call => [call.executable, ...call.commandArguments]), [
66
+ ['claude', 'plugin', 'uninstall', PSTACK_PLUGIN_IDENTIFIER],
67
+ ['claude', 'plugin', 'marketplace', 'remove', 'pstack-claude'],
63
68
  ['claude', 'plugin', 'marketplace', 'add', PSTACK_MARKETPLACE_REPOSITORY],
64
69
  ['claude', 'plugin', 'install', PSTACK_PLUGIN_IDENTIFIER],
70
+ ['codex', 'plugin', 'remove', PSTACK_PLUGIN_IDENTIFIER],
71
+ ['codex', 'plugin', 'marketplace', 'remove', 'pstack-claude'],
65
72
  ['codex', 'plugin', 'marketplace', 'add', PSTACK_MARKETPLACE_REPOSITORY],
66
73
  ['codex', 'plugin', 'add', PSTACK_PLUGIN_IDENTIFIER],
67
74
  ]);
68
75
  assert.equal(runner.calls[0].environment.CLAUDE_CONFIG_DIR, ROOTS.claudeRoot);
69
- assert.equal(runner.calls[2].environment.CODEX_HOME, ROOTS.codexHome);
76
+ assert.equal(runner.calls[4].environment.CODEX_HOME, ROOTS.codexHome);
77
+ });
78
+
79
+ test('each host removes the installed plugin and marketplace before it installs', () => {
80
+ assert.deepEqual(marketplacePluginPlan(PSTACK_PLUGIN_SPEC, 'claude').removalCommands, [
81
+ ['plugin', 'uninstall', PSTACK_PLUGIN_IDENTIFIER],
82
+ ['plugin', 'marketplace', 'remove', 'pstack-claude'],
83
+ ]);
84
+ assert.deepEqual(marketplacePluginPlan(PSTACK_PLUGIN_SPEC, 'codex').removalCommands, [
85
+ ['plugin', 'remove', PSTACK_PLUGIN_IDENTIFIER],
86
+ ['plugin', 'marketplace', 'remove', 'pstack-claude'],
87
+ ]);
88
+ });
89
+
90
+ test('a removal that finds nothing installed still lets the install run', () => {
91
+ const runner = recordingRunner((executable, commandArguments) => (
92
+ commandArguments.includes('uninstall') || commandArguments.includes('remove')
93
+ ? { status: 1, stderr: 'not installed' }
94
+ : { status: 0, stderr: '' }));
95
+ const outcome = installMarketplacePlugin(PSTACK_PLUGIN_SPEC, { ...ROOTS, hosts: ['claude'] }, runner);
96
+ assert.equal(outcome.status, 'installed');
97
+ assert.deepEqual(runner.calls.at(-1).commandArguments, ['plugin', 'install', PSTACK_PLUGIN_IDENTIFIER]);
70
98
  });
71
99
 
72
100
  test('an absent host command skips that host and leaves the other installed', () => {
@@ -83,7 +111,7 @@ test('an absent host command skips that host and leaves the other installed', ()
83
111
 
84
112
  test('a failing command reports that host and still installs the next one', () => {
85
113
  const runner = recordingRunner((executable, commandArguments) => (
86
- executable === 'claude' && commandArguments[1] === 'marketplace'
114
+ executable === 'claude' && commandArguments[2] === 'add'
87
115
  ? { status: 1, stderr: 'marketplace unreachable' }
88
116
  : { status: 0, stderr: '' }));
89
117
  const outcome = installMarketplacePlugin(PSTACK_PLUGIN_SPEC, ROOTS, runner);
@@ -92,7 +120,8 @@ test('a failing command reports that host and still installs the next one', () =
92
120
  assert.equal(claude.status, 'failed');
93
121
  assert.match(claude.warning, /marketplace unreachable/);
94
122
  assert.equal(outcome.hosts.find(host => host.host === 'codex').status, 'installed');
95
- assert.equal(runner.calls.filter(call => call.executable === 'claude').length, 1);
123
+ assert.deepEqual(runner.calls.filter(call => call.executable === 'claude').at(-1).commandArguments,
124
+ ['plugin', 'marketplace', 'add', PSTACK_MARKETPLACE_REPOSITORY]);
96
125
  });
97
126
 
98
127
  test('an executable override replaces the host command for that host only', () => {
@@ -207,6 +236,8 @@ test('installing usage-wrapup runs only the Claude commands, inside the managed
207
236
  assert.equal(outcome.status, 'installed');
208
237
  assert.deepEqual(outcome.hosts.map(host => host.host), ['claude']);
209
238
  assert.deepEqual(runner.calls.map(call => [call.executable, ...call.commandArguments]), [
239
+ ['claude', 'plugin', 'uninstall', 'usage-wrapup@claude-dev-env'],
240
+ ['claude', 'plugin', 'marketplace', 'remove', 'claude-dev-env'],
210
241
  ['claude', 'plugin', 'marketplace', 'add', 'jl-cmd/claude-dev-env', '--sparse', '.claude-plugin'],
211
242
  ['claude', 'plugin', 'install', 'usage-wrapup@claude-dev-env'],
212
243
  ]);
@@ -255,3 +286,20 @@ test('the repository marketplace lists usage-wrapup from the fork, pinned to the
255
286
  sha: USAGE_WRAPUP_PINNED_COMMIT,
256
287
  });
257
288
  });
289
+
290
+ test('a clean install deletes the orphaned pstack versions and keeps the installed one', () => {
291
+ const claudeRoot = mkdtempSync(join(tmpdir(), 'pstack-orphans-'));
292
+ const versionsDirectory = join(claudeRoot, 'plugins', 'cache', 'pstack-claude', 'pstack');
293
+ mkdirSync(join(versionsDirectory, '0.9.55'), { recursive: true });
294
+ writeFileSync(join(versionsDirectory, '0.9.55', '.orphaned_at'), '1');
295
+ mkdirSync(join(versionsDirectory, '0.9.64'), { recursive: true });
296
+ const outcome = installMarketplacePlugin(
297
+ PSTACK_PLUGIN_SPEC,
298
+ { claudeRoot, hosts: ['claude'], environment: {} },
299
+ recordingRunner(),
300
+ );
301
+ assert.equal(outcome.status, 'installed');
302
+ assert.equal(existsSync(join(versionsDirectory, '0.9.55')), false);
303
+ assert.equal(existsSync(join(versionsDirectory, '0.9.64')), true);
304
+ assert.deepEqual(removeOrphanedPluginVersions(versionsDirectory), []);
305
+ });
@@ -3110,9 +3110,13 @@ test('a full install adds the pstack marketplace and plugin on both hosts', t =>
3110
3110
  const installerOutput = runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
3111
3111
 
3112
3112
  assert.deepEqual(sandbox.recordedCommands(), [
3113
- 'claude plugin marketplace add michael-denyer/pstack-claude',
3113
+ 'claude plugin uninstall pstack@pstack-claude',
3114
+ 'claude plugin marketplace remove pstack-claude',
3115
+ 'claude plugin marketplace add jl-cmd/pstack-claude',
3114
3116
  'claude plugin install pstack@pstack-claude',
3115
- 'codex plugin marketplace add michael-denyer/pstack-claude',
3117
+ 'codex plugin remove pstack@pstack-claude',
3118
+ 'codex plugin marketplace remove pstack-claude',
3119
+ 'codex plugin marketplace add jl-cmd/pstack-claude',
3116
3120
  'codex plugin add pstack@pstack-claude',
3117
3121
  ]);
3118
3122
  assert.match(installerOutput, /Pstack \(claude\): installed/);
@@ -3311,6 +3315,8 @@ test('a pstack install after a --no-pstack install seeds the Codex model sheet',
3311
3315
  });
3312
3316
 
3313
3317
  const USAGE_WRAPUP_CLAUDE_COMMANDS = Object.freeze([
3318
+ 'claude plugin uninstall usage-wrapup@claude-dev-env',
3319
+ 'claude plugin marketplace remove claude-dev-env',
3314
3320
  'claude plugin marketplace add jl-cmd/claude-dev-env --sparse .claude-plugin',
3315
3321
  'claude plugin install usage-wrapup@claude-dev-env',
3316
3322
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.42.6",
3
+ "version": "8.43.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {