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
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 (closure.bins.length === 0) {
394
- steps.binaries.detail = "none declared";
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: closure.bins.length };
398
- const facts = await probeTools(closure.bins, REAL_TOOL_PROBE_IO, {
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(closure.bins, facts);
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(closure.bins, toolFacts),
479
+ bins: planBinUpdates(tools, toolFacts),
459
480
  skills: planSkillUpdates(skillRefs, installedSkills),
460
- binSpecs: new Map(closure.bins.map((b) => [b.name, b])),
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);
@@ -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
- export interface CliTool {
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: CliTool[] = [
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
- packageManager: "bun",
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
- packageManager: "bun",
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<BinRequirement[]>;
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<BinRequirement[]> => [];
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
- * It does NOT touch `autoUpdate`. It used to set it to `true` on every
1405
- * MadAppGang entry whose flag was unset, on every TUI startup — writing a
1406
- * Claude Code-owned field that Claude Code also syncs from settings. See
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 type { CliTool } from "../data/cli-tools.js";
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 binInstallCommand({
168
+ return managedInstallCommand({
68
169
  name: tool.name,
69
170
  via: tool.packageManager,
70
171
  package: tool.packageName,