magus-cli 7.8.0 → 7.9.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.
Files changed (88) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +16 -11
  4. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  5. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  6. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  7. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  8. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  9. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  10. package/src/__tests__/blind/review-followups/bun-install-path.test.ts +57 -0
  11. package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
  12. package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
  13. package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
  14. package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
  15. package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
  16. package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
  17. package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
  18. package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
  19. package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -0
  20. package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
  21. package/src/__tests__/blind-780-review2.test.ts +0 -25
  22. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  23. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  24. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  25. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  26. package/src/__tests__/deps-adapter.test.ts +191 -0
  27. package/src/__tests__/deps-check-hook.test.ts +220 -0
  28. package/src/__tests__/deps-plan.test.ts +897 -0
  29. package/src/__tests__/deps-report.test.ts +197 -0
  30. package/src/__tests__/doctor-bins.test.ts +134 -36
  31. package/src/__tests__/doctor-json.test.ts +261 -0
  32. package/src/__tests__/failure-popup-review2.test.tsx +1 -25
  33. package/src/__tests__/final-fixes-780.test.tsx +25 -9
  34. package/src/__tests__/fixtures/bins.ts +55 -0
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  38. package/src/__tests__/npm-package-name.test.ts +6 -9
  39. package/src/__tests__/plugin-requires.test.ts +138 -153
  40. package/src/__tests__/requires-probe.test.ts +858 -0
  41. package/src/__tests__/resolver.test.ts +129 -52
  42. package/src/__tests__/run.test.ts +108 -0
  43. package/src/__tests__/tool-probe.test.ts +1 -1
  44. package/src/__tests__/toolchain.test.ts +123 -52
  45. package/src/__tests__/update-plan.test.ts +1 -1
  46. package/src/cli/ansi.ts +2 -7
  47. package/src/cli/deps-view.ts +213 -0
  48. package/src/cli/doctor.ts +169 -24
  49. package/src/cli/install.ts +51 -66
  50. package/src/cli/profile.ts +20 -2
  51. package/src/cli/router.ts +6 -2
  52. package/src/cli/update.ts +28 -7
  53. package/src/data/cli-tools.ts +24 -9
  54. package/src/services/active-closure.ts +3 -3
  55. package/src/services/claude-settings.ts +4 -348
  56. package/src/services/cli-tool-commands.ts +104 -3
  57. package/src/services/deps-plan.ts +1154 -0
  58. package/src/services/deps-report.ts +265 -0
  59. package/src/services/doctor-bins.ts +115 -43
  60. package/src/services/install-plan.ts +11 -1
  61. package/src/services/local-marketplace.ts +12 -206
  62. package/src/services/marketplace-refresh.ts +4 -4
  63. package/src/services/plugin-manager.ts +4 -18
  64. package/src/services/plugin-requires.ts +93 -162
  65. package/src/services/resolver.ts +168 -58
  66. package/src/services/tool-probe.ts +18 -25
  67. package/src/services/toolchain.ts +117 -111
  68. package/src/services/update-engine.ts +19 -14
  69. package/src/services/update-plan.ts +3 -3
  70. package/src/shared/plugin/hooks/bun-missing.sh +64 -0
  71. package/src/shared/plugin/hooks/deps-check.ts +240 -0
  72. package/src/shared/plugin/hooks/lib/requires-probe.ts +1279 -0
  73. package/src/types/index.ts +53 -16
  74. package/src/ui/App.tsx +6 -40
  75. package/src/ui/adapters/cliToolsAdapter.ts +75 -37
  76. package/src/ui/adapters/depsAdapter.ts +129 -0
  77. package/src/ui/adapters/startupReports.ts +0 -22
  78. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  79. package/src/ui/screens/CliToolsScreen.tsx +141 -51
  80. package/src/ui/screens/PluginsScreen.tsx +12 -48
  81. package/src/utils/command-utils.ts +4 -5
  82. package/src/utils/run.ts +143 -52
  83. package/src/utils/string-utils.ts +9 -0
  84. package/src/__tests__/go-module.test.ts +0 -43
  85. package/src/__tests__/plugin-setup.test.ts +0 -1016
  86. package/src/__tests__/shell-script-callers.test.ts +0 -92
  87. package/src/services/plugin-setup.ts +0 -965
  88. package/src/utils/go-module.ts +0 -36
@@ -162,9 +162,6 @@ function getGitRemote(marketplacePath: string): string | undefined {
162
162
  }
163
163
  }
164
164
 
165
- /**
166
- * Scan a single marketplace directory and return marketplace info
167
- */
168
165
  /**
169
166
  * Names of the skills under `skillsDir`, recursing through grouping directories.
170
167
  *
@@ -260,6 +257,11 @@ export async function scanMcpServers(pluginPath: string): Promise<string[]> {
260
257
  return found;
261
258
  }
262
259
 
260
+ /**
261
+ * Read one marketplace clone: its manifest, plus the component counts of each
262
+ * plugin whose source directory is on disk. Read-only — the manifest and the
263
+ * clone belong to Claude Code, and nothing here writes either.
264
+ */
263
265
  async function scanSingleMarketplace(
264
266
  marketplacePath: string,
265
267
  marketplaceName: string,
@@ -277,8 +279,6 @@ async function scanSingleMarketplace(
277
279
  const gitRepo = getGitRemote(marketplacePath);
278
280
 
279
281
  const plugins: LocalMarketplacePlugin[] = [];
280
- const validManifestPlugins: typeof manifest.plugins = [];
281
- let manifestModified = false;
282
282
 
283
283
  if (manifest.plugins && Array.isArray(manifest.plugins)) {
284
284
  for (const plugin of manifest.plugins) {
@@ -292,18 +292,13 @@ async function scanSingleMarketplace(
292
292
  const sourceStr =
293
293
  typeof plugin.source === "string" ? plugin.source : null;
294
294
 
295
- if (sourceStr) {
296
- const pluginPath = path.join(
297
- marketplacePath,
298
- sourceStr.replace("./", ""),
299
- );
300
-
301
- // Remove plugins whose source directory doesn't exist
302
- if (!(await fs.pathExists(pluginPath))) {
303
- manifestModified = true;
304
- continue;
305
- }
306
-
295
+ // A plugin whose source directory is not on disk is still listed: the
296
+ // manifest is Claude Code's, and a scan reads it, never rewrites it.
297
+ // Its component lists stay empty because there is nothing to count.
298
+ const pluginPath = sourceStr
299
+ ? path.join(marketplacePath, sourceStr.replace("./", ""))
300
+ : null;
301
+ if (pluginPath && (await fs.pathExists(pluginPath))) {
307
302
  try {
308
303
  // Scan for agents
309
304
  const agentsDir = path.join(pluginPath, "agents");
@@ -330,7 +325,6 @@ async function scanSingleMarketplace(
330
325
  }
331
326
  }
332
327
 
333
- validManifestPlugins.push(plugin);
334
328
  plugins.push({
335
329
  name: plugin.name,
336
330
  version: plugin.version || "0.0.0",
@@ -349,12 +343,6 @@ async function scanSingleMarketplace(
349
343
  }
350
344
  }
351
345
 
352
- // Update marketplace.json if we removed invalid plugins
353
- if (manifestModified) {
354
- manifest.plugins = validManifestPlugins;
355
- await fs.writeJson(manifestPath, manifest, { spaces: 2 });
356
- }
357
-
358
346
  return {
359
347
  name: manifest.name || marketplaceName,
360
348
  description: manifest.description || manifest.metadata?.description || "",
@@ -466,13 +454,6 @@ export async function hasLocalMarketplace(name: string): Promise<boolean> {
466
454
  return fs.pathExists(manifestPath);
467
455
  }
468
456
 
469
- export interface RefreshResult {
470
- name: string;
471
- success: boolean;
472
- updated: boolean;
473
- error?: string;
474
- }
475
-
476
457
  export interface RefreshProgress {
477
458
  current: number;
478
459
  total: number;
@@ -480,178 +461,3 @@ export interface RefreshProgress {
480
461
  }
481
462
 
482
463
  export type ProgressCallback = (progress: RefreshProgress) => void;
483
-
484
- export interface RepairResult {
485
- repaired: boolean;
486
- pluginName: string;
487
- added: {
488
- agents: string[];
489
- commands: string[];
490
- skills: string[];
491
- };
492
- }
493
-
494
- /**
495
- * Repair a plugin's plugin.json by adding missing agents/commands/skills arrays
496
- * based on what files exist on disk.
497
- *
498
- * This fixes plugins where the author forgot to add the arrays to plugin.json,
499
- * causing Claude Code to not load the agents/commands even though files exist.
500
- */
501
- export async function repairPluginJson(
502
- pluginPath: string,
503
- ): Promise<RepairResult> {
504
- const pluginJsonPath = path.join(pluginPath, ".claude-plugin", "plugin.json");
505
- const pluginName = path.basename(pluginPath);
506
-
507
- const result: RepairResult = {
508
- repaired: false,
509
- pluginName,
510
- added: { agents: [], commands: [], skills: [] },
511
- };
512
-
513
- if (!(await fs.pathExists(pluginJsonPath))) {
514
- return result;
515
- }
516
-
517
- try {
518
- const pluginJson = await fs.readJson(pluginJsonPath);
519
- let modified = false;
520
-
521
- // Scan and add agents if array is missing
522
- const agentsDir = path.join(pluginPath, "agents");
523
- if (await fs.pathExists(agentsDir)) {
524
- const files = (await fs.readdir(agentsDir))
525
- .filter((f) => f.endsWith(".md"))
526
- .map((f) => `./agents/${f}`);
527
-
528
- if (files.length > 0 && !pluginJson.agents) {
529
- pluginJson.agents = files;
530
- result.added.agents = files;
531
- modified = true;
532
- }
533
- }
534
-
535
- // Scan and add commands if array is missing
536
- const commandsDir = path.join(pluginPath, "commands");
537
- if (await fs.pathExists(commandsDir)) {
538
- const files = (await fs.readdir(commandsDir))
539
- .filter((f) => f.endsWith(".md"))
540
- .map((f) => `./commands/${f}`);
541
-
542
- if (files.length > 0 && !pluginJson.commands) {
543
- pluginJson.commands = files;
544
- result.added.commands = files;
545
- modified = true;
546
- }
547
- }
548
-
549
- // Scan and add skills if array is missing
550
- const skillsDir = path.join(pluginPath, "skills");
551
- if (await fs.pathExists(skillsDir)) {
552
- const entries = await fs.readdir(skillsDir, { withFileTypes: true });
553
- const skills = entries
554
- .filter((e) => e.isDirectory() || e.name.endsWith(".md"))
555
- .map((e) => `./skills/${e.name}`);
556
-
557
- if (skills.length > 0 && !pluginJson.skills) {
558
- pluginJson.skills = skills;
559
- result.added.skills = skills;
560
- modified = true;
561
- }
562
- }
563
-
564
- if (modified) {
565
- await fs.writeJson(pluginJsonPath, pluginJson, { spaces: 2 });
566
- result.repaired = true;
567
- }
568
- } catch {
569
- // Ignore repair errors for individual plugins
570
- }
571
-
572
- return result;
573
- }
574
-
575
- export interface RepairMarketplaceResult {
576
- marketplace: string;
577
- repaired: RepairResult[];
578
- }
579
-
580
- /**
581
- * Repair all plugins in a marketplace
582
- */
583
- export async function repairMarketplacePlugins(
584
- marketplaceName: string,
585
- ): Promise<RepairMarketplaceResult> {
586
- const result: RepairMarketplaceResult = {
587
- marketplace: marketplaceName,
588
- repaired: [],
589
- };
590
-
591
- const marketplacePath = path.join(CLAUDE_PLUGINS_DIR, marketplaceName);
592
- const manifestPath = path.join(
593
- marketplacePath,
594
- ".claude-plugin",
595
- "marketplace.json",
596
- );
597
-
598
- if (!(await fs.pathExists(manifestPath))) {
599
- return result;
600
- }
601
-
602
- try {
603
- const manifest = await fs.readJson(manifestPath);
604
-
605
- if (manifest.plugins && Array.isArray(manifest.plugins)) {
606
- for (const plugin of manifest.plugins) {
607
- if (plugin.source) {
608
- const pluginPath = path.join(
609
- marketplacePath,
610
- plugin.source.replace("./", ""),
611
- );
612
- const repairResult = await repairPluginJson(pluginPath);
613
-
614
- if (repairResult.repaired) {
615
- result.repaired.push(repairResult);
616
- }
617
- }
618
- }
619
- }
620
- } catch {
621
- // Ignore errors
622
- }
623
-
624
- return result;
625
- }
626
-
627
- /**
628
- * Repair all plugins in all marketplaces
629
- */
630
- export async function repairAllMarketplaces(): Promise<
631
- RepairMarketplaceResult[]
632
- > {
633
- const results: RepairMarketplaceResult[] = [];
634
-
635
- if (!(await fs.pathExists(CLAUDE_PLUGINS_DIR))) {
636
- return results;
637
- }
638
-
639
- try {
640
- const entries = await fs.readdir(CLAUDE_PLUGINS_DIR, {
641
- withFileTypes: true,
642
- });
643
-
644
- for (const entry of entries) {
645
- if (!entry.isDirectory()) continue;
646
-
647
- const repairResult = await repairMarketplacePlugins(entry.name);
648
- if (repairResult.repaired.length > 0) {
649
- results.push(repairResult);
650
- }
651
- }
652
- } catch {
653
- // Return empty on error
654
- }
655
-
656
- return results;
657
- }
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Keep on-disk marketplace clones current — safely.
3
3
  *
4
- * The plugin *list* magus shows is fetched fresh from GitHub, but a plugin's
5
- * `setup` block and Claude Code's own installer read the on-disk marketplace
6
- * clone (~/.claude/plugins/marketplaces/<name>). Nothing else keeps that clone
4
+ * The plugin *list* magus shows is fetched fresh from GitHub, but the
5
+ * `requires` of a plugin not installed yet and Claude Code's own installer read
6
+ * the on-disk marketplace clone (~/.claude/plugins/marketplaces/<name>). Nothing else keeps that clone
7
7
  * current — recoverMarketplaceSettings only pulls entries it had to re-register —
8
8
  * so a healthy marketplace's clone goes stale and its plugin metadata (e.g. a
9
- * pinned dependency version in a plugin's setup) is never seen, even while the
9
+ * dependency pin in a plugin's `requires`) is never seen, even while the
10
10
  * displayed version looks up to date.
11
11
  *
12
12
  * IMPORTANT: this refreshes the clone with a NON-DESTRUCTIVE `git pull --ff-only`,
@@ -33,8 +33,6 @@ import { inheritablePaths } from "./git-worktree.js";
33
33
  import {
34
34
  type LocalMarketplace,
35
35
  type ProgressCallback,
36
- type RepairMarketplaceResult,
37
- repairAllMarketplaces,
38
36
  scanLocalMarketplaces,
39
37
  } from "./local-marketplace.js";
40
38
  import {
@@ -1043,18 +1041,14 @@ async function autoSyncIfStale(
1043
1041
  return localMarketplaces;
1044
1042
  }
1045
1043
 
1046
- export interface RefreshAndRepairResult {
1047
- refresh: never[];
1048
- repair: RepairMarketplaceResult[];
1049
- }
1050
-
1051
1044
  /**
1052
- * Refresh magus's internal cache
1053
- * Note: Marketplace updates should be done via Claude Code's /plugin marketplace update command
1045
+ * Refresh magus's internal cache. Only magus's own caches are cleared: the
1046
+ * marketplace clones are Claude Code's, and refreshing them is
1047
+ * `claude plugin marketplace update`.
1054
1048
  */
1055
1049
  export async function refreshAllMarketplaces(
1056
1050
  onProgress?: ProgressCallback,
1057
- ): Promise<RefreshAndRepairResult> {
1051
+ ): Promise<void> {
1058
1052
  onProgress?.({ current: 1, total: 1, name: "Clearing cache..." });
1059
1053
 
1060
1054
  // Clear all caches to force fresh data. The on-disk catalog cache is awaited
@@ -1062,14 +1056,6 @@ export async function refreshAllMarketplaces(
1062
1056
  // entries this is discarding.
1063
1057
  clearMarketplaceCache();
1064
1058
  await clearPersistedCatalogs();
1065
-
1066
- // Auto-repair plugin.json files with missing agents/commands/skills
1067
- const repairResults = await repairAllMarketplaces();
1068
-
1069
- return {
1070
- refresh: [],
1071
- repair: repairResults,
1072
- };
1073
1059
  }
1074
1060
 
1075
1061
  // Re-export types for consumers
@@ -1,21 +1,28 @@
1
1
  /**
2
- * Read plugin-declared binary dependencies from plugin.json `requires.bin`.
2
+ * What a plugin's plugin.json says it needs: its `dependencies` (other plugins)
3
+ * and its `requires` (binaries on the machine).
3
4
  *
4
- * A plugin declares the binaries it needs on PATH (e.g. terminal@magus needs
5
- * `tmux` and `tmux-mcp`). `magus install` resolves these transitively so a
6
- * teammate who enables a plugin also gets its binaries — closing the class of
7
- * bug where a plugin's MCP server silently fails because its binary is missing.
5
+ * `requires` is parsed by `shared/plugin/hooks/lib/requires-probe.ts` and by
6
+ * nothing else — the same file every declaring plugin carries for its
7
+ * SessionStart deps check, so the hook and magus read one declaration one way.
8
+ * This module only locates the manifest Claude Code loads and hands it over.
8
9
  */
9
10
 
10
11
  import os from "node:os";
11
12
  import path from "node:path";
12
13
  import fs from "fs-extra";
13
- import type {
14
- BinInstaller,
15
- BinRequirement,
16
- PluginRequires,
17
- } from "../types/index.js";
18
- import { goBinaryName } from "../utils/go-module.js";
14
+ import {
15
+ type Arch,
16
+ type Os,
17
+ type ParseResult,
18
+ type Requirement,
19
+ applicable,
20
+ currentPlatform,
21
+ onPath,
22
+ parseRequires,
23
+ selectOption,
24
+ } from "../shared/plugin/hooks/lib/requires-probe.js";
25
+ import type { SelectedBin } from "../types/index.js";
19
26
  import { parsePluginId } from "../utils/string-utils.js";
20
27
  import { readInstalledPluginsRegistry } from "./claude-settings.js";
21
28
  import { getLocalMarketplacesInfo } from "./plugin-manager.js";
@@ -24,144 +31,6 @@ import { pickLoadedEntry } from "./plugin-resolution.js";
24
31
  const MARKETPLACES_DIR = (): string =>
25
32
  path.join(os.homedir(), ".claude", "plugins", "marketplaces");
26
33
 
27
- const VALID_INSTALLERS: readonly BinInstaller[] = [
28
- "bun",
29
- "brew",
30
- "npm",
31
- "pip",
32
- "go",
33
- ];
34
-
35
- /**
36
- * Normalize a raw `requires.bin` entry into a complete BinRequirement,
37
- * filling in defaults. Returns null for entries that can't be made valid
38
- * (missing name, or an unrecognized installer).
39
- */
40
- function normalizeBin(raw: unknown): BinRequirement | null {
41
- if (!raw || typeof raw !== "object") return null;
42
- const o = raw as Record<string, unknown>;
43
- const name = typeof o.name === "string" ? o.name.trim() : "";
44
- if (!name) return null;
45
-
46
- const via = o.via as BinInstaller;
47
- if (!VALID_INSTALLERS.includes(via)) return null;
48
-
49
- const bin: BinRequirement = {
50
- name,
51
- via,
52
- check:
53
- typeof o.check === "string" && o.check.trim()
54
- ? o.check.trim()
55
- : `${name} --version`,
56
- };
57
- if (typeof o.package === "string") bin.package = o.package;
58
- if (typeof o.formula === "string") bin.formula = o.formula;
59
- if (typeof o.module === "string") bin.module = o.module;
60
- if (typeof o.version === "string") bin.version = o.version;
61
- return bin;
62
- }
63
-
64
- /** Split a package/module spec into its base and optional version. Handles
65
- * scoped npm (@scope/pkg@1.2.0) and go modules (host/path@latest). */
66
- function splitVersion(spec: string): { base: string; version?: string } {
67
- const at = spec.lastIndexOf("@");
68
- // at > 0 avoids treating a leading npm scope "@scope/..." as a version.
69
- if (at > 0) return { base: spec.slice(0, at), version: spec.slice(at + 1) };
70
- return { base: spec };
71
- }
72
-
73
- const basename = (p: string): string => p.split("/").pop() ?? p;
74
-
75
- /**
76
- * Map a plugin.json `setup` block (the established convention: browser-use uses
77
- * `pip`, terminal uses `go` + `required`) to BinRequirements.
78
- * setup: { bun|npm|pip: string[], go: string[], brew: string[], required: string[] }
79
- */
80
- function parseSetup(setup: unknown): BinRequirement[] {
81
- if (!setup || typeof setup !== "object") return [];
82
- const s = setup as Record<string, unknown>;
83
- const bins: BinRequirement[] = [];
84
-
85
- const pkgManager = (key: "bun" | "npm" | "pip"): void => {
86
- for (const spec of (s[key] as string[] | undefined) ?? []) {
87
- if (typeof spec !== "string") continue;
88
- const { base, version } = splitVersion(spec);
89
- bins.push({
90
- name: basename(base),
91
- via: key,
92
- package: base,
93
- version,
94
- check: `${basename(base)} --version`,
95
- });
96
- }
97
- };
98
- pkgManager("bun");
99
- pkgManager("npm");
100
- pkgManager("pip");
101
-
102
- // Not basename(): a Go module's trailing /vN is its major version, not the
103
- // binary. `tmux-mcp/v2` installs `tmux-mcp`; naming it `v2` here made every
104
- // downstream presence check look for a binary that never exists.
105
- for (const spec of (s.go as string[] | undefined) ?? []) {
106
- if (typeof spec !== "string") continue;
107
- const { base, version } = splitVersion(spec);
108
- const name = goBinaryName(base);
109
- bins.push({
110
- name,
111
- via: "go",
112
- module: base,
113
- version,
114
- check: `${name} --version`,
115
- });
116
- }
117
- for (const spec of (s.brew as string[] | undefined) ?? []) {
118
- if (typeof spec !== "string") continue;
119
- bins.push({
120
- name: spec,
121
- via: "brew",
122
- formula: spec,
123
- check: `${spec} --version`,
124
- });
125
- }
126
- // `required` names a system tool that must be present; brew is the assumed
127
- // installer (matches the "system tools via brew" decision).
128
- for (const name of (s.required as string[] | undefined) ?? []) {
129
- if (typeof name !== "string") continue;
130
- bins.push({ name, via: "brew", formula: name, check: `${name} --version` });
131
- }
132
-
133
- return bins;
134
- }
135
-
136
- /**
137
- * Parse a plugin.json's binary requirements from BOTH the established `setup`
138
- * block and the richer explicit `requires.bin` form. On a name collision the
139
- * explicit `requires.bin` entry wins (it can override installer/check/version).
140
- */
141
- export function parsePluginRequires(pluginJson: unknown): PluginRequires {
142
- if (!pluginJson || typeof pluginJson !== "object") return {};
143
- const obj = pluginJson as Record<string, unknown>;
144
-
145
- const fromSetup = parseSetup(obj.setup);
146
-
147
- const requires = obj.requires;
148
- const binRaw =
149
- requires && typeof requires === "object"
150
- ? (requires as Record<string, unknown>).bin
151
- : undefined;
152
- const fromRequires = Array.isArray(binRaw)
153
- ? binRaw.map(normalizeBin).filter((b): b is BinRequirement => b !== null)
154
- : [];
155
-
156
- // Merge, requires.bin winning by name.
157
- const byName = new Map<string, BinRequirement>();
158
- for (const b of fromSetup) byName.set(b.name, b);
159
- for (const b of fromRequires) byName.set(b.name, b);
160
-
161
- const bin = [...byName.values()];
162
- return bin.length > 0 ? { bin } : {};
163
- }
164
-
165
34
  /**
166
35
  * The plugin.json at a plugin directory, parsed — null when there is none or
167
36
  * it does not parse.
@@ -191,12 +60,15 @@ export async function readPluginManifest(
191
60
  return null;
192
61
  }
193
62
 
194
- /** Read `requires` from a plugin.json at a known plugin directory. */
63
+ /**
64
+ * `requires` from the plugin.json at a plugin directory, or null when there is
65
+ * no readable manifest there.
66
+ */
195
67
  export async function readPluginRequires(
196
68
  pluginPath: string,
197
- ): Promise<PluginRequires> {
198
- // Unreadable/malformed — treat as no requirements.
199
- return parsePluginRequires(await readPluginManifest(pluginPath));
69
+ ): Promise<ParseResult | null> {
70
+ const manifest = await readPluginManifest(pluginPath);
71
+ return manifest === null ? null : parseRequires(manifest);
200
72
  }
201
73
 
202
74
  /**
@@ -257,8 +129,8 @@ export interface PluginManifestSources {
257
129
  * row its loader resolves there (`pickLoadedEntry`). Claude Code checks
258
130
  * the dependencies of the version it loads, which may be older than the
259
131
  * marketplace's latest;
260
- * 2. its source in the local marketplace clone (the reader `requires.bin`
261
- * uses), for a plugin not installed yet;
132
+ * 2. its source in the local marketplace clone, for a plugin not installed
133
+ * yet;
262
134
  * 3. any other installed copy — where a plugin whose marketplace entry
263
135
  * points at another repository lives.
264
136
  */
@@ -337,14 +209,73 @@ export async function resolvePluginPath(
337
209
  }
338
210
 
339
211
  /**
340
- * Resolve the binary requirements a plugin declares. Returns an empty array
341
- * when the plugin can't be located or declares none.
212
+ * The `requires` of the copy of `pluginId` Claude Code loads in `projectPath`
213
+ * (else its marketplace clone), and the directory it was read from — the same
214
+ * plugin.json its SessionStart hook reads through ${CLAUDE_PLUGIN_ROOT}. Null
215
+ * when no plugin.json for it can be read on this machine.
216
+ */
217
+ export async function resolvePluginRequires(
218
+ pluginId: string,
219
+ projectPath: string = process.cwd(),
220
+ sources: PluginManifestSources = {},
221
+ ): Promise<{ dir: string; parse: ParseResult } | null> {
222
+ for (const dir of await pluginManifestDirs(pluginId, projectPath, sources)) {
223
+ const parse = await readPluginRequires(dir);
224
+ if (parse !== null) return { dir, parse };
225
+ }
226
+ return null;
227
+ }
228
+
229
+ /**
230
+ * Each requirement that applies on this platform, with its option chosen for
231
+ * this machine — the first in declaration order whose prerequisite is on PATH
232
+ * or declared beside it ({@link selectOption}) — and the directory it was read
233
+ * from, which a `${CLAUDE_PLUGIN_ROOT}` check and a plugin-relative script
234
+ * resolve against.
235
+ */
236
+ export function selectBins(
237
+ requirements: Requirement[],
238
+ root: string | undefined,
239
+ pathEnv: string = process.env.PATH ?? "",
240
+ platform: { os: Os; arch: Arch } | null = currentPlatform(),
241
+ ): SelectedBin[] {
242
+ if (!platform) return [];
243
+ const live = applicable(requirements, platform.os);
244
+ const names = new Set(live.map((r) => r.name));
245
+ const has = (tool: string) =>
246
+ names.has(tool) || onPath(tool, pathEnv) !== null;
247
+ return live.map((r) => ({
248
+ ...r,
249
+ chosen: selectOption(r, platform, has),
250
+ ...(root ? { root } : {}),
251
+ }));
252
+ }
253
+
254
+ /**
255
+ * The requirements a plugin declares, selected for this machine, from the copy
256
+ * Claude Code loads in `projectPath`. Empty when the plugin cannot be located,
257
+ * declares none, or its declaration does not parse (doctor and the plugin's
258
+ * own SessionStart hook report that one).
342
259
  */
343
260
  export async function resolvePluginBinRequirements(
344
261
  pluginId: string,
345
- ): Promise<BinRequirement[]> {
346
- const pluginPath = await resolvePluginPath(pluginId);
347
- if (!pluginPath) return [];
348
- const requires = await readPluginRequires(pluginPath);
349
- return requires.bin ?? [];
262
+ projectPath: string = process.cwd(),
263
+ ): Promise<SelectedBin[]> {
264
+ const found = await resolvePluginRequires(pluginId, projectPath);
265
+ if (!found?.parse.ok) return [];
266
+ return selectBins(found.parse.requirements, found.dir);
267
+ }
268
+
269
+ /**
270
+ * The requirement named `name` that `pluginId` declares — how a catalogue tool
271
+ * owned by a plugin is checked and installed — or null when that plugin's
272
+ * declaration cannot be read here or does not name it.
273
+ */
274
+ export async function ownedRequirement(
275
+ pluginId: string,
276
+ name: string,
277
+ projectPath: string = process.cwd(),
278
+ ): Promise<SelectedBin | null> {
279
+ const bins = await resolvePluginBinRequirements(pluginId, projectPath);
280
+ return bins.find((b) => b.name === name) ?? null;
350
281
  }