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.
- package/dist/agent/registry.js +53 -2
- package/dist/cli/auth.d.ts +14 -0
- package/dist/cli/auth.js +65 -19
- package/dist/cli/config.d.ts +2 -1
- package/dist/cli/config.js +20 -0
- package/dist/cli/repl.js +91 -24
- package/dist/cli/setup.d.ts +23 -0
- package/dist/cli/setup.js +49 -0
- package/dist/providers.d.ts +14 -2
- package/dist/providers.js +92 -8
- package/package.json +1 -1
package/dist/agent/registry.js
CHANGED
|
@@ -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
|
|
68
|
-
const
|
|
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;
|
package/dist/cli/auth.d.ts
CHANGED
|
@@ -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
|
|
18
|
-
const
|
|
19
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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 =
|
|
111
|
+
const raw = fsSync.readFileSync(credPath(), 'utf-8');
|
|
67
112
|
const creds = JSON.parse(raw);
|
|
68
|
-
|
|
113
|
+
const v = creds[provider];
|
|
114
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
69
115
|
}
|
|
70
116
|
catch {
|
|
71
117
|
return undefined;
|
package/dist/cli/config.d.ts
CHANGED
|
@@ -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>;
|
package/dist/cli/config.js
CHANGED
|
@@ -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
|
-
|
|
33
|
+
let resolved = await resolveProvider();
|
|
32
34
|
if (!resolved) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
process.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 =
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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} (
|
|
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
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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({
|
|
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
|
+
}
|
package/dist/providers.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
model
|
|
50
|
-
|
|
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
|
-
|
|
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