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,6 +1,6 @@
1
- import { describe, it, expect } from "bun:test";
2
- import { buildCatalogNoticeText } from "../ui/adapters/catalogNotice";
1
+ import { describe, expect, it } from "bun:test";
3
2
  import type { MarketplaceFetchFailure } from "../services/marketplace-fetcher";
3
+ import { buildCatalogNoticeText } from "../ui/adapters/catalogNotice";
4
4
 
5
5
  /**
6
6
  * The banner is the only thing standing between the user and a screen that
@@ -17,139 +17,139 @@ import type { MarketplaceFetchFailure } from "../services/marketplace-fetcher";
17
17
  const NOW = 1_786_980_000_000;
18
18
 
19
19
  function failure(
20
- marketplace: string,
21
- kind: MarketplaceFetchFailure["kind"] = "rate-limited",
22
- retryAt?: number,
20
+ marketplace: string,
21
+ kind: MarketplaceFetchFailure["kind"] = "rate-limited",
22
+ retryAt?: number,
23
23
  ): MarketplaceFetchFailure {
24
- return { marketplace, kind, detail: `${kind} detail`, retryAt };
24
+ return { marketplace, kind, detail: `${kind} detail`, retryAt };
25
25
  }
26
26
 
27
27
  describe("buildCatalogNoticeText", () => {
28
- it("says nothing when every catalog was actually checked", () => {
29
- // The negative control. A banner that is always on carries no information.
30
- expect(
31
- buildCatalogNoticeText({
32
- failures: [],
33
- unverifiedPlugins: 0,
34
- width: 120,
35
- now: NOW,
36
- }),
37
- ).toBeNull();
38
- });
28
+ it("says nothing when every catalog was actually checked", () => {
29
+ // The negative control. A banner that is always on carries no information.
30
+ expect(
31
+ buildCatalogNoticeText({
32
+ failures: [],
33
+ unverifiedPlugins: 0,
34
+ width: 120,
35
+ now: NOW,
36
+ }),
37
+ ).toBeNull();
38
+ });
39
39
 
40
- it("names the reason, because it determines what the user should do", () => {
41
- const rate = buildCatalogNoticeText({
42
- failures: [failure("magus")],
43
- unverifiedPlugins: 3,
44
- width: 120,
45
- now: NOW,
46
- });
47
- expect(rate).toContain("GitHub rate limit");
40
+ it("names the reason, because it determines what the user should do", () => {
41
+ const rate = buildCatalogNoticeText({
42
+ failures: [failure("magus")],
43
+ unverifiedPlugins: 3,
44
+ width: 120,
45
+ now: NOW,
46
+ });
47
+ expect(rate).toContain("GitHub rate limit");
48
48
 
49
- const slow = buildCatalogNoticeText({
50
- failures: [failure("magus", "timeout")],
51
- unverifiedPlugins: 3,
52
- width: 120,
53
- now: NOW,
54
- });
55
- expect(slow).toContain("GitHub timed out");
56
- });
49
+ const slow = buildCatalogNoticeText({
50
+ failures: [failure("magus", "timeout")],
51
+ unverifiedPlugins: 3,
52
+ width: 120,
53
+ now: NOW,
54
+ });
55
+ expect(slow).toContain("GitHub timed out");
56
+ });
57
57
 
58
- it("names one or two failing marketplaces but only counts many", () => {
59
- const few = buildCatalogNoticeText({
60
- failures: [failure("magus"), failure("magus-alpha")],
61
- unverifiedPlugins: 0,
62
- width: 160,
63
- now: NOW,
64
- });
65
- expect(few).toContain("magus-alpha");
58
+ it("names one or two failing marketplaces but only counts many", () => {
59
+ const few = buildCatalogNoticeText({
60
+ failures: [failure("magus"), failure("magus-alpha")],
61
+ unverifiedPlugins: 0,
62
+ width: 160,
63
+ now: NOW,
64
+ });
65
+ expect(few).toContain("magus-alpha");
66
66
 
67
- const many = buildCatalogNoticeText({
68
- failures: ["a", "b", "c", "d", "e", "f"].map((n) => failure(n)),
69
- unverifiedPlugins: 0,
70
- width: 160,
71
- now: NOW,
72
- });
73
- expect(many).toContain("6 catalogs unchecked");
74
- });
67
+ const many = buildCatalogNoticeText({
68
+ failures: ["a", "b", "c", "d", "e", "f"].map((n) => failure(n)),
69
+ unverifiedPlugins: 0,
70
+ width: 160,
71
+ now: NOW,
72
+ });
73
+ expect(many).toContain("6 catalogs unchecked");
74
+ });
75
75
 
76
- it("counts down to the retry instead of saying 'later'", () => {
77
- // Answering "when can I try again" is the point. Guessing costs another
78
- // request against the very limit the user is waiting out.
79
- const line = buildCatalogNoticeText({
80
- failures: [failure("magus", "rate-limited", NOW + 240_000)],
81
- unverifiedPlugins: 17,
82
- width: 160,
83
- now: NOW,
84
- }) as string;
85
- expect(line).toContain("retrying in 4m");
86
- });
76
+ it("counts down to the retry instead of saying 'later'", () => {
77
+ // Answering "when can I try again" is the point. Guessing costs another
78
+ // request against the very limit the user is waiting out.
79
+ const line = buildCatalogNoticeText({
80
+ failures: [failure("magus", "rate-limited", NOW + 240_000)],
81
+ unverifiedPlugins: 17,
82
+ width: 160,
83
+ now: NOW,
84
+ }) as string;
85
+ expect(line).toContain("retrying in 4m");
86
+ });
87
87
 
88
- it("uses the soonest retry when several catalogs are waiting", () => {
89
- const line = buildCatalogNoticeText({
90
- failures: [
91
- failure("magus", "rate-limited", NOW + 600_000),
92
- failure("magus-alpha", "rate-limited", NOW + 45_000),
93
- ],
94
- unverifiedPlugins: 2,
95
- width: 160,
96
- now: NOW,
97
- }) as string;
98
- expect(line).toContain("retrying in 45s");
99
- });
88
+ it("uses the soonest retry when several catalogs are waiting", () => {
89
+ const line = buildCatalogNoticeText({
90
+ failures: [
91
+ failure("magus", "rate-limited", NOW + 600_000),
92
+ failure("magus-alpha", "rate-limited", NOW + 45_000),
93
+ ],
94
+ unverifiedPlugins: 2,
95
+ width: 160,
96
+ now: NOW,
97
+ }) as string;
98
+ expect(line).toContain("retrying in 45s");
99
+ });
100
100
 
101
- it("falls back to the manual hint when no retry time is known", () => {
102
- // raw.githubusercontent.com sends no retry-after, so for some failures there
103
- // is genuinely no time to show. Inventing one would be worse than omitting it.
104
- const line = buildCatalogNoticeText({
105
- failures: [failure("magus", "network")],
106
- unverifiedPlugins: 1,
107
- width: 160,
108
- now: NOW,
109
- }) as string;
110
- expect(line).toContain("r to retry");
111
- expect(line).not.toContain("retrying in");
112
- });
101
+ it("falls back to the manual hint when no retry time is known", () => {
102
+ // raw.githubusercontent.com sends no retry-after, so for some failures there
103
+ // is genuinely no time to show. Inventing one would be worse than omitting it.
104
+ const line = buildCatalogNoticeText({
105
+ failures: [failure("magus", "network")],
106
+ unverifiedPlugins: 1,
107
+ width: 160,
108
+ now: NOW,
109
+ }) as string;
110
+ expect(line).toContain("r to retry");
111
+ expect(line).not.toContain("retrying in");
112
+ });
113
113
 
114
- it("ignores a retry time that has already passed", () => {
115
- const line = buildCatalogNoticeText({
116
- failures: [failure("magus", "rate-limited", NOW - 5_000)],
117
- unverifiedPlugins: 1,
118
- width: 160,
119
- now: NOW,
120
- }) as string;
121
- expect(line).toContain("r to retry");
122
- });
114
+ it("ignores a retry time that has already passed", () => {
115
+ const line = buildCatalogNoticeText({
116
+ failures: [failure("magus", "rate-limited", NOW - 5_000)],
117
+ unverifiedPlugins: 1,
118
+ width: 160,
119
+ now: NOW,
120
+ }) as string;
121
+ expect(line).toContain("r to retry");
122
+ });
123
123
 
124
- it("keeps a retry affordance at every width, however narrow", () => {
125
- const failures = ["a", "b", "c", "d", "e", "f"].map((n) =>
126
- failure(n, "rate-limited", NOW + 90_000),
127
- );
128
- for (const width of [40, 60, 80, 100, 120, 200]) {
129
- const line = buildCatalogNoticeText({
130
- failures,
131
- unverifiedPlugins: 331,
132
- width,
133
- now: NOW,
134
- }) as string;
135
- expect(line).not.toBeNull();
136
- expect(line).toContain("retrying in");
137
- }
138
- });
124
+ it("keeps a retry affordance at every width, however narrow", () => {
125
+ const failures = ["a", "b", "c", "d", "e", "f"].map((n) =>
126
+ failure(n, "rate-limited", NOW + 90_000),
127
+ );
128
+ for (const width of [40, 60, 80, 100, 120, 200]) {
129
+ const line = buildCatalogNoticeText({
130
+ failures,
131
+ unverifiedPlugins: 331,
132
+ width,
133
+ now: NOW,
134
+ }) as string;
135
+ expect(line).not.toBeNull();
136
+ expect(line).toContain("retrying in");
137
+ }
138
+ });
139
139
 
140
- it("never renders wider than the pane", () => {
141
- const failures = ["a", "b", "c", "d", "e", "f"].map((n) =>
142
- failure(n, "rate-limited", NOW + 90_000),
143
- );
144
- for (const width of [40, 60, 80, 100, 120, 200]) {
145
- const line = buildCatalogNoticeText({
146
- failures,
147
- unverifiedPlugins: 331,
148
- width,
149
- now: NOW,
150
- }) as string;
151
- // +1 for `⚠` rendering double-width while String.length counts it once.
152
- expect(line.length + 1).toBeLessThanOrEqual(width);
153
- }
154
- });
140
+ it("never renders wider than the pane", () => {
141
+ const failures = ["a", "b", "c", "d", "e", "f"].map((n) =>
142
+ failure(n, "rate-limited", NOW + 90_000),
143
+ );
144
+ for (const width of [40, 60, 80, 100, 120, 200]) {
145
+ const line = buildCatalogNoticeText({
146
+ failures,
147
+ unverifiedPlugins: 331,
148
+ width,
149
+ now: NOW,
150
+ }) as string;
151
+ // +1 for `⚠` rendering double-width while String.length counts it once.
152
+ expect(line.length + 1).toBeLessThanOrEqual(width);
153
+ }
154
+ });
155
155
  });
@@ -11,16 +11,16 @@
11
11
  */
12
12
 
13
13
  import { describe, expect, mock, test } from "bun:test";
14
+ import { parseArgs } from "../cli/update.js";
15
+ import type { PluginScope } from "../services/claude-cli.js";
16
+ import { checkBinaries } from "../services/doctor-bins.js";
14
17
  import {
15
18
  type ApplyReporter,
16
19
  type PluginApplyDeps,
17
20
  applyPlugins,
18
- pluginNeedsWork,
19
21
  installPluginInScope,
22
+ pluginNeedsWork,
20
23
  } from "../services/update-engine.js";
21
- import { parseArgs } from "../cli/update.js";
22
- import { checkBinaries } from "../services/doctor-bins.js";
23
- import type { PluginScope } from "../services/claude-cli.js";
24
24
  import type { PluginUpdateItem } from "../services/update-plan.js";
25
25
  import type { ResolvedBin } from "../types/index.js";
26
26
 
@@ -259,8 +259,10 @@ describe("checkBinaries onProbe", () => {
259
259
  // A meter fed the raw index reads 0/3 while the first probe runs, which
260
260
  // is correct — it has finished none of them.
261
261
  const first: number[] = [];
262
- await checkBinaries(bins, async () => true, (_n, index) =>
263
- first.push(index),
262
+ await checkBinaries(
263
+ bins,
264
+ async () => true,
265
+ (_n, index) => first.push(index),
264
266
  );
265
267
  expect(first[0]).toBe(0);
266
268
  expect(first.at(-1)).toBe(bins.length - 1);
@@ -273,8 +275,10 @@ describe("checkBinaries onProbe", () => {
273
275
 
274
276
  test("an empty bin list announces nothing", async () => {
275
277
  const seen: string[] = [];
276
- const results = await checkBinaries([], async () => true, (n) =>
277
- seen.push(n),
278
+ const results = await checkBinaries(
279
+ [],
280
+ async () => true,
281
+ (n) => seen.push(n),
278
282
  );
279
283
  expect(results).toEqual([]);
280
284
  expect(seen).toEqual([]);
@@ -1,5 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
- import { route } from "../cli/router.js";
2
+ import { spawnSync } from "node:child_process";
3
+ import path from "node:path";
4
+ import { parseThemeFlag, route } from "../cli/router.js";
3
5
 
4
6
  // Silence the help banner and capture what the router prints, without
5
7
  // touching the network (--version) or spawning processes (claude/update).
@@ -17,9 +19,7 @@ beforeEach(() => {
17
19
  errSpy = mock((...a: unknown[]) => {
18
20
  errs.push(a.join(" "));
19
21
  });
20
- // biome-ignore lint/suspicious/noExplicitAny: test double
21
22
  console.log = logSpy as any;
22
- // biome-ignore lint/suspicious/noExplicitAny: test double
23
23
  console.error = errSpy as any;
24
24
  });
25
25
 
@@ -109,3 +109,132 @@ describe("update / upgrade split", () => {
109
109
  expect(help).not.toContain("update Update claudeup itself");
110
110
  });
111
111
  });
112
+
113
+ describe("--theme", () => {
114
+ test("T-flag-ok: --theme light launches the TUI carrying the theme", async () => {
115
+ const r = await route(["--theme", "light"], "1.0.0");
116
+ expect(r).toEqual({ handled: false, launchTui: true, theme: "light" });
117
+ });
118
+
119
+ test("T-flag-ok: --theme=dark is the same flag", async () => {
120
+ const r = await route(["--theme=dark"], "1.0.0");
121
+ expect(r).toEqual({ handled: false, launchTui: true, theme: "dark" });
122
+ });
123
+
124
+ test("no --theme leaves the outcome without a theme key", async () => {
125
+ const r = await route([], "1.0.0");
126
+ expect("theme" in r).toBe(false);
127
+ });
128
+
129
+ test.each([
130
+ [["--theme", "solarized"]],
131
+ [["--theme", "purple"]],
132
+ [["--theme", "Dark"]],
133
+ [["--theme", "auto"]],
134
+ [["--theme"]],
135
+ [["--theme", "--no-refresh"]],
136
+ [["--theme="]],
137
+ ])(
138
+ "T-flag-bad: %j is handled with exit 1 and names both words",
139
+ async (argv) => {
140
+ const r = await route(argv, "1.0.0");
141
+
142
+ expect(r).toEqual({ handled: true, exitCode: 1, launchTui: false });
143
+ const err = errs.join("\n");
144
+ expect(err).toContain("light");
145
+ expect(err).toContain("dark");
146
+ expect(err).toContain("--theme");
147
+ },
148
+ );
149
+
150
+ test("T-flag-cmd: --theme dark update strips the flag and dispatches update", () => {
151
+ // The seam: parseThemeFlag is what route() dispatches on. Asserting on
152
+ // `rest` proves `update` is the command token that reaches the switch,
153
+ // without running the real update handler against this machine.
154
+ const parsed = parseThemeFlag(["--theme", "dark", "update", "--dry-run"]);
155
+
156
+ expect(parsed).toEqual({
157
+ ok: true,
158
+ theme: "dark",
159
+ rest: ["update", "--dry-run"],
160
+ });
161
+ });
162
+
163
+ test("T-flag-cmd-route: route() hands update the stripped argv, not the original", async () => {
164
+ // The parse test above cannot catch a route() that re-reads the original
165
+ // argv inside the switch. Mock the command seam and assert what reached it.
166
+ // mock.module is process-global and survives mock.restore(), so the real
167
+ // module is put back at the end — a later file in the same `bun test`
168
+ // process must not inherit the fake.
169
+ const actual = { ...(await import("../cli/update.js")) };
170
+ const calls: string[][] = [];
171
+ mock.module("../cli/update.js", () => ({
172
+ ...actual,
173
+ runUpdateCommand: async (args: string[]) => {
174
+ calls.push(args);
175
+ return 0;
176
+ },
177
+ }));
178
+ try {
179
+ const { route: routeWithMock } = await import("../cli/router.js");
180
+ const r = await routeWithMock(
181
+ ["--theme", "dark", "update", "--dry-run"],
182
+ "1.0.0",
183
+ );
184
+
185
+ expect(r).toEqual({ handled: true, exitCode: 0, launchTui: false });
186
+ expect(calls).toEqual([["--dry-run"]]);
187
+ } finally {
188
+ mock.module("../cli/update.js", () => actual);
189
+ }
190
+ });
191
+
192
+ test("T-flag-bad: an invalid --theme does not launch the TUI even when a command follows", async () => {
193
+ const r = await route(["--theme", "purple", "update"], "1.0.0");
194
+
195
+ expect(r).toEqual({ handled: true, exitCode: 1, launchTui: false });
196
+ expect(errs.join("\n")).toContain("purple");
197
+ });
198
+
199
+ test("T-flag-cmd: the flag may follow the command too", () => {
200
+ expect(parseThemeFlag(["update", "--theme=light"])).toEqual({
201
+ ok: true,
202
+ theme: "light",
203
+ rest: ["update"],
204
+ });
205
+ });
206
+
207
+ test("T-flag-help: --help wins over a bad --theme, and documents the flag", async () => {
208
+ const r = await route(["--theme", "bogus", "--help"], "1.0.0");
209
+
210
+ expect(r).toEqual({ handled: true, exitCode: 0, launchTui: false });
211
+ const help = logs.join("\n");
212
+ expect(help).toContain("--theme light|dark");
213
+ expect(help).toContain("CLAUDEUP_THEME");
214
+ expect(help).toContain("TERM_THEME");
215
+ expect(errs).toEqual([]);
216
+ });
217
+
218
+ test("T-flag-live: `bun src/main.tsx --theme bogus` exits 1 before the renderer", () => {
219
+ // Exits in the router, so no tty is needed. stdin is a pipe on purpose:
220
+ // were the renderer reached, createCliRenderer would fail differently.
221
+ // --no-env-file: Bun autoloads the cwd's .env before user code, and the
222
+ // theme snapshot must precede any .env merge — same flag as the launchers.
223
+ const result = spawnSync(
224
+ "bun",
225
+ [
226
+ "--no-env-file",
227
+ path.join(import.meta.dir, "..", "main.tsx"),
228
+ "--theme",
229
+ "bogus",
230
+ ],
231
+ { encoding: "utf-8", input: "" },
232
+ );
233
+
234
+ expect(result.status).toBe(1);
235
+ expect(result.stderr).toContain("--theme");
236
+ expect(result.stderr).toContain("light");
237
+ expect(result.stderr).toContain("dark");
238
+ expect(result.stderr).toContain("bogus");
239
+ });
240
+ });
@@ -13,7 +13,6 @@
13
13
 
14
14
  import { afterEach, describe, expect, test } from "bun:test";
15
15
  import { setColorEnabled, stripAnsi, width } from "../cli/ansi.js";
16
- import type { UpdatePlan } from "../services/update-plan.js";
17
16
  import {
18
17
  type ApplyState,
19
18
  type Step,
@@ -25,6 +24,7 @@ import {
25
24
  stepRecord,
26
25
  summaryLine,
27
26
  } from "../cli/update-view.js";
27
+ import type { UpdatePlan } from "../services/update-plan.js";
28
28
 
29
29
  afterEach(() => setColorEnabled(null));
30
30
 
@@ -159,7 +159,11 @@ describe("NO_COLOR", () => {
159
159
  ...stepFrame(steps(), 3, 500),
160
160
  ...stepRecord(steps(), 500),
161
161
  ...planReport(plan()),
162
- ...applyFrame({ done: 1, total: 4, current: "dev@magus", startedAt: 0 }, 2, 900),
162
+ ...applyFrame(
163
+ { done: 1, total: 4, current: "dev@magus", startedAt: 0 },
164
+ 2,
165
+ 900,
166
+ ),
163
167
  applyRow(true, "dev@magus", 20, "project", 400),
164
168
  ...summaryLine(2, 5, 1, 0, 9000),
165
169
  ].join("\n");
@@ -218,8 +222,20 @@ describe("bars", () => {
218
222
  setColorEnabled(false);
219
223
  const record = stepRecord(
220
224
  [
221
- { label: "fast", state: "done", detail: "", startedAt: 0, endedAt: 100 },
222
- { label: "slow", state: "done", detail: "", startedAt: 0, endedAt: 1000 },
225
+ {
226
+ label: "fast",
227
+ state: "done",
228
+ detail: "",
229
+ startedAt: 0,
230
+ endedAt: 100,
231
+ },
232
+ {
233
+ label: "slow",
234
+ state: "done",
235
+ detail: "",
236
+ startedAt: 0,
237
+ endedAt: 1000,
238
+ },
223
239
  ],
224
240
  1000,
225
241
  );
@@ -230,7 +246,10 @@ describe("bars", () => {
230
246
 
231
247
  test("a step that never ran does not divide by zero", () => {
232
248
  setColorEnabled(false);
233
- const record = stepRecord([{ label: "x", state: "pending", detail: "" }], 0);
249
+ const record = stepRecord(
250
+ [{ label: "x", state: "pending", detail: "" }],
251
+ 0,
252
+ );
234
253
  expect(record[0]).toContain("░");
235
254
  });
236
255
 
@@ -259,9 +278,9 @@ describe("bars", () => {
259
278
  describe("sections", () => {
260
279
  test("an empty section is omitted entirely, not printed as a heading", () => {
261
280
  setColorEnabled(false);
262
- const out = planReport(
263
- plan({ bins: [], skills: [] }) as UpdatePlan,
264
- ).join("\n");
281
+ const out = planReport(plan({ bins: [], skills: [] }) as UpdatePlan).join(
282
+ "\n",
283
+ );
265
284
  expect(out).toContain("Plugins");
266
285
  expect(out).not.toContain("CLI");
267
286
  expect(out).not.toContain("Skills");
@@ -73,7 +73,6 @@ afterEach(async () => {
73
73
 
74
74
  const source: CommunityStyleSource = COMMUNITY_SOURCES[0];
75
75
  const STYLE_ID = "attention-span--spartan";
76
- // biome-ignore lint/style/noNonNullAssertion: a registry entry the tests pin
77
76
  const style = resolveCommunityStyle(STYLE_ID)!.style;
78
77
 
79
78
  function upstreamText(body: string): string {
@@ -596,7 +595,6 @@ describe("the budget, which is the binding constraint", () => {
596
595
  "nattergabriel--socratic",
597
596
  ];
598
597
  for (const id of oneEach) {
599
- // biome-ignore lint/style/noNonNullAssertion: ids pinned from the registry
600
598
  const sourceId = resolveCommunityStyle(id)!.source.id;
601
599
  await writeCommunityCheck(sourceId, {
602
600
  checkedAt: NOW - 2 * COMMUNITY_CHECK_TTL_MS,
@@ -33,11 +33,11 @@ function contrast(a: string, b: string): number {
33
33
  const KINDS = ["skill", "command", "agent", "mcp"] as const;
34
34
 
35
35
  describe("componentBadge", () => {
36
- it("returns no chip when the terminal never answered the theme query", () => {
37
- // Unknown means no tint, the same rule the scope bar's fill follows: a
38
- // light wash guessed onto a dark page is a bright band on every row.
36
+ it("serves the dark pair before any resolution", () => {
37
+ // There is no unknown: dark is the stated default, so a chip is always a
38
+ // pair, never null, and the caller has no bare-glyph branch to keep.
39
39
  for (const kind of KINDS) {
40
- expect(componentBadge(kind)).toBeNull();
40
+ expect(componentBadge(kind)).toEqual(COMPONENT_BADGE[kind].dark);
41
41
  }
42
42
  });
43
43
 
@@ -53,7 +53,10 @@ beforeEach(async () => {
53
53
  recursive: true,
54
54
  });
55
55
  await fs.mkdir(path.join(repo, "plugins", "beta"), { recursive: true });
56
- await fs.writeFile(path.join(repo, "plugins", "alpha", "skills", "s.md"), "v1");
56
+ await fs.writeFile(
57
+ path.join(repo, "plugins", "alpha", "skills", "s.md"),
58
+ "v1",
59
+ );
57
60
  await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v1");
58
61
  await setVersion("alpha", "1.0.0");
59
62
  await setVersion("beta", "1.0.0");
@@ -66,6 +69,7 @@ beforeEach(async () => {
66
69
  });
67
70
 
68
71
  afterEach(async () => {
72
+ // biome-ignore lint/performance/noDelete: assigning undefined to process.env stores the string "undefined"; only delete unsets the variable
69
73
  if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
70
74
  else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
71
75
  await fs.rm(configDir, { recursive: true, force: true });