claudeup 6.0.0 → 6.1.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__/cli-apply-seams.test.ts +50 -2
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/format-command.test.ts +78 -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 +46 -1
- 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/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 +412 -0
- package/src/ui/renderers/cliToolRenderers.tsx +40 -39
- package/src/ui/screens/CliToolsScreen.tsx +56 -64
- package/src/ui/screens/PluginsScreen.tsx +119 -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",
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three commands the CLI-tools screen can run against a catalogue entry:
|
|
3
|
+
* install, update, uninstall.
|
|
4
|
+
*
|
|
5
|
+
* A module rather than helpers inside the screen, for two reasons:
|
|
6
|
+
*
|
|
7
|
+
* - **The duplicate.** Uninstall existed twice — once in `CliToolsScreen.tsx`
|
|
8
|
+
* and once in `cliToolRenderers.tsx` (as `getUninstallHint`) — because the
|
|
9
|
+
* renderer needs to SHOW it and the screen needs to RUN it. The two copies
|
|
10
|
+
* had already drifted: one emitted `pip uninstall`, the other
|
|
11
|
+
* `pip uninstall -y`. Both were wrong in the same way, which is the point —
|
|
12
|
+
* a bug in a duplicated builder has to be found twice.
|
|
13
|
+
* - **Testability.** As `.tsx` helpers these were unreachable from a suite that
|
|
14
|
+
* cannot render React. As pure functions over data they are pinned by
|
|
15
|
+
* `cli-tool-commands.test.ts`, which is what proves no arm emits a bare
|
|
16
|
+
* `pip` — the defect that shipped.
|
|
17
|
+
*
|
|
18
|
+
* Import direction stays acyclic: renderer → service, screen → service +
|
|
19
|
+
* renderer.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { CliTool } from "../data/cli-tools.js";
|
|
23
|
+
import type { Command } from "../utils/command-utils.js";
|
|
24
|
+
import {
|
|
25
|
+
binInstallCommand,
|
|
26
|
+
binUninstallCommand,
|
|
27
|
+
binUpgradeCommand,
|
|
28
|
+
} from "./toolchain.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* How a tool turned out to be installed, as detected from its path on disk.
|
|
32
|
+
*
|
|
33
|
+
* NOT the same question as `CliTool.packageManager`, and deliberately not the
|
|
34
|
+
* same type. `packageManager` is how claudeup WOULD install the tool; this is
|
|
35
|
+
* how it IS installed — which may be neither, since a user can `pnpm add -g` or
|
|
36
|
+
* `yarn global add` something the catalogue calls an npm package. Updating has
|
|
37
|
+
* to follow what is actually on the machine, or it installs a second copy
|
|
38
|
+
* beside the first and the screen then reports a conflict it created itself.
|
|
39
|
+
*
|
|
40
|
+
* `pnpm`, `yarn` and `unknown` have no `BinInstaller` counterpart, which is why
|
|
41
|
+
* the two unions stay separate rather than one being widened into the other.
|
|
42
|
+
*/
|
|
43
|
+
export type InstallMethod =
|
|
44
|
+
| "npm"
|
|
45
|
+
| "bun"
|
|
46
|
+
| "pnpm"
|
|
47
|
+
| "yarn"
|
|
48
|
+
| "brew"
|
|
49
|
+
| "pip"
|
|
50
|
+
| "unknown";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Install a catalogue tool, derived from the entry's own fields.
|
|
54
|
+
*
|
|
55
|
+
* Entries used to carry a literal `installCommand` string beside the
|
|
56
|
+
* `packageManager` and `packageName` that describe the same thing, so the two
|
|
57
|
+
* could disagree — and did. `aider` declared `packageName: "aider-chat"` and
|
|
58
|
+
* `checkCommand: "aider --version"`, and its `installCommand` installed
|
|
59
|
+
* `aider-install`: a bootstrapper whose version nothing in this screen ever
|
|
60
|
+
* looked at, while the version check queried `aider-chat`. Deriving makes that
|
|
61
|
+
* disagreement unrepresentable rather than merely fixed.
|
|
62
|
+
*/
|
|
63
|
+
export function cliToolInstall(tool: CliTool): Command {
|
|
64
|
+
return binInstallCommand({
|
|
65
|
+
name: tool.name,
|
|
66
|
+
via: tool.packageManager,
|
|
67
|
+
package: tool.packageName,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Update a tool, following the method it was ACTUALLY installed with.
|
|
73
|
+
*
|
|
74
|
+
* Returns null when there is nothing sound to run — an `unknown` method means
|
|
75
|
+
* the detector could not attribute the binary on PATH to any package manager,
|
|
76
|
+
* and guessing there would install a second copy alongside the one already
|
|
77
|
+
* there. Callers fall back to installing.
|
|
78
|
+
*/
|
|
79
|
+
export function cliToolUpdate(
|
|
80
|
+
tool: CliTool,
|
|
81
|
+
method: InstallMethod,
|
|
82
|
+
brewFormula?: string,
|
|
83
|
+
): Command | null {
|
|
84
|
+
switch (method) {
|
|
85
|
+
// The three the resolver does not model. `pnpm`/`yarn` are not
|
|
86
|
+
// BinInstallers because claudeup never chooses them — it only finds them.
|
|
87
|
+
case "pnpm":
|
|
88
|
+
return { cmd: "pnpm", args: ["install", "-g", tool.packageName] };
|
|
89
|
+
case "yarn":
|
|
90
|
+
return { cmd: "yarn", args: ["global", "add", tool.packageName] };
|
|
91
|
+
case "unknown":
|
|
92
|
+
return null;
|
|
93
|
+
default:
|
|
94
|
+
// TS has narrowed to "npm" | "bun" | "brew" | "pip" here, which is
|
|
95
|
+
// assignable to BinInstaller — so the four real installers delegate,
|
|
96
|
+
// and `pip` in particular gets the uv/pipx/python3 resolution instead
|
|
97
|
+
// of the bare `pip` this switch used to fall through to.
|
|
98
|
+
return binUpgradeCommand({
|
|
99
|
+
name: tool.name,
|
|
100
|
+
via: method,
|
|
101
|
+
package: tool.packageName,
|
|
102
|
+
formula: brewFormula,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Remove a tool, following the method it was actually installed with.
|
|
109
|
+
*
|
|
110
|
+
* Null means "no command to offer": `unknown` for the reason above, and `go`
|
|
111
|
+
* because a `go install` binary has no uninstaller to call.
|
|
112
|
+
*/
|
|
113
|
+
export function cliToolUninstall(
|
|
114
|
+
tool: CliTool,
|
|
115
|
+
method: InstallMethod,
|
|
116
|
+
brewFormula?: string,
|
|
117
|
+
): Command | null {
|
|
118
|
+
switch (method) {
|
|
119
|
+
case "pnpm":
|
|
120
|
+
return { cmd: "pnpm", args: ["remove", "-g", tool.packageName] };
|
|
121
|
+
case "yarn":
|
|
122
|
+
return { cmd: "yarn", args: ["global", "remove", tool.packageName] };
|
|
123
|
+
case "unknown":
|
|
124
|
+
return null;
|
|
125
|
+
default:
|
|
126
|
+
return binUninstallCommand({
|
|
127
|
+
name: tool.name,
|
|
128
|
+
via: method,
|
|
129
|
+
package: tool.packageName,
|
|
130
|
+
formula: brewFormula,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -8,13 +8,19 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ResolvedBin } from "../types/index.js";
|
|
11
|
-
import { resolveExecutable } from "../utils/command-utils.js";
|
|
11
|
+
import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
|
|
12
12
|
import { binInstallCommand } from "./toolchain.js";
|
|
13
13
|
|
|
14
14
|
export interface BinCheckResult {
|
|
15
15
|
name: string;
|
|
16
16
|
present: boolean;
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* The command that would install it, shown when missing.
|
|
19
|
+
*
|
|
20
|
+
* A rendered string, not a {@link Command}: this is advisory text printed
|
|
21
|
+
* after "run:" for a human to copy. Nothing executes it — doctor diagnoses
|
|
22
|
+
* and never installs — so there is nothing here for a shell to re-parse.
|
|
23
|
+
*/
|
|
18
24
|
installCommand: string;
|
|
19
25
|
/** Where the requirement came from (profile cliTools / plugin ids). */
|
|
20
26
|
sources: string[];
|
|
@@ -44,7 +50,7 @@ export async function checkBinaries(
|
|
|
44
50
|
results.push({
|
|
45
51
|
name: bin.name,
|
|
46
52
|
present: await isPresent(bin.name),
|
|
47
|
-
installCommand: binInstallCommand(bin),
|
|
53
|
+
installCommand: formatCommand(binInstallCommand(bin)),
|
|
48
54
|
sources: bin.sources,
|
|
49
55
|
});
|
|
50
56
|
}
|