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
@@ -132,7 +132,9 @@ function categoryDetail(
132
132
  <text fg={isEnabled ? theme.colors.success : theme.colors.muted}>
133
133
  {isEnabled ? "● Added" : "○ Not added"}
134
134
  </text>
135
- <text fg={theme.colors.info}>github.com/{mp.source.repo}</text>
135
+ {mp.source.repo ? (
136
+ <text fg={theme.colors.info}>github.com/{mp.source.repo}</text>
137
+ ) : null}
136
138
  <text fg={theme.colors.text}>Plugins: {item.pluginCount}</text>
137
139
  <ActionHints
138
140
  hints={[
@@ -328,7 +330,12 @@ function StatusLine({
328
330
  ? `→ v${status.target}`
329
331
  : "";
330
332
  // Moved or still required: kept on purpose, which is known — never UNKNOWN.
331
- const label = isKeptDeprecated(status) ? "KEPT" : ACTION_LABEL[status.action];
333
+ // Left out of updates for its marketplace: known too.
334
+ const label = isKeptDeprecated(status)
335
+ ? "KEPT"
336
+ : status.reason === "marketplace-conflict"
337
+ ? "LEFT OUT"
338
+ : ACTION_LABEL[status.action];
332
339
  return (
333
340
  <KeyValueLine
334
341
  label="Status"
@@ -571,7 +578,28 @@ function pluginDetail(
571
578
 
572
579
  {/* Metadata */}
573
580
  <StatusLine status={status} />
574
- {showVersion ? (
581
+ {/*
582
+ * Left out: the version this catalog offers is the other repository's,
583
+ * so it is not shown as one to install. What is installed here, and
584
+ * that updates leave it alone, is.
585
+ */}
586
+ {status?.reason === "marketplace-conflict" ? (
587
+ <KeyValueLine
588
+ label="Version"
589
+ value={
590
+ <span>
591
+ {status.installed ? (
592
+ <span fg={theme.colors.info}>
593
+ v{status.installed} installed
594
+ </span>
595
+ ) : (
596
+ <span fg={theme.colors.muted}>not installed</span>
597
+ )}
598
+ <span fg={theme.colors.warning}> · left out of updates</span>
599
+ </span>
600
+ }
601
+ />
602
+ ) : showVersion ? (
575
603
  <KeyValueLine
576
604
  label="Version"
577
605
  value={
@@ -40,6 +40,12 @@ export function pluginStatusText(
40
40
  };
41
41
  }
42
42
 
43
+ // Left out of every update: its marketplace here is not the profile's. A
44
+ // fact that needs a look, so amber, and never the "?" of "cannot tell".
45
+ if (status.reason === "marketplace-conflict") {
46
+ return { suffix: ` left out — ${status.note ?? ""}`, tone: "warning" };
47
+ }
48
+
43
49
  switch (status.action) {
44
50
  case "unknown":
45
51
  // Amber, not red: "cannot tell" is not a failure.
@@ -12,6 +12,12 @@ import {
12
12
  } from "../../services/claude-cli.js";
13
13
  import { readSettings, setMcpEnvVar } from "../../services/claude-settings.js";
14
14
  import { clearContentDriftCache } from "../../services/content-drift.js";
15
+ import {
16
+ type MarketplaceCheck,
17
+ NOTHING_LEFT_OUT,
18
+ leftOutWhy,
19
+ } from "../../services/marketplace-conflicts.js";
20
+ import { conflictFix } from "../../services/marketplace-sync.js";
15
21
  import { missingPluginEnvVars } from "../../services/plugin-env-needs.js";
16
22
  import {
17
23
  type MarketplaceFetchFailure,
@@ -179,6 +185,13 @@ export function PluginsScreen() {
179
185
  const [listedByProfiles, setListedByProfiles] = useState<readonly string[]>(
180
186
  [],
181
187
  );
188
+ /**
189
+ * The profile's marketplaces whose plugins `magus update` leaves out here,
190
+ * found without changing anything: their rows say so, and no update or
191
+ * install is planned for them.
192
+ */
193
+ const [marketplaceCheck, setMarketplaceCheck] =
194
+ useState<MarketplaceCheck>(NOTHING_LEFT_OUT);
182
195
 
183
196
  // Fetch data (always fetches all scopes). Only the most recently started
184
197
  // load may touch the screen.
@@ -213,6 +226,7 @@ export function PluginsScreen() {
213
226
  setActiveProfileId(active?.profileId ?? null);
214
227
  setRequiredBy(required);
215
228
  setListedByProfiles(active?.listedByProfiles ?? []);
229
+ setMarketplaceCheck(active?.marketplaces ?? NOTHING_LEFT_OUT);
216
230
 
217
231
  // Which catalogs answered, and which only appeared to. Read straight
218
232
  // after the fetch, while the record still describes this attempt.
@@ -309,9 +323,10 @@ export function PluginsScreen() {
309
323
  pins ?? {},
310
324
  requiredBy,
311
325
  listedByProfiles,
326
+ (pluginId) => leftOutWhy(pluginId, marketplaceCheck),
312
327
  )
313
328
  : new Map(),
314
- [shownPlugins, pins, requiredBy, listedByProfiles],
329
+ [shownPlugins, pins, requiredBy, listedByProfiles, marketplaceCheck],
315
330
  );
316
331
 
317
332
  /**
@@ -754,7 +769,7 @@ export function PluginsScreen() {
754
769
  if (choice === null) return;
755
770
  if (choice) {
756
771
  try {
757
- await cliAddMarketplace(choice);
772
+ await cliAddMarketplace(choice, "user");
758
773
  await handleRefresh();
759
774
  } catch (e) {
760
775
  await modal.failures(addMarketplaceFailure(choice, e));
@@ -909,6 +924,25 @@ export function PluginsScreen() {
909
924
  }
910
925
  };
911
926
 
927
+ /**
928
+ * A row `magus update` leaves out — its marketplace here is not the one the
929
+ * profile names, or could not be compared — takes no install, update,
930
+ * removal or scope change from any key: what this machine would install is
931
+ * the other repository's plugin. The one popup says so; no claude command
932
+ * runs. True when the key was refused.
933
+ */
934
+ const refuseLeftOut = async (plugin: PluginInfo): Promise<boolean> => {
935
+ const status = statuses.get(plugin.id);
936
+ if (status?.reason !== "marketplace-conflict") return false;
937
+ const conflicted = marketplaceCheck.conflicted.has(plugin.marketplace);
938
+ await modal.failures({
939
+ headline: `${plugin.name} is left out — nothing was changed`,
940
+ rows: [{ subject: plugin.name, reason: status.note ?? "left out" }],
941
+ fix: conflicted ? conflictFix(plugin.marketplace) : null,
942
+ });
943
+ return true;
944
+ };
945
+
912
946
  const handleSelect = async () => {
913
947
  const item = selectableItems[pluginsState.selectedIndex];
914
948
  if (!item) return;
@@ -940,7 +974,7 @@ export function PluginsScreen() {
940
974
  );
941
975
  if (wantAdd) {
942
976
  try {
943
- await cliAddMarketplace(repo);
977
+ await cliAddMarketplace(repo, "user");
944
978
  await handleRefresh();
945
979
  } catch (e) {
946
980
  await modal.failures(addMarketplaceFailure(repo, e));
@@ -949,6 +983,7 @@ export function PluginsScreen() {
949
983
  }
950
984
  } else if (item.kind === "plugin") {
951
985
  const plugin = item.plugin;
986
+ if (await refuseLeftOut(plugin)) return;
952
987
  const status = statuses.get(plugin.id);
953
988
 
954
989
  // The label's arrow and what the key then does are ONE rule —
@@ -1088,6 +1123,7 @@ export function PluginsScreen() {
1088
1123
  const handleUpdate = async () => {
1089
1124
  const item = selectableItems[pluginsState.selectedIndex];
1090
1125
  if (!item || item.kind !== "plugin") return;
1126
+ if (await refuseLeftOut(item.plugin)) return;
1091
1127
  // The row said "update", "stale — reinstall" or "BROKEN"; nothing else is
1092
1128
  // work for this key. `install` stays out — that is the scope keys' and the
1093
1129
  // profile's business, and the detail panel never offers U for it.
@@ -1180,6 +1216,7 @@ export function PluginsScreen() {
1180
1216
  const handleScopeToggle = async (scope: "user" | "project" | "local") => {
1181
1217
  const item = selectableItems[pluginsState.selectedIndex];
1182
1218
  if (!item || item.kind !== "plugin") return;
1219
+ if (await refuseLeftOut(item.plugin)) return;
1183
1220
 
1184
1221
  const plugin = item.plugin;
1185
1222
  const scopeLabel =
@@ -1253,6 +1290,7 @@ export function PluginsScreen() {
1253
1290
  const handleRemoveDeprecated = async () => {
1254
1291
  const item = selectableItems[pluginsState.selectedIndex];
1255
1292
  if (!item || item.kind !== "plugin" || !item.plugin.isOrphaned) return;
1293
+ if (await refuseLeftOut(item.plugin)) return;
1256
1294
 
1257
1295
  const plugin = item.plugin;
1258
1296
 
@@ -1425,8 +1463,14 @@ export function PluginsScreen() {
1425
1463
  // "cannot tell rendered as fine" bug.
1426
1464
  // Moved or still-required plugins are `unknown` to the planner but known to
1427
1465
  // the user — kept on purpose — so they are not counted as "unverified".
1466
+ // Left out for their marketplace: known too, and counted in their own words.
1467
+ const leftOutCount = [...statuses.values()].filter(
1468
+ (s) => s.reason === "marketplace-conflict",
1469
+ ).length;
1428
1470
  const unknownCount =
1429
- counts.unknown - [...statuses.values()].filter(isKeptDeprecated).length;
1471
+ counts.unknown -
1472
+ [...statuses.values()].filter(isKeptDeprecated).length -
1473
+ leftOutCount;
1430
1474
  const installCount = counts.install;
1431
1475
  // Broken: installed at the right version, loading an older one. Its own
1432
1476
  // number, never folded into `updateCount` or into silence — a clean header
@@ -1439,7 +1483,7 @@ export function PluginsScreen() {
1439
1483
  // No longer published, and `a` removes them. Counted where the rows can be
1440
1484
  // seen to need work; they used to sit inside "unverified".
1441
1485
  const deprecatedCount = counts.remove;
1442
- const subtitle = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} updates` : ""}${installCount > 0 ? ` │ ${installCount} to install` : ""}${brokenCount > 0 ? ` │ ${brokenCount} broken` : ""}${deprecatedCount > 0 ? ` │ ${deprecatedCount} deprecated` : ""}${unknownCount > 0 ? ` │ ${unknownCount} unverified` : ""}`;
1486
+ const subtitle = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} updates` : ""}${installCount > 0 ? ` │ ${installCount} to install` : ""}${brokenCount > 0 ? ` │ ${brokenCount} broken` : ""}${deprecatedCount > 0 ? ` │ ${deprecatedCount} deprecated` : ""}${leftOutCount > 0 ? ` │ ${leftOutCount} left out` : ""}${unknownCount > 0 ? ` │ ${unknownCount} unverified` : ""}`;
1443
1487
  const searchPlaceholder = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} ⬆` : ""}${installCount > 0 ? ` │ ${installCount} +` : ""}${brokenCount + deprecatedCount > 0 ? ` │ ${brokenCount + deprecatedCount} ✗` : ""}${unknownCount > 0 ? ` │ ${unknownCount} ?` : ""} │ / to search`;
1444
1488
 
1445
1489
  const catalogNotice = buildCatalogNotice({
@@ -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 React, {
@@ -27,6 +26,7 @@ import {
27
26
  fetchAvailableSkills,
28
27
  fetchSkillFrontmatter,
29
28
  fetchSkillSetSkills,
29
+ getUserSkillsDir,
30
30
  installSkill,
31
31
  readLocalSkillFrontmatter,
32
32
  uninstallSkill,
@@ -213,7 +213,7 @@ export function SkillsScreen() {
213
213
  const user = new Map<string, SkillFrontmatter | null>();
214
214
  const project = new Map<string, SkillFrontmatter | null>();
215
215
  const unreadable: FailureRow[] = [];
216
- const userDir = path.join(os.homedir(), ".claude", "skills");
216
+ const userDir = getUserSkillsDir();
217
217
  const projDir = path.join(
218
218
  state.projectPath || process.cwd(),
219
219
  ".claude",
@@ -6,6 +6,7 @@ import {
6
6
  useContext,
7
7
  useReducer,
8
8
  } from "react";
9
+ import type { HelpSection } from "../keymap.js";
9
10
  import { appReducer, initialState } from "./reducer.js";
10
11
  import type {
11
12
  AppAction,
@@ -15,6 +16,7 @@ import type {
15
16
  ProgressState,
16
17
  Route,
17
18
  Screen,
19
+ SelectOption,
18
20
  } from "./types.js";
19
21
 
20
22
  // ============================================================================
@@ -144,7 +146,11 @@ export function useModal() {
144
146
  dispatch({ type: "HIDE_MODAL" });
145
147
  },
146
148
 
147
- confirm: (title: string, message: string): Promise<boolean> => {
149
+ confirm: (
150
+ title: string,
151
+ message: string,
152
+ details?: readonly string[],
153
+ ): Promise<boolean> => {
148
154
  return new Promise((resolve) => {
149
155
  dispatch({
150
156
  type: "SHOW_MODAL",
@@ -152,6 +158,7 @@ export function useModal() {
152
158
  type: "confirm",
153
159
  title,
154
160
  message,
161
+ ...(details ? { details } : {}),
155
162
  onConfirm: () => {
156
163
  dispatch({ type: "HIDE_MODAL" });
157
164
  resolve(true);
@@ -194,7 +201,7 @@ export function useModal() {
194
201
  select: (
195
202
  title: string,
196
203
  message: string,
197
- options: { label: string; value: string; description?: string }[],
204
+ options: SelectOption[],
198
205
  defaultIndex?: number,
199
206
  ): Promise<string | null> => {
200
207
  return new Promise((resolve) => {
@@ -275,6 +282,11 @@ export function useModal() {
275
282
  help: (screen: Screen) => {
276
283
  dispatch({ type: "SHOW_MODAL", modal: { type: "help", screen } });
277
284
  },
285
+
286
+ /** The key help overlay of an app with its own keymap: exactly `sections`. */
287
+ helpSections: (sections: readonly HelpSection[]) => {
288
+ dispatch({ type: "SHOW_MODAL", modal: { type: "help", sections } });
289
+ },
278
290
  };
279
291
  }
280
292
 
@@ -12,6 +12,7 @@ import type {
12
12
  ProfileEntry,
13
13
  SkillInfo,
14
14
  } from "../../types/index.js";
15
+ import type { HelpSection } from "../keymap.js";
15
16
  import type { ReportBadgeKind } from "../theme-mode.js";
16
17
 
17
18
  // ============================================================================
@@ -120,6 +121,8 @@ export type ModalState =
120
121
  type: "confirm";
121
122
  title: string;
122
123
  message: string;
124
+ /** What confirming does, one line each — a plan, drawn as a list. */
125
+ details?: readonly string[];
123
126
  onConfirm: () => void;
124
127
  onCancel: () => void;
125
128
  }
@@ -173,9 +176,13 @@ export type ModalState =
173
176
  message: string;
174
177
  }
175
178
  | {
176
- /** The key help overlay, opened with `?`, listing `screen` first. */
179
+ /**
180
+ * The key help overlay, opened with `?`: `screen` first, from the main
181
+ * keymap — or, in an app with its own keymap, exactly `sections`.
182
+ */
177
183
  type: "help";
178
- screen: Screen;
184
+ screen?: Screen;
185
+ sections?: readonly HelpSection[];
179
186
  };
180
187
 
181
188
  // ============================================================================
@@ -1,15 +1,26 @@
1
1
  /**
2
2
  * config-dir.ts — where Claude Code's config lives, with a test guard.
3
3
  *
4
- * `CLAUDE_CONFIG_DIR` is Claude Code's own override and must be honoured. It is
5
- * resolved per call rather than captured at import time, because a module-level
6
- * `os.homedir()` bakes in the operator's real directory and cannot be overridden
7
- * afterwards.
4
+ * `CLAUDE_CONFIG_DIR` is Claude Code's own override and must be honoured: Claude
5
+ * Code keeps everything under it — `settings.json`, `skills/`, and `plugins/`
6
+ * with the registry, the marketplace clones and the cache. Reading any of those
7
+ * from one root and the rest from another makes installed flags and installed
8
+ * versions disagree, so every reader resolves through this file.
9
+ *
10
+ * Resolved per call rather than captured at import time, because a
11
+ * module-level path bakes in the operator's real directory and cannot be
12
+ * overridden afterwards.
13
+ *
14
+ * Without `CLAUDE_CONFIG_DIR` the root is `<HOME>/.claude`, with `HOME` read at
15
+ * call time and `os.homedir()` only as the fallback: under Bun `os.homedir()`
16
+ * answers the home the process started with and ignores a later
17
+ * `process.env.HOME` (measured on Bun 1.4; Node follows the change).
8
18
  *
9
19
  * The test guard
10
20
  * --------------
11
- * Under `bun test`, a missing `CLAUDE_CONFIG_DIR` returns null instead of
12
- * `~/.claude`. Callers treat null as "no cache, no clone" — an empty, inert state.
21
+ * Under `bun test`, a missing `CLAUDE_CONFIG_DIR` makes `claudeConfigDirOrNull`
22
+ * return null instead of `~/.claude`. Callers treat null as "no cache, no
23
+ * clone" — an empty, inert state.
13
24
  *
14
25
  * This is not defensive decoration. Once the catalog cache and the rate-limit
15
26
  * cooldown moved to disk, and the catalog resolver gained a `git fetch` fallback,
@@ -25,6 +36,11 @@
25
36
  import os from "node:os";
26
37
  import path from "node:path";
27
38
 
39
+ /** `HOME` at call time, else the home the process started with. */
40
+ function homeDir(): string {
41
+ return process.env.HOME || os.homedir();
42
+ }
43
+
28
44
  /**
29
45
  * The Claude config directory, or null when a test has not chosen one.
30
46
  *
@@ -36,10 +52,14 @@ export function claudeConfigDirOrNull(): string | null {
36
52
  const explicit = process.env.CLAUDE_CONFIG_DIR;
37
53
  if (explicit) return explicit;
38
54
  if (process.env.NODE_ENV === "test") return null;
39
- return path.join(os.homedir(), ".claude");
55
+ return path.join(homeDir(), ".claude");
40
56
  }
41
57
 
42
- /** The Claude config directory, falling back to `~/.claude` even under test. */
58
+ /**
59
+ * The Claude config directory: `CLAUDE_CONFIG_DIR`, else `<HOME>/.claude`,
60
+ * even under test. The one root for every Claude Code file magus reads: plugin
61
+ * state, USER `settings.json` and USER skills.
62
+ */
43
63
  export function requireClaudeConfigDir(): string {
44
- return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
64
+ return process.env.CLAUDE_CONFIG_DIR || path.join(homeDir(), ".claude");
45
65
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * display-path.ts — a folder as a person reads it: relative to the project
3
+ * when it is inside it, `~/…` when it is under the home folder, else as is.
4
+ * For display only; JSON and every file operation keep the absolute path.
5
+ */
6
+
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+
10
+ /** `child` is `parent` itself or somewhere under it. */
11
+ function within(parent: string, child: string): string | null {
12
+ const rel = path.relative(parent, child);
13
+ if (rel === "") return "";
14
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
15
+ return rel;
16
+ }
17
+
18
+ /**
19
+ * `abs` shortened for display: `.claude/skills/greet` inside `cwd`,
20
+ * `~/.claude/skills/team-tools` under the home folder, otherwise unchanged.
21
+ * The home folder is `$HOME` when set, as Claude Code and the shell read it.
22
+ */
23
+ export function displayPath(
24
+ abs: string,
25
+ cwd: string,
26
+ home: string = process.env.HOME || os.homedir(),
27
+ ): string {
28
+ const inProject = within(path.resolve(cwd), path.resolve(abs));
29
+ if (inProject !== null) return inProject === "" ? "." : inProject;
30
+ const inHome = home ? within(path.resolve(home), path.resolve(abs)) : null;
31
+ if (inHome !== null) return inHome === "" ? "~" : `~/${inHome}`;
32
+ return abs;
33
+ }