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
@@ -160,7 +160,7 @@ async function recoverMissingMarketplace(
160
160
  const defaultMp = defaultMarketplaces.find((m) => m.name === marketplace);
161
161
  if (!defaultMp?.source.repo) return false;
162
162
 
163
- await addMarketplace(defaultMp.source.repo);
163
+ await addMarketplace(defaultMp.source.repo, "user");
164
164
  return true;
165
165
  }
166
166
 
@@ -180,7 +180,7 @@ async function execPluginCommand(
180
180
  ): Promise<void> {
181
181
  const argv = ["plugin", verb, pluginId, "--scope", scope];
182
182
  try {
183
- await execClaude(argv, timeoutMs);
183
+ await runPluginVerb(verb, pluginId, scope, undefined, timeoutMs);
184
184
  } catch (error) {
185
185
  const msg = error instanceof Error ? error.message : String(error);
186
186
  if (msg.includes("not found in marketplace")) {
@@ -387,19 +387,149 @@ export async function updatePlugin(
387
387
  }
388
388
 
389
389
  /**
390
- * Add a marketplace by GitHub repo (e.g., "MadAppGang/magus")
391
- * Uses longer timeout since this involves cloning a git repo
390
+ * Register a marketplace: `claude plugin marketplace add <source> --scope
391
+ * <scope>`. `source` is anything Claude Code accepts — `owner/repo`, an https
392
+ * git URL, or a path to a marketplace directory. The scope is required so no
393
+ * caller declares one by accident. Longer timeout: this clones.
394
+ *
395
+ * There is deliberately no wrapper for `claude plugin marketplace update`: it
396
+ * deletes the clone before re-cloning and can leave it gone for good. A clone
397
+ * is refreshed with `fastForwardCloneIfPresent` instead.
398
+ */
399
+ export async function addMarketplace(
400
+ source: string,
401
+ scope: PluginScope,
402
+ ): Promise<void> {
403
+ await execClaude(
404
+ ["plugin", "marketplace", "add", source, "--scope", scope],
405
+ 60000,
406
+ );
407
+ }
408
+
409
+ /**
410
+ * Remove a marketplace from every settings scope: `claude plugin marketplace
411
+ * remove <name>`. Claude Code uninstalls every plugin it provided.
392
412
  */
393
- export async function addMarketplace(repo: string): Promise<void> {
394
- await execClaude(["plugin", "marketplace", "add", repo], 60000);
413
+ export async function removeMarketplace(name: string): Promise<void> {
414
+ await execClaude(["plugin", "marketplace", "remove", name], 60000);
395
415
  }
396
416
 
397
417
  /**
398
- * Update marketplace cache by running git pull via Claude CLI
399
- * Uses longer timeout since this involves git network operations
418
+ * Run `claude plugin install|update <id> --scope <scope>` and nothing else.
419
+ *
420
+ * No recovery on "not found in marketplace": the recovery behind
421
+ * {@link installPlugin} and {@link updatePlugin} edits
422
+ * `known_marketplaces.json` directly, so a caller that must change Claude
423
+ * Code's state only through its CLI runs this and reports the error.
424
+ *
425
+ * `cwd` is where Claude Code resolves project and local scope; it defaults to
426
+ * this process's working directory.
400
427
  */
401
- export async function updateMarketplace(name: string): Promise<void> {
402
- await execClaude(["plugin", "marketplace", "update", name], 60000);
428
+ export async function runPluginVerb(
429
+ verb: "install" | "update",
430
+ pluginId: string,
431
+ scope: PluginScope,
432
+ cwd?: string,
433
+ timeoutMs = 60000,
434
+ ): Promise<void> {
435
+ await execClaude(
436
+ ["plugin", verb, pluginId, "--scope", scope],
437
+ timeoutMs,
438
+ cwd,
439
+ );
440
+ }
441
+
442
+ /** One row of `claude plugin marketplace list --json`. */
443
+ export interface CliMarketplace {
444
+ name: string;
445
+ source: string;
446
+ repo?: string;
447
+ url?: string;
448
+ path?: string;
449
+ installLocation?: string;
450
+ }
451
+
452
+ /** `claude plugin marketplace list --json`. */
453
+ export async function listMarketplaces(): Promise<CliMarketplace[]> {
454
+ const out = await execClaude(["plugin", "marketplace", "list", "--json"]);
455
+ return parseJsonArray<CliMarketplace>(out, "marketplace list").filter(
456
+ (m) => typeof m?.name === "string",
457
+ );
458
+ }
459
+
460
+ /** One row of `claude plugin list --json`. */
461
+ export interface CliPlugin {
462
+ id: string;
463
+ version?: string;
464
+ scope: PluginScope;
465
+ enabled?: boolean;
466
+ installPath?: string;
467
+ projectPath?: string;
468
+ }
469
+
470
+ /** `claude plugin list --json`, run in `cwd` when given. */
471
+ export async function listPlugins(cwd?: string): Promise<CliPlugin[]> {
472
+ const out = await execClaude(["plugin", "list", "--json"], 30000, cwd);
473
+ return parseJsonArray<CliPlugin>(out, "plugin list").filter(
474
+ (p) => typeof p?.id === "string",
475
+ );
476
+ }
477
+
478
+ function parseJsonArray<T>(out: string, what: string): T[] {
479
+ let parsed: unknown;
480
+ try {
481
+ parsed = JSON.parse(out || "[]");
482
+ } catch (error) {
483
+ throw new Error(
484
+ `claude ${what} --json printed something that is not JSON`,
485
+ { cause: error },
486
+ );
487
+ }
488
+ if (!Array.isArray(parsed)) {
489
+ throw new Error(`claude ${what} --json did not print a list`);
490
+ }
491
+ return parsed as T[];
492
+ }
493
+
494
+ /** What `claude plugin validate` said about a directory. */
495
+ export interface ValidateResult {
496
+ ok: boolean;
497
+ output: string;
498
+ }
499
+
500
+ /**
501
+ * `claude plugin validate <dir>`, plain — never `--strict`, which fails on the
502
+ * company marker and on a marketplace with no plugins yet. The exit code is
503
+ * the gate; the output is kept for the report either way.
504
+ */
505
+ export async function validatePath(dir: string): Promise<ValidateResult> {
506
+ try {
507
+ const output = await execClaude(["plugin", "validate", dir], 60000);
508
+ return { ok: true, output };
509
+ } catch (error) {
510
+ return {
511
+ ok: false,
512
+ output: error instanceof Error ? error.message : String(error),
513
+ };
514
+ }
515
+ }
516
+
517
+ /**
518
+ * The next step when `claude plugin marketplace add` failed on GitHub
519
+ * credentials, or null when it failed for another reason. Claude Code clones
520
+ * with plain git, so a gh sign-in alone is not enough: git must also use gh's
521
+ * credentials, which `gh auth setup-git` arranges.
522
+ */
523
+ export function marketplaceAddHint(error: unknown): string | null {
524
+ const message = error instanceof Error ? error.message : String(error);
525
+ if (
526
+ /terminal prompts disabled|could not read (Username|Password)|Authentication failed|Invalid username or password|Permission denied \(publickey|Repository not found|returned error: 40[13]/i.test(
527
+ message,
528
+ )
529
+ ) {
530
+ return "Run `gh auth login`, then `gh auth setup-git`, and try again.";
531
+ }
532
+ return null;
403
533
  }
404
534
 
405
535
  /**
@@ -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 semver from "semver";
@@ -11,6 +10,7 @@ import type {
11
10
  MarketplaceSource,
12
11
  McpServerConfig,
13
12
  } from "../types/index.js";
13
+ import { requireClaudeConfigDir } from "../utils/config-dir.js";
14
14
  import { parsePluginId } from "../utils/string-utils.js";
15
15
  import { repoHeadSha } from "./content-drift.js";
16
16
  import { inheritablePaths } from "./git-worktree.js";
@@ -61,16 +61,14 @@ export function getClaudeDir(projectPath?: string): string {
61
61
  }
62
62
 
63
63
  /**
64
- * `~/.claude`, resolved per call.
65
- *
66
- * `HOME` first, then `os.homedir()` — Node's own rule for `os.homedir()`, done
67
- * by hand because Bun does not follow it: under Bun 1.4 `os.homedir()` answers
68
- * the home the process started with and ignores a later `process.env.HOME`
69
- * (measured; Node follows the change). Reading `HOME` at call time is what lets
70
- * a caller — a test above all — point the global settings somewhere else.
64
+ * Claude Code's USER config directory, resolved per call through
65
+ * {@link requireClaudeConfigDir}: `CLAUDE_CONFIG_DIR` when set, else
66
+ * `<HOME>/.claude` with `HOME` read at call time. Claude Code keeps USER
67
+ * `settings.json` there as well as its plugin state, so both come from one
68
+ * root and a caller — a test above all — moves them together.
71
69
  */
72
70
  export function getGlobalClaudeDir(): string {
73
- return path.join(process.env.HOME || os.homedir(), CLAUDE_DIR);
71
+ return requireClaudeConfigDir();
74
72
  }
75
73
 
76
74
  export async function ensureClaudeDir(projectPath?: string): Promise<string> {
@@ -1070,13 +1068,13 @@ export async function discoverAllGlobalMarketplaces(): Promise<
1070
1068
  * path it writes still names the operator's real home.
1071
1069
  */
1072
1070
  export const pluginsDir = (): string =>
1073
- path.join(os.homedir(), ".claude", "plugins");
1071
+ path.join(requireClaudeConfigDir(), "plugins");
1074
1072
 
1075
1073
  const installedPluginsFile = (root: string = pluginsDir()): string =>
1076
1074
  path.join(root, "installed_plugins.json");
1077
1075
 
1078
1076
  const knownMarketplacesFile = (): string =>
1079
- path.join(os.homedir(), ".claude", "plugins", "known_marketplaces.json");
1077
+ path.join(pluginsDir(), "known_marketplaces.json");
1080
1078
 
1081
1079
  interface KnownMarketplaceEntry {
1082
1080
  source: { source: string; path?: string; repo?: string };
@@ -1160,12 +1158,7 @@ export async function recoverMarketplaceSettings(): Promise<MarketplaceRecoveryR
1160
1158
  if (entry.source?.source === "directory") {
1161
1159
  const defaultMp = defaultMarketplaces.find((m) => m.name === name);
1162
1160
  if (defaultMp?.source.repo) {
1163
- const marketplacesDir = path.join(
1164
- os.homedir(),
1165
- ".claude",
1166
- "plugins",
1167
- "marketplaces",
1168
- );
1161
+ const marketplacesDir = path.join(pluginsDir(), "marketplaces");
1169
1162
  entry.source = {
1170
1163
  source: "github",
1171
1164
  repo: defaultMp.source.repo,
@@ -1209,14 +1202,15 @@ async function readKnownMarketplaces(): Promise<KnownMarketplaces> {
1209
1202
  */
1210
1203
  export async function isMarketplaceRegistered(name: string): Promise<boolean> {
1211
1204
  const known = await readKnownMarketplaces();
1212
- if (!known[name]) return false;
1213
- const marketplacesDir = path.join(
1214
- os.homedir(),
1215
- ".claude",
1216
- "plugins",
1217
- "marketplaces",
1218
- );
1219
- return fs.pathExists(path.join(marketplacesDir, name));
1205
+ const entry = known[name];
1206
+ if (!entry) return false;
1207
+ // A `directory` marketplace is read where it is: it has no clone under
1208
+ // plugins/marketplaces, and its own folder is what must exist.
1209
+ const where =
1210
+ entry.source.source === "directory" && entry.source.path
1211
+ ? entry.source.path
1212
+ : path.join(pluginsDir(), "marketplaces", name);
1213
+ return fs.pathExists(where);
1220
1214
  }
1221
1215
 
1222
1216
  /**
@@ -15,6 +15,7 @@
15
15
  import { existsSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
  import type { PluginConventions } from "../types/index.js";
18
+ import { requireClaudeConfigDir } from "../utils/config-dir.js";
18
19
  import { parsePluginId } from "../utils/string-utils.js";
19
20
  import { readInstalledPluginsRegistry } from "./claude-settings.js";
20
21
  import {
@@ -70,10 +71,9 @@ export type DoctorLogger = (
70
71
  // INTERNAL HELPERS
71
72
  // ============================================================
72
73
 
73
- import { homedir } from "node:os";
74
-
75
- /** Path constants matching plugin-manager.ts */
76
- const MARKETPLACES_DIR = join(homedir(), ".claude", "plugins", "marketplaces");
74
+ /** Claude Code's marketplace clones, resolved per call. */
75
+ const marketplacesDir = (): string =>
76
+ join(requireClaudeConfigDir(), "plugins", "marketplaces");
77
77
 
78
78
  /**
79
79
  * Resolve a plugin ID to its filesystem source path.
@@ -92,7 +92,7 @@ async function resolvePluginPath(pluginId: string): Promise<string | null> {
92
92
  if (!localPlugin?.source || typeof localPlugin.source !== "string")
93
93
  return null;
94
94
 
95
- const marketplacePath = join(MARKETPLACES_DIR, mpName);
95
+ const marketplacePath = join(marketplacesDir(), mpName);
96
96
  const pluginPath = join(
97
97
  marketplacePath,
98
98
  localPlugin.source.replace("./", ""),