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,71 +1,79 @@
1
- import { describe, it, expect } from "bun:test";
1
+ import { describe, expect, it } from "bun:test";
2
2
  import { buildGitignoreModal } from "../ui/hooks/useGitignoreModal";
3
3
 
4
4
  describe("buildGitignoreModal", () => {
5
- it("builds a select modal with three options", () => {
6
- let dismissed = false;
7
- let fixed = false;
8
- let opened = false;
9
- const modal = buildGitignoreModal({
10
- violationCount: 5,
11
- safeCount: 3,
12
- onFixSafe: () => { fixed = true; },
13
- onOpenTab: () => { opened = true; },
14
- onDismiss: () => { dismissed = true; },
15
- });
16
- expect(modal.type).toBe("select");
17
- if (modal.type !== "select") throw new Error("type guard");
18
- expect(modal.options.length).toBe(3);
19
- expect(modal.options[0].value).toBe("fix-safe");
20
- expect(modal.options[1].value).toBe("open-tab");
21
- expect(modal.options[2].value).toBe("dismiss");
22
- expect(modal.options[0].label).toContain("3");
5
+ it("builds a select modal with three options", () => {
6
+ let dismissed = false;
7
+ let fixed = false;
8
+ let opened = false;
9
+ const modal = buildGitignoreModal({
10
+ violationCount: 5,
11
+ safeCount: 3,
12
+ onFixSafe: () => {
13
+ fixed = true;
14
+ },
15
+ onOpenTab: () => {
16
+ opened = true;
17
+ },
18
+ onDismiss: () => {
19
+ dismissed = true;
20
+ },
21
+ });
22
+ expect(modal.type).toBe("select");
23
+ if (modal.type !== "select") throw new Error("type guard");
24
+ expect(modal.options.length).toBe(3);
25
+ expect(modal.options[0].value).toBe("fix-safe");
26
+ expect(modal.options[1].value).toBe("open-tab");
27
+ expect(modal.options[2].value).toBe("dismiss");
28
+ expect(modal.options[0].label).toContain("3");
23
29
 
24
- modal.onSelect("fix-safe");
25
- expect(fixed).toBe(true);
30
+ modal.onSelect("fix-safe");
31
+ expect(fixed).toBe(true);
26
32
 
27
- modal.onSelect("open-tab");
28
- expect(opened).toBe(true);
33
+ modal.onSelect("open-tab");
34
+ expect(opened).toBe(true);
29
35
 
30
- modal.onSelect("dismiss");
31
- expect(dismissed).toBe(true);
32
- });
36
+ modal.onSelect("dismiss");
37
+ expect(dismissed).toBe(true);
38
+ });
33
39
 
34
- it("shows no missing entries label when safeCount is 0", () => {
35
- const modal = buildGitignoreModal({
36
- violationCount: 2,
37
- safeCount: 0,
38
- onFixSafe: () => {},
39
- onOpenTab: () => {},
40
- onDismiss: () => {},
41
- });
42
- if (modal.type !== "select") throw new Error("type guard");
43
- expect(modal.options[0].label).toContain("no missing .gitignore");
44
- });
40
+ it("shows no missing entries label when safeCount is 0", () => {
41
+ const modal = buildGitignoreModal({
42
+ violationCount: 2,
43
+ safeCount: 0,
44
+ onFixSafe: () => {},
45
+ onOpenTab: () => {},
46
+ onDismiss: () => {},
47
+ });
48
+ if (modal.type !== "select") throw new Error("type guard");
49
+ expect(modal.options[0].label).toContain("no missing .gitignore");
50
+ });
45
51
 
46
- it("includes violation count in the message", () => {
47
- const modal = buildGitignoreModal({
48
- violationCount: 7,
49
- safeCount: 2,
50
- onFixSafe: () => {},
51
- onOpenTab: () => {},
52
- onDismiss: () => {},
53
- });
54
- if (modal.type !== "select") throw new Error("type guard");
55
- expect(modal.message).toContain("7");
56
- });
52
+ it("includes violation count in the message", () => {
53
+ const modal = buildGitignoreModal({
54
+ violationCount: 7,
55
+ safeCount: 2,
56
+ onFixSafe: () => {},
57
+ onOpenTab: () => {},
58
+ onDismiss: () => {},
59
+ });
60
+ if (modal.type !== "select") throw new Error("type guard");
61
+ expect(modal.message).toContain("7");
62
+ });
57
63
 
58
- it("calls onCancel as dismiss", () => {
59
- let dismissed = false;
60
- const modal = buildGitignoreModal({
61
- violationCount: 1,
62
- safeCount: 0,
63
- onFixSafe: () => {},
64
- onOpenTab: () => {},
65
- onDismiss: () => { dismissed = true; },
66
- });
67
- if (modal.type !== "select") throw new Error("type guard");
68
- modal.onCancel();
69
- expect(dismissed).toBe(true);
70
- });
64
+ it("calls onCancel as dismiss", () => {
65
+ let dismissed = false;
66
+ const modal = buildGitignoreModal({
67
+ violationCount: 1,
68
+ safeCount: 0,
69
+ onFixSafe: () => {},
70
+ onOpenTab: () => {},
71
+ onDismiss: () => {
72
+ dismissed = true;
73
+ },
74
+ });
75
+ if (modal.type !== "select") throw new Error("type guard");
76
+ modal.onCancel();
77
+ expect(dismissed).toBe(true);
78
+ });
71
79
  });
@@ -3,11 +3,11 @@ import { promises as fs } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import {
6
- diffVersions,
7
- isKnownVersion,
8
- loadSeenVersions,
9
- saveSeenVersions,
10
- UNKNOWN_VERSION,
6
+ UNKNOWN_VERSION,
7
+ diffVersions,
8
+ isKnownVersion,
9
+ loadSeenVersions,
10
+ saveSeenVersions,
11
11
  } from "../services/version-snapshot.js";
12
12
 
13
13
  /**
@@ -18,179 +18,197 @@ import {
18
18
  */
19
19
 
20
20
  describe("isKnownVersion", () => {
21
- test('"0.0.0" is not a usable version', () => {
22
- // Claude Code records this for a plugin whose version it cannot determine.
23
- expect(isKnownVersion(UNKNOWN_VERSION)).toBe(false);
24
- expect(isKnownVersion(undefined)).toBe(false);
25
- expect(isKnownVersion("")).toBe(false);
26
- expect(isKnownVersion("1.0.0")).toBe(true);
27
- });
21
+ test('"0.0.0" is not a usable version', () => {
22
+ // Claude Code records this for a plugin whose version it cannot determine.
23
+ expect(isKnownVersion(UNKNOWN_VERSION)).toBe(false);
24
+ expect(isKnownVersion(undefined)).toBe(false);
25
+ expect(isKnownVersion("")).toBe(false);
26
+ expect(isKnownVersion("1.0.0")).toBe(true);
27
+ });
28
28
  });
29
29
 
30
30
  describe("diffVersions — first run", () => {
31
- test("no baseline reports nothing at all", () => {
32
- // Without this, the first launch would flag every installed plugin as new,
33
- // which is both wrong and the fastest way to train someone to ignore badges.
34
- expect(
35
- diffVersions(null, { "dev@magus": "2.12.1", "a@mp": UNKNOWN_VERSION }),
36
- ).toEqual([]);
37
- });
38
-
39
- test("an empty baseline is NOT the same as no baseline", () => {
40
- // A real snapshot that happens to be empty means we have looked before and
41
- // saw nothing, so anything present now is genuinely new.
42
- const changes = diffVersions({}, { "dev@magus": "2.12.1" });
43
- expect(changes).toEqual([
44
- { pluginId: "dev@magus", kind: "installed", to: "2.12.1" },
45
- ]);
46
- });
31
+ test("no baseline reports nothing at all", () => {
32
+ // Without this, the first launch would flag every installed plugin as new,
33
+ // which is both wrong and the fastest way to train someone to ignore badges.
34
+ expect(
35
+ diffVersions(null, { "dev@magus": "2.12.1", "a@mp": UNKNOWN_VERSION }),
36
+ ).toEqual([]);
37
+ });
38
+
39
+ test("an empty baseline is NOT the same as no baseline", () => {
40
+ // A real snapshot that happens to be empty means we have looked before and
41
+ // saw nothing, so anything present now is genuinely new.
42
+ const changes = diffVersions({}, { "dev@magus": "2.12.1" });
43
+ expect(changes).toEqual([
44
+ { pluginId: "dev@magus", kind: "installed", to: "2.12.1" },
45
+ ]);
46
+ });
47
47
  });
48
48
 
49
49
  describe("diffVersions — installs", () => {
50
- test("a plugin absent from the baseline is reported as installed, not updated", () => {
51
- const changes = diffVersions({ "other@mp": "1.0.0" }, { "new@mp": "3.1.0" });
52
- expect(changes).toEqual([
53
- { pluginId: "new@mp", kind: "installed", to: "3.1.0" },
54
- ]);
55
- });
56
-
57
- test("an install with an unknown version is still reported", () => {
58
- // The version is unusable but the install is real — the UI shows "new"
59
- // with no version rather than suppressing the plugin entirely.
60
- const changes = diffVersions({}, { "a@mp": UNKNOWN_VERSION });
61
- expect(changes).toEqual([
62
- { pluginId: "a@mp", kind: "installed", to: UNKNOWN_VERSION },
63
- ]);
64
- });
50
+ test("a plugin absent from the baseline is reported as installed, not updated", () => {
51
+ const changes = diffVersions(
52
+ { "other@mp": "1.0.0" },
53
+ { "new@mp": "3.1.0" },
54
+ );
55
+ expect(changes).toEqual([
56
+ { pluginId: "new@mp", kind: "installed", to: "3.1.0" },
57
+ ]);
58
+ });
59
+
60
+ test("an install with an unknown version is still reported", () => {
61
+ // The version is unusable but the install is real — the UI shows "new"
62
+ // with no version rather than suppressing the plugin entirely.
63
+ const changes = diffVersions({}, { "a@mp": UNKNOWN_VERSION });
64
+ expect(changes).toEqual([
65
+ { pluginId: "a@mp", kind: "installed", to: UNKNOWN_VERSION },
66
+ ]);
67
+ });
65
68
  });
66
69
 
67
70
  describe("diffVersions — updates", () => {
68
- test("reports a genuine version transition", () => {
69
- expect(
70
- diffVersions({ "dev@magus": "2.12.0" }, { "dev@magus": "2.12.1" }),
71
- ).toEqual([
72
- { pluginId: "dev@magus", kind: "updated", from: "2.12.0", to: "2.12.1" },
73
- ]);
74
- });
75
-
76
- test("unchanged versions report nothing", () => {
77
- expect(
78
- diffVersions({ "dev@magus": "2.12.1" }, { "dev@magus": "2.12.1" }),
79
- ).toEqual([]);
80
- });
81
-
82
- test("learning a previously unknown version is not an update", () => {
83
- // This is exactly what v4.22.0's registry overlay caused: versions that
84
- // could not be resolved suddenly could be. Reporting it would announce
85
- // claudeup's own bugfix as if the user's plugins had changed.
86
- expect(diffVersions({ "a@mp": UNKNOWN_VERSION }, { "a@mp": "5.3.0" })).toEqual([]);
87
- });
88
-
89
- test("losing a known version is not an update either", () => {
90
- expect(diffVersions({ "a@mp": "5.3.0" }, { "a@mp": UNKNOWN_VERSION })).toEqual([]);
91
- });
92
-
93
- test("a downgrade IS reported", () => {
94
- // Rolling back is a real change, arguably the most important to surface.
95
- expect(diffVersions({ "a@mp": "2.0.0" }, { "a@mp": "1.0.0" })).toEqual([
96
- { pluginId: "a@mp", kind: "updated", from: "2.0.0", to: "1.0.0" },
97
- ]);
98
- });
71
+ test("reports a genuine version transition", () => {
72
+ expect(
73
+ diffVersions({ "dev@magus": "2.12.0" }, { "dev@magus": "2.12.1" }),
74
+ ).toEqual([
75
+ { pluginId: "dev@magus", kind: "updated", from: "2.12.0", to: "2.12.1" },
76
+ ]);
77
+ });
78
+
79
+ test("unchanged versions report nothing", () => {
80
+ expect(
81
+ diffVersions({ "dev@magus": "2.12.1" }, { "dev@magus": "2.12.1" }),
82
+ ).toEqual([]);
83
+ });
84
+
85
+ test("learning a previously unknown version is not an update", () => {
86
+ // This is exactly what v4.22.0's registry overlay caused: versions that
87
+ // could not be resolved suddenly could be. Reporting it would announce
88
+ // claudeup's own bugfix as if the user's plugins had changed.
89
+ expect(
90
+ diffVersions({ "a@mp": UNKNOWN_VERSION }, { "a@mp": "5.3.0" }),
91
+ ).toEqual([]);
92
+ });
93
+
94
+ test("losing a known version is not an update either", () => {
95
+ expect(
96
+ diffVersions({ "a@mp": "5.3.0" }, { "a@mp": UNKNOWN_VERSION }),
97
+ ).toEqual([]);
98
+ });
99
+
100
+ test("a downgrade IS reported", () => {
101
+ // Rolling back is a real change, arguably the most important to surface.
102
+ expect(diffVersions({ "a@mp": "2.0.0" }, { "a@mp": "1.0.0" })).toEqual([
103
+ { pluginId: "a@mp", kind: "updated", from: "2.0.0", to: "1.0.0" },
104
+ ]);
105
+ });
99
106
  });
100
107
 
101
108
  describe("diffVersions — removals and mixed sets", () => {
102
- test("a removed plugin is not reported", () => {
103
- expect(diffVersions({ "gone@mp": "1.0.0" }, {})).toEqual([]);
104
- });
105
-
106
- test("classifies each plugin independently in one pass", () => {
107
- const changes = diffVersions(
108
- { same: "1.0.0", moved: "2.0.0", learned: UNKNOWN_VERSION },
109
- { same: "1.0.0", moved: "2.1.0", learned: "9.0.0", fresh: "0.1.0" },
110
- );
111
- expect(changes).toEqual([
112
- { pluginId: "moved", kind: "updated", from: "2.0.0", to: "2.1.0" },
113
- { pluginId: "fresh", kind: "installed", to: "0.1.0" },
114
- ]);
115
- });
109
+ test("a removed plugin is not reported", () => {
110
+ expect(diffVersions({ "gone@mp": "1.0.0" }, {})).toEqual([]);
111
+ });
112
+
113
+ test("classifies each plugin independently in one pass", () => {
114
+ const changes = diffVersions(
115
+ { same: "1.0.0", moved: "2.0.0", learned: UNKNOWN_VERSION },
116
+ { same: "1.0.0", moved: "2.1.0", learned: "9.0.0", fresh: "0.1.0" },
117
+ );
118
+ expect(changes).toEqual([
119
+ { pluginId: "moved", kind: "updated", from: "2.0.0", to: "2.1.0" },
120
+ { pluginId: "fresh", kind: "installed", to: "0.1.0" },
121
+ ]);
122
+ });
116
123
  });
117
124
 
118
125
  describe("per-project baselines", () => {
119
- /**
120
- * The baseline used to be one flat map for the whole machine, but the
121
- * versions written into it are resolved per project. Two projects
122
- * legitimately on different versions overwrote each other's baseline, so
123
- * every differing plugin was reported as "updated" on the next render — a
124
- * change that never happened.
125
- *
126
- * CLAUDE_CONFIG_DIR, not HOME: os.homedir() reads the passwd database on
127
- * macOS and ignores $HOME, so a HOME-only override writes to the operator's
128
- * real ~/.claude. That is not hypothetical — it happened while writing these.
129
- */
130
- const withSandbox = async (fn: () => Promise<void>): Promise<void> => {
131
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "vsnap-"));
132
- const prev = process.env.CLAUDE_CONFIG_DIR;
133
- process.env.CLAUDE_CONFIG_DIR = dir;
134
- try {
135
- await fn();
136
- } finally {
137
- if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
138
- else process.env.CLAUDE_CONFIG_DIR = prev;
139
- await fs.rm(dir, { recursive: true, force: true });
140
- }
141
- };
142
-
143
- test("a project with no baseline of its own reports nothing", async () => {
144
- await withSandbox(async () => {
145
- await saveSeenVersions("/proj/a", { "statusline@magus": "2.2.0" });
146
- const baseline = await loadSeenVersions("/proj/b");
147
- expect(baseline).toBeNull();
148
- expect(diffVersions(baseline, { "statusline@magus": "2.5.0" })).toEqual([]);
149
- });
150
- });
151
-
152
- test("saving one project preserves the others", async () => {
153
- await withSandbox(async () => {
154
- await saveSeenVersions("/proj/a", { "gtd@magus": "2.0.0" });
155
- await saveSeenVersions("/proj/b", { "gtd@magus": "2.0.1" });
156
- expect(await loadSeenVersions("/proj/a")).toEqual({ "gtd@magus": "2.0.0" });
157
- expect(await loadSeenVersions("/proj/b")).toEqual({ "gtd@magus": "2.0.1" });
158
- });
159
- });
160
-
161
- test("a real update inside one project is still reported", async () => {
162
- await withSandbox(async () => {
163
- await saveSeenVersions("/proj/a", { "dev@magus": "3.0.0" });
164
- const previous = await loadSeenVersions("/proj/a");
165
- expect(diffVersions(previous, { "dev@magus": "3.0.1" })).toEqual([
166
- { pluginId: "dev@magus", kind: "updated", from: "3.0.0", to: "3.0.1" },
167
- ]);
168
- });
169
- });
170
-
171
- test("a pre-migration flat baseline is adopted once, then replaced", async () => {
172
- await withSandbox(async () => {
173
- const file = path.join(
174
- process.env.CLAUDE_CONFIG_DIR as string,
175
- "claudeup-version-snapshot.json",
176
- );
177
- await fs.writeFile(
178
- file,
179
- JSON.stringify({
180
- seen: { "dev@magus": "3.0.0" },
181
- seededAt: "2026-07-29T00:00:00.000Z",
182
- updatedAt: "2026-07-29T00:00:00.000Z",
183
- }),
184
- );
185
-
186
- // Legacy shape carries no project, so it stands in for whoever asks first
187
- // rather than being thrown away.
188
- expect(await loadSeenVersions("/proj/a")).toEqual({ "dev@magus": "3.0.0" });
189
-
190
- await saveSeenVersions("/proj/a", { "dev@magus": "3.0.1" });
191
- // Once per-project entries exist, the flat map is no longer consulted.
192
- expect(await loadSeenVersions("/proj/b")).toBeNull();
193
- expect(await loadSeenVersions("/proj/a")).toEqual({ "dev@magus": "3.0.1" });
194
- });
195
- });
126
+ /**
127
+ * The baseline used to be one flat map for the whole machine, but the
128
+ * versions written into it are resolved per project. Two projects
129
+ * legitimately on different versions overwrote each other's baseline, so
130
+ * every differing plugin was reported as "updated" on the next render — a
131
+ * change that never happened.
132
+ *
133
+ * CLAUDE_CONFIG_DIR, not HOME: os.homedir() reads the passwd database on
134
+ * macOS and ignores $HOME, so a HOME-only override writes to the operator's
135
+ * real ~/.claude. That is not hypothetical — it happened while writing these.
136
+ */
137
+ const withSandbox = async (fn: () => Promise<void>): Promise<void> => {
138
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "vsnap-"));
139
+ const prev = process.env.CLAUDE_CONFIG_DIR;
140
+ process.env.CLAUDE_CONFIG_DIR = dir;
141
+ try {
142
+ await fn();
143
+ } finally {
144
+ // biome-ignore lint/performance/noDelete: assigning undefined to process.env stores the string "undefined"; only delete unsets the variable
145
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
146
+ else process.env.CLAUDE_CONFIG_DIR = prev;
147
+ await fs.rm(dir, { recursive: true, force: true });
148
+ }
149
+ };
150
+
151
+ test("a project with no baseline of its own reports nothing", async () => {
152
+ await withSandbox(async () => {
153
+ await saveSeenVersions("/proj/a", { "statusline@magus": "2.2.0" });
154
+ const baseline = await loadSeenVersions("/proj/b");
155
+ expect(baseline).toBeNull();
156
+ expect(diffVersions(baseline, { "statusline@magus": "2.5.0" })).toEqual(
157
+ [],
158
+ );
159
+ });
160
+ });
161
+
162
+ test("saving one project preserves the others", async () => {
163
+ await withSandbox(async () => {
164
+ await saveSeenVersions("/proj/a", { "gtd@magus": "2.0.0" });
165
+ await saveSeenVersions("/proj/b", { "gtd@magus": "2.0.1" });
166
+ expect(await loadSeenVersions("/proj/a")).toEqual({
167
+ "gtd@magus": "2.0.0",
168
+ });
169
+ expect(await loadSeenVersions("/proj/b")).toEqual({
170
+ "gtd@magus": "2.0.1",
171
+ });
172
+ });
173
+ });
174
+
175
+ test("a real update inside one project is still reported", async () => {
176
+ await withSandbox(async () => {
177
+ await saveSeenVersions("/proj/a", { "dev@magus": "3.0.0" });
178
+ const previous = await loadSeenVersions("/proj/a");
179
+ expect(diffVersions(previous, { "dev@magus": "3.0.1" })).toEqual([
180
+ { pluginId: "dev@magus", kind: "updated", from: "3.0.0", to: "3.0.1" },
181
+ ]);
182
+ });
183
+ });
184
+
185
+ test("a pre-migration flat baseline is adopted once, then replaced", async () => {
186
+ await withSandbox(async () => {
187
+ const file = path.join(
188
+ process.env.CLAUDE_CONFIG_DIR as string,
189
+ "claudeup-version-snapshot.json",
190
+ );
191
+ await fs.writeFile(
192
+ file,
193
+ JSON.stringify({
194
+ seen: { "dev@magus": "3.0.0" },
195
+ seededAt: "2026-07-29T00:00:00.000Z",
196
+ updatedAt: "2026-07-29T00:00:00.000Z",
197
+ }),
198
+ );
199
+
200
+ // Legacy shape carries no project, so it stands in for whoever asks first
201
+ // rather than being thrown away.
202
+ expect(await loadSeenVersions("/proj/a")).toEqual({
203
+ "dev@magus": "3.0.0",
204
+ });
205
+
206
+ await saveSeenVersions("/proj/a", { "dev@magus": "3.0.1" });
207
+ // Once per-project entries exist, the flat map is no longer consulted.
208
+ expect(await loadSeenVersions("/proj/b")).toBeNull();
209
+ expect(await loadSeenVersions("/proj/a")).toEqual({
210
+ "dev@magus": "3.0.1",
211
+ });
212
+ });
213
+ });
196
214
  });
package/src/cli/ansi.ts CHANGED
@@ -59,7 +59,7 @@ const clamp01 = (n: number): number =>
59
59
  /** `#rgb` and `#rrggbb`, hash optional. Anything else resolves to black. */
60
60
  function parseHex(hex: string): [number, number, number] {
61
61
  let h = hex.trim().replace(/^#/, "");
62
- if (h.length === 3) h = h[0]! + h[0]! + h[1]! + h[1]! + h[2]! + h[2]!;
62
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
63
63
  if (!/^[0-9a-fA-F]{6}$/.test(h)) return [0, 0, 0];
64
64
  return [
65
65
  Number.parseInt(h.slice(0, 2), 16),
@@ -110,19 +110,22 @@ export function mix(from: string, to: string, t: number): string {
110
110
  export function blend1D(steps: number, from: string, to: string): string[] {
111
111
  if (!Number.isInteger(steps) || steps <= 0) return [];
112
112
  if (steps === 1) return [toHex(parseHex(from))];
113
- return Array.from({ length: steps }, (_, i) => mix(from, to, i / (steps - 1)));
113
+ return Array.from({ length: steps }, (_, i) =>
114
+ mix(from, to, i / (steps - 1)),
115
+ );
114
116
  }
115
117
 
116
118
  /** Multi-stop ramp: `steps` colours spread evenly across every stop, in order. */
117
119
  export function blendStops(steps: number, ...stops: string[]): string[] {
118
120
  if (!Number.isInteger(steps) || steps <= 0 || stops.length === 0) return [];
119
- if (stops.length === 1 || steps === 1)
120
- return blend1D(steps, stops[0]!, stops.at(-1)!);
121
+ const first = stops[0];
122
+ const last = stops[stops.length - 1];
123
+ if (stops.length === 1 || steps === 1) return blend1D(steps, first, last);
121
124
  const segs = stops.length - 1;
122
125
  return Array.from({ length: steps }, (_, i) => {
123
126
  const p = (i / (steps - 1)) * segs;
124
127
  const s = Math.min(segs - 1, Math.floor(p));
125
- return mix(stops[s]!, stops[s + 1]!, p - s);
128
+ return mix(stops[s], stops[s + 1], p - s);
126
129
  });
127
130
  }
128
131
 
@@ -283,7 +286,7 @@ function charWidth(cp: number): number {
283
286
  /** Visible cell width, ignoring escape sequences. */
284
287
  export function width(text: string): number {
285
288
  let w = 0;
286
- for (const ch of stripAnsi(text)) w += charWidth(ch.codePointAt(0)!);
289
+ for (const ch of stripAnsi(text)) w += charWidth(ch.codePointAt(0) ?? 0);
287
290
  return w;
288
291
  }
289
292
 
@@ -321,8 +324,10 @@ export function truncate(text: string, n: number): string {
321
324
  i += m[0].length;
322
325
  continue;
323
326
  }
324
- const ch = String.fromCodePoint(text.codePointAt(i)!);
325
- const cw = charWidth(ch.codePointAt(0)!);
327
+ const cp = text.codePointAt(i);
328
+ if (cp === undefined) break;
329
+ const ch = String.fromCodePoint(cp);
330
+ const cw = charWidth(cp);
326
331
  if (w + cw > n) break;
327
332
  out += ch;
328
333
  w += cw;
@@ -391,10 +396,13 @@ export function meter(
391
396
  if (cells <= 0) return "";
392
397
  const p = clamp01((Number.isFinite(pct) ? pct : 0) / 100);
393
398
  const filled = Math.round(p * cells);
394
- const cols = blendStops(cells, ...(stops.length > 0 ? stops : ramps.progress));
399
+ const cols = blendStops(
400
+ cells,
401
+ ...(stops.length > 0 ? stops : ramps.progress),
402
+ );
395
403
  let out = "";
396
404
  for (let i = 0; i < cells; i++) {
397
- out += i < filled ? fg(cols[i]!, FILL) : fg(TRACK_COLOR, TRACK);
405
+ out += i < filled ? fg(cols[i], FILL) : fg(TRACK_COLOR, TRACK);
398
406
  }
399
407
  return out;
400
408
  }
@@ -446,14 +454,14 @@ export function stackedBar(cells: number, segments: BarSegment[]): string {
446
454
  while (drift !== 0) {
447
455
  const i = widths.indexOf(Math.max(...widths));
448
456
  if (drift > 0) {
449
- widths[i]!++;
457
+ widths[i]++;
450
458
  drift--;
451
- } else if (widths[i]! > 1) {
452
- widths[i]!--;
459
+ } else if (widths[i] > 1) {
460
+ widths[i]--;
453
461
  drift++;
454
462
  } else break;
455
463
  }
456
- return live.map((s, i) => fg(s.color, FILL.repeat(widths[i]!))).join("");
464
+ return live.map((s, i) => fg(s.color, FILL.repeat(widths[i]))).join("");
457
465
  }
458
466
 
459
467
  /**
@@ -498,7 +506,7 @@ export function badge(label: string, hex: string): string {
498
506
  const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
499
507
 
500
508
  export function spinner(tick: number): string {
501
- return SPINNER[Math.abs(Math.trunc(tick)) % SPINNER.length]!;
509
+ return SPINNER[Math.abs(Math.trunc(tick)) % SPINNER.length];
502
510
  }
503
511
 
504
512
  /** Elapsed time sized to its magnitude: `340ms`, `1.4s`, `2m04s`. */