acryl-cli-linux-x64 0.1.35 → 0.1.36

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.
@@ -6,6 +6,7 @@ import { join, relative, resolve } from "node:path";
6
6
  import { execFile, spawn } from "node:child_process";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { bootAcrylHarnessProfile, createAcrylSessionBridge } from "acryl-harness-runtime";
9
+ import { AuthorizationService, computeCredentialProjection } from "acryl-control";
9
10
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
10
11
  import { diffLines } from "diff";
11
12
  import { Container, Editor, HStack, Key, KeybindingsManager, ProcessTerminal, ScrollView, TUI_KEYBINDINGS, Text, TuiAltScreen, VStack, fuzzyFilter, matchesKey, setKeybindings, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
@@ -1072,7 +1073,7 @@ const ACRYL_MARK_ROWS = [
1072
1073
  " ▀ "
1073
1074
  ];
1074
1075
  /** ISO timestamp this build was produced at. */
1075
- const BUILD_TIME = "2026-09-08T14:33:49.586Z";
1076
+ const BUILD_TIME = "2026-09-08T18:16:12.207Z";
1076
1077
  //#endregion
1077
1078
  //#region src/tui/statsFormat.ts
1078
1079
  /**
@@ -2910,14 +2911,42 @@ function renderMiniTextField(state, cursorVisible, mask) {
2910
2911
  return `${display.slice(0, state.cursor)}\x1b[7m${display[state.cursor] ?? " "}\x1b[0m${display.slice(state.cursor + 1)}`;
2911
2912
  }
2912
2913
  //#endregion
2913
- //#region src/tui/modelProfile/types.ts
2914
+ //#region src/tui/listWindow.ts
2915
+ /**
2916
+ * Scrollable-list windowing shared by every overlay that paints a
2917
+ * selection-following list (`/login`'s provider list, `/model`'s picker and
2918
+ * provider list). Previously duplicated verbatim in `LoginOverlay.ts` and
2919
+ * `ModelProfileOverlay.ts` (specs/001-acryl-refactor-improvements-and-tech-debt, R7).
2920
+ * @module @tomowang/dsh-tui/tui/listWindow
2921
+ */
2914
2922
  /**
2915
- * Data shapes for the `/model` provider-profile overlay: the read model that
2916
- * joins `ctx.llm`'s provider directory with `ctx.settings`' stored sections,
2917
- * the raw shape stored at each provider's settings path, and the mutable
2918
- * draft one add/edit form works with before a save round-trips it back.
2919
- * @module @tomowang/dsh-tui/tui/modelProfile/types
2923
+ * Rows available for a scrollable list body: terminal height minus the lines
2924
+ * every such screen spends on chrome (header/hint/notice/search — `chrome`
2925
+ * lines, caller-counted since it varies by screen). Long catalogs (~35
2926
+ * providers, matching that many models) previously rendered every row
2927
+ * unconditionally, pushing the key-legend hint line — the only place a
2928
+ * shortcut is documented — past the bottom of the terminal, invisible
2929
+ * without scrolling back. Every list-shaped view windows around the current
2930
+ * selection instead, so the hint line is always the last line printed and
2931
+ * always fits on screen.
2920
2932
  */
2933
+ function listWindow(terminalRows, chrome) {
2934
+ return Math.max(3, terminalRows - chrome);
2935
+ }
2936
+ /** The `[start, end)` slice of `count` items to show so `selected` stays visible within `maxVisible` rows, biased to keep it centered. */
2937
+ function visibleRange(count, selected, maxVisible) {
2938
+ if (count <= maxVisible) return {
2939
+ start: 0,
2940
+ end: count
2941
+ };
2942
+ const start = Math.max(0, Math.min(selected - Math.floor(maxVisible / 2), count - maxVisible));
2943
+ return {
2944
+ start,
2945
+ end: start + maxVisible
2946
+ };
2947
+ }
2948
+ //#endregion
2949
+ //#region src/tui/modelProfile/types.ts
2921
2950
  /**
2922
2951
  * The `apiKeyEnv` reference a provider route falls back to when its settings
2923
2952
  * profile names none, e.g. `my-proxy` -> `MY_PROXY_API_KEY`. Shared between
@@ -2990,32 +3019,6 @@ var ModelProfileOverlay = class {
2990
3019
  }
2991
3020
  invalidate() {}
2992
3021
  /**
2993
- * Rows available for a scrollable list body: terminal height minus the
2994
- * lines every such screen spends on chrome (header/hint/notice/search —
2995
- * `chrome` lines, caller-counted since it varies by screen). Long catalogs
2996
- * (~35 providers, matching that many models) previously rendered every row
2997
- * unconditionally, pushing the key-legend hint line — the only place a
2998
- * shortcut is documented — past the bottom of the terminal, invisible
2999
- * without scrolling back. Every list-shaped view here windows around the
3000
- * current selection instead, so the hint line is always the last line
3001
- * printed and always fits on screen.
3002
- */
3003
- listWindow(chrome) {
3004
- return Math.max(3, this.tui.terminal.rows - chrome);
3005
- }
3006
- /** The `[start, end)` slice of `count` items to show so `selected` stays visible within `maxVisible` rows, biased to keep it centered. */
3007
- visibleRange(count, selected, maxVisible) {
3008
- if (count <= maxVisible) return {
3009
- start: 0,
3010
- end: count
3011
- };
3012
- const start = Math.max(0, Math.min(selected - Math.floor(maxVisible / 2), count - maxVisible));
3013
- return {
3014
- start,
3015
- end: start + maxVisible
3016
- };
3017
- }
3018
- /**
3019
3022
  * The global one-line notice (`store.setNotice`), rendered inline here.
3020
3023
  * `/model` runs as a full-screen overlay that paints over the whole
3021
3024
  * terminal, so the notice dock underneath — where "Saved X."/"Active model
@@ -3115,8 +3118,8 @@ var ModelProfileOverlay = class {
3115
3118
  const all = this.pickerItems(mp);
3116
3119
  const filtered = this.filteredPickerItems(mp);
3117
3120
  const chrome = lines.length + 2;
3118
- const maxVisible = this.listWindow(chrome);
3119
- const { start, end } = this.visibleRange(filtered.length, this.modelPickerCursor, maxVisible);
3121
+ const maxVisible = listWindow(this.tui.terminal.rows, chrome);
3122
+ const { start, end } = visibleRange(filtered.length, this.modelPickerCursor, maxVisible);
3120
3123
  filtered.slice(start, end).forEach((item, offset) => {
3121
3124
  const isSelected = start + offset === this.modelPickerCursor;
3122
3125
  const via = item.authMethod === "oauth" ? " oauth" : item.authMethod === "api-key" ? " api" : "";
@@ -3175,8 +3178,8 @@ var ModelProfileOverlay = class {
3175
3178
  if (busy && providers === void 0) lines.push(muted$10("Loading…"));
3176
3179
  const filtered = this.filteredProviders(mp);
3177
3180
  const chrome = lines.length + 2;
3178
- const maxVisible = this.listWindow(chrome);
3179
- const { start, end } = this.visibleRange(filtered.length, this.providerCursor, maxVisible);
3181
+ const maxVisible = listWindow(this.tui.terminal.rows, chrome);
3182
+ const { start, end } = visibleRange(filtered.length, this.providerCursor, maxVisible);
3180
3183
  filtered.slice(start, end).forEach((row, offset) => {
3181
3184
  const index = start + offset;
3182
3185
  const marker = row.configured ? "● " : "○ ";
@@ -3441,13 +3444,10 @@ var LoginOverlay = class {
3441
3444
  tui;
3442
3445
  store;
3443
3446
  actions;
3444
- step = "authType";
3445
- authType;
3446
- authTypeCursor = 0;
3447
- chooserSkipped = false;
3448
- autoSkipChecked = false;
3449
- listCursor = 0;
3450
- searchQuery = emptyMiniTextField();
3447
+ view = {
3448
+ kind: "authType",
3449
+ cursor: 0
3450
+ };
3451
3451
  promptField = emptyMiniTextField();
3452
3452
  promptCursor = 0;
3453
3453
  constructor(tui, store, actions) {
@@ -3456,22 +3456,6 @@ var LoginOverlay = class {
3456
3456
  this.actions = actions;
3457
3457
  }
3458
3458
  invalidate() {}
3459
- /** See `ModelProfileOverlay.listWindow` — same overflow bug, same fix: a long provider list (~35 catalog entries) must never push the key-legend hint line past the bottom of the terminal. */
3460
- listWindow(chrome) {
3461
- return Math.max(3, this.tui.terminal.rows - chrome);
3462
- }
3463
- /** The `[start, end)` slice of `count` items to show so `selected` stays visible within `maxVisible` rows, biased to keep it centered. */
3464
- visibleRange(count, selected, maxVisible) {
3465
- if (count <= maxVisible) return {
3466
- start: 0,
3467
- end: count
3468
- };
3469
- const start = Math.max(0, Math.min(selected - Math.floor(maxVisible / 2), count - maxVisible));
3470
- return {
3471
- start,
3472
- end: start + maxVisible
3473
- };
3474
- }
3475
3459
  /**
3476
3460
  * The global one-line notice (`store.setNotice`), rendered inline here.
3477
3461
  * `/login` runs as a full-screen overlay that paints over the whole
@@ -3485,56 +3469,80 @@ var LoginOverlay = class {
3485
3469
  const notice = this.store.getSnapshot().notice;
3486
3470
  return notice === void 0 ? [] : [muted$9(notice)];
3487
3471
  }
3488
- /** Partition the loaded flows by method type, once, the first time they arrive; auto-advances past the chooser if only one type is on offer. */
3489
- maybeAutoSkipChooser(login) {
3490
- if (this.autoSkipChecked || login.flows === void 0) return;
3491
- this.autoSkipChecked = true;
3472
+ /**
3473
+ * Pure: given the loaded flows, which single method (if any) they all
3474
+ * share the chooser has nothing to offer when only one method type is
3475
+ * registered at all. Recomputed fresh every call (no cached/latched
3476
+ * result), so a later-refreshed flow set is always re-evaluated instead of
3477
+ * replaying a stale decision (specs/001-…, R6).
3478
+ */
3479
+ autoSkipAuthType(login) {
3480
+ if (login.flows === void 0) return void 0;
3492
3481
  const hasOAuth = login.flows.some((flow) => flow.methods.some((method) => method.id === "oauth"));
3493
- if (hasOAuth === login.flows.some((flow) => flow.methods.some((method) => method.id === "api-key"))) return;
3494
- this.authType = hasOAuth ? "oauth" : "api-key";
3495
- this.step = "list";
3496
- this.chooserSkipped = true;
3482
+ return hasOAuth === login.flows.some((flow) => flow.methods.some((method) => method.id === "api-key")) ? void 0 : hasOAuth ? "oauth" : "api-key";
3497
3483
  }
3498
- filteredFlows(login) {
3484
+ /**
3485
+ * Pure: the view to actually render/operate against. `this.view` stays the
3486
+ * untouched `authType` default until the user explicitly chooses (Enter on
3487
+ * the chooser) — until then, this derives the auto-skipped `list` view on
3488
+ * every call instead of mutating `this.view` from inside `render()`. The
3489
+ * first `handleInput` mutation against a derived (not-yet-explicit) `list`
3490
+ * view reassigns `this.view` directly (see `handleProviderListInput`), so
3491
+ * cursor/search state persists across renders once the user interacts.
3492
+ */
3493
+ effectiveView(login) {
3494
+ if (this.view.kind === "authType") {
3495
+ const auto = this.autoSkipAuthType(login);
3496
+ if (auto !== void 0) return {
3497
+ kind: "list",
3498
+ authType: auto,
3499
+ chooserSkipped: true,
3500
+ cursor: 0,
3501
+ searchQuery: emptyMiniTextField()
3502
+ };
3503
+ }
3504
+ return this.view;
3505
+ }
3506
+ filteredFlows(login, view) {
3499
3507
  const all = login.flows ?? [];
3500
- const byType = this.authType === void 0 ? all : all.filter((flow) => flow.methods.some((method) => method.id === this.authType));
3501
- const query = this.searchQuery.value.trim();
3508
+ const byType = view.authType === void 0 ? all : all.filter((flow) => flow.methods.some((method) => method.id === view.authType));
3509
+ const query = view.searchQuery.value.trim();
3502
3510
  return query === "" ? byType : fuzzyFilter([...byType], query, (flow) => flow.label);
3503
3511
  }
3504
- renderAuthTypeChooser(login) {
3512
+ renderAuthTypeChooser(login, view) {
3505
3513
  const lines = [bold$8(secondary$8("Select authentication method"))];
3506
3514
  lines.push(...this.noticeLines());
3507
3515
  if (login.error !== void 0) lines.push(errorColor$4(login.error));
3508
3516
  if (login.busy && login.flows === void 0) lines.push(muted$9("Loading…"));
3509
3517
  AUTH_TYPES.forEach((type, index) => {
3510
- const text = `${index === this.authTypeCursor ? "› " : " "}${AUTH_TYPE_LABELS[type]}`;
3511
- lines.push(index === this.authTypeCursor ? invert$4(text) : text);
3518
+ const text = `${index === view.cursor ? "› " : " "}${AUTH_TYPE_LABELS[type]}`;
3519
+ lines.push(index === view.cursor ? invert$4(text) : text);
3512
3520
  });
3513
3521
  lines.push(muted$9("↑↓ select · enter continue · esc close"));
3514
3522
  return lines;
3515
3523
  }
3516
- renderProviderList(login) {
3524
+ renderProviderList(login, view) {
3517
3525
  const lines = [bold$8(secondary$8("Select provider to configure:"))];
3518
3526
  lines.push(...this.noticeLines());
3519
3527
  if (login.error !== void 0) lines.push(errorColor$4(login.error));
3520
- lines.push(`> ${renderMiniTextField(this.searchQuery, true)}`);
3528
+ lines.push(`> ${renderMiniTextField(view.searchQuery, true)}`);
3521
3529
  if (login.busy && login.flows === void 0) lines.push(muted$9("Loading…"));
3522
- const flows = this.filteredFlows(login);
3530
+ const flows = this.filteredFlows(login, view);
3523
3531
  const chrome = lines.length + 2;
3524
- const maxVisible = this.listWindow(chrome);
3525
- const { start, end } = this.visibleRange(flows.length, this.listCursor, maxVisible);
3532
+ const maxVisible = listWindow(this.tui.terminal.rows, chrome);
3533
+ const { start, end } = visibleRange(flows.length, view.cursor, maxVisible);
3526
3534
  flows.slice(start, end).forEach((flow, offset) => {
3527
3535
  const index = start + offset;
3528
3536
  const marker = flow.inFlight ? "· " : flow.configured ? "✓ " : "○ ";
3529
3537
  const signingIn = login.signingIn === flow.key ? " — signing in…" : "";
3530
3538
  const via = flow.authMethod === "oauth" ? " [oauth]" : flow.authMethod === "api-key" ? " [api]" : "";
3531
3539
  const readyNote = login.signingIn !== flow.key && flow.configured ? ` — ready to use${via}` : "";
3532
- const text = `${index === this.listCursor ? "› " : " "}${marker}${flow.label}${signingIn}${readyNote}`;
3533
- lines.push(index === this.listCursor ? invert$4(text) : flow.configured ? successColor(text) : text);
3540
+ const text = `${index === view.cursor ? "› " : " "}${marker}${flow.label}${signingIn}${readyNote}`;
3541
+ lines.push(index === view.cursor ? invert$4(text) : flow.configured ? successColor(text) : text);
3534
3542
  });
3535
- if (flows.length > maxVisible) lines.push(muted$9(`(${this.listCursor + 1}/${flows.length})`));
3543
+ if (flows.length > maxVisible) lines.push(muted$9(`(${view.cursor + 1}/${flows.length})`));
3536
3544
  if (login.flows !== void 0 && flows.length === 0) lines.push(muted$9("No matching providers."));
3537
- const back = this.chooserSkipped ? "esc close" : "esc back";
3545
+ const back = view.chooserSkipped ? "esc close" : "esc back";
3538
3546
  lines.push(muted$9(`type to search · ↑↓ select · enter sign in (or edit key/models if already configured) · ctrl+p add custom provider · ${back}`));
3539
3547
  return lines;
3540
3548
  }
@@ -3565,8 +3573,8 @@ var LoginOverlay = class {
3565
3573
  if (overlay.kind !== "login") return [];
3566
3574
  const { login } = overlay;
3567
3575
  if (login.prompt !== void 0) return this.renderPrompt(login.prompt);
3568
- this.maybeAutoSkipChooser(login);
3569
- return this.step === "authType" ? this.renderAuthTypeChooser(login) : this.renderProviderList(login);
3576
+ const view = this.effectiveView(login);
3577
+ return view.kind === "authType" ? this.renderAuthTypeChooser(login, view) : this.renderProviderList(login, view);
3570
3578
  }
3571
3579
  handleInput(data) {
3572
3580
  const overlay = this.store.getSnapshot().overlay;
@@ -3576,8 +3584,9 @@ var LoginOverlay = class {
3576
3584
  this.handlePromptInput(data, login.prompt);
3577
3585
  return;
3578
3586
  }
3579
- if (this.step === "authType") this.handleAuthTypeChooserInput(data);
3580
- else this.handleProviderListInput(data, login);
3587
+ const view = this.effectiveView(login);
3588
+ if (view.kind === "authType") this.handleAuthTypeChooserInput(data, view);
3589
+ else this.handleProviderListInput(data, login, view);
3581
3590
  }
3582
3591
  handlePromptInput(data, prompt) {
3583
3592
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
@@ -3603,65 +3612,79 @@ var LoginOverlay = class {
3603
3612
  const next = miniTextFieldInput(this.promptField, data);
3604
3613
  if (next !== void 0) this.promptField = next;
3605
3614
  }
3606
- handleAuthTypeChooserInput(data) {
3615
+ handleAuthTypeChooserInput(data, view) {
3607
3616
  if (matchesKey(data, Key.escape)) {
3608
3617
  this.actions.closeLogin();
3609
3618
  return;
3610
3619
  }
3611
3620
  if (matchesKey(data, Key.up)) {
3612
- this.authTypeCursor = Math.max(0, this.authTypeCursor - 1);
3621
+ this.view = {
3622
+ kind: "authType",
3623
+ cursor: Math.max(0, view.cursor - 1)
3624
+ };
3613
3625
  return;
3614
3626
  }
3615
3627
  if (matchesKey(data, Key.down)) {
3616
- this.authTypeCursor = Math.min(AUTH_TYPES.length - 1, this.authTypeCursor + 1);
3628
+ this.view = {
3629
+ kind: "authType",
3630
+ cursor: Math.min(AUTH_TYPES.length - 1, view.cursor + 1)
3631
+ };
3617
3632
  return;
3618
3633
  }
3619
- if (matchesKey(data, Key.enter)) {
3620
- this.authType = AUTH_TYPES[this.authTypeCursor];
3621
- this.step = "list";
3622
- this.listCursor = 0;
3623
- this.searchQuery = emptyMiniTextField();
3624
- }
3634
+ if (matchesKey(data, Key.enter)) this.view = {
3635
+ kind: "list",
3636
+ authType: AUTH_TYPES[view.cursor],
3637
+ chooserSkipped: false,
3638
+ cursor: 0,
3639
+ searchQuery: emptyMiniTextField()
3640
+ };
3625
3641
  }
3626
- handleProviderListInput(data, login) {
3642
+ handleProviderListInput(data, login, view) {
3627
3643
  if (matchesKey(data, Key.escape)) {
3628
- if (this.chooserSkipped) this.actions.closeLogin();
3629
- else {
3630
- this.step = "authType";
3631
- this.searchQuery = emptyMiniTextField();
3632
- }
3644
+ if (view.chooserSkipped) this.actions.closeLogin();
3645
+ else this.view = {
3646
+ kind: "authType",
3647
+ cursor: 0
3648
+ };
3633
3649
  return;
3634
3650
  }
3635
3651
  if (matchesKey(data, Key.ctrl("p"))) {
3636
3652
  this.actions.addCustomProvider();
3637
3653
  return;
3638
3654
  }
3639
- const flows = this.filteredFlows(login);
3655
+ const flows = this.filteredFlows(login, view);
3640
3656
  if (matchesKey(data, Key.up)) {
3641
- this.listCursor = Math.max(0, this.listCursor - 1);
3657
+ this.view = {
3658
+ ...view,
3659
+ cursor: Math.max(0, view.cursor - 1)
3660
+ };
3642
3661
  return;
3643
3662
  }
3644
3663
  if (matchesKey(data, Key.down)) {
3645
- this.listCursor = Math.min(Math.max(0, flows.length - 1), this.listCursor + 1);
3664
+ this.view = {
3665
+ ...view,
3666
+ cursor: Math.min(Math.max(0, flows.length - 1), view.cursor + 1)
3667
+ };
3646
3668
  return;
3647
3669
  }
3648
3670
  if (matchesKey(data, Key.ctrl("e"))) {
3649
- const flow = flows[this.listCursor];
3671
+ const flow = flows[view.cursor];
3650
3672
  if (flow !== void 0 && flow.configured && flow.key.startsWith("llm-pi-ai/")) this.actions.openProviderEditor(flow.key.slice(10));
3651
3673
  return;
3652
3674
  }
3653
3675
  if (matchesKey(data, Key.enter)) {
3654
- const flow = flows[this.listCursor];
3676
+ const flow = flows[view.cursor];
3655
3677
  if (flow === void 0) return;
3656
3678
  if (flow.configured && flow.key.startsWith("llm-pi-ai/")) this.actions.openProviderEditor(flow.key.slice(10));
3657
- else this.actions.beginAuthorization(flow.key, this.authType);
3679
+ else this.actions.beginAuthorization(flow.key, view.authType);
3658
3680
  return;
3659
3681
  }
3660
- const next = miniTextFieldInput(this.searchQuery, data);
3661
- if (next !== void 0) {
3662
- this.searchQuery = next;
3663
- this.listCursor = 0;
3664
- }
3682
+ const next = miniTextFieldInput(view.searchQuery, data);
3683
+ if (next !== void 0) this.view = {
3684
+ ...view,
3685
+ searchQuery: next,
3686
+ cursor: 0
3687
+ };
3665
3688
  }
3666
3689
  };
3667
3690
  //#endregion
@@ -4988,7 +5011,7 @@ var TuiApp = class {
4988
5011
  gap: 1,
4989
5012
  align: "start"
4990
5013
  });
4991
- const buildStamp = `build bf7e1ab · ${(/* @__PURE__ */ new Date(BUILD_TIME)).toLocaleTimeString()}`;
5014
+ const buildStamp = `build a980b38 · ${(/* @__PURE__ */ new Date(BUILD_TIME)).toLocaleTimeString()}`;
4992
5015
  const sessionInfo = new DynamicText(() => {
4993
5016
  const { provider, model, cwd } = options;
4994
5017
  return [
@@ -5241,6 +5264,8 @@ const ACRYL_VERSION = createRequire(import.meta.url)("../package.json").version;
5241
5264
  * TUI shows the same capabilities the web surface does. `/clear` flushes the
5242
5265
  * current session and re-attaches a fresh one (durable history stays on disk).
5243
5266
  */
5267
+ /** The credential-record scope `dsh-llm-pi-ai` writes a `/login` sign-in under (`credentialKey('llm-pi-ai', providerId)`). */
5268
+ const LOGIN_RECORD_SCOPE = "llm-pi-ai";
5244
5269
  const TUI_VERSION = ACRYL_VERSION;
5245
5270
  const PROMPT_HISTORY_LIMIT = 200;
5246
5271
  function failUnknown(status) {
@@ -5283,14 +5308,16 @@ function getAtPath(value, path) {
5283
5308
  return current;
5284
5309
  }
5285
5310
  /**
5286
- * `dsh-llm-pi-ai`'s own credential record scope (`credentialKey('llm-pi-ai', providerId)`,
5287
- * i.e. `"llm-pi-ai/<providerId>"`). A `/login` sign-in (OAuth or API key) writes here through
5288
- * pi-ai's own `CredentialStore`, entirely separate from the `apiKeyEnv` reference a manually
5289
- * configured provider's settings profile points at so a provider's "has credentials" status
5290
- * has to check both places, and this is the address for the first.
5311
+ * `dsh-llm-pi-ai`'s own credential record key under `LOGIN_RECORD_SCOPE`
5312
+ * (`credentialKey('llm-pi-ai', providerId)`, i.e. `"llm-pi-ai/<providerId>"`).
5313
+ * A `/login` sign-in (OAuth or API key) writes here through pi-ai's own
5314
+ * `CredentialStore`, entirely separate from the `apiKeyEnv` reference a
5315
+ * manually configured provider's settings profile points at.
5316
+ * `acryl-control`'s `CredentialProjection` computes this internally for the
5317
+ * read side; only `clearApiKey`'s explicit delete still needs it directly.
5291
5318
  */
5292
- function piAiRecordKey(providerId) {
5293
- return `llm-pi-ai/${providerId}`;
5319
+ function loginRecordKey(providerId) {
5320
+ return `${LOGIN_RECORD_SCOPE}/${providerId}`;
5294
5321
  }
5295
5322
  /**
5296
5323
  * Wait out `ctx.settings`' asynchronous watcher queue after a write.
@@ -5373,46 +5400,45 @@ async function attachSession(host, resumeId) {
5373
5400
  const agent = host.ctx.agents?.get?.(SessionId(id));
5374
5401
  const session = agent?.session;
5375
5402
  const history = [];
5376
- async function computeProviderRows() {
5403
+ function credentialProjectionServices() {
5377
5404
  const settingsSvc = host.ctx.get("settings");
5378
5405
  const credentialsSvc = host.ctx.get("credentials");
5379
5406
  const llmSvc = host.ctx.get("llm");
5380
5407
  if (settingsSvc === void 0 || credentialsSvc === void 0 || llmSvc === void 0) return void 0;
5381
- const configurable = llmSvc.listConfigurableProviders();
5382
- const live = new Set(llmSvc.listProviders().map((provider) => provider.id));
5383
- const descriptors = settingsSvc.describe({ redactSecrets: true });
5384
- const byNs = new Map(descriptors.map((descriptor) => [descriptor.ns, descriptor]));
5385
- const rows = [];
5386
- for (const entry of configurable) {
5387
- const descriptor = byNs.get(entry.settingsNs);
5388
- const value = descriptor === void 0 ? void 0 : getAtPath(descriptor.value, entry.settingsPath);
5389
- const userValue = descriptor === void 0 ? void 0 : getAtPath(descriptor.user, entry.settingsPath);
5390
- const apiKeyRef = value?.apiKeyEnv ?? deriveApiKeyRef(entry.provider);
5391
- const info = await credentialsSvc.describe(apiKeyRef);
5392
- const loginRecord = await credentialsSvc.readRecord?.(piAiRecordKey(entry.provider));
5393
- const authMethod = loginRecord !== void 0 ? loginRecord.kind === "grant" ? "oauth" : "api-key" : info.configured ? "api-key" : void 0;
5394
- const isLive = live.has(entry.provider);
5395
- const models = isLive ? await llmSvc.listModels(entry.provider).catch(() => value?.models ?? []) : value?.models ?? [];
5396
- rows.push({
5397
- route: entry.provider,
5398
- displayName: value?.displayName ?? entry.displayName,
5399
- settingsNs: entry.settingsNs,
5400
- settingsPath: entry.settingsPath,
5401
- configured: userValue !== void 0,
5402
- live: isLive,
5403
- api: value?.api,
5404
- baseURL: value?.baseURL,
5405
- apiKeyRef,
5406
- apiKeyConfigured: info.configured || loginRecord !== void 0,
5407
- authMethod,
5408
- models,
5409
- revision: descriptor?.revision
5410
- });
5411
- }
5412
- return rows;
5408
+ return {
5409
+ settings: settingsSvc,
5410
+ credentials: credentialsSvc,
5411
+ llm: llmSvc
5412
+ };
5413
+ }
5414
+ async function credentialRows() {
5415
+ const services = credentialProjectionServices();
5416
+ if (services === void 0) return void 0;
5417
+ return computeCredentialProjection(services, {
5418
+ deriveApiKeyRef,
5419
+ loginRecordScope: LOGIN_RECORD_SCOPE
5420
+ });
5421
+ }
5422
+ /** Map the shared `CredentialProjectionRow` onto `/model`'s own `ProviderRow` presentation shape. */
5423
+ function toProviderRow(row) {
5424
+ return {
5425
+ route: row.route,
5426
+ displayName: row.displayName,
5427
+ settingsNs: row.settingsNs,
5428
+ settingsPath: row.settingsPath,
5429
+ configured: row.hasSettingsProfile,
5430
+ live: row.isLive,
5431
+ api: row.api,
5432
+ baseURL: row.baseURL,
5433
+ apiKeyRef: row.apiKeyRef,
5434
+ apiKeyConfigured: row.hasCredential,
5435
+ authMethod: row.authMethod,
5436
+ models: row.models,
5437
+ revision: row.revision
5438
+ };
5413
5439
  }
5414
5440
  async function loadProviders() {
5415
- const rows = await computeProviderRows();
5441
+ const rows = await credentialRows();
5416
5442
  if (rows === void 0) {
5417
5443
  store.updateModelProfile({
5418
5444
  providers: [],
@@ -5421,8 +5447,9 @@ async function attachSession(host, resumeId) {
5421
5447
  });
5422
5448
  return;
5423
5449
  }
5450
+ const providers = rows.map(toProviderRow);
5424
5451
  store.updateModelProfile({
5425
- providers: rows,
5452
+ providers,
5426
5453
  busy: false,
5427
5454
  error: void 0,
5428
5455
  selected: 0
@@ -5466,31 +5493,19 @@ async function attachSession(host, resumeId) {
5466
5493
  } });
5467
5494
  })();
5468
5495
  }
5469
- async function ensureProviderActivated(providerId, method) {
5496
+ const routeActivationPort = { async ensureRouteActivated(providerId) {
5470
5497
  const settingsSvc = host.ctx.get("settings");
5471
5498
  const llmSvc = host.ctx.get("llm");
5472
- if (settingsSvc === void 0 || llmSvc === void 0) return false;
5499
+ if (settingsSvc === void 0 || llmSvc === void 0) return;
5473
5500
  const entry = llmSvc.listConfigurableProviders().find((candidate) => candidate.provider === providerId);
5474
- if (entry === void 0) return false;
5501
+ if (entry === void 0) return;
5475
5502
  const descriptor = settingsSvc.describe({ redactSecrets: true }).find((candidate) => candidate.ns === entry.settingsNs);
5476
- const existing = descriptor === void 0 ? void 0 : getAtPath(descriptor.user, entry.settingsPath);
5477
- const oauthName = `${entry.displayName.replace(/(?:-oauth)+$/, "")}-oauth`;
5478
- if (existing !== void 0) {
5479
- if (method === "oauth" && existing.displayName !== oauthName) {
5480
- await settingsSvc.update(entry.settingsNs, nestAtPath(entry.settingsPath, { displayName: oauthName }), descriptor?.revision);
5481
- await flushSettingsWatchers();
5482
- return true;
5483
- }
5484
- return false;
5485
- }
5486
- const section = method === "oauth" ? { displayName: oauthName } : {};
5487
- await settingsSvc.update(entry.settingsNs, nestAtPath(entry.settingsPath, section), descriptor?.revision);
5503
+ if ((descriptor === void 0 ? void 0 : getAtPath(descriptor.user, entry.settingsPath)) !== void 0) return;
5504
+ await settingsSvc.update(entry.settingsNs, nestAtPath(entry.settingsPath, {}), descriptor?.revision);
5488
5505
  await flushSettingsWatchers();
5489
- return true;
5490
- }
5491
- async function loadAuthorizationFlows() {
5506
+ } };
5507
+ async function loadLoginFlows() {
5492
5508
  const authSvc = host.ctx.get("authorization");
5493
- const credentialsSvc = host.ctx.get("credentials");
5494
5509
  if (authSvc === void 0) {
5495
5510
  store.updateLogin({
5496
5511
  flows: [],
@@ -5501,27 +5516,21 @@ async function attachSession(host, resumeId) {
5501
5516
  }
5502
5517
  try {
5503
5518
  const entries = authSvc.list();
5504
- const list = await Promise.all(entries.map(async (entry) => {
5505
- const record = credentialsSvc === void 0 ? void 0 : await credentialsSvc.readRecord?.(entry.key);
5519
+ const rows = await credentialRows();
5520
+ const byKey = new Map((rows ?? []).map((row) => [loginRecordKey(row.route), row]));
5521
+ const list = entries.map((entry) => {
5522
+ const row = byKey.get(entry.key);
5506
5523
  return {
5507
5524
  ...entry,
5508
- configured: record !== void 0,
5509
- authMethod: record === void 0 ? void 0 : record.kind === "grant" ? "oauth" : "api-key"
5525
+ configured: row?.hasCredential ?? false,
5526
+ authMethod: row?.authMethod
5510
5527
  };
5511
- }));
5528
+ });
5512
5529
  store.updateLogin({
5513
5530
  flows: list,
5514
5531
  busy: false,
5515
5532
  error: void 0
5516
5533
  });
5517
- const toRepair = list.filter((flow) => flow.configured && flow.authMethod === "oauth" && flow.key.startsWith("llm-pi-ai/"));
5518
- if (toRepair.length > 0) (async () => {
5519
- let repaired = false;
5520
- for (const flow of toRepair) try {
5521
- if (await ensureProviderActivated(flow.key.slice(10), "oauth")) repaired = true;
5522
- } catch {}
5523
- if (repaired) refreshCredentialState();
5524
- })();
5525
5534
  } catch (error) {
5526
5535
  store.updateLogin({
5527
5536
  busy: false,
@@ -5529,9 +5538,9 @@ async function attachSession(host, resumeId) {
5529
5538
  });
5530
5539
  }
5531
5540
  }
5532
- function refreshCredentialState() {
5541
+ function syncCredentialViews() {
5533
5542
  loadProviders();
5534
- loadAuthorizationFlows();
5543
+ loadLoginFlows();
5535
5544
  }
5536
5545
  /** Open `/model`'s blank custom-provider draft — assumes `/model` is (or is about to become) the open overlay. Shared by `createProvider` (already there) and `addCustomProvider` (getting there first). */
5537
5546
  function openCustomProviderDraft() {
@@ -5716,7 +5725,7 @@ async function attachSession(host, resumeId) {
5716
5725
  },
5717
5726
  login() {
5718
5727
  store.openLogin();
5719
- loadAuthorizationFlows();
5728
+ loadLoginFlows();
5720
5729
  },
5721
5730
  closeLogin() {
5722
5731
  store.closeOverlay();
@@ -5777,16 +5786,18 @@ async function attachSession(host, resumeId) {
5777
5786
  });
5778
5787
  }
5779
5788
  };
5789
+ const authorizationService = new AuthorizationService({
5790
+ authorization: authSvc,
5791
+ routeActivation: routeActivationPort,
5792
+ onCredentialChanged: () => syncCredentialViews()
5793
+ });
5780
5794
  try {
5781
- if ((await authSvc.begin({
5795
+ if ((await authorizationService.begin({
5782
5796
  key,
5783
5797
  method,
5784
5798
  interaction
5785
- })).status === "authorized") {
5786
- store.setNotice(`Signed in to ${flow.label} — credentials saved, ready to use from /model.`);
5787
- if (key.startsWith("llm-pi-ai/")) await ensureProviderActivated(key.slice(10), method);
5788
- refreshCredentialState();
5789
- } else store.updateLogin({ error: "Sign-in cancelled." });
5799
+ })).status === "authorized") store.setNotice(`Signed in to ${flow.label} — credentials saved, ready to use from /model.`);
5800
+ else store.updateLogin({ error: "Sign-in cancelled." });
5790
5801
  } catch (error) {
5791
5802
  store.updateLogin({ error: `Sign-in failed: ${error instanceof Error ? error.message : String(error)}` });
5792
5803
  } finally {
@@ -5831,7 +5842,7 @@ async function attachSession(host, resumeId) {
5831
5842
  },
5832
5843
  backToProviderList() {
5833
5844
  store.updateModelProfile({ view: "list" });
5834
- refreshCredentialState();
5845
+ syncCredentialViews();
5835
5846
  },
5836
5847
  createProvider() {
5837
5848
  openCustomProviderDraft();
@@ -5851,14 +5862,15 @@ async function attachSession(host, resumeId) {
5851
5862
  openProviderEditor(route) {
5852
5863
  (async () => {
5853
5864
  store.openModelProfile();
5854
- const rows = await computeProviderRows();
5865
+ const rows = await credentialRows();
5866
+ const providers = rows?.map(toProviderRow);
5855
5867
  store.updateModelProfile({
5856
- providers: rows ?? [],
5868
+ providers: providers ?? [],
5857
5869
  busy: false,
5858
5870
  error: rows === void 0 ? "Model provider settings are not available in this profile." : void 0,
5859
5871
  selected: 0
5860
5872
  });
5861
- const row = rows?.find((entry) => entry.route === route);
5873
+ const row = providers?.find((entry) => entry.route === route);
5862
5874
  if (row === void 0) {
5863
5875
  store.setNotice(`Provider "${route}" not found.`);
5864
5876
  return;
@@ -5892,7 +5904,7 @@ async function attachSession(host, resumeId) {
5892
5904
  await flushSettingsWatchers();
5893
5905
  store.setNotice(`Saved ${draft.displayName || draft.route} — credentials stored, ready to use from /model.`);
5894
5906
  store.updateModelProfile({ view: "list" });
5895
- refreshCredentialState();
5907
+ syncCredentialViews();
5896
5908
  } catch (error) {
5897
5909
  store.setNotice(`save failed: ${error instanceof Error ? error.message : String(error)}`);
5898
5910
  }
@@ -5911,7 +5923,7 @@ async function attachSession(host, resumeId) {
5911
5923
  await settingsSvc.update(row.settingsNs, nestAtPath(row.settingsPath, {}), row.revision);
5912
5924
  await flushSettingsWatchers();
5913
5925
  store.setNotice(`Removed ${row.displayName}.`);
5914
- refreshCredentialState();
5926
+ syncCredentialViews();
5915
5927
  } catch (error) {
5916
5928
  store.setNotice(`delete failed: ${error instanceof Error ? error.message : String(error)}`);
5917
5929
  }
@@ -5926,10 +5938,10 @@ async function attachSession(host, resumeId) {
5926
5938
  }
5927
5939
  try {
5928
5940
  await credentialsSvc.unset(draft.apiKeyRef);
5929
- await credentialsSvc.deleteRecord?.(piAiRecordKey(draft.route));
5941
+ await credentialsSvc.deleteRecord?.(loginRecordKey(draft.route));
5930
5942
  store.setNotice(`Removed the API key for ${draft.displayName || draft.route}.`);
5931
5943
  store.updateModelProfile({ view: "list" });
5932
- refreshCredentialState();
5944
+ syncCredentialViews();
5933
5945
  } catch (error) {
5934
5946
  store.setNotice(`Could not remove the key: ${error instanceof Error ? error.message : String(error)}`);
5935
5947
  }
@@ -6223,7 +6235,7 @@ async function runAcryl(args, supplied = {}) {
6223
6235
  return;
6224
6236
  }
6225
6237
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
6226
- dependencies.write("acryl-tui: stdin and stdout must both be TTYs; use `acryl tui --json` for a headless probe");
6238
+ dependencies.write("acryl-cli: stdin and stdout must both be TTYs; use `acryl tui --json` for a headless probe");
6227
6239
  dependencies.exit(1);
6228
6240
  return;
6229
6241
  }