magus-cli 7.12.2 → 7.14.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.
Files changed (111) hide show
  1. package/README.md +21 -0
  2. package/package.json +4 -4
  3. package/src/__tests__/market-bases.test.ts +112 -0
  4. package/src/__tests__/market-config.test.ts +84 -0
  5. package/src/__tests__/market-conflicts.test.ts +175 -0
  6. package/src/__tests__/market-consent.test.ts +185 -0
  7. package/src/__tests__/market-content.test.ts +183 -0
  8. package/src/__tests__/market-delivery.test.ts +308 -0
  9. package/src/__tests__/market-fixture.ts +1167 -0
  10. package/src/__tests__/market-git.test.ts +130 -0
  11. package/src/__tests__/market-identity.test.ts +127 -0
  12. package/src/__tests__/market-install.test.ts +143 -0
  13. package/src/__tests__/market-layout.test.ts +206 -0
  14. package/src/__tests__/market-publish.test.ts +263 -0
  15. package/src/__tests__/market-r2-adapters.test.ts +190 -0
  16. package/src/__tests__/market-r2-company-cli.test.ts +350 -0
  17. package/src/__tests__/market-r2-company-unit.test.ts +224 -0
  18. package/src/__tests__/market-r2-local-items.test.ts +197 -0
  19. package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
  20. package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
  21. package/src/__tests__/market-r2-routing.test.ts +127 -0
  22. package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
  23. package/src/__tests__/market-r2b-display-path.test.ts +180 -0
  24. package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
  25. package/src/__tests__/market-r2b-local-items.test.ts +253 -0
  26. package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
  27. package/src/__tests__/market-readonly.test.ts +162 -0
  28. package/src/__tests__/market-repositories.test.ts +263 -0
  29. package/src/__tests__/market-results.test.ts +198 -0
  30. package/src/__tests__/market-router.test.ts +65 -0
  31. package/src/__tests__/market-safety.test.ts +357 -0
  32. package/src/__tests__/market-teammates.test.ts +304 -0
  33. package/src/cli/install.ts +64 -5
  34. package/src/cli/market-view.ts +329 -0
  35. package/src/cli/market.ts +664 -0
  36. package/src/cli/router.ts +69 -3
  37. package/src/cli/update-view.ts +56 -0
  38. package/src/cli/update.ts +111 -9
  39. package/src/data/marketplaces.ts +8 -2
  40. package/src/main.tsx +20 -2
  41. package/src/market-tui.tsx +21 -0
  42. package/src/services/active-closure.ts +71 -9
  43. package/src/services/claude-cli.ts +140 -10
  44. package/src/services/claude-settings.ts +19 -25
  45. package/src/services/doctor.ts +5 -5
  46. package/src/services/git-write.ts +615 -0
  47. package/src/services/github-cli.ts +309 -0
  48. package/src/services/local-marketplace.ts +112 -50
  49. package/src/services/market-bases.ts +191 -0
  50. package/src/services/market-change.ts +242 -0
  51. package/src/services/market-clone.ts +572 -0
  52. package/src/services/market-fs.ts +138 -0
  53. package/src/services/market-install.ts +172 -0
  54. package/src/services/market-layout.ts +311 -0
  55. package/src/services/market-local.ts +93 -0
  56. package/src/services/market-marker.ts +49 -0
  57. package/src/services/market-ops-content.ts +277 -0
  58. package/src/services/market-ops-plugin.ts +655 -0
  59. package/src/services/market-ops-publish.ts +1227 -0
  60. package/src/services/market-ops-repo.ts +916 -0
  61. package/src/services/market-ops-skill.ts +788 -0
  62. package/src/services/market-ops.ts +686 -0
  63. package/src/services/market-publish.ts +468 -0
  64. package/src/services/market-registry.ts +382 -0
  65. package/src/services/market-types.ts +268 -0
  66. package/src/services/marketplace-conflicts.ts +127 -0
  67. package/src/services/marketplace-refresh.ts +33 -3
  68. package/src/services/marketplace-sync.ts +253 -22
  69. package/src/services/plugin-dependencies.ts +17 -0
  70. package/src/services/plugin-manager.ts +1 -1
  71. package/src/services/plugin-mcp-config.ts +6 -9
  72. package/src/services/plugin-requires.ts +20 -8
  73. package/src/services/profile-absorb.ts +88 -3
  74. package/src/services/profile-adopt.ts +10 -1
  75. package/src/services/profiles.ts +64 -1
  76. package/src/services/resolver.ts +17 -5
  77. package/src/services/settings-manager.ts +2 -2
  78. package/src/services/skills-manager.ts +2 -2
  79. package/src/services/update-plan.ts +67 -8
  80. package/src/tui-runtime.tsx +142 -0
  81. package/src/tui.tsx +7 -124
  82. package/src/types/index.ts +7 -0
  83. package/src/ui/adapters/marketAdapter.ts +366 -0
  84. package/src/ui/adapters/pluginsAdapter.ts +45 -3
  85. package/src/ui/components/layout/ScreenLayout.tsx +43 -17
  86. package/src/ui/components/modals/ConfirmModal.tsx +145 -8
  87. package/src/ui/components/modals/HelpOverlay.tsx +10 -3
  88. package/src/ui/components/modals/ModalContainer.tsx +47 -5
  89. package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
  90. package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
  91. package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
  92. package/src/ui/components/primitives/SelectableRow.tsx +14 -1
  93. package/src/ui/components/primitives/ToneLine.tsx +25 -0
  94. package/src/ui/components/primitives/index.ts +5 -0
  95. package/src/ui/keymap.ts +21 -5
  96. package/src/ui/market/MarketApp.tsx +59 -0
  97. package/src/ui/market/MarketplaceScreen.tsx +280 -0
  98. package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
  99. package/src/ui/market/MarketplacesScreen.tsx +264 -0
  100. package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
  101. package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
  102. package/src/ui/market/marketKeymap.ts +243 -0
  103. package/src/ui/market/useMarketState.ts +694 -0
  104. package/src/ui/renderers/pluginRenderers.tsx +31 -3
  105. package/src/ui/renderers/pluginStatusText.ts +6 -0
  106. package/src/ui/screens/PluginsScreen.tsx +49 -5
  107. package/src/ui/screens/SkillsScreen.tsx +2 -2
  108. package/src/ui/state/AppContext.tsx +14 -2
  109. package/src/ui/state/types.ts +9 -2
  110. package/src/utils/config-dir.ts +29 -9
  111. package/src/utils/display-path.ts +33 -0
package/src/cli/router.ts CHANGED
@@ -28,6 +28,12 @@ export interface RouteOutcome {
28
28
  launchTui: boolean;
29
29
  /** From --theme, validated. Only meaningful when launchTui is true. */
30
30
  theme?: ThemeMode;
31
+ /**
32
+ * Which interactive app to launch when `launchTui` is true: the dependency
33
+ * TUI (bare `magus`, the default) or the company marketplace screens
34
+ * (`magus edit`).
35
+ */
36
+ app?: "main" | "market";
31
37
  }
32
38
 
33
39
  export type ThemeFlagParse =
@@ -87,6 +93,21 @@ const HANDLED = (exitCode: number): RouteOutcome => ({
87
93
  });
88
94
  const LAUNCH_TUI: RouteOutcome = { handled: false, launchTui: true };
89
95
 
96
+ /** `magus edit --help`. */
97
+ function editHelp(): string {
98
+ return `magus edit — open the company marketplace screens
99
+
100
+ Usage: magus edit
101
+
102
+ Lists the company marketplaces on this machine, their plugins and skills, and
103
+ creates, connects and changes them: publishes edited skills (one skill, or a
104
+ whole plugin as one new version), and adds this project's and your USER skills
105
+ and plugin folders to a marketplace. Needs a terminal.
106
+
107
+ \`magus market --help\` lists the commands that do the same without one.
108
+ `;
109
+ }
110
+
90
111
  /**
91
112
  * Every name the switch below handles.
92
113
  *
@@ -101,6 +122,8 @@ const SUBCOMMANDS = new Set([
101
122
  "profile",
102
123
  "models",
103
124
  "doctor",
125
+ "market",
126
+ "edit",
104
127
  ]);
105
128
 
106
129
  /**
@@ -122,8 +145,12 @@ export async function route(
122
145
  */
123
146
  themeEnv?: ThemeEnv,
124
147
  ): Promise<RouteOutcome> {
125
- // Top-level flags take precedence over any command token.
126
- if (argv.includes("--version") || argv.includes("-v")) {
148
+ // Top-level flags take precedence over any command token — for every
149
+ // command but `market` and `edit`, which answer --help and --version
150
+ // themselves (and write nothing doing so). `magus upgrade --help` must still
151
+ // print help rather than upgrade, so this exception is exactly argv[0].
152
+ const marketFirst = argv[0] === "market" || argv[0] === "edit";
153
+ if (!marketFirst && (argv.includes("--version") || argv.includes("-v"))) {
127
154
  console.log(`magus v${version}`);
128
155
  const result = await checkForUpdates();
129
156
  if (result.updateAvailable) {
@@ -133,7 +160,7 @@ export async function route(
133
160
  return HANDLED(0);
134
161
  }
135
162
 
136
- if (argv.includes("--help") || argv.includes("-h")) {
163
+ if (!marketFirst && (argv.includes("--help") || argv.includes("-h"))) {
137
164
  printHelp(version);
138
165
  return HANDLED(0);
139
166
  }
@@ -216,6 +243,32 @@ export async function route(
216
243
  return HANDLED(await runModelsCommand(rest));
217
244
  case "doctor":
218
245
  return HANDLED(await runDoctorCommand(rest));
246
+ case "market": {
247
+ const { runMarketCommand } = await import("./market.js");
248
+ return HANDLED(await runMarketCommand(rest, version));
249
+ }
250
+ case "edit": {
251
+ if (rest.includes("--help") || rest.includes("-h")) {
252
+ console.log(editHelp());
253
+ return HANDLED(0);
254
+ }
255
+ if (rest.includes("--version") || rest.includes("-v")) {
256
+ console.log(`magus v${version}`);
257
+ return HANDLED(0);
258
+ }
259
+ if (rest.length > 0) {
260
+ console.error(
261
+ "magus edit: takes no arguments — the commands are `magus market <verb>` (`magus market --help` lists them)",
262
+ );
263
+ return HANDLED(2);
264
+ }
265
+ return {
266
+ handled: false,
267
+ launchTui: true,
268
+ app: "market",
269
+ ...(themeFlag.theme === undefined ? {} : { theme: themeFlag.theme }),
270
+ };
271
+ }
219
272
  default:
220
273
  // Bare `magus` or an unrecognized token → interactive TUI.
221
274
  return themeFlag.theme === undefined
@@ -303,6 +356,19 @@ applied by a PreToolUse hook magus registers at user scope.
303
356
  models status What is routed now, plus any drift from the config
304
357
  models off Stop routing; subagents inherit the session model again
305
358
 
359
+ Company marketplaces — your team's own Claude Code plugin marketplace on
360
+ GitHub, listed first in magus. Every change prints its plan and asks first.
361
+ edit Open the company marketplace screens
362
+ market list Company marketplaces on this machine, with their plugins
363
+ market create Create one on GitHub; market connect use an existing one
364
+ market remove <m> Remove one from this machine (the repository stays)
365
+ market plugin create|delete <p@m>; market plugin add <path>
366
+ market skill add|edit|remove <skill>
367
+ market status How your local copies of company skills stand
368
+ market publish <skill> Send a local skill's edits back to its plugin
369
+ market publish --plugin <p@m> Every edited skill of a plugin, one version
370
+ market --help Every market command, its flags and exit codes
371
+
306
372
  Other commands:
307
373
  upgrade Update magus ITSELF to the latest version
308
374
 
@@ -26,6 +26,7 @@
26
26
  * own word, so the report survives NO_COLOR, a pipe, and colour blindness.
27
27
  */
28
28
 
29
+ import type { RegistrationResult } from "../services/marketplace-sync.js";
29
30
  import { brewUnpinCommand } from "../services/toolchain.js";
30
31
  import { binCommandFor } from "../services/update-engine.js";
31
32
  import {
@@ -935,6 +936,61 @@ export function note(text: string): string {
935
936
  return `${PAD} ${dim(text)}`;
936
937
  }
937
938
 
939
+ // -- marketplace registration ------------------------------------------------
940
+
941
+ /**
942
+ * What registering the profile's marketplaces did, for `install` and
943
+ * `update` alike: each marketplace added, the one-time notice for a company
944
+ * marketplace trusted from the profile, then each conflict and failure with
945
+ * its fix. Empty when nothing happened.
946
+ */
947
+ export function registrationReport(result: RegistrationResult): string[] {
948
+ const lines: string[] = [];
949
+ for (const { name, source } of result.added) {
950
+ lines.push(ok(`${bold(name)} added to this machine ${dim(`(${source})`)}`));
951
+ }
952
+ for (const name of result.trusted) {
953
+ lines.push(
954
+ info(
955
+ `${bold(name)} is now a company marketplace on this machine, from this project's profile`,
956
+ ),
957
+ );
958
+ }
959
+ for (const problem of [...result.conflicts, ...result.failed]) {
960
+ lines.push(fail(`${bold(problem.name)}: ${problem.message}`));
961
+ if (problem.fix) lines.push(note(problem.fix));
962
+ }
963
+ return lines;
964
+ }
965
+
966
+ /** The plugins left out of this run because their marketplace's source conflicts. */
967
+ export function conflictSkipLine(name: string, plugins: string[]): string {
968
+ return warn(
969
+ `Left out ${plugins.map((p) => bold(p)).join(", ")} — ${name} on this machine is not the one this project's profile names`,
970
+ );
971
+ }
972
+
973
+ /**
974
+ * `magus install` stopped before activating `profile`: it turns on plugins
975
+ * from a marketplace whose registration here is another repository's.
976
+ */
977
+ export function conflictStopLine(
978
+ profile: string,
979
+ name: string,
980
+ plugins: string[],
981
+ ): string {
982
+ return fail(
983
+ `Did not activate ${bold(profile)}: it turns on ${plugins.map((p) => bold(p)).join(", ")}, and ${name} on this machine is not the one this project's profile names — nothing was installed`,
984
+ );
985
+ }
986
+
987
+ /** A read-only run names each marketplace a real run would add, and adds none. */
988
+ export function notRegisteredYetLine(name: string): string {
989
+ return info(
990
+ `${bold(name)} is not on this machine yet — ${bold("magus update")} adds it`,
991
+ );
992
+ }
993
+
938
994
  // -- unverified items ----------------------------------------------------------
939
995
  //
940
996
  // A plugin is `unknown` for several distinct reasons — its marketplace publishes
package/src/cli/update.ts CHANGED
@@ -39,11 +39,25 @@ import { isClaudeAvailable } from "../services/claude-cli.js";
39
39
  import { clearContentDriftCache } from "../services/content-drift.js";
40
40
  import { refusedPinWhy } from "../services/deps-plan.js";
41
41
  import { validateManifest } from "../services/manifest.js";
42
+ import {
43
+ conflictFreeOptions,
44
+ fromConflicted,
45
+ leaveOutConflicted,
46
+ } from "../services/marketplace-conflicts.js";
42
47
  import {
43
48
  refreshRegisteredMarketplaces,
44
49
  reportRegisteredMarketplaces,
45
50
  } from "../services/marketplace-refresh.js";
46
- import { registerClosureMarketplaces } from "../services/marketplace-sync.js";
51
+ import {
52
+ findMarketplaceConflicts,
53
+ isMarketplaceRegistered,
54
+ registerClosureMarketplaces,
55
+ } from "../services/marketplace-sync.js";
56
+ import {
57
+ type PluginDependencyReader,
58
+ readPluginDependencies,
59
+ readUpdatedPluginDependencies,
60
+ } from "../services/plugin-dependencies.js";
47
61
  import {
48
62
  type PluginInfo,
49
63
  clearMarketplaceCache,
@@ -99,14 +113,17 @@ import {
99
113
  type Step,
100
114
  applyFrame,
101
115
  applyRow,
116
+ conflictSkipLine,
102
117
  fail as failLine,
103
118
  header,
104
119
  info,
120
+ notRegisteredYetLine,
105
121
  note as noteLine,
106
122
  nothingToApplyLines,
107
123
  ok as okLine,
108
124
  pendingHeadline,
109
125
  planReport,
126
+ registrationReport,
110
127
  stepFrame,
111
128
  stepRecord,
112
129
  summaryLine,
@@ -334,7 +351,7 @@ async function updateProfile(
334
351
  /** Notices worth keeping, flushed after the checklist collapses. */
335
352
  const notices: string[] = [];
336
353
 
337
- const closure = await runStep(steps.profile, async (say) => {
354
+ let closure = await runStep(steps.profile, async (say) => {
338
355
  say(`resolving "${targetId}"`);
339
356
  const resolved = await resolveProfile(current, targetId);
340
357
  steps.profile.detail = [
@@ -369,14 +386,66 @@ async function updateProfile(
369
386
  // profile's plugins, plus whatever is installed at USER scope (the refresher
370
387
  // reads that itself). And each clone at most once an hour, machine-wide.
371
388
  const readOnly = flags.dryRun || flags.check;
372
- const pluginIds = Object.keys(closure.plugins);
389
+ /** Marketplaces whose registration here is not the one the profile names. */
390
+ const conflicted = new Set<string>();
391
+ /**
392
+ * The conflicts a real run would report, found without registering or
393
+ * trusting anything, so a run that registers nothing still leaves out the
394
+ * same plugins as one that does.
395
+ */
396
+ const detectConflicts = async () => {
397
+ const conflicts = await findMarketplaceConflicts(
398
+ closure.marketplaces,
399
+ projectPath,
400
+ );
401
+ notices.push(
402
+ ...registrationReport({ added: [], trusted: [], conflicts, failed: [] }),
403
+ );
404
+ for (const c of conflicts) conflicted.add(c.name);
405
+ };
373
406
  const refresh = await runStep(steps.marketplaces, async (say) => {
374
407
  if (readOnly) {
408
+ await detectConflicts();
409
+ // Registering is a write, so a read-only run names what it would add.
410
+ // A conflicted one is never added, so it is not named.
411
+ for (const name of Object.keys(closure.marketplaces)) {
412
+ if (conflicted.has(name)) continue;
413
+ if (!(await isMarketplaceRegistered(name))) {
414
+ notices.push(notRegisteredYetLine(name));
415
+ }
416
+ }
375
417
  say("read-only — selecting, not pulling");
376
418
  // Report-only, NOT a skip: the selection is a pure read, so a read-only
377
419
  // run can still say exactly what a real run would pull.
378
- return reportRegisteredMarketplaces([], { pluginIds });
420
+ return reportRegisteredMarketplaces([], {
421
+ pluginIds: Object.keys(closure.plugins),
422
+ });
423
+ }
424
+ // Register what the profile names before anything is planned: without
425
+ // the marketplace a plugin from it is "not found", and its dependencies
426
+ // cannot be read. A marketplace added here holds plugin.json files the
427
+ // first resolve could not see, so the profile is resolved again.
428
+ // Without `claude` nothing can be registered; the apply phase says so.
429
+ if (await isClaudeAvailable()) {
430
+ say("registering marketplaces the profile names");
431
+ const registration = await registerClosureMarketplaces(
432
+ closure.marketplaces,
433
+ projectPath,
434
+ );
435
+ notices.push(...registrationReport(registration));
436
+ for (const c of registration.conflicts) conflicted.add(c.name);
437
+ if (registration.added.length > 0) {
438
+ clearMarketplaceCache();
439
+ closure = await resolveProfile(
440
+ current,
441
+ targetId,
442
+ conflictFreeOptions(conflicted),
443
+ );
444
+ }
445
+ } else {
446
+ await detectConflicts();
379
447
  }
448
+ const pluginIds = Object.keys(closure.plugins);
380
449
  let settled = 0;
381
450
  return refreshRegisteredMarketplaces(
382
451
  [],
@@ -428,6 +497,40 @@ async function updateProfile(
428
497
  clearMarketplaceCache();
429
498
  clearContentDriftCache();
430
499
 
500
+ // A pulled clone may carry a new publish that adds a dependency or a tool
501
+ // requirement to a plugin already listed: resolve again, so this run
502
+ // installs those too rather than the next one.
503
+ // A conflicted marketplace resolves again too: the first resolve read its
504
+ // plugins' dependencies and tool requirements from the other repository.
505
+ if (conflicted.size > 0 || (!readOnly && refresh.refreshed.length > 0)) {
506
+ // A plugin that follows its marketplace's latest is moved to the
507
+ // clone's version by this run, so its dependencies are that version's:
508
+ // read the clone first. A pinned plugin keeps the installed copy's.
509
+ const specs = closure.plugins;
510
+ const readDependencies: PluginDependencyReader | undefined = readOnly
511
+ ? undefined
512
+ : (id, at) => {
513
+ const spec = specs[id];
514
+ return spec === undefined || spec === "latest"
515
+ ? readUpdatedPluginDependencies(id, at)
516
+ : readPluginDependencies(id, at);
517
+ };
518
+ closure = await resolveProfile(
519
+ current,
520
+ targetId,
521
+ conflictFreeOptions(conflicted, readDependencies),
522
+ );
523
+ }
524
+
525
+ // A plugin whose marketplace here is not the profile's is left out of this
526
+ // run's work, with the tools only it declares: installing either would take
527
+ // it from the wrong repository. The same filter `magus install` uses.
528
+ const permitted = leaveOutConflicted(closure, conflicted);
529
+ closure = permitted.closure;
530
+ for (const l of permitted.leftOut) {
531
+ notices.push(conflictSkipLine(l.marketplace, l.plugins));
532
+ }
533
+
431
534
  const catalog = await runStep(steps.catalog, async (say) => {
432
535
  say("reading installed and available plugins");
433
536
  const map = new Map<string, PluginInfo>(
@@ -527,11 +630,15 @@ async function updateProfile(
527
630
  // Every profile's plugins, not only this one's: a deprecated plugin a
528
631
  // profile that is not active lists is still a row the render keeps, and
529
632
  // is planned for removal.
633
+ // The catalog lists a conflicted marketplace's installed plugins, and
634
+ // other profiles may list more of them: the planner leaves out every
635
+ // one, not only those `leaveOutConflicted` took out of the closure.
530
636
  plugins: planPluginUpdates(
531
637
  closure.plugins,
532
638
  catalog,
533
639
  requiredBy,
534
640
  pluginsAnyProfileLists(current),
641
+ (pluginId) => fromConflicted(pluginId, conflicted),
535
642
  ),
536
643
  bins: planBinUpdates(tools, toolFacts),
537
644
  skills: planSkillUpdates(skillRefs, installedSkills),
@@ -648,11 +755,6 @@ async function updateProfile(
648
755
  return 1;
649
756
  }
650
757
 
651
- // Register any marketplace the profile names but this machine does not know.
652
- // Without this an install fails with "not found in marketplace" for anything
653
- // outside magus's built-in list, which is every private or third-party one.
654
- await registerClosureMarketplaces(closure.marketplaces);
655
-
656
758
  // ---- apply phase, under an animated meter --------------------------------
657
759
 
658
760
  const nameWidth = Math.max(12, ...workItems.map((n) => width(n)));
@@ -249,6 +249,8 @@ export function getAllMarketplaces(
249
249
  featured: defaultMp?.featured,
250
250
  deprecated: defaultMp?.deprecated,
251
251
  owned: defaultMp?.owned,
252
+ ...(local.company ? { company: true } : {}),
253
+ ...(local.ownerName ? { ownerName: local.ownerName } : {}),
252
254
  });
253
255
  }
254
256
  }
@@ -262,9 +264,13 @@ export function getAllMarketplaces(
262
264
  }
263
265
  }
264
266
 
265
- // Sort: Magus first, deprecated last, then alphabetically
267
+ // Sort: company marketplaces first, then Magus, deprecated last, then
268
+ // alphabetically
266
269
  return Array.from(all.values()).sort((a, b) => {
267
- // Magus (MadAppGang) always first
270
+ // This machine's company marketplaces lead
271
+ if (a.company && !b.company) return -1;
272
+ if (!a.company && b.company) return 1;
273
+ // Magus (MadAppGang) next
268
274
  const aIsMag = a.source.repo?.toLowerCase().includes("madappgang/");
269
275
  const bIsMag = b.source.repo?.toLowerCase().includes("madappgang/");
270
276
  if (aIsMag && !bIsMag) return -1;
package/src/main.tsx CHANGED
@@ -12,8 +12,8 @@ export const VERSION = (pkg as { version: string }).version;
12
12
  //
13
13
  // NOTHING in this file may statically import @opentui/* or ./ui/*. `magus
14
14
  // hook …` runs on every matching Claude Code tool call, and a static import
15
- // would load the whole TUI module graph before the hook could answer. The TUI
16
- // (./tui.js), the router (./cli/router.js, which reaches @opentui through
15
+ // would load the whole TUI module graph before the hook could answer. The TUIs
16
+ // (./tui.js and ./market-tui.js), the router (./cli/router.js, which reaches @opentui through
17
17
  // cli/update.ts → ui/theme.ts) and the hook itself are all imported on demand,
18
18
  // after the branch that decides which one is needed.
19
19
  // Pinned by __tests__/hook-import-policy.test.ts.
@@ -94,6 +94,24 @@ async function main(): Promise<void> {
94
94
  const isInteractive =
95
95
  process.stdin.isTTY === true && process.stdout.isTTY === true;
96
96
 
97
+ if (outcome.app === "market") {
98
+ if (!isInteractive) {
99
+ console.error(
100
+ "magus edit opens an interactive screen, which needs a terminal. `magus market --help` lists the commands that run without one.",
101
+ );
102
+ process.exit(2);
103
+ }
104
+ // The market screens, imported as late as the main TUI below and for
105
+ // the same reason.
106
+ const { startMarketTui } = await import("./market-tui.js");
107
+ await startMarketTui({
108
+ theme: outcome.theme,
109
+ env: themeEnv,
110
+ isInteractive,
111
+ });
112
+ return;
113
+ }
114
+
97
115
  // Last possible moment: this is the import that pulls in @opentui and the
98
116
  // whole ui/ tree. Everything above runs without them.
99
117
  const { startTui } = await import("./tui.js");
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The interactive screens behind `magus edit`: the company
3
+ * marketplaces on this machine, and the plugins and skills of one of them.
4
+ *
5
+ * Imported dynamically from `main.tsx`, like `tui.tsx`, so no other command
6
+ * loads `@opentui/*` or `src/ui/`. Opening it writes nothing; every change it
7
+ * makes is a plan the user confirmed, applied by the same functions the
8
+ * `magus market` verbs run.
9
+ */
10
+
11
+ import { type MountTuiOptions, mountTui } from "./tui-runtime.js";
12
+ import { MarketApp } from "./ui/market/MarketApp.js";
13
+
14
+ export type StartMarketTuiOptions = MountTuiOptions;
15
+
16
+ /** Start the market TUI. Never resolves — it runs until the user quits. */
17
+ export async function startMarketTui(
18
+ opts: StartMarketTuiOptions,
19
+ ): Promise<void> {
20
+ await mountTui(opts, (quit) => <MarketApp onExit={quit} />);
21
+ }
@@ -14,14 +14,25 @@
14
14
  * NOTHING HERE PRINTS — it runs under the CLI's live region and inside the TUI.
15
15
  */
16
16
 
17
+ import { getMarketplaceByName } from "../data/marketplaces.js";
17
18
  import type {
18
19
  ProfileManifest,
19
20
  ResolvedClosure,
20
21
  SelectedBin,
21
22
  } from "../types/index.js";
22
23
  import { readManifest, validateManifest } from "./manifest.js";
24
+ import {
25
+ type MarketplaceCheck,
26
+ NOTHING_LEFT_OUT,
27
+ conflictFreeOptions,
28
+ } from "./marketplace-conflicts.js";
29
+ import { findMarketplaceConflicts } from "./marketplace-sync.js";
23
30
  import { activeProfile } from "./profile-live.js";
24
- import { pluginsAnyProfileLists, resolveProfile } from "./resolver.js";
31
+ import {
32
+ type ResolveOptions,
33
+ pluginsAnyProfileLists,
34
+ resolveProfile,
35
+ } from "./resolver.js";
25
36
 
26
37
  /**
27
38
  * The profile to use: the explicit one, else the active one, else the only one.
@@ -51,6 +62,13 @@ export interface ActiveClosure {
51
62
  * set `magus update` passes it.
52
63
  */
53
64
  listedByProfiles: string[];
65
+ /**
66
+ * The profile's marketplaces whose plugins are left out of updates here —
67
+ * the same comparison `magus update` makes, read without changing
68
+ * anything. When this machine's side could not be read, every marketplace
69
+ * the comparison covers is `unchecked`.
70
+ */
71
+ marketplaces: MarketplaceCheck;
54
72
  }
55
73
 
56
74
  export interface ActiveClosureDeps {
@@ -59,16 +77,19 @@ export interface ActiveClosureDeps {
59
77
  resolveProfile(
60
78
  manifest: ProfileManifest,
61
79
  id: string,
62
- opts?: {
63
- binResolver?: (pluginId: string) => Promise<SelectedBin[]>;
64
- },
80
+ opts?: ResolveOptions,
65
81
  ): Promise<ResolvedClosure>;
82
+ findMarketplaceConflicts(
83
+ marketplaces: ResolvedClosure["marketplaces"],
84
+ projectRoot?: string,
85
+ ): Promise<Array<{ name: string }>>;
66
86
  }
67
87
 
68
88
  const REAL_DEPS: ActiveClosureDeps = {
69
89
  readManifest,
70
90
  activeProfile,
71
91
  resolveProfile,
92
+ findMarketplaceConflicts,
72
93
  };
73
94
 
74
95
  export interface LoadActiveClosureOptions {
@@ -111,21 +132,62 @@ export async function loadActiveClosure(
111
132
  );
112
133
  if (profileId === null) return null;
113
134
 
114
- const closure = await deps.resolveProfile(
115
- manifest,
116
- profileId,
117
- opts.resolveBins === false ? { binResolver: NO_BINS } : {},
118
- );
135
+ const bins: ResolveOptions =
136
+ opts.resolveBins === false ? { binResolver: NO_BINS } : {};
137
+ let closure = await deps.resolveProfile(manifest, profileId, bins);
138
+ const marketplaces = await checkMarketplaces(deps, closure, projectPath);
139
+ // A conflicted plugin's dependencies and tools were read from the other
140
+ // repository: resolve again without them, as `magus update` does.
141
+ const leftOut = new Set([
142
+ ...marketplaces.conflicted,
143
+ ...marketplaces.unchecked,
144
+ ]);
145
+ if (leftOut.size > 0) {
146
+ closure = await deps.resolveProfile(manifest, profileId, {
147
+ ...conflictFreeOptions(leftOut),
148
+ ...bins,
149
+ });
150
+ }
119
151
  return {
120
152
  profileId,
121
153
  closure,
122
154
  listedByProfiles: pluginsAnyProfileLists(manifest),
155
+ marketplaces,
123
156
  };
124
157
  } catch {
125
158
  return null;
126
159
  }
127
160
  }
128
161
 
162
+ /**
163
+ * Which of the closure's marketplaces differ from this machine's. A failed
164
+ * read is an answer too: every marketplace the comparison covers (any that is
165
+ * not one of magus's defaults) is then unchecked, and left out.
166
+ */
167
+ async function checkMarketplaces(
168
+ deps: ActiveClosureDeps,
169
+ closure: ResolvedClosure,
170
+ projectPath: string,
171
+ ): Promise<MarketplaceCheck> {
172
+ const names = Object.keys(closure.marketplaces);
173
+ if (names.length === 0) return NOTHING_LEFT_OUT;
174
+ try {
175
+ const conflicts = await deps.findMarketplaceConflicts(
176
+ closure.marketplaces,
177
+ projectPath,
178
+ );
179
+ return {
180
+ conflicted: new Set(conflicts.map((c) => c.name)),
181
+ unchecked: new Set(),
182
+ };
183
+ } catch {
184
+ return {
185
+ conflicted: new Set(),
186
+ unchecked: new Set(names.filter((n) => !getMarketplaceByName(n))),
187
+ };
188
+ }
189
+ }
190
+
129
191
  /**
130
192
  * The active profile's plugin pins — `pluginId -> exact version | "latest"` — and
131
193
  * `{}` when no profile resolves.