@opsee/cli 0.11.13 → 0.11.18

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.
@@ -1,9 +1,10 @@
1
- import { constants } from "node:fs";
1
+ import { accessSync, constants, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { resolve } from "node:path";
4
4
  import { isVendor, VENDORS, type Vendor } from "./vendor.js";
5
5
  import type { AccountStore } from "./account-store.js";
6
- import { oneLine, printableOneLine } from "./core/text.js";
6
+ import { formatTable, oneLine, printableOneLine } from "./core/text.js";
7
+ import { describeAge, headroom, thresholdHold, type AccountUsage } from "./usage.js";
7
8
 
8
9
  /**
9
10
  * An Account (see ../../CONTEXT.md): one identity for one vendor, registered by path or key
@@ -26,6 +27,22 @@ export const DEFAULT_CAP = 2;
26
27
  * nothing below would refuse it: the Run would obligingly put a hundred Workers on one login. */
27
28
  export const MAX_CAP = 32;
28
29
 
30
+ /**
31
+ * How much of a window may be spent before the Foreman stops offering the Account new Tasks (the
32
+ * multi-account headroom design, §4).
33
+ *
34
+ * 90 because that is the point Claude Code fires its own `allowed_warning` — so at the default the
35
+ * Foreman acts on the first warning the vendor gives rather than on a number it invented.
36
+ */
37
+ export const DEFAULT_THRESHOLD_PCT = 90;
38
+
39
+ /** The lowest threshold that still leaves an Account usable. Below this a busy Account spends most
40
+ * of every window held back, which is indistinguishable from not having registered it. */
41
+ export const MIN_THRESHOLD_PCT = 50;
42
+
43
+ /** A threshold of 100 is "hold only what the vendor says is spent"; past that is not a percentage. */
44
+ export const MAX_THRESHOLD_PCT = 100;
45
+
29
46
  /** The least a stall timeout may be set to. Below this a Worker that is only thinking, or running
30
47
  * the project's test suite, is stopped as `stalled` every time, and the Task spends its attempts
31
48
  * (`DEFAULT_ATTEMPT_RETRIES`) in seconds without a single turn having had a chance. */
@@ -148,6 +165,21 @@ export function resumeAccount(store: AccountStore, name: string): Account | unde
148
165
  return updateAccount(store, name, (account) => ({ ...account, paused: null }));
149
166
  }
150
167
 
168
+ /**
169
+ * Whether this Account could take work at this moment: not quarantined, not Paused, and not one
170
+ * another Foreman already has Workers on.
171
+ *
172
+ * One name for the question, because it was being asked in pieces. `core/scheduler.ts` says plainly
173
+ * that eligibility "is decided in core/run.ts before and after this, and deliberately not here", so
174
+ * a third hand-rolled `filter(a => !isQuarantined(a))` in a command module was the shape of that
175
+ * rule coming apart.
176
+ *
177
+ * `busy` is the Process Table's answer where a caller has one; without it the other two still hold.
178
+ */
179
+ export function isUsableNow(account: Pick<Account, "name" | "paused" | "quarantined">, now: number, busy?: (name: string) => boolean): boolean {
180
+ return !isQuarantined(account) && !isPaused(account, now) && !busy?.(account.name);
181
+ }
182
+
151
183
  /** True while the Account is quarantined (OPS-288). No `now`, unlike `isPaused`: a quarantine has no
152
184
  * reset, so nothing about it changes with the clock. */
153
185
  export function isQuarantined(account: Pick<Account, "quarantined">): boolean {
@@ -242,6 +274,9 @@ interface AccountBase {
242
274
  /** How long a Worker on this Account may go silent before its turn is stopped as `stalled`
243
275
  * (story 33). Unset falls back to the Run's `--stall-timeout` and then to the CLI's default. */
244
276
  stallTimeoutMs?: number;
277
+ /** How much of a window may be spent before this Account is held back (`DEFAULT_THRESHOLD_PCT`).
278
+ * Per-Account because a Max subscription and a Pro one do not want the same number. */
279
+ thresholdPct?: number;
245
280
  }
246
281
 
247
282
  /** A subscription the user has already signed into, isolated in its own config directory; the
@@ -270,6 +305,11 @@ export interface ConfigDirFs {
270
305
  accessSync(path: string, mode: number): void;
271
306
  }
272
307
 
308
+ /** The real file system behind `ConfigDirFs`: the two calls registration is allowed to make, and
309
+ * no more. Here rather than in the CLI so that everything registering an Account — the command, its
310
+ * tests — goes through the same two functions the boundary test audits. */
311
+ export const realConfigDirFs: ConfigDirFs = { statSync, accessSync };
312
+
273
313
  /** A registration the user got wrong; reported as a plain message, never a stack. */
274
314
  export class AccountError extends Error {}
275
315
 
@@ -285,7 +325,9 @@ export interface AccountSpec {
285
325
  const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
286
326
  const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
287
327
 
288
- function expandHome(path: string): string {
328
+ /** `~` and `~/…` against the user's home. Exported because `account add --login` expands the same
329
+ * flag before the vendor sees it, and two implementations drifted on bare `~`. */
330
+ export function expandHome(path: string): string {
289
331
  return path === "~" || path.startsWith("~/") ? homedir() + path.slice(1) : path;
290
332
  }
291
333
 
@@ -319,7 +361,17 @@ export function validateConfigDir(fs: ConfigDirFs, dir: string): string {
319
361
 
320
362
  /** Validates the spec against the file system and the existing Accounts, then stores the new one.
321
363
  * Names are unique across vendors; a directory or key may back only one Account per vendor. */
322
- export function registerAccount(store: AccountStore, fs: ConfigDirFs, spec: AccountSpec): Account {
364
+ /**
365
+ * Everything `registerAccount` refuses a spec for, *except* what needs the file system.
366
+ *
367
+ * Split out so a caller that is about to do something irreversible on the spec's behalf can find
368
+ * out first: `account add --login` makes a directory and runs an interactive sign-in, and running
369
+ * all of this afterwards meant a name collision cost the user a full login and registered nothing.
370
+ *
371
+ * Returns the validated vendor and name; throws `AccountError` exactly as registration does, so the
372
+ * message a caller sees is the same either way.
373
+ */
374
+ export function validateAccountSpec(store: AccountStore, spec: AccountSpec): { vendor: Vendor; name: string } {
323
375
  if (!isVendor(spec.vendor)) {
324
376
  throw new AccountError(`Unknown vendor "${spec.vendor}". Vendors: ${VENDORS.join(", ")}`);
325
377
  }
@@ -338,15 +390,23 @@ export function registerAccount(store: AccountStore, fs: ConfigDirFs, spec: Acco
338
390
  if (!NAME_PATTERN.test(name)) {
339
391
  throw new AccountError("Account names are letters, digits, '.', '_' and '-' and start with a letter or digit");
340
392
  }
341
-
342
- const existing = store.load();
343
- if (existing.some((a) => a.name === name)) {
393
+ if (store.load().some((a) => a.name === name)) {
344
394
  throw new AccountError(
345
395
  spec.name === undefined
346
396
  ? `An Account named "${name}" already exists; pass --name to register a second ${vendor} Account`
347
397
  : `An Account named "${name}" already exists`,
348
398
  );
349
399
  }
400
+ return { vendor, name };
401
+ }
402
+
403
+ export function registerAccount(store: AccountStore, fs: ConfigDirFs, spec: AccountSpec): Account {
404
+ // The same guards `account add --login` asks for up front, in one place: two copies of five
405
+ // refusals with identical messages would drift, and the one that drifted would be the one a
406
+ // caller had already acted on.
407
+ const { vendor, name } = validateAccountSpec(store, spec);
408
+ const cap = spec.cap ?? DEFAULT_CAP;
409
+ const existing = store.load();
350
410
 
351
411
  // A registration is a fresh record, so re-adding an Account is how a human lifts a quarantine
352
412
  // (OPS-288): the state and its streak are not carried over from anything.
@@ -383,6 +443,12 @@ export interface AccountSettings {
383
443
  cap?: number;
384
444
  maxTurns?: number;
385
445
  stallTimeoutMs?: number;
446
+ thresholdPct?: number;
447
+ }
448
+
449
+ /** The threshold in force for this Account: its own, or the default. */
450
+ export function thresholdFor(account: Pick<Account, "thresholdPct">): number {
451
+ return account.thresholdPct ?? DEFAULT_THRESHOLD_PCT;
386
452
  }
387
453
 
388
454
  /** Changes an existing Account's scheduling limits (story 27): the cap that decides how many Slots
@@ -394,13 +460,19 @@ export function setAccountLimits(store: AccountStore, name: string, settings: Ac
394
460
  if (!account) {
395
461
  throw new AccountError(`No Account named "${name}"`);
396
462
  }
397
- if (settings.cap === undefined && settings.maxTurns === undefined && settings.stallTimeoutMs === undefined) {
398
- throw new AccountError("foreman account set needs at least one of --cap, --max-turns, --stall-timeout");
463
+ if (
464
+ settings.cap === undefined &&
465
+ settings.maxTurns === undefined &&
466
+ settings.stallTimeoutMs === undefined &&
467
+ settings.thresholdPct === undefined
468
+ ) {
469
+ throw new AccountError("foreman account set needs at least one of --cap, --max-turns, --stall-timeout, --threshold");
399
470
  }
400
471
  for (const [flag, value] of [
401
472
  ["--cap", settings.cap],
402
473
  ["--max-turns", settings.maxTurns],
403
474
  ["--stall-timeout", settings.stallTimeoutMs],
475
+ ["--threshold", settings.thresholdPct],
404
476
  ] as const) {
405
477
  if (value !== undefined && (!Number.isInteger(value) || value < 1)) {
406
478
  throw new AccountError(`${flag} must be a positive integer`);
@@ -414,11 +486,19 @@ export function setAccountLimits(store: AccountStore, name: string, settings: Ac
414
486
  if (settings.stallTimeoutMs !== undefined && settings.stallTimeoutMs < MIN_STALL_TIMEOUT_MS) {
415
487
  throw new AccountError(`--stall-timeout is at least ${MIN_STALL_TIMEOUT_MS}ms; below that a Worker that is only thinking is stopped as stalled`);
416
488
  }
489
+ // Both ends again: a threshold so low the Account is held back most of the time, and one past
490
+ // what a percentage of a window can be.
491
+ if (settings.thresholdPct !== undefined && (settings.thresholdPct < MIN_THRESHOLD_PCT || settings.thresholdPct > MAX_THRESHOLD_PCT)) {
492
+ throw new AccountError(
493
+ `--threshold is a percentage from ${MIN_THRESHOLD_PCT} to ${MAX_THRESHOLD_PCT}; it is how much of a rate-limit window may be spent before the Account is held back`,
494
+ );
495
+ }
417
496
  const updated: Account = {
418
497
  ...account,
419
498
  cap: settings.cap ?? account.cap,
420
499
  maxTurns: settings.maxTurns ?? account.maxTurns,
421
500
  stallTimeoutMs: settings.stallTimeoutMs ?? account.stallTimeoutMs,
501
+ thresholdPct: settings.thresholdPct ?? account.thresholdPct,
422
502
  };
423
503
  store.save(existing.map((a) => (a === account ? updated : a)));
424
504
  return updated;
@@ -442,19 +522,31 @@ export function removeAccount(store: AccountStore, name: string): Account {
442
522
  * Quarantine (OPS-288) is reported ahead of a pause, and swallows it: an Account may well have been
443
523
  * Paused before its credential died, and of the two facts only one still needs the reader to do
444
524
  * something. It names the remedy for the same reason, since nothing lifts it by itself. */
445
- export function formatAccountTable(accounts: Account[], now: number = Date.now()): string[] {
525
+ export function formatAccountTable(
526
+ accounts: Account[],
527
+ now: number = Date.now(),
528
+ usage?: ReadonlyMap<string, AccountUsage>,
529
+ ): string[] {
446
530
  const state = (a: Account): string => {
447
531
  if (a.quarantined) return `quarantined since ${a.quarantined.at} (${printableOneLine(a.quarantined.reason)}); re-add it or: opsee foreman account resume ${a.name}`;
448
532
  const until = pausedUntil(a, now);
449
533
  if (until) return `paused until ${a.paused!.until}${a.paused!.reason ? ` (${oneLine(a.paused!.reason)})` : ""}`;
450
- return a.paused ? `active (pause lapsed ${a.paused.until})` : "active";
534
+ // A hold the Foreman chose is reported differently from a pause the vendor imposed, because the
535
+ // two ask different things of a reader: a pause is "Anthropic refused this", a hold is "we
536
+ // stopped offering it work at your threshold", which `--threshold` can lift at once.
537
+ const record = usage?.get(a.name);
538
+ const hold = thresholdHold(record, thresholdFor(a), now);
539
+ if (hold) return `held until ${hold.until.toISOString()} (${hold.reason}, ${describeAge(hold.observedAt, now)})`;
540
+ const lapsed = a.paused ? `active (pause lapsed ${a.paused.until})` : "active";
541
+ const left = headroom(record, now);
542
+ if (left.kind === "unknown") return lapsed;
543
+ return `${lapsed} (${left.remainingPct}% left of ${left.tightest}, ${describeAge(left.observedAt, now)})`;
451
544
  };
452
545
  const rows = [
453
546
  ["NAME", "VENDOR", "TYPE", "SOURCE", "CAP", "STATE"],
454
547
  ...accounts.map((a) => [a.name, a.vendor, a.type, describeSource(a), String(a.cap), state(a)]),
455
548
  ];
456
- const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)));
457
- return rows.map((r) => r.map((cell, i) => (i === r.length - 1 ? cell : cell.padEnd(widths[i]))).join(" "));
549
+ return formatTable(rows);
458
550
  }
459
551
 
460
552
  export function describeAccount(account: Account): string {
@@ -469,6 +561,7 @@ export function describeLimits(account: Account): string {
469
561
  const perTurn = [
470
562
  account.maxTurns === undefined ? "" : `max turns ${account.maxTurns}`,
471
563
  account.stallTimeoutMs === undefined ? "" : `stall timeout ${account.stallTimeoutMs}ms`,
564
+ account.thresholdPct === undefined ? "" : `threshold ${account.thresholdPct}%`,
472
565
  ].filter(Boolean);
473
566
  return `cap ${account.cap} (${slots})${perTurn.length ? `, ${perTurn.join(", ")}` : ""}`;
474
567
  }
@@ -1,5 +1,6 @@
1
1
  import type { Account } from "./account.js";
2
2
  import { COMPLETION_REPORT_CONTRACT, completedEvent } from "./completion-report.js";
3
+ import type { UsageWindow } from "./usage.js";
3
4
  import { VENDOR_CONFIG_DIR_ENV } from "./vendor.js";
4
5
  import type { InteractiveCommand, InteractiveSessionRequest, McpServerSpec, OutputContract, TurnFailureReason, TurnHandle, TurnRequest, TurnSandbox, WorkerAdapter } from "./worker-adapter.js";
5
6
  import {
@@ -227,6 +228,97 @@ export function resetAtFromLimitText(text: string): string | undefined {
227
228
  return epoch ? new Date(Number(epoch[1]) * 1000).toISOString() : undefined;
228
229
  }
229
230
 
231
+ /** One window inside `rate_limit_info.unifiedWindows`: a fraction spent and an epoch-second reset. */
232
+ interface RateLimitWindow {
233
+ utilization?: number;
234
+ resetsAt?: number;
235
+ }
236
+
237
+ /**
238
+ * `rate_limit_info` as Claude Code emits it (see the recorded fixtures in `__fixtures__/claude`).
239
+ *
240
+ * `unifiedWindows` is the valuable part and the reason no credential is needed to know an Account's
241
+ * headroom while it is working: the vendor sends every window it tracks, with the fraction spent
242
+ * and the reset, on every `rate_limit_event` — including the `allowed_warning` that fires at 90%
243
+ * of a window, long before anything is refused.
244
+ */
245
+ interface RateLimitInfo {
246
+ status?: string;
247
+ resetsAt?: number;
248
+ rateLimitType?: string;
249
+ utilization?: number;
250
+ /** The fraction of the window whose crossing fired this warning: 0.9 in the recorded fixtures. */
251
+ surpassedThreshold?: number;
252
+ unifiedWindows?: Record<string, RateLimitWindow>;
253
+ }
254
+
255
+ /** Where the vendor's own warning fires, when the line does not carry `surpassedThreshold`. Claude
256
+ * Code documents the warning at 90% of a window, and the fixtures bear it out. */
257
+ export const WARNING_USED_PCT = 90;
258
+
259
+ /**
260
+ * The vendor's windows as usage observations, or undefined when it reported nothing usable.
261
+ *
262
+ * Two shapes are accepted: `unifiedWindows` when it is there, and the flat
263
+ * `rateLimitType`/`utilization`/`resetsAt` trio otherwise, which is what an older Claude Code sends
264
+ * and what the flat fields still mirror for the window that triggered the event.
265
+ *
266
+ * A window is dropped unless it has both a utilization and a reset. A utilization with no reset
267
+ * describes no interval — nothing downstream can tell whether it is still current (`usage.ts`
268
+ * `isLive`) — and a reset with no utilization is not a measurement: `status: "allowed"` with no
269
+ * number is the vendor declining to say, and recording that as 0% would make a nearly-spent
270
+ * Account look fresh.
271
+ */
272
+ export function usageWindowsFrom(info: RateLimitInfo | undefined, observedAt: string): Record<string, UsageWindow> | undefined {
273
+ if (!info) return undefined;
274
+ const source: Record<string, RateLimitWindow> =
275
+ info.unifiedWindows && typeof info.unifiedWindows === "object"
276
+ ? info.unifiedWindows
277
+ : info.rateLimitType
278
+ ? { [info.rateLimitType]: { utilization: info.utilization, resetsAt: info.resetsAt } }
279
+ : {};
280
+ // What a line with no utilization still tells us. `allowed_warning` means a threshold of this
281
+ // window was crossed, and a refusal means it is spent — both are real observations, where a plain
282
+ // `allowed` with no number is the vendor declining to say and must stay unrecorded.
283
+ //
284
+ // It applies to **one** window: the one `rateLimitType` names, which is the window the event is
285
+ // about. Spreading it across every window of `unifiedWindows` would write the five-hour warning's
286
+ // 90% onto a seven-day window the vendor said nothing about, and `thresholdHold` would then take
287
+ // the Account out of rotation until the *weekly* reset — up to seven days, on a number nobody
288
+ // measured. A status describes the window that raised it and no other.
289
+ const implied = impliedUsedPct(info);
290
+ const impliedFor = info.rateLimitType;
291
+ const windows: Record<string, UsageWindow> = {};
292
+ for (const [name, window] of Object.entries(source)) {
293
+ const measured = typeof window?.utilization === "number" && Number.isFinite(window.utilization) ? window.utilization : undefined;
294
+ const utilization = measured ?? (name === impliedFor ? implied : undefined);
295
+ const resetsAt = window?.resetsAt;
296
+ if (typeof utilization !== "number" || !Number.isFinite(utilization)) continue;
297
+ if (typeof resetsAt !== "number" || !Number.isFinite(resetsAt)) continue;
298
+ windows[name] = {
299
+ // A fraction to a percentage, to one decimal: `0.93 * 100` is 93.00000000000001 in binary
300
+ // floating point, and an Account's usage should not read as that.
301
+ usedPct: Math.max(0, Math.min(100, Math.round(utilization * 1000) / 10)),
302
+ resetsAt: new Date(resetsAt * 1000).toISOString(),
303
+ observedAt,
304
+ source: "in_band",
305
+ };
306
+ }
307
+ return Object.keys(windows).length === 0 ? undefined : windows;
308
+ }
309
+
310
+ /** What the status alone implies about a window the vendor sent no utilization for, as a fraction,
311
+ * or undefined when it implies nothing. A refusal is a spent window; a warning is one past the
312
+ * threshold the line names, or past the documented 90% when it names none. */
313
+ function impliedUsedPct(info: RateLimitInfo): number | undefined {
314
+ if (info.status === "allowed_warning") {
315
+ const named = info.surpassedThreshold;
316
+ return typeof named === "number" && Number.isFinite(named) && named > 0 ? named : WARNING_USED_PCT / 100;
317
+ }
318
+ // Anything that is not `allowed` and not a warning is a refusal (`handleRateLimit` treats it so).
319
+ return info.status !== undefined && info.status !== "allowed" ? 1 : undefined;
320
+ }
321
+
230
322
  /** The Claude Code tool through which `--json-schema` output is delivered; its input is the report. */
231
323
  export const STRUCTURED_OUTPUT_TOOL = "StructuredOutput";
232
324
 
@@ -235,7 +327,7 @@ interface StreamLine {
235
327
  subtype?: string;
236
328
  session_id?: string;
237
329
  message?: { content?: unknown };
238
- rate_limit_info?: { status?: string; resetsAt?: number; rateLimitType?: string };
330
+ rate_limit_info?: RateLimitInfo;
239
331
  is_error?: boolean;
240
332
  result?: unknown;
241
333
  structured_output?: unknown;
@@ -291,6 +383,13 @@ class ClaudeTurn extends ProcessTurn {
291
383
  }
292
384
 
293
385
  private handleRateLimit(info: StreamLine["rate_limit_info"]): void {
386
+ // Every rate-limit line is worth something, whatever its status: the vendor puts its windows
387
+ // and their utilizations on all of them, and an `allowed_warning` at 90% is the whole point —
388
+ // it is the one warning that arrives before a Task has been spent finding the limit. These
389
+ // numbers used to be discarded here along with the status.
390
+ const windows = usageWindowsFrom(info, new Date().toISOString());
391
+ if (windows) this.emit({ type: "usage", windows });
392
+
294
393
  // `allowed` and `allowed_warning` are advisory (a warning fires at 90% of a window and the run
295
394
  // goes on); only a refusal is reported, and it fails the turn only if the result confirms it.
296
395
  if (!info || info.status === "allowed" || info.status === "allowed_warning") return;
@@ -0,0 +1,167 @@
1
+ import { existsSync, lstatSync, readlinkSync, rmSync, statSync, symlinkSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+
4
+ /**
5
+ * The shared config skeleton (the multi-account headroom design, §5).
6
+ *
7
+ * One config directory per Account is what makes multi-account work without touching a credential
8
+ * (claude-launcher.ts): a directory the user has logged into is a whole identity, and the vendor's
9
+ * own variable selects it. The cost is that everything else in there — settings, skills, commands —
10
+ * would live in each Account separately, so a change had to be made several times.
11
+ *
12
+ * This narrows that to history and per-account state, by symlinking the account-independent entries
13
+ * from one canonical directory into each Account's. Symlinks and not copies, so the canonical file
14
+ * stays the single thing a human edits and there is no second copy to drift.
15
+ *
16
+ * **It moves no bytes.** Every operation here is `lstat`, `readlink`, `symlink` and `rm` of a link:
17
+ * nothing is ever opened or read, so a credential sitting beside an allowlisted file is not read
18
+ * even by accident. That is what keeps this inside the boundary ADR-0013 draws, and
19
+ * `config-skeleton.test.ts` holds it to it.
20
+ */
21
+
22
+ /**
23
+ * The entries that are the same whoever is logged in.
24
+ *
25
+ * An allowlist, never a denylist or a scan of the directory: a vendor that adds a file holding
26
+ * identity would otherwise be shared between Accounts the first time someone ran this, and nobody
27
+ * would find out until two Accounts were one. Anything not named here is simply not the skeleton's
28
+ * business.
29
+ */
30
+ export const SKELETON_ENTRIES: readonly string[] = ["settings.json", "CLAUDE.md", "skills", "commands", "agents", ".mcp.json"];
31
+
32
+ /** Anything that looks like it carries identity, refused whatever the caller asked for. The
33
+ * allowlist above already excludes these; this is the second lock, for a caller passing `entries`
34
+ * of its own. */
35
+ const CREDENTIAL_SHAPED = /credential|token|keychain|auth|secret|session/i;
36
+
37
+ /**
38
+ * Files that are never shared however they are asked for, by exact name.
39
+ *
40
+ * `.claude.json` is the one worth naming: it does not read as credential-shaped, it sits right
41
+ * beside the entries that *are* shared, and it carries the account's own identity and project
42
+ * state. Linking it between two Accounts would quietly make them one Account — the exact failure
43
+ * this whole design exists to avoid — and nothing above would have refused it.
44
+ */
45
+ const NEVER_SHARED: readonly string[] = [".claude.json", ".credentials.json"];
46
+
47
+ export interface SkeletonRequest {
48
+ /** The directory the shared files really live in — normally the user's own `~/.claude`. */
49
+ canonical: string;
50
+ /** The Account config directories to link them into. */
51
+ targets: readonly string[];
52
+ /** Defaults to `SKELETON_ENTRIES`. */
53
+ entries?: readonly string[];
54
+ }
55
+
56
+ export type SkeletonOutcome =
57
+ /** A new link was made. */
58
+ | "linked"
59
+ /** A link that pointed elsewhere now points at the canonical entry. */
60
+ | "relinked"
61
+ /** The right link was already there. */
62
+ | "already"
63
+ /** The canonical directory does not have this entry, so there is nothing to share. */
64
+ | "missing"
65
+ /** The target directory does not exist; a config directory is the vendor's login to create. */
66
+ | "no-target"
67
+ /** The target *is* the canonical directory. */
68
+ | "self"
69
+ /** Deliberately not done, with a reason: a real file in the way, or a credential-shaped name. */
70
+ | "refused";
71
+
72
+ export interface SkeletonResult {
73
+ target: string;
74
+ entry: string;
75
+ outcome: SkeletonOutcome;
76
+ note?: string;
77
+ }
78
+
79
+ export function syncSkeleton(request: SkeletonRequest): SkeletonResult[] {
80
+ const canonical = resolve(request.canonical);
81
+ const entries = request.entries ?? SKELETON_ENTRIES;
82
+ const results: SkeletonResult[] = [];
83
+
84
+ for (const rawTarget of request.targets) {
85
+ const target = resolve(rawTarget);
86
+ for (const entry of entries) {
87
+ const refusal = unsafe(entry);
88
+ if (refusal) {
89
+ results.push({ target, entry, outcome: "refused", note: refusal });
90
+ continue;
91
+ }
92
+ if (target === canonical) {
93
+ results.push({ target, entry, outcome: "self", note: "this is the canonical directory; its files are the originals" });
94
+ continue;
95
+ }
96
+ if (!isDirectory(target)) {
97
+ results.push({ target, entry, outcome: "no-target", note: "no such config directory; the vendor's own login creates one" });
98
+ continue;
99
+ }
100
+ const source = join(canonical, entry);
101
+ if (!existsSync(source)) {
102
+ results.push({ target, entry, outcome: "missing" });
103
+ continue;
104
+ }
105
+ results.push(link(source, join(target, entry), target, entry));
106
+ }
107
+ }
108
+ return results;
109
+ }
110
+
111
+ /** Why this entry will not be touched, or undefined when it is fine. */
112
+ function unsafe(entry: string): string | undefined {
113
+ if (entry === "" || entry === "." || entry === "..") return "not a file name";
114
+ // One path segment only. An entry with a separator in it could place a link anywhere, and an
115
+ // entry with `..` could reach out of the config directory entirely.
116
+ if (entry.includes("/") || entry.includes("\\") || entry.includes("..")) return "an entry is one file name, not a path";
117
+ if (NEVER_SHARED.includes(entry)) return `${entry} carries this Account's own identity, and is never shared between Accounts`;
118
+ if (CREDENTIAL_SHAPED.test(entry)) return "this is credential-shaped, and a credential is never shared between Accounts";
119
+ return undefined;
120
+ }
121
+
122
+ function isDirectory(path: string): boolean {
123
+ try {
124
+ return statSync(path).isDirectory();
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ /** Makes `at` a symlink to `source`, or explains why it was left alone. */
131
+ function link(source: string, at: string, target: string, entry: string): SkeletonResult {
132
+ let existing: ReturnType<typeof lstatSync> | undefined;
133
+ try {
134
+ existing = lstatSync(at);
135
+ } catch {
136
+ existing = undefined;
137
+ }
138
+ if (existing?.isSymbolicLink()) {
139
+ let points: string | undefined;
140
+ try {
141
+ points = readlinkSync(at);
142
+ } catch {
143
+ points = undefined;
144
+ }
145
+ if (points !== undefined && resolve(points) === source) return { target, entry, outcome: "already" };
146
+ // Only ever a symlink is removed here, and only after `lstat` has said it is one — so nothing
147
+ // a person wrote can be lost by this branch.
148
+ rmSync(at);
149
+ symlinkSync(source, at);
150
+ return { target, entry, outcome: "relinked", note: `it pointed at ${points ?? "somewhere unreadable"}` };
151
+ }
152
+ if (existing) {
153
+ return {
154
+ target,
155
+ entry,
156
+ outcome: "refused",
157
+ note: `this Account has its own ${entry} here; move or delete it to share the canonical one`,
158
+ };
159
+ }
160
+ symlinkSync(source, at);
161
+ return { target, entry, outcome: "linked" };
162
+ }
163
+
164
+ /** One line per result, for the command to print. Ordered as the sync happened. */
165
+ export function formatSkeletonResults(results: readonly SkeletonResult[]): string[] {
166
+ return results.map((r) => ` ${r.outcome.padEnd(9)} ${r.entry}${r.outcome === "linked" || r.outcome === "relinked" || r.outcome === "already" ? ` in ${r.target}` : r.note ? ` — ${r.note}` : ""}`);
167
+ }