pi-advisor-flow 0.11.2 → 0.11.3-dev.1

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,131 +1,45 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
1
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
3
2
  import type { Component, Focusable } from "@earendil-works/pi-tui";
4
- import { noul } from "@typesafe-ai/sdk";
5
3
 
6
- import { jevClientFromCredentials } from "../jev/client.ts";
4
+ import type { JevCredentials, JevTransportKind } from "../jev/transport.ts";
5
+ import { redactSecrets } from "../redaction.ts";
6
+ import { renderJevSetup, setupActions } from "./jev-setup-submenu-view.ts";
7
7
  import {
8
- clearKeyTypeSafeKey,
9
- consumePlaintextKeyWarning,
10
- removeTypeSafeKeyFromAdvisorJson,
11
- writeKeyTypeSafeKey,
12
- } from "../jev/key-store.ts";
13
- import type { JevKeyStoreResult } from "../jev/key-store.ts";
14
- import { resolveJevTransport } from "../jev/transport.ts";
15
- import type { JevCredentials } from "../jev/transport.ts";
8
+ canClearCredential,
9
+ canReplaceWithEnteredKey,
10
+ consumeSetupPlaintextWarning,
11
+ defaultSetupDeps,
12
+ fireAndForget,
13
+ } from "./jev-setup-support.ts";
14
+ import type {
15
+ JevSetupSubmenuOptions,
16
+ JevSetupViewState,
17
+ SetupAction,
18
+ SetupMode,
19
+ } from "./jev-setup-support.ts";
16
20
  import { MaskedInput } from "./masked-input.ts";
17
- import type { RenderRequester } from "./types.ts";
21
+ import type { JevSetupDeps, JevSetupSelection } from "./types.ts";
18
22
 
19
- export interface JevSetupSubmenuOptions {
20
- currentValue: string;
21
- done: (selectedValue?: string) => void;
22
- theme: Theme;
23
- tui: RenderRequester;
24
- }
25
-
26
- export interface JevSetupDeps {
27
- clearStoredKey?: () => Promise<JevKeyStoreResult>;
28
- removePlaintextKey?: () => JevKeyStoreResult;
29
- resolveTransport?: () => Promise<JevCredentials | undefined>;
30
- verify?: (credentials: JevCredentials) => Promise<JevFailureLike>;
31
- writeKey?: (key: string) => Promise<JevKeyStoreResult>;
32
- }
33
-
34
- interface JevFailureLike {
35
- message?: string;
36
- ok: boolean;
37
- }
38
-
39
- type SetupMode = "menu" | "key-entry" | "verifying";
40
- type SetupAction =
41
- | "verify-enable"
42
- | "verify-again"
43
- | "disable"
44
- | "disable-clear"
45
- | "enter-key"
46
- | "done";
47
-
48
- const ACTION_LABELS: Record<SetupAction, string> = {
49
- disable: "Disable",
50
- "disable-clear": "Disable and clear stored key",
51
- done: "Done",
52
- "enter-key": "Enter a TypeSafe API key",
53
- "verify-again": "Verify again",
54
- "verify-enable": "Verify and enable",
55
- };
56
-
57
- const fireAndForget = async (action: Promise<unknown>): Promise<void> => {
58
- try {
59
- await action;
60
- } catch {
61
- // Background setup actions surface their own notices.
62
- }
63
- };
64
-
65
- const transportLabel = (credentials: JevCredentials): string => {
66
- if (credentials.transport === "openrouter") {
67
- return "OpenRouter (reusing pi login)";
68
- }
69
- switch (credentials.source) {
70
- case "advisor-json": {
71
- return "TypeSafe (key: advisor.json — plaintext, not recommended)";
72
- }
73
- case "bun-secrets": {
74
- return "TypeSafe (key: Bun.secrets)";
75
- }
76
- case "file": {
77
- return "TypeSafe (key: stored file, mode 0600)";
78
- }
79
- default: {
80
- return "TypeSafe (key: TYPESAFE_API_KEY)";
81
- }
82
- }
83
- };
84
-
85
- const defaultVerify = async (credentials: JevCredentials) => {
86
- const client = jevClientFromCredentials(credentials);
87
- try {
88
- await client.ask(
89
- { purpose: "pi-advisor setup verification" },
90
- { verified: noul("Answer yes.") }
91
- );
92
- return { ok: true };
93
- } catch (error) {
94
- return {
95
- message: error instanceof Error ? error.message : String(error),
96
- ok: false,
97
- };
98
- }
99
- };
100
-
101
- /** Guided Jev credentials setup: detects a reusable login, verifies live
102
- * before enabling, and never echoes an entered key back. */
103
23
  export class JevSetupSubmenu implements Component, Focusable {
104
24
  private readonly options: JevSetupSubmenuOptions;
105
- private readonly deps: JevSetupDeps;
106
- private readonly maskedInput: MaskedInput;
25
+ private readonly deps: Required<JevSetupDeps>;
26
+ private maskedInput: MaskedInput;
107
27
  private credentials: JevCredentials | undefined;
28
+ private selectedTransport: JevTransportKind | undefined;
108
29
  private mode: SetupMode = "menu";
109
30
  private notice: string | undefined;
110
31
  private selectedIndex = 0;
32
+ private filterEnabled: boolean;
33
+ private canEnterKey = false;
34
+ private pendingSelectionRefresh = false;
111
35
  private _focused = true;
112
36
 
113
37
  constructor(options: JevSetupSubmenuOptions, deps: JevSetupDeps = {}) {
114
38
  this.options = options;
115
- this.deps = {
116
- clearStoredKey: deps.clearStoredKey ?? clearKeyTypeSafeKey,
117
- removePlaintextKey:
118
- deps.removePlaintextKey ?? removeTypeSafeKeyFromAdvisorJson,
119
- resolveTransport: deps.resolveTransport ?? (() => resolveJevTransport()),
120
- verify: deps.verify ?? defaultVerify,
121
- writeKey: deps.writeKey ?? writeKeyTypeSafeKey,
122
- };
123
- this.maskedInput = new MaskedInput({
124
- onEscape: () => this.options.done(),
125
- onSubmit: (value) => this.submitEnteredKey(value),
126
- placeholder: "Paste a TypeSafe API key",
127
- });
128
- void fireAndForget(this.refresh());
39
+ this.filterEnabled = options.currentValue === "On";
40
+ this.deps = { ...defaultSetupDeps, ...options.setupDeps, ...deps };
41
+ this.maskedInput = this.createMaskedInput("Paste a TypeSafe API key");
42
+ void fireAndForget(this.refresh(), this.reportBackgroundError);
129
43
  }
130
44
 
131
45
  get focused(): boolean {
@@ -150,7 +64,7 @@ export class JevSetupSubmenu implements Component, Focusable {
150
64
  if (this.mode !== "menu") {
151
65
  return;
152
66
  }
153
- const actions = this.actions();
67
+ const actions = setupActions(this.viewState());
154
68
  if (
155
69
  matchesKey(keyData, Key.down) ||
156
70
  keyData === "\u001B[B" ||
@@ -173,184 +87,288 @@ export class JevSetupSubmenu implements Component, Focusable {
173
87
  }
174
88
 
175
89
  render(width: number): string[] {
176
- const { theme } = this.options;
177
- const enabled = this.options.currentValue === "On";
178
- const lines = [
179
- theme.fg("accent", theme.bold(" Jev consultation filter")),
180
- "",
181
- ];
182
- if (this.credentials) {
183
- lines.push(
184
- ` Transport: ${transportLabel(this.credentials)}`,
185
- ` Filter: ${enabled ? "On" : "Off"}`
186
- );
187
- } else if (this.mode === "verifying") {
188
- lines.push(" Checking available Jev credentials…");
189
- } else {
190
- lines.push(
191
- " No Jev credentials found. Enter a TypeSafe API key below, or add",
192
- " an OpenRouter login in pi; it is reused automatically."
193
- );
194
- }
195
- if (this.notice) {
196
- lines.push("", theme.fg("warning", ` ${this.notice}`));
197
- }
198
- lines.push("");
199
- if (this.mode === "verifying") {
200
- lines.push(" Verifying with a live Jev call…");
201
- } else if (this.mode === "key-entry") {
202
- lines.push(
203
- ` ${this.maskedInput.render(Math.max(10, width - 4))[0] ?? ""}`,
204
- theme.fg("dim", " Enter: verify · Esc: cancel")
205
- );
206
- } else {
207
- for (const [index, label] of this.labels().entries()) {
208
- const prefix = index === this.selectedIndex ? "→ " : " ";
209
- lines.push(`${prefix}${label}`);
210
- }
211
- }
212
- return lines.map((line) => truncateToWidth(line, width));
90
+ return renderJevSetup(
91
+ this.options,
92
+ this.viewState(),
93
+ this.maskedInput,
94
+ width
95
+ );
213
96
  }
214
97
 
215
- private actions(): SetupAction[] {
216
- if (!this.credentials) {
217
- return this.options.currentValue === "On"
218
- ? ["enter-key", "disable", "done"]
219
- : ["enter-key", "done"];
220
- }
221
- const enabled = this.options.currentValue === "On";
222
- const actions: SetupAction[] = [enabled ? "verify-again" : "verify-enable"];
223
- actions.push("disable");
224
- if (
225
- this.credentials.source === "bun-secrets" ||
226
- this.credentials.source === "file"
227
- ) {
228
- actions.push("disable-clear");
229
- }
230
- actions.push("done");
231
- return actions;
98
+ private viewState(): JevSetupViewState {
99
+ return {
100
+ canEnterKey: this.canEnterKey,
101
+ credentials: this.credentials,
102
+ filterEnabled: this.filterEnabled,
103
+ mode: this.mode,
104
+ notice: this.notice,
105
+ selectedIndex: this.selectedIndex,
106
+ selectedTransport: this.selectedTransport,
107
+ };
232
108
  }
233
109
 
234
- private labels(): string[] {
235
- return this.actions().map((action) => ACTION_LABELS[action]);
110
+ private createMaskedInput(placeholder: string): MaskedInput {
111
+ return new MaskedInput({
112
+ onEscape: () => this.cancelKeyEntry(),
113
+ onSubmit: (value) => this.submitEnteredKey(value),
114
+ placeholder,
115
+ });
236
116
  }
237
117
 
238
118
  private async refresh(): Promise<void> {
239
- const wasVerifying = this.mode === "verifying";
240
- this.mode = "verifying";
241
- if (!wasVerifying) {
119
+ this.mode = "resolving";
120
+ this.options.tui.requestRender();
121
+ try {
122
+ const credentials = await this.deps.resolveTransport(
123
+ this.options.currentTransport === "auto"
124
+ ? undefined
125
+ : this.options.currentTransport
126
+ );
127
+ this.credentials = credentials;
128
+ this.selectedTransport = credentials?.transport;
129
+ this.mode = "menu";
130
+ this.selectedIndex = 0;
131
+ if (credentials?.source === "advisor-json") {
132
+ this.notice = consumeSetupPlaintextWarning();
133
+ }
242
134
  this.options.tui.requestRender();
135
+ } catch (error) {
136
+ this.reportBackgroundError(
137
+ error instanceof Error ? error.message : String(error)
138
+ );
243
139
  }
244
- this.credentials = await this.deps.resolveTransport?.();
245
- this.mode = "menu";
246
- this.selectedIndex = 0;
247
- if (this.credentials?.source === "advisor-json") {
248
- this.notice ??= consumePlaintextKeyWarning();
249
- }
250
- this.options.tui.requestRender();
251
140
  }
252
141
 
253
142
  private activate(action: SetupAction): void {
254
143
  if (action === "done") {
255
- this.options.done();
256
- return;
257
- }
258
- if (action === "enter-key") {
259
- this.mode = "key-entry";
144
+ this.closeSetup();
260
145
  return;
261
146
  }
262
147
  if (action === "disable") {
263
148
  this.notice = undefined;
264
- this.options.done("Off");
149
+ if (
150
+ this.commitSelection(
151
+ {
152
+ enabled: false,
153
+ transport: this.options.currentTransport,
154
+ },
155
+ "Off"
156
+ )
157
+ ) {
158
+ this.filterEnabled = false;
159
+ }
265
160
  return;
266
161
  }
267
- if (action === "disable-clear") {
268
- void fireAndForget(this.disableAndClear());
162
+ if (action === "clear-stored-key") {
163
+ void fireAndForget(this.disableAndClear(), this.reportBackgroundError);
269
164
  return;
270
165
  }
271
- if (action === "verify-again" || action === "verify-enable") {
272
- void fireAndForget(this.verifyAndEnable());
166
+ if (action === "enter-key") {
167
+ if (this.selectedTransport) {
168
+ this.openKeyEntry(this.selectedTransport);
169
+ }
170
+ return;
273
171
  }
172
+ void fireAndForget(this.selectProvider(action), this.reportBackgroundError);
274
173
  }
275
174
 
276
- private async disableAndClear(): Promise<void> {
277
- const clear = this.deps.clearStoredKey;
278
- if (!clear) {
175
+ private reportBackgroundError = (message: string): void => {
176
+ this.mode = "menu";
177
+ this.notice = `Setup failed: ${redactSecrets(message)}`;
178
+ this.options.tui.requestRender();
179
+ };
180
+
181
+ private async selectProvider(transport: JevTransportKind): Promise<void> {
182
+ this.selectedTransport = transport;
183
+ this.mode = "resolving";
184
+ this.notice = undefined;
185
+ this.canEnterKey = false;
186
+ this.options.tui.requestRender();
187
+ let credentials: JevCredentials | undefined;
188
+ try {
189
+ credentials = await this.deps.resolveTransport(transport);
190
+ } catch (error) {
191
+ this.mode = "menu";
192
+ this.notice = `Credential lookup failed: ${redactSecrets(error instanceof Error ? error.message : String(error))}`;
193
+ this.options.tui.requestRender();
279
194
  return;
280
195
  }
281
- const result = await clear();
282
- this.notice = result.message;
283
- if (!result.ok) {
196
+ this.credentials = credentials;
197
+ this.mode = "menu";
198
+ if (!credentials) {
199
+ if (transport === "typesafe" || transport === "openai-decisions") {
200
+ this.notice = undefined;
201
+ this.openKeyEntry(transport);
202
+ } else {
203
+ this.notice = "No OpenRouter Pi login found. Add one in Pi and retry.";
204
+ }
284
205
  this.options.tui.requestRender();
285
206
  return;
286
207
  }
287
- this.credentials = undefined;
288
- this.options.done("Off");
208
+ if (credentials.transport !== transport) {
209
+ this.notice =
210
+ "The selected provider did not resolve matching credentials.";
211
+ this.options.tui.requestRender();
212
+ return;
213
+ }
214
+ await this.verifyAndEnable(credentials);
289
215
  }
290
216
 
291
- private async verifyAndEnable(): Promise<void> {
292
- const { credentials } = this;
293
- if (!(credentials && this.deps.verify)) {
217
+ private openKeyEntry(transport: JevTransportKind): void {
218
+ this.selectedTransport = transport;
219
+ this.mode = "key-entry";
220
+ const placeholder =
221
+ transport === "openai-decisions"
222
+ ? "Paste an OpenAI Platform API key"
223
+ : "Paste a TypeSafe API key";
224
+ this.maskedInput = this.createMaskedInput(placeholder);
225
+ this.maskedInput.focused = this._focused;
226
+ this.options.tui.requestRender();
227
+ }
228
+
229
+ private cancelKeyEntry(): void {
230
+ this.mode = "menu";
231
+ this.selectedTransport = undefined;
232
+ this.maskedInput.setValue("");
233
+ this.options.tui.requestRender();
234
+ }
235
+
236
+ private async submitEnteredKey(value: string): Promise<void> {
237
+ const transport = this.selectedTransport;
238
+ const key = value.trim();
239
+ this.maskedInput.setValue("");
240
+ if (!transport || !key) {
294
241
  return;
295
242
  }
243
+ await this.verifyAndEnable({ apiKey: key, transport }, key);
244
+ }
245
+
246
+ private async verifyAndEnable(
247
+ credentials: JevCredentials,
248
+ enteredKey?: string
249
+ ): Promise<void> {
296
250
  this.mode = "verifying";
297
251
  this.notice = undefined;
298
252
  this.options.tui.requestRender();
299
253
  const outcome = await this.deps.verify(credentials);
300
- this.mode = "menu";
301
254
  if (!outcome.ok) {
302
255
  this.notice = `Verification failed: ${outcome.message ?? "unknown error"}`;
256
+ this.mode = enteredKey ? "key-entry" : "menu";
257
+ this.canEnterKey = !enteredKey && canReplaceWithEnteredKey(credentials);
303
258
  this.options.tui.requestRender();
304
259
  return;
305
260
  }
306
- if (
307
- credentials.transport === "typesafe" &&
261
+ const keyToStore =
262
+ enteredKey ??
263
+ (credentials.transport === "typesafe" &&
308
264
  credentials.source === "advisor-json"
309
- ) {
310
- const stored = await this.deps.writeKey?.(credentials.apiKey);
311
- if (!stored?.ok) {
312
- this.notice =
313
- stored?.message ??
314
- "Storing the key failed; the plaintext advisor.json key keeps working.";
265
+ ? credentials.apiKey
266
+ : undefined);
267
+ if (keyToStore) {
268
+ const stored = await this.deps.writeKey(
269
+ keyToStore,
270
+ credentials.transport
271
+ );
272
+ if (!stored.ok) {
273
+ this.notice = stored.message;
274
+ this.mode = "key-entry";
275
+ this.canEnterKey = false;
315
276
  this.options.tui.requestRender();
316
277
  return;
317
278
  }
318
- const removed = this.deps.removePlaintextKey?.();
319
- this.notice = removed?.ok
320
- ? "Key moved from advisor.json into the secure store."
321
- : `Stored securely, but ${removed?.message ?? "removing the plaintext copy failed; remove it yourself."}`;
279
+ if (
280
+ credentials.transport === "typesafe" &&
281
+ credentials.source === "advisor-json"
282
+ ) {
283
+ const removed = this.deps.removePlaintextKey();
284
+ this.notice = removed.ok
285
+ ? "Key moved from advisor.json into the secure store."
286
+ : `Stored securely, but ${removed.message}`;
287
+ } else {
288
+ this.notice = `${stored.message} Verification succeeded.`;
289
+ }
290
+ }
291
+ this.credentials = credentials;
292
+ const saved = this.commitSelection(
293
+ { enabled: true, transport: credentials.transport },
294
+ "On"
295
+ );
296
+ if (!saved) {
297
+ this.mode = "menu";
298
+ this.notice ??= "Provider verified, but settings could not be saved.";
299
+ this.options.tui.requestRender();
300
+ return;
322
301
  }
323
- this.options.done("On");
302
+ this.filterEnabled = true;
303
+ this.mode = "menu";
304
+ this.canEnterKey = false;
305
+ this.options.tui.requestRender();
324
306
  }
325
307
 
326
- private async submitEnteredKey(value: string): Promise<void> {
327
- const key = value.trim();
328
- if (!key) {
329
- return;
308
+ private commitSelection(
309
+ selection: JevSetupSelection,
310
+ legacyValue: string
311
+ ): boolean {
312
+ if (this.options.onSelection) {
313
+ if (!this.options.onSelection(selection)) {
314
+ return false;
315
+ }
316
+ this.closeSetup(true);
317
+ return true;
318
+ }
319
+ this.options.done(legacyValue);
320
+ return true;
321
+ }
322
+
323
+ private closeSetup(refreshSettings = false): void {
324
+ this.options.done();
325
+ if (refreshSettings || this.pendingSelectionRefresh) {
326
+ this.options.afterSelection?.();
330
327
  }
331
- if (!this.deps.verify) {
328
+ this.pendingSelectionRefresh = false;
329
+ }
330
+
331
+ private async disableAndClear(): Promise<void> {
332
+ const { credentials } = this;
333
+ if (!(credentials && canClearCredential(credentials))) {
332
334
  return;
333
335
  }
334
- this.mode = "verifying";
336
+ if (this.options.onSelection) {
337
+ const saved = this.options.onSelection({
338
+ enabled: false,
339
+ transport: this.options.currentTransport,
340
+ });
341
+ if (!saved) {
342
+ this.mode = "menu";
343
+ this.notice =
344
+ "Could not save the disabled state; the key was not cleared.";
345
+ this.options.tui.requestRender();
346
+ return;
347
+ }
348
+ this.filterEnabled = false;
349
+ this.pendingSelectionRefresh = true;
350
+ }
351
+ this.mode = "clearing";
352
+ this.notice = undefined;
335
353
  this.options.tui.requestRender();
336
- const outcome = await this.deps.verify({
337
- apiKey: key,
338
- transport: "typesafe",
339
- });
354
+ const result = await this.deps.clearStoredKey(credentials.transport);
340
355
  this.mode = "menu";
341
- if (!outcome.ok) {
342
- this.notice = `Verification failed: ${outcome.message ?? "unknown error"}`;
356
+ this.notice = result.message;
357
+ if (!result.ok) {
343
358
  this.options.tui.requestRender();
344
359
  return;
345
360
  }
346
- const stored = await this.deps.writeKey?.(key);
347
- if (!stored?.ok) {
348
- this.notice = stored?.message ?? "Storing the key failed.";
349
- this.options.tui.requestRender();
350
- return;
361
+ this.credentials = undefined;
362
+ if (this.options.onSelection) {
363
+ this.closeSetup();
364
+ } else if (
365
+ this.commitSelection(
366
+ { enabled: false, transport: this.options.currentTransport },
367
+ "Off"
368
+ )
369
+ ) {
370
+ this.filterEnabled = false;
351
371
  }
352
- this.notice = `${stored.message} Verification succeeded.`;
353
- await this.refresh();
354
- this.options.done("On");
372
+ this.options.tui.requestRender();
355
373
  }
356
374
  }