@vincemakes/kiso-code 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.d.ts CHANGED
@@ -8,8 +8,9 @@
8
8
  *
9
9
  * Schema v1:
10
10
  * model?: string — a profile NAME (models.<name>) or "provider/model"
11
- * direct write (provider: "openai-compat" | "anthropic")
12
- * models?: { [name]: { kind: "openai-compat"|"anthropic",
11
+ * direct write (provider: "openai-compat" |
12
+ * "anthropic" | "openai-responses")
13
+ * models?: { [name]: { kind: "openai-compat"|"anthropic"|"openai-responses",
13
14
  * baseUrl?: string, model: string, apiKeyEnv: string } }
14
15
  * mode?: "manual"|"default"|"accept-edits"|"plan"|"bypass"
15
16
  * contextWindow?: number — tokens
@@ -27,7 +28,11 @@
27
28
  * ignored (forward compatibility).
28
29
  */
29
30
  import type { Mode } from "./mode.js";
30
- export type ProfileKind = "openai-compat" | "anthropic";
31
+ /** OR-1: `openai-responses` is a DIALECT, not a vendor — the same kind
32
+ * drives the first-party Responses API and the ChatGPT subscription
33
+ * backend, and which one it is follows the profile's baseUrl (and so
34
+ * the credential it resolves to). */
35
+ export type ProfileKind = "openai-compat" | "anthropic" | "openai-responses";
31
36
  export interface ModelProfile {
32
37
  readonly kind: ProfileKind;
33
38
  readonly baseUrl?: string;
@@ -82,13 +87,44 @@ export declare function loadUserConfig(): KisoConfig | null;
82
87
  export declare function loadProjectConfig(cwd: string, trusted: boolean): KisoConfig | null;
83
88
  /** Merge: project wins over user (each layer's own keys only). */
84
89
  export declare function mergeConfigs(user: KisoConfig | null, project: KisoConfig | null): KisoConfig;
85
- /** A profile is available when its apiKeyEnv var is set (the key exists). */
90
+ /** What a profile's sign-in actually IS. The two shapes are not
91
+ * interchangeable: an API key is a string an adapter holds, an OAuth
92
+ * sign-in is a token that expires and must be re-resolved per request. */
93
+ export type ProfileAuth = {
94
+ readonly type: "api-key";
95
+ readonly apiKey: string;
96
+ readonly source: "store" | "env" | "none";
97
+ } | {
98
+ readonly type: "oauth";
99
+ readonly providerId: string;
100
+ };
101
+ /** Where a profile's sign-in comes from, or why it has none. The sign-in
102
+ * plan's resolve rule: a stored credential OWNS the provider (an
103
+ * unusable stored one is an error, never a silent fall back to env);
104
+ * with nothing stored, the profile's env var applies; a keyless profile
105
+ * (no apiKeyEnv — PH-1c, finding PH-F19) is an unauthenticated endpoint.
106
+ *
107
+ * OR-1: a stored OAuth credential is USABLE only by an adapter that can
108
+ * drive one, which today is the Responses adapter's ChatGPT target. For
109
+ * every other kind it stays the same loud refusal it has always been —
110
+ * the caller is told which sign-in it has and which one it needs. */
111
+ export declare function authForProfile(name: string, p: ModelProfile): ProfileAuth;
112
+ /** The reason a profile is unavailable, for the surfaces that print it. */
113
+ export declare function unavailableReason(name: string, p: ModelProfile): string;
114
+ /** A profile is available when a sign-in resolves for it — of EITHER
115
+ * shape: an OAuth-only profile is available, and calling the key-only
116
+ * resolver here would have declared it broken. */
86
117
  export declare function profileAvailable(p: ModelProfile): boolean;
87
- /** The resolved runtime model — what the adapter is built from. */
118
+ /** The resolved runtime model — what the adapter is built from. Exactly
119
+ * one of the two sign-in fields is set: an OAuth profile has no key to
120
+ * hand over, and the caller must build the adapter from the token thunk
121
+ * instead (the compiler enforces the branch — `apiKey` is optional). */
88
122
  export interface ResolvedModel {
89
123
  readonly name: string;
90
124
  readonly profile: ModelProfile;
91
- readonly apiKey: string;
125
+ readonly apiKey?: string;
126
+ /** OR-1: the provider whose stored OAuth sign-in this profile uses. */
127
+ readonly oauthProviderId?: string;
92
128
  }
93
129
  /** "provider/model" direct write → a profile. */
94
130
  export declare function directWriteProfile(value: string): ModelProfile | null;
package/dist/config.js CHANGED
@@ -8,8 +8,9 @@
8
8
  *
9
9
  * Schema v1:
10
10
  * model?: string — a profile NAME (models.<name>) or "provider/model"
11
- * direct write (provider: "openai-compat" | "anthropic")
12
- * models?: { [name]: { kind: "openai-compat"|"anthropic",
11
+ * direct write (provider: "openai-compat" |
12
+ * "anthropic" | "openai-responses")
13
+ * models?: { [name]: { kind: "openai-compat"|"anthropic"|"openai-responses",
13
14
  * baseUrl?: string, model: string, apiKeyEnv: string } }
14
15
  * mode?: "manual"|"default"|"accept-edits"|"plan"|"bypass"
15
16
  * contextWindow?: number — tokens
@@ -29,7 +30,8 @@
29
30
  import { readFileSync } from "node:fs";
30
31
  import { join } from "node:path";
31
32
  import { kisoHome } from "./state.js";
32
- const KINDS = ["openai-compat", "anthropic"];
33
+ import { AuthError, getCredential, providerIdOf } from "./auth/credentials.js";
34
+ const KINDS = ["openai-compat", "anthropic", "openai-responses"];
33
35
  const MODES_LIST = ["manual", "default", "accept-edits", "plan", "bypass"];
34
36
  export class ConfigError extends Error {
35
37
  }
@@ -177,11 +179,65 @@ export function mergeConfigs(user, project) {
177
179
  ...(u.checks !== undefined || p.checks !== undefined ? { checks: { ...(u.checks ?? {}), ...(p.checks ?? {}) } } : {}),
178
180
  };
179
181
  }
180
- /** A profile is available when its apiKeyEnv var is set (the key exists). */
182
+ /** Where a profile's sign-in comes from, or why it has none. The sign-in
183
+ * plan's resolve rule: a stored credential OWNS the provider (an
184
+ * unusable stored one is an error, never a silent fall back to env);
185
+ * with nothing stored, the profile's env var applies; a keyless profile
186
+ * (no apiKeyEnv — PH-1c, finding PH-F19) is an unauthenticated endpoint.
187
+ *
188
+ * OR-1: a stored OAuth credential is USABLE only by an adapter that can
189
+ * drive one, which today is the Responses adapter's ChatGPT target. For
190
+ * every other kind it stays the same loud refusal it has always been —
191
+ * the caller is told which sign-in it has and which one it needs. */
192
+ export function authForProfile(name, p) {
193
+ const providerId = providerIdOf(p.kind, p.baseUrl);
194
+ if (providerId !== null) {
195
+ let stored;
196
+ try {
197
+ stored = getCredential(providerId);
198
+ }
199
+ catch (err) {
200
+ if (err instanceof AuthError)
201
+ throw new ConfigError(`model ${name}: ${err.message}`);
202
+ throw err;
203
+ }
204
+ if (stored !== undefined) {
205
+ if (stored.type === "api-key")
206
+ return { type: "api-key", apiKey: stored.key, source: "store" };
207
+ if (p.kind === "openai-responses")
208
+ return { type: "oauth", providerId };
209
+ throw new ConfigError(`model ${name}: signed in to ${providerId} with OAuth, but this profile's adapter needs an API key — run \`kiso login ${providerId}\` with a key, or \`kiso logout ${providerId}\` to use the env var ${p.apiKeyEnv ?? "(none configured)"}`);
210
+ }
211
+ }
212
+ if (p.apiKeyEnv === undefined)
213
+ return { type: "api-key", apiKey: "none", source: "none" };
214
+ const fromEnv = process.env[p.apiKeyEnv];
215
+ if (fromEnv !== undefined)
216
+ return { type: "api-key", apiKey: fromEnv, source: "env" };
217
+ const hint = providerId !== null ? `run \`kiso login ${providerId}\` or set the env var ${p.apiKeyEnv}` : `set the env var ${p.apiKeyEnv}`;
218
+ throw new ConfigError(`model ${name}: unavailable — no credential: ${hint} (configs never store keys, only the env-var name)`);
219
+ }
220
+ /** The reason a profile is unavailable, for the surfaces that print it. */
221
+ export function unavailableReason(name, p) {
222
+ try {
223
+ authForProfile(name, p);
224
+ return "";
225
+ }
226
+ catch (err) {
227
+ return err instanceof Error ? err.message : String(err);
228
+ }
229
+ }
230
+ /** A profile is available when a sign-in resolves for it — of EITHER
231
+ * shape: an OAuth-only profile is available, and calling the key-only
232
+ * resolver here would have declared it broken. */
181
233
  export function profileAvailable(p) {
182
- // PH-1c (finding PH-F19): a keyless profile (no apiKeyEnv) is an
183
- // unauthenticated endpoint — always available.
184
- return p.apiKeyEnv === undefined || process.env[p.apiKeyEnv] !== undefined;
234
+ try {
235
+ authForProfile("?", p);
236
+ return true;
237
+ }
238
+ catch {
239
+ return false;
240
+ }
185
241
  }
186
242
  /** "provider/model" direct write → a profile. */
187
243
  export function directWriteProfile(value) {
@@ -189,11 +245,15 @@ export function directWriteProfile(value) {
189
245
  if (slash <= 0 || slash === value.length - 1)
190
246
  return null;
191
247
  const kind = value.slice(0, slash);
192
- if (kind !== "openai-compat" && kind !== "anthropic")
248
+ if (kind !== "openai-compat" && kind !== "anthropic" && kind !== "openai-responses")
193
249
  return null;
194
250
  return {
195
251
  kind,
196
252
  model: value.slice(slash + 1),
253
+ // OR-1: a direct-write `openai-responses/…` names no baseUrl, so it
254
+ // is the FIRST-PARTY target and its env var is OpenAI's. The
255
+ // ChatGPT backend needs a baseUrl and a stored sign-in, which is a
256
+ // configured profile's job, not a one-line direct write.
197
257
  apiKeyEnv: kind === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY",
198
258
  };
199
259
  }
@@ -256,12 +316,10 @@ export function resolveModel(modelFlag, merged) {
256
316
  return null;
257
317
  }
258
318
  function resolveProfile(name, p) {
259
- if (!profileAvailable(p)) {
260
- throw new ConfigError(`model ${name}: unavailable — the env var ${p.apiKeyEnv} is not set (configs never store keys, only the env-var name)`);
261
- }
262
319
  // PH-1c (finding PH-F19): a keyless profile hands the adapter a
263
320
  // placeholder — the SDKs require SOME string; the endpoint ignores it.
264
- return { name, profile: p, apiKey: p.apiKeyEnv === undefined ? "none" : process.env[p.apiKeyEnv] };
321
+ const auth = authForProfile(name, p);
322
+ return auth.type === "oauth" ? { name, profile: p, oauthProviderId: auth.providerId } : { name, profile: p, apiKey: auth.apiKey };
265
323
  }
266
324
  /** Mode: env (KISO_MODE) beats config.mode; the --mode flag is applied by
267
325
  * main before this runs (flags are the top of the chain). */
package/dist/dispatch.js CHANGED
@@ -5,11 +5,37 @@
5
5
  */
6
6
  import { contextRows, contextUnavailableRows, displayVerb, escapeTerminal, helpRows, kUnit, modePickView, modelPickView, palette } from "@vincemakes/kiso-tui";
7
7
  import { newSessionId } from "./session-id.js";
8
- import { buildAdapter, resolveContinuationScope, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
8
+ import { buildAdapter, lookupModelMetadata, resolveContinuationScope, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
9
9
  import { MODES, MODE_NOTE, getMode, setMode } from "./mode.js";
10
10
  import { clipboardWrite, lastAnswer } from "./clipboard.js";
11
11
  import { agentModel, body, bodyLog, configModels, dock, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setLastBinding } from "./state.js";
12
- import { directWriteProfile, profileAvailable } from "./config.js";
12
+ import { authForProfile, directWriteProfile, profileAvailable, unavailableReason } from "./config.js";
13
+ /** The picker's CLI half (owner 2026-09-08): a profile's LEGAL effort levels
14
+ * and its default, from the registry, shown wherever the profile is listed
15
+ * — kiso never silently drops a level, and now it shows the ones it takes.
16
+ * A model the registry does not know shows `unknown` (nothing is guessed);
17
+ * it must not read `none`, which is a REAL level since the Responses rows. */
18
+ function effortNote(p) {
19
+ const reasoning = lookupModelMetadata(p.model, p.baseUrl)?.capabilities.reasoning ?? null;
20
+ const effort = reasoning?.effort ?? null;
21
+ if (effort === null)
22
+ return "effort: unknown";
23
+ return `effort: ${effort.levels.map((l) => (l === effort.default ? `[${l}]` : l)).join(" · ")}`;
24
+ }
25
+ /** What signs a profile in, for the listing: the env var's name, `oauth`
26
+ * for a subscription sign-in, or `no key` for an unauthenticated endpoint. */
27
+ function signInNote(p) {
28
+ try {
29
+ const auth = authForProfile("?", p);
30
+ if (auth.type === "oauth")
31
+ return "oauth";
32
+ }
33
+ catch {
34
+ // unavailable — the availability mark says so; the env name still names what would sign it in
35
+ }
36
+ return p.apiKeyEnv ?? "no key";
37
+ }
38
+ import { oauthTokenThunk } from "./auth/token.js";
13
39
  /** The ONE dispatcher — slash commands, exit, and turns. The recovery
14
40
  * replay routes through it too — a queued "/last" must never become a
15
41
  * user turn (v2c: the rl lives in main, so lines arrive earlier and the
@@ -295,7 +321,7 @@ export function dispatch(line, ctx) {
295
321
  header: `model — current: ${agentModel}`,
296
322
  options: names.map((name) => {
297
323
  const profile = configModels[name];
298
- const marks = [`profile: ${name}`, ...(profileAvailable(profile) ? [] : ["unavailable"]), ...(profile.model === agentModel ? ["current"] : [])];
324
+ const marks = [`profile: ${name}`, ...(profileAvailable(profile) ? [] : ["unavailable"]), ...(profile.model === agentModel ? ["current"] : []), effortNote(profile)];
299
325
  return { label: `${profile.kind}/${profile.model}`, note: marks.join(" · ") };
300
326
  }),
301
327
  // PH-1a (finding PH-F4): the example must be a syntax
@@ -329,7 +355,7 @@ export function dispatch(line, ctx) {
329
355
  else {
330
356
  for (const name of names) {
331
357
  const p = configModels[name];
332
- bodyLog(` ${name} → ${p.kind}/${p.model} · ${p.apiKeyEnv} ${profileAvailable(p) ? "(available)" : "(unavailable)"}`);
358
+ bodyLog(` ${name} → ${p.kind}/${p.model} · ${signInNote(p)} ${profileAvailable(p) ? "(available)" : "(unavailable)"} · ${effortNote(p)}`);
333
359
  }
334
360
  }
335
361
  bodyLog("switch with /model <profile-name|provider/model>");
@@ -347,13 +373,19 @@ export function dispatch(line, ctx) {
347
373
  bodyLog(`no such model profile: ${profName}`);
348
374
  }
349
375
  else if (!profileAvailable(profile)) {
350
- bodyLog(`model ${arg}: unavailable — the env var ${profile.apiKeyEnv} is not set (configs never store keys, only the env-var name)`);
376
+ bodyLog(unavailableReason(arg, profile));
351
377
  }
352
378
  else {
379
+ const auth = authForProfile(profName, profile);
353
380
  const adapter = await buildAdapter(profile.kind, {
354
381
  // PH-1c (PH-F19): a keyless profile = an unauthenticated
355
382
  // endpoint — the placeholder satisfies the SDK's ctor.
356
- apiKey: profile.apiKeyEnv === undefined ? "none" : process.env[profile.apiKeyEnv],
383
+ // OR-1: an OAuth profile passes a token thunk instead
384
+ // — there is no key, and the adapter re-resolves the
385
+ // token per request.
386
+ ...(auth.type === "oauth"
387
+ ? { oauth: oauthTokenThunk(auth.providerId), ...(ctx.session.id !== undefined ? { promptCacheKey: ctx.session.id } : {}) }
388
+ : { apiKey: auth.apiKey }),
357
389
  ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
358
390
  ...(profile.promptCaching !== undefined ? { promptCaching: profile.promptCaching } : {}),
359
391
  });
@@ -370,7 +402,9 @@ export function dispatch(line, ctx) {
370
402
  let refused = null;
371
403
  if (effortTok !== undefined) {
372
404
  const candidate = { thinking: "default", effort: effortTok };
373
- const resolved = resolveReasoning(profile.model, candidate);
405
+ // OR-1: the endpoint narrows the row — the same model id answers
406
+ // differently at the first-party API and at the subscription backend
407
+ const resolved = resolveReasoning(profile.model, candidate, profile.baseUrl);
374
408
  if (resolved.ok)
375
409
  reasoning = candidate;
376
410
  else
@@ -386,12 +420,15 @@ export function dispatch(line, ctx) {
386
420
  adapter,
387
421
  model: profile.model,
388
422
  provider: profile.kind,
423
+ // OR-1: the endpoint is the fourth passenger — the cost
424
+ // path and the window lookup key on (model, endpoint).
425
+ ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
389
426
  ...(scope !== undefined ? { scope } : {}),
390
427
  ...(reasoning !== undefined ? { reasoning } : {}),
391
428
  };
392
429
  ctx.session.setModelBinding(binding);
393
430
  setLastBinding(binding);
394
- setAgentModel(profile.model);
431
+ setAgentModel(profile.model, profile.baseUrl);
395
432
  setCurrentModelName(arg);
396
433
  body.notice(`model → ${profName} (${profile.model}${effortTok !== undefined ? ` · ${effortTok}` : ""}) — takes effect on the next turn`);
397
434
  }
package/dist/index.js CHANGED
@@ -41,6 +41,7 @@ import { isFirstRun, scaffoldFirstRun } from "./first-run.js";
41
41
  import { fauxSkip, readFauxScript } from "./faux-glue.js";
42
42
  import { chat, contextWindowTokens, displayCtxRatio } from "./chat.js";
43
43
  import { loadProjectConfig, loadUserConfig, mergeConfigs, resolveAutoCompact, resolveContextWindow, resolveModel } from "./config.js";
44
+ import { oauthTokenThunk } from "./auth/token.js";
44
45
  import { checkForUpdate } from "./update-check.js";
45
46
  import { resume } from "./resume.js";
46
47
  import { resumeTail } from "./resume-tail.js";
@@ -607,7 +608,7 @@ async function makeAgent(sessionId, input, modelFlag) {
607
608
  setCurrentFaux(false);
608
609
  setCurrentModelName(resolved.name);
609
610
  }
610
- setAgentModel(model); // v2b: the status bar shows it
611
+ setAgentModel(model, resolved?.profile.baseUrl); // v2b: the status bar shows it; OR-1: the endpoint rides along
611
612
  // W21: the extensions array is built ONCE per agent and shared with
612
613
  // the runtime by reference — the don't-ask-again writer pushes the
613
614
  // generated extension into it so a first-time rule joins the chain
@@ -647,13 +648,24 @@ async function makeAgent(sessionId, input, modelFlag) {
647
648
  // field survives for the callers that want a bound (subagents, the
648
649
  // SDK, `kiso run`); the interactive front door does not set one.
649
650
  // Modes: the five tiers join at the CHAIN HEAD, before the user/
650
- // project extensions (the deny>ask>allow composition keeps a user
651
+ // project extensions (the deny>allow>ask composition keeps a user
651
652
  // deny winning over any mode tier — bypass included).
652
653
  extensions,
653
654
  ...(resolved !== null
654
655
  ? {
655
656
  provider: resolved.profile.kind,
656
- apiKey: resolved.apiKey,
657
+ // OR-1: exactly one sign-in shape reaches the adapter. An
658
+ // OAuth profile has no key to pass — it passes the thunk
659
+ // the adapter re-resolves per request instead.
660
+ ...(resolved.oauthProviderId !== undefined
661
+ ? { oauth: oauthTokenThunk(resolved.oauthProviderId) }
662
+ : { apiKey: resolved.apiKey ?? "none" }),
663
+ // OR-1: the ChatGPT backend's cache lane is the SESSION —
664
+ // one conversation's requests share a key, different
665
+ // conversations never do. The one entry point that hands
666
+ // no session id is `kiso sessions`, a read-only listing
667
+ // that streams nothing, so its absence costs no cache.
668
+ ...(sessionId !== undefined ? { promptCacheKey: sessionId } : {}),
657
669
  ...(resolved.profile.baseUrl !== undefined ? { baseUrl: resolved.profile.baseUrl } : {}),
658
670
  ...(resolved.profile.promptCaching !== undefined ? { promptCaching: resolved.profile.promptCaching } : {}),
659
671
  }
@@ -789,7 +801,7 @@ async function chatLoop(agent, firstId, input, autoCompact) {
789
801
  // XP-1 §2.1: the switched-to session's OWN truth repaints the row —
790
802
  // the global display state never outlives the session it described
791
803
  // (pre-XP the row kept the previous session's /model selection).
792
- setAgentModel(session.model);
804
+ setAgentModel(session.model, session.baseUrl);
793
805
  setCurrentModelName(session.model);
794
806
  paintBootStatus(session);
795
807
  const nav = {
@@ -803,6 +815,51 @@ async function chatLoop(agent, firstId, input, autoCompact) {
803
815
  id = end.id;
804
816
  }
805
817
  }
818
+ /** A secret from the terminal: hidden input on a TTY, the whole of stdin
819
+ * otherwise (a pipe, a heredoc) — never an argument, which shell history
820
+ * would keep. */
821
+ async function readSecret(prompt) {
822
+ if (process.stdin.isTTY !== true) {
823
+ return await new Promise((resolve) => {
824
+ let buf = "";
825
+ process.stdin.setEncoding("utf8");
826
+ process.stdin.on("data", (d) => {
827
+ buf += d;
828
+ });
829
+ process.stdin.on("end", () => resolve(buf.split("\n")[0] ?? ""));
830
+ process.stdin.resume();
831
+ });
832
+ }
833
+ process.stdout.write(prompt);
834
+ return await new Promise((resolve) => {
835
+ let buf = "";
836
+ process.stdin.setRawMode(true);
837
+ process.stdin.resume();
838
+ process.stdin.setEncoding("utf8");
839
+ const onData = (chunk) => {
840
+ for (const ch of chunk) {
841
+ if (ch === "\r" || ch === "\n") {
842
+ process.stdin.setRawMode(false);
843
+ process.stdin.pause();
844
+ process.stdin.off("data", onData);
845
+ process.stdout.write("\n");
846
+ resolve(buf);
847
+ return;
848
+ }
849
+ if (ch === "\u0003") {
850
+ process.stdin.setRawMode(false);
851
+ process.stdout.write("\n");
852
+ process.exit(130);
853
+ }
854
+ if (ch === "\u007f" || ch === "\b")
855
+ buf = buf.slice(0, -1);
856
+ else
857
+ buf += ch;
858
+ }
859
+ };
860
+ process.stdin.on("data", onData);
861
+ });
862
+ }
806
863
  async function main() {
807
864
  // E group (the graceful-exit gate ③, R-G 0.1.48): a terminal closing
808
865
  // turns the in-flight stdout/stderr writes into EIO, and node's
@@ -1095,6 +1152,67 @@ async function main() {
1095
1152
  }
1096
1153
  break;
1097
1154
  }
1155
+ case "login":
1156
+ case "logout":
1157
+ case "auth": {
1158
+ // the sign-in plan (2026-09-08), step 1: the credential store.
1159
+ // `login <provider>` reads the key from a hidden prompt on a TTY
1160
+ // or from stdin otherwise (never from an argument — shell history);
1161
+ // `logout <provider>` deletes; `auth` lists, keys masked.
1162
+ const { KNOWN_PROVIDERS, OAUTH_PROVIDERS, authPath, deleteCredential, maskSecret, readAuthFile, setCredential } = await import("./auth/credentials.js");
1163
+ const provider = arg;
1164
+ if (command === "auth") {
1165
+ const file = readAuthFile();
1166
+ const rows = Object.entries(file.credentials);
1167
+ const lines = [`credentials: ${authPath()}${rows.length === 0 ? " (none stored)" : ""}`];
1168
+ for (const [id, cred] of rows) {
1169
+ if (cred.type === "api-key")
1170
+ lines.push(` ${id.padEnd(10)} api-key ${maskSecret(cred.key)} saved ${new Date(cred.savedAt).toISOString().slice(0, 10)}`);
1171
+ else
1172
+ lines.push(` ${id.padEnd(10)} oauth ${cred.accountId ?? ""} expires ${new Date(cred.expires).toISOString()}${cred.expires < Date.now() ? " (expired)" : ""}`);
1173
+ }
1174
+ const envs = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"].filter((k) => process.env[k] !== undefined);
1175
+ lines.push(`env vars set: ${envs.length ? envs.join(", ") : "none"} (a stored credential owns its provider; env applies only when nothing is stored)`);
1176
+ process.stdout.write(`${lines.join("\n")}\n`);
1177
+ break;
1178
+ }
1179
+ if (provider === undefined || !KNOWN_PROVIDERS.includes(provider)) {
1180
+ throw new CliUsageError(`kiso ${command} <provider> — one of: ${KNOWN_PROVIDERS.join(", ")}`);
1181
+ }
1182
+ if (command === "logout") {
1183
+ const had = deleteCredential(provider);
1184
+ process.stdout.write(had ? `signed out of ${provider}\n` : `nothing stored for ${provider}\n`);
1185
+ break;
1186
+ }
1187
+ if (OAUTH_PROVIDERS.includes(provider)) {
1188
+ // the subscription sign-in (plan step 2): the browser flow with a
1189
+ // paste path; the callback host/port can be moved by env for tests
1190
+ const { chatgptFlow } = await import("./auth/oauth/chatgpt.js");
1191
+ const { createInterface } = await import("node:readline");
1192
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1193
+ const cbPort = process.env.KISO_OAUTH_CALLBACK_PORT !== undefined ? Number(process.env.KISO_OAUTH_CALLBACK_PORT) : undefined;
1194
+ try {
1195
+ const cred = await chatgptFlow.login({
1196
+ notify: (line) => process.stdout.write(`${line}\n`),
1197
+ prompt: (q) => new Promise((resolve) => rl.question(q, resolve)),
1198
+ ...(cbPort !== undefined ? { callback: { host: process.env.KISO_OAUTH_CALLBACK_HOST ?? "127.0.0.1", port: cbPort } } : {}),
1199
+ ...(process.env.KISO_OAUTH_TOKEN_URL !== undefined ? { tokenUrl: process.env.KISO_OAUTH_TOKEN_URL } : {}),
1200
+ });
1201
+ setCredential(provider, cred);
1202
+ process.stdout.write(`signed in to ${provider} (${chatgptFlow.label}) — account ${cred.accountId ?? "?"}, expires ${new Date(cred.expires).toISOString()} — stored in ${authPath()}\n`);
1203
+ }
1204
+ finally {
1205
+ rl.close();
1206
+ }
1207
+ break;
1208
+ }
1209
+ const key = (await readSecret(`API key for ${provider}: `)).trim();
1210
+ if (key === "")
1211
+ throw new CliUsageError(`kiso login ${provider}: no key given`);
1212
+ setCredential(provider, { type: "api-key", key, savedAt: Date.now() });
1213
+ process.stdout.write(`signed in to ${provider} with an API key (${maskSecret(key)}) — stored in ${authPath()}\n`);
1214
+ break;
1215
+ }
1098
1216
  case "help": {
1099
1217
  // PH-1b (finding PH-F21): the CLI must be able to describe its
1100
1218
  // own configuration — the old help listed five commands and
@@ -1108,6 +1226,10 @@ async function main() {
1108
1226
  " kiso resume pick a session to continue (TTY picker)\n" +
1109
1227
  " kiso resume <id> [prompt] continue a session (one-shot)\n" +
1110
1228
  " kiso sessions list durable sessions\n" +
1229
+ " kiso login <provider> anthropic|openai|deepseek|zai: store an API key (hidden prompt, or stdin when piped);\n" +
1230
+ " chatgpt: sign in with a ChatGPT subscription (browser; unofficial third-party flow)\n" +
1231
+ " kiso logout <provider> remove the stored credential\n" +
1232
+ " kiso auth list stored credentials (keys masked)\n" +
1111
1233
  " kiso help this help\n\n" +
1112
1234
  "flags (any position):\n" +
1113
1235
  " --model <profile|provider/model> pick the model (also /model in-session)\n" +
package/dist/mode.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * switches take effect immediately. The extension NAME rides the runtime's decidedBy
7
7
  * field: an automated denial records decidedBy: "mode:<name>" — the
8
8
  * audit sell. User-level extensions stay on the chain AFTER the mode
9
- * tiers; a user deny always wins (the chain's deny>ask>allow
9
+ * tiers; a user deny always wins (the chain's deny>allow>ask
10
10
  * monotonicity — bypass cannot override an extension deny).
11
11
  */
12
12
  import type { KisoExtension } from "@vincemakes/kiso-runtime";
@@ -29,7 +29,7 @@ export declare function modeFromEnv(): Mode | undefined;
29
29
  * extension name). The CURRENT tier is first: an all-allow chain records
30
30
  * decidedBy = the FIRST SPEAKER, so an auto-allow under the startup mode
31
31
  * names that mode honestly. Order never affects verdicts — the chain is
32
- * deny>ask>allow over the SPEAKING verdicts (abstain = no opinion), so a
32
+ * deny>allow>ask over the SPEAKING verdicts (abstain = no opinion), so a
33
33
  * user extension's deny wins over any mode tier, bypass included (the
34
34
  * monotonicity e2e pins it). */
35
35
  export declare function modeExtensions(): readonly KisoExtension[];
package/dist/mode.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * switches take effect immediately. The extension NAME rides the runtime's decidedBy
7
7
  * field: an automated denial records decidedBy: "mode:<name>" — the
8
8
  * audit sell. User-level extensions stay on the chain AFTER the mode
9
- * tiers; a user deny always wins (the chain's deny>ask>allow
9
+ * tiers; a user deny always wins (the chain's deny>allow>ask
10
10
  * monotonicity — bypass cannot override an extension deny).
11
11
  */
12
12
  export const MODES = ["manual", "default", "accept-edits", "plan", "bypass"];
@@ -78,7 +78,7 @@ function tierVerdict(tier, call) {
78
78
  * extension name). The CURRENT tier is first: an all-allow chain records
79
79
  * decidedBy = the FIRST SPEAKER, so an auto-allow under the startup mode
80
80
  * names that mode honestly. Order never affects verdicts — the chain is
81
- * deny>ask>allow over the SPEAKING verdicts (abstain = no opinion), so a
81
+ * deny>allow>ask over the SPEAKING verdicts (abstain = no opinion), so a
82
82
  * user extension's deny wins over any mode tier, bypass included (the
83
83
  * monotonicity e2e pins it). */
84
84
  export function modeExtensions() {
package/dist/state.d.ts CHANGED
@@ -156,7 +156,10 @@ export declare function setSessionStore(value: {
156
156
  }): void;
157
157
  /** The model name for the status bar — set by makeAgent. */
158
158
  export declare let agentModel: string;
159
- export declare function setAgentModel(value: string): void;
159
+ /** OR-1: the live model's ENDPOINT, set in the same call as the model so
160
+ * the two can never drift: the window lookup keys on (model, endpoint). */
161
+ export declare let agentBaseUrl: string | undefined;
162
+ export declare function setAgentModel(value: string, baseUrl?: string): void;
160
163
  /** merge round B: whether the agent runs on the faux provider (no real key) —
161
164
  * set inside makeAgent, read by main for chat/resume's exhaustion check. */
162
165
  export declare let currentFaux: boolean;
@@ -213,7 +216,9 @@ export declare const CANCELLED: unique symbol;
213
216
  export interface LastBinding {
214
217
  readonly adapter: import("@vincemakes/kiso-core").Adapter;
215
218
  readonly model: string;
216
- readonly provider?: "anthropic" | "openai-compat";
219
+ readonly provider?: "anthropic" | "openai-compat" | "openai-responses";
220
+ /** OR-1: the endpoint inherits with the adapter (the cost path keys on it). */
221
+ readonly baseUrl?: string;
217
222
  readonly scope?: import("@vincemakes/kiso-core").ContinuationScope;
218
223
  readonly reasoning?: import("@vincemakes/kiso-runtime/internal").ReasoningSetting;
219
224
  }
package/dist/state.js CHANGED
@@ -213,8 +213,12 @@ export function setSessionStore(value) {
213
213
  }
214
214
  /** The model name for the status bar — set by makeAgent. */
215
215
  export let agentModel = "faux";
216
- export function setAgentModel(value) {
216
+ /** OR-1: the live model's ENDPOINT, set in the same call as the model so
217
+ * the two can never drift: the window lookup keys on (model, endpoint). */
218
+ export let agentBaseUrl;
219
+ export function setAgentModel(value, baseUrl) {
217
220
  agentModel = value;
221
+ agentBaseUrl = baseUrl;
218
222
  }
219
223
  /** merge round B: whether the agent runs on the faux provider (no real key) —
220
224
  * set inside makeAgent, read by main for chat/resume's exhaustion check. */
@@ -37,6 +37,19 @@ export declare let pendingAsk: (() => void) | null;
37
37
  export declare function askPanel(input: LineInput, view: PanelView, opts?: {
38
38
  safer?: () => Promise<SaferAnswer>;
39
39
  }): Promise<PanelVerdict>;
40
+ /**
41
+ * The dock-less fallback line → a verdict. `y…` allows. `n <reason>` / `no
42
+ * <reason>` DENIES WITH THE REASON — the words become the tool result and
43
+ * the run continues with the model seeing them, the same verdict the panel's
44
+ * "No + words" produces (chat.ts, the deny case). A bare `n`/`no`, or any
45
+ * other line, is the bare denial that aborts the run — unchanged. (LH-1 ruling
46
+ * 2026-09-08: the default refusal across arms is refuse-with-a-reason-and-
47
+ * continue, and the surface a piped or 0-row leg drives is this one.)
48
+ */
49
+ export declare function parseFallbackAnswer(answer: string): {
50
+ action: "allow" | "deny";
51
+ reason: string;
52
+ };
40
53
  /**
41
54
  * KC3.5 — the AskUI bridge: the panel the ask extension asks through.
42
55
  *
package/dist/trust-ui.js CHANGED
@@ -104,12 +104,27 @@ opts) {
104
104
  }
105
105
  settled = true;
106
106
  pendingAsk = null;
107
- const yes = answer.trim().toLowerCase().startsWith("y");
108
- resolve(yes ? { action: "allow", reason: "" } : { action: "deny", reason: "" });
107
+ resolve(parseFallbackAnswer(answer));
109
108
  });
110
109
  }
111
110
  });
112
111
  }
112
+ /**
113
+ * The dock-less fallback line → a verdict. `y…` allows. `n <reason>` / `no
114
+ * <reason>` DENIES WITH THE REASON — the words become the tool result and
115
+ * the run continues with the model seeing them, the same verdict the panel's
116
+ * "No + words" produces (chat.ts, the deny case). A bare `n`/`no`, or any
117
+ * other line, is the bare denial that aborts the run — unchanged. (LH-1 ruling
118
+ * 2026-09-08: the default refusal across arms is refuse-with-a-reason-and-
119
+ * continue, and the surface a piped or 0-row leg drives is this one.)
120
+ */
121
+ export function parseFallbackAnswer(answer) {
122
+ const line = answer.trim();
123
+ if (line.toLowerCase().startsWith("y"))
124
+ return { action: "allow", reason: "" };
125
+ const m = /^(?:n|no)\b[\s:,-]*(.*)$/is.exec(line);
126
+ return { action: "deny", reason: (m?.[1] ?? "").trim() };
127
+ }
113
128
  /**
114
129
  * KC3.5 — the AskUI bridge: the panel the ask extension asks through.
115
130
  *