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
@@ -0,0 +1,172 @@
1
+ /**
2
+ * market-install.ts — move a plugin's installs to a version just published,
3
+ * through Claude Code's CLI and nothing else.
4
+ *
5
+ * The only things this module may call are `runPluginVerb` (spawns `claude
6
+ * plugin install|update`), `readInstalledVersionForScope` and
7
+ * `loadedVersionHere` (both only read `installed_plugins.json`). None of them
8
+ * reaches a function that writes Claude Code's files directly, so nothing here
9
+ * can repair, redirect or rewrite an install: a scope Claude Code did not move
10
+ * is reported as not moved, with `magus update` as the fix.
11
+ *
12
+ * Keep the import list exactly as it is. Anything more is a path to a direct
13
+ * writer of Claude Code's state.
14
+ */
15
+
16
+ import { type PluginScope, runPluginVerb } from "./claude-cli.js";
17
+ import { loadedVersionHere } from "./claude-settings.js";
18
+ import { readInstalledVersionForScope } from "./plugin-manager.js";
19
+
20
+ /** What happened at one scope. */
21
+ export interface InstallMove {
22
+ scope: PluginScope;
23
+ /** This folder's own row at the scope before the verb; null when none. */
24
+ from: string | null;
25
+ /** The row after every verb ran; null when there is none. */
26
+ to: string | null;
27
+ /** The version Claude Code loads in this folder, read once after every verb. */
28
+ loaded: string | null;
29
+ status: "updated" | "unchanged" | "failed";
30
+ message?: string;
31
+ fix?: string;
32
+ }
33
+
34
+ export interface AdvanceResult {
35
+ installs: InstallMove[];
36
+ /** The version Claude Code loads in this folder after the move. */
37
+ loaded: string | null;
38
+ /** `partial` when any scope failed or this folder does not load the target. */
39
+ outcome: "ok" | "partial";
40
+ message?: string;
41
+ fix?: string;
42
+ }
43
+
44
+ /** The three calls {@link advanceViaCli} makes; replaceable for tests. */
45
+ export interface AdvanceDeps {
46
+ runPluginVerb: typeof runPluginVerb;
47
+ readInstalledVersionForScope: typeof readInstalledVersionForScope;
48
+ loadedVersionHere: typeof loadedVersionHere;
49
+ }
50
+
51
+ const realDeps: AdvanceDeps = {
52
+ runPluginVerb,
53
+ readInstalledVersionForScope,
54
+ loadedVersionHere,
55
+ };
56
+
57
+ const FIX = "magus update";
58
+
59
+ function errorText(error: unknown): string {
60
+ return error instanceof Error ? error.message : String(error);
61
+ }
62
+
63
+ /**
64
+ * Bring `pluginId` to `target` at each of `scopes`, as seen from `cwd`.
65
+ *
66
+ * Two passes. First the verbs, scope by scope: `update` where this folder
67
+ * already has a row at that scope, else `install`. Then one read-back, after
68
+ * every verb has run: each scope's own row, and once the version the loader
69
+ * picks in this folder. Judging the loaded version inside the first pass
70
+ * would depend on registry order, since the loader takes the first relevant
71
+ * row, not the one just moved.
72
+ *
73
+ * Each scope is judged by its own row. The move as a whole is judged by what
74
+ * this folder loads. Claude Code's marketplace clone must already hold the
75
+ * new version: `claude plugin update` does not fetch it.
76
+ */
77
+ export async function advanceViaCli(
78
+ pluginId: string,
79
+ scopes: readonly PluginScope[],
80
+ cwd: string,
81
+ target: string,
82
+ deps: AdvanceDeps = realDeps,
83
+ ): Promise<AdvanceResult> {
84
+ const pending: Array<{
85
+ scope: PluginScope;
86
+ from: string | null;
87
+ error?: string;
88
+ }> = [];
89
+
90
+ for (const scope of scopes) {
91
+ let from: string | null = null;
92
+ try {
93
+ from = await deps.readInstalledVersionForScope(pluginId, scope, cwd);
94
+ await deps.runPluginVerb(
95
+ from ? "update" : "install",
96
+ pluginId,
97
+ scope,
98
+ cwd,
99
+ );
100
+ pending.push({ scope, from });
101
+ } catch (error) {
102
+ pending.push({ scope, from, error: errorText(error) });
103
+ }
104
+ }
105
+
106
+ const loaded = await deps.loadedVersionHere(pluginId, cwd);
107
+ const installs: InstallMove[] = [];
108
+ for (const { scope, from, error } of pending) {
109
+ let to: string | null = null;
110
+ try {
111
+ to = await deps.readInstalledVersionForScope(pluginId, scope, cwd);
112
+ } catch (readError) {
113
+ installs.push({
114
+ scope,
115
+ from,
116
+ to: null,
117
+ loaded,
118
+ status: "failed",
119
+ message: `could not read what Claude Code installed: ${errorText(readError)}`,
120
+ fix: FIX,
121
+ });
122
+ continue;
123
+ }
124
+ if (error !== undefined) {
125
+ installs.push({
126
+ scope,
127
+ from,
128
+ to,
129
+ loaded,
130
+ status: "failed",
131
+ message: error,
132
+ ...(/not found in marketplace/i.test(error) ? { fix: FIX } : {}),
133
+ });
134
+ continue;
135
+ }
136
+ if (to === target) {
137
+ installs.push({
138
+ scope,
139
+ from,
140
+ to,
141
+ loaded,
142
+ status: from === target ? "unchanged" : "updated",
143
+ });
144
+ continue;
145
+ }
146
+ installs.push({
147
+ scope,
148
+ from,
149
+ to,
150
+ loaded,
151
+ status: "failed",
152
+ message: `Claude Code did not move this folder to v${target}`,
153
+ fix: FIX,
154
+ });
155
+ }
156
+
157
+ if (scopes.length > 0 && loaded !== target) {
158
+ return {
159
+ installs,
160
+ loaded,
161
+ outcome: "partial",
162
+ message: loaded
163
+ ? `installed v${target}, but this folder still loads v${loaded}`
164
+ : `installed v${target}, but this folder loads no version of it`,
165
+ fix: FIX,
166
+ };
167
+ }
168
+ const failed = installs.some((i) => i.status === "failed");
169
+ return failed
170
+ ? { installs, loaded, outcome: "partial", fix: FIX }
171
+ : { installs, loaded, outcome: "ok" };
172
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * market-layout.ts — pure rules about how a marketplace repository is laid out.
3
+ *
4
+ * Nothing here reads or writes the disk: every function takes what it needs
5
+ * and answers. The scan, dependency resolution and the market verbs all ask
6
+ * the same question — where does this plugin live in its marketplace? — and
7
+ * get one answer.
8
+ */
9
+
10
+ import { createHash } from "node:crypto";
11
+ import path from "node:path";
12
+
13
+ /** True when `child` is `root` or sits below it. Both must be absolute. */
14
+ function isInside(root: string, child: string): boolean {
15
+ const rel = path.relative(root, child);
16
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
17
+ }
18
+
19
+ /**
20
+ * The directory a plugin entry's `source` names inside a marketplace, or null
21
+ * when it names none there.
22
+ *
23
+ * Claude Code's two relative forms:
24
+ * - `"./plugins/kit"` (or `"../…"`, `"."`) is relative to the marketplace root.
25
+ * - A bare name, `"kit"`, resolves under `metadata.pluginRoot` — a relative
26
+ * path inside the marketplace such as `"./plugins"`. With no `pluginRoot`,
27
+ * or one pointing outside the marketplace, a bare name does not resolve.
28
+ *
29
+ * Anything else — an object source (a git repository, a URL), an absolute
30
+ * path, or a path that leaves the marketplace — is not a directory here.
31
+ */
32
+ export function pluginDirFor(
33
+ marketplaceRoot: string,
34
+ source: unknown,
35
+ pluginRoot?: unknown,
36
+ ): string | null {
37
+ if (typeof source !== "string" || source.trim() === "") return null;
38
+ if (path.isAbsolute(source)) return null;
39
+ const root = path.resolve(marketplaceRoot);
40
+
41
+ const relative =
42
+ source === "." ||
43
+ source === ".." ||
44
+ source.startsWith("./") ||
45
+ source.startsWith("../");
46
+ if (relative) {
47
+ const dir = path.resolve(root, source);
48
+ return isInside(root, dir) ? dir : null;
49
+ }
50
+
51
+ if (typeof pluginRoot !== "string" || pluginRoot.trim() === "") return null;
52
+ if (path.isAbsolute(pluginRoot)) return null;
53
+ const base = path.resolve(root, pluginRoot);
54
+ if (!isInside(root, base)) return null;
55
+ const dir = path.resolve(base, source);
56
+ return isInside(base, dir) ? dir : null;
57
+ }
58
+
59
+ // ─── Names ───────────────────────────────────────────────────────────────────
60
+
61
+ /** A marketplace or plugin name: lower case, digits and dashes. */
62
+ const PACKAGE_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
63
+ /** A skill folder name: letters, digits, dots, dashes and underscores. */
64
+ const SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
65
+
66
+ /** True for a marketplace or plugin name magus will write. */
67
+ export function validPackageName(name: string): boolean {
68
+ return PACKAGE_NAME.test(name);
69
+ }
70
+
71
+ /** True for a skill folder name: one path segment, never `.` or `..`. */
72
+ export function validSkillName(name: string): boolean {
73
+ return SKILL_NAME.test(name);
74
+ }
75
+
76
+ /**
77
+ * A plugin's directory relative to the marketplace root, with forward
78
+ * slashes ("" for the root itself), or null when its source names no
79
+ * directory inside it.
80
+ */
81
+ export function pluginRelDir(
82
+ source: unknown,
83
+ pluginRoot?: unknown,
84
+ ): string | null {
85
+ const root = path.resolve(path.sep, "marketplace-root");
86
+ const dir = pluginDirFor(root, source, pluginRoot);
87
+ if (dir === null) return null;
88
+ return path.relative(root, dir).split(path.sep).join("/");
89
+ }
90
+
91
+ // ─── Manifest entries ────────────────────────────────────────────────────────
92
+
93
+ export interface PluginEntry {
94
+ name: string;
95
+ source?: unknown;
96
+ version?: unknown;
97
+ description?: unknown;
98
+ [key: string]: unknown;
99
+ }
100
+
101
+ function isRecord(v: unknown): v is Record<string, unknown> {
102
+ return typeof v === "object" && v !== null && !Array.isArray(v);
103
+ }
104
+
105
+ /** The manifest's plugin entries that carry a name. */
106
+ export function pluginEntries(manifest: unknown): PluginEntry[] {
107
+ if (!isRecord(manifest) || !Array.isArray(manifest.plugins)) return [];
108
+ return manifest.plugins.filter(
109
+ (p): p is PluginEntry => isRecord(p) && typeof p.name === "string",
110
+ );
111
+ }
112
+
113
+ /** `metadata.pluginRoot`, when the manifest declares one. */
114
+ export function pluginRootOf(manifest: unknown): unknown {
115
+ if (!isRecord(manifest) || !isRecord(manifest.metadata)) return undefined;
116
+ return manifest.metadata.pluginRoot;
117
+ }
118
+
119
+ /** The manifest with `entry` appended to `plugins`. Returns a copy. */
120
+ export function addPluginEntry<T extends Record<string, unknown>>(
121
+ manifest: T,
122
+ entry: PluginEntry,
123
+ ): T {
124
+ const plugins = Array.isArray(manifest.plugins) ? manifest.plugins : [];
125
+ return { ...manifest, plugins: [...plugins, entry] };
126
+ }
127
+
128
+ /** The manifest without the entry named `name`. Returns a copy. */
129
+ export function removePluginEntry<T extends Record<string, unknown>>(
130
+ manifest: T,
131
+ name: string,
132
+ ): T {
133
+ const plugins = Array.isArray(manifest.plugins) ? manifest.plugins : [];
134
+ return {
135
+ ...manifest,
136
+ plugins: plugins.filter((p) => !(isRecord(p) && p.name === name)),
137
+ };
138
+ }
139
+
140
+ /** The manifest with the entry named `name` at `version`. Returns a copy. */
141
+ export function setEntryVersion<T extends Record<string, unknown>>(
142
+ manifest: T,
143
+ name: string,
144
+ version: string,
145
+ ): T {
146
+ const plugins = Array.isArray(manifest.plugins) ? manifest.plugins : [];
147
+ return {
148
+ ...manifest,
149
+ plugins: plugins.map((p) =>
150
+ isRecord(p) && p.name === name ? { ...p, version } : p,
151
+ ),
152
+ };
153
+ }
154
+
155
+ // ─── Versions ────────────────────────────────────────────────────────────────
156
+
157
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
158
+
159
+ /**
160
+ * The version a plugin is at: `plugin.json`'s `version` first (what Claude
161
+ * Code honours when both exist), else the marketplace entry's, else null for
162
+ * an unversioned plugin.
163
+ */
164
+ export function currentVersion(
165
+ pluginJson: unknown,
166
+ entry: unknown,
167
+ ): string | null {
168
+ for (const source of [pluginJson, entry]) {
169
+ if (isRecord(source) && typeof source.version === "string") {
170
+ const v = source.version.trim();
171
+ if (v) return v;
172
+ }
173
+ }
174
+ return null;
175
+ }
176
+
177
+ /**
178
+ * The patch bump of `current`; an unversioned plugin goes to `0.0.1`. Null
179
+ * when `current` is not `major.minor.patch`, which magus will not guess at.
180
+ */
181
+ export function nextVersion(current: string | null): string | null {
182
+ if (current === null) return "0.0.1";
183
+ const m = SEMVER.exec(current);
184
+ if (!m) return null;
185
+ return `${m[1]}.${m[2]}.${Number(m[3]) + 1}`;
186
+ }
187
+
188
+ // ─── Ownership ───────────────────────────────────────────────────────────────
189
+
190
+ export interface OwnerCandidate {
191
+ marketplace: string;
192
+ /** Each plugin with its directory relative to the root (null: none here). */
193
+ plugins: Array<{ name: string; dir: string | null }>;
194
+ /** Every file path in the marketplace at the commit read. */
195
+ paths: ReadonlySet<string>;
196
+ }
197
+
198
+ /** Every `plugin@marketplace` whose plugin holds `skills/<skill>/SKILL.md`. */
199
+ export function findSkillOwners(
200
+ candidates: readonly OwnerCandidate[],
201
+ skill: string,
202
+ ): string[] {
203
+ const owners: string[] = [];
204
+ for (const c of candidates) {
205
+ for (const p of c.plugins) {
206
+ if (p.dir === null) continue;
207
+ const file = joinRel(p.dir, "skills", skill, "SKILL.md");
208
+ if (c.paths.has(file)) owners.push(`${p.name}@${c.marketplace}`);
209
+ }
210
+ }
211
+ return owners;
212
+ }
213
+
214
+ /** Join path segments with `/`, dropping empty ones (a plugin at the root). */
215
+ export function joinRel(...parts: string[]): string {
216
+ return parts.filter((p) => p !== "" && p !== ".").join("/");
217
+ }
218
+
219
+ // ─── Copy sets ───────────────────────────────────────────────────────────────
220
+
221
+ /** The file mode git records for a symlink. */
222
+ export const SYMLINK_MODE = "120000";
223
+
224
+ /**
225
+ * Why a path inside a skill folder is never copied, by path and mode alone,
226
+ * or null when it may be: a symlink, any segment starting with `.`, or a path
227
+ * under `node_modules/`.
228
+ */
229
+ export function excludedByShape(
230
+ relPath: string,
231
+ symlink: boolean,
232
+ ): "symlink" | "dotfile" | "node_modules" | null {
233
+ if (symlink) return "symlink";
234
+ const segments = relPath.split("/");
235
+ if (segments.some((s) => s.startsWith("."))) return "dotfile";
236
+ if (segments.includes("node_modules")) return "node_modules";
237
+ return null;
238
+ }
239
+
240
+ /**
241
+ * The compare domain: a path counts when its shape allows it and neither
242
+ * side ignores it. Decided from path and mode alone, so it applies equally to
243
+ * a file present on one side only.
244
+ */
245
+ export function inDomain(
246
+ relPath: string,
247
+ mode: string,
248
+ ignoredHere: ReadonlySet<string>,
249
+ ignoredThere: ReadonlySet<string>,
250
+ ): boolean {
251
+ return (
252
+ excludedByShape(relPath, mode === SYMLINK_MODE) === null &&
253
+ !ignoredHere.has(relPath) &&
254
+ !ignoredThere.has(relPath)
255
+ );
256
+ }
257
+
258
+ /** A file in a skill: its path inside the skill folder and its blob id. */
259
+ export interface SkillFile {
260
+ path: string;
261
+ oid: string;
262
+ }
263
+
264
+ /** One hash for a file set, the same function for a stored tree and a folder. */
265
+ export function treeHash(files: readonly SkillFile[]): string {
266
+ const lines = [...files]
267
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
268
+ .map((f) => `${f.oid} ${f.path}`)
269
+ .join("\n");
270
+ return createHash("sha256").update(lines).digest("hex");
271
+ }
272
+
273
+ /**
274
+ * How a local copy of a skill stands against its plugin, from three tree
275
+ * hashes over the compare domain: `base` (what the copy started from; null
276
+ * when there is no record), `theirs` (the plugin now) and `ours` (the copy).
277
+ *
278
+ * - `same`: the copy equals the plugin; nothing to publish.
279
+ * - `plugin-newer`: the copy has no edits and the plugin moved on.
280
+ * - `fast`: the plugin has not changed since the copy was made.
281
+ * - `no-base`: nobody knows what the copy started from.
282
+ * - `conflict`: both the copy and the plugin changed.
283
+ */
284
+ export type CompareState =
285
+ | "same"
286
+ | "fast"
287
+ | "plugin-newer"
288
+ | "no-base"
289
+ | "conflict";
290
+
291
+ export function compareSkillTrees(
292
+ base: string | null,
293
+ theirs: string,
294
+ ours: string,
295
+ ): CompareState {
296
+ if (ours === theirs) return "same";
297
+ if (base === null) return "no-base";
298
+ if (ours === base) return "plugin-newer";
299
+ if (theirs === base) return "fast";
300
+ return "conflict";
301
+ }
302
+
303
+ /** True when both sets hold the same paths with the same blob ids. */
304
+ export function sameFiles(
305
+ a: readonly SkillFile[],
306
+ b: readonly SkillFile[],
307
+ ): boolean {
308
+ if (a.length !== b.length) return false;
309
+ const map = new Map(a.map((f) => [f.path, f.oid]));
310
+ return b.every((f) => map.get(f.path) === f.oid);
311
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * market-local.ts — the skills and plugins on this machine that no company
3
+ * marketplace holds yet: what the market screens offer to add to one.
4
+ *
5
+ * Read-only: folders are listed and two small files read, nothing written.
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { ORIGIN_FILE } from "./market-bases.js";
11
+ import { profileManaged } from "./market-publish.js";
12
+ import type { LocalItem } from "./market-types.js";
13
+ import { getUserSkillsDir } from "./skills-manager.js";
14
+
15
+ const PLUGIN_JSON = path.join(".claude-plugin", "plugin.json");
16
+
17
+ function isFile(file: string): boolean {
18
+ try {
19
+ return fs.lstatSync(file).isFile();
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ /** The `name` a plugin folder's plugin.json gives, or null. */
26
+ function pluginJsonName(folder: string): string | null {
27
+ try {
28
+ const json = JSON.parse(
29
+ fs.readFileSync(path.join(folder, PLUGIN_JSON), "utf8"),
30
+ ) as { name?: unknown };
31
+ return typeof json.name === "string" && json.name.trim()
32
+ ? json.name.trim()
33
+ : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * This project's skills (`.claude/skills/<name>/`) and your USER skills
41
+ * (`<claude config dir>/skills/<name>/`), real folders only, as two kinds:
42
+ * - a plugin: the folder holds `.claude-plugin/plugin.json`, and no company
43
+ * marketplace has a plugin of its name (`companyPlugins`) — once added,
44
+ * the folder is the marketplace's plugin, not a local one;
45
+ * - a skill: the folder holds `SKILL.md`, and is none of a copy magus made of
46
+ * a company skill (it holds `.magus-origin`), a skill a company plugin
47
+ * holds under the same name (`companySkills`), or a skill folder this
48
+ * project's profile installs.
49
+ * Sorted by name, project before USER for the same name.
50
+ */
51
+ export async function localItems(
52
+ cwd: string,
53
+ companySkills: ReadonlySet<string>,
54
+ companyPlugins: ReadonlySet<string> = new Set(),
55
+ ): Promise<LocalItem[]> {
56
+ const roots = [
57
+ { scope: "project" as const, dir: path.join(cwd, ".claude", "skills") },
58
+ { scope: "user" as const, dir: getUserSkillsDir() },
59
+ ];
60
+ const out: LocalItem[] = [];
61
+ const seen = new Set<string>();
62
+ for (const { scope, dir } of roots) {
63
+ let entries: fs.Dirent[];
64
+ try {
65
+ entries = fs.readdirSync(dir, { withFileTypes: true });
66
+ } catch {
67
+ continue;
68
+ }
69
+ for (const d of entries) {
70
+ if (!d.isDirectory()) continue;
71
+ const folder = path.join(dir, d.name);
72
+ if (seen.has(folder)) continue;
73
+ seen.add(folder);
74
+ if (isFile(path.join(folder, PLUGIN_JSON))) {
75
+ const name = pluginJsonName(folder) ?? d.name;
76
+ if (!companyPlugins.has(name)) {
77
+ out.push({ kind: "plugin", name, scope, path: folder });
78
+ }
79
+ continue;
80
+ }
81
+ if (!isFile(path.join(folder, "SKILL.md"))) continue;
82
+ if (fs.existsSync(path.join(folder, ORIGIN_FILE))) continue;
83
+ if (companySkills.has(d.name)) continue;
84
+ if (scope === "project" && (await profileManaged(cwd, d.name))) continue;
85
+ out.push({ kind: "skill", name: d.name, scope, path: folder });
86
+ }
87
+ }
88
+ return out.sort((a, b) =>
89
+ a.name === b.name
90
+ ? a.scope.localeCompare(b.scope)
91
+ : a.name.localeCompare(b.name),
92
+ );
93
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * market-marker.ts — the company marker in a marketplace manifest.
3
+ *
4
+ * A company marketplace says so in its own `.claude-plugin/marketplace.json`:
5
+ * `metadata.magus = {"schema": 1, "company": true}`. Claude Code ignores the
6
+ * field at load time; plain `claude plugin validate` reports it as a warning
7
+ * and still exits 0. `--strict` would fail on it, so magus never runs that.
8
+ *
9
+ * The marker alone does not make a marketplace company on this machine: the
10
+ * marketplace must also be on this machine's trust list, so a repository
11
+ * cannot promote itself by committing the field.
12
+ */
13
+
14
+ export const MARKER_SCHEMA = 1;
15
+
16
+ export interface MagusMarker {
17
+ schema: number;
18
+ company: boolean;
19
+ }
20
+
21
+ function isRecord(v: unknown): v is Record<string, unknown> {
22
+ return typeof v === "object" && v !== null && !Array.isArray(v);
23
+ }
24
+
25
+ /** True when the parsed manifest carries `metadata.magus.company === true`. */
26
+ export function readMarker(manifest: unknown): boolean {
27
+ if (!isRecord(manifest)) return false;
28
+ const metadata = manifest.metadata;
29
+ if (!isRecord(metadata)) return false;
30
+ const magus = metadata.magus;
31
+ return isRecord(magus) && magus.company === true;
32
+ }
33
+
34
+ /**
35
+ * The manifest with the marker set. Returns a copy; every other field, and
36
+ * every other key under `metadata`, is kept as it was.
37
+ */
38
+ export function withMarker<T extends Record<string, unknown>>(
39
+ manifest: T,
40
+ ): T & { metadata: Record<string, unknown> & { magus: MagusMarker } } {
41
+ const metadata = isRecord(manifest.metadata) ? manifest.metadata : {};
42
+ return {
43
+ ...manifest,
44
+ metadata: {
45
+ ...metadata,
46
+ magus: { schema: MARKER_SCHEMA, company: true },
47
+ },
48
+ };
49
+ }