claudeup 6.2.1 → 6.3.2

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 (150) 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__/install-command.test.ts +3 -3
  31. package/src/__tests__/install-plan.test.ts +13 -7
  32. package/src/__tests__/manifest.test.ts +16 -5
  33. package/src/__tests__/markdown-renderer.test.ts +0 -1
  34. package/src/__tests__/marketplace-badge.test.ts +111 -90
  35. package/src/__tests__/marketplace-version.test.ts +64 -61
  36. package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
  37. package/src/__tests__/plugin-requires.test.ts +18 -4
  38. package/src/__tests__/plugin-setup.test.ts +12 -12
  39. package/src/__tests__/profile-command.test.ts +1 -7
  40. package/src/__tests__/profile-materializer.test.ts +31 -8
  41. package/src/__tests__/profile-sync.test.ts +23 -5
  42. package/src/__tests__/registry-version-resolution.test.ts +61 -47
  43. package/src/__tests__/resolve-executable.test.ts +1 -1
  44. package/src/__tests__/resolver.test.ts +40 -15
  45. package/src/__tests__/scope-action.test.ts +52 -50
  46. package/src/__tests__/scope-squares.test.tsx +4 -5
  47. package/src/__tests__/skill-install-files.test.ts +111 -103
  48. package/src/__tests__/symlink-manager.test.ts +27 -10
  49. package/src/__tests__/theme-env.test.ts +281 -0
  50. package/src/__tests__/theme-resolve.test.ts +380 -0
  51. package/src/__tests__/update-apply.test.ts +1 -3
  52. package/src/__tests__/useGitignoreModal.test.ts +69 -61
  53. package/src/__tests__/version-snapshot.test.ts +181 -165
  54. package/src/cli/ansi.ts +23 -15
  55. package/src/cli/doctor.ts +10 -9
  56. package/src/cli/install.ts +3 -3
  57. package/src/cli/profile.ts +5 -4
  58. package/src/cli/prompt.ts +0 -1
  59. package/src/cli/router.ts +69 -2
  60. package/src/cli/update-view.ts +11 -6
  61. package/src/cli/update.ts +9 -4
  62. package/src/cli/upgrade.ts +6 -4
  63. package/src/data/alias-flags.ts +260 -260
  64. package/src/data/gitignore-defaults.ts +20 -20
  65. package/src/data/gitignore-reasons.ts +86 -92
  66. package/src/data/gitignore-templates.ts +14 -14
  67. package/src/data/predefined-profiles.ts +8 -9
  68. package/src/data/skill-repos.ts +43 -35
  69. package/src/main.tsx +29 -6
  70. package/src/opentui.d.ts +1 -3
  71. package/src/prerunner/index.ts +7 -8
  72. package/src/services/alias-settings.ts +20 -23
  73. package/src/services/alias-shell-writer.ts +800 -803
  74. package/src/services/alias-store.ts +77 -77
  75. package/src/services/claude-settings.ts +27 -18
  76. package/src/services/content-drift.ts +13 -3
  77. package/src/services/conventions-manager.ts +692 -617
  78. package/src/services/doctor.ts +464 -403
  79. package/src/services/gitignore-detector.ts +113 -103
  80. package/src/services/gitignore-fixer.ts +204 -189
  81. package/src/services/gitignore-prerun.ts +26 -26
  82. package/src/services/gitignore-resolver.ts +162 -144
  83. package/src/services/gitignore-service.ts +120 -117
  84. package/src/services/install-plan.ts +4 -4
  85. package/src/services/manifest.ts +1 -1
  86. package/src/services/marketplace-catalog-git.ts +5 -2
  87. package/src/services/marketplace-fetcher.ts +241 -235
  88. package/src/services/plugin-manager.ts +17 -20
  89. package/src/services/plugin-mcp-config.ts +2 -2
  90. package/src/services/plugin-requires.ts +9 -6
  91. package/src/services/plugin-setup.ts +7 -9
  92. package/src/services/plugin-version-check.ts +4 -4
  93. package/src/services/profile-materializer.ts +9 -5
  94. package/src/services/profile-sync.ts +12 -4
  95. package/src/services/profiles.ts +3 -3
  96. package/src/services/resolver.ts +10 -6
  97. package/src/services/settings-manager.ts +82 -46
  98. package/src/services/skills-manager.ts +11 -9
  99. package/src/services/symlink-manager.ts +2 -5
  100. package/src/services/toolchain.ts +3 -3
  101. package/src/services/update-cache.ts +1 -1
  102. package/src/services/update-engine.ts +4 -4
  103. package/src/services/update-plan.ts +9 -11
  104. package/src/services/version-snapshot.ts +88 -88
  105. package/src/types/gitignore.ts +28 -28
  106. package/src/ui/App.tsx +36 -36
  107. package/src/ui/adapters/settingsAdapter.ts +2 -2
  108. package/src/ui/components/CategoryHeader.tsx +1 -1
  109. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  110. package/src/ui/components/ScopeIndicator.tsx +3 -1
  111. package/src/ui/components/ScrollableList.tsx +2 -1
  112. package/src/ui/components/StyledText.tsx +1 -1
  113. package/src/ui/components/layout/FooterHints.tsx +5 -3
  114. package/src/ui/components/layout/Panel.tsx +2 -2
  115. package/src/ui/components/layout/ScreenLayout.tsx +5 -4
  116. package/src/ui/components/modals/InputModal.tsx +7 -1
  117. package/src/ui/components/modals/ModalContainer.tsx +3 -3
  118. package/src/ui/components/modals/SelectModal.tsx +1 -1
  119. package/src/ui/components/primitives/DetailSection.tsx +1 -1
  120. package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
  121. package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
  122. package/src/ui/hooks/index.ts +0 -1
  123. package/src/ui/hooks/useGitignoreModal.ts +1 -5
  124. package/src/ui/hooks/useMismatchModal.ts +4 -10
  125. package/src/ui/renderers/cliToolRenderers.tsx +2 -2
  126. package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
  127. package/src/ui/renderers/mcpRenderers.tsx +1 -1
  128. package/src/ui/renderers/pluginRenderers.tsx +3 -9
  129. package/src/ui/renderers/profileRenderers.tsx +9 -8
  130. package/src/ui/renderers/settingsRenderers.tsx +4 -4
  131. package/src/ui/renderers/skillRenderers.tsx +14 -14
  132. package/src/ui/screens/AliasScreen.tsx +31 -34
  133. package/src/ui/screens/CliToolsScreen.tsx +14 -13
  134. package/src/ui/screens/EnvVarsScreen.tsx +8 -8
  135. package/src/ui/screens/GitignoreScreen.tsx +15 -14
  136. package/src/ui/screens/McpRegistryScreen.tsx +10 -7
  137. package/src/ui/screens/McpScreen.tsx +10 -10
  138. package/src/ui/screens/PluginsScreen.tsx +17 -21
  139. package/src/ui/screens/ProfilesScreen.tsx +25 -24
  140. package/src/ui/screens/SkillsScreen.tsx +16 -14
  141. package/src/ui/state/AppContext.tsx +4 -8
  142. package/src/ui/state/DimensionsContext.tsx +3 -2
  143. package/src/ui/theme-mode.ts +20 -38
  144. package/src/ui/theme-resolve.ts +148 -0
  145. package/src/utils/clipboard.ts +5 -5
  146. package/src/utils/command-utils.ts +2 -2
  147. package/src/utils/config-dir.ts +1 -3
  148. package/src/utils/file-locking.ts +88 -86
  149. package/src/ui/hooks/useAsyncData.ts +0 -127
  150. 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,9 +7,9 @@
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,
@@ -80,9 +80,7 @@ function parseSetup(setup: unknown): BinRequirement[] {
80
80
  const s = setup as Record<string, unknown>;
81
81
  const bins: BinRequirement[] = [];
82
82
 
83
- const pkgManager = (
84
- key: "bun" | "npm" | "pip",
85
- ): void => {
83
+ const pkgManager = (key: "bun" | "npm" | "pip"): void => {
86
84
  for (const spec of (s[key] as string[] | undefined) ?? []) {
87
85
  if (typeof spec !== "string") continue;
88
86
  const { base, version } = splitVersion(spec);
@@ -116,7 +114,12 @@ function parseSetup(setup: unknown): BinRequirement[] {
116
114
  }
117
115
  for (const spec of (s.brew as string[] | undefined) ?? []) {
118
116
  if (typeof spec !== "string") continue;
119
- 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
+ });
120
123
  }
121
124
  // `required` names a system tool that must be present; brew is the assumed
122
125
  // installer (matches the "system tools via brew" decision).
@@ -26,12 +26,12 @@
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
36
  import { goBinaryName } from "../utils/go-module.js";
37
37
 
@@ -800,10 +800,10 @@ export async function getPluginSetupConfig(
800
800
 
801
801
  // Find the version directory
802
802
  const versions = await fs.readdir(pluginPath);
803
- if (versions.length === 0) return null;
804
803
 
805
804
  // Use the latest version directory
806
- const latestVersion = versions.sort().pop()!;
805
+ const latestVersion = versions.sort().pop();
806
+ if (latestVersion === undefined) return null;
807
807
  const manifestPath = path.join(pluginPath, latestVersion, "plugin.json");
808
808
 
809
809
  if (!(await fs.pathExists(manifestPath))) return null;
@@ -843,9 +843,7 @@ export async function getPluginSetupFromSource(
843
843
  try {
844
844
  const manifest = await fs.readJson(pluginJson);
845
845
  return manifest.setup || null;
846
- } catch {
847
- continue;
848
- }
846
+ } catch {}
849
847
  }
850
848
  }
851
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) {
@@ -14,8 +14,8 @@
14
14
  * survive switches untouched.
15
15
  */
16
16
 
17
- import fs from "fs-extra";
18
17
  import path from "node:path";
18
+ import fs from "fs-extra";
19
19
 
20
20
  /**
21
21
  * Paths that must be gitignored once a project uses profiles: the generated
@@ -113,10 +113,7 @@ async function isProfileLink(
113
113
  try {
114
114
  const stat = await fs.lstat(link);
115
115
  if (!stat.isSymbolicLink()) return false;
116
- const resolved = path.resolve(
117
- path.dirname(link),
118
- await fs.readlink(link),
119
- );
116
+ const resolved = path.resolve(path.dirname(link), await fs.readlink(link));
120
117
  return resolved.startsWith(profilesRoot(projectPath) + path.sep);
121
118
  } catch {
122
119
  return false;
@@ -127,9 +127,9 @@ let pythonCliMemo: PythonCliInstaller | undefined;
127
127
  */
128
128
  export function resolvePythonCliInstaller(): PythonCliInstaller {
129
129
  if (pythonCliMemo) return pythonCliMemo;
130
- pythonCliMemo =
131
- PYTHON_CLI_INSTALLERS.find((i) => whichSync(i.probe) !== null) ??
132
- PYTHON_CLI_INSTALLERS.at(-1)!;
130
+ const found = PYTHON_CLI_INSTALLERS.find((i) => whichSync(i.probe) !== null);
131
+ const fallback = PYTHON_CLI_INSTALLERS[PYTHON_CLI_INSTALLERS.length - 1];
132
+ pythonCliMemo = found ?? fallback;
133
133
  return pythonCliMemo;
134
134
  }
135
135
 
@@ -1,6 +1,6 @@
1
1
  import { promises as fs } from "node:fs";
2
- import path from "node:path";
3
2
  import os from "node:os";
3
+ import path from "node:path";
4
4
 
5
5
  const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
6
6
 
@@ -30,6 +30,10 @@
30
30
  */
31
31
 
32
32
  import path from "node:path";
33
+ import type { ProfileSkillRef, SkillInfo } from "../types/index.js";
34
+ import type { Command } from "../utils/command-utils.js";
35
+ import { formatCommand } from "../utils/command-utils.js";
36
+ import { runCommand } from "../utils/run.js";
33
37
  import type { PluginScope } from "./claude-cli.js";
34
38
  import { installPlugin, repairPlugin, updatePlugin } from "./claude-cli.js";
35
39
  import {
@@ -39,10 +43,6 @@ import {
39
43
  import { installSkill } from "./skills-manager.js";
40
44
  import { binInstallCommand, binUpgradeCommand } from "./toolchain.js";
41
45
  import type { PluginUpdateItem, UpdatePlan } from "./update-plan.js";
42
- import type { ProfileSkillRef, SkillInfo } from "../types/index.js";
43
- import type { Command } from "../utils/command-utils.js";
44
- import { formatCommand } from "../utils/command-utils.js";
45
- import { runCommand } from "../utils/run.js";
46
46
 
47
47
  // -- results -----------------------------------------------------------------
48
48