claude-dev-env 8.51.4 → 8.52.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.
@@ -25,3 +25,15 @@ PROCESS_TREE_KILL_TIMEOUT_SECONDS: int = 10
25
25
  Gates the kill command alone. Each caller sets its own bound on the drain that
26
26
  follows the kill.
27
27
  """
28
+
29
+ PROCESS_TREE_EXIT_WAIT_SECONDS: float = 2.0
30
+ """Seconds ``terminate_process_tree`` waits for the signalled POSIX group to exit.
31
+
32
+ A SIGKILL takes effect when the kernel next schedules each target, so a
33
+ descendant can still run for a moment after ``killpg`` returns. The wait ends
34
+ as soon as no group member is still running; the bound applies only when a
35
+ member survived the signal.
36
+ """
37
+
38
+ PROCESS_TREE_EXIT_POLL_SECONDS: float = 0.01
39
+ """Pause between checks for running members of the signalled process group."""
@@ -25,6 +25,7 @@ import os
25
25
  import signal
26
26
  import subprocess
27
27
  import sys
28
+ import time
28
29
  from pathlib import Path
29
30
 
30
31
  _config_directory = str(Path(__file__).resolve().parent / "config")
@@ -32,6 +33,8 @@ if _config_directory not in sys.path:
32
33
  sys.path.insert(0, _config_directory)
33
34
 
34
35
  from process_tree_scripts_constants.process_tree_kill_constants import ( # noqa: E402
36
+ PROCESS_TREE_EXIT_POLL_SECONDS,
37
+ PROCESS_TREE_EXIT_WAIT_SECONDS,
35
38
  PROCESS_TREE_KILL_TIMEOUT_SECONDS,
36
39
  WINDOWS_TASKKILL_COMMAND,
37
40
  WINDOWS_TASKKILL_FORCE_FLAG,
@@ -126,19 +129,103 @@ def terminate_process_tree(process: subprocess.Popen[str]) -> None:
126
129
  tree kill takes ok: grandchildren die, pipes close
127
130
  tree kill misses the child ok: Popen.kill() ends the direct child
128
131
  child exits mid-kill ok: the raised lookup error is swallowed
132
+ grandchild still running ok: returns once the POSIX group has exited
129
133
 
130
134
  Falls back to ``Popen.kill()`` when the direct child survives the tree kill,
131
- so the caller never waits on a live process.
135
+ so the caller never waits on a live process. On POSIX it then waits, bounded
136
+ by ``PROCESS_TREE_EXIT_WAIT_SECONDS``, until no group member is running, so
137
+ a descendant cannot touch files after this call returns.
132
138
 
133
139
  Args:
134
140
  process: The process whose tree is ended.
135
141
  """
136
142
  if process.poll() is not None:
137
143
  return
144
+ process_group_identifier = _posix_process_group(process.pid)
138
145
  kill_process_tree_by_identifier(process.pid)
139
- if process.poll() is not None:
140
- return
146
+ if process.poll() is None:
147
+ try:
148
+ process.kill()
149
+ except ProcessLookupError:
150
+ pass
151
+ if process_group_identifier is not None:
152
+ _wait_for_process_group_exit(process, process_group_identifier)
153
+
154
+
155
+ def _posix_process_group(process_identifier: int) -> int | None:
156
+ if sys.platform == "win32":
157
+ return None
141
158
  try:
142
- process.kill()
143
- except ProcessLookupError:
144
- return
159
+ return os.getpgid(process_identifier)
160
+ except OSError:
161
+ return None
162
+
163
+
164
+ def _wait_for_process_group_exit(
165
+ process: subprocess.Popen[str], process_group_identifier: int
166
+ ) -> None:
167
+ """Block until no member of the signalled group is still running, bounded.
168
+
169
+ ``killpg`` returns once the signal is queued, and each target dies only when
170
+ the kernel next schedules it. A grandchild preempted inside a system call
171
+ finishes that call first, so it can still truncate or write a file after
172
+ the caller has moved on. Waiting here keeps that work inside the call.
173
+ """
174
+ deadline = time.monotonic() + PROCESS_TREE_EXIT_WAIT_SECONDS
175
+ while running_process_group_members(process, process_group_identifier):
176
+ if time.monotonic() >= deadline:
177
+ return
178
+ time.sleep(PROCESS_TREE_EXIT_POLL_SECONDS)
179
+
180
+
181
+ def running_process_group_members(
182
+ process: subprocess.Popen[str], process_group_identifier: int
183
+ ) -> list[int]:
184
+ """List the process ids in a POSIX group that have not exited yet.
185
+
186
+ Reads ``/proc`` where it exists, which tells a running member from a zombie
187
+ waiting on its reaper. Elsewhere it reaps the direct child and asks the
188
+ kernel whether the group still has any member.
189
+
190
+ Args:
191
+ process: The direct child that leads the group.
192
+ process_group_identifier: The group the direct child leads.
193
+
194
+ Returns:
195
+ Process ids still running, or an empty list once the group has exited.
196
+ """
197
+ if sys.platform == "win32":
198
+ return []
199
+ proc_directory = Path("/proc")
200
+ if proc_directory.is_dir():
201
+ return _running_members_from_proc(proc_directory, process_group_identifier)
202
+ try:
203
+ process.wait(timeout=PROCESS_TREE_EXIT_POLL_SECONDS)
204
+ except subprocess.TimeoutExpired:
205
+ return [process.pid]
206
+ try:
207
+ os.killpg(process_group_identifier, 0)
208
+ except OSError:
209
+ return []
210
+ return [process_group_identifier]
211
+
212
+
213
+ def _running_members_from_proc(
214
+ proc_directory: Path, process_group_identifier: int
215
+ ) -> list[int]:
216
+ all_running: list[int] = []
217
+ for each_entry in proc_directory.iterdir():
218
+ if not each_entry.name.isdigit():
219
+ continue
220
+ try:
221
+ stat_text = (each_entry / "stat").read_text(
222
+ encoding="ascii", errors="replace"
223
+ )
224
+ except OSError:
225
+ continue
226
+ state, _parent_identifier, group_identifier, *_later_fields = stat_text.rsplit(
227
+ ")", 1
228
+ )[1].split()
229
+ if int(group_identifier) == process_group_identifier and state != "Z":
230
+ all_running.append(int(each_entry.name))
231
+ return all_running
@@ -7,9 +7,12 @@ the taskkill argv it builds, the failures it swallows, and the
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import os
10
11
  import subprocess
11
12
  import sys
13
+ import time
12
14
  from pathlib import Path
15
+ from typing import cast
13
16
 
14
17
  import pytest
15
18
 
@@ -23,6 +26,8 @@ from process_tree_kill import ( # noqa: E402
23
26
  terminate_process_tree,
24
27
  )
25
28
  from process_tree_scripts_constants.process_tree_kill_constants import ( # noqa: E402
29
+ PROCESS_TREE_EXIT_POLL_SECONDS,
30
+ PROCESS_TREE_EXIT_WAIT_SECONDS,
26
31
  PROCESS_TREE_KILL_TIMEOUT_SECONDS,
27
32
  WINDOWS_TASKKILL_COMMAND,
28
33
  WINDOWS_TASKKILL_FORCE_FLAG,
@@ -276,3 +281,147 @@ def test_new_session_is_requested_off_windows_only(
276
281
 
277
282
  _install_windows_platform(monkeypatch)
278
283
  assert process_tree_kill.should_start_new_session() is False
284
+
285
+
286
+ def _install_posix_group_wait(
287
+ monkeypatch: pytest.MonkeyPatch, all_member_snapshots: list[list[int]]
288
+ ) -> list[float]:
289
+ """Script what the group wait sees after the kill, and record each pause.
290
+
291
+ The last snapshot repeats once the list runs dry, so a never-empty script
292
+ models a group that outlives the deadline.
293
+ """
294
+ all_sleeps: list[float] = []
295
+ remaining_snapshots = list(all_member_snapshots)
296
+
297
+ def scripted_members(process: object, process_group_identifier: int) -> list[int]:
298
+ del process, process_group_identifier
299
+ if len(remaining_snapshots) > 1:
300
+ return remaining_snapshots.pop(0)
301
+ return remaining_snapshots[0]
302
+
303
+ _install_posix_platform(monkeypatch)
304
+ monkeypatch.setattr(
305
+ process_tree_kill.os,
306
+ "getpgid",
307
+ lambda process_identifier: FAKE_PROCESS_GROUP_IDENTIFIER,
308
+ raising=False,
309
+ )
310
+ monkeypatch.setattr(
311
+ process_tree_kill.os,
312
+ "killpg",
313
+ lambda group_identifier, signal_number: None,
314
+ raising=False,
315
+ )
316
+ monkeypatch.setattr(
317
+ process_tree_kill, "running_process_group_members", scripted_members
318
+ )
319
+ monkeypatch.setattr(process_tree_kill.time, "sleep", all_sleeps.append)
320
+ return all_sleeps
321
+
322
+
323
+ def test_terminate_waits_until_no_group_member_is_running(
324
+ monkeypatch: pytest.MonkeyPatch,
325
+ ) -> None:
326
+ """The POSIX kill returns only once the signalled group has emptied.
327
+
328
+ ::
329
+
330
+ members [7], [7], [] ok: two pauses, then return
331
+ no wait flag: a grandchild still inside a system call
332
+ writes after the caller has moved on
333
+ """
334
+ all_sleeps = _install_posix_group_wait(monkeypatch, [[7], [7], []])
335
+
336
+ terminate_process_tree(_as_popen(_FakeProcess([None, 0])))
337
+
338
+ assert all_sleeps == [PROCESS_TREE_EXIT_POLL_SECONDS] * 2
339
+
340
+
341
+ def test_terminate_stops_waiting_at_the_exit_deadline(
342
+ monkeypatch: pytest.MonkeyPatch,
343
+ ) -> None:
344
+ """A group member that survives the signal does not hold the caller forever."""
345
+ all_sleeps = _install_posix_group_wait(monkeypatch, [[7]])
346
+ clock = iter(
347
+ [0.0, 0.0, PROCESS_TREE_EXIT_WAIT_SECONDS / 2, PROCESS_TREE_EXIT_WAIT_SECONDS]
348
+ )
349
+ monkeypatch.setattr(process_tree_kill.time, "monotonic", lambda: next(clock))
350
+
351
+ terminate_process_tree(_as_popen(_FakeProcess([None, 0])))
352
+
353
+ assert all_sleeps == [PROCESS_TREE_EXIT_POLL_SECONDS] * 2
354
+
355
+
356
+ def _as_popen(fake_process: _FakeProcess) -> subprocess.Popen[str]:
357
+ return cast("subprocess.Popen[str]", fake_process)
358
+
359
+
360
+ def _group_led_by(process: subprocess.Popen[str]) -> int:
361
+ """Name the group a session-leading child owns; Windows has none, so its pid."""
362
+ if process_tree_kill.should_start_new_session():
363
+ return os.getpgid(process.pid)
364
+ return process.pid
365
+
366
+
367
+ def test_running_members_leave_out_a_zombie() -> None:
368
+ """A member that died but awaits its reaper is not running.
369
+
370
+ ::
371
+
372
+ child sleeping ok: listed on POSIX, [] on Windows
373
+ child killed, not yet reaped ok: dropped, so the wait ends
374
+ """
375
+ sleeper = subprocess.Popen(
376
+ [sys.executable, "-c", "import time; time.sleep(60)"],
377
+ start_new_session=process_tree_kill.should_start_new_session(),
378
+ )
379
+ group_identifier = _group_led_by(sleeper)
380
+ all_expected_running = (
381
+ [sleeper.pid] if process_tree_kill.should_start_new_session() else []
382
+ )
383
+ try:
384
+ assert (
385
+ process_tree_kill.running_process_group_members(sleeper, group_identifier)
386
+ == all_expected_running
387
+ )
388
+ sleeper.kill()
389
+ deadline = time.monotonic() + PROCESS_TREE_EXIT_WAIT_SECONDS
390
+ while process_tree_kill.running_process_group_members(
391
+ sleeper, group_identifier
392
+ ):
393
+ assert time.monotonic() < deadline, "the killed sleeper still counts"
394
+ time.sleep(PROCESS_TREE_EXIT_POLL_SECONDS)
395
+ finally:
396
+ sleeper.wait()
397
+
398
+
399
+ def test_terminate_returns_with_the_grandchild_already_gone(tmp_path: Path) -> None:
400
+ """Right after the kill returns, no process in the child's group still runs."""
401
+ heartbeat_file = tmp_path / "heartbeat.txt"
402
+ grandchild_source = (
403
+ "import pathlib, sys\n"
404
+ "heartbeat = pathlib.Path(sys.argv[1])\n"
405
+ "while True:\n"
406
+ " heartbeat.write_text('beat', encoding='utf-8')\n"
407
+ )
408
+ child_source = (
409
+ "import subprocess, sys, time\n"
410
+ "subprocess.Popen([sys.executable, '-c', sys.argv[1], sys.argv[2]])\n"
411
+ "time.sleep(60)\n"
412
+ )
413
+ child = subprocess.Popen(
414
+ [sys.executable, "-c", child_source, grandchild_source, str(heartbeat_file)],
415
+ start_new_session=process_tree_kill.should_start_new_session(),
416
+ )
417
+ group_identifier = _group_led_by(child)
418
+ while not heartbeat_file.exists():
419
+ time.sleep(PROCESS_TREE_EXIT_POLL_SECONDS)
420
+
421
+ terminate_process_tree(child)
422
+ still_running = process_tree_kill.running_process_group_members(
423
+ child, group_identifier
424
+ )
425
+ child.wait()
426
+
427
+ assert still_running == []
@@ -15,7 +15,7 @@ export const PSTACK_PLUGIN_SPEC = Object.freeze({
15
15
  label: 'Pstack',
16
16
  marketplaceRepository: PSTACK_MARKETPLACE_REPOSITORY,
17
17
  marketplaceName: PSTACK_MARKETPLACE_NAME,
18
- marketplaceAddArguments: Object.freeze([]),
18
+ sparsePaths: Object.freeze([]),
19
19
  pluginIdentifier: PSTACK_PLUGIN_IDENTIFIER,
20
20
  hosts: PSTACK_PLUGIN_HOSTS,
21
21
  optOutFlag: PSTACK_PLUGIN_OPT_OUT_FLAG,
@@ -27,13 +27,25 @@ export const USAGE_WRAPUP_PLUGIN_SPEC = Object.freeze({
27
27
  label: 'Usage-wrapup',
28
28
  marketplaceRepository: 'jl-cmd/claude-dev-env',
29
29
  marketplaceName: 'claude-dev-env',
30
- marketplaceAddArguments: Object.freeze(['--sparse', '.claude-plugin', 'packages/usage-wrapup']),
30
+ sparsePaths: Object.freeze(['.claude-plugin', 'packages/usage-wrapup']),
31
31
  pluginIdentifier: 'usage-wrapup@claude-dev-env',
32
32
  hosts: Object.freeze(['claude']),
33
33
  optOutFlag: '--no-usage-wrapup',
34
34
  optOutVariable: 'CDE_INSTALL_USAGE_WRAPUP',
35
35
  });
36
36
 
37
+ export const SUBAGENT_MODELS_PLUGIN_SPEC = Object.freeze({
38
+ name: 'subagent-models',
39
+ label: 'Subagent-models',
40
+ marketplaceRepository: 'jl-cmd/claude-dev-env',
41
+ marketplaceName: 'claude-dev-env',
42
+ sparsePaths: Object.freeze(['.claude-plugin', 'packages/subagent-models']),
43
+ pluginIdentifier: 'subagent-models@claude-dev-env',
44
+ hosts: Object.freeze(['claude']),
45
+ optOutFlag: '--no-subagent-models',
46
+ optOutVariable: 'CDE_INSTALL_SUBAGENT_MODELS',
47
+ });
48
+
37
49
  const HOST_DEFINITIONS = Object.freeze({
38
50
  claude: Object.freeze({
39
51
  executable: 'claude',
@@ -55,42 +67,60 @@ const HOST_DEFINITIONS = Object.freeze({
55
67
  });
56
68
 
57
69
  /**
58
- * Read the marketplace and plugin commands one host installs a plugin with.
70
+ * Read the marketplace and plugin commands one host installs a set of plugins with.
59
71
  *
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.
72
+ * Every plugin in the set comes from one marketplace. Removing a marketplace
73
+ * uninstalls each plugin it supplied, so the plan removes and adds that
74
+ * marketplace once for the whole set. The removal commands run first and take
75
+ * out each installed plugin, its cached versions, and the marketplace clone,
76
+ * so the install commands that follow fetch the newest published version into
77
+ * an empty slot.
63
78
  *
64
79
  * Claude Code and Codex publish a marketplace under different plugin verbs, so
65
80
  * the install verb belongs to the host and the marketplace and identifier
66
81
  * belong to the plugin spec. A spec whose marketplace is a large repository
67
- * passes `--sparse .claude-plugin` among its marketplace add arguments, so the
68
- * clone holds only the catalog and stays clear of Windows path-length limits.
82
+ * names its sparse paths, and the one marketplace add checks out every path
83
+ * the set names, so the clone holds only the catalog and those plugin folders
84
+ * and stays clear of Windows path-length limits.
69
85
  *
70
- * @param {typeof PSTACK_PLUGIN_SPEC} spec The plugin to install.
71
- * @param {string} host A host the spec names, `claude` or `codex`.
86
+ * @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins to install, from one marketplace.
87
+ * @param {string} host A host every spec names, `claude` or `codex`.
72
88
  * @returns {{executable: string, executableVariable: string, homeVariable: string,
73
89
  * removalCommands: ReadonlyArray<ReadonlyArray<string>>,
74
90
  * commands: ReadonlyArray<ReadonlyArray<string>>}} The host's install plan.
75
91
  */
76
- export function marketplacePluginPlan(spec, host) {
92
+ export function marketplacePluginsPlan(allSpecs, host) {
77
93
  const hostDefinition = HOST_DEFINITIONS[host];
78
- if (!hostDefinition || !spec.hosts.includes(host)) {
94
+ for (const eachSpec of allSpecs) {
95
+ if (!hostDefinition || !eachSpec.hosts.includes(host)) {
96
+ throw new Error(
97
+ `Unsupported ${eachSpec.name} plugin host ${host}: this installer knows ${eachSpec.hosts.join(', ')}.`,
98
+ );
99
+ }
100
+ }
101
+ const [firstSpec] = allSpecs;
102
+ const otherMarketplaceSpec = allSpecs.find(
103
+ eachSpec => eachSpec.marketplaceRepository !== firstSpec.marketplaceRepository,
104
+ );
105
+ if (otherMarketplaceSpec) {
79
106
  throw new Error(
80
- `Unsupported ${spec.name} plugin host ${host}: this installer knows ${spec.hosts.join(', ')}.`,
107
+ `${otherMarketplaceSpec.name} comes from ${otherMarketplaceSpec.marketplaceRepository}, `
108
+ + `and one plan installs from ${firstSpec.marketplaceRepository} only.`,
81
109
  );
82
110
  }
111
+ const allSparsePaths = [...new Set(allSpecs.flatMap(eachSpec => eachSpec.sparsePaths))];
112
+ const sparseArguments = allSparsePaths.length > 0 ? ['--sparse', ...allSparsePaths] : [];
83
113
  return {
84
114
  executable: hostDefinition.executable,
85
115
  executableVariable: hostDefinition.executableVariable,
86
116
  homeVariable: hostDefinition.homeVariable,
87
117
  removalCommands: [
88
- ['plugin', hostDefinition.uninstallVerb, spec.pluginIdentifier],
89
- ['plugin', 'marketplace', 'remove', spec.marketplaceName],
118
+ ...allSpecs.map(eachSpec => ['plugin', hostDefinition.uninstallVerb, eachSpec.pluginIdentifier]),
119
+ ['plugin', 'marketplace', 'remove', firstSpec.marketplaceName],
90
120
  ],
91
121
  commands: [
92
- ['plugin', 'marketplace', 'add', spec.marketplaceRepository, ...spec.marketplaceAddArguments],
93
- ['plugin', hostDefinition.installVerb, spec.pluginIdentifier],
122
+ ['plugin', 'marketplace', 'add', firstSpec.marketplaceRepository, ...sparseArguments],
123
+ ...allSpecs.map(eachSpec => ['plugin', hostDefinition.installVerb, eachSpec.pluginIdentifier]),
94
124
  ],
95
125
  };
96
126
  }
@@ -198,30 +228,31 @@ export function removeOrphanedPluginVersions(versionsDirectory) {
198
228
  return removedPaths;
199
229
  }
200
230
 
201
- function absentHostOutcome(spec, host, executable) {
231
+ function absentHostOutcome(allSpecs, host, executable) {
232
+ const pluginNames = allSpecs.map(eachSpec => eachSpec.name).join(', ');
202
233
  return {
203
234
  host,
204
235
  executable,
205
236
  status: 'skipped',
206
- warning: `${executable} is not on PATH, so ${spec.name} was not installed for ${host}.`,
237
+ warning: `${executable} is not on PATH, so ${pluginNames} was not installed for ${host}.`,
207
238
  };
208
239
  }
209
240
 
210
- function installForHost(spec, host, homeDirectory, environment, runCommand) {
211
- const plan = marketplacePluginPlan(spec, host);
241
+ function installForHost(allSpecs, host, homeDirectory, environment, runCommand) {
242
+ const plan = marketplacePluginsPlan(allSpecs, host);
212
243
  const executable = environment[plan.executableVariable] || plan.executable;
213
244
  const commandEnvironment = { [plan.homeVariable]: homeDirectory };
214
245
  for (const commandArguments of plan.removalCommands) {
215
246
  const outcome = runCommand(executable, [...commandArguments], {
216
247
  environment: commandEnvironment,
217
248
  });
218
- if (namesAnAbsentCommand(outcome)) return absentHostOutcome(spec, host, executable);
249
+ if (namesAnAbsentCommand(outcome)) return absentHostOutcome(allSpecs, host, executable);
219
250
  }
220
251
  for (const commandArguments of plan.commands) {
221
252
  const outcome = runCommand(executable, [...commandArguments], {
222
253
  environment: commandEnvironment,
223
254
  });
224
- if (namesAnAbsentCommand(outcome)) return absentHostOutcome(spec, host, executable);
255
+ if (namesAnAbsentCommand(outcome)) return absentHostOutcome(allSpecs, host, executable);
225
256
  if (outcome.status !== 0 || outcome.error) {
226
257
  const detail = firstLine(outcome.stderr) || outcome.error?.message || `exit ${outcome.status}`;
227
258
  return {
@@ -234,17 +265,19 @@ function installForHost(spec, host, homeDirectory, environment, runCommand) {
234
265
  }
235
266
  const hostDefinition = HOST_DEFINITIONS[host];
236
267
  if (hostDefinition.orphanedVersionsDirectory && homeDirectory) {
237
- removeOrphanedPluginVersions(hostDefinition.orphanedVersionsDirectory(homeDirectory, spec));
268
+ for (const eachSpec of allSpecs) {
269
+ removeOrphanedPluginVersions(hostDefinition.orphanedVersionsDirectory(homeDirectory, eachSpec));
270
+ }
238
271
  }
239
272
  return { host, executable, status: 'installed', warning: null };
240
273
  }
241
274
 
242
275
  /**
243
- * Install one plugin from its marketplace into each host's own home.
276
+ * Install a set of plugins from one marketplace into each host's own home.
244
277
  *
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.
278
+ * Each host first removes every plugin in the set and then the marketplace,
279
+ * so an older version never survives beside the new one. A removal that finds
280
+ * nothing to remove exits non-zero on a fresh home, and the install goes on.
248
281
  *
249
282
  * Each host is one member of the batch. A host without its command-line tool
250
283
  * is skipped and a host whose command fails is reported, so the rules, hooks,
@@ -253,22 +286,24 @@ function installForHost(spec, host, homeDirectory, environment, runCommand) {
253
286
  * `cmd.exe`'s command-not-found exit code on Windows, and both read as
254
287
  * skipped.
255
288
  *
256
- * @param {typeof PSTACK_PLUGIN_SPEC} spec The plugin to install.
289
+ * @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins to install, from one marketplace.
257
290
  * @param {object} [options] Install targets.
258
291
  * @param {string} [options.claudeRoot] The managed Claude root to install into.
259
292
  * @param {string} [options.codexHome] The Codex home to install into.
260
- * @param {string[]} [options.hosts] The hosts to install. Defaults to the spec's hosts.
293
+ * @param {string[]} [options.hosts] The hosts to install. Defaults to the hosts every spec names.
261
294
  * @param {Record<string, string|undefined>} [options.environment] The process environment.
262
295
  * @param {object} [dependencies] Seams for the command runner.
263
296
  * @returns {{status: string, hosts: object[], warning: string|null}} The outcome per host.
264
297
  */
265
- export function installMarketplacePlugin(spec, options = {}, dependencies = {}) {
298
+ export function installMarketplacePlugins(allSpecs, options = {}, dependencies = {}) {
266
299
  const environment = options.environment ?? process.env;
267
300
  const runCommand = dependencies.runCommand ?? runHostCommand;
268
301
  const homeDirectories = { claude: options.claudeRoot, codex: options.codexHome };
269
- const hosts = options.hosts ?? spec.hosts;
302
+ const hosts = options.hosts ?? allSpecs[0].hosts.filter(
303
+ eachHost => allSpecs.every(eachSpec => eachSpec.hosts.includes(eachHost)),
304
+ );
270
305
  const hostOutcomes = hosts.map(host => installForHost(
271
- spec,
306
+ allSpecs,
272
307
  host,
273
308
  homeDirectories[host],
274
309
  environment,