claudeup 6.2.1 → 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 (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 +38 -13
  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 +183 -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 +1 -2
  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
@@ -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`. */
package/src/cli/doctor.ts CHANGED
@@ -8,16 +8,13 @@
8
8
  * 3. Convention compliance — .gitignore + CLAUDE.md blocks (ported engine).
9
9
  */
10
10
 
11
- import fs from "fs-extra";
12
11
  import path from "node:path";
13
- import { readManifest } from "../services/manifest.js";
14
- import { resolveAllProfiles } from "../services/resolver.js";
12
+ import fs from "fs-extra";
15
13
  import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
16
- import {
17
- activeProfile,
18
- profileDir,
19
- } from "../services/symlink-manager.js";
20
14
  import { checkProject, fixDoctorIssues } from "../services/doctor.js";
15
+ import { readManifest } from "../services/manifest.js";
16
+ import { resolveAllProfiles } from "../services/resolver.js";
17
+ import { activeProfile, profileDir } from "../services/symlink-manager.js";
21
18
 
22
19
  export async function runDoctorCommand(args: string[]): Promise<number> {
23
20
  const fix = args.includes("--fix");
@@ -42,7 +39,9 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
42
39
  problems++;
43
40
  }
44
41
  } else {
45
- console.log(`✓ All ${closure.bins.length} binary dependencies present.`);
42
+ console.log(
43
+ `✓ All ${closure.bins.length} binary dependencies present.`,
44
+ );
46
45
  }
47
46
  }
48
47
  }
@@ -70,7 +69,9 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
70
69
  if (actionable.length > 0) {
71
70
  console.log("\nConventions:");
72
71
  for (const check of actionable) {
73
- console.log(` ${check.status === "fail" ? "✗" : "•"} ${check.message}`);
72
+ console.log(
73
+ ` ${check.status === "fail" ? "✗" : "•"} ${check.message}`,
74
+ );
74
75
  }
75
76
  const fixable = actionable.filter((c) => c.fixable).length;
76
77
  const failures = actionable.filter((c) => c.status === "fail").length;
@@ -48,9 +48,9 @@ import type {
48
48
  SkillInfo,
49
49
  } from "../types/index.js";
50
50
  import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
51
+ import { runCommand, runShellScript } from "../utils/run.js";
51
52
  import { ensureManifest } from "./bootstrap.js";
52
53
  import { confirm, promptValue } from "./prompt.js";
53
- import { runCommand, runShellScript } from "../utils/run.js";
54
54
 
55
55
  interface InstallFlags {
56
56
  check: boolean;
@@ -292,8 +292,8 @@ export async function runInstallCommand(
292
292
  );
293
293
  for (const c of drift) console.error(` ${c.kind}: ${c.detail}`);
294
294
  console.error(
295
- `\nRun \`claudeup profile sync\` to promote them into .claude/profiles.json,\n` +
296
- `or re-run with --force to discard them.`,
295
+ "\nRun `claudeup profile sync` to promote them into .claude/profiles.json,\n" +
296
+ "or re-run with --force to discard them.",
297
297
  );
298
298
  return 1;
299
299
  }
@@ -66,7 +66,7 @@ export async function initProfile(
66
66
  `${getManifestPath(projectPath)} already defines: ${Object.keys(existing.profiles).join(", ")}`,
67
67
  );
68
68
  console.error(
69
- `Use \`claudeup profile sync\` to promote local edits into it.`,
69
+ "Use `claudeup profile sync` to promote local edits into it.",
70
70
  );
71
71
  return 1;
72
72
  }
@@ -85,7 +85,7 @@ export async function listProfiles(projectPath: string): Promise<number> {
85
85
  const active = await activeProfile(projectPath);
86
86
  console.log("Profiles:");
87
87
  for (const id of ids) {
88
- const entry = manifest.profiles[id]!;
88
+ const entry = manifest.profiles[id];
89
89
  const marker = id === active ? "●" : " ";
90
90
  const materialized = (await fs.pathExists(profileDir(id, projectPath)))
91
91
  ? ""
@@ -105,14 +105,15 @@ export async function showProfile(
105
105
  return 1;
106
106
  }
107
107
  const manifest = await readManifest(projectPath);
108
- if (!manifest.profiles[name]) {
108
+ const entry = manifest.profiles[name];
109
+ if (!entry) {
109
110
  console.error(
110
111
  `Profile "${name}" not found. Available: ${Object.keys(manifest.profiles).join(", ")}`,
111
112
  );
112
113
  return 1;
113
114
  }
114
115
  const closure = await resolveProfile(manifest, name);
115
- console.log(`Profile: ${name} (${manifest.profiles[name]!.name})`);
116
+ console.log(`Profile: ${name} (${entry.name})`);
116
117
  for (const line of summarizeClosure(closure)) console.log(` ${line}`);
117
118
  return 0;
118
119
  }
package/src/cli/prompt.ts CHANGED
@@ -30,4 +30,3 @@ export async function promptValue(question: string): Promise<string> {
30
30
  rl.close();
31
31
  }
32
32
  }
33
-