claudeup 6.5.1 → 6.7.1
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.
- package/package.json +4 -4
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cli-apply-seams.test.ts +2 -0
- package/src/__tests__/cli-tool-commands.test.ts +1 -1
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +142 -2
- package/src/__tests__/content-drift.test.ts +60 -0
- package/src/__tests__/conventions-manager.test.ts +45 -45
- package/src/__tests__/enabled-not-installed.test.ts +2 -3
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
- package/src/__tests__/models-presets-marketplace.test.ts +141 -0
- package/src/__tests__/plugin-cli-argv.test.ts +1 -1
- package/src/__tests__/plugin-manager-fallback.test.ts +3 -5
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/resolver.test.ts +3 -3
- package/src/__tests__/scope-action.test.ts +69 -19
- package/src/__tests__/shell-script-callers.test.ts +8 -8
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/update-apply.test.ts +283 -1
- package/src/__tests__/update-plan.test.ts +773 -31
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/cli/router.ts +0 -5
- package/src/cli/update-view.ts +92 -12
- package/src/cli/update.ts +114 -38
- package/src/data/models-presets.ts +10 -11
- package/src/data/predefined-profiles.ts +7 -7
- package/src/services/active-closure.ts +117 -0
- package/src/services/catalog-cache-store.ts +6 -2
- package/src/services/claude-settings.ts +2 -174
- package/src/services/community-styles.ts +1 -1
- package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
- package/src/services/gitignore-fixer.ts +1 -1
- package/src/services/marketplace-refresh.ts +30 -0
- package/src/services/plugin-manager.ts +200 -283
- package/src/services/resolver.ts +7 -2
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +97 -0
- package/src/services/update-engine.ts +220 -32
- package/src/services/update-plan.ts +640 -160
- package/src/services/version-snapshot.ts +1 -1
- package/src/types/bun.d.ts +6 -0
- package/src/ui/App.tsx +2 -27
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/pluginsAdapter.ts +57 -0
- package/src/ui/components/modals/ModalContainer.tsx +0 -26
- package/src/ui/hooks/index.ts +0 -1
- package/src/ui/hooks/useGitignoreModal.ts +2 -3
- package/src/ui/renderers/cliToolRenderers.tsx +108 -67
- package/src/ui/renderers/pluginRenderers.tsx +157 -98
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/screens/CliToolsScreen.tsx +154 -280
- package/src/ui/screens/PluginsScreen.tsx +156 -140
- package/src/ui/state/types.ts +0 -7
- package/src/__tests__/dual-write-prevention.test.ts +0 -380
- package/src/__tests__/gap-fill-versions.test.ts +0 -389
- package/src/__tests__/plugin-version-check.test.ts +0 -762
- package/src/cli/claude.ts +0 -21
- package/src/prerunner/index.ts +0 -500
- package/src/services/plugin-version-check.ts +0 -360
- package/src/services/update-cache.ts +0 -78
- package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
- package/src/ui/hooks/useMismatchModal.ts +0 -89
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildScopeStatus,
|
|
4
|
+
compareVersions,
|
|
5
|
+
isBehind,
|
|
6
|
+
isInstalledInScope,
|
|
7
|
+
resolveScopeAction,
|
|
8
|
+
} from "../services/plugin-manager.js";
|
|
9
|
+
import type { PluginInfo } from "../services/plugin-manager.js";
|
|
10
|
+
import { planPluginUpdates } from "../services/update-plan.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* REGRESSION: claudeup renders "I cannot tell" as "nothing there" in three
|
|
14
|
+
* separate places — Fixed in /dev:fix session dev-fix-20260910-claudeup.
|
|
15
|
+
*
|
|
16
|
+
* Measured before the fix, against the real function and real registry values:
|
|
17
|
+
*
|
|
18
|
+
* compareVersions("517b2fcd1b60", "b819188d2eea") = 0 hasUpdate=false
|
|
19
|
+
* compareVersions("7.2.0", "517b2fcd1b60") = 1 hasUpdate=true
|
|
20
|
+
*
|
|
21
|
+
* Two different git commits compared EQUAL, so a sha-versioned plugin could
|
|
22
|
+
* never report an update; and a sha compared against a semver reported a
|
|
23
|
+
* spurious one. `feature-dev@claude-plugins-official` is 517b2fcd1b60 in one
|
|
24
|
+
* project and b819188d2eea in another on the reporter's machine.
|
|
25
|
+
*
|
|
26
|
+
* The rule this file pins: an unorderable pair is `null` (unknown), never 0
|
|
27
|
+
* (equal) and never a direction.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const SHA_A = "517b2fcd1b60";
|
|
31
|
+
const SHA_B = "b819188d2eea";
|
|
32
|
+
|
|
33
|
+
describe("compareVersions — orderable pairs", () => {
|
|
34
|
+
test("semver ordering is unchanged", () => {
|
|
35
|
+
expect(compareVersions("7.2.0", "7.1.0")).toBe(1);
|
|
36
|
+
expect(compareVersions("7.1.0", "7.2.0")).toBe(-1);
|
|
37
|
+
expect(compareVersions("7.1.0", "7.1.0")).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a leading v is still stripped", () => {
|
|
41
|
+
expect(compareVersions("v2.0.0", "1.9.9")).toBe(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("uneven part counts still compare", () => {
|
|
45
|
+
expect(compareVersions("1.2", "1.2.0")).toBe(0);
|
|
46
|
+
expect(compareVersions("1.2.1", "1.2")).toBe(1);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("compareVersions — unorderable pairs are null, not 0", () => {
|
|
51
|
+
test("two DIFFERENT commit shas are not equal", () => {
|
|
52
|
+
// The bug: this returned 0, so nothing downstream could see a difference.
|
|
53
|
+
expect(compareVersions(SHA_A, SHA_B)).toBeNull();
|
|
54
|
+
expect(compareVersions(SHA_B, SHA_A)).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("the same commit sha is genuinely equal", () => {
|
|
58
|
+
expect(compareVersions(SHA_A, SHA_A)).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a sha against a semver is not orderable in either direction", () => {
|
|
62
|
+
// The bug: these returned 1 and -1, a fabricated ordering.
|
|
63
|
+
expect(compareVersions("7.2.0", SHA_A)).toBeNull();
|
|
64
|
+
expect(compareVersions(SHA_A, "7.2.0")).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a missing side is unknown, not equal", () => {
|
|
68
|
+
// The bug: `if (!a || !b) return 0` claimed two absent versions matched.
|
|
69
|
+
expect(compareVersions(undefined, "1.0.0")).toBeNull();
|
|
70
|
+
expect(compareVersions("1.0.0", null)).toBeNull();
|
|
71
|
+
expect(compareVersions(undefined, undefined)).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("the 0.0.0 sentinel is comparable — official plugins record it", () => {
|
|
75
|
+
expect(compareVersions("0.0.0", "0.0.0")).toBe(0);
|
|
76
|
+
expect(compareVersions("1.0.0", "0.0.0")).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("isBehind — the question callers actually ask", () => {
|
|
81
|
+
test("semver behind, ahead, level", () => {
|
|
82
|
+
expect(isBehind("7.2.0", "7.1.0")).toBe(true);
|
|
83
|
+
expect(isBehind("7.1.0", "7.2.0")).toBe(false);
|
|
84
|
+
expect(isBehind("7.1.0", "7.1.0")).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a different published sha means the install is not current", () => {
|
|
88
|
+
// The catalog is authoritative for what is published NOW. An installed
|
|
89
|
+
// commit that differs from it is not current, even though the two shas
|
|
90
|
+
// carry no ordering between them.
|
|
91
|
+
expect(isBehind(SHA_B, SHA_A)).toBe(true);
|
|
92
|
+
expect(isBehind(SHA_A, SHA_A)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a sha against a semver is unknown, never a silent false", () => {
|
|
96
|
+
expect(isBehind("7.2.0", SHA_A)).toBeNull();
|
|
97
|
+
expect(isBehind(SHA_A, "7.2.0")).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("not installed is unknown, not up to date", () => {
|
|
101
|
+
// The bug: `installedVersion && compare(...)` yielded false, so a plugin
|
|
102
|
+
// that was not installed at all was excluded from the update count and
|
|
103
|
+
// read as up to date.
|
|
104
|
+
expect(isBehind("7.2.0", undefined)).toBeNull();
|
|
105
|
+
expect(isBehind("7.2.0", null)).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("buildScopeStatus — installation is a registry fact, not a settings key", () => {
|
|
110
|
+
test("an installed but NOT enabled plugin still reports its scope", () => {
|
|
111
|
+
// The bug: scope was `enabledPlugins[id] !== undefined ? {...} : undefined`,
|
|
112
|
+
// so a plugin with a real registry row but no settings key rendered
|
|
113
|
+
// identically to one that was never installed.
|
|
114
|
+
const scope = buildScopeStatus({}, { "dev@magus": "7.2.0" }, "dev@magus");
|
|
115
|
+
expect(scope).toBeDefined();
|
|
116
|
+
expect(scope?.version).toBe("7.2.0");
|
|
117
|
+
expect(scope?.enabled).toBe(false);
|
|
118
|
+
expect(isInstalledInScope(scope)).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("enabled with no install is still reported, as a broken state", () => {
|
|
122
|
+
const scope = buildScopeStatus({ "dev@magus": true }, {}, "dev@magus");
|
|
123
|
+
expect(scope).toBeDefined();
|
|
124
|
+
expect(scope?.enabled).toBe(true);
|
|
125
|
+
expect(scope?.version).toBeUndefined();
|
|
126
|
+
expect(isInstalledInScope(scope)).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("neither enabled nor installed has no scope at all", () => {
|
|
130
|
+
expect(buildScopeStatus({}, {}, "dev@magus")).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("isInstalledInScope — presence of files, not permission to load", () => {
|
|
135
|
+
test("installed and disabled is still installed", () => {
|
|
136
|
+
// The old rule was `!!scope?.enabled && !!scope.version`, which made
|
|
137
|
+
// disabling a plugin look like uninstalling it.
|
|
138
|
+
expect(isInstalledInScope({ enabled: false, version: "7.2.0" })).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("the 0.0.0 sentinel counts as installed", () => {
|
|
142
|
+
expect(isInstalledInScope({ enabled: true, version: "0.0.0" })).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("enabled with no version is not installed", () => {
|
|
146
|
+
expect(isInstalledInScope({ enabled: true, version: undefined })).toBe(
|
|
147
|
+
false,
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const info = (over: Partial<PluginInfo>): PluginInfo =>
|
|
153
|
+
({
|
|
154
|
+
id: "x@magus",
|
|
155
|
+
name: "x",
|
|
156
|
+
version: "1.0.0",
|
|
157
|
+
description: "",
|
|
158
|
+
marketplace: "magus",
|
|
159
|
+
marketplaceDisplay: "Magus",
|
|
160
|
+
enabled: true,
|
|
161
|
+
...over,
|
|
162
|
+
}) as PluginInfo;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A catalog that HAS been read for @magus — it lists other magus plugins. That
|
|
166
|
+
* distinction is load-bearing: an empty catalog means "this marketplace was
|
|
167
|
+
* never read", where absence proves nothing, and installing is what registers
|
|
168
|
+
* it. Only a catalog we demonstrably hold entries from can turn absence into
|
|
169
|
+
* the answer "that plugin is not published".
|
|
170
|
+
*/
|
|
171
|
+
const magusCatalog = (...extra: PluginInfo[]) =>
|
|
172
|
+
new Map(
|
|
173
|
+
[
|
|
174
|
+
info({ id: "dev@magus", version: "7.2.0" }),
|
|
175
|
+
info({ id: "terminal@magus", version: "5.0.1" }),
|
|
176
|
+
...extra,
|
|
177
|
+
].map((p) => [p.id, p]),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
describe("planPluginUpdates — a pin its own marketplace does not offer", () => {
|
|
181
|
+
test("declared, not installed, marketplace read, plugin absent → unknown", () => {
|
|
182
|
+
// The bug: the !installed branch ran before any catalog guard, so
|
|
183
|
+
// `agentdev@magus` (retired) and `seo@magus` (moved to magus-marketing)
|
|
184
|
+
// planned as `install` with the literal pin as target on every run,
|
|
185
|
+
// forever, and every apply failed. The installed path already answered
|
|
186
|
+
// this correctly further down; the two are now consistent.
|
|
187
|
+
const items = planPluginUpdates(
|
|
188
|
+
{ "agentdev@magus": "latest" },
|
|
189
|
+
magusCatalog(),
|
|
190
|
+
);
|
|
191
|
+
expect(items).toHaveLength(1);
|
|
192
|
+
expect(items[0]?.action).toBe("unknown");
|
|
193
|
+
expect(items[0]?.target).toBeNull();
|
|
194
|
+
expect(items[0]?.note).toContain(
|
|
195
|
+
"not offered by any configured marketplace",
|
|
196
|
+
);
|
|
197
|
+
expect(items[0]?.reason).toBe("not-offered");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("an exact pin its marketplace does not offer is also unknown", () => {
|
|
201
|
+
const items = planPluginUpdates({ "gone@magus": "3.0.0" }, magusCatalog());
|
|
202
|
+
expect(items[0]?.action).toBe("unknown");
|
|
203
|
+
expect(items[0]?.target).toBeNull();
|
|
204
|
+
expect(items[0]?.reason).toBe("not-offered");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("declared, not installed, and the catalog does offer it → install", () => {
|
|
208
|
+
const items = planPluginUpdates(
|
|
209
|
+
{ "kanban@magus": "latest" },
|
|
210
|
+
magusCatalog(info({ id: "kanban@magus", version: "1.6.2" })),
|
|
211
|
+
);
|
|
212
|
+
expect(items[0]?.action).toBe("install");
|
|
213
|
+
expect(items[0]?.target).toBe("1.6.2");
|
|
214
|
+
expect(items[0]?.reason).toBe("absent");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("a marketplace we have NOT read still plans an install", () => {
|
|
218
|
+
// Not the dead-pin case: with no entries from @newmp we never read its
|
|
219
|
+
// catalog, so absence is not evidence — and installing is what registers
|
|
220
|
+
// the marketplace in the first place. Guarding on `available === null`
|
|
221
|
+
// alone broke exactly this, which the existing suite caught.
|
|
222
|
+
const items = planPluginUpdates(
|
|
223
|
+
{ "thing@newmp": "latest" },
|
|
224
|
+
magusCatalog(),
|
|
225
|
+
);
|
|
226
|
+
expect(items[0]?.action).toBe("install");
|
|
227
|
+
expect(items[0]?.reason).toBe("absent");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("REVIEW-B regressions — found by the patch-quality panel", () => {
|
|
232
|
+
test("a catalog entry with NO version is offered, not unknown", () => {
|
|
233
|
+
// Measured: 278 of the 292 plugins in claude-plugins-official declare no
|
|
234
|
+
// version at all, `feature-dev` among them. Keying "not offered" on
|
|
235
|
+
// `available === null` planned `unknown` for every one of them and never
|
|
236
|
+
// installed. Membership in the catalog is the real test.
|
|
237
|
+
const items = planPluginUpdates(
|
|
238
|
+
{ "feature-dev@official": "latest" },
|
|
239
|
+
new Map(
|
|
240
|
+
[
|
|
241
|
+
info({ id: "feature-dev@official", version: null }),
|
|
242
|
+
info({ id: "github@official", version: null }),
|
|
243
|
+
].map((p) => [p.id, p]),
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
expect(items[0]?.action).toBe("install");
|
|
247
|
+
expect(items[0]?.reason).toBe("absent");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("an installed version that cannot be compared is unknown, not current", () => {
|
|
251
|
+
// 21 registry rows carry the literal string "unknown" as their version.
|
|
252
|
+
// Against a semver catalog they are unorderable, so no scope is provably
|
|
253
|
+
// behind — and falling through to `current` claims they are up to date.
|
|
254
|
+
const items = planPluginUpdates(
|
|
255
|
+
{ "playground@official": "latest" },
|
|
256
|
+
new Map([
|
|
257
|
+
[
|
|
258
|
+
"playground@official",
|
|
259
|
+
info({
|
|
260
|
+
id: "playground@official",
|
|
261
|
+
version: "7.3.0",
|
|
262
|
+
projectScope: { enabled: true, version: "unknown" },
|
|
263
|
+
}),
|
|
264
|
+
],
|
|
265
|
+
]),
|
|
266
|
+
);
|
|
267
|
+
expect(items[0]?.action).toBe("unknown");
|
|
268
|
+
expect(items[0]?.note).toContain("cannot be compared");
|
|
269
|
+
expect(items[0]?.reason).toBe("unorderable");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("a genuinely current install is still current", () => {
|
|
273
|
+
const items = planPluginUpdates(
|
|
274
|
+
{ "dev@magus": "latest" },
|
|
275
|
+
new Map([
|
|
276
|
+
[
|
|
277
|
+
"dev@magus",
|
|
278
|
+
info({
|
|
279
|
+
id: "dev@magus",
|
|
280
|
+
version: "7.3.0",
|
|
281
|
+
projectScope: { enabled: true, version: "7.3.0" },
|
|
282
|
+
}),
|
|
283
|
+
],
|
|
284
|
+
]),
|
|
285
|
+
);
|
|
286
|
+
expect(items[0]?.action).toBe("current");
|
|
287
|
+
expect(items[0]?.reason).toBe("up-to-date");
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("resolveScopeAction — the scope key must never silently uninstall", () => {
|
|
292
|
+
test("installed but DISABLED resolves to install, not uninstall", () => {
|
|
293
|
+
// The hazard: the scope key runs cliUninstallPlugin with no confirmation.
|
|
294
|
+
// Relaxing isInstalledInScope made this combination fall through to
|
|
295
|
+
// "uninstall", so pressing the key on a plugin the user had merely
|
|
296
|
+
// switched off would delete it.
|
|
297
|
+
expect(
|
|
298
|
+
resolveScopeAction({ enabled: false, version: "3.0.2" }, false),
|
|
299
|
+
).toBe("install");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("installed AND enabled at the latest version still offers uninstall", () => {
|
|
303
|
+
expect(resolveScopeAction({ enabled: true, version: "3.0.2" }, false)).toBe(
|
|
304
|
+
"uninstall",
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("installed, enabled, and behind still offers update", () => {
|
|
309
|
+
expect(resolveScopeAction({ enabled: true, version: "3.0.1" }, true)).toBe(
|
|
310
|
+
"update",
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("not installed at all resolves to install", () => {
|
|
315
|
+
expect(resolveScopeAction(undefined, false)).toBe("install");
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe("installed with no comparable catalog version — two different causes", () => {
|
|
320
|
+
test("in the catalog but declaring no version is NOT 'withdrawn'", () => {
|
|
321
|
+
// feature-dev@claude-plugins-official, measured live: installed, listed by
|
|
322
|
+
// its marketplace, and declaring no version. Reporting it as "not offered
|
|
323
|
+
// by any configured marketplace" told the user a working, currently
|
|
324
|
+
// published plugin had been withdrawn.
|
|
325
|
+
const items = planPluginUpdates(
|
|
326
|
+
{ "feature-dev@official": "latest" },
|
|
327
|
+
new Map([
|
|
328
|
+
[
|
|
329
|
+
"feature-dev@official",
|
|
330
|
+
info({
|
|
331
|
+
id: "feature-dev@official",
|
|
332
|
+
version: null,
|
|
333
|
+
projectScope: { enabled: true, version: "3ea32df27be7" },
|
|
334
|
+
}),
|
|
335
|
+
],
|
|
336
|
+
]),
|
|
337
|
+
);
|
|
338
|
+
expect(items[0]?.action).toBe("unknown");
|
|
339
|
+
expect(items[0]?.note).toContain("publishes no version");
|
|
340
|
+
expect(items[0]?.note).not.toContain("not offered");
|
|
341
|
+
expect(items[0]?.reason).toBe("no-published-version");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("absent from the catalog entirely IS 'not offered'", () => {
|
|
345
|
+
const items = planPluginUpdates(
|
|
346
|
+
{ "gone@magus": "latest" },
|
|
347
|
+
new Map([
|
|
348
|
+
[
|
|
349
|
+
"other@magus",
|
|
350
|
+
info({
|
|
351
|
+
id: "other@magus",
|
|
352
|
+
version: "1.0.0",
|
|
353
|
+
projectScope: { enabled: true, version: "1.0.0" },
|
|
354
|
+
}),
|
|
355
|
+
],
|
|
356
|
+
]),
|
|
357
|
+
);
|
|
358
|
+
expect(items[0]?.action).toBe("unknown");
|
|
359
|
+
expect(items[0]?.note).toContain(
|
|
360
|
+
"not offered by any configured marketplace",
|
|
361
|
+
);
|
|
362
|
+
expect(items[0]?.reason).toBe("not-offered");
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe('the "0.0.0" registry value is unknown, never healthy', () => {
|
|
367
|
+
test("installed at 0.0.0 with no published version is no-published-version", () => {
|
|
368
|
+
// Real: explanatory-output-style@claude-plugins-official is recorded as
|
|
369
|
+
// "0.0.0" in the registry for many project paths, and its marketplace
|
|
370
|
+
// publishes no version. The TUI row hid every status suffix behind a
|
|
371
|
+
// displayable version, so this row rendered with none at all.
|
|
372
|
+
const items = planPluginUpdates(
|
|
373
|
+
{ "explanatory-output-style@official": "latest" },
|
|
374
|
+
new Map([
|
|
375
|
+
[
|
|
376
|
+
"explanatory-output-style@official",
|
|
377
|
+
info({
|
|
378
|
+
id: "explanatory-output-style@official",
|
|
379
|
+
version: null,
|
|
380
|
+
projectScope: { enabled: true, version: "0.0.0" },
|
|
381
|
+
}),
|
|
382
|
+
],
|
|
383
|
+
]),
|
|
384
|
+
);
|
|
385
|
+
expect(items[0]?.action).toBe("unknown");
|
|
386
|
+
expect(items[0]?.reason).toBe("no-published-version");
|
|
387
|
+
});
|
|
388
|
+
});
|
package/src/cli/router.ts
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
import type { ThemeMode } from "@opentui/core";
|
|
10
10
|
import { checkForUpdates } from "../services/version-check.js";
|
|
11
11
|
import { parseThemeWord } from "../ui/theme-resolve.js";
|
|
12
|
-
import { runClaudeCommand } from "./claude.js";
|
|
13
12
|
import { runDoctorCommand } from "./doctor.js";
|
|
14
13
|
import { runInstallCommand } from "./install.js";
|
|
15
14
|
import { runModelsCommand } from "./models.js";
|
|
@@ -116,8 +115,6 @@ export async function route(
|
|
|
116
115
|
const [command, ...rest] = themeFlag.rest;
|
|
117
116
|
|
|
118
117
|
switch (command) {
|
|
119
|
-
case "claude":
|
|
120
|
-
return HANDLED(await runClaudeCommand(rest));
|
|
121
118
|
case "update":
|
|
122
119
|
return HANDLED(await runUpdateCommand(rest));
|
|
123
120
|
case "upgrade":
|
|
@@ -193,8 +190,6 @@ applied by a PreToolUse hook claudeup registers at user scope.
|
|
|
193
190
|
models off Stop routing; subagents inherit the session model again
|
|
194
191
|
|
|
195
192
|
Other commands:
|
|
196
|
-
claude [args...] Check for plugin updates (1h cache), then run claude
|
|
197
|
-
-f, --force Force update check (bypass 1h cache)
|
|
198
193
|
upgrade Update claudeup ITSELF to the latest version
|
|
199
194
|
|
|
200
195
|
Navigation (TUI):
|
package/src/cli/update-view.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* own word, so the report survives NO_COLOR, a pipe, and colour blindness.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import {
|
|
27
|
+
import { binCommandFor } from "../services/update-engine.js";
|
|
28
28
|
import type {
|
|
29
29
|
BinUpdateItem,
|
|
30
30
|
PluginUpdateItem,
|
|
@@ -88,7 +88,9 @@ const ACTIONS: Record<ActionKey, ActionStyle> = {
|
|
|
88
88
|
upgrade: { glyph: "↑", label: "UPGRADE", color: brand.warning },
|
|
89
89
|
repair: { glyph: "⟳", label: "REPAIR", color: brand.info },
|
|
90
90
|
refresh: { glyph: "⟳", label: "REFRESH", color: brand.info },
|
|
91
|
-
|
|
91
|
+
// Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
|
|
92
|
+
// as "broken". The summary line and the TUI already use warning for it.
|
|
93
|
+
unknown: { glyph: "?", label: "UNKNOWN", color: brand.warning },
|
|
92
94
|
};
|
|
93
95
|
|
|
94
96
|
/** Widest label plus the badge's own two spaces of padding. */
|
|
@@ -251,17 +253,40 @@ function pluginDetail(item: PluginUpdateItem): string {
|
|
|
251
253
|
}
|
|
252
254
|
}
|
|
253
255
|
|
|
256
|
+
/**
|
|
257
|
+
* What a tool row says on its right.
|
|
258
|
+
*
|
|
259
|
+
* A `current` row used to print `pinned —` unconditionally, which was true only
|
|
260
|
+
* while "current" implied "pinned". An unpinned tool can be current now, so the
|
|
261
|
+
* pin is shown only when there is one.
|
|
262
|
+
*/
|
|
254
263
|
function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
|
|
255
264
|
const spec = plan.binSpecs.get(item.name);
|
|
256
|
-
if (item.action === "current" || !spec)
|
|
257
|
-
return `pinned ${item.version ?? "—"}`;
|
|
258
|
-
const cmd =
|
|
259
|
-
item.action === "install"
|
|
260
|
-
? binInstallCommand(spec)
|
|
261
|
-
: binUpgradeCommand(spec);
|
|
262
265
|
// Rendered for the reader's eyes only — this row says what WILL run, while
|
|
263
|
-
// applyBins runs the argv itself.
|
|
264
|
-
|
|
266
|
+
// applyBins runs the argv itself. Same builder, so they cannot differ.
|
|
267
|
+
const cmd = spec ? binCommandFor(item, spec) : null;
|
|
268
|
+
const command = cmd ? dim(formatCommand(cmd)) : "";
|
|
269
|
+
switch (item.action) {
|
|
270
|
+
case "current":
|
|
271
|
+
return `${item.installed ?? "—"}${
|
|
272
|
+
item.version ? ` ${dim(`pinned ${item.version}`)}` : ""
|
|
273
|
+
}`;
|
|
274
|
+
case "upgrade":
|
|
275
|
+
return `${dim(item.installed ?? "—")} ${fg(brand.warning, "→")} ${fg(
|
|
276
|
+
brand.warning,
|
|
277
|
+
item.target ?? "?",
|
|
278
|
+
)}${command ? ` ${command}` : ""}${
|
|
279
|
+
// An upgrade that also changes the tool's owner says so here; a
|
|
280
|
+
// plain one reads identically to before.
|
|
281
|
+
item.adopted ? ` ${dim(`(${item.adopted})`)}` : ""
|
|
282
|
+
}`;
|
|
283
|
+
case "unknown":
|
|
284
|
+
return `${item.installed ?? "—"}${
|
|
285
|
+
item.note ? ` ${dim(`(${item.note})`)}` : ""
|
|
286
|
+
}`;
|
|
287
|
+
case "install":
|
|
288
|
+
return command;
|
|
289
|
+
}
|
|
265
290
|
}
|
|
266
291
|
|
|
267
292
|
/**
|
|
@@ -396,22 +421,30 @@ export function applyRow(
|
|
|
396
421
|
)}`;
|
|
397
422
|
}
|
|
398
423
|
|
|
399
|
-
/**
|
|
424
|
+
/**
|
|
425
|
+
* Closing line: the run's shape as a bar, then the counts that label it.
|
|
426
|
+
*
|
|
427
|
+
* `unchanged` is its own count: an installer that exited 0 while read-back
|
|
428
|
+
* shows nothing moved. Folding it into `changed` is how every run printed
|
|
429
|
+
* "3 changed" while changing nothing; it is not a failure either.
|
|
430
|
+
*/
|
|
400
431
|
export function summaryLine(
|
|
401
432
|
changed: number,
|
|
402
433
|
current: number,
|
|
403
434
|
unknown: number,
|
|
404
435
|
failed: number,
|
|
405
436
|
ms: number,
|
|
437
|
+
unchanged = 0,
|
|
406
438
|
): string[] {
|
|
407
439
|
const bar = stackedBar(BAR * 2, [
|
|
408
440
|
{ value: changed, color: brand.success },
|
|
409
441
|
{ value: current, color: brand.muted },
|
|
410
|
-
{ value: unknown, color: brand.warning },
|
|
442
|
+
{ value: unchanged + unknown, color: brand.warning },
|
|
411
443
|
{ value: failed, color: brand.danger },
|
|
412
444
|
]);
|
|
413
445
|
const parts = [
|
|
414
446
|
changed > 0 ? fg(brand.success, `${changed} changed`) : "",
|
|
447
|
+
unchanged > 0 ? fg(brand.warning, `${unchanged} unchanged`) : "",
|
|
415
448
|
current > 0 ? dim(`${current} current`) : "",
|
|
416
449
|
unknown > 0 ? fg(brand.warning, `${unknown} unverified`) : "",
|
|
417
450
|
failed > 0 ? fg(brand.danger, `${failed} failed`) : "",
|
|
@@ -444,3 +477,50 @@ export function info(text: string): string {
|
|
|
444
477
|
export function note(text: string): string {
|
|
445
478
|
return `${PAD} ${dim(text)}`;
|
|
446
479
|
}
|
|
480
|
+
|
|
481
|
+
// -- unverified items ----------------------------------------------------------
|
|
482
|
+
//
|
|
483
|
+
// A plugin is `unknown` for several distinct reasons — its marketplace publishes
|
|
484
|
+
// no version, the two versions cannot be ordered, no marketplace offers it, its
|
|
485
|
+
// marketplace was not read, or the catalog fetch failed — and every row above
|
|
486
|
+
// already prints its own. These lines therefore claim only what holds for EVERY
|
|
487
|
+
// reason. They once advised "re-run once the catalog is reachable", which is
|
|
488
|
+
// false for a plugin whose marketplace publishes no version: re-running changes
|
|
489
|
+
// nothing, and the reader went looking for a network fault that did not exist.
|
|
490
|
+
|
|
491
|
+
/** Unverified plugins, under the plan. Never "up to date" — that is the point. */
|
|
492
|
+
export function unknownPluginsNotice(count: number): string {
|
|
493
|
+
return warn(
|
|
494
|
+
`${bold(String(count))} plugin(s) could not be checked — not applied. They are NOT reported as up to date. Reasons are shown above.`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Unverified tools, under the plan. */
|
|
499
|
+
export function unknownToolsNotice(count: number, names: string[]): string {
|
|
500
|
+
return warn(
|
|
501
|
+
`${bold(String(count))} tool(s) could not be checked: ${names.join(", ")} — not applied. Reasons are shown above.`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* `--check` is about to exit 0 while N items went unchecked.
|
|
507
|
+
*
|
|
508
|
+
* The gate does not fail on an unknown — plugin or tool — because most unknowns
|
|
509
|
+
* are permanent (a marketplace that publishes no version, a tool another npm
|
|
510
|
+
* owns), and a gate that can never pass protects nothing. This line is what
|
|
511
|
+
* keeps the exit 0 honest: it is "nothing is KNOWN to be behind", not
|
|
512
|
+
* "everything was verified". It counts plugins and tools together because the
|
|
513
|
+
* two per-kind notices above already name which is which.
|
|
514
|
+
*/
|
|
515
|
+
export function uncheckedGateNotice(count: number): string {
|
|
516
|
+
return warn(
|
|
517
|
+
`${bold(String(count))} item(s) could not be checked — they do not fail this gate. They are NOT reported as up to date. Reasons are shown above.`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** The run ends with nothing to apply because every remaining plugin is unverified. */
|
|
522
|
+
export function nothingToApplyNotice(unknownPlugins: number): string {
|
|
523
|
+
return warn(
|
|
524
|
+
`Nothing to apply. ${unknownPlugins} plugin(s) could not be checked. They are NOT reported as up to date. Reasons are shown above.`,
|
|
525
|
+
);
|
|
526
|
+
}
|