magus-cli 7.13.0 → 7.14.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 (46) hide show
  1. package/README.md +7 -4
  2. package/package.json +4 -4
  3. package/src/__tests__/market-r2-adapters.test.ts +190 -0
  4. package/src/__tests__/market-r2-company-cli.test.ts +350 -0
  5. package/src/__tests__/market-r2-company-unit.test.ts +224 -0
  6. package/src/__tests__/market-r2-local-items.test.ts +197 -0
  7. package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
  8. package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
  9. package/src/__tests__/market-r2-routing.test.ts +127 -0
  10. package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
  11. package/src/__tests__/market-r2b-display-path.test.ts +180 -0
  12. package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
  13. package/src/__tests__/market-r2b-local-items.test.ts +253 -0
  14. package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
  15. package/src/__tests__/market-router.test.ts +4 -4
  16. package/src/__tests__/scope-uninstall-wrong-scope.test.ts +341 -0
  17. package/src/cli/market-view.ts +59 -18
  18. package/src/cli/market.ts +128 -44
  19. package/src/cli/router.ts +42 -10
  20. package/src/data/marketplaces.ts +1 -0
  21. package/src/main.tsx +1 -1
  22. package/src/market-tui.tsx +1 -1
  23. package/src/services/claude-cli.ts +13 -0
  24. package/src/services/local-marketplace.ts +8 -0
  25. package/src/services/market-change.ts +6 -6
  26. package/src/services/market-local.ts +93 -0
  27. package/src/services/market-ops-plugin.ts +366 -47
  28. package/src/services/market-ops-publish.ts +363 -15
  29. package/src/services/market-ops-repo.ts +94 -13
  30. package/src/services/market-ops-skill.ts +93 -11
  31. package/src/services/market-ops.ts +72 -8
  32. package/src/services/market-publish.ts +10 -1
  33. package/src/services/market-types.ts +23 -0
  34. package/src/services/update-engine.ts +97 -1
  35. package/src/tui-runtime.tsx +2 -2
  36. package/src/types/index.ts +2 -0
  37. package/src/ui/adapters/marketAdapter.ts +123 -4
  38. package/src/ui/adapters/pluginsAdapter.ts +25 -1
  39. package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
  40. package/src/ui/market/MarketplaceScreen.tsx +55 -24
  41. package/src/ui/market/MarketplacesDetail.snowflake.tsx +60 -4
  42. package/src/ui/market/MarketplacesScreen.tsx +120 -29
  43. package/src/ui/market/PluginDetail.snowflake.tsx +12 -1
  44. package/src/ui/market/marketKeymap.ts +29 -2
  45. package/src/ui/market/useMarketState.ts +146 -5
  46. package/src/utils/display-path.ts +33 -0
package/src/cli/market.ts CHANGED
@@ -7,17 +7,18 @@
7
7
  * only that plan. Under `--json` one MarketResult is printed, on the last
8
8
  * line of stdout, and nothing is asked.
9
9
  *
10
- * Bare `magus market` is the interactive screen; this module answers "tui"
11
- * for it and the router decides.
10
+ * Bare `magus market` prints the help; the interactive screen is `magus
11
+ * edit`.
12
12
  */
13
13
 
14
14
  import {
15
+ planPluginAdd,
15
16
  planPluginCreate,
16
17
  planPluginDelete,
17
18
  } from "../services/market-ops-plugin.js";
18
19
  import {
19
- type PublishArgs,
20
20
  marketStatus,
21
+ planPluginPublish,
21
22
  planPublish,
22
23
  provisionalPublish,
23
24
  } from "../services/market-ops-publish.js";
@@ -61,6 +62,7 @@ import { askYesNo, promptValue } from "./prompt.js";
61
62
  /** Flags that take a value, and flags that stand alone. */
62
63
  const VALUE_FLAGS = new Set([
63
64
  "--name",
65
+ "--company",
64
66
  "--description",
65
67
  "--plugin",
66
68
  "--from",
@@ -70,6 +72,7 @@ const BOOL_FLAGS = new Set([
70
72
  "--private",
71
73
  "--public",
72
74
  "--keep-local",
75
+ "--create-plugin",
73
76
  "--force",
74
77
  "--dry-run",
75
78
  "--json",
@@ -276,15 +279,15 @@ function warnBlock(text: string): string {
276
279
 
277
280
  /**
278
281
  * `publish --dry-run`: the plan, or the answer a publish would give, and
279
- * nothing written. On state older than GitHub's, a provisional plan with no
280
- * compare, still exit 0.
282
+ * nothing written. On state older than GitHub's, a provisional answer with
283
+ * no compare (`provisional`), still exit 0; without one, that error.
281
284
  */
282
285
  async function dryRunPublish(
283
- args: PublishArgs,
286
+ plan: () => Promise<PlanAnswer>,
284
287
  io: Io,
285
- cwd: string,
288
+ provisional?: (error: MarketError) => MarketResult,
286
289
  ): Promise<number> {
287
- const answer = await planPublish(args, cwd);
290
+ const answer = await plan();
288
291
  if (answer.kind === "plan") {
289
292
  const p = answer.plan;
290
293
  const result: MarketResult = {
@@ -307,8 +310,11 @@ async function dryRunPublish(
307
310
  emit(answer.result, io);
308
311
  return exitCodeFor(answer.result);
309
312
  }
310
- if (answer.error.code === "stale" || answer.error.code === "no-network") {
311
- emit(provisionalPublish(args, cwd, answer.error), io);
313
+ if (
314
+ provisional &&
315
+ (answer.error.code === "stale" || answer.error.code === "no-network")
316
+ ) {
317
+ emit(provisional(answer.error), io);
312
318
  return 0;
313
319
  }
314
320
  const result = errorResult(answer.command, answer.error);
@@ -316,15 +322,12 @@ async function dryRunPublish(
316
322
  return answer.error.exit ?? exitCodeFor(result);
317
323
  }
318
324
 
319
- /**
320
- * Run `magus market <argv>`. Returns the exit code, or "tui" for a bare
321
- * `magus market`.
322
- */
325
+ /** Run `magus market <argv>`. Returns the exit code. */
323
326
  export async function runMarketCommand(
324
327
  argv: string[],
325
328
  version: string,
326
329
  cwd: string = process.cwd(),
327
- ): Promise<number | "tui"> {
330
+ ): Promise<number> {
328
331
  if (argv.includes("--help") || argv.includes("-h")) {
329
332
  console.log(marketHelp());
330
333
  return 0;
@@ -333,7 +336,11 @@ export async function runMarketCommand(
333
336
  console.log(`magus v${version}`);
334
337
  return 0;
335
338
  }
336
- if (argv.length === 0) return "tui";
339
+ if (argv.length === 0) {
340
+ console.log(marketHelp());
341
+ console.log("Open the screen with `magus edit`.");
342
+ return 0;
343
+ }
337
344
 
338
345
  const json = argv.includes("--json");
339
346
  const io: Io = {
@@ -348,7 +355,7 @@ export async function runMarketCommand(
348
355
  switch (verb) {
349
356
  case "list": {
350
357
  expect(p, [], { min: 0, max: 0, usage: "magus market list [--json]" });
351
- const result = await marketList();
358
+ const result = await marketList(cwd);
352
359
  emit(result, io);
353
360
  return exitCodeFor(result);
354
361
  }
@@ -367,12 +374,16 @@ export async function runMarketCommand(
367
374
  }
368
375
  case "create": {
369
376
  const usage =
370
- "magus market create <owner>/<repo> --private|--public [--name <m>] [--description <t>]";
371
- expect(p, ["--private", "--public", "--name", "--description"], {
372
- min: 1,
373
- max: 1,
374
- usage,
375
- });
377
+ "magus market create <owner>/<repo> --private|--public [--name <m>] [--company <name>] [--description <t>]";
378
+ expect(
379
+ p,
380
+ ["--private", "--public", "--name", "--company", "--description"],
381
+ {
382
+ min: 1,
383
+ max: 1,
384
+ usage,
385
+ },
386
+ );
376
387
  const priv = p.flags.has("--private");
377
388
  const pub = p.flags.has("--public");
378
389
  if (priv === pub) {
@@ -386,6 +397,7 @@ export async function runMarketCommand(
386
397
  repo: p.positional[0] as string,
387
398
  visibility: priv ? "private" : "public",
388
399
  name: str(p, "--name"),
400
+ company: str(p, "--company"),
389
401
  description: str(p, "--description"),
390
402
  }),
391
403
  io,
@@ -394,15 +406,20 @@ export async function runMarketCommand(
394
406
  );
395
407
  }
396
408
  case "connect": {
397
- expect(p, ["--name"], {
409
+ expect(p, ["--name", "--company"], {
398
410
  min: 1,
399
411
  max: 1,
400
- usage: "magus market connect <owner/repo|git-url|path> [--name <m>]",
412
+ usage:
413
+ "magus market connect <owner/repo|git-url|path> [--name <m>] [--company <name>]",
401
414
  });
402
415
  return await runWrite(
403
416
  () =>
404
417
  planConnect(
405
- { source: p.positional[0] as string, name: str(p, "--name") },
418
+ {
419
+ source: p.positional[0] as string,
420
+ name: str(p, "--name"),
421
+ company: str(p, "--company"),
422
+ },
406
423
  cwd,
407
424
  ),
408
425
  io,
@@ -435,6 +452,27 @@ export async function runMarketCommand(
435
452
  cwd,
436
453
  );
437
454
  }
455
+ if (sub === "add") {
456
+ expect(sp, ["--to", "--name"], {
457
+ min: 1,
458
+ max: 1,
459
+ usage: "magus market plugin add <path> [--to <m>] [--name <p>]",
460
+ });
461
+ return await runWrite(
462
+ () =>
463
+ planPluginAdd(
464
+ {
465
+ path: ref as string,
466
+ to: str(p, "--to"),
467
+ name: str(p, "--name"),
468
+ },
469
+ cwd,
470
+ ),
471
+ io,
472
+ yes,
473
+ cwd,
474
+ );
475
+ }
438
476
  if (sub === "delete") {
439
477
  expect(sp, [], {
440
478
  min: 1,
@@ -448,22 +486,40 @@ export async function runMarketCommand(
448
486
  cwd,
449
487
  );
450
488
  }
451
- throw new UsageError("usage: magus market plugin create|delete <p@m>");
489
+ throw new UsageError(
490
+ "usage: magus market plugin create|delete <p@m>, or magus market plugin add <path>",
491
+ );
452
492
  }
453
493
  case "skill": {
454
494
  const [sub, skill, ...extra] = p.positional;
455
495
  const sp = { ...p, positional: skill ? [skill, ...extra] : [] };
456
496
  if (sub === "add") {
457
497
  const usage =
458
- "magus market skill add <skill> --plugin <p@m> [--from project|global] [--keep-local]";
459
- expect(sp, ["--plugin", "--from", "--keep-local"], {
460
- min: 1,
461
- max: 1,
462
- usage,
463
- });
498
+ "magus market skill add <skill> --plugin <p@m> [--create-plugin [--description <t>]] [--from project|global] [--keep-local]";
499
+ expect(
500
+ sp,
501
+ [
502
+ "--plugin",
503
+ "--from",
504
+ "--keep-local",
505
+ "--create-plugin",
506
+ "--description",
507
+ ],
508
+ {
509
+ min: 1,
510
+ max: 1,
511
+ usage,
512
+ },
513
+ );
464
514
  const plugin = str(p, "--plugin");
465
515
  if (!plugin)
466
516
  throw new UsageError(`--plugin is required — usage: ${usage}`);
517
+ const createPlugin = p.flags.has("--create-plugin");
518
+ if (p.flags.has("--description") && !createPlugin) {
519
+ throw new UsageError(
520
+ `--description describes a plugin --create-plugin creates — usage: ${usage}`,
521
+ );
522
+ }
467
523
  const from = choice(
468
524
  p,
469
525
  "--from",
@@ -478,6 +534,12 @@ export async function runMarketCommand(
478
534
  plugin,
479
535
  from,
480
536
  keepLocal: p.flags.has("--keep-local"),
537
+ ...(createPlugin
538
+ ? {
539
+ createPlugin: true,
540
+ description: str(p, "--description"),
541
+ }
542
+ : {}),
481
543
  },
482
544
  cwd,
483
545
  ),
@@ -538,25 +600,47 @@ export async function runMarketCommand(
538
600
  return exitCodeFor(result);
539
601
  }
540
602
  case "publish": {
603
+ const usage =
604
+ "magus market publish <skill> [--plugin <p@m>] [--from project|global] [--keep-local] [--force] [--dry-run], or magus market publish --plugin <p@m> [--keep-local] [--dry-run]";
541
605
  expect(
542
606
  p,
543
607
  ["--plugin", "--from", "--keep-local", "--force", "--dry-run"],
544
- {
545
- min: 1,
546
- max: 1,
547
- usage:
548
- "magus market publish <skill> [--plugin <p@m>] [--from project|global] [--keep-local] [--force] [--dry-run]",
549
- },
608
+ { min: 0, max: 1, usage },
550
609
  );
551
- const args: PublishArgs = {
552
- skill: p.positional[0] as string,
610
+ const skill = p.positional[0];
611
+ const keepLocal = p.flags.has("--keep-local");
612
+ if (skill === undefined) {
613
+ const plugin = str(p, "--plugin");
614
+ if (!plugin) {
615
+ throw new UsageError(
616
+ `name a skill, or a plugin with --plugin — usage: ${usage}`,
617
+ );
618
+ }
619
+ for (const flag of ["--from", "--force"]) {
620
+ if (p.flags.has(flag)) {
621
+ throw new UsageError(
622
+ `${flag} applies to one named skill: \`magus market publish <skill> ${flag} …\``,
623
+ );
624
+ }
625
+ }
626
+ const plan = () => planPluginPublish({ plugin, keepLocal }, cwd);
627
+ if (p.flags.has("--dry-run")) return await dryRunPublish(plan, io);
628
+ return await runWrite(plan, io, yes, cwd);
629
+ }
630
+ const args = {
631
+ skill,
553
632
  plugin: str(p, "--plugin"),
554
633
  from: choice(p, "--from", ["project", "global"] as const, "project"),
555
- keepLocal: p.flags.has("--keep-local"),
634
+ keepLocal,
556
635
  force: p.flags.has("--force"),
557
636
  };
558
- if (p.flags.has("--dry-run")) return await dryRunPublish(args, io, cwd);
559
- return await runWrite(() => planPublish(args, cwd), io, yes, cwd);
637
+ const plan = () => planPublish(args, cwd);
638
+ if (p.flags.has("--dry-run")) {
639
+ return await dryRunPublish(plan, io, (error) =>
640
+ provisionalPublish(args, cwd, error),
641
+ );
642
+ }
643
+ return await runWrite(plan, io, yes, cwd);
560
644
  }
561
645
  default:
562
646
  throw new UsageError(
package/src/cli/router.ts CHANGED
@@ -30,8 +30,8 @@ export interface RouteOutcome {
30
30
  theme?: ThemeMode;
31
31
  /**
32
32
  * Which interactive app to launch when `launchTui` is true: the dependency
33
- * TUI (bare `magus`, the default) or the company marketplace screens (bare
34
- * `magus market`).
33
+ * TUI (bare `magus`, the default) or the company marketplace screens
34
+ * (`magus edit`).
35
35
  */
36
36
  app?: "main" | "market";
37
37
  }
@@ -93,6 +93,21 @@ const HANDLED = (exitCode: number): RouteOutcome => ({
93
93
  });
94
94
  const LAUNCH_TUI: RouteOutcome = { handled: false, launchTui: true };
95
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
+
96
111
  /**
97
112
  * Every name the switch below handles.
98
113
  *
@@ -108,6 +123,7 @@ const SUBCOMMANDS = new Set([
108
123
  "models",
109
124
  "doctor",
110
125
  "market",
126
+ "edit",
111
127
  ]);
112
128
 
113
129
  /**
@@ -130,10 +146,10 @@ export async function route(
130
146
  themeEnv?: ThemeEnv,
131
147
  ): Promise<RouteOutcome> {
132
148
  // Top-level flags take precedence over any command token — for every
133
- // command but `market`, whose own verbs answer --help and --version (and
134
- // write nothing doing so). `magus upgrade --help` must still print help
135
- // rather than upgrade, so this exception is exactly argv[0] === "market".
136
- const marketFirst = argv[0] === "market";
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";
137
153
  if (!marketFirst && (argv.includes("--version") || argv.includes("-v"))) {
138
154
  console.log(`magus v${version}`);
139
155
  const result = await checkForUpdates();
@@ -229,8 +245,23 @@ export async function route(
229
245
  return HANDLED(await runDoctorCommand(rest));
230
246
  case "market": {
231
247
  const { runMarketCommand } = await import("./market.js");
232
- const outcome = await runMarketCommand(rest, version);
233
- if (outcome !== "tui") return HANDLED(outcome);
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
+ }
234
265
  return {
235
266
  handled: false,
236
267
  launchTui: true,
@@ -327,14 +358,15 @@ applied by a PreToolUse hook magus registers at user scope.
327
358
 
328
359
  Company marketplaces — your team's own Claude Code plugin marketplace on
329
360
  GitHub, listed first in magus. Every change prints its plan and asks first.
330
- market Open the company marketplace screens
361
+ edit Open the company marketplace screens
331
362
  market list Company marketplaces on this machine, with their plugins
332
363
  market create Create one on GitHub; market connect use an existing one
333
364
  market remove <m> Remove one from this machine (the repository stays)
334
- market plugin create|delete <p@m>
365
+ market plugin create|delete <p@m>; market plugin add <path>
335
366
  market skill add|edit|remove <skill>
336
367
  market status How your local copies of company skills stand
337
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
338
370
  market --help Every market command, its flags and exit codes
339
371
 
340
372
  Other commands:
@@ -250,6 +250,7 @@ export function getAllMarketplaces(
250
250
  deprecated: defaultMp?.deprecated,
251
251
  owned: defaultMp?.owned,
252
252
  ...(local.company ? { company: true } : {}),
253
+ ...(local.ownerName ? { ownerName: local.ownerName } : {}),
253
254
  });
254
255
  }
255
256
  }
package/src/main.tsx CHANGED
@@ -97,7 +97,7 @@ async function main(): Promise<void> {
97
97
  if (outcome.app === "market") {
98
98
  if (!isInteractive) {
99
99
  console.error(
100
- "magus market opens an interactive screen, which needs a terminal. `magus market --help` lists the commands that run without one.",
100
+ "magus edit opens an interactive screen, which needs a terminal. `magus market --help` lists the commands that run without one.",
101
101
  );
102
102
  process.exit(2);
103
103
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The interactive screens behind bare `magus market`: the company
2
+ * The interactive screens behind `magus edit`: the company
3
3
  * marketplaces on this machine, and the plugins and skills of one of them.
4
4
  *
5
5
  * Imported dynamically from `main.tsx`, like `tui.tsx`, so no other command
@@ -288,6 +288,19 @@ export function isNotInstalledAnswer(error: unknown): boolean {
288
288
  );
289
289
  }
290
290
 
291
+ /**
292
+ * The scope a wrong-scope answer says the plugin IS installed at — `user` from
293
+ * `is installed in user scope, not project` — or null when the answer names
294
+ * none, as the `not installed in <scope> scope` wording does.
295
+ */
296
+ export function installedScopeFromAnswer(error: unknown): PluginScope | null {
297
+ const message = error instanceof Error ? error.message : String(error);
298
+ const match = /is installed in (user|project|local) scope, not /i.exec(
299
+ message,
300
+ );
301
+ return match ? (match[1].toLowerCase() as PluginScope) : null;
302
+ }
303
+
291
304
  /**
292
305
  * Repair a plugin whose files drifted from the marketplace under an unchanged
293
306
  * version — uninstall, then install again at the same scope.
@@ -55,6 +55,8 @@ export interface LocalMarketplace {
55
55
  * and the name and source are on this machine's trust list.
56
56
  */
57
57
  company?: boolean;
58
+ /** `owner.name` from the manifest, trimmed; absent when empty or missing. */
59
+ ownerName?: string;
58
60
  }
59
61
 
60
62
  /** Claude Code's marketplace clones, resolved per call. */
@@ -322,6 +324,11 @@ async function scanSingleMarketplace(
322
324
  ? manifest.metadata.pluginRoot
323
325
  : undefined;
324
326
 
327
+ const ownerName: string | undefined =
328
+ typeof manifest.owner?.name === "string" && manifest.owner.name.trim()
329
+ ? manifest.owner.name.trim()
330
+ : undefined;
331
+
325
332
  const plugins: LocalMarketplacePlugin[] = [];
326
333
 
327
334
  if (manifest.plugins && Array.isArray(manifest.plugins)) {
@@ -398,6 +405,7 @@ async function scanSingleMarketplace(
398
405
  company:
399
406
  readMarker(manifest) &&
400
407
  isTrustedIn(trusted, marketplaceName, remoteSource),
408
+ ...(ownerName ? { ownerName } : {}),
401
409
  };
402
410
  } catch {
403
411
  return null;
@@ -140,8 +140,8 @@ export interface ChangeSpec {
140
140
  /** Names a pull-request branch. */
141
141
  slug: string;
142
142
  prBody: string;
143
- /** A skill directory whose committed files must equal these. */
144
- verify?: { dir: string; files: readonly SkillFile[] };
143
+ /** Directories whose committed files must equal these, each. */
144
+ verify?: ReadonlyArray<{ dir: string; files: readonly SkillFile[] }>;
145
145
  /** Set when the commit is made, for later steps. */
146
146
  state: { commit?: string; pushed?: boolean };
147
147
  }
@@ -186,15 +186,15 @@ export function changeSteps(spec: ChangeSpec): MarketStep[] {
186
186
  }
187
187
  const made = await gitCommit(dir, spec.message);
188
188
  if (!made.ok) throw gitError(made, spec.label);
189
- if (spec.verify) {
190
- const tree = await lsTree(dir, made.value, spec.verify.dir);
189
+ for (const check of spec.verify ?? []) {
190
+ const tree = await lsTree(dir, made.value, check.dir);
191
191
  if (!tree.ok) throw gitError(tree, spec.label);
192
- const prefix = `${spec.verify.dir}/`;
192
+ const prefix = `${check.dir}/`;
193
193
  const committed = tree.value.map((e) => ({
194
194
  path: e.path.slice(prefix.length),
195
195
  oid: e.oid,
196
196
  }));
197
- if (!sameFiles(committed, spec.verify.files)) {
197
+ if (!sameFiles(committed, check.files)) {
198
198
  throw new MarketError(
199
199
  "other",
200
200
  "the commit did not hold exactly the planned files — nothing was pushed",
@@ -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
+ }