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.
- package/README.md +1 -1
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +16 -11
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
- package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
- package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
- package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
- package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
- package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
- package/src/__tests__/blind/review-followups/bun-install-path.test.ts +57 -0
- package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
- package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
- package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
- package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
- package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
- package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
- package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
- package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
- package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -0
- package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
- package/src/__tests__/blind-780-review2.test.ts +0 -25
- package/src/__tests__/cli-apply-seams.test.ts +2 -67
- package/src/__tests__/cli-tool-commands.test.ts +131 -6
- package/src/__tests__/cli-tools-adapter.test.ts +52 -2
- package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
- package/src/__tests__/deps-adapter.test.ts +191 -0
- package/src/__tests__/deps-check-hook.test.ts +220 -0
- package/src/__tests__/deps-plan.test.ts +897 -0
- package/src/__tests__/deps-report.test.ts +197 -0
- package/src/__tests__/doctor-bins.test.ts +134 -36
- package/src/__tests__/doctor-json.test.ts +261 -0
- package/src/__tests__/failure-popup-review2.test.tsx +1 -25
- package/src/__tests__/final-fixes-780.test.tsx +25 -9
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- package/src/__tests__/no-shell-spawn.test.ts +77 -0
- package/src/__tests__/npm-package-name.test.ts +6 -9
- package/src/__tests__/plugin-requires.test.ts +138 -153
- package/src/__tests__/requires-probe.test.ts +858 -0
- package/src/__tests__/resolver.test.ts +129 -52
- package/src/__tests__/run.test.ts +108 -0
- package/src/__tests__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-plan.test.ts +1 -1
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +169 -24
- package/src/cli/install.ts +51 -66
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update.ts +28 -7
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-settings.ts +4 -348
- package/src/services/cli-tool-commands.ts +104 -3
- package/src/services/deps-plan.ts +1154 -0
- package/src/services/deps-report.ts +265 -0
- package/src/services/doctor-bins.ts +115 -43
- package/src/services/install-plan.ts +11 -1
- package/src/services/local-marketplace.ts +12 -206
- package/src/services/marketplace-refresh.ts +4 -4
- package/src/services/plugin-manager.ts +4 -18
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/resolver.ts +168 -58
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +19 -14
- package/src/services/update-plan.ts +3 -3
- package/src/shared/plugin/hooks/bun-missing.sh +64 -0
- package/src/shared/plugin/hooks/deps-check.ts +240 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1279 -0
- package/src/types/index.ts +53 -16
- package/src/ui/App.tsx +6 -40
- package/src/ui/adapters/cliToolsAdapter.ts +75 -37
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/startupReports.ts +0 -22
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/screens/CliToolsScreen.tsx +141 -51
- package/src/ui/screens/PluginsScreen.tsx +12 -48
- package/src/utils/command-utils.ts +4 -5
- package/src/utils/run.ts +143 -52
- package/src/utils/string-utils.ts +9 -0
- package/src/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/services/plugin-setup.ts +0 -965
- 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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
5
|
-
* `
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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<
|
|
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
|
-
*
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
/**
|
|
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<
|
|
198
|
-
|
|
199
|
-
return
|
|
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
|
|
261
|
-
*
|
|
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
|
-
*
|
|
341
|
-
*
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
return
|
|
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
|
}
|