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
@@ -16,17 +16,6 @@ import {
16
16
  setMcpEnvVar,
17
17
  } from "../../services/claude-settings.js";
18
18
  import { clearContentDriftCache } from "../../services/content-drift.js";
19
- // The one update implementation, shared with `claudeup update`. See the header
20
- // of update-engine.ts for what drifted while the TUI had its own loop.
21
- import {
22
- type ApplyReporter,
23
- REAL_PLUGIN_DEPS,
24
- applyPlugins,
25
- pluginNeedsWork,
26
- installPluginInScope,
27
- updatePluginInScope,
28
- } from "../../services/update-engine.js";
29
- import { planPluginUpdates } from "../../services/update-plan.js";
30
19
  import {
31
20
  type MarketplaceFetchFailure,
32
21
  type PluginInfo,
@@ -58,6 +47,17 @@ import {
58
47
  fetchPluginContents,
59
48
  fetchPluginSkillDetail,
60
49
  } from "../../services/skills-manager.js";
50
+ // The one update implementation, shared with `claudeup update`. See the header
51
+ // of update-engine.ts for what drifted while the TUI had its own loop.
52
+ import {
53
+ type ApplyReporter,
54
+ REAL_PLUGIN_DEPS,
55
+ applyPlugins,
56
+ installPluginInScope,
57
+ pluginNeedsWork,
58
+ updatePluginInScope,
59
+ } from "../../services/update-engine.js";
60
+ import { planPluginUpdates } from "../../services/update-plan.js";
61
61
  import {
62
62
  diffVersions,
63
63
  loadSeenVersions,
@@ -184,6 +184,7 @@ export function PluginsScreen() {
184
184
  }, [dispatch, state.projectPath]);
185
185
 
186
186
  // Load data on mount or when dataRefreshVersion changes
187
+ // biome-ignore lint/correctness/useExhaustiveDependencies: dataRefreshVersion is the external refresh trigger; bumping it must re-run the fetch even though the body never reads it
187
188
  useEffect(() => {
188
189
  fetchData();
189
190
  }, [fetchData, state.dataRefreshVersion]);
@@ -725,7 +726,7 @@ export function PluginsScreen() {
725
726
  if (value === null) {
726
727
  await modal.message(
727
728
  "Configuration Incomplete",
728
- `Skipped remaining configuration.\nYou can configure these later in Environment Variables (press 4).`,
729
+ "Skipped remaining configuration.\nYou can configure these later in Environment Variables (press 4).",
729
730
  "info",
730
731
  );
731
732
  return true;
@@ -821,9 +822,7 @@ export function PluginsScreen() {
821
822
  } else {
822
823
  await modal.message(
823
824
  `Remove ${mp.displayName}?`,
824
- `To remove this marketplace, run in Claude Code:\n\n` +
825
- ` /plugin marketplace remove ${mp.name}\n\n` +
826
- `After removing, refresh claudeup with 'r' to update the display.`,
825
+ `To remove this marketplace, run in Claude Code:\n\n /plugin marketplace remove ${mp.name}\n\nAfter removing, refresh claudeup with 'r' to update the display.`,
827
826
  "info",
828
827
  );
829
828
  }
@@ -835,8 +834,7 @@ export function PluginsScreen() {
835
834
  const repo = mp.source.repo || mp.name;
836
835
  const wantAdd = await modal.confirm(
837
836
  `Add ${mp.displayName}?`,
838
- `Clone ${repo} and register it as a marketplace?\n\n` +
839
- `Auto-update is enabled by default.`,
837
+ `Clone ${repo} and register it as a marketplace?\n\nAuto-update is enabled by default.`,
840
838
  );
841
839
  if (wantAdd) {
842
840
  try {
@@ -845,9 +843,7 @@ export function PluginsScreen() {
845
843
  } catch (e) {
846
844
  await modal.message(
847
845
  "Could not add marketplace",
848
- `${(e as Error).message}\n\n` +
849
- `Run it yourself if this persists:\n\n` +
850
- ` claude plugin marketplace add ${repo}`,
846
+ `${(e as Error).message}\n\nRun it yourself if this persists:\n\n claude plugin marketplace add ${repo}`,
851
847
  "error",
852
848
  );
853
849
  }
@@ -897,7 +893,7 @@ export function PluginsScreen() {
897
893
 
898
894
  const scopeValue = await modal.select(
899
895
  plugin.name,
900
- `Select scope to toggle:`,
896
+ "Select scope to toggle:",
901
897
  scopeOptions,
902
898
  );
903
899
  if (scopeValue === null) return;
@@ -1,34 +1,34 @@
1
1
  import React, { useEffect, useCallback } from "react";
2
+ import { ScreenLayout } from "../components/layout/index.js";
3
+ import { useKeyboard } from "../hooks/useKeyboard.js";
2
4
  import { useApp, useModal } from "../state/AppContext.js";
3
5
  import { useDimensions } from "../state/DimensionsContext.js";
4
- import { useKeyboard } from "../hooks/useKeyboard.js";
5
- import { ScreenLayout } from "../components/layout/index.js";
6
6
 
7
7
  import {
8
- listProfiles,
8
+ PREDEFINED_PROFILES,
9
+ type PredefinedProfile,
10
+ } from "../../data/predefined-profiles.js";
11
+ import { readSettings, writeSettings } from "../../services/claude-settings.js";
12
+ import {
9
13
  applyProfile,
10
- renameProfile,
11
14
  deleteProfile,
12
15
  exportProfileToJson,
13
16
  importProfileFromJson,
17
+ listProfiles,
18
+ renameProfile,
14
19
  } from "../../services/profiles.js";
15
- import { readSettings, writeSettings } from "../../services/claude-settings.js";
16
20
  import {
17
- writeClipboard,
18
- readClipboard,
19
21
  ClipboardUnavailableError,
22
+ readClipboard,
23
+ writeClipboard,
20
24
  } from "../../utils/clipboard.js";
25
+ import { ScrollableList } from "../components/ScrollableList.js";
21
26
  import {
22
- PREDEFINED_PROFILES,
23
- type PredefinedProfile,
24
- } from "../../data/predefined-profiles.js";
25
- import {
27
+ type ProfileListItem,
26
28
  buildProfileListItems,
27
- renderProfileRow,
28
29
  renderProfileDetail,
29
- type ProfileListItem,
30
+ renderProfileRow,
30
31
  } from "../renderers/profileRenderers.js";
31
- import { ScrollableList } from "../components/ScrollableList.js";
32
32
  import { theme } from "../theme.js";
33
33
 
34
34
  export function ProfilesScreen() {
@@ -50,6 +50,7 @@ export function ProfilesScreen() {
50
50
  }
51
51
  }, [dispatch, state.projectPath]);
52
52
 
53
+ // biome-ignore lint/correctness/useExhaustiveDependencies: dataRefreshVersion is the external refresh trigger; bumping it must re-run the fetch even though the body never reads it
53
54
  useEffect(() => {
54
55
  fetchData();
55
56
  }, [fetchData, state.dataRefreshVersion]);
@@ -63,28 +64,29 @@ export function ProfilesScreen() {
63
64
  const selectedItem: ProfileListItem | undefined =
64
65
  allItems[profilesState.selectedIndex];
65
66
 
66
- const isNavigable = (item: ProfileListItem) => item.kind !== "header";
67
+ const isNavigable = (item: ProfileListItem | undefined) =>
68
+ item !== undefined && item.kind !== "header";
67
69
 
68
70
  useKeyboard((event) => {
69
71
  if (state.isSearching || state.modal) return;
70
72
 
71
73
  if (event.name === "up" || event.name === "k") {
72
74
  let newIndex = profilesState.selectedIndex - 1;
73
- while (newIndex > 0 && !isNavigable(allItems[newIndex]!)) {
75
+ while (newIndex > 0 && !isNavigable(allItems[newIndex])) {
74
76
  newIndex--;
75
77
  }
76
- if (newIndex >= 0 && isNavigable(allItems[newIndex]!)) {
78
+ if (newIndex >= 0 && isNavigable(allItems[newIndex])) {
77
79
  dispatch({ type: "PROFILES_SELECT", index: newIndex });
78
80
  }
79
81
  } else if (event.name === "down" || event.name === "j") {
80
82
  let newIndex = profilesState.selectedIndex + 1;
81
83
  while (
82
84
  newIndex < allItems.length - 1 &&
83
- !isNavigable(allItems[newIndex]!)
85
+ !isNavigable(allItems[newIndex])
84
86
  ) {
85
87
  newIndex++;
86
88
  }
87
- if (newIndex < allItems.length && isNavigable(allItems[newIndex]!)) {
89
+ if (newIndex < allItems.length && isNavigable(allItems[newIndex])) {
88
90
  dispatch({ type: "PROFILES_SELECT", index: newIndex });
89
91
  }
90
92
  } else if (event.name === "enter" || event.name === "a") {
@@ -133,13 +135,13 @@ export function ProfilesScreen() {
133
135
  const envMap = value as Record<string, string>;
134
136
  const existing = settings as Record<string, unknown>;
135
137
  const existingEnv =
136
- (existing["env"] as Record<string, string> | undefined) ?? {};
138
+ (existing.env as Record<string, string> | undefined) ?? {};
137
139
  for (const [envKey, envVal] of Object.entries(envMap)) {
138
140
  if (!existingEnv[envKey]) {
139
141
  existingEnv[envKey] = envVal;
140
142
  }
141
143
  }
142
- (settings as Record<string, unknown>)["env"] = existingEnv;
144
+ (settings as Record<string, unknown>).env = existingEnv;
143
145
  } else {
144
146
  const settingsMap = settings as Record<string, unknown>;
145
147
  if (settingsMap[key] === undefined) {
@@ -304,7 +306,7 @@ export function ProfilesScreen() {
304
306
  await writeClipboard(json);
305
307
  await modal.message(
306
308
  "Copied",
307
- `Profile JSON copied to clipboard.\nShare it with teammates to import.`,
309
+ "Profile JSON copied to clipboard.\nShare it with teammates to import.",
308
310
  "success",
309
311
  );
310
312
  } catch (err) {
@@ -397,8 +399,7 @@ export function ProfilesScreen() {
397
399
  ? firstNavigableIndex
398
400
  : profilesState.selectedIndex;
399
401
 
400
- const loadingStatus =
401
- profilesState.profiles.status === "loading" ? true : false;
402
+ const loadingStatus = profilesState.profiles.status === "loading";
402
403
  const errorMessage =
403
404
  profilesState.profiles.status === "error"
404
405
  ? profilesState.profiles.error.message
@@ -1,3 +1,6 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import fs from "fs-extra";
1
4
  import React, {
2
5
  useEffect,
3
6
  useCallback,
@@ -5,15 +8,16 @@ import React, {
5
8
  useState,
6
9
  useRef,
7
10
  } from "react";
8
- import os from "node:os";
9
- import path from "node:path";
10
- import fs from "fs-extra";
11
+ import { ScreenLayout } from "../components/layout/index.js";
12
+ import { useKeyboard } from "../hooks/useKeyboard.js";
11
13
  import { useApp, useModal } from "../state/AppContext.js";
12
14
  import { useDimensions } from "../state/DimensionsContext.js";
13
- import { useKeyboard } from "../hooks/useKeyboard.js";
14
- import { ScreenLayout } from "../components/layout/index.js";
15
15
 
16
- import { EmptyFilterState } from "../components/EmptyFilterState.js";
16
+ import {
17
+ DEFAULT_SKILL_REPOS,
18
+ type RecommendedSkill,
19
+ classifyStarReliability,
20
+ } from "../../data/skill-repos.js";
17
21
  import {
18
22
  fetchAvailableSkills,
19
23
  fetchSkillFrontmatter,
@@ -25,11 +29,6 @@ import {
25
29
  fetchCuratedSkillsCatalog,
26
30
  searchSkills,
27
31
  } from "../../services/skillsmp-client.js";
28
- import {
29
- DEFAULT_SKILL_REPOS,
30
- classifyStarReliability,
31
- type RecommendedSkill,
32
- } from "../../data/skill-repos.js";
33
32
  import type {
34
33
  SkillInfo,
35
34
  SkillSetInfo,
@@ -37,11 +36,12 @@ import type {
37
36
  } from "../../types/index.js";
38
37
  import { buildSkillBrowserItems } from "../adapters/skillsAdapter.js";
39
38
  import type { SkillBrowserItem } from "../adapters/skillsAdapter.js";
39
+ import { EmptyFilterState } from "../components/EmptyFilterState.js";
40
+ import { ScrollableList } from "../components/ScrollableList.js";
40
41
  import {
41
- renderSkillRow,
42
42
  renderSkillDetail,
43
+ renderSkillRow,
43
44
  } from "../renderers/skillRenderers.js";
44
- import { ScrollableList } from "../components/ScrollableList.js";
45
45
  import { theme } from "../theme.js";
46
46
 
47
47
  export function SkillsScreen() {
@@ -95,6 +95,7 @@ export function SkillsScreen() {
95
95
  }
96
96
  }, [dispatch, state.projectPath]);
97
97
 
98
+ // biome-ignore lint/correctness/useExhaustiveDependencies: dataRefreshVersion is the external refresh trigger; bumping it must re-run the fetch even though the body never reads it
98
99
  useEffect(() => {
99
100
  fetchData();
100
101
  }, [fetchData, state.dataRefreshVersion]);
@@ -208,6 +209,7 @@ export function SkillsScreen() {
208
209
  setInstalledFromDisk({ user, project });
209
210
  }, [state.projectPath]);
210
211
 
212
+ // biome-ignore lint/correctness/useExhaustiveDependencies: dataRefreshVersion is the external refresh trigger; bumping it must re-scan the disk even though the body never reads it
211
213
  useEffect(() => {
212
214
  scanDisk();
213
215
  }, [scanDisk, state.dataRefreshVersion]);
@@ -520,7 +522,7 @@ export function SkillsScreen() {
520
522
  }
521
523
  })
522
524
  .catch(() => {});
523
- }, [selectedSkill?.id, isSkillSetChild, dispatch]);
525
+ }, [selectedSkill, isSkillSetChild, dispatch]);
524
526
 
525
527
  // ── Action handlers ───────────────────────────────────────────────────────
526
528
 
@@ -1,16 +1,12 @@
1
- import React, {
2
- createContext,
3
- useContext,
4
- useReducer,
5
- type ReactNode,
6
- } from "react";
1
+ import type React from "react";
2
+ import { type ReactNode, createContext, useContext, useReducer } from "react";
7
3
  import { appReducer, initialState } from "./reducer.js";
8
4
  import type {
9
- AppState,
10
5
  AppAction,
11
- Route,
6
+ AppState,
12
7
  ModalState,
13
8
  ProgressState,
9
+ Route,
14
10
  } from "./types.js";
15
11
 
16
12
  // ============================================================================
@@ -1,5 +1,6 @@
1
- import React, { createContext, useContext, useState, useEffect } from "react";
2
- import { useRenderer, useOnResize } from "@opentui/react";
1
+ import { useOnResize, useRenderer } from "@opentui/react";
2
+ import type React from "react";
3
+ import { createContext, useContext, useEffect, useState } from "react";
3
4
 
4
5
  interface Dimensions {
5
6
  /** Terminal width */
@@ -13,26 +13,27 @@ import type { ThemeMode } from "@opentui/core";
13
13
  * expressed as "the page, 8% toward the foreground". OpenTUI's alpha blending
14
14
  * lives only in its 3d module, not the text renderer.
15
15
  *
16
- * So we ask. OpenTUI queries the terminal over OSC and reports `themeMode`; we
17
- * read it once at startup. **Unknown means no tint** — a terminal that does not
18
- * answer the query gets today's behaviour rather than a guess that could paint a
19
- * bright band across half the list.
16
+ * So we resolve, once at startup, through `theme-resolve.ts`: flag, then
17
+ * `CLAUDEUP_THEME`, then `TERM_THEME`, then the bounded OSC probe, then
18
+ * `COLORFGBG`, then dark. **There is no unknown.** Resolution always yields a
19
+ * mode; when nothing answered it is dark, because that is the user's stated
20
+ * default, not because dark was detected.
20
21
  */
21
22
 
22
- let detected: ThemeMode | null = null;
23
+ let detected: ThemeMode = "dark";
23
24
 
24
- /** Set once at startup from `renderer.themeMode`. */
25
- export function setThemeMode(mode: ThemeMode | null): void {
25
+ /** Set once at startup from `resolveTheme()`. */
26
+ export function setThemeMode(mode: ThemeMode): void {
26
27
  detected = mode;
27
28
  }
28
29
 
29
- export function getThemeMode(): ThemeMode | null {
30
+ export function getThemeMode(): ThemeMode {
30
31
  return detected;
31
32
  }
32
33
 
33
- /** Test seam — reset detection between cases. */
34
+ /** Test seam — return to the pre-resolution default between cases. */
34
35
  export function resetThemeMode(): void {
35
- detected = null;
36
+ detected = "dark";
36
37
  }
37
38
 
38
39
  /**
@@ -46,8 +47,8 @@ export function resetThemeMode(): void {
46
47
  *
47
48
  * An unlit segment should recede toward the page, and "toward the page" is the
48
49
  * one thing a fixed colour cannot express: a pale fill that sits quietly on cream
49
- * is a bright block on near-black. Hence the two values, chosen per detected
50
- * terminal — this is the only place claudeup asks.
50
+ * is a bright block on near-black. Hence the two values, chosen per resolved
51
+ * mode — this is the only place claudeup asks.
51
52
  */
52
53
  export const SCOPE_OFF_FILL = {
53
54
  /** Just off a light page — a filled but quiet segment. */
@@ -56,20 +57,10 @@ export const SCOPE_OFF_FILL = {
56
57
  dark: "#3A3A40",
57
58
  } as const;
58
59
 
59
- /**
60
- * Neutral fallback when the terminal never answered the OSC query. A mid grey is
61
- * visible on both pages — not as quiet as the tuned values, but never invisible
62
- * and never a bright block. Failing to a wrong extreme would be worse than
63
- * failing to something merely unremarkable.
64
- */
65
- export const SCOPE_OFF_FALLBACK = "#9CA3AF";
66
-
67
60
  /** Fill colour for an unlit scope segment. Always a colour — never undefined,
68
61
  * because the segment is a filled block whatever the terminal turns out to be. */
69
62
  export function scopeOffFill(): string {
70
- if (detected === "light") return SCOPE_OFF_FILL.light;
71
- if (detected === "dark") return SCOPE_OFF_FILL.dark;
72
- return SCOPE_OFF_FALLBACK;
63
+ return SCOPE_OFF_FILL[detected];
73
64
  }
74
65
 
75
66
  /**
@@ -122,19 +113,10 @@ export const CONTRAST_REFERENCE_KEYS = {
122
113
  dark: "#1C1C1E",
123
114
  } as const;
124
115
 
125
- /**
126
- * Chip colours for one component kind, or `null` when the terminal never
127
- * answered the OSC query.
128
- *
129
- * Null means *no chip* — the caller falls back to a bare glyph. That follows the
130
- * rule at the top of this file: an undetected terminal gets today's behaviour
131
- * rather than a guess, and here the wrong guess paints a wash of the wrong end
132
- * behind every row in the list.
133
- */
134
- export function componentBadge(
135
- kind: ComponentBadgeKind,
136
- ): { bg: string; fg: string } | null {
137
- if (detected === "light") return COMPONENT_BADGE[kind].light;
138
- if (detected === "dark") return COMPONENT_BADGE[kind].dark;
139
- return null;
116
+ /** Chip colours for one component kind, for the resolved mode. */
117
+ export function componentBadge(kind: ComponentBadgeKind): {
118
+ bg: string;
119
+ fg: string;
120
+ } {
121
+ return COMPONENT_BADGE[kind][detected];
140
122
  }
@@ -0,0 +1,148 @@
1
+ import type { ThemeMode } from "@opentui/core";
2
+
3
+ /**
4
+ * Terminal theme resolution — the one order claudeup follows, decided once at
5
+ * process start, first answer wins:
6
+ *
7
+ * 1. `--theme` flag
8
+ * 2. `CLAUDEUP_THEME`
9
+ * 3. `TERM_THEME`
10
+ * 4. OSC 10/11 probe (bounded, interactive tty only)
11
+ * 5. `COLORFGBG`
12
+ * 6. dark
13
+ *
14
+ * A step with no answer says nothing and the next one runs; nothing before
15
+ * step 6 ever asserts dark. Every theme word is compared exactly — the byte
16
+ * strings `light` and `dark`, lowercase, untrimmed — because the same test has
17
+ * to run in a POSIX `case` and a tmux format, and only the exact comparison is
18
+ * free in all three.
19
+ *
20
+ * This module is pure by design: no `process` access except as a *default
21
+ * argument* to `snapshotThemeEnv`, no I/O, no renderer. Everything it needs is
22
+ * passed in, so the whole contract runs in a test without a terminal.
23
+ */
24
+
25
+ export type ThemeSource =
26
+ | "flag"
27
+ | "variable"
28
+ | "TERM_THEME"
29
+ | "osc11"
30
+ | "COLORFGBG"
31
+ | "default";
32
+
33
+ export interface ThemeResolution {
34
+ /** Never null — when nothing answered, it is dark by decision. */
35
+ mode: ThemeMode;
36
+ source: ThemeSource;
37
+ }
38
+
39
+ /** The three variables the resolver reads, captured BEFORE .env is merged. */
40
+ export interface ThemeEnv {
41
+ TERM_THEME?: string;
42
+ CLAUDEUP_THEME?: string;
43
+ COLORFGBG?: string;
44
+ }
45
+
46
+ export interface ResolveThemeInput {
47
+ /** Already validated by the router: only "light" | "dark" | undefined arrive here. */
48
+ flag?: ThemeMode;
49
+ env: ThemeEnv;
50
+ /** stdin AND stdout are TTYs. Decided by the caller; the resolver never touches process. */
51
+ isInteractive: boolean;
52
+ /** Bounded OSC 10/11 probe. Called at most once, and only when step 4 is reached. */
53
+ probe: () => Promise<ThemeMode | null>;
54
+ }
55
+
56
+ /** Exact match on "light" | "dark"; everything else is no opinion. */
57
+ export function parseThemeWord(value: string | undefined): ThemeMode | null {
58
+ if (value === "light") return "light";
59
+ if (value === "dark") return "dark";
60
+ return null;
61
+ }
62
+
63
+ /** The literal background fields that answer — the same set as the statusline's `case`. */
64
+ const LIGHT_BACKGROUNDS: ReadonlySet<string> = new Set(["7", "15"]);
65
+ const DARK_BACKGROUNDS: ReadonlySet<string> = new Set([
66
+ "0",
67
+ "1",
68
+ "2",
69
+ "3",
70
+ "4",
71
+ "5",
72
+ "6",
73
+ "8",
74
+ "9",
75
+ "10",
76
+ "11",
77
+ "12",
78
+ "13",
79
+ "14",
80
+ ]);
81
+
82
+ /**
83
+ * Last ";"-field is the background: 7|15 → light, 0-6|8-14 → dark, else null.
84
+ *
85
+ * Exact string membership, not numeric parsing: the statusline's
86
+ * `parse_colorfgbg` is a POSIX `case` over the literal words, so `07`, `015`
87
+ * and `00` are no opinion there, and that literal set is the contract the two
88
+ * tools share. `Number("07")` would make claudeup say light where the
89
+ * statusline says nothing.
90
+ *
91
+ * A value without a `;` is not `<fg>;<bg>` at all — a lone `7` is no opinion,
92
+ * matching the statusline's `case "$1" in *\;*)` guard so the two tools agree.
93
+ */
94
+ export function parseColorFgBg(value: string | undefined): ThemeMode | null {
95
+ if (value === undefined || !value.includes(";")) return null;
96
+ const bg = value.slice(value.lastIndexOf(";") + 1);
97
+ if (LIGHT_BACKGROUNDS.has(bg)) return "light";
98
+ if (DARK_BACKGROUNDS.has(bg)) return "dark";
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * Copy the three variables out of `env` — call this before loadProjectDotenv.
104
+ *
105
+ * Copies, never references: the returned object must not observe later writes
106
+ * to `process.env`, which is exactly what a merged `.env` would be.
107
+ */
108
+ export function snapshotThemeEnv(
109
+ env: Readonly<Record<string, string | undefined>> = process.env,
110
+ ): ThemeEnv {
111
+ const snapshot: ThemeEnv = {};
112
+ if (env.TERM_THEME !== undefined) snapshot.TERM_THEME = env.TERM_THEME;
113
+ if (env.CLAUDEUP_THEME !== undefined)
114
+ snapshot.CLAUDEUP_THEME = env.CLAUDEUP_THEME;
115
+ if (env.COLORFGBG !== undefined) snapshot.COLORFGBG = env.COLORFGBG;
116
+ return snapshot;
117
+ }
118
+
119
+ export async function resolveTheme(
120
+ input: ResolveThemeInput,
121
+ ): Promise<ThemeResolution> {
122
+ if (input.flag !== undefined) {
123
+ return { mode: input.flag, source: "flag" };
124
+ }
125
+
126
+ const variable = parseThemeWord(input.env.CLAUDEUP_THEME);
127
+ if (variable !== null) return { mode: variable, source: "variable" };
128
+
129
+ const termTheme = parseThemeWord(input.env.TERM_THEME);
130
+ if (termTheme !== null) return { mode: termTheme, source: "TERM_THEME" };
131
+
132
+ // Steps 1-3 answered nothing, so this is the only path that probes. Not
133
+ // reaching here is what keeps first paint undelayed when a word answered.
134
+ if (input.isInteractive) {
135
+ let probed: ThemeMode | null = null;
136
+ try {
137
+ probed = await input.probe();
138
+ } catch {
139
+ probed = null;
140
+ }
141
+ if (probed !== null) return { mode: probed, source: "osc11" };
142
+ }
143
+
144
+ const colorFgBg = parseColorFgBg(input.env.COLORFGBG);
145
+ if (colorFgBg !== null) return { mode: colorFgBg, source: "COLORFGBG" };
146
+
147
+ return { mode: "dark", source: "default" };
148
+ }
@@ -27,13 +27,13 @@ export async function writeClipboard(text: string): Promise<void> {
27
27
  export async function readClipboard(): Promise<string> {
28
28
  if (process.platform === "darwin") {
29
29
  return execSync("pbpaste").toString();
30
- } else if (process.platform === "linux") {
30
+ }
31
+ if (process.platform === "linux") {
31
32
  return execSync("xclip -selection clipboard -o").toString();
32
- } else {
33
- throw new ClipboardUnavailableError(
34
- `Clipboard not supported on platform: ${process.platform}`,
35
- );
36
33
  }
34
+ throw new ClipboardUnavailableError(
35
+ `Clipboard not supported on platform: ${process.platform}`,
36
+ );
37
37
  }
38
38
 
39
39
  export class ClipboardUnavailableError extends Error {
@@ -1,8 +1,8 @@
1
1
  import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
2
+ import { constants, accessSync, statSync } from "node:fs";
3
3
  import { access, realpath } from "node:fs/promises";
4
- import { accessSync, constants, statSync } from "node:fs";
5
4
  import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
6
 
7
7
  const execFileAsync = promisify(execFile);
8
8
 
@@ -41,7 +41,5 @@ export function claudeConfigDirOrNull(): string | null {
41
41
 
42
42
  /** The Claude config directory, falling back to `~/.claude` even under test. */
43
43
  export function requireClaudeConfigDir(): string {
44
- return (
45
- process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude")
46
- );
44
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
47
45
  }