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/src/cli/update.ts CHANGED
@@ -364,36 +364,53 @@ async function updateProfile(
364
364
  // own output, which is where this was caught. A read-only run plans against
365
365
  // the clone as it stands; the notice below says so, so an unrefreshed
366
366
  // catalog is never mistaken for a current one.
367
+ //
368
+ // Only marketplaces some installed plugin comes from are pulled: this
369
+ // profile's plugins, plus whatever is installed at USER scope (the refresher
370
+ // reads that itself). And each clone at most once an hour, machine-wide.
371
+ const readOnly = flags.dryRun || flags.check;
372
+ const pluginIds = Object.keys(closure.plugins);
367
373
  const refresh = await runStep(steps.marketplaces, async (say) => {
368
- if (flags.dryRun || flags.check) {
374
+ if (readOnly) {
369
375
  say("read-only — selecting, not pulling");
370
- // Report-only, NOT a skip: the autoUpdate-disabled verdict is a pure
371
- // read and is the one diagnostic a read-only run must not lose.
372
- const report = await reportRegisteredMarketplaces();
373
- steps.marketplaces.detail = `not refreshed (read-only) · ${report.skipped.length} eligible`;
374
- return report;
376
+ // Report-only, NOT a skip: the selection is a pure read, so a read-only
377
+ // run can still say exactly what a real run would pull.
378
+ return reportRegisteredMarketplaces([], { pluginIds });
375
379
  }
376
380
  let settled = 0;
377
- return refreshRegisteredMarketplaces([], {
378
- onStart: (names) => {
379
- // Only now is there a denominator; until `onStart` fires the step
380
- // shows a sweep rather than a meter that would have to guess one.
381
- steps.marketplaces.progress = { done: 0, total: names.length };
382
- say(
383
- names.length === 0 ? "none eligible" : `pulling ${names.join(", ")}`,
384
- );
385
- },
386
- onSettled: (name, status) => {
387
- settled++;
388
- if (steps.marketplaces.progress)
389
- steps.marketplaces.progress.done = settled;
390
- say(`${name} ${status}`);
381
+ return refreshRegisteredMarketplaces(
382
+ [],
383
+ {
384
+ onStart: (names) => {
385
+ // Only now is there a denominator; until `onStart` fires the step
386
+ // shows a sweep rather than a meter that would have to guess one.
387
+ steps.marketplaces.progress = { done: 0, total: names.length };
388
+ say(
389
+ names.length === 0
390
+ ? "none eligible"
391
+ : `pulling ${names.join(", ")}`,
392
+ );
393
+ },
394
+ onSettled: (name, status) => {
395
+ settled++;
396
+ if (steps.marketplaces.progress)
397
+ steps.marketplaces.progress.done = settled;
398
+ say(`${name} ${status}`);
399
+ },
391
400
  },
392
- });
401
+ { pluginIds },
402
+ );
393
403
  });
394
404
  steps.marketplaces.detail = [
395
- `${refresh.refreshed.length} refreshed`,
405
+ readOnly
406
+ ? `not refreshed (read-only) · ${refresh.pending.length} would refresh`
407
+ : `${refresh.refreshed.length} refreshed`,
396
408
  refresh.failed.length > 0 ? `${refresh.failed.length} failed` : "",
409
+ // Not "up to date": the clone was pulled within the hour, and upstream
410
+ // may have moved since. Say what is known.
411
+ refresh.fresh.length > 0
412
+ ? `${refresh.fresh.length} refreshed in the last hour`
413
+ : "",
397
414
  refresh.skipped.length > 0 ? `${refresh.skipped.length} skipped` : "",
398
415
  ]
399
416
  .filter(Boolean)
@@ -72,6 +72,18 @@ export interface AliasFlag {
72
72
  * token. See `TEMPLATE_TOKENS` for the supported placeholder set.
73
73
  */
74
74
  templated?: boolean;
75
+ /**
76
+ * Environment assignment (`NAME=VALUE`) the alias sets before `claude`,
77
+ * instead of an argv token. Only for `kind: "boolean"`: enabled emits the
78
+ * assignment, disabled emits nothing. `flag` carries the same string so
79
+ * lookups by `flag` keep working.
80
+ */
81
+ env?: string;
82
+ /**
83
+ * Value check for `kind: "text"` flags, run when the user enters a value.
84
+ * Returns null when the value is valid, otherwise the reason it is not.
85
+ */
86
+ validateValue?: (value: string) => string | null;
75
87
  /** UI grouping; rendered as section headers in the list. */
76
88
  group: FlagGroup;
77
89
  }
@@ -100,7 +112,8 @@ export type FlagGroup =
100
112
  | "channels"
101
113
  | "debug"
102
114
  | "integration"
103
- | "worktree";
115
+ | "worktree"
116
+ | "agents";
104
117
 
105
118
  export const FLAG_GROUPS: Array<{ id: FlagGroup; label: string }> = [
106
119
  { id: "danger", label: "Permissions" },
@@ -110,8 +123,81 @@ export const FLAG_GROUPS: Array<{ id: FlagGroup; label: string }> = [
110
123
  { id: "debug", label: "Debug" },
111
124
  { id: "integration", label: "Integrations" },
112
125
  { id: "worktree", label: "Worktree" },
126
+ { id: "agents", label: "Agent teams" },
113
127
  ];
114
128
 
129
+ /** Smallest auto-compact window Claude Code accepts, in tokens. */
130
+ export const AUTOCOMPACT_MIN_TOKENS = 100_000;
131
+ /** Largest auto-compact window Claude Code accepts, in tokens. */
132
+ export const AUTOCOMPACT_MAX_TOKENS = 1_000_000;
133
+
134
+ /** Claude Code's own wording when it refuses an `--autocompact` value. */
135
+ export const AUTOCOMPACT_INVALID_REASON =
136
+ "It must be 'auto', or between 100k and 1M (e.g. 500k, 200000, or 200 as shorthand)";
137
+
138
+ const EXPONENT_NUMBER = /^[+-]?(\d+(\.\d*)?|\.\d+)[eE][+-]?\d+$/;
139
+ const GROUPED_DIGITS = /^[+-]?\d{1,3}([_,   ])\d{3}(?:\1\d{3})*$/;
140
+ const DIGIT_SEPARATORS = /[_,   ]/g;
141
+
142
+ /**
143
+ * Read an unsuffixed number the way Claude Code does: exponent notation
144
+ * (`2e5`) when it is an integer, digit groups (`200,000`, `200_000`), and
145
+ * otherwise `parseInt`, which keeps the leading digits of the string.
146
+ */
147
+ function parsePlainNumber(text: string): number {
148
+ if (text.length <= 32) {
149
+ if (EXPONENT_NUMBER.test(text)) {
150
+ const n = Number(text);
151
+ return Number.isInteger(n) ? n : Number.NaN;
152
+ }
153
+ if (GROUPED_DIGITS.test(text)) {
154
+ return Number.parseInt(text.replace(DIGIT_SEPARATORS, ""), 10);
155
+ }
156
+ }
157
+ return Number.parseInt(text, 10);
158
+ }
159
+
160
+ /**
161
+ * Parse an `--autocompact` value exactly as Claude Code's option parser does.
162
+ *
163
+ * `auto` (any case) stays `"auto"`. A trailing `k` or `m` multiplies by a
164
+ * thousand or a million; an unsuffixed number from 100 to 1000 is read as
165
+ * thousands (`200` is 200,000 tokens). The result must fall between
166
+ * {@link AUTOCOMPACT_MIN_TOKENS} and {@link AUTOCOMPACT_MAX_TOKENS}.
167
+ *
168
+ * Returns the token count, `"auto"`, or `undefined` when Claude Code would
169
+ * refuse the value.
170
+ */
171
+ export function parseAutocompactSize(
172
+ value: string,
173
+ ): number | "auto" | undefined {
174
+ if (value.trim().toLowerCase() === "auto") return "auto";
175
+ const text = value.trim();
176
+ const suffix = text.slice(-1).toLowerCase();
177
+ const multiplier =
178
+ suffix === "m" ? 1_000_000 : suffix === "k" ? 1_000 : undefined;
179
+ const parsed =
180
+ multiplier === undefined
181
+ ? parsePlainNumber(text)
182
+ : Number.parseFloat(text) * multiplier;
183
+ const tokens =
184
+ multiplier === undefined && parsed >= 100 && parsed <= 1000
185
+ ? parsed * 1000
186
+ : parsed;
187
+ if (Number.isNaN(tokens) || tokens < 1) return undefined;
188
+ if (tokens < AUTOCOMPACT_MIN_TOKENS || tokens > AUTOCOMPACT_MAX_TOKENS) {
189
+ return undefined;
190
+ }
191
+ return Math.round(tokens);
192
+ }
193
+
194
+ /** `validateValue` for the context window size: Claude Code's reason, or null. */
195
+ export function validateAutocompactSize(value: string): string | null {
196
+ return parseAutocompactSize(value) === undefined
197
+ ? AUTOCOMPACT_INVALID_REASON
198
+ : null;
199
+ }
200
+
115
201
  export const ALIAS_FLAGS: AliasFlag[] = [
116
202
  {
117
203
  id: "dangerously-skip-permissions",
@@ -222,6 +308,16 @@ export const ALIAS_FLAGS: AliasFlag[] = [
222
308
  description:
223
309
  "Path to a file whose contents are appended to the default system prompt.",
224
310
  },
311
+ {
312
+ id: "autocompact",
313
+ label: "Context window size",
314
+ flag: "--autocompact",
315
+ kind: "text",
316
+ group: "context",
317
+ validateValue: validateAutocompactSize,
318
+ description:
319
+ "The window size Claude Code compacts the conversation at: auto, or 100k to 1M tokens (e.g. 200k, 500k, 1m, 200000, or 200 as shorthand for 200k).",
320
+ },
225
321
  {
226
322
  id: "channels",
227
323
  label: "Channels (approved)",
@@ -291,6 +387,32 @@ export const ALIAS_FLAGS: AliasFlag[] = [
291
387
  { label: "classic", value: "classic" },
292
388
  ],
293
389
  },
390
+ {
391
+ id: "agent-teams",
392
+ label: "Agent teams",
393
+ flag: "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1",
394
+ env: "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1",
395
+ kind: "boolean",
396
+ group: "agents",
397
+ description:
398
+ "Turn on agent teams, so the session can start teammates. Claude Code has no flag for this: the alias sets CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 before claude.",
399
+ },
400
+ {
401
+ id: "teammate-mode",
402
+ label: "Teammate panes",
403
+ flag: "--teammate-mode",
404
+ kind: "select",
405
+ group: "agents",
406
+ requires: "agent-teams",
407
+ description:
408
+ "Where teammates run. tmux opens each teammate in a split pane, but only when the session starts inside tmux. iterm2 uses iTerm2 panes, in-process keeps them in this terminal, auto picks for you. Requires agent teams.",
409
+ options: [
410
+ { label: "tmux", value: "tmux" },
411
+ { label: "iterm2", value: "iterm2" },
412
+ { label: "in-process", value: "in-process" },
413
+ { label: "auto", value: "auto" },
414
+ ],
415
+ },
294
416
  ];
295
417
 
296
418
  /** Look up a flag by id. Throws if missing — keeps callers honest. */
@@ -125,6 +125,8 @@ export function renderArgs(config: AliasConfig): Segment[] {
125
125
  const seenXor = new Set<string>();
126
126
 
127
127
  for (const flag of ALIAS_FLAGS) {
128
+ // Env flags are assignments before `claude`, never argv: see renderEnv.
129
+ if (flag.env) continue;
128
130
  if (flag.requires) {
129
131
  const dep = config.flags[flag.requires];
130
132
  if (!isFlagEnabled(dep)) continue;
@@ -154,18 +156,35 @@ export function renderArgs(config: AliasConfig): Segment[] {
154
156
  }
155
157
 
156
158
  /**
157
- * Convenience: render each segment to its literal-token approximation.
158
- * Composite segments are stringified by joining their literal parts with
159
- * `<sub>` placeholders for raw parts. Used by tests and validation summaries
160
- * where the actual shell substitution doesn't matter.
159
+ * The environment assignments (`NAME=VALUE`) the alias sets before `claude`,
160
+ * one per enabled env flag, in catalog order. `requires` applies here as it
161
+ * does to argv flags. Pure function.
162
+ */
163
+ export function renderEnv(config: AliasConfig): string[] {
164
+ const out: string[] = [];
165
+ for (const flag of ALIAS_FLAGS) {
166
+ if (!flag.env) continue;
167
+ if (flag.requires && !isFlagEnabled(config.flags[flag.requires])) continue;
168
+ if (isFlagEnabled(config.flags[flag.id])) out.push(flag.env);
169
+ }
170
+ return out;
171
+ }
172
+
173
+ /**
174
+ * Convenience: the whole command after the alias name as literal tokens, env
175
+ * assignments first, then argv. Composite segments are stringified by joining
176
+ * their literal parts with `<sub>` placeholders for raw parts. Used by the
177
+ * adoption fixpoint check, tests and validation summaries, where the actual
178
+ * shell substitution doesn't matter.
161
179
  */
162
180
  export function renderArgsAsTokens(config: AliasConfig): string[] {
163
- return renderArgs(config).map((seg) => {
181
+ const argv = renderArgs(config).map((seg) => {
164
182
  if (seg.kind === "literal") return seg.text;
165
183
  return seg.parts
166
184
  .map((p) => (p.kind === "literal" ? p.text : `<${p.posix}>`))
167
185
  .join("");
168
186
  });
187
+ return [...renderEnv(config), ...argv];
169
188
  }
170
189
 
171
190
  function isFlagEnabled(value: FlagValue | undefined): boolean {
@@ -345,24 +364,32 @@ export function renderAlias(
345
364
  shell: ShellKind,
346
365
  ): RenderedAlias {
347
366
  const args = renderArgs(config);
367
+ const env = renderEnv(config);
348
368
  const name = config.aliasName;
349
369
  if (shell === "fish") {
350
- return { shell, block: renderFishBlock(name, args) };
370
+ return { shell, block: renderFishBlock(name, env, args) };
351
371
  }
352
- return { shell, block: renderPosixBlock(name, args) };
372
+ return { shell, block: renderPosixBlock(name, env, args) };
353
373
  }
354
374
 
355
- function renderPosixBlock(name: string, segments: Segment[]): string {
375
+ function renderPosixBlock(
376
+ name: string,
377
+ env: string[],
378
+ segments: Segment[],
379
+ ): string {
356
380
  // The alias body lives inside outer single quotes:
357
- // alias <name>='claude … '
358
- // Each segment becomes one shell-arg inside that body. Composite segments
381
+ // alias <name>='NAME=VALUE claude … '
382
+ // Env assignments lead, so the shell exports them to `claude` alone. Each
383
+ // segment becomes one shell-arg inside that body. Composite segments
359
384
  // (templated values) interleave literal text with double-quoted shell
360
385
  // substitutions via close-outer + double-quote + reopen-outer.
386
+ const prefix =
387
+ env.length === 0 ? "" : `${env.map(quoteInsidePosixAlias).join(" ")} `;
361
388
  const body =
362
389
  segments.length === 0
363
390
  ? ""
364
391
  : ` ${segments.map(renderPosixSegment).join(" ")}`;
365
- const line = `alias ${name}='claude${body}'`;
392
+ const line = `alias ${name}='${prefix}claude${body}'`;
366
393
  return `${BLOCK_BEGIN}\n${line}\n${BLOCK_END}\n`;
367
394
  }
368
395
 
@@ -425,13 +452,22 @@ export function findUnquotableTokens(segments: Segment[]): string[] {
425
452
  return out;
426
453
  }
427
454
 
428
- function renderFishBlock(name: string, segments: Segment[]): string {
455
+ function renderFishBlock(
456
+ name: string,
457
+ env: string[],
458
+ segments: Segment[],
459
+ ): string {
429
460
  // Fish: `alias <name> 'claude --foo bar'` — outer single quotes, no `=`.
461
+ // Env assignments go through `env NAME=VALUE claude …`: the bare
462
+ // `NAME=VALUE cmd` form needs fish 3.1, `env` works on every version. The
463
+ // assignments are catalog constants of `[A-Za-z0-9_=]`, so they need no
464
+ // quoting inside the outer single quotes.
465
+ const prefix = env.length === 0 ? "" : `env ${env.join(" ")} `;
430
466
  const body =
431
467
  segments.length === 0
432
468
  ? ""
433
469
  : ` ${segments.map(renderFishSegment).join(" ")}`;
434
- const line = `alias ${name} 'claude${body}'`;
470
+ const line = `alias ${name} '${prefix}claude${body}'`;
435
471
  return `${BLOCK_BEGIN}\n${line}\n${BLOCK_END}\n`;
436
472
  }
437
473
 
@@ -604,9 +640,33 @@ export function validateConfig(config: AliasConfig): FlagValidationIssue[] {
604
640
 
605
641
  export interface ParseResult {
606
642
  aliasName: string | null;
643
+ /**
644
+ * The env assignments before `claude` (`NAME=VALUE`), then the argv after
645
+ * it — the shape {@link argsToFlagValuesWithLeftovers} reads.
646
+ */
607
647
  args: string[];
608
648
  }
609
649
 
650
+ /** A shell environment assignment token: `NAME=VALUE`. */
651
+ const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
652
+
653
+ /**
654
+ * Split an alias command into the tokens {@link argsToFlagValuesWithLeftovers}
655
+ * reads: leading env assignments, then the argv after `claude`. Accepts the
656
+ * POSIX `NAME=VALUE claude …` form and the `env NAME=VALUE claude …` form the
657
+ * fish renderer writes. Returns null when the command is not `claude`.
658
+ */
659
+ function commandTokensAfterEnv(tokens: string[]): string[] | null {
660
+ let i = tokens[0] === "env" ? 1 : 0;
661
+ const env: string[] = [];
662
+ while (i < tokens.length && ENV_ASSIGNMENT_RE.test(tokens[i])) {
663
+ env.push(tokens[i]);
664
+ i += 1;
665
+ }
666
+ if (tokens[i] !== "claude") return null;
667
+ return [...env, ...tokens.slice(i + 1)];
668
+ }
669
+
610
670
  /**
611
671
  * Find the managed block in shell rc text, parse the alias line, and return
612
672
  * the alias name plus the argv tokens after `claude`.
@@ -644,10 +704,11 @@ export function parseAliasLine(line: string): ParseResult | null {
644
704
  const match = ALIAS_LINE_RE.exec(line);
645
705
  if (!match) return null;
646
706
  const [, aliasName, body] = match;
647
- // First token is the command itself (claude). Drop it; we only want argv.
648
- const tokens = tokenizePosixAliasBody(body);
649
- if (tokens.length === 0 || tokens[0] !== "claude") return null;
650
- return { aliasName, args: tokens.slice(1) };
707
+ // The command is `claude`, optionally preceded by env assignments. Drop
708
+ // `claude` itself; keep the assignments ahead of the argv.
709
+ const args = commandTokensAfterEnv(tokenizePosixAliasBody(body));
710
+ if (!args) return null;
711
+ return { aliasName, args };
651
712
  }
652
713
 
653
714
  /**
@@ -793,13 +854,25 @@ export interface ParsedFlagsWithLeftovers {
793
854
  * catalog flag. Unknown `--flag value` pairs contribute BOTH tokens to
794
855
  * `leftovers` (the flag and its consumed value) so the adoption gate sees the
795
856
  * full extent of what wouldn't round-trip.
857
+ *
858
+ * Leading `NAME=VALUE` tokens are the env assignments set before `claude`.
859
+ * Each matches an env flag by its assignment string; an assignment the
860
+ * catalog does not know lands in `leftovers` on its own, so adoption never
861
+ * removes a line it cannot reproduce. Env flags are matched only in that
862
+ * leading run, never among the argv after it.
796
863
  */
797
864
  export function argsToFlagValuesWithLeftovers(
798
865
  args: string[],
799
866
  ): ParsedFlagsWithLeftovers {
800
- // Index every flag by its primary `flag` string AND its triStateOff variant.
867
+ // Index argv flags by their primary `flag` string AND their triStateOff
868
+ // variant, and env flags by their assignment string.
801
869
  const byFlag = new Map<string, { flag: AliasFlag; off?: boolean }>();
870
+ const byEnv = new Map<string, AliasFlag>();
802
871
  for (const flag of ALIAS_FLAGS) {
872
+ if (flag.env) {
873
+ byEnv.set(flag.env, flag);
874
+ continue;
875
+ }
803
876
  byFlag.set(flag.flag, { flag, off: false });
804
877
  if (flag.triStateOff) {
805
878
  byFlag.set(flag.triStateOff, { flag, off: true });
@@ -809,6 +882,12 @@ export function argsToFlagValuesWithLeftovers(
809
882
  const out: Record<string, FlagValue> = {};
810
883
  const leftovers: string[] = [];
811
884
  let i = 0;
885
+ while (i < args.length && ENV_ASSIGNMENT_RE.test(args[i])) {
886
+ const envFlag = byEnv.get(args[i]);
887
+ if (envFlag) out[envFlag.id] = { kind: "boolean", enabled: true };
888
+ else leftovers.push(args[i]);
889
+ i += 1;
890
+ }
812
891
  while (i < args.length) {
813
892
  const tok = args[i];
814
893
  const entry = byFlag.get(tok);
@@ -1122,9 +1201,9 @@ function parseAdoptableLine(
1122
1201
  if (!match) return null;
1123
1202
  const [, name, , body] = match;
1124
1203
  const tokens = tokenizeGeneralAliasBody(body);
1125
- if (!tokens || tokens.length === 0 || tokens[0] !== "claude") return null;
1126
-
1127
- const args = tokens.slice(1);
1204
+ if (!tokens) return null;
1205
+ const args = commandTokensAfterEnv(tokens);
1206
+ if (!args) return null;
1128
1207
  const { flags, leftovers } = argsToFlagValuesWithLeftovers(args);
1129
1208
 
1130
1209
  // Fixpoint: render the parsed flags back to tokens and re-parse. If the
@@ -37,10 +37,11 @@ import type { MarketplacePlugin } from "./marketplace-catalog.js";
37
37
  *
38
38
  * This is the ONLY refresh rhythm magus has, and it applies to both surfaces:
39
39
  * `magus update` and the TUI read a catalog written here within the hour
40
- * before they go to the network. `update` pulls the marketplace clones and
41
- * drops its in-process memo before planning, but it does NOT clear this file,
42
- * so a release published inside the TTL is not seen by `update` either. Only
43
- * an explicit refresh (`r` in the TUI) clears it. Anything that reports "no
40
+ * before they go to the network. `update` pulls the marketplace clones (each
41
+ * at most once per TTL, machine-wide — see `cloneRefreshes`) and drops its
42
+ * in-process memo before planning, but it does NOT clear this file, so a
43
+ * release published inside the TTL is not seen by `update` either. Only an
44
+ * explicit refresh (`r` in the TUI) clears it, clone refresh times included. Anything that reports "no
44
45
  * update" — or decides a plugin is no longer published — off a cached catalog
45
46
  * is working from an answer up to an hour old, by design.
46
47
  */
@@ -93,14 +94,24 @@ interface StoreShape {
93
94
  catalogs: Record<string, StoredCatalog>;
94
95
  cooldowns: Record<string, StoredCooldown>;
95
96
  communityStyles: Record<string, StoredCommunityCheck>;
97
+ /**
98
+ * Per marketplace: wall-clock ms of the last SUCCESSFUL fast-forward of its
99
+ * on-disk clone. Machine-wide, so one project's `magus update` spares every
100
+ * other project's the same pull within CATALOG_TTL_MS.
101
+ */
102
+ cloneRefreshes: Record<string, number>;
96
103
  }
97
104
 
98
- const EMPTY: StoreShape = {
99
- version: 1,
100
- catalogs: {},
101
- cooldowns: {},
102
- communityStyles: {},
103
- };
105
+ /** A fresh empty store. A function, so no caller can share and mutate one object. */
106
+ function emptyStore(): StoreShape {
107
+ return {
108
+ version: 1,
109
+ catalogs: {},
110
+ cooldowns: {},
111
+ communityStyles: {},
112
+ cloneRefreshes: {},
113
+ };
114
+ }
104
115
 
105
116
  /**
106
117
  * Resolved per call, honouring CLAUDE_CONFIG_DIR — same reasoning as
@@ -124,29 +135,24 @@ let memo: { path: string; data: StoreShape } | null = null;
124
135
 
125
136
  async function load(): Promise<StoreShape> {
126
137
  const file = storePath();
127
- if (!file)
128
- return { version: 1, catalogs: {}, cooldowns: {}, communityStyles: {} };
138
+ if (!file) return emptyStore();
129
139
  if (memo?.path === file) return memo.data;
130
140
  try {
131
141
  const parsed = JSON.parse(await fs.readFile(file, "utf-8")) as StoreShape;
132
142
  // A cache from a future/unknown layout is discarded rather than migrated:
133
143
  // it is regenerable by definition, so guessing at it buys nothing.
134
- const data = parsed?.version === 1 ? parsed : { ...EMPTY };
144
+ const data = parsed?.version === 1 ? parsed : emptyStore();
135
145
  data.catalogs ??= {};
136
146
  data.cooldowns ??= {};
137
- // Absent in every file written before community styles existed, so this
147
+ // Absent in every file written before these sections existed, so the
138
148
  // default is the migration — an upgrade must not read as a corrupt cache.
139
149
  data.communityStyles ??= {};
150
+ data.cloneRefreshes ??= {};
140
151
  memo = { path: file, data };
141
152
  return data;
142
153
  } catch {
143
154
  // Absent or corrupt. A cache must never be a failure mode — start empty.
144
- const data: StoreShape = {
145
- ...EMPTY,
146
- catalogs: {},
147
- cooldowns: {},
148
- communityStyles: {},
149
- };
155
+ const data = emptyStore();
150
156
  memo = { path: file, data };
151
157
  return data;
152
158
  }
@@ -179,15 +185,11 @@ async function mutate(fn: (data: StoreShape) => void): Promise<void> {
179
185
  catalogs: parsed.catalogs ?? {},
180
186
  cooldowns: parsed.cooldowns ?? {},
181
187
  communityStyles: parsed.communityStyles ?? {},
188
+ cloneRefreshes: parsed.cloneRefreshes ?? {},
182
189
  }
183
- : { version: 1, catalogs: {}, cooldowns: {}, communityStyles: {} };
190
+ : emptyStore();
184
191
  } catch {
185
- onDisk = {
186
- version: 1,
187
- catalogs: {},
188
- cooldowns: {},
189
- communityStyles: {},
190
- };
192
+ onDisk = emptyStore();
191
193
  }
192
194
  fn(onDisk);
193
195
  // Temp + rename, so a crash mid-write cannot leave a half-written file
@@ -312,6 +314,44 @@ export async function clearCommunityChecks(): Promise<void> {
312
314
  });
313
315
  }
314
316
 
317
+ // ─── Marketplace clone refreshes ─────────────────────────────────────────────
318
+
319
+ /**
320
+ * Milliseconds since this marketplace's clone was last fast-forwarded
321
+ * successfully, or null when that was never, longer ago than CATALOG_TTL_MS, or
322
+ * in the future (a clock that moved back must not freeze the clone).
323
+ *
324
+ * Same TTL as the catalogs, on purpose: the clone and the catalog answer the
325
+ * same question, so they go stale on one rhythm and `r` clears both.
326
+ */
327
+ export async function readCloneRefreshAge(
328
+ marketplace: string,
329
+ now: number = Date.now(),
330
+ ): Promise<number | null> {
331
+ const at = (await load()).cloneRefreshes[marketplace];
332
+ if (typeof at !== "number") return null;
333
+ const ageMs = now - at;
334
+ if (ageMs < 0 || ageMs > CATALOG_TTL_MS) return null;
335
+ return ageMs;
336
+ }
337
+
338
+ /** Record a successful fast-forward. Never called for a failed or skipped pull. */
339
+ export async function writeCloneRefreshed(
340
+ marketplace: string,
341
+ now: number = Date.now(),
342
+ ): Promise<void> {
343
+ await mutate((data) => {
344
+ data.cloneRefreshes[marketplace] = now;
345
+ });
346
+ }
347
+
348
+ /** Drop every clone refresh time. Backs the explicit refresh (`r`). */
349
+ export async function clearCloneRefreshes(): Promise<void> {
350
+ await mutate((data) => {
351
+ data.cloneRefreshes = {};
352
+ });
353
+ }
354
+
315
355
  /** Test seam: forget this process's memo so the next read hits disk. */
316
356
  export function resetCatalogCacheMemo(): void {
317
357
  memo = null;
@@ -10,6 +10,7 @@
10
10
  import { isValidGitHubRepo } from "../utils/string-utils.js";
11
11
  import {
12
12
  clearCachedCatalogs,
13
+ clearCloneRefreshes,
13
14
  readCachedCatalog,
14
15
  writeCachedCatalog,
15
16
  } from "./catalog-cache-store.js";
@@ -145,6 +146,9 @@ export function clearMarketplaceCache(): void {
145
146
  */
146
147
  export async function clearPersistedCatalogs(): Promise<void> {
147
148
  await clearCachedCatalogs();
149
+ // The clone refresh times too: an explicit refresh means "ask again now", so
150
+ // the next `magus update` pulls every needed clone instead of trusting the hour.
151
+ await clearCloneRefreshes();
148
152
  }
149
153
 
150
154
  /**