klyro 0.1.47 → 0.1.48

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.
@@ -16,6 +16,9 @@
16
16
  * The result is wrapped in retryingAdapter by default. Pass
17
17
  * { retry: false } to skip.
18
18
  */
19
+ import * as fsSync from 'node:fs';
20
+ import * as os from 'node:os';
21
+ import * as path from 'node:path';
19
22
  import { httpChatAdapter } from './provider-adapter.js';
20
23
  import { anthropicAdapter } from './anthropic-adapter.js';
21
24
  import { retryingAdapter } from './retry.js';
@@ -63,9 +66,57 @@ function normalizeProviderName(name) {
63
66
  return lower;
64
67
  return PROVIDER_ALIASES[lower];
65
68
  }
69
+ /**
70
+ * Persisted provider settings (sync read — for CLIs that must not go async).
71
+ * Env still wins; ~/.klyro/settings.json + credentials are the fallback so
72
+ * `klyro run` works in fresh terminals with zero env vars.
73
+ *
74
+ * Reads the files directly (no module imports) to avoid any import cycle
75
+ * between agent/ and cli/ layers.
76
+ */
77
+ function persistedProviderSettings() {
78
+ try {
79
+ const home = process.env.KLYRO_CONFIG_DIR ?? (os.homedir() || process.cwd());
80
+ const cfgPaths = process.env.KLYRO_CONFIG
81
+ ? [process.env.KLYRO_CONFIG]
82
+ : [path.join(home, '.klyro', 'settings.json'), path.join(home, '.klyro', 'config.json')];
83
+ let cfg = {};
84
+ for (const p of cfgPaths) {
85
+ try {
86
+ const parsed = JSON.parse(fsSync.readFileSync(p, 'utf-8'));
87
+ if (parsed && typeof parsed === 'object') {
88
+ cfg = parsed;
89
+ break;
90
+ }
91
+ }
92
+ catch {
93
+ continue;
94
+ }
95
+ }
96
+ const credFile = process.env.KLYRO_CREDENTIALS_FILE ?? path.join(home, '.klyro', 'credentials.json');
97
+ let creds = {};
98
+ try {
99
+ creds = JSON.parse(fsSync.readFileSync(credFile, 'utf-8'));
100
+ }
101
+ catch { /* none */ }
102
+ const keyOf = (p) => typeof creds[p] === 'string' && creds[p].length > 0 ? creds[p] : undefined;
103
+ const baseURL = (cfg.baseUrl ?? cfg.baseURL);
104
+ const provider = cfg.provider;
105
+ const model = (cfg.model ?? cfg['model.default']);
106
+ const apiKey = (cfg.apiKey ?? cfg.api_key) ??
107
+ (provider ? keyOf(provider) : undefined) ??
108
+ keyOf('openai') ??
109
+ keyOf('anthropic');
110
+ return { baseURL, apiKey, provider, model };
111
+ }
112
+ catch {
113
+ return {};
114
+ }
115
+ }
66
116
  export function buildProvider(opts = {}) {
67
- const baseURL = opts.baseURL ?? process.env.KLYRO_BASE_URL;
68
- const apiKey = opts.apiKey ?? process.env.KLYRO_API_KEY;
117
+ const saved = persistedProviderSettings();
118
+ const baseURL = opts.baseURL ?? process.env.KLYRO_BASE_URL ?? saved.baseURL;
119
+ const apiKey = opts.apiKey ?? process.env.KLYRO_API_KEY ?? saved.apiKey;
69
120
  const timeoutMs = opts.timeoutMs ?? 60_000;
70
121
  // Resolve provider (with aliases like 9router -> openrouter -> openai)
71
122
  let provider;
@@ -2,6 +2,20 @@
2
2
  * 2.2 — klyro login / logout / aliases
3
3
  * Stores masked key → ~/.klyro/credentials.json 0600
4
4
  */
5
+ export declare function credPath(): string;
6
+ /** Persist one provider key (0600). Never logs or returns the key. */
7
+ export declare function saveKey(provider: string, key: string): Promise<void>;
8
+ /** Which providers have stored keys (names only — never values). */
9
+ export declare function storedProviders(): string[];
10
+ export declare const LOGIN_DEFAULTS: Record<string, {
11
+ baseUrl: string;
12
+ model: string;
13
+ }>;
14
+ /**
15
+ * `klyro login` — full one-time setup. Persists provider + base URL + model
16
+ * to ~/.klyro/settings.json and the key to ~/.klyro/credentials.json (0600),
17
+ * so every future terminal picks them up with no env vars.
18
+ */
5
19
  export declare function runLogin(): Promise<number>;
6
20
  export declare function runLogout(provider?: string): Promise<number>;
7
21
  export declare function getStoredKey(provider: string): string | undefined;
package/dist/cli/auth.js CHANGED
@@ -3,37 +3,82 @@
3
3
  * Stores masked key → ~/.klyro/credentials.json 0600
4
4
  */
5
5
  import * as fs from 'node:fs/promises';
6
+ import * as fsSync from 'node:fs';
6
7
  import * as path from 'node:path';
7
8
  import * as os from 'node:os';
8
9
  import * as readline from 'node:readline/promises';
9
10
  import { stdin, stdout } from 'node:process';
10
- function credPath() {
11
+ export function credPath() {
12
+ // KLYRO_CREDENTIALS_FILE override exists for tests; real users get ~/.klyro/credentials.json.
13
+ if (process.env.KLYRO_CREDENTIALS_FILE)
14
+ return process.env.KLYRO_CREDENTIALS_FILE;
11
15
  const home = os.homedir() || process.cwd();
12
16
  return path.join(home, '.klyro', 'credentials.json');
13
17
  }
18
+ /** Persist one provider key (0600). Never logs or returns the key. */
19
+ export async function saveKey(provider, key) {
20
+ const creds = {};
21
+ try {
22
+ const raw = await fs.readFile(credPath(), 'utf-8');
23
+ Object.assign(creds, JSON.parse(raw));
24
+ }
25
+ catch { /* ignore */ }
26
+ creds[provider] = key.trim();
27
+ await fs.mkdir(path.dirname(credPath()), { recursive: true });
28
+ await fs.writeFile(credPath(), JSON.stringify(creds, null, 2), { mode: 0o600 });
29
+ try {
30
+ await fs.chmod(credPath(), 0o600);
31
+ }
32
+ catch { /* ignore on Windows */ }
33
+ }
34
+ /** Which providers have stored keys (names only — never values). */
35
+ export function storedProviders() {
36
+ try {
37
+ const raw = fsSync.readFileSync(credPath(), 'utf-8');
38
+ const creds = JSON.parse(raw);
39
+ return Object.keys(creds).filter((k) => typeof creds[k] === 'string' && creds[k].length > 0);
40
+ }
41
+ catch {
42
+ return [];
43
+ }
44
+ }
45
+ export const LOGIN_DEFAULTS = {
46
+ openai: { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
47
+ anthropic: { baseUrl: 'https://api.anthropic.com/v1', model: 'claude-3-5-sonnet-20240620' },
48
+ local: { baseUrl: 'http://localhost:11434/v1', model: 'llama3.2' },
49
+ };
50
+ /**
51
+ * `klyro login` — full one-time setup. Persists provider + base URL + model
52
+ * to ~/.klyro/settings.json and the key to ~/.klyro/credentials.json (0600),
53
+ * so every future terminal picks them up with no env vars.
54
+ */
14
55
  export async function runLogin() {
15
56
  const rl = readline.createInterface({ input: stdin, output: stdout });
16
57
  try {
17
- const provider = (await rl.question('Provider (anthropic/openai) [openai]: ')) || 'openai';
18
- const key = await rl.question('API key (input hidden, paste): ');
19
- if (!key.trim()) {
58
+ const providerRaw = (await rl.question('Provider (openai/anthropic/local) [openai]: ')) || 'openai';
59
+ const provider = providerRaw.trim().toLowerCase();
60
+ const defs = LOGIN_DEFAULTS[provider] ?? LOGIN_DEFAULTS.openai;
61
+ const keyPrompt = provider === 'local' ? 'API key (empty for local Ollama): ' : 'API key (paste): ';
62
+ const key = await rl.question(keyPrompt);
63
+ if (!key.trim() && provider !== 'local') {
20
64
  process.stderr.write('No key provided\n');
21
65
  return 2;
22
66
  }
23
- const creds = {};
24
- try {
25
- const raw = await fs.readFile(credPath(), 'utf-8');
26
- Object.assign(creds, JSON.parse(raw));
27
- }
28
- catch { /* ignore */ }
29
- creds[provider] = key.trim();
30
- await fs.mkdir(path.dirname(credPath()), { recursive: true });
31
- await fs.writeFile(credPath(), JSON.stringify(creds, null, 2), { mode: 0o600 });
32
- try {
33
- await fs.chmod(credPath(), 0o600);
67
+ const baseUrl = ((await rl.question(`Base URL [${defs.baseUrl}]: `)) || defs.baseUrl).trim();
68
+ const model = ((await rl.question(`Model [${defs.model}]: `)) || defs.model).trim();
69
+ const storeProvider = provider === 'local' ? 'openai' : provider;
70
+ if (key.trim()) {
71
+ await saveKey(storeProvider, key);
72
+ process.stdout.write(`Saved ${storeProvider} key to ${credPath()} (0600)\n`);
34
73
  }
35
- catch { /* ignore on Windows */ }
36
- process.stdout.write(`Saved ${provider} key to ${credPath()} (0600)\n`);
74
+ // Persist non-secret settings (merged with existing config, never clobbers).
75
+ const { loadConfig, saveConfig } = await import('./config.js');
76
+ const cfg = await loadConfig();
77
+ cfg.provider = storeProvider;
78
+ cfg.baseUrl = baseUrl;
79
+ cfg.model = model;
80
+ await saveConfig(cfg);
81
+ process.stdout.write(`Saved provider settings (applies to all terminals — no env vars needed)\n`);
37
82
  return 0;
38
83
  }
39
84
  finally {
@@ -63,9 +108,10 @@ export async function runLogout(provider) {
63
108
  }
64
109
  export function getStoredKey(provider) {
65
110
  try {
66
- const raw = require('node:fs').readFileSync(credPath(), 'utf-8');
111
+ const raw = fsSync.readFileSync(credPath(), 'utf-8');
67
112
  const creds = JSON.parse(raw);
68
- return creds[provider];
113
+ const v = creds[provider];
114
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
69
115
  }
70
116
  catch {
71
117
  return undefined;
@@ -7,8 +7,8 @@ import { z } from 'zod';
7
7
  export declare const ConfigSchema: z.ZodObject<{
8
8
  model: z.ZodOptional<z.ZodString>;
9
9
  provider: z.ZodOptional<z.ZodEnum<{
10
- anthropic: "anthropic";
11
10
  openai: "openai";
11
+ anthropic: "anthropic";
12
12
  }>>;
13
13
  baseUrl: z.ZodOptional<z.ZodString>;
14
14
  apiKey: z.ZodOptional<z.ZodString>;
@@ -31,6 +31,7 @@ declare function setByPath(obj: Record<string, unknown>, dotted: string, value:
31
31
  declare function deleteByPath(obj: Record<string, unknown>, dotted: string): boolean;
32
32
  declare function parseValue(raw: string): unknown;
33
33
  export declare function loadConfig(): Promise<Record<string, unknown>>;
34
+ export declare function loadConfigSync(): Record<string, unknown>;
34
35
  export declare function loadMergedConfig(cwd?: string, flags?: Record<string, unknown>): Promise<Record<string, unknown>>;
35
36
  export declare function saveConfig(obj: Record<string, unknown>): Promise<void>;
36
37
  export declare function runConfig(args: string[]): Promise<number>;
@@ -217,6 +217,26 @@ export async function loadConfig() {
217
217
  }
218
218
  return {};
219
219
  }
220
+ // --- Synchronous single-file load (for sync call sites like buildProvider) ---
221
+ export function loadConfigSync() {
222
+ const files = [getConfigPath(), getLegacyConfigPath()];
223
+ for (const file of files) {
224
+ try {
225
+ const raw = fsSync.readFileSync(file, 'utf-8');
226
+ const obj = parseJsonc(raw, file);
227
+ return validateConfig(obj, file);
228
+ }
229
+ catch (err) {
230
+ const e = err;
231
+ if (e.code === 'ENOENT')
232
+ continue;
233
+ // Corrupt config must not crash provider resolution — ignore here
234
+ // (klyro config/doctor surface the error properly).
235
+ return {};
236
+ }
237
+ }
238
+ return {};
239
+ }
220
240
  // --- Merged load with 5-layer precedence ---
221
241
  export async function loadMergedConfig(cwd = process.cwd(), flags = {}) {
222
242
  const layers = [];
package/dist/cli/repl.js CHANGED
@@ -6,6 +6,8 @@
6
6
  * via the global hooks installed by App.useEffect.
7
7
  */
8
8
  import React from 'react';
9
+ import * as fsSync from 'node:fs';
10
+ import * as nodePath from 'node:path';
9
11
  import { render } from 'ink';
10
12
  import { App } from '../tui/app.js';
11
13
  import { httpChatAdapter } from '../agent/provider-adapter.js';
@@ -28,16 +30,35 @@ export async function startRepl(opts = {}) {
28
30
  // Reuse the same provider resolution as legacy repl.ts — probes local
29
31
  // Ollama / LM Studio / vLLM when env is not fully set, so bare `klyro`
30
32
  // works with a local model just like `klyro chat` does.
31
- const resolved = await resolveProvider();
33
+ let resolved = await resolveProvider();
32
34
  if (!resolved) {
33
- process.stderr.write('klyro: no provider available.\n');
34
- process.stderr.write(` ${providerHelp(null)}\n`);
35
- process.stderr.write(' Set KLYRO_BASE_URL and KLYRO_API_KEY, or run a local server (Ollama, LM Studio, vLLM).\n');
36
- process.stderr.write(' Examples:\n');
37
- process.stderr.write(' set KLYRO_BASE_URL=https://api.openai.com/v1\n');
38
- process.stderr.write(' set KLYRO_API_KEY=sk-...\n');
39
- process.stderr.write(' ollama serve # then KLYRO_BASE_URL=http://localhost:11434/v1 KLYRO_MODEL=llama3.2\n');
40
- return 2;
35
+ // First-run setup: ask ONCE (stdin is free — the Ink App is not mounted
36
+ // yet), persist to ~/.klyro, and continue. Every future terminal — this
37
+ // one, new windows, `klyro run` picks it up with zero env vars.
38
+ const interactive = !opts.nonInteractive && (opts.forceTty || process.stdin.isTTY);
39
+ if (interactive) {
40
+ const { runFirstRunSetup } = await import('./setup.js');
41
+ const readline = await import('node:readline/promises');
42
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
43
+ try {
44
+ const ans = await runFirstRunSetup((q) => rl.question(q));
45
+ if (!ans) {
46
+ process.stderr.write('klyro: setup aborted — run `klyro login` when ready.\n');
47
+ return 2;
48
+ }
49
+ process.stderr.write('klyro: saved — this and all future terminals will use it (change via /provider /model or `klyro config`).\n');
50
+ }
51
+ finally {
52
+ rl.close();
53
+ }
54
+ resolved = await resolveProvider();
55
+ }
56
+ if (!resolved) {
57
+ process.stderr.write('klyro: no provider available.\n');
58
+ process.stderr.write(` ${providerHelp(null)}\n`);
59
+ process.stderr.write(' Run `klyro login` once (persists for all terminals), set KLYRO_BASE_URL and KLYRO_API_KEY, or run a local server (Ollama, LM Studio, vLLM).\n');
60
+ return 2;
61
+ }
41
62
  }
42
63
  const baseUrl = resolved.baseURL;
43
64
  const apiKey = resolved.apiKey;
@@ -215,12 +236,10 @@ export async function startRepl(opts = {}) {
215
236
  if (consoleRing.length > 200)
216
237
  consoleRing.splice(0, consoleRing.length - 200);
217
238
  try {
218
- const fs = require('node:fs');
219
- const path = require('node:path');
220
239
  const home = process.env.HOME ?? process.env.USERPROFILE ?? cwd;
221
- const dir = path.join(home, '.klyro');
222
- fs.mkdirSync(dir, { recursive: true });
223
- fs.appendFileSync(path.join(dir, 'debug.log'), line + '\n');
240
+ const dir = nodePath.join(home, '.klyro');
241
+ fsSync.mkdirSync(dir, { recursive: true });
242
+ fsSync.appendFileSync(nodePath.join(dir, 'debug.log'), line + '\n');
224
243
  }
225
244
  catch { /* ignore */ }
226
245
  };
@@ -240,6 +259,20 @@ export async function startRepl(opts = {}) {
240
259
  patchConsole();
241
260
  installMouseTap();
242
261
  const EFFORT_STEPS = { low: 10, medium: 30, high: 50, max: 100 };
262
+ // Persisted provider settings: /model /provider write through to
263
+ // ~/.klyro/settings.json so new terminals inherit them. Touch flags keep
264
+ // /reload from clobbering explicit in-session switches.
265
+ let modelTouched = false;
266
+ let providerTouched = false;
267
+ async function persistProviderPatch(patch) {
268
+ try {
269
+ const { loadConfig, saveConfig } = await import('./config.js');
270
+ const cfg = await loadConfig();
271
+ Object.assign(cfg, patch);
272
+ await saveConfig(cfg);
273
+ }
274
+ catch { /* best-effort */ }
275
+ }
243
276
  // P1 session/permission state (commands.md Priority 1)
244
277
  let sessionLabel = '';
245
278
  let currentBranch = '';
@@ -276,10 +309,8 @@ export async function startRepl(opts = {}) {
276
309
  catch { /* best-effort */ }
277
310
  function persistMap(file, m) {
278
311
  try {
279
- const fs = require('node:fs');
280
- const path = require('node:path');
281
- fs.mkdirSync(path.dirname(file), { recursive: true });
282
- fs.writeFileSync(file, JSON.stringify(Object.fromEntries(m), null, 2), 'utf-8');
312
+ fsSync.mkdirSync(nodePath.dirname(file), { recursive: true });
313
+ fsSync.writeFileSync(file, JSON.stringify(Object.fromEntries(m), null, 2), 'utf-8');
283
314
  }
284
315
  catch { /* best-effort */ }
285
316
  }
@@ -804,8 +835,10 @@ export async function startRepl(opts = {}) {
804
835
  }
805
836
  else {
806
837
  queuedStatus({ model: next });
807
- queuedAppend({ id: `mdl2-${Date.now()}`, kind: 'text', text: `model switched to ${next} (takes effect on next prompt)`, role: 'assistant' });
838
+ queuedAppend({ id: `mdl2-${Date.now()}`, kind: 'text', text: `model switched to ${next} (saved new terminals inherit it)`, role: 'assistant' });
808
839
  model = next;
840
+ modelTouched = true;
841
+ await persistProviderPatch({ model: next });
809
842
  }
810
843
  return;
811
844
  }
@@ -823,7 +856,9 @@ export async function startRepl(opts = {}) {
823
856
  }
824
857
  currentProvider = next;
825
858
  adapter = buildAdapter(currentProvider, currentBaseUrl, currentApiKey);
826
- queuedAppend({ id: `prov2-${Date.now()}`, kind: 'text', text: `provider switched to ${next} (takes effect on next prompt)`, role: 'assistant' });
859
+ providerTouched = true;
860
+ await persistProviderPatch({ provider: next });
861
+ queuedAppend({ id: `prov2-${Date.now()}`, kind: 'text', text: `provider switched to ${next} (saved — new terminals inherit it)`, role: 'assistant' });
827
862
  }
828
863
  return;
829
864
  }
@@ -844,9 +879,15 @@ export async function startRepl(opts = {}) {
844
879
  return;
845
880
  }
846
881
  case 'login': {
847
- const { runLogin } = await import('./auth.js');
848
- const code = await runLogin();
849
- queuedAppend({ id: `login-${Date.now()}`, kind: 'text', text: code === 0 ? 'login saved (0600)' : 'login failed', role: 'assistant' });
882
+ // NOTE: readline login cannot run inside the TUI (Ink owns stdin —
883
+ // prompts would garble). Secrets must also never echo into the
884
+ // transcript, so login lives outside: run it once, /reload picks it up.
885
+ queuedAppend({
886
+ id: `login-${Date.now()}`,
887
+ kind: 'text',
888
+ text: 'To sign in, run `klyro login` in any terminal (asks once, saves for all terminals), then /reload here.\n(Interactive prompts cannot run inside the TUI — Ink owns stdin.)',
889
+ role: 'assistant',
890
+ });
850
891
  return;
851
892
  }
852
893
  case 'logout': {
@@ -2022,11 +2063,37 @@ export async function startRepl(opts = {}) {
2022
2063
  }
2023
2064
  case 'reload': {
2024
2065
  try {
2066
+ // Re-resolve the provider so `klyro login` (or config edits) in
2067
+ // another terminal take effect here. Explicit in-session switches
2068
+ // (/model, /provider) are never clobbered.
2069
+ const fresh = await resolveProvider();
2070
+ const notes = [];
2071
+ if (fresh) {
2072
+ if (!modelTouched && fresh.model && fresh.model !== model) {
2073
+ model = fresh.model;
2074
+ queuedStatus({ model });
2075
+ notes.push(`model → ${model}`);
2076
+ }
2077
+ if (!providerTouched) {
2078
+ const { inferProviderFromBaseURL: infer } = await import('../agent/registry.js');
2079
+ const prov = infer(fresh.baseURL);
2080
+ currentProvider = prov;
2081
+ currentBaseUrl = fresh.baseURL;
2082
+ currentApiKey = fresh.apiKey;
2083
+ adapter = buildAdapter(currentProvider, currentBaseUrl, currentApiKey);
2084
+ notes.push(`provider → ${prov} (${fresh.baseURL}, ${fresh.source})`);
2085
+ }
2086
+ }
2025
2087
  const ctx = await buildLevel6Context({ cwd });
2026
2088
  ctxPrefix = ctx.formatted ? `\n\n<context>\n${ctx.formatted}\n</context>` : '';
2027
2089
  const md = await import('../context/klyro-md.js').then((m) => m.loadKlyroMd(cwd)).catch(() => '');
2028
2090
  klyroBlock = md ? `\n\n<KLYRO.md>\n${md.slice(0, 4000)}\n</KLYRO.md>` : '';
2029
- queuedAppend({ id: `reload-${Date.now()}`, kind: 'text', text: 'reloaded project context + KLYRO.md', role: 'assistant' });
2091
+ queuedAppend({
2092
+ id: `reload-${Date.now()}`,
2093
+ kind: 'text',
2094
+ text: notes.length > 0 ? `reloaded: ${notes.join(', ')} + project context + KLYRO.md` : 'reloaded project context + KLYRO.md (provider unchanged)',
2095
+ role: 'assistant',
2096
+ });
2030
2097
  }
2031
2098
  catch (err) {
2032
2099
  queuedAppend({ id: `reload-err-${Date.now()}`, kind: 'error', message: String(err) });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * First-run setup — "set once, works in every terminal".
3
+ *
4
+ * When Klyro starts with no provider anywhere (no env, no saved config, no
5
+ * stored keys, no local server), the TUI asks for provider details ONCE via
6
+ * readline (stdin is free — the Ink App is not mounted yet), persists them
7
+ * to ~/.klyro/settings.json + ~/.klyro/credentials.json (0600), and startup
8
+ * continues. The next terminal never asks again; changes happen explicitly
9
+ * via /provider, /model, `klyro login`, or `klyro config`.
10
+ *
11
+ * The `ask` callback is injected so this is unit-testable without a TTY.
12
+ */
13
+ export interface SetupAnswers {
14
+ provider: 'openai' | 'anthropic';
15
+ baseUrl: string;
16
+ model: string;
17
+ keySaved: boolean;
18
+ }
19
+ /**
20
+ * Run the interactive setup. Returns answers, or null if the user aborted
21
+ * (empty provider choice) — the caller should exit(2) in that case.
22
+ */
23
+ export declare function runFirstRunSetup(ask: (question: string) => Promise<string>): Promise<SetupAnswers | null>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * First-run setup — "set once, works in every terminal".
3
+ *
4
+ * When Klyro starts with no provider anywhere (no env, no saved config, no
5
+ * stored keys, no local server), the TUI asks for provider details ONCE via
6
+ * readline (stdin is free — the Ink App is not mounted yet), persists them
7
+ * to ~/.klyro/settings.json + ~/.klyro/credentials.json (0600), and startup
8
+ * continues. The next terminal never asks again; changes happen explicitly
9
+ * via /provider, /model, `klyro login`, or `klyro config`.
10
+ *
11
+ * The `ask` callback is injected so this is unit-testable without a TTY.
12
+ */
13
+ import { LOGIN_DEFAULTS, saveKey } from './auth.js';
14
+ import { loadConfig, saveConfig } from './config.js';
15
+ import { assertSafeBaseURL } from '../chat.js';
16
+ /**
17
+ * Run the interactive setup. Returns answers, or null if the user aborted
18
+ * (empty provider choice) — the caller should exit(2) in that case.
19
+ */
20
+ export async function runFirstRunSetup(ask) {
21
+ const choiceRaw = await ask('No provider configured (one-time setup — saved for all terminals).\nProvider [1=openai-compatible, 2=anthropic, 3=local Ollama] [1]: ');
22
+ const choice = choiceRaw.trim() || '1';
23
+ if (choice !== '1' && choice !== '2' && choice !== '3')
24
+ return null;
25
+ const name = choice === '2' ? 'anthropic' : choice === '3' ? 'local' : 'openai';
26
+ const defs = LOGIN_DEFAULTS[name] ?? LOGIN_DEFAULTS.openai;
27
+ const key = await ask(name === 'local' ? 'API key (empty = none needed for local): ' : 'API key (paste, stored 0600): ');
28
+ if (!key.trim() && name !== 'local')
29
+ return null;
30
+ const baseRaw = await ask(`Base URL [${defs.baseUrl}]: `);
31
+ const baseUrl = (baseRaw.trim() || defs.baseUrl).trim();
32
+ try {
33
+ assertSafeBaseURL(baseUrl);
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ const modelRaw = await ask(`Model [${defs.model}]: `);
39
+ const model = (modelRaw.trim() || defs.model).trim();
40
+ const storeProvider = name === 'local' ? 'openai' : name;
41
+ if (key.trim())
42
+ await saveKey(storeProvider, key);
43
+ const cfg = await loadConfig();
44
+ cfg.provider = storeProvider;
45
+ cfg.baseUrl = baseUrl;
46
+ cfg.model = model;
47
+ await saveConfig(cfg);
48
+ return { provider: storeProvider, baseUrl, model, keySaved: key.trim().length > 0 };
49
+ }
@@ -15,9 +15,21 @@ export interface ProviderConfig {
15
15
  baseURL: string;
16
16
  apiKey: string;
17
17
  model: string;
18
- source: 'env' | 'local-probe' | 'manual';
18
+ source: 'env' | 'config' | 'local-probe' | 'manual';
19
19
  }
20
- /** Resolve which provider to use. Does not throw; returns `null` if nothing is reachable. */
20
+ /**
21
+ * Resolve which provider to use. Does not throw; returns `null` if nothing
22
+ * is reachable.
23
+ *
24
+ * Precedence (later lines are fallbacks, earlier wins):
25
+ * 1. KLYRO_BASE_URL env (+ KLYRO_API_KEY / KLYRO_MODEL)
26
+ * 2. Persisted config (~/.klyro/settings.json: baseUrl, provider, model,
27
+ * apiKey) + stored credentials (~/.klyro/credentials.json) — set once
28
+ * via `klyro login` or first-run setup, applies to ALL terminals.
29
+ * 3. KLYRO_API_KEY env alone (OpenAI default)
30
+ * 4. Stored credential keys alone (provider inferred from which key exists)
31
+ * 5. Local endpoint probe (Ollama / LM Studio / vLLM / llama.cpp)
32
+ */
21
33
  export declare function resolveProvider(): Promise<ProviderConfig | null>;
22
34
  /** Pretty-print a hint about how to configure the provider. */
23
35
  export declare function providerHelp(p: ProviderConfig | null): string;
package/dist/providers.js CHANGED
@@ -18,7 +18,19 @@ const LOCAL_ENDPOINTS = [
18
18
  { name: 'vLLM', baseURL: 'http://localhost:8000/v1', defaultModel: 'meta-llama/Llama-3-8B-Instruct' },
19
19
  { name: 'llama.cpp', baseURL: 'http://localhost:8080/v1', defaultModel: 'local-model' },
20
20
  ];
21
- /** Resolve which provider to use. Does not throw; returns `null` if nothing is reachable. */
21
+ /**
22
+ * Resolve which provider to use. Does not throw; returns `null` if nothing
23
+ * is reachable.
24
+ *
25
+ * Precedence (later lines are fallbacks, earlier wins):
26
+ * 1. KLYRO_BASE_URL env (+ KLYRO_API_KEY / KLYRO_MODEL)
27
+ * 2. Persisted config (~/.klyro/settings.json: baseUrl, provider, model,
28
+ * apiKey) + stored credentials (~/.klyro/credentials.json) — set once
29
+ * via `klyro login` or first-run setup, applies to ALL terminals.
30
+ * 3. KLYRO_API_KEY env alone (OpenAI default)
31
+ * 4. Stored credential keys alone (provider inferred from which key exists)
32
+ * 5. Local endpoint probe (Ollama / LM Studio / vLLM / llama.cpp)
33
+ */
22
34
  export async function resolveProvider() {
23
35
  const envBaseURL = process.env.KLYRO_BASE_URL;
24
36
  const envKey = process.env.KLYRO_API_KEY;
@@ -32,6 +44,62 @@ export async function resolveProvider() {
32
44
  source: 'env',
33
45
  };
34
46
  }
47
+ // Persisted config — the "set once, works in every terminal" layer.
48
+ try {
49
+ const { loadMergedConfig } = await import('./cli/config.js');
50
+ const { getStoredKey } = await import('./cli/auth.js');
51
+ const cfg = await loadMergedConfig(process.cwd(), {});
52
+ const cfgBase = (cfg.baseUrl ?? cfg.baseURL);
53
+ const cfgProvider = cfg.provider;
54
+ const cfgModel = (cfg.model ?? cfg['model.default']);
55
+ const cfgKey = (cfg.apiKey ?? cfg.api_key);
56
+ if (cfgBase) {
57
+ assertSafeBaseURL(cfgBase);
58
+ const key = envKey ?? cfgKey ?? getStoredKey(cfgProvider ?? 'openai') ?? getStoredKey('openai') ?? getStoredKey('anthropic') ?? '';
59
+ return {
60
+ baseURL: normalizeBaseURL(cfgBase),
61
+ apiKey: key,
62
+ model: envModel ?? cfgModel ?? 'gpt-4o-mini',
63
+ source: 'config',
64
+ };
65
+ }
66
+ if (cfgKey) {
67
+ return {
68
+ baseURL: normalizeBaseURL('https://api.openai.com/v1'),
69
+ apiKey: cfgKey,
70
+ model: envModel ?? cfgModel ?? 'gpt-4o-mini',
71
+ source: 'config',
72
+ };
73
+ }
74
+ // Stored keys alone (e.g. `klyro login` with defaults, or key-only setup).
75
+ const anthropicKey = getStoredKey('anthropic');
76
+ const openaiKey = getStoredKey('openai');
77
+ if (cfgProvider === 'anthropic' && anthropicKey) {
78
+ return {
79
+ baseURL: normalizeBaseURL('https://api.anthropic.com/v1'),
80
+ apiKey: anthropicKey,
81
+ model: envModel ?? cfgModel ?? 'claude-3-5-sonnet-20240620',
82
+ source: 'config',
83
+ };
84
+ }
85
+ if (openaiKey) {
86
+ return {
87
+ baseURL: normalizeBaseURL('https://api.openai.com/v1'),
88
+ apiKey: openaiKey,
89
+ model: envModel ?? cfgModel ?? 'gpt-4o-mini',
90
+ source: 'config',
91
+ };
92
+ }
93
+ if (anthropicKey) {
94
+ return {
95
+ baseURL: normalizeBaseURL('https://api.anthropic.com/v1'),
96
+ apiKey: anthropicKey,
97
+ model: envModel ?? cfgModel ?? 'claude-3-5-sonnet-20240620',
98
+ source: 'config',
99
+ };
100
+ }
101
+ }
102
+ catch { /* corrupted config must not break startup; probe below */ }
35
103
  if (envKey) {
36
104
  return {
37
105
  baseURL: normalizeBaseURL('https://api.openai.com/v1'),
@@ -43,12 +111,25 @@ export async function resolveProvider() {
43
111
  // Probe local endpoints
44
112
  for (const ep of LOCAL_ENDPOINTS) {
45
113
  if (await probeLocal(ep.baseURL)) {
46
- return {
47
- baseURL: ep.baseURL,
48
- apiKey: '',
49
- model: envModel ?? ep.defaultModel,
50
- source: 'local-probe',
51
- };
114
+ try {
115
+ const { loadMergedConfig } = await import('./cli/config.js');
116
+ const cfg = await loadMergedConfig(process.cwd(), {});
117
+ const cfgModel = (cfg.model ?? cfg['model.default']);
118
+ return {
119
+ baseURL: ep.baseURL,
120
+ apiKey: '',
121
+ model: envModel ?? cfgModel ?? ep.defaultModel,
122
+ source: 'local-probe',
123
+ };
124
+ }
125
+ catch {
126
+ return {
127
+ baseURL: ep.baseURL,
128
+ apiKey: '',
129
+ model: envModel ?? ep.defaultModel,
130
+ source: 'local-probe',
131
+ };
132
+ }
52
133
  }
53
134
  }
54
135
  return null;
@@ -80,5 +161,8 @@ export function providerHelp(p) {
80
161
  if (p?.source === 'env') {
81
162
  return `(env: ${p.baseURL}, model: ${p.model})`;
82
163
  }
83
- return `(no provider configured — set KLYRO_BASE_URL + KLYRO_API_KEY, or run a local server like Ollama)`;
164
+ if (p?.source === 'config') {
165
+ return `(saved: ${p.baseURL}, model: ${p.model} — change via /provider /model or klyro config)`;
166
+ }
167
+ return `(no provider configured — run klyro login once, or set KLYRO_BASE_URL + KLYRO_API_KEY, or run a local server like Ollama)`;
84
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.47",
3
+ "version": "0.1.48",
4
4
  "description": "Klyro \u2014 autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",