klyro 0.1.4 → 0.1.6
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/anthropic-adapter.d.ts +1 -1
- package/dist/agent/anthropic-adapter.js +22 -20
- package/dist/agent/provider-adapter.js +30 -7
- package/dist/agent/registry.js +24 -7
- package/dist/agent/retry.js +54 -9
- package/dist/agent/runtime.d.ts +45 -1
- package/dist/agent/runtime.js +236 -23
- package/dist/chat.d.ts +3 -3
- package/dist/chat.js +137 -39
- package/dist/cli/auth.d.ts +9 -0
- package/dist/cli/auth.js +84 -0
- package/dist/cli/completion.d.ts +6 -0
- package/dist/cli/completion.js +66 -0
- package/dist/cli/config.d.ts +44 -0
- package/dist/cli/config.js +428 -0
- package/dist/cli/doctor.d.ts +8 -0
- package/dist/cli/doctor.js +136 -0
- package/dist/cli/errors.d.ts +5 -0
- package/dist/cli/errors.js +37 -0
- package/dist/cli/markdown.d.ts +9 -0
- package/dist/cli/markdown.js +77 -0
- package/dist/cli/repl.js +113 -5
- package/dist/cli/run.d.ts +9 -0
- package/dist/cli/run.js +97 -2
- package/dist/cli/slash/parser.d.ts +10 -0
- package/dist/cli/slash/parser.js +6 -1
- package/dist/cli/trace.d.ts +7 -0
- package/dist/cli/trace.js +63 -0
- package/dist/cli/update.d.ts +6 -0
- package/dist/cli/update.js +70 -0
- package/dist/context/system-prompt.d.ts +14 -0
- package/dist/context/system-prompt.js +47 -0
- package/dist/events/bus.d.ts +16 -0
- package/dist/events/bus.js +28 -0
- package/dist/events/catalog.d.ts +136 -0
- package/dist/events/catalog.js +5 -0
- package/dist/index.js +335 -22
- package/dist/persistence/session.d.ts +12 -0
- package/dist/persistence/session.js +45 -0
- package/dist/policy/engine.d.ts +11 -1
- package/dist/policy/engine.js +85 -2
- package/dist/providers/model-info.d.ts +18 -0
- package/dist/providers/model-info.js +18 -0
- package/dist/providers.js +3 -2
- package/dist/renderers/json.d.ts +11 -0
- package/dist/renderers/json.js +14 -0
- package/dist/renderers/terminal.d.ts +9 -0
- package/dist/renderers/terminal.js +41 -0
- package/dist/repl.js +52 -15
- package/dist/shared/errors.d.ts +18 -0
- package/dist/shared/errors.js +33 -0
- package/dist/shared/index.d.ts +2 -0
- package/dist/shared/index.js +2 -0
- package/dist/shared/types.d.ts +19 -0
- package/dist/shared/types.js +11 -0
- package/dist/tools/fs/read-file.d.ts +1 -31
- package/dist/tools/fs/read-file.js +45 -17
- package/dist/tools/fs/read-history.d.ts +3 -0
- package/dist/tools/fs/read-history.js +13 -0
- package/dist/tools/fs/write-file.d.ts +1 -4
- package/dist/tools/fs/write-file.js +35 -1
- package/dist/tools/normalize.d.ts +1 -1
- package/dist/tools/shell/shell-exec.d.ts +1 -26
- package/dist/tools/shell/shell-exec.js +121 -17
- package/dist/tools/types.d.ts +25 -0
- package/dist/trace/writer.d.ts +13 -0
- package/dist/trace/writer.js +53 -0
- package/dist/tui/app.d.ts +1 -15
- package/dist/tui/app.js +203 -22
- package/dist/tui/approval.js +25 -4
- package/dist/tui/approval.test.js +1 -1
- package/dist/tui/snapshot.test.js +2 -2
- package/dist/tui/status.js +5 -1
- package/dist/util/log.d.ts +12 -0
- package/dist/util/log.js +75 -0
- package/dist/verification/auto.d.ts +8 -0
- package/dist/verification/auto.js +52 -0
- package/package.json +3 -2
package/dist/cli/auth.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2.2 — klyro login / logout / aliases
|
|
3
|
+
* Stores masked key → ~/.klyro/credentials.json 0600
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'node:fs/promises';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import * as os from 'node:os';
|
|
8
|
+
import * as readline from 'node:readline/promises';
|
|
9
|
+
import { stdin, stdout } from 'node:process';
|
|
10
|
+
function credPath() {
|
|
11
|
+
const home = os.homedir() || process.cwd();
|
|
12
|
+
return path.join(home, '.klyro', 'credentials.json');
|
|
13
|
+
}
|
|
14
|
+
export async function runLogin() {
|
|
15
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
16
|
+
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()) {
|
|
20
|
+
process.stderr.write('No key provided\n');
|
|
21
|
+
return 2;
|
|
22
|
+
}
|
|
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);
|
|
34
|
+
}
|
|
35
|
+
catch { /* ignore on Windows */ }
|
|
36
|
+
process.stdout.write(`Saved ${provider} key to ${credPath()} (0600)\n`);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
rl.close();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function runLogout(provider) {
|
|
44
|
+
try {
|
|
45
|
+
const raw = await fs.readFile(credPath(), 'utf-8');
|
|
46
|
+
const creds = JSON.parse(raw);
|
|
47
|
+
if (provider) {
|
|
48
|
+
delete creds[provider];
|
|
49
|
+
await fs.writeFile(credPath(), JSON.stringify(creds, null, 2), 'utf-8');
|
|
50
|
+
process.stdout.write(`Removed ${provider} key\n`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
await fs.unlink(credPath());
|
|
54
|
+
process.stdout.write('Removed all credentials\n');
|
|
55
|
+
}
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
60
|
+
process.stderr.write(`No credentials to remove: ${msg}\n`);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function getStoredKey(provider) {
|
|
65
|
+
try {
|
|
66
|
+
const raw = require('node:fs').readFileSync(credPath(), 'utf-8');
|
|
67
|
+
const creds = JSON.parse(raw);
|
|
68
|
+
return creds[provider];
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Model aliases 2.2
|
|
75
|
+
export const MODEL_ALIASES = {
|
|
76
|
+
sonnet: 'claude-3-5-sonnet-20240620',
|
|
77
|
+
opus: 'claude-3-opus-20240229',
|
|
78
|
+
haiku: 'claude-3-haiku-20240307',
|
|
79
|
+
gpt: 'gpt-4o',
|
|
80
|
+
local: 'llama3.2',
|
|
81
|
+
};
|
|
82
|
+
export function resolveAlias(model) {
|
|
83
|
+
return MODEL_ALIASES[model] ?? model;
|
|
84
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.2 — klyro completion
|
|
3
|
+
* Generates shell completion scripts for bash/zsh/fish/powershell.
|
|
4
|
+
*/
|
|
5
|
+
const COMMANDS = ['tui', 'run', 'chat', 'config', 'doctor', 'completion', 'update', 'eval', 'session', 'resume', 'help', 'version'];
|
|
6
|
+
function bashScript() {
|
|
7
|
+
return `# klyro bash completion
|
|
8
|
+
_klyro_complete() {
|
|
9
|
+
local cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
10
|
+
local cmds="${COMMANDS.join(' ')}"
|
|
11
|
+
COMPREPLY=( $(compgen -W "$cmds" -- "$cur") )
|
|
12
|
+
}
|
|
13
|
+
complete -F _klyro_complete klyro
|
|
14
|
+
complete -F _klyro_complete ky
|
|
15
|
+
`;
|
|
16
|
+
}
|
|
17
|
+
function zshScript() {
|
|
18
|
+
return `#compdef klyro ky
|
|
19
|
+
_klyro() {
|
|
20
|
+
local -a completions
|
|
21
|
+
completions=(${COMMANDS.map((c) => `'${c}'`).join(' ')})
|
|
22
|
+
_describe 'klyro commands' completions
|
|
23
|
+
}
|
|
24
|
+
compdef _klyro klyro ky
|
|
25
|
+
`;
|
|
26
|
+
}
|
|
27
|
+
function fishScript() {
|
|
28
|
+
return `# klyro fish completion
|
|
29
|
+
${COMMANDS.map((c) => `complete -c klyro -f -a ${c}`).join('\n')}
|
|
30
|
+
complete -c ky -f -a "${COMMANDS.join(' ')}"
|
|
31
|
+
`;
|
|
32
|
+
}
|
|
33
|
+
function powershellScript() {
|
|
34
|
+
return `# klyro powershell completion
|
|
35
|
+
Register-ArgumentCompleter -Native -CommandName klyro,ky -ScriptBlock {
|
|
36
|
+
param($wordToComplete, $commandAst, $cursorPosition)
|
|
37
|
+
$cmds = @(${COMMANDS.map((c) => `'${c}'`).join(', ')})
|
|
38
|
+
$cmds | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
|
|
39
|
+
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
`;
|
|
43
|
+
}
|
|
44
|
+
export function getCompletionScript(shell) {
|
|
45
|
+
switch (shell) {
|
|
46
|
+
case 'bash': return bashScript();
|
|
47
|
+
case 'zsh': return zshScript();
|
|
48
|
+
case 'fish': return fishScript();
|
|
49
|
+
case 'powershell':
|
|
50
|
+
case 'pwsh': return powershellScript();
|
|
51
|
+
default: return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function runCompletion(shell) {
|
|
55
|
+
if (!shell) {
|
|
56
|
+
process.stderr.write('Usage: klyro completion <bash|zsh|fish|powershell>\n');
|
|
57
|
+
return 2;
|
|
58
|
+
}
|
|
59
|
+
const script = getCompletionScript(shell);
|
|
60
|
+
if (!script) {
|
|
61
|
+
process.stderr.write(`Unknown shell: ${shell} (expected bash|zsh|fish|powershell)\n`);
|
|
62
|
+
return 2;
|
|
63
|
+
}
|
|
64
|
+
process.stdout.write(script);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LEVEL 1 — klyro config (1.3)
|
|
3
|
+
* Precedence: flags → env → .klyro/settings.local.json → .klyro/settings.json → ~/.klyro/settings.json → defaults
|
|
4
|
+
* JSONC-tolerant, Zod schema, exit 3 on invalid.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
export declare const ConfigSchema: z.ZodObject<{
|
|
8
|
+
model: z.ZodOptional<z.ZodString>;
|
|
9
|
+
provider: z.ZodOptional<z.ZodEnum<{
|
|
10
|
+
anthropic: "anthropic";
|
|
11
|
+
openai: "openai";
|
|
12
|
+
}>>;
|
|
13
|
+
baseUrl: z.ZodOptional<z.ZodString>;
|
|
14
|
+
apiKey: z.ZodOptional<z.ZodString>;
|
|
15
|
+
baseURL: z.ZodOptional<z.ZodString>;
|
|
16
|
+
api_key: z.ZodOptional<z.ZodString>;
|
|
17
|
+
'model.default': z.ZodOptional<z.ZodString>;
|
|
18
|
+
'model.small': z.ZodOptional<z.ZodString>;
|
|
19
|
+
}, z.core.$loose>;
|
|
20
|
+
export type KlyroConfig = z.infer<typeof ConfigSchema>;
|
|
21
|
+
export declare function getConfigDir(): string;
|
|
22
|
+
export declare function getConfigPath(): string;
|
|
23
|
+
export declare function getConfigSearchPaths(cwd?: string): Array<{
|
|
24
|
+
path: string;
|
|
25
|
+
layer: string;
|
|
26
|
+
exists: boolean;
|
|
27
|
+
}>;
|
|
28
|
+
declare function stripJsonComments(text: string): string;
|
|
29
|
+
declare function getByPath(obj: Record<string, unknown>, dotted: string): unknown;
|
|
30
|
+
declare function setByPath(obj: Record<string, unknown>, dotted: string, value: unknown): void;
|
|
31
|
+
declare function deleteByPath(obj: Record<string, unknown>, dotted: string): boolean;
|
|
32
|
+
declare function parseValue(raw: string): unknown;
|
|
33
|
+
export declare function loadConfig(): Promise<Record<string, unknown>>;
|
|
34
|
+
export declare function loadMergedConfig(cwd?: string, flags?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
35
|
+
export declare function saveConfig(obj: Record<string, unknown>): Promise<void>;
|
|
36
|
+
export declare function runConfig(args: string[]): Promise<number>;
|
|
37
|
+
export declare const _helpers: {
|
|
38
|
+
getByPath: typeof getByPath;
|
|
39
|
+
setByPath: typeof setByPath;
|
|
40
|
+
deleteByPath: typeof deleteByPath;
|
|
41
|
+
parseValue: typeof parseValue;
|
|
42
|
+
stripJsonComments: typeof stripJsonComments;
|
|
43
|
+
};
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LEVEL 1 — klyro config (1.3)
|
|
3
|
+
* Precedence: flags → env → .klyro/settings.local.json → .klyro/settings.json → ~/.klyro/settings.json → defaults
|
|
4
|
+
* JSONC-tolerant, Zod schema, exit 3 on invalid.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'node:fs/promises';
|
|
7
|
+
import * as fsSync from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import * as os from 'node:os';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { KlyroError } from '../shared/errors.js';
|
|
12
|
+
// --- Zod schema (Appendix A shape, L1 keys active, passthrough for future) ---
|
|
13
|
+
export const ConfigSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
model: z.string().optional(),
|
|
16
|
+
provider: z.enum(['openai', 'anthropic']).optional(),
|
|
17
|
+
baseUrl: z.string().optional(),
|
|
18
|
+
apiKey: z.string().optional(),
|
|
19
|
+
baseURL: z.string().optional(),
|
|
20
|
+
api_key: z.string().optional(),
|
|
21
|
+
// Global flags persisted
|
|
22
|
+
'model.default': z.string().optional(),
|
|
23
|
+
'model.small': z.string().optional(),
|
|
24
|
+
})
|
|
25
|
+
.passthrough();
|
|
26
|
+
// --- Paths ---
|
|
27
|
+
export function getConfigDir() {
|
|
28
|
+
if (process.env.KLYRO_CONFIG_DIR)
|
|
29
|
+
return process.env.KLYRO_CONFIG_DIR;
|
|
30
|
+
const home = os.homedir();
|
|
31
|
+
if (home)
|
|
32
|
+
return path.join(home, '.klyro');
|
|
33
|
+
return path.join(process.cwd(), '.klyro');
|
|
34
|
+
}
|
|
35
|
+
export function getConfigPath() {
|
|
36
|
+
if (process.env.KLYRO_CONFIG)
|
|
37
|
+
return process.env.KLYRO_CONFIG;
|
|
38
|
+
// New canonical path per spec
|
|
39
|
+
return path.join(getConfigDir(), 'settings.json');
|
|
40
|
+
}
|
|
41
|
+
function getLegacyConfigPath() {
|
|
42
|
+
if (process.env.KLYRO_CONFIG)
|
|
43
|
+
return process.env.KLYRO_CONFIG;
|
|
44
|
+
const home = os.homedir();
|
|
45
|
+
if (home)
|
|
46
|
+
return path.join(home, '.klyro', 'config.json');
|
|
47
|
+
return path.join(process.cwd(), '.klyro', 'config.json');
|
|
48
|
+
}
|
|
49
|
+
export function getConfigSearchPaths(cwd = process.cwd()) {
|
|
50
|
+
const homeSettings = path.join(getConfigDir(), 'settings.json');
|
|
51
|
+
const projSettings = path.join(cwd, '.klyro', 'settings.json');
|
|
52
|
+
const projLocal = path.join(cwd, '.klyro', 'settings.local.json');
|
|
53
|
+
const legacy = getLegacyConfigPath();
|
|
54
|
+
const paths = [
|
|
55
|
+
{ path: homeSettings, layer: '~/.klyro/settings.json' },
|
|
56
|
+
{ path: legacy, layer: '~/.klyro/config.json (legacy)' },
|
|
57
|
+
{ path: projSettings, layer: '.klyro/settings.json' },
|
|
58
|
+
{ path: projLocal, layer: '.klyro/settings.local.json' },
|
|
59
|
+
];
|
|
60
|
+
return paths.map((p) => ({ ...p, exists: fsSync.existsSync(p.path) }));
|
|
61
|
+
}
|
|
62
|
+
// --- JSONC ---
|
|
63
|
+
function stripJsonComments(text) {
|
|
64
|
+
let out = '';
|
|
65
|
+
let inString = false;
|
|
66
|
+
let escaped = false;
|
|
67
|
+
let inSingleLineComment = false;
|
|
68
|
+
let inMultiLineComment = false;
|
|
69
|
+
for (let i = 0; i < text.length; i++) {
|
|
70
|
+
const ch = text[i];
|
|
71
|
+
const nxt = text[i + 1];
|
|
72
|
+
if (inSingleLineComment) {
|
|
73
|
+
if (ch === '\n') {
|
|
74
|
+
inSingleLineComment = false;
|
|
75
|
+
out += ch;
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (inMultiLineComment) {
|
|
80
|
+
if (ch === '*' && nxt === '/') {
|
|
81
|
+
inMultiLineComment = false;
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (inString) {
|
|
87
|
+
out += ch;
|
|
88
|
+
if (escaped)
|
|
89
|
+
escaped = false;
|
|
90
|
+
else if (ch === '\\')
|
|
91
|
+
escaped = true;
|
|
92
|
+
else if (ch === '"')
|
|
93
|
+
inString = false;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (ch === '"') {
|
|
97
|
+
inString = true;
|
|
98
|
+
out += ch;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (ch === '/' && nxt === '/') {
|
|
102
|
+
inSingleLineComment = true;
|
|
103
|
+
i++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (ch === '/' && nxt === '*') {
|
|
107
|
+
inMultiLineComment = true;
|
|
108
|
+
i++;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
out += ch;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
function parseJsonc(raw, filePath) {
|
|
116
|
+
const stripped = stripJsonComments(raw);
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(stripped);
|
|
119
|
+
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
120
|
+
throw new KlyroError('CONFIG_INVALID', `config is not a JSON object: ${filePath}`, {
|
|
121
|
+
hint: 'Fix the file to be a JSON object, e.g. {"model":"gpt-4o-mini"}',
|
|
122
|
+
exitCode: 3,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return parsed;
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
if (err instanceof KlyroError)
|
|
129
|
+
throw err;
|
|
130
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
131
|
+
throw new KlyroError('CONFIG_INVALID', `invalid JSON in ${filePath}: ${msg}`, {
|
|
132
|
+
hint: `Check ${filePath} for trailing commas or comments — JSONC is allowed but must be valid`,
|
|
133
|
+
exitCode: 3,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function validateConfig(obj, filePath) {
|
|
138
|
+
const res = ConfigSchema.safeParse(obj);
|
|
139
|
+
if (!res.success) {
|
|
140
|
+
const issues = res.error.issues.map((iss) => `${iss.path.join('.')}: ${iss.message}`).join('; ');
|
|
141
|
+
throw new KlyroError('CONFIG_INVALID', `invalid config ${filePath}: ${issues}`, {
|
|
142
|
+
hint: 'Fix the key per expected type, e.g. "model": "gpt-4o-mini"',
|
|
143
|
+
exitCode: 3,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return res.data;
|
|
147
|
+
}
|
|
148
|
+
// --- Helpers for dotted paths (unchanged) ---
|
|
149
|
+
function getByPath(obj, dotted) {
|
|
150
|
+
const parts = dotted.split('.');
|
|
151
|
+
let cur = obj;
|
|
152
|
+
for (const p of parts) {
|
|
153
|
+
if (cur == null || typeof cur !== 'object')
|
|
154
|
+
return undefined;
|
|
155
|
+
cur = cur[p];
|
|
156
|
+
}
|
|
157
|
+
return cur;
|
|
158
|
+
}
|
|
159
|
+
function setByPath(obj, dotted, value) {
|
|
160
|
+
const parts = dotted.split('.');
|
|
161
|
+
let cur = obj;
|
|
162
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
163
|
+
const p = parts[i];
|
|
164
|
+
const nxt = cur[p];
|
|
165
|
+
if (nxt == null || typeof nxt !== 'object' || Array.isArray(nxt))
|
|
166
|
+
cur[p] = {};
|
|
167
|
+
cur = cur[p];
|
|
168
|
+
}
|
|
169
|
+
cur[parts[parts.length - 1]] = value;
|
|
170
|
+
}
|
|
171
|
+
function deleteByPath(obj, dotted) {
|
|
172
|
+
const parts = dotted.split('.');
|
|
173
|
+
let cur = obj;
|
|
174
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
175
|
+
const p = parts[i];
|
|
176
|
+
const nxt = cur[p];
|
|
177
|
+
if (nxt == null || typeof nxt !== 'object')
|
|
178
|
+
return false;
|
|
179
|
+
cur = nxt;
|
|
180
|
+
}
|
|
181
|
+
const last = parts[parts.length - 1];
|
|
182
|
+
if (!(last in cur))
|
|
183
|
+
return false;
|
|
184
|
+
delete cur[last];
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
function parseValue(raw) {
|
|
188
|
+
const trimmed = raw.trim();
|
|
189
|
+
if (trimmed === '')
|
|
190
|
+
return '';
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(trimmed);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return raw;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// --- Single-file load (backward compat) ---
|
|
199
|
+
export async function loadConfig() {
|
|
200
|
+
const p = getConfigPath();
|
|
201
|
+
const legacy = getLegacyConfigPath();
|
|
202
|
+
// Try canonical first, then legacy
|
|
203
|
+
for (const file of [p, legacy]) {
|
|
204
|
+
try {
|
|
205
|
+
const raw = await fs.readFile(file, 'utf-8');
|
|
206
|
+
const obj = parseJsonc(raw, file);
|
|
207
|
+
return validateConfig(obj, file);
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
const e = err;
|
|
211
|
+
if (e.code === 'ENOENT')
|
|
212
|
+
continue;
|
|
213
|
+
if (err instanceof KlyroError)
|
|
214
|
+
throw err;
|
|
215
|
+
throw new KlyroError('CONFIG_INVALID', `cannot read ${file}: ${e.message}`, { exitCode: 3 });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return {};
|
|
219
|
+
}
|
|
220
|
+
// --- Merged load with 5-layer precedence ---
|
|
221
|
+
export async function loadMergedConfig(cwd = process.cwd(), flags = {}) {
|
|
222
|
+
const layers = [];
|
|
223
|
+
// 1) defaults (empty)
|
|
224
|
+
layers.push({});
|
|
225
|
+
// 2) ~/.klyro/settings.json
|
|
226
|
+
for (const file of [path.join(getConfigDir(), 'settings.json'), getLegacyConfigPath()]) {
|
|
227
|
+
try {
|
|
228
|
+
const raw = await fs.readFile(file, 'utf-8');
|
|
229
|
+
const obj = parseJsonc(raw, file);
|
|
230
|
+
layers.push(validateConfig(obj, file));
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
const e = err;
|
|
234
|
+
if (e.code === 'ENOENT')
|
|
235
|
+
continue;
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// 3) .klyro/settings.json
|
|
240
|
+
try {
|
|
241
|
+
const raw = await fs.readFile(path.join(cwd, '.klyro', 'settings.json'), 'utf-8');
|
|
242
|
+
const obj = parseJsonc(raw, path.join(cwd, '.klyro/settings.json'));
|
|
243
|
+
layers.push(validateConfig(obj, path.join(cwd, '.klyro/settings.json')));
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
const e = err;
|
|
247
|
+
if (e.code !== 'ENOENT')
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
// 4) .klyro/settings.local.json
|
|
251
|
+
try {
|
|
252
|
+
const raw = await fs.readFile(path.join(cwd, '.klyro', 'settings.local.json'), 'utf-8');
|
|
253
|
+
const obj = parseJsonc(raw, path.join(cwd, '.klyro/settings.local.json'));
|
|
254
|
+
layers.push(validateConfig(obj, path.join(cwd, '.klyro/settings.local.json')));
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
const e = err;
|
|
258
|
+
if (e.code !== 'ENOENT')
|
|
259
|
+
throw err;
|
|
260
|
+
}
|
|
261
|
+
// 5) env
|
|
262
|
+
const envLayer = {};
|
|
263
|
+
if (process.env.KLYRO_MODEL)
|
|
264
|
+
envLayer.model = process.env.KLYRO_MODEL;
|
|
265
|
+
if (process.env.KLYRO_PROVIDER)
|
|
266
|
+
envLayer.provider = process.env.KLYRO_PROVIDER;
|
|
267
|
+
if (process.env.KLYRO_BASE_URL)
|
|
268
|
+
envLayer.baseUrl = process.env.KLYRO_BASE_URL;
|
|
269
|
+
if (process.env.KLYRO_API_KEY)
|
|
270
|
+
envLayer.apiKey = process.env.KLYRO_API_KEY;
|
|
271
|
+
if (process.env.OPENAI_API_KEY)
|
|
272
|
+
envLayer.apiKey = process.env.OPENAI_API_KEY;
|
|
273
|
+
if (process.env.ANTHROPIC_API_KEY)
|
|
274
|
+
envLayer.apiKey = process.env.ANTHROPIC_API_KEY;
|
|
275
|
+
layers.push(envLayer);
|
|
276
|
+
// 6) flags (highest)
|
|
277
|
+
layers.push(flags);
|
|
278
|
+
// Merge shallow (dotted keys are already nested via setByPath, but we treat flat)
|
|
279
|
+
const merged = {};
|
|
280
|
+
for (const layer of layers) {
|
|
281
|
+
for (const [k, v] of Object.entries(layer)) {
|
|
282
|
+
if (v !== undefined)
|
|
283
|
+
setByPath(merged, k, v);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return merged;
|
|
287
|
+
}
|
|
288
|
+
export async function saveConfig(obj) {
|
|
289
|
+
const p = getConfigPath();
|
|
290
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
291
|
+
const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
|
|
292
|
+
const data = JSON.stringify(validateConfig(obj, p), null, 2) + '\n';
|
|
293
|
+
await fs.writeFile(tmp, data, { mode: 0o600 });
|
|
294
|
+
try {
|
|
295
|
+
await fs.rename(tmp, p);
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
await fs.unlink(tmp).catch(() => undefined);
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
await fs.chmod(p, 0o600);
|
|
303
|
+
}
|
|
304
|
+
catch { /* ignore on Windows */ }
|
|
305
|
+
}
|
|
306
|
+
export async function runConfig(args) {
|
|
307
|
+
const [cmd, ...rest] = args;
|
|
308
|
+
const cfgPath = getConfigPath();
|
|
309
|
+
if (!cmd || cmd === 'list') {
|
|
310
|
+
try {
|
|
311
|
+
const cfg = await loadMergedConfig(process.cwd(), {});
|
|
312
|
+
if (Object.keys(cfg).length === 0) {
|
|
313
|
+
process.stdout.write(`# ${cfgPath} (empty)\n# No config yet. Use: klyro config set <key> <value>\n`);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
process.stdout.write(JSON.stringify(cfg, null, 2) + '\n');
|
|
317
|
+
}
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
if (err instanceof KlyroError) {
|
|
322
|
+
process.stderr.write(`✖ ${err.message}\n hint: ${err.hint ?? ''}\n`);
|
|
323
|
+
return err.exitCode;
|
|
324
|
+
}
|
|
325
|
+
throw err;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (cmd === 'path') {
|
|
329
|
+
const paths = getConfigSearchPaths(process.cwd());
|
|
330
|
+
for (const p of paths) {
|
|
331
|
+
const status = p.exists ? 'exists' : 'missing';
|
|
332
|
+
process.stdout.write(`${p.layer}: ${p.path} [${status}]\n`);
|
|
333
|
+
}
|
|
334
|
+
// Also show env
|
|
335
|
+
process.stdout.write(`env: KLYRO_MODEL=${process.env.KLYRO_MODEL ?? '(not set)'} KLYRO_PROVIDER=${process.env.KLYRO_PROVIDER ?? '(not set)'}\n`);
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
if (cmd === 'edit') {
|
|
339
|
+
const p = getConfigPath();
|
|
340
|
+
// Try to open $EDITOR
|
|
341
|
+
const editor = process.env.EDITOR || process.env.VISUAL;
|
|
342
|
+
if (editor) {
|
|
343
|
+
const { spawn } = await import('node:child_process');
|
|
344
|
+
const child = spawn(editor, [p], { stdio: 'inherit', shell: true });
|
|
345
|
+
const code = await new Promise((res) => child.on('close', (c) => res(typeof c === 'number' ? c : 1)));
|
|
346
|
+
return code;
|
|
347
|
+
}
|
|
348
|
+
process.stderr.write('klyro: config edit — open ' + p + ' in $EDITOR\n');
|
|
349
|
+
process.stdout.write(p + '\n');
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
if (cmd === 'get') {
|
|
353
|
+
const key = rest[0];
|
|
354
|
+
if (!key) {
|
|
355
|
+
process.stderr.write('klyro: config get <key> — missing key\n');
|
|
356
|
+
return 2;
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
const cfg = await loadMergedConfig(process.cwd(), {});
|
|
360
|
+
const val = getByPath(cfg, key);
|
|
361
|
+
if (val === undefined) {
|
|
362
|
+
process.stderr.write(`klyro: key not found: ${key}\n`);
|
|
363
|
+
return 1;
|
|
364
|
+
}
|
|
365
|
+
if (typeof val === 'string')
|
|
366
|
+
process.stdout.write(val + '\n');
|
|
367
|
+
else
|
|
368
|
+
process.stdout.write(JSON.stringify(val, null, 2) + '\n');
|
|
369
|
+
return 0;
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
if (err instanceof KlyroError) {
|
|
373
|
+
process.stderr.write(`✖ ${err.message}\n hint: ${err.hint ?? ''}\n`);
|
|
374
|
+
return err.exitCode;
|
|
375
|
+
}
|
|
376
|
+
throw err;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (cmd === 'set') {
|
|
380
|
+
const key = rest[0];
|
|
381
|
+
const rawVal = rest.slice(1).join(' ');
|
|
382
|
+
if (!key) {
|
|
383
|
+
process.stderr.write('klyro: config set <key> <value> — missing key\n');
|
|
384
|
+
return 2;
|
|
385
|
+
}
|
|
386
|
+
if (rawVal === '' && rest.length < 2) {
|
|
387
|
+
process.stderr.write('klyro: config set <key> <value> — missing value\n');
|
|
388
|
+
return 2;
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
const cfg = await loadConfig();
|
|
392
|
+
const val = parseValue(rawVal);
|
|
393
|
+
setByPath(cfg, key, val);
|
|
394
|
+
// Validate before save
|
|
395
|
+
validateConfig(cfg, cfgPath);
|
|
396
|
+
await saveConfig(cfg);
|
|
397
|
+
process.stdout.write(`set ${key} = ${typeof val === 'string' ? val : JSON.stringify(val)}\n`);
|
|
398
|
+
return 0;
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
if (err instanceof KlyroError) {
|
|
402
|
+
process.stderr.write(`✖ ${err.message}\n hint: ${err.hint ?? ''}\n`);
|
|
403
|
+
return err.exitCode;
|
|
404
|
+
}
|
|
405
|
+
throw err;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (cmd === 'unset' || cmd === 'delete' || cmd === 'rm') {
|
|
409
|
+
const key = rest[0];
|
|
410
|
+
if (!key) {
|
|
411
|
+
process.stderr.write(`klyro: config ${cmd} <key> — missing key\n`);
|
|
412
|
+
return 2;
|
|
413
|
+
}
|
|
414
|
+
const cfg = await loadConfig();
|
|
415
|
+
const ok = deleteByPath(cfg, key);
|
|
416
|
+
if (!ok) {
|
|
417
|
+
process.stderr.write(`klyro: key not found: ${key}\n`);
|
|
418
|
+
return 1;
|
|
419
|
+
}
|
|
420
|
+
await saveConfig(cfg);
|
|
421
|
+
process.stdout.write(`unset ${key}\n`);
|
|
422
|
+
return 0;
|
|
423
|
+
}
|
|
424
|
+
process.stderr.write(`klyro: unknown config command: ${cmd}\n`);
|
|
425
|
+
process.stderr.write('Usage: klyro config [list|get <key>|set <key> <value>|unset <key>|path|edit]\n');
|
|
426
|
+
return 2;
|
|
427
|
+
}
|
|
428
|
+
export const _helpers = { getByPath, setByPath, deleteByPath, parseValue, stripJsonComments: stripJsonComments };
|