magus-cli 7.11.7 → 7.12.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/src/__tests__/alias-agent-teams.test.ts +259 -0
- package/src/__tests__/alias-autocompact.test.ts +169 -0
- package/src/__tests__/alias-env-adoption.test.ts +334 -0
- package/src/__tests__/alias-option-compatibility.test.ts +92 -0
- package/src/__tests__/marketplace-refresh.test.ts +211 -2
- package/src/cli/update.ts +39 -22
- package/src/data/alias-flags.ts +123 -1
- package/src/services/alias-shell-writer.ts +100 -21
- package/src/services/catalog-cache-store.ts +67 -27
- package/src/services/marketplace-fetcher.ts +4 -0
- package/src/services/marketplace-refresh.ts +107 -30
- package/src/ui/screens/AliasScreen.tsx +31 -6
|
@@ -20,9 +20,15 @@
|
|
|
20
20
|
import fs from "node:fs";
|
|
21
21
|
import path from "node:path";
|
|
22
22
|
import { runBounded } from "../utils/spawn-bounded.js";
|
|
23
|
+
import { parsePluginId } from "../utils/string-utils.js";
|
|
24
|
+
import {
|
|
25
|
+
readCloneRefreshAge,
|
|
26
|
+
writeCloneRefreshed,
|
|
27
|
+
} from "./catalog-cache-store.js";
|
|
23
28
|
import {
|
|
24
29
|
getConfiguredMarketplaces,
|
|
25
30
|
getGlobalClaudeDir,
|
|
31
|
+
getGlobalEnabledPlugins,
|
|
26
32
|
getMarketplaceAutoUpdate,
|
|
27
33
|
} from "./claude-settings.js";
|
|
28
34
|
|
|
@@ -37,10 +43,54 @@ export interface RefreshResult {
|
|
|
37
43
|
refreshed: string[];
|
|
38
44
|
/** Pull errored (network, diverged, timeout) — clone left intact and stale. */
|
|
39
45
|
failed: string[];
|
|
40
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Not pulled because this machine already refreshed the clone successfully
|
|
48
|
+
* within CATALOG_TTL_MS — by this project's run or any other.
|
|
49
|
+
*/
|
|
50
|
+
fresh: string[];
|
|
51
|
+
/**
|
|
52
|
+
* Report-only: selected, and a writing run would pull it. Always empty when
|
|
53
|
+
* the refresh actually ran.
|
|
54
|
+
*/
|
|
55
|
+
pending: string[];
|
|
56
|
+
/**
|
|
57
|
+
* Not pulled, and not a failure: no installed plugin comes from it, not a
|
|
58
|
+
* github source, autoUpdate off, no clone on disk, or dirty tree.
|
|
59
|
+
*/
|
|
41
60
|
skipped: string[];
|
|
42
61
|
}
|
|
43
62
|
|
|
63
|
+
export interface RefreshOptions {
|
|
64
|
+
/**
|
|
65
|
+
* Plugin ids (`name@marketplace`) this run installs — the profile's
|
|
66
|
+
* PROJECT/LOCAL set. Unioned with every plugin installed at USER scope;
|
|
67
|
+
* a marketplace none of them comes from is not pulled.
|
|
68
|
+
*/
|
|
69
|
+
pluginIds?: Iterable<string>;
|
|
70
|
+
/** Clock seam for the once-an-hour rule. */
|
|
71
|
+
now?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The marketplaces some installed plugin comes from: the ones passed in
|
|
76
|
+
* (PROJECT/LOCAL, from the profile) plus every plugin turned on at USER scope.
|
|
77
|
+
* A USER entry set to `false` is not installed and does not count.
|
|
78
|
+
*/
|
|
79
|
+
async function neededMarketplaces(
|
|
80
|
+
pluginIds: Iterable<string>,
|
|
81
|
+
): Promise<Set<string>> {
|
|
82
|
+
const ids = [...pluginIds];
|
|
83
|
+
for (const [id, on] of Object.entries(await getGlobalEnabledPlugins())) {
|
|
84
|
+
if (on === true) ids.push(id);
|
|
85
|
+
}
|
|
86
|
+
const needed = new Set<string>();
|
|
87
|
+
for (const id of ids) {
|
|
88
|
+
const parsed = parsePluginId(id);
|
|
89
|
+
if (parsed) needed.add(parsed.marketplace);
|
|
90
|
+
}
|
|
91
|
+
return needed;
|
|
92
|
+
}
|
|
93
|
+
|
|
44
94
|
function cloneDir(name: string): string {
|
|
45
95
|
return path.join(getGlobalClaudeDir(), "plugins", "marketplaces", name);
|
|
46
96
|
}
|
|
@@ -170,46 +220,62 @@ function tap(fn: (() => void) | undefined): void {
|
|
|
170
220
|
}
|
|
171
221
|
}
|
|
172
222
|
|
|
173
|
-
/**
|
|
174
|
-
* Non-destructively fast-forward the on-disk clone of every registered,
|
|
175
|
-
* auto-update-enabled, github-sourced marketplace.
|
|
176
|
-
*
|
|
177
|
-
* @param skip marketplaces already refreshed this run (e.g. re-registered ones),
|
|
178
|
-
* so they aren't pulled twice.
|
|
179
|
-
* @param progress optional taps for a live progress display.
|
|
180
|
-
* @returns which marketplaces were refreshed, which failed (clone left intact),
|
|
181
|
-
* and which were skipped. Failures and skips are collected rather than thrown —
|
|
182
|
-
* one unreachable marketplace must not block launching Claude. Pulls run
|
|
183
|
-
* concurrently, so a slow one doesn't serialize the rest onto the launch path.
|
|
184
|
-
*/
|
|
185
223
|
/**
|
|
186
224
|
* Select and report, writing nothing.
|
|
187
225
|
*
|
|
188
|
-
* Everything above the pull loop is a read — the marketplace registry
|
|
189
|
-
* entry's `autoUpdate` flag
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
226
|
+
* Everything above the pull loop is a read — the marketplace registry, each
|
|
227
|
+
* entry's `autoUpdate` flag, which marketplaces installed plugins come from,
|
|
228
|
+
* and when each clone was last refreshed — so a read-only caller can have the
|
|
229
|
+
* full selection verdict for free. `--dry-run` and `--check` promise to write
|
|
230
|
+
* nothing and therefore cannot pull (nor record a refresh time), but their
|
|
231
|
+
* report must name the same set a writing run would pull: that set is
|
|
232
|
+
* `pending`.
|
|
195
233
|
*/
|
|
196
234
|
export async function reportRegisteredMarketplaces(
|
|
197
235
|
skip: Iterable<string> = [],
|
|
236
|
+
options: RefreshOptions = {},
|
|
198
237
|
): Promise<RefreshResult> {
|
|
199
|
-
return
|
|
238
|
+
return selectAndRefresh(skip, {}, { ...options, reportOnly: true });
|
|
200
239
|
}
|
|
201
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Non-destructively fast-forward the on-disk clone of every registered,
|
|
243
|
+
* auto-update-enabled, github-sourced marketplace that some installed plugin
|
|
244
|
+
* comes from (USER scope, or `options.pluginIds`), unless this machine already
|
|
245
|
+
* refreshed it within CATALOG_TTL_MS. A successful pull records its time; a
|
|
246
|
+
* failed one does not, so the next run retries.
|
|
247
|
+
*
|
|
248
|
+
* @param skip marketplaces already refreshed this run (e.g. re-registered ones),
|
|
249
|
+
* so they aren't pulled twice.
|
|
250
|
+
* @param progress optional taps for a live progress display.
|
|
251
|
+
* @returns which marketplaces were refreshed, which failed (clone left intact),
|
|
252
|
+
* which were fresh, and which were skipped. Failures and skips are collected
|
|
253
|
+
* rather than thrown — one unreachable marketplace must not block launching
|
|
254
|
+
* Claude. Pulls run concurrently, so a slow one doesn't serialize the rest
|
|
255
|
+
* onto the launch path.
|
|
256
|
+
*/
|
|
202
257
|
export async function refreshRegisteredMarketplaces(
|
|
203
258
|
skip: Iterable<string> = [],
|
|
204
259
|
progress: RefreshProgress = {},
|
|
205
|
-
options:
|
|
260
|
+
options: RefreshOptions = {},
|
|
261
|
+
): Promise<RefreshResult> {
|
|
262
|
+
return selectAndRefresh(skip, progress, { ...options, reportOnly: false });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function selectAndRefresh(
|
|
266
|
+
skip: Iterable<string>,
|
|
267
|
+
progress: RefreshProgress,
|
|
268
|
+
options: RefreshOptions & { reportOnly: boolean },
|
|
206
269
|
): Promise<RefreshResult> {
|
|
207
270
|
const refreshed: string[] = [];
|
|
208
271
|
const failed: string[] = [];
|
|
272
|
+
const fresh: string[] = [];
|
|
209
273
|
const skipped: string[] = [];
|
|
274
|
+
const now = options.now ?? Date.now();
|
|
210
275
|
|
|
211
276
|
const configured = await getConfiguredMarketplaces();
|
|
212
277
|
const skipSet = new Set(skip);
|
|
278
|
+
const needed = await neededMarketplaces(options.pluginIds ?? []);
|
|
213
279
|
|
|
214
280
|
// Select eligible marketplaces first so the pulls can run concurrently.
|
|
215
281
|
const eligible: string[] = [];
|
|
@@ -228,18 +294,25 @@ export async function refreshRegisteredMarketplaces(
|
|
|
228
294
|
skipped.push(name);
|
|
229
295
|
continue;
|
|
230
296
|
}
|
|
297
|
+
// Nothing installed at any scope comes from it, so a pull buys nothing.
|
|
298
|
+
if (!needed.has(name)) {
|
|
299
|
+
skipped.push(name);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
// Refreshed on this machine within the hour, by any project's run.
|
|
303
|
+
if ((await readCloneRefreshAge(name, now)) !== null) {
|
|
304
|
+
fresh.push(name);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
231
307
|
eligible.push(name);
|
|
232
308
|
}
|
|
233
309
|
|
|
234
310
|
// A read-only caller stops here, with the selection verdict intact and not
|
|
235
|
-
// one clone touched. `eligible` becomes `
|
|
236
|
-
// claiming otherwise would be the false "all current" this
|
|
311
|
+
// one clone touched. `eligible` becomes `pending`, never `refreshed`: nothing
|
|
312
|
+
// was pulled, so claiming otherwise would be the false "all current" this
|
|
313
|
+
// module guards.
|
|
237
314
|
if (options.reportOnly) {
|
|
238
|
-
return {
|
|
239
|
-
refreshed: [],
|
|
240
|
-
failed: [],
|
|
241
|
-
skipped: [...skipped, ...eligible],
|
|
242
|
-
};
|
|
315
|
+
return { refreshed: [], failed: [], fresh, pending: eligible, skipped };
|
|
243
316
|
}
|
|
244
317
|
|
|
245
318
|
// Fast-forward every eligible clone in parallel so a slow one doesn't
|
|
@@ -261,5 +334,9 @@ export async function refreshRegisteredMarketplaces(
|
|
|
261
334
|
else skipped.push(name); // absent | skipped
|
|
262
335
|
});
|
|
263
336
|
|
|
264
|
-
|
|
337
|
+
// Only a successful pull earns the hour. A failed, absent or dirty clone
|
|
338
|
+
// records nothing, so the next run — from any project — tries again.
|
|
339
|
+
for (const name of refreshed) await writeCloneRefreshed(name, now);
|
|
340
|
+
|
|
341
|
+
return { refreshed, failed, fresh, pending: [], skipped };
|
|
265
342
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
ALIAS_FLAGS,
|
|
8
8
|
type AliasFlag,
|
|
9
9
|
FLAG_GROUPS,
|
|
10
|
+
getFlagById,
|
|
10
11
|
} from "../../data/alias-flags.js";
|
|
11
12
|
import { loadAliasName, saveAliasName } from "../../services/alias-settings.js";
|
|
12
13
|
import {
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
findAdoptableAlias,
|
|
19
20
|
parseAliasFromRc,
|
|
20
21
|
renderArgs,
|
|
22
|
+
renderEnv,
|
|
21
23
|
validateConfig,
|
|
22
24
|
writeAliasToShell,
|
|
23
25
|
} from "../../services/alias-shell-writer.js";
|
|
@@ -521,9 +523,10 @@ export function AliasScreen() {
|
|
|
521
523
|
selectedRow.flag,
|
|
522
524
|
selectedRow.value,
|
|
523
525
|
modal,
|
|
526
|
+
showFailures,
|
|
524
527
|
);
|
|
525
528
|
if (next) updateFlag(selectedRow.flag.id, next);
|
|
526
|
-
}, [selectedRow, modal, updateFlag, editAliasName]);
|
|
529
|
+
}, [selectedRow, modal, showFailures, updateFlag, editAliasName]);
|
|
527
530
|
|
|
528
531
|
const toggleSelected = useCallback(async () => {
|
|
529
532
|
if (!selectedRow) return;
|
|
@@ -680,6 +683,7 @@ export function AliasScreen() {
|
|
|
680
683
|
|
|
681
684
|
// ── Render ────────────────────────────────────────────────────────────
|
|
682
685
|
const previewSegments = config ? renderArgs(config) : [];
|
|
686
|
+
const previewEnv = config ? renderEnv(config) : [];
|
|
683
687
|
const previewAliasName = config?.aliasName ?? "";
|
|
684
688
|
|
|
685
689
|
// Native OpenTUI scrollboxes own the scroll math. We render every item
|
|
@@ -767,6 +771,7 @@ export function AliasScreen() {
|
|
|
767
771
|
selectedRow,
|
|
768
772
|
isGated,
|
|
769
773
|
previewAliasName,
|
|
774
|
+
previewEnv,
|
|
770
775
|
previewSegments,
|
|
771
776
|
);
|
|
772
777
|
// When editing --channels and there are derived rows, the right pane carries
|
|
@@ -795,6 +800,7 @@ export function AliasScreen() {
|
|
|
795
800
|
detailFlag,
|
|
796
801
|
detailValue,
|
|
797
802
|
previewAliasName,
|
|
803
|
+
previewEnv,
|
|
798
804
|
previewSegments,
|
|
799
805
|
derivedNote,
|
|
800
806
|
stateOverride,
|
|
@@ -1081,6 +1087,7 @@ async function editFlagValue(
|
|
|
1081
1087
|
flag: AliasFlag,
|
|
1082
1088
|
value: FlagValue,
|
|
1083
1089
|
modal: ReturnType<typeof useModal>,
|
|
1090
|
+
showFailures: ReturnType<typeof useFailurePopup>,
|
|
1084
1091
|
): Promise<FlagValue | null> {
|
|
1085
1092
|
switch (value.kind) {
|
|
1086
1093
|
case "boolean":
|
|
@@ -1154,6 +1161,17 @@ async function editFlagValue(
|
|
|
1154
1161
|
}
|
|
1155
1162
|
const v = await modal.input(flag.label, flag.description, value.value);
|
|
1156
1163
|
if (v === null) return null;
|
|
1164
|
+
// An empty value disables the flag, so only a non-empty one is checked.
|
|
1165
|
+
// A refused value leaves the old one in place.
|
|
1166
|
+
const reason = v.length > 0 ? (flag.validateValue?.(v) ?? null) : null;
|
|
1167
|
+
if (reason) {
|
|
1168
|
+
await showFailures(
|
|
1169
|
+
failureReport(`The ${flag.label.toLowerCase()} was not changed`, [
|
|
1170
|
+
{ subject: v, reason },
|
|
1171
|
+
]),
|
|
1172
|
+
);
|
|
1173
|
+
return null;
|
|
1174
|
+
}
|
|
1157
1175
|
return { kind: "text", enabled: v.length > 0, value: v };
|
|
1158
1176
|
}
|
|
1159
1177
|
|
|
@@ -1336,6 +1354,7 @@ function renderDetail(
|
|
|
1336
1354
|
row: Row | undefined,
|
|
1337
1355
|
isGated: (f: AliasFlag) => boolean,
|
|
1338
1356
|
aliasName: string,
|
|
1357
|
+
env: string[],
|
|
1339
1358
|
segments: Segment[],
|
|
1340
1359
|
): React.ReactNode {
|
|
1341
1360
|
if (!row) {
|
|
@@ -1357,7 +1376,7 @@ function renderDetail(
|
|
|
1357
1376
|
{headerAndBody}
|
|
1358
1377
|
<text fg={theme.colors.text}> </text>
|
|
1359
1378
|
<text fg={theme.colors.muted}>Preview:</text>
|
|
1360
|
-
{renderPreviewLines(aliasName, segments)}
|
|
1379
|
+
{renderPreviewLines(aliasName, env, segments)}
|
|
1361
1380
|
</box>
|
|
1362
1381
|
);
|
|
1363
1382
|
}
|
|
@@ -1407,7 +1426,7 @@ function renderFlagDetail(
|
|
|
1407
1426
|
</text>
|
|
1408
1427
|
{gated && (
|
|
1409
1428
|
<text fg={theme.colors.muted}>
|
|
1410
|
-
{`Depends on
|
|
1429
|
+
{`Depends on ${getFlagById(row.flag.requires ?? "").flag}. Enabling this will turn that on too.`}
|
|
1411
1430
|
</text>
|
|
1412
1431
|
)}
|
|
1413
1432
|
{row.issue && (
|
|
@@ -1438,18 +1457,23 @@ function renderFlagDetail(
|
|
|
1438
1457
|
*/
|
|
1439
1458
|
function renderPreviewLines(
|
|
1440
1459
|
aliasName: string,
|
|
1460
|
+
env: string[],
|
|
1441
1461
|
segments: Segment[],
|
|
1442
1462
|
): React.ReactNode {
|
|
1463
|
+
// Env assignments run before `claude`, so they sit on the head line.
|
|
1464
|
+
const command = [...env, "claude"].join(" ");
|
|
1443
1465
|
if (segments.length === 0) {
|
|
1444
1466
|
return (
|
|
1445
|
-
<text
|
|
1467
|
+
<text
|
|
1468
|
+
fg={theme.colors.text}
|
|
1469
|
+
>{`alias ${aliasName || "?"}='${command}'`}</text>
|
|
1446
1470
|
);
|
|
1447
1471
|
}
|
|
1448
1472
|
const lines: React.ReactNode[] = [
|
|
1449
1473
|
<text
|
|
1450
1474
|
key="head"
|
|
1451
1475
|
fg={theme.colors.text}
|
|
1452
|
-
>{`alias ${aliasName || "?"}='
|
|
1476
|
+
>{`alias ${aliasName || "?"}='${command}`}</text>,
|
|
1453
1477
|
];
|
|
1454
1478
|
segments.forEach((seg, i) => {
|
|
1455
1479
|
lines.push(
|
|
@@ -1530,6 +1554,7 @@ function renderFlagDetailRightPane(
|
|
|
1530
1554
|
flag: AliasFlag,
|
|
1531
1555
|
value: FlagValue,
|
|
1532
1556
|
aliasName: string,
|
|
1557
|
+
env: string[],
|
|
1533
1558
|
segments: Segment[],
|
|
1534
1559
|
derivedNote?: string,
|
|
1535
1560
|
stateOverride?: string,
|
|
@@ -1555,7 +1580,7 @@ function renderFlagDetailRightPane(
|
|
|
1555
1580
|
</text>
|
|
1556
1581
|
<text fg={theme.colors.text}> </text>
|
|
1557
1582
|
<text fg={theme.colors.muted}>Preview:</text>
|
|
1558
|
-
{renderPreviewLines(aliasName, segments)}
|
|
1583
|
+
{renderPreviewLines(aliasName, env, segments)}
|
|
1559
1584
|
<text fg={theme.colors.text}> </text>
|
|
1560
1585
|
<text fg={theme.colors.muted}>Keys:</text>
|
|
1561
1586
|
<text fg={theme.colors.text}>
|