claudeup 6.2.0 → 6.3.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 (153) hide show
  1. package/README.md +22 -0
  2. package/bin/claudeup.js +6 -1
  3. package/package.json +6 -6
  4. package/src/__tests__/alias-adopt.test.ts +331 -329
  5. package/src/__tests__/alias-parser.test.ts +384 -376
  6. package/src/__tests__/alias-shell-writer.test.ts +638 -625
  7. package/src/__tests__/alias-store.test.ts +143 -143
  8. package/src/__tests__/catalog-cache-store.test.ts +265 -265
  9. package/src/__tests__/catalog-notice.test.ts +123 -123
  10. package/src/__tests__/cli-apply-seams.test.ts +12 -8
  11. package/src/__tests__/cli-router.test.ts +132 -3
  12. package/src/__tests__/cli-update-view.test.ts +27 -8
  13. package/src/__tests__/community-staleness.test.ts +0 -2
  14. package/src/__tests__/component-badge.test.ts +4 -4
  15. package/src/__tests__/content-drift.test.ts +5 -1
  16. package/src/__tests__/conventions-integration.test.ts +774 -689
  17. package/src/__tests__/conventions-manager.test.ts +1216 -1152
  18. package/src/__tests__/doctor-bins.test.ts +14 -6
  19. package/src/__tests__/doctor.test.ts +438 -425
  20. package/src/__tests__/dual-write-prevention.test.ts +277 -282
  21. package/src/__tests__/enabled-not-installed.test.ts +71 -63
  22. package/src/__tests__/file-locking.test.ts +196 -196
  23. package/src/__tests__/gap-fill-versions.test.ts +318 -311
  24. package/src/__tests__/github-budget.test.ts +170 -164
  25. package/src/__tests__/gitignore-detector.test.ts +160 -152
  26. package/src/__tests__/gitignore-fixer.test.ts +285 -247
  27. package/src/__tests__/gitignore-prerun.test.ts +57 -57
  28. package/src/__tests__/gitignore-resolver.test.ts +173 -146
  29. package/src/__tests__/gitignore-service.test.ts +121 -119
  30. package/src/__tests__/go-module.test.ts +43 -0
  31. package/src/__tests__/install-command.test.ts +3 -3
  32. package/src/__tests__/install-plan.test.ts +13 -7
  33. package/src/__tests__/manifest.test.ts +16 -5
  34. package/src/__tests__/markdown-renderer.test.ts +0 -1
  35. package/src/__tests__/marketplace-badge.test.ts +111 -90
  36. package/src/__tests__/marketplace-version.test.ts +64 -61
  37. package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
  38. package/src/__tests__/plugin-requires.test.ts +55 -1
  39. package/src/__tests__/plugin-setup.test.ts +111 -9
  40. package/src/__tests__/profile-command.test.ts +1 -7
  41. package/src/__tests__/profile-materializer.test.ts +31 -8
  42. package/src/__tests__/profile-sync.test.ts +23 -5
  43. package/src/__tests__/registry-version-resolution.test.ts +61 -47
  44. package/src/__tests__/resolve-executable.test.ts +1 -1
  45. package/src/__tests__/resolver.test.ts +38 -13
  46. package/src/__tests__/scope-action.test.ts +52 -50
  47. package/src/__tests__/scope-squares.test.tsx +4 -5
  48. package/src/__tests__/skill-install-files.test.ts +111 -103
  49. package/src/__tests__/symlink-manager.test.ts +27 -10
  50. package/src/__tests__/theme-env.test.ts +281 -0
  51. package/src/__tests__/theme-resolve.test.ts +380 -0
  52. package/src/__tests__/update-apply.test.ts +1 -3
  53. package/src/__tests__/useGitignoreModal.test.ts +69 -61
  54. package/src/__tests__/version-snapshot.test.ts +183 -165
  55. package/src/cli/ansi.ts +23 -15
  56. package/src/cli/doctor.ts +10 -9
  57. package/src/cli/install.ts +3 -3
  58. package/src/cli/profile.ts +5 -4
  59. package/src/cli/prompt.ts +0 -1
  60. package/src/cli/router.ts +69 -2
  61. package/src/cli/update-view.ts +11 -6
  62. package/src/cli/update.ts +9 -4
  63. package/src/cli/upgrade.ts +6 -4
  64. package/src/data/alias-flags.ts +260 -260
  65. package/src/data/gitignore-defaults.ts +20 -20
  66. package/src/data/gitignore-reasons.ts +86 -92
  67. package/src/data/gitignore-templates.ts +14 -14
  68. package/src/data/predefined-profiles.ts +1 -2
  69. package/src/data/skill-repos.ts +43 -35
  70. package/src/main.tsx +29 -6
  71. package/src/opentui.d.ts +1 -3
  72. package/src/prerunner/index.ts +7 -8
  73. package/src/services/alias-settings.ts +20 -23
  74. package/src/services/alias-shell-writer.ts +800 -803
  75. package/src/services/alias-store.ts +77 -77
  76. package/src/services/claude-settings.ts +27 -18
  77. package/src/services/content-drift.ts +14 -4
  78. package/src/services/conventions-manager.ts +692 -617
  79. package/src/services/doctor.ts +464 -403
  80. package/src/services/gitignore-detector.ts +113 -103
  81. package/src/services/gitignore-fixer.ts +204 -189
  82. package/src/services/gitignore-prerun.ts +26 -26
  83. package/src/services/gitignore-resolver.ts +162 -144
  84. package/src/services/gitignore-service.ts +120 -117
  85. package/src/services/install-plan.ts +4 -4
  86. package/src/services/local-marketplace.ts +7 -5
  87. package/src/services/manifest.ts +1 -1
  88. package/src/services/marketplace-catalog-git.ts +5 -2
  89. package/src/services/marketplace-fetcher.ts +241 -235
  90. package/src/services/plugin-manager.ts +17 -20
  91. package/src/services/plugin-mcp-config.ts +2 -2
  92. package/src/services/plugin-requires.ts +16 -8
  93. package/src/services/plugin-setup.ts +32 -15
  94. package/src/services/plugin-version-check.ts +4 -4
  95. package/src/services/profile-materializer.ts +9 -5
  96. package/src/services/profile-sync.ts +12 -4
  97. package/src/services/profiles.ts +3 -3
  98. package/src/services/resolver.ts +10 -6
  99. package/src/services/settings-manager.ts +82 -46
  100. package/src/services/skills-manager.ts +11 -9
  101. package/src/services/symlink-manager.ts +2 -5
  102. package/src/services/toolchain.ts +3 -3
  103. package/src/services/update-cache.ts +1 -1
  104. package/src/services/update-engine.ts +4 -4
  105. package/src/services/update-plan.ts +9 -11
  106. package/src/services/version-snapshot.ts +88 -88
  107. package/src/types/gitignore.ts +28 -28
  108. package/src/ui/App.tsx +36 -36
  109. package/src/ui/adapters/settingsAdapter.ts +2 -2
  110. package/src/ui/components/CategoryHeader.tsx +1 -1
  111. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  112. package/src/ui/components/ScopeIndicator.tsx +3 -1
  113. package/src/ui/components/ScrollableList.tsx +2 -1
  114. package/src/ui/components/StyledText.tsx +1 -1
  115. package/src/ui/components/layout/FooterHints.tsx +5 -3
  116. package/src/ui/components/layout/Panel.tsx +2 -2
  117. package/src/ui/components/layout/ScreenLayout.tsx +5 -4
  118. package/src/ui/components/modals/InputModal.tsx +7 -1
  119. package/src/ui/components/modals/ModalContainer.tsx +3 -3
  120. package/src/ui/components/modals/SelectModal.tsx +1 -1
  121. package/src/ui/components/primitives/DetailSection.tsx +1 -1
  122. package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
  123. package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
  124. package/src/ui/hooks/index.ts +0 -1
  125. package/src/ui/hooks/useGitignoreModal.ts +1 -5
  126. package/src/ui/hooks/useMismatchModal.ts +4 -10
  127. package/src/ui/renderers/cliToolRenderers.tsx +2 -2
  128. package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
  129. package/src/ui/renderers/mcpRenderers.tsx +1 -1
  130. package/src/ui/renderers/pluginRenderers.tsx +3 -9
  131. package/src/ui/renderers/profileRenderers.tsx +9 -8
  132. package/src/ui/renderers/settingsRenderers.tsx +4 -4
  133. package/src/ui/renderers/skillRenderers.tsx +14 -14
  134. package/src/ui/screens/AliasScreen.tsx +31 -34
  135. package/src/ui/screens/CliToolsScreen.tsx +14 -13
  136. package/src/ui/screens/EnvVarsScreen.tsx +8 -8
  137. package/src/ui/screens/GitignoreScreen.tsx +15 -14
  138. package/src/ui/screens/McpRegistryScreen.tsx +10 -7
  139. package/src/ui/screens/McpScreen.tsx +10 -10
  140. package/src/ui/screens/PluginsScreen.tsx +17 -21
  141. package/src/ui/screens/ProfilesScreen.tsx +25 -24
  142. package/src/ui/screens/SkillsScreen.tsx +16 -14
  143. package/src/ui/state/AppContext.tsx +4 -8
  144. package/src/ui/state/DimensionsContext.tsx +3 -2
  145. package/src/ui/theme-mode.ts +20 -38
  146. package/src/ui/theme-resolve.ts +148 -0
  147. package/src/utils/clipboard.ts +5 -5
  148. package/src/utils/command-utils.ts +2 -2
  149. package/src/utils/config-dir.ts +1 -3
  150. package/src/utils/file-locking.ts +88 -86
  151. package/src/utils/go-module.ts +36 -0
  152. package/src/ui/hooks/useAsyncData.ts +0 -127
  153. package/src/ui/state/AnimationContext.tsx +0 -76
@@ -1,10 +1,15 @@
1
1
  import path from "node:path";
2
+ import { defaultMarketplaces } from "../data/marketplaces.js";
3
+ import type {
4
+ InstalledPluginsRegistry,
5
+ PluginRelease,
6
+ } from "../types/index.js";
7
+ import { formatMarketplaceName, parsePluginId } from "../utils/string-utils.js";
8
+ import type { PluginScope } from "./claude-cli.js";
2
9
  import {
3
10
  getConfiguredMarketplaces,
4
11
  getEnabledPlugins,
5
12
  getGlobalClaudeDir,
6
- readSettings,
7
- writeSettings,
8
13
  getGlobalConfiguredMarketplaces,
9
14
  getGlobalEnabledPlugins,
10
15
  getGlobalInstalledPluginVersions,
@@ -13,40 +18,32 @@ import {
13
18
  getProjectInstalledPluginVersions,
14
19
  pickRegistryEntry,
15
20
  readInstalledPluginsRegistry,
21
+ readSettings,
22
+ removeFromInstalledPluginsRegistry,
16
23
  saveGlobalInstalledPluginVersion,
17
24
  saveLocalInstalledPluginVersion,
18
25
  updateInstalledPluginsRegistry,
19
- removeFromInstalledPluginsRegistry,
26
+ writeSettings,
20
27
  } from "./claude-settings.js";
21
- import type { PluginScope } from "./claude-cli.js";
22
28
  import { hasContentDrift } from "./content-drift.js";
23
29
  import { inheritablePaths } from "./git-worktree.js";
24
- import { defaultMarketplaces } from "../data/marketplaces.js";
25
- import type {
26
- InstalledPluginsRegistry,
27
- PluginRelease,
28
- } from "../types/index.js";
29
30
  import {
30
- scanLocalMarketplaces,
31
- repairAllMarketplaces,
32
31
  type LocalMarketplace,
33
32
  type ProgressCallback,
34
33
  type RepairMarketplaceResult,
34
+ repairAllMarketplaces,
35
+ scanLocalMarketplaces,
35
36
  } from "./local-marketplace.js";
36
37
  import {
37
- formatMarketplaceName,
38
- parsePluginId,
39
- } from "../utils/string-utils.js";
40
- import {
38
+ type CatalogSource,
39
+ type MarketplaceFetchFailure,
40
+ type MarketplacePlugin,
41
41
  clearMarketplaceCache as clearFetcherCache,
42
42
  clearPersistedCatalogs,
43
43
  fetchMarketplacePlugins,
44
44
  getMarketplaceFetchFailures,
45
45
  isAuthoritative,
46
46
  resolveMarketplacePlugins,
47
- type CatalogSource,
48
- type MarketplaceFetchFailure,
49
- type MarketplacePlugin,
50
47
  } from "./marketplace-fetcher.js";
51
48
  export {
52
49
  fetchMarketplacePlugins,
@@ -224,7 +221,6 @@ export function resolveScopeAction(
224
221
  return hasUpdate ? "update" : "uninstall";
225
222
  }
226
223
 
227
-
228
224
  export async function getAvailablePlugins(
229
225
  projectPath?: string,
230
226
  ): Promise<PluginInfo[]> {
@@ -728,7 +724,8 @@ async function annotateContentDrift(
728
724
  plugins.map(async (plugin) => {
729
725
  if (plugin.hasUpdate || plugin.isOrphaned) return;
730
726
  if (!plugin.installedVersion || !plugin.version) return;
731
- if (compareVersions(plugin.version, plugin.installedVersion) !== 0) return;
727
+ if (compareVersions(plugin.version, plugin.installedVersion) !== 0)
728
+ return;
732
729
 
733
730
  const entry = pickRegistryEntry(
734
731
  registry.plugins[plugin.id],
@@ -1,6 +1,6 @@
1
- import fs from "fs-extra";
2
- import path from "node:path";
3
1
  import os from "node:os";
2
+ import path from "node:path";
3
+ import fs from "fs-extra";
4
4
 
5
5
  const CLAUDE_PLUGINS_DIR = path.join(
6
6
  os.homedir(),
@@ -7,14 +7,15 @@
7
7
  * bug where a plugin's MCP server silently fails because its binary is missing.
8
8
  */
9
9
 
10
- import fs from "fs-extra";
11
- import path from "node:path";
12
10
  import os from "node:os";
11
+ import path from "node:path";
12
+ import fs from "fs-extra";
13
13
  import type {
14
14
  BinInstaller,
15
15
  BinRequirement,
16
16
  PluginRequires,
17
17
  } from "../types/index.js";
18
+ import { goBinaryName } from "../utils/go-module.js";
18
19
  import { parsePluginId } from "../utils/string-utils.js";
19
20
  import { getLocalMarketplacesInfo } from "./plugin-manager.js";
20
21
 
@@ -79,9 +80,7 @@ function parseSetup(setup: unknown): BinRequirement[] {
79
80
  const s = setup as Record<string, unknown>;
80
81
  const bins: BinRequirement[] = [];
81
82
 
82
- const pkgManager = (
83
- key: "bun" | "npm" | "pip",
84
- ): void => {
83
+ const pkgManager = (key: "bun" | "npm" | "pip"): void => {
85
84
  for (const spec of (s[key] as string[] | undefined) ?? []) {
86
85
  if (typeof spec !== "string") continue;
87
86
  const { base, version } = splitVersion(spec);
@@ -98,20 +97,29 @@ function parseSetup(setup: unknown): BinRequirement[] {
98
97
  pkgManager("npm");
99
98
  pkgManager("pip");
100
99
 
100
+ // Not basename(): a Go module's trailing /vN is its major version, not the
101
+ // binary. `tmux-mcp/v2` installs `tmux-mcp`; naming it `v2` here made every
102
+ // downstream presence check look for a binary that never exists.
101
103
  for (const spec of (s.go as string[] | undefined) ?? []) {
102
104
  if (typeof spec !== "string") continue;
103
105
  const { base, version } = splitVersion(spec);
106
+ const name = goBinaryName(base);
104
107
  bins.push({
105
- name: basename(base),
108
+ name,
106
109
  via: "go",
107
110
  module: base,
108
111
  version,
109
- check: `${basename(base)} --version`,
112
+ check: `${name} --version`,
110
113
  });
111
114
  }
112
115
  for (const spec of (s.brew as string[] | undefined) ?? []) {
113
116
  if (typeof spec !== "string") continue;
114
- bins.push({ name: spec, via: "brew", formula: spec, check: `${spec} --version` });
117
+ bins.push({
118
+ name: spec,
119
+ via: "brew",
120
+ formula: spec,
121
+ check: `${spec} --version`,
122
+ });
115
123
  }
116
124
  // `required` names a system tool that must be present; brew is the assumed
117
125
  // installer (matches the "system tools via brew" decision).
@@ -26,13 +26,14 @@
26
26
  */
27
27
 
28
28
  import { execFile } from "node:child_process";
29
- import { promisify } from "node:util";
30
- import fs from "fs-extra";
31
- import { access } from "node:fs/promises";
32
29
  import { constants as fsConstants } from "node:fs";
33
- import path from "node:path";
30
+ import { access } from "node:fs/promises";
34
31
  import os from "node:os";
32
+ import path from "node:path";
33
+ import { promisify } from "node:util";
34
+ import fs from "fs-extra";
35
35
  import { which } from "../utils/command-utils.js";
36
+ import { goBinaryName } from "../utils/go-module.js";
36
37
 
37
38
  const execFileAsync = promisify(execFile);
38
39
 
@@ -392,14 +393,32 @@ async function installCargoPackages(
392
393
  }
393
394
  }
394
395
 
396
+ /**
397
+ * {@link parseAtSpec} for a Go module spec.
398
+ *
399
+ * Same split, but the name comes from {@link goBinaryName}: Go's `/vN`
400
+ * major-version suffix is part of the module path, not the binary, so
401
+ * "github.com/MadAppGang/tmux-mcp/v2@v2.0.0" installs `tmux-mcp`, not `v2`.
402
+ * The generic parser cannot do this — an npm scope may legitimately be `v2`.
403
+ */
404
+ export function parseGoSpec(spec: string): ParsedSpec {
405
+ const parsed = parseAtSpec(spec);
406
+ // Same separator rule as parseAtSpec: a leading `@` is never a version.
407
+ const at = spec.lastIndexOf("@");
408
+ const modulePath = at > 0 ? spec.slice(0, at) : spec;
409
+ return { ...parsed, name: goBinaryName(modulePath) };
410
+ }
411
+
395
412
  /**
396
413
  * Extract binary name from a Go module path.
397
- * The binary name is the last path segment before @version.
398
- * e.g., "github.com/MadAppGang/tmux-mcp@latest" → "tmux-mcp"
399
- * "github.com/user/tool" → "tool"
414
+ * The binary name is the last path segment before @version, minus any /vN
415
+ * major-version suffix.
416
+ * e.g., "github.com/MadAppGang/tmux-mcp@latest" → "tmux-mcp"
417
+ * "github.com/MadAppGang/tmux-mcp/v2@v2.0.0" → "tmux-mcp"
418
+ * "github.com/user/tool" → "tool"
400
419
  */
401
420
  export function extractGoBinaryName(pkg: string): string {
402
- return parseAtSpec(pkg).name;
421
+ return parseGoSpec(pkg).name;
403
422
  }
404
423
 
405
424
  /**
@@ -409,7 +428,7 @@ export function extractGoBinaryName(pkg: string): string {
409
428
  * "github.com/user/tool" → null
410
429
  */
411
430
  export function extractGoVersion(pkg: string): string | null {
412
- return parseAtSpec(pkg).version;
431
+ return parseGoSpec(pkg).version;
413
432
  }
414
433
 
415
434
  /** Normalize a semver-ish string for comparison: trim and drop a leading "v". */
@@ -641,7 +660,7 @@ export async function binaryDepDecision(
641
660
 
642
661
  /** {@link binaryDepDecision} for a Go module path. */
643
662
  export async function goDepDecision(pkg: string): Promise<GoDepDecision> {
644
- return binaryDepDecision(parseAtSpec(pkg), getGoInstallDir);
663
+ return binaryDepDecision(parseGoSpec(pkg), getGoInstallDir);
645
664
  }
646
665
 
647
666
  /**
@@ -781,10 +800,10 @@ export async function getPluginSetupConfig(
781
800
 
782
801
  // Find the version directory
783
802
  const versions = await fs.readdir(pluginPath);
784
- if (versions.length === 0) return null;
785
803
 
786
804
  // Use the latest version directory
787
- const latestVersion = versions.sort().pop()!;
805
+ const latestVersion = versions.sort().pop();
806
+ if (latestVersion === undefined) return null;
788
807
  const manifestPath = path.join(pluginPath, latestVersion, "plugin.json");
789
808
 
790
809
  if (!(await fs.pathExists(manifestPath))) return null;
@@ -824,9 +843,7 @@ export async function getPluginSetupFromSource(
824
843
  try {
825
844
  const manifest = await fs.readJson(pluginJson);
826
845
  return manifest.setup || null;
827
- } catch {
828
- continue;
829
- }
846
+ } catch {}
830
847
  }
831
848
  }
832
849
 
@@ -276,9 +276,7 @@ export function formatMismatchWarning(
276
276
  * Format mismatch info for TUI modal display.
277
277
  * Returns a multi-line string suitable for a modal message.
278
278
  */
279
- export function formatMismatchModal(
280
- mismatches: VersionMismatchInfo[],
281
- ): string {
279
+ export function formatMismatchModal(mismatches: VersionMismatchInfo[]): string {
282
280
  const lines: string[] = [];
283
281
 
284
282
  lines.push("Your plugins will not work correctly in this project.");
@@ -290,7 +288,9 @@ export function formatMismatchModal(
290
288
 
291
289
  for (const m of mismatches) {
292
290
  const name = m.pluginId.split("@")[0];
293
- lines.push(` ${name}: v${m.firstEntryVersion} loaded, expected v${m.currentProjectVersion}`);
291
+ lines.push(
292
+ ` ${name}: v${m.firstEntryVersion} loaded, expected v${m.currentProjectVersion}`,
293
+ );
294
294
  }
295
295
 
296
296
  lines.push("");
@@ -11,8 +11,8 @@
11
11
  * and touches no global state, so it is fully testable with a temp dir.
12
12
  */
13
13
 
14
- import fs from "fs-extra";
15
14
  import path from "node:path";
15
+ import fs from "fs-extra";
16
16
  import type { ResolvedClosure } from "../types/index.js";
17
17
  import { profileDir } from "./symlink-manager.js";
18
18
 
@@ -48,9 +48,9 @@ export function buildProfileSettings(
48
48
  }
49
49
 
50
50
  /** Build the .mcp.json contents for a profile. */
51
- export function buildProfileMcp(
52
- closure: ResolvedClosure,
53
- ): { mcpServers: Record<string, unknown> } {
51
+ export function buildProfileMcp(closure: ResolvedClosure): {
52
+ mcpServers: Record<string, unknown>;
53
+ } {
54
54
  return { mcpServers: { ...closure.mcpServers } };
55
55
  }
56
56
 
@@ -73,7 +73,11 @@ async function seedSkillsFromProject(
73
73
  ): Promise<void> {
74
74
  if (await fs.pathExists(skillsDir)) return;
75
75
 
76
- const projectSkills = path.join(projectPath ?? process.cwd(), ".claude", "skills");
76
+ const projectSkills = path.join(
77
+ projectPath ?? process.cwd(),
78
+ ".claude",
79
+ "skills",
80
+ );
77
81
  try {
78
82
  // lstat, not pathExists: an existing profile symlink is not a source.
79
83
  const stat = await fs.lstat(projectSkills);
@@ -8,8 +8,8 @@
8
8
  * becomes a reviewable manifest diff instead of silent, throwaway drift.
9
9
  */
10
10
 
11
- import fs from "fs-extra";
12
11
  import path from "node:path";
12
+ import fs from "fs-extra";
13
13
  import type {
14
14
  McpServerConfig,
15
15
  ProfileManifest,
@@ -68,7 +68,10 @@ export function computeSync(
68
68
  const { enabledPlugins: _drop, ...settings } = materializedSettings;
69
69
  if (JSON.stringify(settings) !== JSON.stringify(entry.settings ?? {})) {
70
70
  if (Object.keys(settings).length > 0) next.settings = settings;
71
- else delete next.settings;
71
+ else {
72
+ // biome-ignore lint/performance/noDelete: the manifest entry is serialised to profiles.json and diffed with JSON.stringify/toStrictEqual; the key must be absent, not undefined
73
+ delete next.settings;
74
+ }
72
75
  changes.push({ kind: "settings-changed", detail: "settings updated" });
73
76
  }
74
77
 
@@ -76,7 +79,10 @@ export function computeSync(
76
79
  const mcp = materializedMcp.mcpServers ?? {};
77
80
  if (JSON.stringify(mcp) !== JSON.stringify(entry.mcpServers ?? {})) {
78
81
  if (Object.keys(mcp).length > 0) next.mcpServers = mcp;
79
- else delete next.mcpServers;
82
+ else {
83
+ // biome-ignore lint/performance/noDelete: the manifest entry is serialised to profiles.json and diffed with JSON.stringify/toStrictEqual; the key must be absent, not undefined
84
+ delete next.mcpServers;
85
+ }
80
86
  changes.push({ kind: "mcp-changed", detail: "mcp servers updated" });
81
87
  }
82
88
 
@@ -129,7 +135,9 @@ export async function syncProfile(
129
135
  const settingsPath = path.join(dir, "settings.json");
130
136
  const mcpPath = path.join(dir, "mcp.json");
131
137
  if (!(await fs.pathExists(settingsPath))) {
132
- throw new Error(`Profile "${name}" isn't materialized (${settingsPath} missing).`);
138
+ throw new Error(
139
+ `Profile "${name}" isn't materialized (${settingsPath} missing).`,
140
+ );
133
141
  }
134
142
 
135
143
  const materializedSettings = await fs.readJson(settingsPath);
@@ -1,7 +1,7 @@
1
- import fs from "fs-extra";
2
- import path from "node:path";
3
1
  import os from "node:os";
4
- import type { Profile, ProfilesFile, ProfileEntry } from "../types/index.js";
2
+ import path from "node:path";
3
+ import fs from "fs-extra";
4
+ import type { Profile, ProfileEntry, ProfilesFile } from "../types/index.js";
5
5
 
6
6
  const PROFILES_FILE = "profiles.json";
7
7
 
@@ -9,6 +9,9 @@
9
9
  */
10
10
 
11
11
  import semver from "semver";
12
+ import { cliTools } from "../data/cli-tools.js";
13
+ import { getMarketplaceByName } from "../data/marketplaces.js";
14
+ import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
12
15
  import type {
13
16
  BinRequirement,
14
17
  ProfileManifest,
@@ -16,9 +19,6 @@ import type {
16
19
  ResolvedBin,
17
20
  ResolvedClosure,
18
21
  } from "../types/index.js";
19
- import { cliTools } from "../data/cli-tools.js";
20
- import { getMarketplaceByName } from "../data/marketplaces.js";
21
- import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
22
22
  import { resolvePluginBinRequirements } from "./plugin-requires.js";
23
23
 
24
24
  export interface ResolveOptions {
@@ -237,17 +237,21 @@ function pickVersion(
237
237
  * resolved deterministically to the higher pin — NOT by profile order — and
238
238
  * collected into `conflicts` so `install`/`profile show` can surface them.
239
239
  */
240
- function mergeClosures(a: ResolvedClosure, b: ResolvedClosure): ResolvedClosure {
240
+ function mergeClosures(
241
+ a: ResolvedClosure,
242
+ b: ResolvedClosure,
243
+ ): ResolvedClosure {
241
244
  const conflicts = [...(a.conflicts ?? []), ...(b.conflicts ?? [])];
242
245
 
243
246
  // Plugins: deterministic version pick per id.
244
247
  const plugins: Record<string, string> = { ...a.plugins };
245
248
  for (const [id, ver] of Object.entries(b.plugins)) {
246
- if (plugins[id] === undefined) {
249
+ const existing = plugins[id];
250
+ if (existing === undefined) {
247
251
  plugins[id] = ver;
248
252
  continue;
249
253
  }
250
- const { chosen, conflict } = pickVersion(id, plugins[id]!, ver);
254
+ const { chosen, conflict } = pickVersion(id, existing, ver);
251
255
  plugins[id] = chosen;
252
256
  if (conflict) conflicts.push(conflict);
253
257
  }
@@ -1,42 +1,74 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { SettingDefinition } from "../data/settings-catalog.js";
4
+ import type { ClaudeSettings } from "../types/index.js";
3
5
  import {
4
- readSettings,
5
- writeSettings,
6
6
  readGlobalSettings,
7
+ readSettings,
7
8
  writeGlobalSettings,
9
+ writeSettings,
8
10
  } from "./claude-settings.js";
9
- import type { SettingDefinition } from "../data/settings-catalog.js";
10
11
 
11
12
  export type SettingScope = "user" | "project";
12
13
 
13
- /** Read the current value of a setting from the given scope */
14
- export async function readSettingValue(
15
- setting: SettingDefinition,
14
+ /**
15
+ * settings.json is an open JSON object: the catalog addresses keys that the
16
+ * typed `ClaudeSettings` shape does not enumerate (`attribution`, `env`, any
17
+ * dotted path). Read and write through this record view and narrow each value.
18
+ */
19
+ type SettingsRecord = ClaudeSettings & Record<string, unknown>;
20
+
21
+ function isRecord(value: unknown): value is Record<string, unknown> {
22
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23
+ }
24
+
25
+ /** The `attribution` block as Claude Code stores it. */
26
+ interface Attribution {
27
+ commit?: unknown;
28
+ pr?: unknown;
29
+ }
30
+
31
+ function readAttribution(settings: SettingsRecord): Attribution | undefined {
32
+ const raw = settings.attribution;
33
+ return isRecord(raw) ? raw : undefined;
34
+ }
35
+
36
+ async function loadSettings(
16
37
  scope: SettingScope,
17
38
  projectPath?: string,
18
- ): Promise<string | undefined> {
39
+ ): Promise<SettingsRecord> {
19
40
  const settings =
20
41
  scope === "user"
21
42
  ? await readGlobalSettings()
22
43
  : await readSettings(projectPath);
44
+ return settings as SettingsRecord;
45
+ }
46
+
47
+ /** Read the current value of a setting from the given scope */
48
+ export async function readSettingValue(
49
+ setting: SettingDefinition,
50
+ scope: SettingScope,
51
+ projectPath?: string,
52
+ ): Promise<string | undefined> {
53
+ const settings = await loadSettings(scope, projectPath);
23
54
 
24
55
  if (setting.storage.type === "attribution") {
25
56
  // Attribution is an object: { commit: "", pr: "" } means disabled
26
- const attr = (settings as any).attribution;
57
+ const attr = readAttribution(settings);
27
58
  if (attr && attr.commit === "" && attr.pr === "") {
28
59
  return "false";
29
60
  }
30
61
  return undefined; // default (enabled)
31
- } else if (setting.storage.type === "attribution-text") {
62
+ }
63
+ if (setting.storage.type === "attribution-text") {
32
64
  // Custom attribution text: read from attribution.commit, strip the Co-Authored-By trailer prefix
33
- const attr = (settings as any).attribution;
65
+ const attr = readAttribution(settings);
34
66
  if (!attr || (attr.commit === "" && attr.pr === "")) {
35
67
  // Attribution is disabled or not set — no custom text stored
36
68
  return undefined;
37
69
  }
38
- const commit: string | undefined = attr.commit;
39
- if (!commit) {
70
+ const commit = attr.commit;
71
+ if (typeof commit !== "string" || commit === "") {
40
72
  return undefined;
41
73
  }
42
74
  // The commit value is "Co-Authored-By: Magus <magus@madappgang.com>\n\n{customText}"
@@ -48,18 +80,19 @@ export async function readSettingValue(
48
80
  }
49
81
  // If no trailer prefix, the value is the raw text (or Claude default — return undefined)
50
82
  return undefined;
51
- } else if (setting.storage.type === "env") {
52
- const env = (settings as any).env as Record<string, string> | undefined;
53
- return env?.[setting.storage.key];
54
- } else {
55
- // Direct settings key (supports dot notation like "permissions.defaultMode")
56
- const keys = setting.storage.key.split(".");
57
- let value: any = settings;
58
- for (const k of keys) {
59
- value = value?.[k];
60
- }
61
- return value !== undefined && value !== null ? String(value) : undefined;
62
83
  }
84
+ if (setting.storage.type === "env") {
85
+ const env = settings.env;
86
+ const raw = isRecord(env) ? env[setting.storage.key] : undefined;
87
+ return typeof raw === "string" ? raw : undefined;
88
+ }
89
+ // Direct settings key (supports dot notation like "permissions.defaultMode")
90
+ const keys = setting.storage.key.split(".");
91
+ let value: unknown = settings;
92
+ for (const k of keys) {
93
+ value = isRecord(value) ? value[k] : undefined;
94
+ }
95
+ return value !== undefined && value !== null ? String(value) : undefined;
63
96
  }
64
97
 
65
98
  /** Write a setting value to the given scope */
@@ -69,57 +102,60 @@ export async function writeSettingValue(
69
102
  scope: SettingScope,
70
103
  projectPath?: string,
71
104
  ): Promise<void> {
72
- const settings =
73
- scope === "user"
74
- ? await readGlobalSettings()
75
- : await readSettings(projectPath);
105
+ const settings = await loadSettings(scope, projectPath);
76
106
 
77
107
  if (setting.storage.type === "attribution") {
78
108
  // Boolean toggle: "false" -> write { commit: "", pr: "" }, "true"/undefined -> delete key
79
109
  if (value === "false") {
80
- (settings as any).attribution = { commit: "", pr: "" };
110
+ settings.attribution = { commit: "", pr: "" };
81
111
  } else {
82
- delete (settings as any).attribution;
112
+ // biome-ignore lint/performance/noDelete: the key must be absent from the serialised settings.json; `undefined` would still be listed by Object.keys and defeat the "revert to Claude defaults" contract
113
+ delete settings.attribution;
83
114
  }
84
115
  } else if (setting.storage.type === "attribution-text") {
85
116
  if (value && value.trim().length > 0) {
86
117
  // Write custom text: commit gets a Co-Authored-By trailer + the text; pr gets the text
87
- (settings as any).attribution = {
118
+ settings.attribution = {
88
119
  commit: `Co-Authored-By: Magus <magus@madappgang.com>\n\n${value}`,
89
120
  pr: value,
90
121
  };
91
122
  } else {
92
123
  // Empty value: remove attribution key entirely (revert to Claude defaults)
93
- delete (settings as any).attribution;
124
+ // biome-ignore lint/performance/noDelete: the key must be absent from the serialised settings.json, not present with an undefined value
125
+ delete settings.attribution;
94
126
  }
95
127
  } else if (setting.storage.type === "env") {
96
128
  // Write to the "env" block in settings.json
97
- (settings as any).env = (settings as any).env || {};
129
+ const env = isRecord(settings.env) ? settings.env : {};
130
+ settings.env = env;
98
131
  if (value === undefined || value === "" || value === setting.defaultValue) {
99
- delete (settings as any).env[setting.storage.key];
132
+ delete env[setting.storage.key];
100
133
  // Clean up empty env block
101
- if (Object.keys((settings as any).env).length === 0) {
102
- delete (settings as any).env;
134
+ if (Object.keys(env).length === 0) {
135
+ // biome-ignore lint/performance/noDelete: an empty env block is removed from the file entirely; assigning undefined would leave the key for Object.keys and JSON readers that check `in`
136
+ delete settings.env;
103
137
  }
104
138
  } else {
105
- (settings as any).env[setting.storage.key] = value;
139
+ env[setting.storage.key] = value;
106
140
  }
107
141
  } else {
108
142
  // Direct settings key (supports dot notation)
109
143
  const keys = setting.storage.key.split(".");
110
144
  if (keys.length === 1) {
111
145
  if (value === undefined || value === "") {
112
- delete (settings as any)[keys[0]];
146
+ delete settings[keys[0]];
113
147
  } else {
114
148
  // Try to preserve type: boolean, number, or string
115
- (settings as any)[keys[0]] = parseSettingValue(value, setting.type);
149
+ settings[keys[0]] = parseSettingValue(value, setting.type);
116
150
  }
117
151
  } else {
118
152
  // Nested key like "permissions.defaultMode"
119
- let obj: any = settings;
153
+ let obj: Record<string, unknown> = settings;
120
154
  for (let i = 0; i < keys.length - 1; i++) {
121
- obj[keys[i]] = obj[keys[i]] || {};
122
- obj = obj[keys[i]];
155
+ const existing = obj[keys[i]];
156
+ const next = isRecord(existing) ? existing : {};
157
+ obj[keys[i]] = next;
158
+ obj = next;
123
159
  }
124
160
  const lastKey = keys[keys.length - 1];
125
161
  if (value === undefined || value === "") {
@@ -137,7 +173,7 @@ export async function writeSettingValue(
137
173
  }
138
174
  }
139
175
 
140
- function parseSettingValue(value: string, type: string): any {
176
+ function parseSettingValue(value: string, type: string): string | boolean {
141
177
  if (type === "boolean") {
142
178
  return value === "true" || value === "1";
143
179
  }
@@ -205,13 +241,13 @@ export async function discoverOutputStyles(
205
241
  const enabledPlugins = new Set<string>();
206
242
  try {
207
243
  const userSettings = await readGlobalSettings();
208
- for (const [k, v] of Object.entries((userSettings as any).enabledPlugins || {})) {
244
+ for (const [k, v] of Object.entries(userSettings.enabledPlugins ?? {})) {
209
245
  if (v) enabledPlugins.add(k);
210
246
  }
211
247
  } catch {}
212
248
  try {
213
249
  const projSettings = await readSettings(projectPath);
214
- for (const [k, v] of Object.entries((projSettings as any).enabledPlugins || {})) {
250
+ for (const [k, v] of Object.entries(projSettings.enabledPlugins ?? {})) {
215
251
  if (v) enabledPlugins.add(k);
216
252
  }
217
253
  } catch {}
@@ -751,10 +751,14 @@ export async function getInstalledSkillNames(
751
751
 
752
752
  // ─── Firebase Skills API ──────────────────────────────────────────────────────
753
753
 
754
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
755
- function mapApiSkillToSkillInfo(raw: any): SkillInfo {
756
- const repo = (raw.repo as string) || "unknown";
757
- const skillPath = (raw.skillPath as string) || (raw.name as string) || "";
754
+ function mapApiSkillToSkillInfo(entry: unknown): SkillInfo {
755
+ const raw: Record<string, unknown> =
756
+ typeof entry === "object" && entry !== null
757
+ ? (entry as Record<string, unknown>)
758
+ : {};
759
+ const str = (v: unknown): string => (typeof v === "string" ? v : "");
760
+ const repo = str(raw.repo) || "unknown";
761
+ const skillPath = str(raw.skillPath) || str(raw.name) || "";
758
762
  const source: SkillSource = {
759
763
  label: repo,
760
764
  repo,
@@ -763,8 +767,8 @@ function mapApiSkillToSkillInfo(raw: any): SkillInfo {
763
767
  const stars = typeof raw.stars === "number" ? raw.stars : undefined;
764
768
  return {
765
769
  id: `${repo}/${skillPath}`,
766
- name: (raw.name as string) || skillPath,
767
- description: (raw.description as string) || "",
770
+ name: str(raw.name) || skillPath,
771
+ description: str(raw.description),
768
772
  source,
769
773
  repoPath: skillPath ? `${skillPath}/SKILL.md` : "SKILL.md",
770
774
  gitBlobSha: "",
@@ -943,9 +947,7 @@ export async function installSkill(
943
947
  content = await response.text();
944
948
  break;
945
949
  }
946
- } catch {
947
- continue;
948
- }
950
+ } catch {}
949
951
  }
950
952
 
951
953
  if (!content) {