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
@@ -53,9 +53,11 @@ import type {
53
53
  McpServerConfig,
54
54
  ProfileManifest,
55
55
  ProfileManifestEntry,
56
+ ProfileMarketplaceRef,
56
57
  ProfileSkillRef,
57
58
  } from "../types/index.js";
58
59
  import { readManifest, writeManifest } from "./manifest.js";
60
+ import { registryLookup } from "./market-registry.js";
59
61
  import { validateModelsConfig } from "./models-core.js";
60
62
  import {
61
63
  type ActivateOptions,
@@ -92,7 +94,7 @@ import {
92
94
  skillFolder,
93
95
  updateSkillIgnoreLines,
94
96
  } from "./profile-skills.js";
95
- import { resolveExtends, resolveProfile } from "./resolver.js";
97
+ import { EMPTY_LOOKUP, resolveExtends, resolveProfile } from "./resolver.js";
96
98
  import { SettingsParseError } from "./settings-file.js";
97
99
  import { trackedSkillFolders } from "./tracked-live-files.js";
98
100
 
@@ -105,7 +107,8 @@ export type AbsorbChange =
105
107
  | { kind: "settings-changed" }
106
108
  | { kind: "mcp-changed" }
107
109
  | { kind: "models-changed" }
108
- | { kind: "skill-released"; skill: string; folder: string };
110
+ | { kind: "skill-released"; skill: string; folder: string }
111
+ | { kind: "marketplace-added"; name: string; repo: string };
109
112
 
110
113
  /** One line per change, naming the profile it went into. */
111
114
  export function describeChange(change: AbsorbChange, profile: string): string {
@@ -124,9 +127,47 @@ export function describeChange(change: AbsorbChange, profile: string): string {
124
127
  return `Updated model routing of profile "${profile}"`;
125
128
  case "skill-released":
126
129
  return `Skill "${change.skill}" was edited — it is now a custom skill in .claude/skills/${change.folder}; commit it`;
130
+ case "marketplace-added":
131
+ return `Profile "${profile}" records where ${change.name} comes from (${change.repo})`;
127
132
  }
128
133
  }
129
134
 
135
+ /**
136
+ * Record in `next.marketplaces` where each enabled plugin's marketplace comes
137
+ * from, for every marketplace the entry does not name yet and `lookup` knows —
138
+ * this machine's registrations outside magus's defaults. Mutates `next`; each
139
+ * added name is pushed to `changes` when given. Without the record a teammate's
140
+ * `magus install` cannot register the marketplace, and the plugin fails to
141
+ * install there.
142
+ */
143
+ function recordMarketplaces(
144
+ next: ProfileManifestEntry,
145
+ lookup: ReadonlyMap<string, ProfileMarketplaceRef>,
146
+ changes?: AbsorbChange[],
147
+ ): void {
148
+ if (lookup.size === 0) return;
149
+ const declared = { ...(next.marketplaces ?? {}) };
150
+ const inherited = resolveExtends(next).marketplaces ?? {};
151
+ let added = false;
152
+ for (const [id, pin] of Object.entries(next.plugins ?? {})) {
153
+ if (!isOn(pin)) continue;
154
+ const at = id.lastIndexOf("@");
155
+ if (at <= 0) continue;
156
+ const name = id.slice(at + 1);
157
+ if (declared[name] || inherited[name]) continue;
158
+ const ref = lookup.get(name);
159
+ if (!ref) continue;
160
+ declared[name] = { ...ref };
161
+ added = true;
162
+ changes?.push({
163
+ kind: "marketplace-added",
164
+ name,
165
+ repo: ref.repo ?? ref.path ?? "",
166
+ });
167
+ }
168
+ if (added) next.marketplaces = declared;
169
+ }
170
+
130
171
  // ─── The inverse of rendering ────────────────────────────────────────────────
131
172
 
132
173
  /** The live config as absorption reads it. */
@@ -188,6 +229,7 @@ export function computeAbsorb(
188
229
  live: LiveView,
189
230
  otherPluginIds: ReadonlySet<string>,
190
231
  derived: ReadonlySet<string> = new Set(),
232
+ lookup: ReadonlyMap<string, ProfileMarketplaceRef> = EMPTY_LOOKUP,
191
233
  ): { entry: ProfileManifestEntry; changes: AbsorbChange[] } {
192
234
  const changes: AbsorbChange[] = [];
193
235
  const next: ProfileManifestEntry = { ...entry };
@@ -240,6 +282,8 @@ export function computeAbsorb(
240
282
  }
241
283
  }
242
284
  setOrDelete(next, "plugins", own);
285
+ // Where each plugin's marketplace comes from, when only this machine knows.
286
+ recordMarketplaces(next, lookup, changes);
243
287
 
244
288
  // Settings: everything but enabledPlugins and machine-local keys, minus
245
289
  // what `extends` supplies unchanged.
@@ -538,6 +582,20 @@ export function merge3(
538
582
  ),
539
583
  ),
540
584
  );
585
+ // Where marketplaces come from, keyed by name: a name either side added is
586
+ // kept, and the same name with different refs on both sides is a conflict.
587
+ put(
588
+ "marketplaces",
589
+ nonEmpty(
590
+ merge3Entries(
591
+ base.marketplaces as Record<string, unknown> | undefined,
592
+ local.marketplaces as Record<string, unknown> | undefined,
593
+ remote.marketplaces as Record<string, unknown> | undefined,
594
+ "marketplaces",
595
+ conflicts,
596
+ ),
597
+ ),
598
+ );
541
599
  // The routing config is validated as a unit, so it merges as one value.
542
600
  let models = local.models;
543
601
  if (!jsonEqual(local.models, remote.models)) {
@@ -746,6 +804,12 @@ function mergeAdditively(
746
804
  };
747
805
  }
748
806
  if (everything.models) merged.models = everything.models;
807
+ // A marketplace `target` does not name is added; one it names keeps its ref.
808
+ const marketplaces = { ...(target.marketplaces ?? {}) };
809
+ for (const [name, ref] of Object.entries(everything.marketplaces ?? {})) {
810
+ if (!(name in marketplaces)) marketplaces[name] = ref;
811
+ }
812
+ if (Object.keys(marketplaces).length > 0) merged.marketplaces = marketplaces;
749
813
  return merged;
750
814
  }
751
815
 
@@ -804,6 +868,15 @@ function diffEntries(
804
868
  if (!jsonEqual(before.models ?? null, after.models ?? null)) {
805
869
  changes.push({ kind: "models-changed" });
806
870
  }
871
+ const bm = before.marketplaces ?? {};
872
+ for (const [name, ref] of Object.entries(after.marketplaces ?? {})) {
873
+ if (jsonEqual(bm[name], ref)) continue;
874
+ changes.push({
875
+ kind: "marketplace-added",
876
+ name,
877
+ repo: ref.repo ?? ref.path ?? "",
878
+ });
879
+ }
807
880
  return changes;
808
881
  }
809
882
 
@@ -899,6 +972,7 @@ export async function absorbOrphanedProfile(
899
972
  models: live.models,
900
973
  };
901
974
 
975
+ const lookup = await registryLookup();
902
976
  let merged: ProfileManifestEntry;
903
977
  if (base) {
904
978
  const local = computeAbsorb(
@@ -906,12 +980,19 @@ export async function absorbOrphanedProfile(
906
980
  view,
907
981
  union,
908
982
  new Set(renderedBase?.dependencies?.derived ?? []),
983
+ lookup,
909
984
  ).entry;
910
985
  const merge = merge3(base, local, targetEntry);
911
986
  merged = merge.entry;
912
987
  result.conflicts.push(...merge.conflicts);
913
988
  } else {
914
- const everything = computeAbsorb({ name: orphan }, view, union).entry;
989
+ const everything = computeAbsorb(
990
+ { name: orphan },
991
+ view,
992
+ union,
993
+ undefined,
994
+ lookup,
995
+ ).entry;
915
996
  merged = mergeAdditively(targetEntry, everything);
916
997
  }
917
998
  // A plugin the target pins keeps its pin when the edit merely enabled it.
@@ -1053,6 +1134,7 @@ export async function absorbOlderLayout(
1053
1134
  },
1054
1135
  await olderRenderIds(projectPath, named, rendered.allPluginIds),
1055
1136
  new Set(rendered.dependencies?.derived ?? []),
1137
+ await registryLookup(),
1056
1138
  );
1057
1139
  const next = absorbed.entry;
1058
1140
  const changes = diffEntries(entry, next);
@@ -1201,6 +1283,7 @@ export function absorbAdditively(
1201
1283
  old: LiveView,
1202
1284
  renderIds: ReadonlySet<string>,
1203
1285
  derived: ReadonlySet<string> = new Set(),
1286
+ lookup: ReadonlyMap<string, ProfileMarketplaceRef> = EMPTY_LOOKUP,
1204
1287
  ): { entry: ProfileManifestEntry; conflicts: string[]; keptMore: boolean } {
1205
1288
  const conflicts: string[] = [];
1206
1289
  const flags = { keptMore: false };
@@ -1234,6 +1317,7 @@ export function absorbAdditively(
1234
1317
  if (isOn(declared) && enabled[id] === undefined) flags.keptMore = true;
1235
1318
  }
1236
1319
  setOrDelete(next, "plugins", own);
1320
+ recordMarketplaces(next, lookup);
1237
1321
 
1238
1322
  // Settings, recursively; `extends` keys compare against what they resolve to.
1239
1323
  const { enabledPlugins: _drop, ...oldSettings } = withoutMachineKeys(
@@ -1463,6 +1547,7 @@ export async function reconcileActiveProfile(
1463
1547
  marker.derived,
1464
1548
  ),
1465
1549
  new Set(renderedBase.dependencies?.derived ?? []),
1550
+ await registryLookup(),
1466
1551
  );
1467
1552
  local = absorbed.entry;
1468
1553
  result.changes.push(...absorbed.changes);
@@ -17,6 +17,7 @@ import type {
17
17
  McpServerConfig,
18
18
  ProfileManifest,
19
19
  ProfileManifestEntry,
20
+ ProfileMarketplaceRef,
20
21
  } from "../types/index.js";
21
22
  import {
22
23
  getEnabledPlugins,
@@ -24,6 +25,7 @@ import {
24
25
  readSettings,
25
26
  } from "./claude-settings.js";
26
27
  import { emptyManifest } from "./manifest.js";
28
+ import { registryLookup } from "./market-registry.js";
27
29
  import { withDerivedMarketplaces } from "./resolver.js";
28
30
 
29
31
  /** The profile id used when adopting a project that has no manifest. */
@@ -62,6 +64,12 @@ export interface AdoptInput {
62
64
  name?: string;
63
65
  /** Injectable clock so the output is deterministic in tests. */
64
66
  now?: string;
67
+ /**
68
+ * This machine's registered marketplaces outside magus's defaults
69
+ * ({@link registryLookup}), so a plugin from one of them records where its
70
+ * marketplace comes from and a teammate can register it.
71
+ */
72
+ lookup?: ReadonlyMap<string, ProfileMarketplaceRef>;
65
73
  }
66
74
 
67
75
  /**
@@ -127,7 +135,7 @@ export function buildAdoptedProfile(input: AdoptInput): ProfileManifestEntry {
127
135
 
128
136
  const entry: ProfileManifestEntry = {
129
137
  name: input.name ?? "Default",
130
- marketplaces: withDerivedMarketplaces(undefined, plugins),
138
+ marketplaces: withDerivedMarketplaces(undefined, plugins, input.lookup),
131
139
  plugins,
132
140
  createdAt: input.now ?? new Date().toISOString(),
133
141
  };
@@ -205,6 +213,7 @@ export async function adoptCurrentProject(
205
213
  settings,
206
214
  mcpServers: mcp.mcpServers ?? {},
207
215
  name,
216
+ lookup: await registryLookup(),
208
217
  });
209
218
 
210
219
  // Copy rather than mutate the caller's manifest: this function does not
@@ -1,8 +1,13 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
  import fs from "fs-extra";
4
- import type { ProfileEntry, ProfileManifest } from "../types/index.js";
4
+ import type {
5
+ ProfileEntry,
6
+ ProfileManifest,
7
+ ProfileMarketplaceRef,
8
+ } from "../types/index.js";
5
9
  import { readProfilesFile, writeJsonMinimal } from "./manifest.js";
10
+ import { registryLookup } from "./market-registry.js";
6
11
  import { activeProfile } from "./profile-live.js";
7
12
 
8
13
  const PROFILES_FILE = "profiles.json";
@@ -111,18 +116,27 @@ export async function listProfiles(
111
116
  /**
112
117
  * Save (create or overwrite) a profile.
113
118
  * Returns the generated profile ID.
119
+ *
120
+ * `marketplaces` is written with the entry, and every plugin from a
121
+ * marketplace it does not name is looked up in this machine's registrations
122
+ * ({@link registryLookup}): a profile saved at PROJECT scope is committed, and
123
+ * a teammate's `magus install` can only register a marketplace the profile
124
+ * says where to find. magus's default marketplaces need no record.
114
125
  */
115
126
  export async function saveProfile(
116
127
  name: string,
117
128
  plugins: Record<string, boolean>,
118
129
  scope: "user" | "project",
119
130
  projectPath?: string,
131
+ marketplaces?: Record<string, ProfileMarketplaceRef>,
120
132
  ): Promise<string> {
121
133
  const data = await readProfiles(scope, projectPath);
122
134
  const id = generateUniqueId(name, data.profiles);
123
135
  const now = new Date().toISOString();
136
+ const recorded = await recordedMarketplaces(plugins, marketplaces);
124
137
  data.profiles[id] = {
125
138
  name,
139
+ ...(Object.keys(recorded).length > 0 ? { marketplaces: recorded } : {}),
126
140
  plugins,
127
141
  createdAt: now,
128
142
  updatedAt: now,
@@ -131,6 +145,27 @@ export async function saveProfile(
131
145
  return id;
132
146
  }
133
147
 
148
+ /**
149
+ * `given`, plus a ref from this machine's registrations for each plugin's
150
+ * marketplace that `given` does not name and magus does not know by default.
151
+ */
152
+ async function recordedMarketplaces(
153
+ plugins: Record<string, unknown>,
154
+ given: Record<string, ProfileMarketplaceRef> | undefined,
155
+ ): Promise<Record<string, ProfileMarketplaceRef>> {
156
+ const lookup = await registryLookup();
157
+ const out: Record<string, ProfileMarketplaceRef> = { ...(given ?? {}) };
158
+ for (const id of Object.keys(plugins)) {
159
+ const at = id.lastIndexOf("@");
160
+ if (at <= 0) continue;
161
+ const name = id.slice(at + 1);
162
+ if (out[name]) continue;
163
+ const ref = lookup.get(name);
164
+ if (ref) out[name] = { ...ref };
165
+ }
166
+ return out;
167
+ }
168
+
134
169
  /**
135
170
  * A profile's plugin map as Claude Code's `enabledPlugins` needs it.
136
171
  *
@@ -240,9 +275,37 @@ export async function importProfileFromJson(
240
275
  entry.plugins as Record<string, boolean>,
241
276
  targetScope,
242
277
  projectPath,
278
+ pastedMarketplaces(entry.marketplaces),
243
279
  );
244
280
  }
245
281
 
282
+ /**
283
+ * The pasted entry's `marketplaces`, keeping only refs of a shape a profile
284
+ * can hold: a `github` ref with a repo, or a `directory` ref with a path.
285
+ */
286
+ function pastedMarketplaces(
287
+ value: unknown,
288
+ ): Record<string, ProfileMarketplaceRef> | undefined {
289
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
290
+ return undefined;
291
+ }
292
+ const out: Record<string, ProfileMarketplaceRef> = {};
293
+ for (const [name, ref] of Object.entries(value as Record<string, unknown>)) {
294
+ if (!ref || typeof ref !== "object") continue;
295
+ const r = ref as Partial<ProfileMarketplaceRef>;
296
+ if (r.source === "github" && typeof r.repo === "string" && r.repo) {
297
+ out[name] = { source: "github", repo: r.repo };
298
+ } else if (
299
+ r.source === "directory" &&
300
+ typeof r.path === "string" &&
301
+ r.path
302
+ ) {
303
+ out[name] = { source: "directory", path: r.path };
304
+ }
305
+ }
306
+ return out;
307
+ }
308
+
246
309
  // ─── Internal helpers ──────────────────────────────────────────────────────────
247
310
 
248
311
  function slugify(name: string): string {
@@ -23,6 +23,7 @@ import type {
23
23
  DependencyReport,
24
24
  ProfileManifest,
25
25
  ProfileManifestEntry,
26
+ ProfileMarketplaceRef,
26
27
  ResolvedBin,
27
28
  ResolvedClosure,
28
29
  SelectedBin,
@@ -479,13 +480,16 @@ async function resolveBins(
479
480
  * never registered. `extends` made this the default failure, because it
480
481
  * inherits plugins from a predefined profile and no marketplaces at all.
481
482
  *
482
- * Only marketplaces magus already knows are derived, since a repo is needed
483
- * to register one. An unknown suffix is left alone for the manifest to declare.
483
+ * Only marketplaces with a known repo are derived, since a repo is needed to
484
+ * register one: magus's default marketplaces, and the names `lookup` answers
485
+ * (this machine's registrations, for a caller writing a profile a teammate
486
+ * will read). An unknown suffix is left alone for the manifest to declare.
484
487
  * An explicit entry always wins: deriving must never override a pin.
485
488
  */
486
489
  export function withDerivedMarketplaces(
487
490
  declared: ProfileManifestEntry["marketplaces"],
488
- plugins: Record<string, string>,
491
+ plugins: Record<string, unknown>,
492
+ lookup: ReadonlyMap<string, ProfileMarketplaceRef> = EMPTY_LOOKUP,
489
493
  ): NonNullable<ProfileManifestEntry["marketplaces"]> {
490
494
  const out = { ...(declared ?? {}) };
491
495
  for (const id of Object.keys(plugins)) {
@@ -494,12 +498,20 @@ export function withDerivedMarketplaces(
494
498
  const name = id.slice(at + 1);
495
499
  if (out[name]) continue;
496
500
  const known = getMarketplaceByName(name);
497
- if (!known?.source.repo) continue;
498
- out[name] = { source: "github", repo: known.source.repo };
501
+ if (known?.source.repo) {
502
+ out[name] = { source: "github", repo: known.source.repo };
503
+ continue;
504
+ }
505
+ const registered = lookup.get(name);
506
+ if (registered) out[name] = { ...registered };
499
507
  }
500
508
  return out;
501
509
  }
502
510
 
511
+ /** No registrations to consult: derive from magus's default marketplaces only. */
512
+ export const EMPTY_LOOKUP: ReadonlyMap<string, ProfileMarketplaceRef> =
513
+ new Map();
514
+
503
515
  /**
504
516
  * The active profile's plugin pins, or null when there is nothing to read.
505
517
  *
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { SettingDefinition } from "../data/settings-catalog.js";
4
4
  import type { ClaudeSettings } from "../types/index.js";
5
+ import { requireClaudeConfigDir } from "../utils/config-dir.js";
5
6
  import {
6
7
  getGlobalSettingsPath,
7
8
  getSettingsPath,
@@ -248,8 +249,7 @@ export async function discoverOutputStyles(
248
249
  { label: "Default", value: "" },
249
250
  ];
250
251
 
251
- const homeDir = process.env.HOME || process.env.USERPROFILE || "";
252
- const cacheDir = path.join(homeDir, ".claude", "plugins", "cache");
252
+ const cacheDir = path.join(requireClaudeConfigDir(), "plugins", "cache");
253
253
 
254
254
  // Collect enabled plugins from both scopes
255
255
  const enabledPlugins = new Set<string>();
@@ -1,4 +1,3 @@
1
- import os from "node:os";
2
1
  import path from "node:path";
3
2
  import fs from "fs-extra";
4
3
  import {
@@ -11,6 +10,7 @@ import type {
11
10
  SkillInfo,
12
11
  SkillSource,
13
12
  } from "../types/index.js";
13
+ import { requireClaudeConfigDir } from "../utils/config-dir.js";
14
14
  import {
15
15
  getMarketplaceClonePath,
16
16
  scanMcpServers,
@@ -33,7 +33,7 @@ const treeCache = new Map<string, TreeCacheEntry>();
33
33
  // ─── Path helpers ──────────────────────────────────────────────────────────────
34
34
 
35
35
  export function getUserSkillsDir(): string {
36
- return path.join(os.homedir(), ".claude", "skills");
36
+ return path.join(requireClaudeConfigDir(), "skills");
37
37
  }
38
38
 
39
39
  export function getProjectSkillsDir(projectPath?: string): string {
@@ -118,6 +118,12 @@ export type PluginReason =
118
118
  | "marketplace-unread"
119
119
  /** unknown: the catalog fetch failed ("latest" pins only). */
120
120
  | "catalog-unverified"
121
+ /**
122
+ * unknown: its marketplace on this machine is not the one the profile
123
+ * names, or could not be compared with it. The Plugins screen's rows only;
124
+ * the plan leaves such a plugin out instead.
125
+ */
126
+ | "marketplace-conflict"
121
127
  /** current: listed, but its marketplace publishes no version to compare. */
122
128
  | "no-published-version"
123
129
  /** repair: files changed without a version bump. */
@@ -897,27 +903,37 @@ export function classifyPlugin(
897
903
  * (`pluginsAnyProfileLists`). One neither pinned nor
898
904
  * installed is planned only as a deprecated `remove`; see
899
905
  * {@link onlyOtherProfilesList}.
906
+ * @param leaveOut plugins that get no item at all, whichever of the three
907
+ * sources would bring them in. `magus update` passes the
908
+ * plugins of every marketplace registered here from another
909
+ * repository than the profile names: what the catalog says
910
+ * about them describes the other repository, so planning an
911
+ * update, an install or a deprecated removal from it would
912
+ * act on the wrong source.
900
913
  */
901
914
  export function planPluginUpdates(
902
915
  pinned: Readonly<Record<string, string>>,
903
916
  catalog: ReadonlyMap<string, PluginInfo> | readonly PluginInfo[],
904
917
  requiredBy: RequiredBy = {},
905
918
  listedByProfiles: readonly string[] = [],
919
+ leaveOut: (pluginId: string) => boolean = () => false,
906
920
  ): PluginUpdateItem[] {
907
921
  const index = indexCatalog(catalog);
908
922
  const plugins =
909
923
  catalog instanceof Map
910
924
  ? [...catalog.values()]
911
925
  : (catalog as readonly PluginInfo[]);
912
- const pins = new Map(Object.entries(pinned));
926
+ const pins = new Map(
927
+ Object.entries(pinned).filter(([pluginId]) => !leaveOut(pluginId)),
928
+ );
913
929
  for (const info of plugins) {
914
- if (pins.has(info.id)) continue;
930
+ if (pins.has(info.id) || leaveOut(info.id)) continue;
915
931
  const pin = pinForRow(info, pinned);
916
932
  if (pin !== null) pins.set(info.id, pin);
917
933
  }
918
934
  const othersOnly = new Set<string>();
919
935
  for (const pluginId of listedByProfiles) {
920
- if (pins.has(pluginId)) continue;
936
+ if (pins.has(pluginId) || leaveOut(pluginId)) continue;
921
937
  othersOnly.add(pluginId);
922
938
  pins.set(pluginId, "latest");
923
939
  }
@@ -982,19 +998,62 @@ export function pinForRow(
982
998
  * SAME plan `magus update` runs, not a second classification. Computed once
983
999
  * per data/pins change, and the one map feeds the row, the detail panel, the
984
1000
  * header and the scope picker, so none of them can disagree.
1001
+ *
1002
+ * `leftOut` answers, for a plugin whose marketplace on this machine is not the
1003
+ * one the profile names, why it is left out; null for every other plugin. The
1004
+ * plan leaves those plugins out exactly as `magus update`'s does, and each
1005
+ * one's row gets a `marketplace-conflict` status carrying that answer, which
1006
+ * is no work for any apply.
985
1007
  */
986
1008
  export function classifyPluginRows(
987
1009
  plugins: readonly PluginInfo[],
988
1010
  pins: Readonly<Record<string, string>>,
989
1011
  requiredBy: RequiredBy = {},
990
1012
  listedByProfiles: readonly string[] = [],
1013
+ leftOut: (pluginId: string) => string | null = () => null,
991
1014
  ): Map<string, PluginUpdateItem> {
992
- return new Map(
993
- planPluginUpdates(pins, plugins, requiredBy, listedByProfiles).map((i) => [
994
- i.pluginId,
995
- i,
996
- ]),
1015
+ const rows = new Map(
1016
+ planPluginUpdates(
1017
+ pins,
1018
+ plugins,
1019
+ requiredBy,
1020
+ listedByProfiles,
1021
+ (pluginId) => leftOut(pluginId) !== null,
1022
+ ).map((i) => [i.pluginId, i]),
997
1023
  );
1024
+ for (const info of plugins) {
1025
+ const note = leftOut(info.id);
1026
+ if (note === null) continue;
1027
+ rows.set(info.id, leftOutItem(info, pins[info.id] ?? "latest", note));
1028
+ }
1029
+ return rows;
1030
+ }
1031
+
1032
+ /**
1033
+ * The status of a row the plan leaves out: no target, no scopes, nothing to
1034
+ * apply. Its marketplace's catalog is the other repository's, so no version it
1035
+ * offers is shown as available.
1036
+ */
1037
+ function leftOutItem(
1038
+ info: PluginInfo,
1039
+ pinned: string,
1040
+ note: string,
1041
+ ): PluginUpdateItem {
1042
+ return {
1043
+ pluginId: info.id,
1044
+ pinned,
1045
+ installed: highestInstalledVersion(info),
1046
+ loaded: info.loadedVersion ?? null,
1047
+ available: null,
1048
+ beforeVersions: Object.fromEntries(installedScopeVersions(info)) as Partial<
1049
+ Record<PluginScope, string>
1050
+ >,
1051
+ target: null,
1052
+ action: "unknown",
1053
+ reason: "marketplace-conflict",
1054
+ scopes: [],
1055
+ note,
1056
+ };
998
1057
  }
999
1058
 
1000
1059
  export interface PluginStateCounts {