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.
@@ -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
- /** Nothing to do: no clone on disk, not a git repo, or dirty tree. */
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 and each
189
- * entry's `autoUpdate` flag — so a read-only caller can have the full selection
190
- * verdict for free. `--dry-run` and `--check` promise to write nothing and
191
- * therefore cannot pull, but they must still be able to say "your magus clone
192
- * has autoUpdate off, so its catalog will never advance". Gating the whole call
193
- * off silently dropped that warning, which is the same "no signal reads as no
194
- * problem" failure the flag exists to prevent.
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 refreshRegisteredMarketplaces(skip, {}, { reportOnly: true });
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: { reportOnly?: boolean } = {},
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 `skipped`: nothing was refreshed, so
236
- // claiming otherwise would be the false "all current" this module guards.
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
- return { refreshed, failed, skipped };
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 --${row.flag.requires}. Enabling this will turn that on too.`}
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 fg={theme.colors.text}>{`alias ${aliasName || "?"}='claude'`}</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 || "?"}='claude`}</text>,
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}>