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.
@@ -2,6 +2,7 @@ import { createRequire } from "node:module";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { bootAcrylHarnessProfile, createAcrylSessionBridge } from "acryl-harness-runtime";
4
4
  import { execFile, spawn } from "node:child_process";
5
+ import { AuthorizationService, computeCredentialProjection } from "acryl-control";
5
6
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
6
7
  import { diffLines } from "diff";
7
8
  import { Container, Editor, HStack, Key, KeybindingsManager, ProcessTerminal, ScrollView, TUI_KEYBINDINGS, Text, TuiAltScreen, VStack, fuzzyFilter, matchesKey, setKeybindings, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
@@ -1121,7 +1122,7 @@ const ACRYL_MARK_ROWS = [
1121
1122
  " ▀ "
1122
1123
  ];
1123
1124
  /** ISO timestamp this build was produced at. */
1124
- const BUILD_TIME = "2026-09-08T14:33:49.586Z";
1125
+ const BUILD_TIME = "2026-09-08T18:16:12.207Z";
1125
1126
  //#endregion
1126
1127
  //#region src/tui/statsFormat.ts
1127
1128
  /**
@@ -2959,14 +2960,42 @@ function renderMiniTextField(state, cursorVisible, mask) {
2959
2960
  return `${display.slice(0, state.cursor)}\x1b[7m${display[state.cursor] ?? " "}\x1b[0m${display.slice(state.cursor + 1)}`;
2960
2961
  }
2961
2962
  //#endregion
2962
- //#region src/tui/modelProfile/types.ts
2963
+ //#region src/tui/listWindow.ts
2964
+ /**
2965
+ * Scrollable-list windowing shared by every overlay that paints a
2966
+ * selection-following list (`/login`'s provider list, `/model`'s picker and
2967
+ * provider list). Previously duplicated verbatim in `LoginOverlay.ts` and
2968
+ * `ModelProfileOverlay.ts` (specs/001-acryl-refactor-improvements-and-tech-debt, R7).
2969
+ * @module @tomowang/dsh-tui/tui/listWindow
2970
+ */
2963
2971
  /**
2964
- * Data shapes for the `/model` provider-profile overlay: the read model that
2965
- * joins `ctx.llm`'s provider directory with `ctx.settings`' stored sections,
2966
- * the raw shape stored at each provider's settings path, and the mutable
2967
- * draft one add/edit form works with before a save round-trips it back.
2968
- * @module @tomowang/dsh-tui/tui/modelProfile/types
2972
+ * Rows available for a scrollable list body: terminal height minus the lines
2973
+ * every such screen spends on chrome (header/hint/notice/search — `chrome`
2974
+ * lines, caller-counted since it varies by screen). Long catalogs (~35
2975
+ * providers, matching that many models) previously rendered every row
2976
+ * unconditionally, pushing the key-legend hint line — the only place a
2977
+ * shortcut is documented — past the bottom of the terminal, invisible
2978
+ * without scrolling back. Every list-shaped view windows around the current
2979
+ * selection instead, so the hint line is always the last line printed and
2980
+ * always fits on screen.
2969
2981
  */
2982
+ function listWindow(terminalRows, chrome) {
2983
+ return Math.max(3, terminalRows - chrome);
2984
+ }
2985
+ /** The `[start, end)` slice of `count` items to show so `selected` stays visible within `maxVisible` rows, biased to keep it centered. */
2986
+ function visibleRange(count, selected, maxVisible) {
2987
+ if (count <= maxVisible) return {
2988
+ start: 0,
2989
+ end: count
2990
+ };
2991
+ const start = Math.max(0, Math.min(selected - Math.floor(maxVisible / 2), count - maxVisible));
2992
+ return {
2993
+ start,
2994
+ end: start + maxVisible
2995
+ };
2996
+ }
2997
+ //#endregion
2998
+ //#region src/tui/modelProfile/types.ts
2970
2999
  /**
2971
3000
  * The `apiKeyEnv` reference a provider route falls back to when its settings
2972
3001
  * profile names none, e.g. `my-proxy` -> `MY_PROXY_API_KEY`. Shared between
@@ -3039,32 +3068,6 @@ var ModelProfileOverlay = class {
3039
3068
  }
3040
3069
  invalidate() {}
3041
3070
  /**
3042
- * Rows available for a scrollable list body: terminal height minus the
3043
- * lines every such screen spends on chrome (header/hint/notice/search —
3044
- * `chrome` lines, caller-counted since it varies by screen). Long catalogs
3045
- * (~35 providers, matching that many models) previously rendered every row
3046
- * unconditionally, pushing the key-legend hint line — the only place a
3047
- * shortcut is documented — past the bottom of the terminal, invisible
3048
- * without scrolling back. Every list-shaped view here windows around the
3049
- * current selection instead, so the hint line is always the last line
3050
- * printed and always fits on screen.
3051
- */
3052
- listWindow(chrome) {
3053
- return Math.max(3, this.tui.terminal.rows - chrome);
3054
- }
3055
- /** The `[start, end)` slice of `count` items to show so `selected` stays visible within `maxVisible` rows, biased to keep it centered. */
3056
- visibleRange(count, selected, maxVisible) {
3057
- if (count <= maxVisible) return {
3058
- start: 0,
3059
- end: count
3060
- };
3061
- const start = Math.max(0, Math.min(selected - Math.floor(maxVisible / 2), count - maxVisible));
3062
- return {
3063
- start,
3064
- end: start + maxVisible
3065
- };
3066
- }
3067
- /**
3068
3071
  * The global one-line notice (`store.setNotice`), rendered inline here.
3069
3072
  * `/model` runs as a full-screen overlay that paints over the whole
3070
3073
  * terminal, so the notice dock underneath — where "Saved X."/"Active model
@@ -3164,8 +3167,8 @@ var ModelProfileOverlay = class {
3164
3167
  const all = this.pickerItems(mp);
3165
3168
  const filtered = this.filteredPickerItems(mp);
3166
3169
  const chrome = lines.length + 2;
3167
- const maxVisible = this.listWindow(chrome);
3168
- const { start, end } = this.visibleRange(filtered.length, this.modelPickerCursor, maxVisible);
3170
+ const maxVisible = listWindow(this.tui.terminal.rows, chrome);
3171
+ const { start, end } = visibleRange(filtered.length, this.modelPickerCursor, maxVisible);
3169
3172
  filtered.slice(start, end).forEach((item, offset) => {
3170
3173
  const isSelected = start + offset === this.modelPickerCursor;
3171
3174
  const via = item.authMethod === "oauth" ? " oauth" : item.authMethod === "api-key" ? " api" : "";
@@ -3224,8 +3227,8 @@ var ModelProfileOverlay = class {
3224
3227
  if (busy && providers === void 0) lines.push(muted$10("Loading…"));
3225
3228
  const filtered = this.filteredProviders(mp);
3226
3229
  const chrome = lines.length + 2;
3227
- const maxVisible = this.listWindow(chrome);
3228
- const { start, end } = this.visibleRange(filtered.length, this.providerCursor, maxVisible);
3230
+ const maxVisible = listWindow(this.tui.terminal.rows, chrome);
3231
+ const { start, end } = visibleRange(filtered.length, this.providerCursor, maxVisible);
3229
3232
  filtered.slice(start, end).forEach((row, offset) => {
3230
3233
  const index = start + offset;
3231
3234
  const marker = row.configured ? "● " : "○ ";
@@ -3490,13 +3493,10 @@ var LoginOverlay = class {
3490
3493
  tui;
3491
3494
  store;
3492
3495
  actions;
3493
- step = "authType";
3494
- authType;
3495
- authTypeCursor = 0;
3496
- chooserSkipped = false;
3497
- autoSkipChecked = false;
3498
- listCursor = 0;
3499
- searchQuery = emptyMiniTextField();
3496
+ view = {
3497
+ kind: "authType",
3498
+ cursor: 0
3499
+ };
3500
3500
  promptField = emptyMiniTextField();
3501
3501
  promptCursor = 0;
3502
3502
  constructor(tui, store, actions) {
@@ -3505,22 +3505,6 @@ var LoginOverlay = class {
3505
3505
  this.actions = actions;
3506
3506
  }
3507
3507
  invalidate() {}
3508
- /** 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. */
3509
- listWindow(chrome) {
3510
- return Math.max(3, this.tui.terminal.rows - chrome);
3511
- }
3512
- /** The `[start, end)` slice of `count` items to show so `selected` stays visible within `maxVisible` rows, biased to keep it centered. */
3513
- visibleRange(count, selected, maxVisible) {
3514
- if (count <= maxVisible) return {
3515
- start: 0,
3516
- end: count
3517
- };
3518
- const start = Math.max(0, Math.min(selected - Math.floor(maxVisible / 2), count - maxVisible));
3519
- return {
3520
- start,
3521
- end: start + maxVisible
3522
- };
3523
- }
3524
3508
  /**
3525
3509
  * The global one-line notice (`store.setNotice`), rendered inline here.
3526
3510
  * `/login` runs as a full-screen overlay that paints over the whole
@@ -3534,56 +3518,80 @@ var LoginOverlay = class {
3534
3518
  const notice = this.store.getSnapshot().notice;
3535
3519
  return notice === void 0 ? [] : [muted$9(notice)];
3536
3520
  }
3537
- /** 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. */
3538
- maybeAutoSkipChooser(login) {
3539
- if (this.autoSkipChecked || login.flows === void 0) return;
3540
- this.autoSkipChecked = true;
3521
+ /**
3522
+ * Pure: given the loaded flows, which single method (if any) they all
3523
+ * share the chooser has nothing to offer when only one method type is
3524
+ * registered at all. Recomputed fresh every call (no cached/latched
3525
+ * result), so a later-refreshed flow set is always re-evaluated instead of
3526
+ * replaying a stale decision (specs/001-…, R6).
3527
+ */
3528
+ autoSkipAuthType(login) {
3529
+ if (login.flows === void 0) return void 0;
3541
3530
  const hasOAuth = login.flows.some((flow) => flow.methods.some((method) => method.id === "oauth"));
3542
- if (hasOAuth === login.flows.some((flow) => flow.methods.some((method) => method.id === "api-key"))) return;
3543
- this.authType = hasOAuth ? "oauth" : "api-key";
3544
- this.step = "list";
3545
- this.chooserSkipped = true;
3531
+ return hasOAuth === login.flows.some((flow) => flow.methods.some((method) => method.id === "api-key")) ? void 0 : hasOAuth ? "oauth" : "api-key";
3546
3532
  }
3547
- filteredFlows(login) {
3533
+ /**
3534
+ * Pure: the view to actually render/operate against. `this.view` stays the
3535
+ * untouched `authType` default until the user explicitly chooses (Enter on
3536
+ * the chooser) — until then, this derives the auto-skipped `list` view on
3537
+ * every call instead of mutating `this.view` from inside `render()`. The
3538
+ * first `handleInput` mutation against a derived (not-yet-explicit) `list`
3539
+ * view reassigns `this.view` directly (see `handleProviderListInput`), so
3540
+ * cursor/search state persists across renders once the user interacts.
3541
+ */
3542
+ effectiveView(login) {
3543
+ if (this.view.kind === "authType") {
3544
+ const auto = this.autoSkipAuthType(login);
3545
+ if (auto !== void 0) return {
3546
+ kind: "list",
3547
+ authType: auto,
3548
+ chooserSkipped: true,
3549
+ cursor: 0,
3550
+ searchQuery: emptyMiniTextField()
3551
+ };
3552
+ }
3553
+ return this.view;
3554
+ }
3555
+ filteredFlows(login, view) {
3548
3556
  const all = login.flows ?? [];
3549
- const byType = this.authType === void 0 ? all : all.filter((flow) => flow.methods.some((method) => method.id === this.authType));
3550
- const query = this.searchQuery.value.trim();
3557
+ const byType = view.authType === void 0 ? all : all.filter((flow) => flow.methods.some((method) => method.id === view.authType));
3558
+ const query = view.searchQuery.value.trim();
3551
3559
  return query === "" ? byType : fuzzyFilter([...byType], query, (flow) => flow.label);
3552
3560
  }
3553
- renderAuthTypeChooser(login) {
3561
+ renderAuthTypeChooser(login, view) {
3554
3562
  const lines = [bold$8(secondary$8("Select authentication method"))];
3555
3563
  lines.push(...this.noticeLines());
3556
3564
  if (login.error !== void 0) lines.push(errorColor$4(login.error));
3557
3565
  if (login.busy && login.flows === void 0) lines.push(muted$9("Loading…"));
3558
3566
  AUTH_TYPES.forEach((type, index) => {
3559
- const text = `${index === this.authTypeCursor ? "› " : " "}${AUTH_TYPE_LABELS[type]}`;
3560
- lines.push(index === this.authTypeCursor ? invert$4(text) : text);
3567
+ const text = `${index === view.cursor ? "› " : " "}${AUTH_TYPE_LABELS[type]}`;
3568
+ lines.push(index === view.cursor ? invert$4(text) : text);
3561
3569
  });
3562
3570
  lines.push(muted$9("↑↓ select · enter continue · esc close"));
3563
3571
  return lines;
3564
3572
  }
3565
- renderProviderList(login) {
3573
+ renderProviderList(login, view) {
3566
3574
  const lines = [bold$8(secondary$8("Select provider to configure:"))];
3567
3575
  lines.push(...this.noticeLines());
3568
3576
  if (login.error !== void 0) lines.push(errorColor$4(login.error));
3569
- lines.push(`> ${renderMiniTextField(this.searchQuery, true)}`);
3577
+ lines.push(`> ${renderMiniTextField(view.searchQuery, true)}`);
3570
3578
  if (login.busy && login.flows === void 0) lines.push(muted$9("Loading…"));
3571
- const flows = this.filteredFlows(login);
3579
+ const flows = this.filteredFlows(login, view);
3572
3580
  const chrome = lines.length + 2;
3573
- const maxVisible = this.listWindow(chrome);
3574
- const { start, end } = this.visibleRange(flows.length, this.listCursor, maxVisible);
3581
+ const maxVisible = listWindow(this.tui.terminal.rows, chrome);
3582
+ const { start, end } = visibleRange(flows.length, view.cursor, maxVisible);
3575
3583
  flows.slice(start, end).forEach((flow, offset) => {
3576
3584
  const index = start + offset;
3577
3585
  const marker = flow.inFlight ? "· " : flow.configured ? "✓ " : "○ ";
3578
3586
  const signingIn = login.signingIn === flow.key ? " — signing in…" : "";
3579
3587
  const via = flow.authMethod === "oauth" ? " [oauth]" : flow.authMethod === "api-key" ? " [api]" : "";
3580
3588
  const readyNote = login.signingIn !== flow.key && flow.configured ? ` — ready to use${via}` : "";
3581
- const text = `${index === this.listCursor ? "› " : " "}${marker}${flow.label}${signingIn}${readyNote}`;
3582
- lines.push(index === this.listCursor ? invert$4(text) : flow.configured ? successColor(text) : text);
3589
+ const text = `${index === view.cursor ? "› " : " "}${marker}${flow.label}${signingIn}${readyNote}`;
3590
+ lines.push(index === view.cursor ? invert$4(text) : flow.configured ? successColor(text) : text);
3583
3591
  });
3584
- if (flows.length > maxVisible) lines.push(muted$9(`(${this.listCursor + 1}/${flows.length})`));
3592
+ if (flows.length > maxVisible) lines.push(muted$9(`(${view.cursor + 1}/${flows.length})`));
3585
3593
  if (login.flows !== void 0 && flows.length === 0) lines.push(muted$9("No matching providers."));
3586
- const back = this.chooserSkipped ? "esc close" : "esc back";
3594
+ const back = view.chooserSkipped ? "esc close" : "esc back";
3587
3595
  lines.push(muted$9(`type to search · ↑↓ select · enter sign in (or edit key/models if already configured) · ctrl+p add custom provider · ${back}`));
3588
3596
  return lines;
3589
3597
  }
@@ -3614,8 +3622,8 @@ var LoginOverlay = class {
3614
3622
  if (overlay.kind !== "login") return [];
3615
3623
  const { login } = overlay;
3616
3624
  if (login.prompt !== void 0) return this.renderPrompt(login.prompt);
3617
- this.maybeAutoSkipChooser(login);
3618
- return this.step === "authType" ? this.renderAuthTypeChooser(login) : this.renderProviderList(login);
3625
+ const view = this.effectiveView(login);
3626
+ return view.kind === "authType" ? this.renderAuthTypeChooser(login, view) : this.renderProviderList(login, view);
3619
3627
  }
3620
3628
  handleInput(data) {
3621
3629
  const overlay = this.store.getSnapshot().overlay;
@@ -3625,8 +3633,9 @@ var LoginOverlay = class {
3625
3633
  this.handlePromptInput(data, login.prompt);
3626
3634
  return;
3627
3635
  }
3628
- if (this.step === "authType") this.handleAuthTypeChooserInput(data);
3629
- else this.handleProviderListInput(data, login);
3636
+ const view = this.effectiveView(login);
3637
+ if (view.kind === "authType") this.handleAuthTypeChooserInput(data, view);
3638
+ else this.handleProviderListInput(data, login, view);
3630
3639
  }
3631
3640
  handlePromptInput(data, prompt) {
3632
3641
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
@@ -3652,65 +3661,79 @@ var LoginOverlay = class {
3652
3661
  const next = miniTextFieldInput(this.promptField, data);
3653
3662
  if (next !== void 0) this.promptField = next;
3654
3663
  }
3655
- handleAuthTypeChooserInput(data) {
3664
+ handleAuthTypeChooserInput(data, view) {
3656
3665
  if (matchesKey(data, Key.escape)) {
3657
3666
  this.actions.closeLogin();
3658
3667
  return;
3659
3668
  }
3660
3669
  if (matchesKey(data, Key.up)) {
3661
- this.authTypeCursor = Math.max(0, this.authTypeCursor - 1);
3670
+ this.view = {
3671
+ kind: "authType",
3672
+ cursor: Math.max(0, view.cursor - 1)
3673
+ };
3662
3674
  return;
3663
3675
  }
3664
3676
  if (matchesKey(data, Key.down)) {
3665
- this.authTypeCursor = Math.min(AUTH_TYPES.length - 1, this.authTypeCursor + 1);
3677
+ this.view = {
3678
+ kind: "authType",
3679
+ cursor: Math.min(AUTH_TYPES.length - 1, view.cursor + 1)
3680
+ };
3666
3681
  return;
3667
3682
  }
3668
- if (matchesKey(data, Key.enter)) {
3669
- this.authType = AUTH_TYPES[this.authTypeCursor];
3670
- this.step = "list";
3671
- this.listCursor = 0;
3672
- this.searchQuery = emptyMiniTextField();
3673
- }
3683
+ if (matchesKey(data, Key.enter)) this.view = {
3684
+ kind: "list",
3685
+ authType: AUTH_TYPES[view.cursor],
3686
+ chooserSkipped: false,
3687
+ cursor: 0,
3688
+ searchQuery: emptyMiniTextField()
3689
+ };
3674
3690
  }
3675
- handleProviderListInput(data, login) {
3691
+ handleProviderListInput(data, login, view) {
3676
3692
  if (matchesKey(data, Key.escape)) {
3677
- if (this.chooserSkipped) this.actions.closeLogin();
3678
- else {
3679
- this.step = "authType";
3680
- this.searchQuery = emptyMiniTextField();
3681
- }
3693
+ if (view.chooserSkipped) this.actions.closeLogin();
3694
+ else this.view = {
3695
+ kind: "authType",
3696
+ cursor: 0
3697
+ };
3682
3698
  return;
3683
3699
  }
3684
3700
  if (matchesKey(data, Key.ctrl("p"))) {
3685
3701
  this.actions.addCustomProvider();
3686
3702
  return;
3687
3703
  }
3688
- const flows = this.filteredFlows(login);
3704
+ const flows = this.filteredFlows(login, view);
3689
3705
  if (matchesKey(data, Key.up)) {
3690
- this.listCursor = Math.max(0, this.listCursor - 1);
3706
+ this.view = {
3707
+ ...view,
3708
+ cursor: Math.max(0, view.cursor - 1)
3709
+ };
3691
3710
  return;
3692
3711
  }
3693
3712
  if (matchesKey(data, Key.down)) {
3694
- this.listCursor = Math.min(Math.max(0, flows.length - 1), this.listCursor + 1);
3713
+ this.view = {
3714
+ ...view,
3715
+ cursor: Math.min(Math.max(0, flows.length - 1), view.cursor + 1)
3716
+ };
3695
3717
  return;
3696
3718
  }
3697
3719
  if (matchesKey(data, Key.ctrl("e"))) {
3698
- const flow = flows[this.listCursor];
3720
+ const flow = flows[view.cursor];
3699
3721
  if (flow !== void 0 && flow.configured && flow.key.startsWith("llm-pi-ai/")) this.actions.openProviderEditor(flow.key.slice(10));
3700
3722
  return;
3701
3723
  }
3702
3724
  if (matchesKey(data, Key.enter)) {
3703
- const flow = flows[this.listCursor];
3725
+ const flow = flows[view.cursor];
3704
3726
  if (flow === void 0) return;
3705
3727
  if (flow.configured && flow.key.startsWith("llm-pi-ai/")) this.actions.openProviderEditor(flow.key.slice(10));
3706
- else this.actions.beginAuthorization(flow.key, this.authType);
3728
+ else this.actions.beginAuthorization(flow.key, view.authType);
3707
3729
  return;
3708
3730
  }
3709
- const next = miniTextFieldInput(this.searchQuery, data);
3710
- if (next !== void 0) {
3711
- this.searchQuery = next;
3712
- this.listCursor = 0;
3713
- }
3731
+ const next = miniTextFieldInput(view.searchQuery, data);
3732
+ if (next !== void 0) this.view = {
3733
+ ...view,
3734
+ searchQuery: next,
3735
+ cursor: 0
3736
+ };
3714
3737
  }
3715
3738
  };
3716
3739
  //#endregion
@@ -5037,7 +5060,7 @@ var TuiApp = class {
5037
5060
  gap: 1,
5038
5061
  align: "start"
5039
5062
  });
5040
- const buildStamp = `build bf7e1ab · ${(/* @__PURE__ */ new Date(BUILD_TIME)).toLocaleTimeString()}`;
5063
+ const buildStamp = `build a980b38 · ${(/* @__PURE__ */ new Date(BUILD_TIME)).toLocaleTimeString()}`;
5041
5064
  const sessionInfo = new DynamicText(() => {
5042
5065
  const { provider, model, cwd } = options;
5043
5066
  return [
@@ -5276,6 +5299,8 @@ async function walkDirectory(cwd) {
5276
5299
  }
5277
5300
  return results;
5278
5301
  }
5302
+ /** Canonical ACRYL version string used by the CLI and the release smoke checks. */
5303
+ const ACRYL_VERSION = createRequire(import.meta.url)("../package.json").version;
5279
5304
  //#endregion
5280
5305
  //#region src/tui-app/session.ts
5281
5306
  /**
@@ -5288,7 +5313,9 @@ async function walkDirectory(cwd) {
5288
5313
  * TUI shows the same capabilities the web surface does. `/clear` flushes the
5289
5314
  * current session and re-attaches a fresh one (durable history stays on disk).
5290
5315
  */
5291
- const TUI_VERSION = createRequire(import.meta.url)("../package.json").version;
5316
+ /** The credential-record scope `dsh-llm-pi-ai` writes a `/login` sign-in under (`credentialKey('llm-pi-ai', providerId)`). */
5317
+ const LOGIN_RECORD_SCOPE = "llm-pi-ai";
5318
+ const TUI_VERSION = ACRYL_VERSION;
5292
5319
  const PROMPT_HISTORY_LIMIT = 200;
5293
5320
  function failUnknown(status) {
5294
5321
  return status === "running" ? "running" : "idle";
@@ -5330,14 +5357,16 @@ function getAtPath(value, path) {
5330
5357
  return current;
5331
5358
  }
5332
5359
  /**
5333
- * `dsh-llm-pi-ai`'s own credential record scope (`credentialKey('llm-pi-ai', providerId)`,
5334
- * i.e. `"llm-pi-ai/<providerId>"`). A `/login` sign-in (OAuth or API key) writes here through
5335
- * pi-ai's own `CredentialStore`, entirely separate from the `apiKeyEnv` reference a manually
5336
- * configured provider's settings profile points at so a provider's "has credentials" status
5337
- * has to check both places, and this is the address for the first.
5360
+ * `dsh-llm-pi-ai`'s own credential record key under `LOGIN_RECORD_SCOPE`
5361
+ * (`credentialKey('llm-pi-ai', providerId)`, i.e. `"llm-pi-ai/<providerId>"`).
5362
+ * A `/login` sign-in (OAuth or API key) writes here through pi-ai's own
5363
+ * `CredentialStore`, entirely separate from the `apiKeyEnv` reference a
5364
+ * manually configured provider's settings profile points at.
5365
+ * `acryl-control`'s `CredentialProjection` computes this internally for the
5366
+ * read side; only `clearApiKey`'s explicit delete still needs it directly.
5338
5367
  */
5339
- function piAiRecordKey(providerId) {
5340
- return `llm-pi-ai/${providerId}`;
5368
+ function loginRecordKey(providerId) {
5369
+ return `${LOGIN_RECORD_SCOPE}/${providerId}`;
5341
5370
  }
5342
5371
  /**
5343
5372
  * Wait out `ctx.settings`' asynchronous watcher queue after a write.
@@ -5420,46 +5449,45 @@ async function attachSession(host, resumeId) {
5420
5449
  const agent = host.ctx.agents?.get?.(SessionId(id));
5421
5450
  const session = agent?.session;
5422
5451
  const history = [];
5423
- async function computeProviderRows() {
5452
+ function credentialProjectionServices() {
5424
5453
  const settingsSvc = host.ctx.get("settings");
5425
5454
  const credentialsSvc = host.ctx.get("credentials");
5426
5455
  const llmSvc = host.ctx.get("llm");
5427
5456
  if (settingsSvc === void 0 || credentialsSvc === void 0 || llmSvc === void 0) return void 0;
5428
- const configurable = llmSvc.listConfigurableProviders();
5429
- const live = new Set(llmSvc.listProviders().map((provider) => provider.id));
5430
- const descriptors = settingsSvc.describe({ redactSecrets: true });
5431
- const byNs = new Map(descriptors.map((descriptor) => [descriptor.ns, descriptor]));
5432
- const rows = [];
5433
- for (const entry of configurable) {
5434
- const descriptor = byNs.get(entry.settingsNs);
5435
- const value = descriptor === void 0 ? void 0 : getAtPath(descriptor.value, entry.settingsPath);
5436
- const userValue = descriptor === void 0 ? void 0 : getAtPath(descriptor.user, entry.settingsPath);
5437
- const apiKeyRef = value?.apiKeyEnv ?? deriveApiKeyRef(entry.provider);
5438
- const info = await credentialsSvc.describe(apiKeyRef);
5439
- const loginRecord = await credentialsSvc.readRecord?.(piAiRecordKey(entry.provider));
5440
- const authMethod = loginRecord !== void 0 ? loginRecord.kind === "grant" ? "oauth" : "api-key" : info.configured ? "api-key" : void 0;
5441
- const isLive = live.has(entry.provider);
5442
- const models = isLive ? await llmSvc.listModels(entry.provider).catch(() => value?.models ?? []) : value?.models ?? [];
5443
- rows.push({
5444
- route: entry.provider,
5445
- displayName: value?.displayName ?? entry.displayName,
5446
- settingsNs: entry.settingsNs,
5447
- settingsPath: entry.settingsPath,
5448
- configured: userValue !== void 0,
5449
- live: isLive,
5450
- api: value?.api,
5451
- baseURL: value?.baseURL,
5452
- apiKeyRef,
5453
- apiKeyConfigured: info.configured || loginRecord !== void 0,
5454
- authMethod,
5455
- models,
5456
- revision: descriptor?.revision
5457
- });
5458
- }
5459
- return rows;
5457
+ return {
5458
+ settings: settingsSvc,
5459
+ credentials: credentialsSvc,
5460
+ llm: llmSvc
5461
+ };
5462
+ }
5463
+ async function credentialRows() {
5464
+ const services = credentialProjectionServices();
5465
+ if (services === void 0) return void 0;
5466
+ return computeCredentialProjection(services, {
5467
+ deriveApiKeyRef,
5468
+ loginRecordScope: LOGIN_RECORD_SCOPE
5469
+ });
5470
+ }
5471
+ /** Map the shared `CredentialProjectionRow` onto `/model`'s own `ProviderRow` presentation shape. */
5472
+ function toProviderRow(row) {
5473
+ return {
5474
+ route: row.route,
5475
+ displayName: row.displayName,
5476
+ settingsNs: row.settingsNs,
5477
+ settingsPath: row.settingsPath,
5478
+ configured: row.hasSettingsProfile,
5479
+ live: row.isLive,
5480
+ api: row.api,
5481
+ baseURL: row.baseURL,
5482
+ apiKeyRef: row.apiKeyRef,
5483
+ apiKeyConfigured: row.hasCredential,
5484
+ authMethod: row.authMethod,
5485
+ models: row.models,
5486
+ revision: row.revision
5487
+ };
5460
5488
  }
5461
5489
  async function loadProviders() {
5462
- const rows = await computeProviderRows();
5490
+ const rows = await credentialRows();
5463
5491
  if (rows === void 0) {
5464
5492
  store.updateModelProfile({
5465
5493
  providers: [],
@@ -5468,8 +5496,9 @@ async function attachSession(host, resumeId) {
5468
5496
  });
5469
5497
  return;
5470
5498
  }
5499
+ const providers = rows.map(toProviderRow);
5471
5500
  store.updateModelProfile({
5472
- providers: rows,
5501
+ providers,
5473
5502
  busy: false,
5474
5503
  error: void 0,
5475
5504
  selected: 0
@@ -5513,31 +5542,19 @@ async function attachSession(host, resumeId) {
5513
5542
  } });
5514
5543
  })();
5515
5544
  }
5516
- async function ensureProviderActivated(providerId, method) {
5545
+ const routeActivationPort = { async ensureRouteActivated(providerId) {
5517
5546
  const settingsSvc = host.ctx.get("settings");
5518
5547
  const llmSvc = host.ctx.get("llm");
5519
- if (settingsSvc === void 0 || llmSvc === void 0) return false;
5548
+ if (settingsSvc === void 0 || llmSvc === void 0) return;
5520
5549
  const entry = llmSvc.listConfigurableProviders().find((candidate) => candidate.provider === providerId);
5521
- if (entry === void 0) return false;
5550
+ if (entry === void 0) return;
5522
5551
  const descriptor = settingsSvc.describe({ redactSecrets: true }).find((candidate) => candidate.ns === entry.settingsNs);
5523
- const existing = descriptor === void 0 ? void 0 : getAtPath(descriptor.user, entry.settingsPath);
5524
- const oauthName = `${entry.displayName.replace(/(?:-oauth)+$/, "")}-oauth`;
5525
- if (existing !== void 0) {
5526
- if (method === "oauth" && existing.displayName !== oauthName) {
5527
- await settingsSvc.update(entry.settingsNs, nestAtPath(entry.settingsPath, { displayName: oauthName }), descriptor?.revision);
5528
- await flushSettingsWatchers();
5529
- return true;
5530
- }
5531
- return false;
5532
- }
5533
- const section = method === "oauth" ? { displayName: oauthName } : {};
5534
- await settingsSvc.update(entry.settingsNs, nestAtPath(entry.settingsPath, section), descriptor?.revision);
5552
+ if ((descriptor === void 0 ? void 0 : getAtPath(descriptor.user, entry.settingsPath)) !== void 0) return;
5553
+ await settingsSvc.update(entry.settingsNs, nestAtPath(entry.settingsPath, {}), descriptor?.revision);
5535
5554
  await flushSettingsWatchers();
5536
- return true;
5537
- }
5538
- async function loadAuthorizationFlows() {
5555
+ } };
5556
+ async function loadLoginFlows() {
5539
5557
  const authSvc = host.ctx.get("authorization");
5540
- const credentialsSvc = host.ctx.get("credentials");
5541
5558
  if (authSvc === void 0) {
5542
5559
  store.updateLogin({
5543
5560
  flows: [],
@@ -5548,27 +5565,21 @@ async function attachSession(host, resumeId) {
5548
5565
  }
5549
5566
  try {
5550
5567
  const entries = authSvc.list();
5551
- const list = await Promise.all(entries.map(async (entry) => {
5552
- const record = credentialsSvc === void 0 ? void 0 : await credentialsSvc.readRecord?.(entry.key);
5568
+ const rows = await credentialRows();
5569
+ const byKey = new Map((rows ?? []).map((row) => [loginRecordKey(row.route), row]));
5570
+ const list = entries.map((entry) => {
5571
+ const row = byKey.get(entry.key);
5553
5572
  return {
5554
5573
  ...entry,
5555
- configured: record !== void 0,
5556
- authMethod: record === void 0 ? void 0 : record.kind === "grant" ? "oauth" : "api-key"
5574
+ configured: row?.hasCredential ?? false,
5575
+ authMethod: row?.authMethod
5557
5576
  };
5558
- }));
5577
+ });
5559
5578
  store.updateLogin({
5560
5579
  flows: list,
5561
5580
  busy: false,
5562
5581
  error: void 0
5563
5582
  });
5564
- const toRepair = list.filter((flow) => flow.configured && flow.authMethod === "oauth" && flow.key.startsWith("llm-pi-ai/"));
5565
- if (toRepair.length > 0) (async () => {
5566
- let repaired = false;
5567
- for (const flow of toRepair) try {
5568
- if (await ensureProviderActivated(flow.key.slice(10), "oauth")) repaired = true;
5569
- } catch {}
5570
- if (repaired) refreshCredentialState();
5571
- })();
5572
5583
  } catch (error) {
5573
5584
  store.updateLogin({
5574
5585
  busy: false,
@@ -5576,9 +5587,9 @@ async function attachSession(host, resumeId) {
5576
5587
  });
5577
5588
  }
5578
5589
  }
5579
- function refreshCredentialState() {
5590
+ function syncCredentialViews() {
5580
5591
  loadProviders();
5581
- loadAuthorizationFlows();
5592
+ loadLoginFlows();
5582
5593
  }
5583
5594
  /** 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). */
5584
5595
  function openCustomProviderDraft() {
@@ -5763,7 +5774,7 @@ async function attachSession(host, resumeId) {
5763
5774
  },
5764
5775
  login() {
5765
5776
  store.openLogin();
5766
- loadAuthorizationFlows();
5777
+ loadLoginFlows();
5767
5778
  },
5768
5779
  closeLogin() {
5769
5780
  store.closeOverlay();
@@ -5824,16 +5835,18 @@ async function attachSession(host, resumeId) {
5824
5835
  });
5825
5836
  }
5826
5837
  };
5838
+ const authorizationService = new AuthorizationService({
5839
+ authorization: authSvc,
5840
+ routeActivation: routeActivationPort,
5841
+ onCredentialChanged: () => syncCredentialViews()
5842
+ });
5827
5843
  try {
5828
- if ((await authSvc.begin({
5844
+ if ((await authorizationService.begin({
5829
5845
  key,
5830
5846
  method,
5831
5847
  interaction
5832
- })).status === "authorized") {
5833
- store.setNotice(`Signed in to ${flow.label} — credentials saved, ready to use from /model.`);
5834
- if (key.startsWith("llm-pi-ai/")) await ensureProviderActivated(key.slice(10), method);
5835
- refreshCredentialState();
5836
- } else store.updateLogin({ error: "Sign-in cancelled." });
5848
+ })).status === "authorized") store.setNotice(`Signed in to ${flow.label} — credentials saved, ready to use from /model.`);
5849
+ else store.updateLogin({ error: "Sign-in cancelled." });
5837
5850
  } catch (error) {
5838
5851
  store.updateLogin({ error: `Sign-in failed: ${error instanceof Error ? error.message : String(error)}` });
5839
5852
  } finally {
@@ -5878,7 +5891,7 @@ async function attachSession(host, resumeId) {
5878
5891
  },
5879
5892
  backToProviderList() {
5880
5893
  store.updateModelProfile({ view: "list" });
5881
- refreshCredentialState();
5894
+ syncCredentialViews();
5882
5895
  },
5883
5896
  createProvider() {
5884
5897
  openCustomProviderDraft();
@@ -5898,14 +5911,15 @@ async function attachSession(host, resumeId) {
5898
5911
  openProviderEditor(route) {
5899
5912
  (async () => {
5900
5913
  store.openModelProfile();
5901
- const rows = await computeProviderRows();
5914
+ const rows = await credentialRows();
5915
+ const providers = rows?.map(toProviderRow);
5902
5916
  store.updateModelProfile({
5903
- providers: rows ?? [],
5917
+ providers: providers ?? [],
5904
5918
  busy: false,
5905
5919
  error: rows === void 0 ? "Model provider settings are not available in this profile." : void 0,
5906
5920
  selected: 0
5907
5921
  });
5908
- const row = rows?.find((entry) => entry.route === route);
5922
+ const row = providers?.find((entry) => entry.route === route);
5909
5923
  if (row === void 0) {
5910
5924
  store.setNotice(`Provider "${route}" not found.`);
5911
5925
  return;
@@ -5939,7 +5953,7 @@ async function attachSession(host, resumeId) {
5939
5953
  await flushSettingsWatchers();
5940
5954
  store.setNotice(`Saved ${draft.displayName || draft.route} — credentials stored, ready to use from /model.`);
5941
5955
  store.updateModelProfile({ view: "list" });
5942
- refreshCredentialState();
5956
+ syncCredentialViews();
5943
5957
  } catch (error) {
5944
5958
  store.setNotice(`save failed: ${error instanceof Error ? error.message : String(error)}`);
5945
5959
  }
@@ -5958,7 +5972,7 @@ async function attachSession(host, resumeId) {
5958
5972
  await settingsSvc.update(row.settingsNs, nestAtPath(row.settingsPath, {}), row.revision);
5959
5973
  await flushSettingsWatchers();
5960
5974
  store.setNotice(`Removed ${row.displayName}.`);
5961
- refreshCredentialState();
5975
+ syncCredentialViews();
5962
5976
  } catch (error) {
5963
5977
  store.setNotice(`delete failed: ${error instanceof Error ? error.message : String(error)}`);
5964
5978
  }
@@ -5973,10 +5987,10 @@ async function attachSession(host, resumeId) {
5973
5987
  }
5974
5988
  try {
5975
5989
  await credentialsSvc.unset(draft.apiKeyRef);
5976
- await credentialsSvc.deleteRecord?.(piAiRecordKey(draft.route));
5990
+ await credentialsSvc.deleteRecord?.(loginRecordKey(draft.route));
5977
5991
  store.setNotice(`Removed the API key for ${draft.displayName || draft.route}.`);
5978
5992
  store.updateModelProfile({ view: "list" });
5979
- refreshCredentialState();
5993
+ syncCredentialViews();
5980
5994
  } catch (error) {
5981
5995
  store.setNotice(`Could not remove the key: ${error instanceof Error ? error.message : String(error)}`);
5982
5996
  }