claudeup 6.0.0 → 6.2.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.
- package/package.json +4 -4
- package/scripts/test-isolated.ts +7 -1
- package/src/__tests__/catalog-cache-store.test.ts +49 -11
- package/src/__tests__/cli-apply-seams.test.ts +51 -2
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/toolchain.test.ts +176 -31
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +137 -3
- package/src/cli/install.ts +11 -5
- package/src/cli/prompt.ts +0 -9
- package/src/cli/update-view.ts +4 -1
- package/src/cli/update.ts +18 -287
- package/src/data/cli-tools.ts +20 -13
- package/src/services/claude-cli.ts +67 -30
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/doctor-bins.ts +9 -3
- package/src/services/toolchain.ts +235 -33
- package/src/services/update-engine.ts +503 -0
- package/src/ui/renderers/cliToolRenderers.tsx +40 -39
- package/src/ui/screens/CliToolsScreen.tsx +56 -64
- package/src/ui/screens/PluginsScreen.tsx +120 -64
- package/src/utils/command-utils.ts +102 -1
- package/src/utils/run.ts +67 -0
package/src/cli/update.ts
CHANGED
|
@@ -34,13 +34,7 @@
|
|
|
34
34
|
* `cli/update-view.ts` owns every string. Nothing in this file formats.
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
-
import
|
|
38
|
-
import type { PluginScope } from "../services/claude-cli.js";
|
|
39
|
-
import {
|
|
40
|
-
isClaudeAvailable,
|
|
41
|
-
repairPlugin,
|
|
42
|
-
updatePlugin,
|
|
43
|
-
} from "../services/claude-cli.js";
|
|
37
|
+
import { isClaudeAvailable } from "../services/claude-cli.js";
|
|
44
38
|
import { clearContentDriftCache } from "../services/content-drift.js";
|
|
45
39
|
import { checkBinaries } from "../services/doctor-bins.js";
|
|
46
40
|
import { validateManifest } from "../services/manifest.js";
|
|
@@ -50,16 +44,18 @@ import {
|
|
|
50
44
|
type PluginInfo,
|
|
51
45
|
clearMarketplaceCache,
|
|
52
46
|
getAvailablePlugins,
|
|
53
|
-
readInstalledVersionForScope,
|
|
54
|
-
saveInstalledPluginVersionForScope,
|
|
55
47
|
} from "../services/plugin-manager.js";
|
|
56
48
|
import { resolveProfile } from "../services/resolver.js";
|
|
57
|
-
import { installSkill } from "../services/skills-manager.js";
|
|
58
49
|
import { getInstalledSkillNames } from "../services/skills-manager.js";
|
|
59
50
|
import { activeProfile } from "../services/symlink-manager.js";
|
|
60
|
-
|
|
51
|
+
// The apply half lives in services/ so the TUI runs the SAME code — see the
|
|
52
|
+
// header of update-engine.ts for what drifted while it did not.
|
|
53
|
+
import {
|
|
54
|
+
type ApplyReporter,
|
|
55
|
+
applyPlan,
|
|
56
|
+
planWorkItems,
|
|
57
|
+
} from "../services/update-engine.js";
|
|
61
58
|
import {
|
|
62
|
-
type PluginUpdateItem,
|
|
63
59
|
type UpdatePlan,
|
|
64
60
|
planBinUpdates,
|
|
65
61
|
planHasWork,
|
|
@@ -68,12 +64,10 @@ import {
|
|
|
68
64
|
planSkillUpdates,
|
|
69
65
|
summarizePlan,
|
|
70
66
|
} from "../services/update-plan.js";
|
|
71
|
-
import type { ProfileSkillRef, SkillInfo } from "../types/index.js";
|
|
72
67
|
import { brand } from "../ui/theme.js";
|
|
73
68
|
import { bold, dim, fg, width } from "./ansi.js";
|
|
74
69
|
import { ensureManifest } from "./bootstrap.js";
|
|
75
70
|
import { LiveRegion, withPaused } from "./live.js";
|
|
76
|
-
import { runShell } from "./prompt.js";
|
|
77
71
|
import {
|
|
78
72
|
type ApplyState,
|
|
79
73
|
type Step,
|
|
@@ -114,253 +108,6 @@ export function parseArgs(args: string[]): UpdateFlags {
|
|
|
114
108
|
};
|
|
115
109
|
}
|
|
116
110
|
|
|
117
|
-
// -- apply -------------------------------------------------------------------
|
|
118
|
-
|
|
119
|
-
/** Map a manifest skill ref to the SkillInfo shape installSkill expects. */
|
|
120
|
-
function toSkillInfo(ref: ProfileSkillRef): SkillInfo {
|
|
121
|
-
return {
|
|
122
|
-
id: `${ref.repo}/${ref.path}`,
|
|
123
|
-
name: ref.name,
|
|
124
|
-
source: {
|
|
125
|
-
label: ref.repo,
|
|
126
|
-
repo: ref.repo,
|
|
127
|
-
skillsPath: path.dirname(ref.path),
|
|
128
|
-
},
|
|
129
|
-
repoPath: ref.path,
|
|
130
|
-
gitBlobSha: "",
|
|
131
|
-
frontmatter: null,
|
|
132
|
-
installed: false,
|
|
133
|
-
installedScope: null,
|
|
134
|
-
hasUpdate: false,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
interface ApplyResult {
|
|
139
|
-
ok: number;
|
|
140
|
-
failed: string[];
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** An exact pin the installer could not deliver, discovered by reading back. */
|
|
144
|
-
export interface PinMismatch {
|
|
145
|
-
pluginId: string;
|
|
146
|
-
pinned: string;
|
|
147
|
-
actual: string | null;
|
|
148
|
-
scope: PluginScope;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export interface PluginApplyResult extends ApplyResult {
|
|
152
|
-
mismatches: PinMismatch[];
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* How the apply loop reports itself.
|
|
157
|
-
*
|
|
158
|
-
* Injected rather than hard-coded, because the loop is the same work whether it
|
|
159
|
-
* is driving an animated meter, printing plain lines into a pipe, or running
|
|
160
|
-
* under a test that wants silence. The default prints one line per item, which
|
|
161
|
-
* is what every non-interactive caller wants.
|
|
162
|
-
*/
|
|
163
|
-
export interface ApplyReporter {
|
|
164
|
-
/** An item is starting. Called before any of its work. */
|
|
165
|
-
begin(name: string): void;
|
|
166
|
-
/** An item finished. `detail` is already formatted for display. */
|
|
167
|
-
finish(ok: boolean, name: string, detail: string, ms: number): void;
|
|
168
|
-
/**
|
|
169
|
-
* Run `work` with the terminal released.
|
|
170
|
-
*
|
|
171
|
-
* A binary upgrade spawns `brew`/`npm` with inherited stdio: it writes and
|
|
172
|
-
* moves the cursor itself, so any animated block must be erased first or the
|
|
173
|
-
* two interleave into garbage.
|
|
174
|
-
*/
|
|
175
|
-
detach<T>(work: () => Promise<T>): Promise<T>;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const PLAIN_REPORTER: ApplyReporter = {
|
|
179
|
-
begin: () => {},
|
|
180
|
-
finish: (ok, name, detail) => {
|
|
181
|
-
const line = `${ok ? "✓" : "⚠"} ${name}${detail ? ` ${detail}` : ""}`;
|
|
182
|
-
if (ok) console.log(line);
|
|
183
|
-
else console.warn(line);
|
|
184
|
-
},
|
|
185
|
-
detach: (work) => work(),
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* The four side-effecting calls the plugin apply path makes, injectable so the
|
|
190
|
-
* path itself can be tested.
|
|
191
|
-
*
|
|
192
|
-
* This exists because the apply half shipped with no coverage while the pure
|
|
193
|
-
* planner had forty tests — and every real defect found in review lived here,
|
|
194
|
-
* not there. Following the same shape `checkBinaries` and `resolveProfile`
|
|
195
|
-
* already use for their impure edges.
|
|
196
|
-
*/
|
|
197
|
-
export interface PluginApplyDeps {
|
|
198
|
-
update: (pluginId: string, scope: PluginScope) => Promise<void>;
|
|
199
|
-
repair: (
|
|
200
|
-
pluginId: string,
|
|
201
|
-
scope: PluginScope,
|
|
202
|
-
projectPath: string,
|
|
203
|
-
) => Promise<void>;
|
|
204
|
-
readInstalled: (
|
|
205
|
-
pluginId: string,
|
|
206
|
-
scope: PluginScope,
|
|
207
|
-
projectPath: string,
|
|
208
|
-
) => Promise<string | null>;
|
|
209
|
-
saveInstalled: (
|
|
210
|
-
pluginId: string,
|
|
211
|
-
version: string,
|
|
212
|
-
scope: PluginScope,
|
|
213
|
-
projectPath: string,
|
|
214
|
-
) => Promise<void>;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
218
|
-
update: updatePlugin,
|
|
219
|
-
repair: repairPlugin,
|
|
220
|
-
readInstalled: readInstalledVersionForScope,
|
|
221
|
-
saveInstalled: saveInstalledPluginVersionForScope,
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Scopes the apply loop would touch for `item`. Empty means it is skipped.
|
|
226
|
-
*
|
|
227
|
-
* A plugin the profile declares but nothing has installed goes in at PROJECT
|
|
228
|
-
* scope, matching `install`: a profile is a property of one repo, so its
|
|
229
|
-
* plugins must not be enabled machine-wide.
|
|
230
|
-
*/
|
|
231
|
-
function scopesToApply(item: PluginUpdateItem): PluginScope[] {
|
|
232
|
-
if (item.action === "current" || item.action === "unknown") return [];
|
|
233
|
-
return item.action === "install" ? ["project"] : item.scopes;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Whether the apply loop will do anything for `item`.
|
|
238
|
-
*
|
|
239
|
-
* Exported shape for ONE reason: the progress meter's denominator must be the
|
|
240
|
-
* same predicate the loop actually applies. It was previously re-derived at the
|
|
241
|
-
* call site and replicated only the first of the two skips — an actionable item
|
|
242
|
-
* with empty `scopes` was counted but never run, so the bar would stop short of
|
|
243
|
-
* full and sit there looking hung at the end of a successful update.
|
|
244
|
-
*/
|
|
245
|
-
export function pluginNeedsWork(item: PluginUpdateItem): boolean {
|
|
246
|
-
return scopesToApply(item).length > 0;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export async function applyPlugins(
|
|
250
|
-
items: PluginUpdateItem[],
|
|
251
|
-
projectPath: string,
|
|
252
|
-
deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
|
|
253
|
-
report: ApplyReporter = PLAIN_REPORTER,
|
|
254
|
-
): Promise<PluginApplyResult> {
|
|
255
|
-
const result: PluginApplyResult = { ok: 0, failed: [], mismatches: [] };
|
|
256
|
-
|
|
257
|
-
for (const item of items) {
|
|
258
|
-
const scopes = scopesToApply(item);
|
|
259
|
-
if (scopes.length === 0) continue;
|
|
260
|
-
|
|
261
|
-
report.begin(item.pluginId);
|
|
262
|
-
const startedAt = Date.now();
|
|
263
|
-
|
|
264
|
-
try {
|
|
265
|
-
for (const scope of scopes) {
|
|
266
|
-
if (item.action === "repair") {
|
|
267
|
-
await deps.repair(item.pluginId, scope, projectPath);
|
|
268
|
-
} else {
|
|
269
|
-
await deps.update(item.pluginId, scope);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Record what LANDED, not what we asked for.
|
|
273
|
-
//
|
|
274
|
-
// `claude plugin install` accepts no version, so an exact pin is a
|
|
275
|
-
// request the installer cannot honour — it fetches whatever the
|
|
276
|
-
// marketplace publishes now. Writing `item.target` here recorded the
|
|
277
|
-
// pin as satisfied whatever actually installed, and `install --check`
|
|
278
|
-
// reads that same field to decide whether the machine has drifted.
|
|
279
|
-
// The gate would have compared the pin against a copy of itself and
|
|
280
|
-
// reported clean forever.
|
|
281
|
-
const actual = await deps.readInstalled(
|
|
282
|
-
item.pluginId,
|
|
283
|
-
scope,
|
|
284
|
-
projectPath,
|
|
285
|
-
);
|
|
286
|
-
if (actual) {
|
|
287
|
-
await deps.saveInstalled(item.pluginId, actual, scope, projectPath);
|
|
288
|
-
}
|
|
289
|
-
if (item.pinned !== "latest" && actual !== item.pinned) {
|
|
290
|
-
result.mismatches.push({
|
|
291
|
-
pluginId: item.pluginId,
|
|
292
|
-
pinned: item.pinned,
|
|
293
|
-
actual,
|
|
294
|
-
scope,
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
report.finish(
|
|
299
|
-
true,
|
|
300
|
-
item.pluginId,
|
|
301
|
-
`${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`,
|
|
302
|
-
Date.now() - startedAt,
|
|
303
|
-
);
|
|
304
|
-
result.ok++;
|
|
305
|
-
} catch (error) {
|
|
306
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
307
|
-
report.finish(false, item.pluginId, msg, Date.now() - startedAt);
|
|
308
|
-
result.failed.push(item.pluginId);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
return result;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
async function applyBins(
|
|
316
|
-
plan: UpdatePlan,
|
|
317
|
-
report: ApplyReporter = PLAIN_REPORTER,
|
|
318
|
-
): Promise<ApplyResult> {
|
|
319
|
-
const result: ApplyResult = { ok: 0, failed: [] };
|
|
320
|
-
for (const item of plan.bins) {
|
|
321
|
-
if (item.action === "current") continue;
|
|
322
|
-
const bin = plan.binSpecs.get(item.name);
|
|
323
|
-
if (!bin) continue;
|
|
324
|
-
const cmd =
|
|
325
|
-
item.action === "install"
|
|
326
|
-
? binInstallCommand(bin)
|
|
327
|
-
: binUpgradeCommand(bin);
|
|
328
|
-
report.begin(item.name);
|
|
329
|
-
const startedAt = Date.now();
|
|
330
|
-
// The installer owns the terminal for the duration: it streams its own
|
|
331
|
-
// output through inherited stdio.
|
|
332
|
-
const succeeded = await report.detach(async () => {
|
|
333
|
-
console.log(`\n$ ${cmd}`);
|
|
334
|
-
return runShell(cmd);
|
|
335
|
-
});
|
|
336
|
-
report.finish(succeeded, item.name, cmd, Date.now() - startedAt);
|
|
337
|
-
if (succeeded) result.ok++;
|
|
338
|
-
else result.failed.push(item.name);
|
|
339
|
-
}
|
|
340
|
-
return result;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
async function applySkills(
|
|
344
|
-
plan: UpdatePlan,
|
|
345
|
-
projectPath: string,
|
|
346
|
-
report: ApplyReporter = PLAIN_REPORTER,
|
|
347
|
-
): Promise<ApplyResult> {
|
|
348
|
-
const result: ApplyResult = { ok: 0, failed: [] };
|
|
349
|
-
for (const item of plan.skills) {
|
|
350
|
-
report.begin(item.name);
|
|
351
|
-
const startedAt = Date.now();
|
|
352
|
-
try {
|
|
353
|
-
await installSkill(toSkillInfo(item.ref), "project", projectPath);
|
|
354
|
-
report.finish(true, item.name, item.ref.repo, Date.now() - startedAt);
|
|
355
|
-
result.ok++;
|
|
356
|
-
} catch (error) {
|
|
357
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
358
|
-
report.finish(false, item.name, msg, Date.now() - startedAt);
|
|
359
|
-
result.failed.push(item.name);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
return result;
|
|
363
|
-
}
|
|
364
111
|
|
|
365
112
|
// -- phase runner ------------------------------------------------------------
|
|
366
113
|
|
|
@@ -637,14 +384,10 @@ export async function runUpdateCommand(
|
|
|
637
384
|
return 1;
|
|
638
385
|
}
|
|
639
386
|
|
|
640
|
-
// Exactly the items the
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
const workItems =
|
|
644
|
-
...plan.plugins.filter(pluginNeedsWork).map((p) => p.pluginId),
|
|
645
|
-
...plan.bins.filter((b) => b.action !== "current").map((b) => b.name),
|
|
646
|
-
...plan.skills.map((s) => s.name),
|
|
647
|
-
];
|
|
387
|
+
// Exactly the items the engine will touch. Derived by the engine from the
|
|
388
|
+
// same predicates its loops use, so the meter's denominator cannot drift
|
|
389
|
+
// from the work — a bar stuck short of full reads as a hang.
|
|
390
|
+
const workItems = planWorkItems(plan);
|
|
648
391
|
|
|
649
392
|
if (!planHasWork(plan) || workItems.length === 0) {
|
|
650
393
|
for (const line of summaryLine(
|
|
@@ -741,26 +484,14 @@ export async function runUpdateCommand(
|
|
|
741
484
|
console.log("");
|
|
742
485
|
applyLive.start((tick) => applyFrame(state, tick, Date.now()));
|
|
743
486
|
|
|
744
|
-
const
|
|
745
|
-
plan.plugins,
|
|
746
|
-
projectPath,
|
|
747
|
-
REAL_PLUGIN_DEPS,
|
|
748
|
-
reporter,
|
|
749
|
-
);
|
|
750
|
-
const binResult = await applyBins(plan, reporter);
|
|
751
|
-
const skillResult = await applySkills(plan, projectPath, reporter);
|
|
487
|
+
const outcome = await applyPlan(plan, projectPath, { report: reporter });
|
|
752
488
|
|
|
753
489
|
applyLive.stop();
|
|
754
490
|
|
|
755
|
-
const failed =
|
|
756
|
-
...pluginResult.failed,
|
|
757
|
-
...binResult.failed,
|
|
758
|
-
...skillResult.failed,
|
|
759
|
-
];
|
|
760
|
-
const changed = pluginResult.ok + binResult.ok + skillResult.ok;
|
|
491
|
+
const { failed } = outcome;
|
|
761
492
|
|
|
762
493
|
for (const line of summaryLine(
|
|
763
|
-
changed,
|
|
494
|
+
outcome.changed,
|
|
764
495
|
counts.current,
|
|
765
496
|
counts.unknown,
|
|
766
497
|
failed.length,
|
|
@@ -773,13 +504,13 @@ export async function runUpdateCommand(
|
|
|
773
504
|
// of the manifest rather than something this run did wrong, and failing here
|
|
774
505
|
// would make `claudeup update` exit 1 forever for any profile with a pin.
|
|
775
506
|
// `install --check` with `strictVersions` is where pin drift is GATED.
|
|
776
|
-
if (
|
|
507
|
+
if (outcome.plugins.mismatches.length > 0) {
|
|
777
508
|
console.log(
|
|
778
509
|
warnLine(
|
|
779
|
-
`${bold(String(
|
|
510
|
+
`${bold(String(outcome.plugins.mismatches.length))} pinned version(s) are not what is installed. Only the newest published version can be installed:`,
|
|
780
511
|
),
|
|
781
512
|
);
|
|
782
|
-
for (const m of
|
|
513
|
+
for (const m of outcome.plugins.mismatches) {
|
|
783
514
|
console.log(
|
|
784
515
|
noteLine(
|
|
785
516
|
`${m.pluginId} (${m.scope}): manifest pins ${m.pinned}, installed ${m.actual ?? "unknown"}`,
|
package/src/data/cli-tools.ts
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
|
+
import type { BinInstaller } from "../types/index.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A CLI tool claudeup can install, check and update.
|
|
5
|
+
*
|
|
6
|
+
* There is deliberately NO `installCommand` field. Entries used to carry one
|
|
7
|
+
* alongside `packageManager` + `packageName`, which describe the same thing —
|
|
8
|
+
* so the two could disagree, and did: `aider` declared
|
|
9
|
+
* `packageName: "aider-chat"` while its `installCommand` installed
|
|
10
|
+
* `aider-install`, and the version check queried the former. The command is
|
|
11
|
+
* derived by `services/cli-tool-commands.ts` instead, which also means a
|
|
12
|
+
* catalogue entry can no longer smuggle shell syntax (that same entry's command
|
|
13
|
+
* was a `&&` chain) into something that gets executed.
|
|
14
|
+
*/
|
|
1
15
|
export interface CliTool {
|
|
2
16
|
name: string;
|
|
3
17
|
displayName: string;
|
|
4
18
|
description: string;
|
|
5
|
-
installCommand: string;
|
|
6
19
|
checkCommand: string;
|
|
7
20
|
website: string;
|
|
8
21
|
category: "ai-coding" | "utility";
|
|
9
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Reuses `BinInstaller` rather than restating its members, so the
|
|
24
|
+
* correspondence with the resolver in `services/toolchain.ts` — which is
|
|
25
|
+
* what turns this field into an actual command — is a compile-time fact.
|
|
26
|
+
*/
|
|
27
|
+
packageManager: BinInstaller;
|
|
10
28
|
packageName: string;
|
|
11
29
|
}
|
|
12
30
|
|
|
@@ -16,7 +34,6 @@ export const cliTools: CliTool[] = [
|
|
|
16
34
|
displayName: "claudeup",
|
|
17
35
|
description:
|
|
18
36
|
"TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
19
|
-
installCommand: "bun install -g claudeup",
|
|
20
37
|
checkCommand: "claudeup --version",
|
|
21
38
|
website: "https://github.com/MadAppGang/magus/tree/main/tools/claudeup",
|
|
22
39
|
category: "ai-coding",
|
|
@@ -28,7 +45,6 @@ export const cliTools: CliTool[] = [
|
|
|
28
45
|
displayName: "Mnemex",
|
|
29
46
|
description:
|
|
30
47
|
"AST-aware code search with PageRank, callers/callees, and semantic embeddings",
|
|
31
|
-
installCommand: "bun install -g mnemex",
|
|
32
48
|
checkCommand: "mnemex --version",
|
|
33
49
|
website: "https://github.com/MadAppGang/mnemex",
|
|
34
50
|
category: "ai-coding",
|
|
@@ -39,7 +55,6 @@ export const cliTools: CliTool[] = [
|
|
|
39
55
|
name: "claudish",
|
|
40
56
|
displayName: "Claudish",
|
|
41
57
|
description: "Run Claude Code with OpenRouter models (Grok, GPT-5, Gemini)",
|
|
42
|
-
installCommand: "bun install -g claudish",
|
|
43
58
|
checkCommand: "claudish --version",
|
|
44
59
|
website: "https://github.com/MadAppGang/claudish",
|
|
45
60
|
category: "ai-coding",
|
|
@@ -50,7 +65,6 @@ export const cliTools: CliTool[] = [
|
|
|
50
65
|
name: "claude",
|
|
51
66
|
displayName: "Claude Code",
|
|
52
67
|
description: "Anthropic official agentic coding tool",
|
|
53
|
-
installCommand: "npm install -g @anthropic-ai/claude-code",
|
|
54
68
|
checkCommand: "claude --version",
|
|
55
69
|
website: "https://claude.ai/code",
|
|
56
70
|
category: "ai-coding",
|
|
@@ -62,7 +76,6 @@ export const cliTools: CliTool[] = [
|
|
|
62
76
|
displayName: "OpenAI Codex",
|
|
63
77
|
description:
|
|
64
78
|
"Lightweight coding agent from OpenAI that runs in your terminal",
|
|
65
|
-
installCommand: "npm install -g @openai/codex",
|
|
66
79
|
checkCommand: "codex --version",
|
|
67
80
|
website: "https://github.com/openai/codex",
|
|
68
81
|
category: "ai-coding",
|
|
@@ -73,7 +86,6 @@ export const cliTools: CliTool[] = [
|
|
|
73
86
|
name: "gemini",
|
|
74
87
|
displayName: "Gemini CLI",
|
|
75
88
|
description: "Google AI agent with 1M token context, free tier available",
|
|
76
|
-
installCommand: "npm install -g @google/gemini-cli",
|
|
77
89
|
checkCommand: "gemini --version",
|
|
78
90
|
website: "https://github.com/google-gemini/gemini-cli",
|
|
79
91
|
category: "ai-coding",
|
|
@@ -84,7 +96,6 @@ export const cliTools: CliTool[] = [
|
|
|
84
96
|
name: "qwen",
|
|
85
97
|
displayName: "Qwen Code",
|
|
86
98
|
description: "Alibaba coding agent optimized for Qwen3-Coder models",
|
|
87
|
-
installCommand: "npm install -g @qwen-code/qwen-code",
|
|
88
99
|
checkCommand: "qwen --version",
|
|
89
100
|
website: "https://github.com/QwenLM/qwen-code",
|
|
90
101
|
category: "ai-coding",
|
|
@@ -96,7 +107,6 @@ export const cliTools: CliTool[] = [
|
|
|
96
107
|
displayName: "Cline",
|
|
97
108
|
description:
|
|
98
109
|
"Autonomous coding agent with plan & act, checkpoints, browser use",
|
|
99
|
-
installCommand: "npm install -g cline",
|
|
100
110
|
checkCommand: "cline --version",
|
|
101
111
|
website: "https://cline.bot",
|
|
102
112
|
category: "ai-coding",
|
|
@@ -107,7 +117,6 @@ export const cliTools: CliTool[] = [
|
|
|
107
117
|
name: "opencode",
|
|
108
118
|
displayName: "OpenCode",
|
|
109
119
|
description: "Open source AI coding agent trusted by 400k+ developers",
|
|
110
|
-
installCommand: "npm install -g opencode-ai",
|
|
111
120
|
checkCommand: "opencode --version",
|
|
112
121
|
website: "https://opencode.ai",
|
|
113
122
|
category: "ai-coding",
|
|
@@ -118,7 +127,6 @@ export const cliTools: CliTool[] = [
|
|
|
118
127
|
name: "aider",
|
|
119
128
|
displayName: "Aider",
|
|
120
129
|
description: "AI pair programming - works with Claude, GPT-4, local models",
|
|
121
|
-
installCommand: "pip install aider-install && aider-install",
|
|
122
130
|
checkCommand: "aider --version",
|
|
123
131
|
website: "https://aider.chat",
|
|
124
132
|
category: "ai-coding",
|
|
@@ -129,7 +137,6 @@ export const cliTools: CliTool[] = [
|
|
|
129
137
|
name: "crush",
|
|
130
138
|
displayName: "Crush",
|
|
131
139
|
description: "AI coding assistant from Charm with terminal-native UX",
|
|
132
|
-
installCommand: "npm install -g @charmland/crush",
|
|
133
140
|
checkCommand: "crush --version",
|
|
134
141
|
website: "https://github.com/charmbracelet/crush",
|
|
135
142
|
category: "ai-coding",
|
|
@@ -109,25 +109,28 @@ async function recoverMissingMarketplace(
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
112
|
+
* Run a `claude plugin` subcommand, recovering once from a missing or stale
|
|
113
|
+
* marketplace.
|
|
114
114
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
115
|
+
* Shared by install and update because the recovery is identical and the two
|
|
116
|
+
* had already been written twice; a third copy for `update` is how the next
|
|
117
|
+
* divergence starts.
|
|
118
118
|
*/
|
|
119
|
-
|
|
119
|
+
async function execPluginCommand(
|
|
120
|
+
verb: "install" | "update",
|
|
120
121
|
pluginId: string,
|
|
121
|
-
scope: PluginScope
|
|
122
|
+
scope: PluginScope,
|
|
123
|
+
timeoutMs?: number,
|
|
122
124
|
): Promise<void> {
|
|
125
|
+
const argv = ["plugin", verb, pluginId, "--scope", scope];
|
|
123
126
|
try {
|
|
124
|
-
await execClaude(
|
|
127
|
+
await execClaude(argv, timeoutMs);
|
|
125
128
|
} catch (error) {
|
|
126
129
|
const msg = error instanceof Error ? error.message : String(error);
|
|
127
130
|
if (msg.includes("not found in marketplace")) {
|
|
128
131
|
const marketplace = pluginId.split("@")[1];
|
|
129
132
|
if (marketplace && (await recoverMissingMarketplace(marketplace))) {
|
|
130
|
-
await execClaude(
|
|
133
|
+
await execClaude(argv, timeoutMs);
|
|
131
134
|
return;
|
|
132
135
|
}
|
|
133
136
|
}
|
|
@@ -135,6 +138,29 @@ export async function installPlugin(
|
|
|
135
138
|
}
|
|
136
139
|
}
|
|
137
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Install a plugin using claude CLI
|
|
143
|
+
* Handles enabling + version tracking + cache copy in one shot.
|
|
144
|
+
*
|
|
145
|
+
* ONLY for a plugin that is not installed at this scope yet. On one already
|
|
146
|
+
* installed there, `claude plugin install` is a no-op that ignores the version
|
|
147
|
+
* — see {@link updatePlugin}.
|
|
148
|
+
*
|
|
149
|
+
* If the install fails because the plugin is "not found in marketplace",
|
|
150
|
+
* attempts to recover (add the marketplace if missing, or refresh it if
|
|
151
|
+
* stale) and retries once.
|
|
152
|
+
*/
|
|
153
|
+
export async function installPlugin(
|
|
154
|
+
pluginId: string,
|
|
155
|
+
scope: PluginScope = "user",
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
// 60s, matching update and repair. Before the install/update split this path
|
|
158
|
+
// ran through `updatePlugin`'s 60s budget; leaving it on the 30s default
|
|
159
|
+
// would have quietly halved the time a fresh install — the SLOWEST of the
|
|
160
|
+
// three, since it downloads and copies — is allowed to take.
|
|
161
|
+
await execPluginCommand("install", pluginId, scope, 60000);
|
|
162
|
+
}
|
|
163
|
+
|
|
138
164
|
/**
|
|
139
165
|
* Uninstall a plugin using claude CLI.
|
|
140
166
|
* Falls back to direct settings removal if CLI uninstall fails
|
|
@@ -212,11 +238,37 @@ export async function disablePlugin(
|
|
|
212
238
|
}
|
|
213
239
|
|
|
214
240
|
/**
|
|
215
|
-
* Update
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
241
|
+
* Update an ALREADY-INSTALLED plugin to the latest version.
|
|
242
|
+
*
|
|
243
|
+
* Runs `claude plugin update`, not `claude plugin install`.
|
|
244
|
+
*
|
|
245
|
+
* This used to run `install`, on the documented premise that it "handles both
|
|
246
|
+
* fresh installs and re-installs (upgrades) of existing plugins". That premise
|
|
247
|
+
* is false, and every claudeup update path was silently doing nothing because
|
|
248
|
+
* of it. Measured against Claude Code 2.1.252, with designer@magus at 0.6.0 and
|
|
249
|
+
* 0.6.1 published:
|
|
250
|
+
*
|
|
251
|
+
* $ claude plugin install designer@magus --scope project
|
|
252
|
+
* Installing plugin "designer@magus"...
|
|
253
|
+
* ✔ Plugin "designer@magus" is already installed (scope: project)
|
|
254
|
+
* EXIT: 0
|
|
255
|
+
*
|
|
256
|
+
* $ claude plugin update designer@magus --scope project
|
|
257
|
+
* Checking for updates for plugin "designer@magus" at project scope…
|
|
258
|
+
* ✔ Plugin "designer" updated from 0.6.0 to 0.6.1 for scope project. Restart to apply.
|
|
259
|
+
* EXIT: 0
|
|
260
|
+
*
|
|
261
|
+
* `install` is idempotent on (plugin, scope) and never looks at the version, so
|
|
262
|
+
* it exits 0 having done nothing — indistinguishable from success to every
|
|
263
|
+
* caller. The registry keeps the old version, the read-back in `update-engine`
|
|
264
|
+
* records that old version as "what landed", and the same update is offered
|
|
265
|
+
* again on the next run, for ever.
|
|
266
|
+
*
|
|
267
|
+
* The two commands are NOT interchangeable in the other direction either:
|
|
268
|
+
* `claude plugin update` on a plugin absent from this scope reports "already at
|
|
269
|
+
* the latest version" and installs nothing. A fresh install must go through
|
|
270
|
+
* {@link installPlugin}, and content drift under an unchanged version still
|
|
271
|
+
* needs {@link repairPlugin} — `update` has no version bump to act on there.
|
|
220
272
|
*
|
|
221
273
|
* Retries with marketplace update on "not found" errors (same as installPlugin).
|
|
222
274
|
*/
|
|
@@ -224,22 +276,7 @@ export async function updatePlugin(
|
|
|
224
276
|
pluginId: string,
|
|
225
277
|
scope: PluginScope = "user",
|
|
226
278
|
): Promise<void> {
|
|
227
|
-
|
|
228
|
-
await execClaude(["plugin", "install", pluginId, "--scope", scope], 60000);
|
|
229
|
-
} catch (error) {
|
|
230
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
231
|
-
if (msg.includes("not found in marketplace")) {
|
|
232
|
-
const marketplace = pluginId.split("@")[1];
|
|
233
|
-
if (marketplace && (await recoverMissingMarketplace(marketplace))) {
|
|
234
|
-
await execClaude(
|
|
235
|
-
["plugin", "install", pluginId, "--scope", scope],
|
|
236
|
-
60000,
|
|
237
|
-
);
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
throw error;
|
|
242
|
-
}
|
|
279
|
+
await execPluginCommand("update", pluginId, scope, 60000);
|
|
243
280
|
}
|
|
244
281
|
|
|
245
282
|
/**
|