magus-cli 7.8.0 → 7.9.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.
- 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-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 +896 -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 +962 -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 +39 -0
- package/src/shared/plugin/hooks/deps-check.ts +224 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -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 +123 -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
package/src/cli/update.ts
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
import { selectProfileId } from "../services/active-closure.js";
|
|
38
38
|
import { isClaudeAvailable } from "../services/claude-cli.js";
|
|
39
39
|
import { clearContentDriftCache } from "../services/content-drift.js";
|
|
40
|
+
import { refusedPinWhy } from "../services/deps-plan.js";
|
|
40
41
|
import { validateManifest } from "../services/manifest.js";
|
|
41
42
|
import {
|
|
42
43
|
refreshRegisteredMarketplaces,
|
|
@@ -65,6 +66,7 @@ import {
|
|
|
65
66
|
import { requirersHere, resolveProfile } from "../services/resolver.js";
|
|
66
67
|
import { getInstalledSkillNames } from "../services/skills-manager.js";
|
|
67
68
|
import { REAL_TOOL_PROBE_IO, probeTools } from "../services/tool-probe.js";
|
|
69
|
+
import { managedBins } from "../services/toolchain.js";
|
|
68
70
|
// The apply half lives in services/ so the TUI runs the SAME code — see the
|
|
69
71
|
// header of update-engine.ts for what drifted while it did not.
|
|
70
72
|
import {
|
|
@@ -318,6 +320,14 @@ export async function runUpdateCommand(
|
|
|
318
320
|
return resolved;
|
|
319
321
|
});
|
|
320
322
|
|
|
323
|
+
// A pin the owning plugin's option cannot carry: whatever installs or
|
|
324
|
+
// upgrades the tool ignores it, so say so rather than let it read as kept.
|
|
325
|
+
for (const bin of closure.bins) {
|
|
326
|
+
for (const pin of bin.refusedPins ?? []) {
|
|
327
|
+
notices.push(warnLine(`${bold(bin.name)}: ${refusedPinWhy(bin, pin)}`));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
321
331
|
// "latest" is only as fresh as the catalog. Fast-forward the marketplace
|
|
322
332
|
// clones first, then drop every cached answer derived from the old HEAD —
|
|
323
333
|
// skipping this is how a clone sits days behind while every plugin reads as
|
|
@@ -389,13 +399,23 @@ export async function runUpdateCommand(
|
|
|
389
399
|
// "behind?" answer — the same probe the TUI's CLI Tools screen uses. It used
|
|
390
400
|
// to be a presence check, which is why every present unpinned tool planned
|
|
391
401
|
// as an upgrade on every run.
|
|
402
|
+
//
|
|
403
|
+
// Only the requirements installed by a versioned package manager have a
|
|
404
|
+
// version to move. The rest (apt, a pinned release, a toolchain bootstrap,
|
|
405
|
+
// a plugin's uv env) are as current as their declaration, and
|
|
406
|
+
// `magus doctor --fix` installs them.
|
|
407
|
+
const tools = managedBins(closure.bins);
|
|
408
|
+
const declaredOnly = closure.bins.length - tools.length;
|
|
392
409
|
const toolFacts = await runStep(steps.binaries, async (say) => {
|
|
393
|
-
if (
|
|
394
|
-
steps.binaries.detail =
|
|
410
|
+
if (tools.length === 0) {
|
|
411
|
+
steps.binaries.detail =
|
|
412
|
+
declaredOnly > 0
|
|
413
|
+
? `${declaredOnly} managed by their declaration`
|
|
414
|
+
: "none declared";
|
|
395
415
|
return [];
|
|
396
416
|
}
|
|
397
|
-
steps.binaries.progress = { done: 0, total:
|
|
398
|
-
const facts = await probeTools(
|
|
417
|
+
steps.binaries.progress = { done: 0, total: tools.length };
|
|
418
|
+
const facts = await probeTools(tools, REAL_TOOL_PROBE_IO, {
|
|
399
419
|
// `settled`, not `index`: results arrive in completion order, and a
|
|
400
420
|
// meter driven by index would jump backwards.
|
|
401
421
|
onResult: (f, { settled, total }) => {
|
|
@@ -403,7 +423,7 @@ export async function runUpdateCommand(
|
|
|
403
423
|
say(f.name);
|
|
404
424
|
},
|
|
405
425
|
});
|
|
406
|
-
const items = planBinUpdates(
|
|
426
|
+
const items = planBinUpdates(tools, facts);
|
|
407
427
|
const n = (a: string) => items.filter((i) => i.action === a).length;
|
|
408
428
|
const behind = n("upgrade");
|
|
409
429
|
const unknown = n("unknown");
|
|
@@ -413,6 +433,7 @@ export async function runUpdateCommand(
|
|
|
413
433
|
behind > 0 ? `${behind} behind` : "",
|
|
414
434
|
unknown > 0 ? `${unknown} unknown` : "",
|
|
415
435
|
missing > 0 ? `${missing} missing` : "",
|
|
436
|
+
declaredOnly > 0 ? `${declaredOnly} managed by their declaration` : "",
|
|
416
437
|
]
|
|
417
438
|
.filter(Boolean)
|
|
418
439
|
.join(" · ");
|
|
@@ -455,9 +476,9 @@ export async function runUpdateCommand(
|
|
|
455
476
|
const plan: UpdatePlan = {
|
|
456
477
|
profileId: targetId,
|
|
457
478
|
plugins: planPluginUpdates(closure.plugins, catalog, requiredBy),
|
|
458
|
-
bins: planBinUpdates(
|
|
479
|
+
bins: planBinUpdates(tools, toolFacts),
|
|
459
480
|
skills: planSkillUpdates(skillRefs, installedSkills),
|
|
460
|
-
binSpecs: new Map(
|
|
481
|
+
binSpecs: new Map(tools.map((b) => [b.name, b])),
|
|
461
482
|
};
|
|
462
483
|
|
|
463
484
|
for (const line of planReport(plan)) console.log(line);
|
package/src/data/cli-tools.ts
CHANGED
|
@@ -13,13 +13,16 @@ import { MAGUS_NPM_PACKAGE } from "./npm-package.js";
|
|
|
13
13
|
* catalogue entry can no longer smuggle shell syntax (that same entry's command
|
|
14
14
|
* was a `&&` chain) into something that gets executed.
|
|
15
15
|
*/
|
|
16
|
-
|
|
16
|
+
interface CatalogueDisplay {
|
|
17
17
|
name: string;
|
|
18
18
|
displayName: string;
|
|
19
19
|
description: string;
|
|
20
|
-
checkCommand: string;
|
|
21
20
|
website: string;
|
|
22
21
|
category: "ai-coding" | "utility";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CliTool extends CatalogueDisplay {
|
|
25
|
+
checkCommand: string;
|
|
23
26
|
/**
|
|
24
27
|
* Reuses `BinInstaller` rather than restating its members, so the
|
|
25
28
|
* correspondence with the resolver in `services/toolchain.ts` — which is
|
|
@@ -29,6 +32,22 @@ export interface CliTool {
|
|
|
29
32
|
packageName: string;
|
|
30
33
|
}
|
|
31
34
|
|
|
35
|
+
/**
|
|
36
|
+
* A catalogue tool a plugin declares in its `requires`. The entry carries
|
|
37
|
+
* display fields only: how the tool is checked and installed is that plugin's
|
|
38
|
+
* declaration, read through requires-probe (`resolveCatalogue`), so the two
|
|
39
|
+
* can never disagree.
|
|
40
|
+
*/
|
|
41
|
+
export interface OwnedCatalogueEntry extends CatalogueDisplay {
|
|
42
|
+
/** `<plugin>@<marketplace>` whose `requires` declares this tool. */
|
|
43
|
+
ownedBy: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type CatalogueEntry = CliTool | OwnedCatalogueEntry;
|
|
47
|
+
|
|
48
|
+
export const isOwnedEntry = (e: CatalogueEntry): e is OwnedCatalogueEntry =>
|
|
49
|
+
"ownedBy" in e;
|
|
50
|
+
|
|
32
51
|
/**
|
|
33
52
|
* Claude Code's npm package. Named once because two readers need it: the
|
|
34
53
|
* catalogue entry below, and the probe, which asks this package's `latest`
|
|
@@ -37,7 +56,7 @@ export interface CliTool {
|
|
|
37
56
|
*/
|
|
38
57
|
export const CLAUDE_CODE_NPM_PACKAGE = "@anthropic-ai/claude-code";
|
|
39
58
|
|
|
40
|
-
export const cliTools:
|
|
59
|
+
export const cliTools: CatalogueEntry[] = [
|
|
41
60
|
{
|
|
42
61
|
name: "magus",
|
|
43
62
|
displayName: "magus",
|
|
@@ -56,21 +75,17 @@ export const cliTools: CliTool[] = [
|
|
|
56
75
|
displayName: "Mnemex",
|
|
57
76
|
description:
|
|
58
77
|
"AST-aware code search with PageRank, callers/callees, and semantic embeddings",
|
|
59
|
-
checkCommand: "mnemex --version",
|
|
60
78
|
website: "https://github.com/MadAppGang/mnemex",
|
|
61
79
|
category: "ai-coding",
|
|
62
|
-
|
|
63
|
-
packageName: "mnemex",
|
|
80
|
+
ownedBy: "mnemex@magus",
|
|
64
81
|
},
|
|
65
82
|
{
|
|
66
83
|
name: "claudish",
|
|
67
84
|
displayName: "Claudish",
|
|
68
85
|
description: "Run Claude Code with OpenRouter models (Grok, GPT-5, Gemini)",
|
|
69
|
-
checkCommand: "claudish --version",
|
|
70
86
|
website: "https://github.com/MadAppGang/claudish",
|
|
71
87
|
category: "ai-coding",
|
|
72
|
-
|
|
73
|
-
packageName: "claudish",
|
|
88
|
+
ownedBy: "claudish@magus",
|
|
74
89
|
},
|
|
75
90
|
{
|
|
76
91
|
name: "claude",
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type {
|
|
18
|
-
BinRequirement,
|
|
19
18
|
ProfileManifest,
|
|
20
19
|
ResolvedClosure,
|
|
20
|
+
SelectedBin,
|
|
21
21
|
} from "../types/index.js";
|
|
22
22
|
import { readManifest, validateManifest } from "./manifest.js";
|
|
23
23
|
import { activeProfile } from "./profile-live.js";
|
|
@@ -54,7 +54,7 @@ export interface ActiveClosureDeps {
|
|
|
54
54
|
manifest: ProfileManifest,
|
|
55
55
|
id: string,
|
|
56
56
|
opts?: {
|
|
57
|
-
binResolver?: (pluginId: string) => Promise<
|
|
57
|
+
binResolver?: (pluginId: string) => Promise<SelectedBin[]>;
|
|
58
58
|
},
|
|
59
59
|
): Promise<ResolvedClosure>;
|
|
60
60
|
}
|
|
@@ -79,7 +79,7 @@ export interface LoadActiveClosureOptions {
|
|
|
79
79
|
deps?: Partial<ActiveClosureDeps>;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
const NO_BINS = async (): Promise<
|
|
82
|
+
const NO_BINS = async (): Promise<SelectedBin[]> => [];
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
85
|
* The active profile's closure for a project.
|
|
@@ -1052,359 +1052,15 @@ export interface MarketplaceRecoveryResult {
|
|
|
1052
1052
|
reregistered: string[];
|
|
1053
1053
|
}
|
|
1054
1054
|
|
|
1055
|
-
// =============================================================================
|
|
1056
|
-
// MARKETPLACE RENAME MIGRATION: mag-claude-plugins / MadAppGang-claude-code → magus
|
|
1057
|
-
// =============================================================================
|
|
1058
|
-
|
|
1059
|
-
const OLD_MARKETPLACE_NAMES = ["mag-claude-plugins", "MadAppGang-claude-code"];
|
|
1060
|
-
const NEW_MARKETPLACE_NAME = "magus";
|
|
1061
|
-
const NEW_MARKETPLACE_REPO = "MadAppGang/magus";
|
|
1062
|
-
const OLD_MARKETPLACE_REPOS = ["MadAppGang/claude-code"];
|
|
1063
|
-
|
|
1064
|
-
/**
|
|
1065
|
-
* Rename plugin keys in a Record from any old marketplace name to new.
|
|
1066
|
-
* e.g., "frontend@mag-claude-plugins" → "frontend@magus"
|
|
1067
|
-
* "dev@MadAppGang-claude-code" → "dev@magus"
|
|
1068
|
-
* Returns [migratedRecord, count] — count=0 means no changes.
|
|
1069
|
-
*/
|
|
1070
|
-
function migratePluginKeys<T>(
|
|
1071
|
-
record: Record<string, T> | undefined,
|
|
1072
|
-
): [Record<string, T>, number] {
|
|
1073
|
-
if (!record) return [{}, 0];
|
|
1074
|
-
const migrated: Record<string, T> = {};
|
|
1075
|
-
let count = 0;
|
|
1076
|
-
for (const [key, value] of Object.entries(record)) {
|
|
1077
|
-
const oldName = OLD_MARKETPLACE_NAMES.find((n) => key.endsWith(`@${n}`));
|
|
1078
|
-
if (oldName) {
|
|
1079
|
-
const pluginName = key.slice(0, key.lastIndexOf("@"));
|
|
1080
|
-
const newKey = `${pluginName}@${NEW_MARKETPLACE_NAME}`;
|
|
1081
|
-
// Don't overwrite if canonical key already exists
|
|
1082
|
-
if (!record[newKey]) {
|
|
1083
|
-
migrated[newKey] = value;
|
|
1084
|
-
}
|
|
1085
|
-
count++;
|
|
1086
|
-
} else {
|
|
1087
|
-
migrated[key] = value;
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
return [migrated, count];
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
/**
|
|
1094
|
-
* Migrate a single settings object in-place.
|
|
1095
|
-
* Returns number of keys renamed.
|
|
1096
|
-
*/
|
|
1097
|
-
function migrateSettingsObject(settings: ClaudeSettings): number {
|
|
1098
|
-
let total = 0;
|
|
1099
|
-
|
|
1100
|
-
const [ep, epCount] = migratePluginKeys(settings.enabledPlugins);
|
|
1101
|
-
if (epCount > 0) {
|
|
1102
|
-
settings.enabledPlugins = ep;
|
|
1103
|
-
total += epCount;
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
const [iv, ivCount] = migratePluginKeys(settings.installedPluginVersions);
|
|
1107
|
-
if (ivCount > 0) {
|
|
1108
|
-
settings.installedPluginVersions = iv;
|
|
1109
|
-
total += ivCount;
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
// Migrate extraKnownMarketplaces keys
|
|
1113
|
-
for (const oldName of OLD_MARKETPLACE_NAMES) {
|
|
1114
|
-
if (settings.extraKnownMarketplaces?.[oldName]) {
|
|
1115
|
-
const entry = settings.extraKnownMarketplaces[oldName];
|
|
1116
|
-
delete settings.extraKnownMarketplaces[oldName];
|
|
1117
|
-
if (!settings.extraKnownMarketplaces[NEW_MARKETPLACE_NAME]) {
|
|
1118
|
-
settings.extraKnownMarketplaces[NEW_MARKETPLACE_NAME] = {
|
|
1119
|
-
...entry,
|
|
1120
|
-
source: {
|
|
1121
|
-
...entry.source,
|
|
1122
|
-
repo: NEW_MARKETPLACE_REPO,
|
|
1123
|
-
},
|
|
1124
|
-
};
|
|
1125
|
-
}
|
|
1126
|
-
total++;
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
// Fix stale repo URL on existing magus entry (e.g. key is "magus" but repo is still "MadAppGang/claude-code")
|
|
1131
|
-
const magusEntry = settings.extraKnownMarketplaces?.[NEW_MARKETPLACE_NAME];
|
|
1132
|
-
if (
|
|
1133
|
-
magusEntry?.source?.repo &&
|
|
1134
|
-
OLD_MARKETPLACE_REPOS.includes(magusEntry.source.repo)
|
|
1135
|
-
) {
|
|
1136
|
-
magusEntry.source.repo = NEW_MARKETPLACE_REPO;
|
|
1137
|
-
total++;
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
return total;
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
export interface MigrationResult {
|
|
1144
|
-
projectMigrated: number;
|
|
1145
|
-
globalMigrated: number;
|
|
1146
|
-
localMigrated: number;
|
|
1147
|
-
knownMarketplacesMigrated: boolean;
|
|
1148
|
-
registryMigrated: number;
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
/**
|
|
1152
|
-
* Migrate all settings from mag-claude-plugins to magus.
|
|
1153
|
-
* Safe to call multiple times — no-ops if already migrated.
|
|
1154
|
-
* Runs across project settings, global settings, local settings,
|
|
1155
|
-
* known_marketplaces.json, and installed_plugins.json.
|
|
1156
|
-
*/
|
|
1157
|
-
/**
|
|
1158
|
-
* Decode a ~/.claude/projects/ directory name back to a filesystem path.
|
|
1159
|
-
* Claude Code encodes paths by replacing "/" with "-", which is lossy when
|
|
1160
|
-
* directory names themselves contain dashes (e.g., "circl-infra" → "circl/infra").
|
|
1161
|
-
* Strategy: split on "-", then greedily recombine segments by checking which
|
|
1162
|
-
* combinations actually exist on disk.
|
|
1163
|
-
* Returns null if no valid path can be resolved.
|
|
1164
|
-
*/
|
|
1165
|
-
async function decodeProjectDirName(encoded: string): Promise<string | null> {
|
|
1166
|
-
// Split into segments: "-Users-jack-dev-circl-infra" → ["Users","jack","dev","circl","infra"]
|
|
1167
|
-
const segments = encoded.replace(/^-/, "").split("-");
|
|
1168
|
-
if (segments.length === 0) return null;
|
|
1169
|
-
|
|
1170
|
-
// Build path greedily: at each step, try joining the next segment with a dash first
|
|
1171
|
-
// (preserving directory names like "circl-infra"), fall back to slash (new path segment)
|
|
1172
|
-
let current = `/${segments[0]}`;
|
|
1173
|
-
for (let i = 1; i < segments.length; i++) {
|
|
1174
|
-
const withDash = `${current}-${segments[i]}`;
|
|
1175
|
-
const withSlash = `${current}/${segments[i]}`;
|
|
1176
|
-
// Prefer dash (keeps compound names intact) if that path prefix exists
|
|
1177
|
-
if (await fs.pathExists(withDash)) {
|
|
1178
|
-
current = withDash;
|
|
1179
|
-
} else {
|
|
1180
|
-
current = withSlash;
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
return (await fs.pathExists(current)) ? current : null;
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
export async function migrateMarketplaceRename(
|
|
1188
|
-
projectPath?: string,
|
|
1189
|
-
): Promise<MigrationResult> {
|
|
1190
|
-
const result: MigrationResult = {
|
|
1191
|
-
projectMigrated: 0,
|
|
1192
|
-
globalMigrated: 0,
|
|
1193
|
-
localMigrated: 0,
|
|
1194
|
-
knownMarketplacesMigrated: false,
|
|
1195
|
-
registryMigrated: 0,
|
|
1196
|
-
};
|
|
1197
|
-
|
|
1198
|
-
// 1. Project settings
|
|
1199
|
-
try {
|
|
1200
|
-
const settings = await readSettings(projectPath);
|
|
1201
|
-
const count = migrateSettingsObject(settings);
|
|
1202
|
-
if (count > 0) {
|
|
1203
|
-
await writeSettings(settings, projectPath);
|
|
1204
|
-
result.projectMigrated = count;
|
|
1205
|
-
}
|
|
1206
|
-
} catch {
|
|
1207
|
-
/* skip if unreadable */
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
// 2. Global settings
|
|
1211
|
-
try {
|
|
1212
|
-
const settings = await readGlobalSettings();
|
|
1213
|
-
const count = migrateSettingsObject(settings);
|
|
1214
|
-
if (count > 0) {
|
|
1215
|
-
await writeGlobalSettings(settings);
|
|
1216
|
-
result.globalMigrated = count;
|
|
1217
|
-
}
|
|
1218
|
-
} catch {
|
|
1219
|
-
/* skip if unreadable */
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
// 3. Local settings (settings.local.json)
|
|
1223
|
-
try {
|
|
1224
|
-
const local = await readLocalSettings(projectPath);
|
|
1225
|
-
let localCount = 0;
|
|
1226
|
-
const [ep, epCount] = migratePluginKeys(local.enabledPlugins);
|
|
1227
|
-
if (epCount > 0) {
|
|
1228
|
-
local.enabledPlugins = ep;
|
|
1229
|
-
localCount += epCount;
|
|
1230
|
-
}
|
|
1231
|
-
const [iv, ivCount] = migratePluginKeys(local.installedPluginVersions);
|
|
1232
|
-
if (ivCount > 0) {
|
|
1233
|
-
local.installedPluginVersions = iv;
|
|
1234
|
-
localCount += ivCount;
|
|
1235
|
-
}
|
|
1236
|
-
if (localCount > 0) {
|
|
1237
|
-
await writeLocalSettings(local, projectPath);
|
|
1238
|
-
result.localMigrated = localCount;
|
|
1239
|
-
}
|
|
1240
|
-
} catch {
|
|
1241
|
-
/* skip if unreadable */
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
// 4. known_marketplaces.json — rename old keys + physical directory cleanup
|
|
1245
|
-
const marketplacesDir = path.join(
|
|
1246
|
-
os.homedir(),
|
|
1247
|
-
".claude",
|
|
1248
|
-
"plugins",
|
|
1249
|
-
"marketplaces",
|
|
1250
|
-
);
|
|
1251
|
-
const newDir = path.join(marketplacesDir, NEW_MARKETPLACE_NAME);
|
|
1252
|
-
|
|
1253
|
-
try {
|
|
1254
|
-
const known = await readKnownMarketplaces();
|
|
1255
|
-
let knownModified = false;
|
|
1256
|
-
|
|
1257
|
-
for (const oldName of OLD_MARKETPLACE_NAMES) {
|
|
1258
|
-
if (known[oldName]) {
|
|
1259
|
-
const oldEntry = known[oldName];
|
|
1260
|
-
// If canonical entry doesn't exist yet, create it
|
|
1261
|
-
if (!known[NEW_MARKETPLACE_NAME]) {
|
|
1262
|
-
known[NEW_MARKETPLACE_NAME] = {
|
|
1263
|
-
...oldEntry,
|
|
1264
|
-
source: {
|
|
1265
|
-
...oldEntry.source,
|
|
1266
|
-
repo: "MadAppGang/magus",
|
|
1267
|
-
},
|
|
1268
|
-
};
|
|
1269
|
-
}
|
|
1270
|
-
delete known[oldName];
|
|
1271
|
-
knownModified = true;
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
// Ensure installLocation doesn't reference old directory names
|
|
1275
|
-
if (known[NEW_MARKETPLACE_NAME]?.installLocation?.includes(oldName)) {
|
|
1276
|
-
known[NEW_MARKETPLACE_NAME].installLocation = known[
|
|
1277
|
-
NEW_MARKETPLACE_NAME
|
|
1278
|
-
].installLocation.replace(oldName, NEW_MARKETPLACE_NAME);
|
|
1279
|
-
knownModified = true;
|
|
1280
|
-
}
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
if (knownModified) {
|
|
1284
|
-
await writeKnownMarketplaces(known);
|
|
1285
|
-
result.knownMarketplacesMigrated = true;
|
|
1286
|
-
}
|
|
1287
|
-
} catch {
|
|
1288
|
-
/* skip if unreadable */
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
// 4b. Rename/remove old physical directories (runs even if keys were already migrated)
|
|
1292
|
-
for (const oldName of OLD_MARKETPLACE_NAMES) {
|
|
1293
|
-
const oldDir = path.join(marketplacesDir, oldName);
|
|
1294
|
-
try {
|
|
1295
|
-
if (await fs.pathExists(oldDir)) {
|
|
1296
|
-
if (!(await fs.pathExists(newDir))) {
|
|
1297
|
-
await fs.rename(oldDir, newDir);
|
|
1298
|
-
} else {
|
|
1299
|
-
// Both exist — remove the old one (magus dir is canonical)
|
|
1300
|
-
await fs.remove(oldDir);
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
} catch {
|
|
1304
|
-
/* non-fatal: directory cleanup is best-effort */
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
// 4c. Update git remote URL in the marketplace clone (old → new repo)
|
|
1309
|
-
try {
|
|
1310
|
-
if (await fs.pathExists(path.join(newDir, ".git"))) {
|
|
1311
|
-
const { execSync } = await import("node:child_process");
|
|
1312
|
-
const remote = execSync("git remote get-url origin", {
|
|
1313
|
-
cwd: newDir,
|
|
1314
|
-
encoding: "utf-8",
|
|
1315
|
-
timeout: 5000,
|
|
1316
|
-
}).trim();
|
|
1317
|
-
if (remote.includes("claude-code") && remote.includes("MadAppGang")) {
|
|
1318
|
-
const newRemote = remote.replace("claude-code", NEW_MARKETPLACE_NAME);
|
|
1319
|
-
execSync(`git remote set-url origin "${newRemote}"`, {
|
|
1320
|
-
cwd: newDir,
|
|
1321
|
-
encoding: "utf-8",
|
|
1322
|
-
timeout: 5000,
|
|
1323
|
-
});
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
} catch {
|
|
1327
|
-
/* non-fatal: git remote update is best-effort */
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
// 5. installed_plugins.json — rename plugin ID keys
|
|
1331
|
-
try {
|
|
1332
|
-
let regCount = 0;
|
|
1333
|
-
await updateRegistryFile((registry) => {
|
|
1334
|
-
const newPlugins: typeof registry.plugins = {};
|
|
1335
|
-
for (const [pluginId, entries] of Object.entries(registry.plugins)) {
|
|
1336
|
-
const oldName = OLD_MARKETPLACE_NAMES.find((n) =>
|
|
1337
|
-
pluginId.endsWith(`@${n}`),
|
|
1338
|
-
);
|
|
1339
|
-
if (oldName) {
|
|
1340
|
-
const pluginName = pluginId.slice(0, pluginId.lastIndexOf("@"));
|
|
1341
|
-
const newKey = `${pluginName}@${NEW_MARKETPLACE_NAME}`;
|
|
1342
|
-
if (!newPlugins[newKey]) {
|
|
1343
|
-
newPlugins[newKey] = entries;
|
|
1344
|
-
}
|
|
1345
|
-
regCount++;
|
|
1346
|
-
} else {
|
|
1347
|
-
newPlugins[pluginId] = entries;
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
if (regCount === 0) return false;
|
|
1351
|
-
registry.plugins = newPlugins;
|
|
1352
|
-
return true;
|
|
1353
|
-
});
|
|
1354
|
-
// After the write, never inside the mutation: a refused write (a corrupt
|
|
1355
|
-
// file, or one another session changed) must not report a migration that
|
|
1356
|
-
// did not land.
|
|
1357
|
-
result.registryMigrated = regCount;
|
|
1358
|
-
} catch {
|
|
1359
|
-
/* skip if unreadable */
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
// 6. Scan all known project settings (derived from ~/.claude/projects/ directory names)
|
|
1363
|
-
try {
|
|
1364
|
-
const projectsDir = path.join(os.homedir(), ".claude", "projects");
|
|
1365
|
-
if (await fs.pathExists(projectsDir)) {
|
|
1366
|
-
const entries = await fs.readdir(projectsDir);
|
|
1367
|
-
const seenPaths = new Set<string>();
|
|
1368
|
-
// Current project (from step 1) already handled — skip it
|
|
1369
|
-
const currentProject = projectPath || process.cwd();
|
|
1370
|
-
seenPaths.add(currentProject);
|
|
1371
|
-
|
|
1372
|
-
for (const entry of entries) {
|
|
1373
|
-
const decoded = await decodeProjectDirName(entry);
|
|
1374
|
-
if (!decoded || seenPaths.has(decoded)) continue;
|
|
1375
|
-
seenPaths.add(decoded);
|
|
1376
|
-
|
|
1377
|
-
const settingsFile = path.join(decoded, ".claude", "settings.json");
|
|
1378
|
-
try {
|
|
1379
|
-
if (await fs.pathExists(settingsFile)) {
|
|
1380
|
-
const raw = await fs.readJson(settingsFile);
|
|
1381
|
-
const count = migrateSettingsObject(raw);
|
|
1382
|
-
if (count > 0) {
|
|
1383
|
-
await fs.writeJson(settingsFile, raw, { spaces: 2 });
|
|
1384
|
-
result.projectMigrated += count;
|
|
1385
|
-
}
|
|
1386
|
-
}
|
|
1387
|
-
} catch {
|
|
1388
|
-
/* skip individual projects that fail */
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
} catch {
|
|
1393
|
-
/* non-fatal: cross-project scan is best-effort */
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
return result;
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
1055
|
/**
|
|
1400
1056
|
* Recover/sync marketplace settings:
|
|
1401
1057
|
* - Remove entries for marketplaces whose installLocation no longer exists
|
|
1402
1058
|
* - Fix stale "directory" source for known Magus marketplaces that should be "github"
|
|
1403
1059
|
*
|
|
1404
|
-
*
|
|
1405
|
-
*
|
|
1406
|
-
*
|
|
1407
|
-
* {@link getMarketplaceAutoUpdate}.
|
|
1060
|
+
* A write to Claude Code's registry, so it runs only inside an install the user
|
|
1061
|
+
* asked for, after Claude Code itself answered "not found in marketplace"
|
|
1062
|
+
* (claude-cli's recovery). Opening magus never calls it. It does not touch
|
|
1063
|
+
* `autoUpdate` — see {@link getMarketplaceAutoUpdate}.
|
|
1408
1064
|
*/
|
|
1409
1065
|
export async function recoverMarketplaceSettings(): Promise<MarketplaceRecoveryResult> {
|
|
1410
1066
|
const known = await readKnownMarketplaces();
|
|
@@ -19,14 +19,115 @@
|
|
|
19
19
|
* renderer.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import
|
|
22
|
+
import {
|
|
23
|
+
type CatalogueEntry,
|
|
24
|
+
type CliTool,
|
|
25
|
+
type OwnedCatalogueEntry,
|
|
26
|
+
cliTools,
|
|
27
|
+
isOwnedEntry,
|
|
28
|
+
} from "../data/cli-tools.js";
|
|
29
|
+
import type { ResolvedBin, SelectedBin } from "../types/index.js";
|
|
23
30
|
import type { Command } from "../utils/command-utils.js";
|
|
31
|
+
import { ownedRequirement } from "./plugin-requires.js";
|
|
24
32
|
import {
|
|
25
|
-
binInstallCommand,
|
|
26
33
|
binUninstallCommand,
|
|
27
34
|
binUpgradeCommand,
|
|
35
|
+
managedInstallCommand,
|
|
36
|
+
managedTool,
|
|
28
37
|
} from "./toolchain.js";
|
|
29
38
|
|
|
39
|
+
/**
|
|
40
|
+
* A catalogue row ready to probe. For a tool a plugin owns, `owned` is that
|
|
41
|
+
* plugin's requirement — what an install runs through the deps executor — and
|
|
42
|
+
* the tool's check and installer are read from it.
|
|
43
|
+
*/
|
|
44
|
+
export interface CatalogueTool {
|
|
45
|
+
tool: CliTool;
|
|
46
|
+
owned?: ResolvedBin;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A catalogue tool a plugin owns and installs with an option magus does not
|
|
51
|
+
* version — a release, a Homebrew cask, apt. Shown read-only, as managed by
|
|
52
|
+
* that plugin: its presence is the dependency probe's answer, and its
|
|
53
|
+
* install, update and removal are the plugin's declaration, run by
|
|
54
|
+
* `magus doctor --fix`.
|
|
55
|
+
*/
|
|
56
|
+
export interface PluginManagedTool {
|
|
57
|
+
entry: OwnedCatalogueEntry;
|
|
58
|
+
owned: ResolvedBin;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One catalogue row: a tool magus versions, or one its plugin manages. */
|
|
62
|
+
export type CatalogueItem = CatalogueTool | PluginManagedTool;
|
|
63
|
+
|
|
64
|
+
export const isPluginManagedItem = (
|
|
65
|
+
item: CatalogueItem,
|
|
66
|
+
): item is PluginManagedTool => "entry" in item;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* An owned entry as a {@link CliTool}, from its plugin's requirement: null when
|
|
70
|
+
* that requirement is not installed by a version-managed kind, which the CLI
|
|
71
|
+
* Tools screen cannot track — such an entry is a {@link PluginManagedTool}.
|
|
72
|
+
*/
|
|
73
|
+
export function ownedCliTool(
|
|
74
|
+
entry: CatalogueEntry,
|
|
75
|
+
owned: SelectedBin,
|
|
76
|
+
): CliTool | null {
|
|
77
|
+
const managed = managedTool(owned);
|
|
78
|
+
if (!managed) return null;
|
|
79
|
+
const { name, displayName, description, website, category } = entry;
|
|
80
|
+
return {
|
|
81
|
+
name,
|
|
82
|
+
displayName,
|
|
83
|
+
description,
|
|
84
|
+
website,
|
|
85
|
+
category,
|
|
86
|
+
checkCommand: owned.check.join(" "),
|
|
87
|
+
packageManager: managed.via,
|
|
88
|
+
packageName:
|
|
89
|
+
managed.package ?? managed.formula ?? managed.module ?? owned.name,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The catalogue entries that need no lookup: every one no plugin owns. */
|
|
94
|
+
export function unownedCatalogue(): CatalogueTool[] {
|
|
95
|
+
return cliTools.flatMap((e) => (isOwnedEntry(e) ? [] : [{ tool: e }]));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The whole catalogue, in catalogue order, each owned entry resolved through
|
|
100
|
+
* its plugin's declaration (the copy Claude Code loads here, else the
|
|
101
|
+
* marketplace clone): a tracked tool when its chosen option is version-managed,
|
|
102
|
+
* else a {@link PluginManagedTool}. An owned entry whose declaration cannot be
|
|
103
|
+
* read here is left out: magus cannot say how to check or install it.
|
|
104
|
+
*/
|
|
105
|
+
export async function resolveCatalogue(
|
|
106
|
+
projectPath: string,
|
|
107
|
+
owned: (
|
|
108
|
+
pluginId: string,
|
|
109
|
+
name: string,
|
|
110
|
+
projectPath: string,
|
|
111
|
+
) => Promise<SelectedBin | null> = ownedRequirement,
|
|
112
|
+
): Promise<CatalogueItem[]> {
|
|
113
|
+
const out: CatalogueItem[] = [];
|
|
114
|
+
for (const entry of cliTools) {
|
|
115
|
+
if (!isOwnedEntry(entry)) {
|
|
116
|
+
out.push({ tool: entry });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const requirement = await owned(entry.ownedBy, entry.name, projectPath);
|
|
120
|
+
if (!requirement) continue;
|
|
121
|
+
const resolved = {
|
|
122
|
+
...requirement,
|
|
123
|
+
sources: [`plugin:${entry.ownedBy}`],
|
|
124
|
+
};
|
|
125
|
+
const tool = ownedCliTool(entry, requirement);
|
|
126
|
+
out.push(tool ? { tool, owned: resolved } : { entry, owned: resolved });
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
30
131
|
/**
|
|
31
132
|
* How a tool turned out to be installed, as detected from its path on disk.
|
|
32
133
|
*
|
|
@@ -64,7 +165,7 @@ export type InstallMethod =
|
|
|
64
165
|
* disagreement unrepresentable rather than merely fixed.
|
|
65
166
|
*/
|
|
66
167
|
export function cliToolInstall(tool: CliTool): Command {
|
|
67
|
-
return
|
|
168
|
+
return managedInstallCommand({
|
|
68
169
|
name: tool.name,
|
|
69
170
|
via: tool.packageManager,
|
|
70
171
|
package: tool.packageName,
|