@vdntio/clai 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/dist/ai/index.d.ts +24 -0
- package/dist/ai/index.js +78 -0
- package/dist/ai/mock.d.ts +17 -0
- package/dist/ai/mock.js +49 -0
- package/dist/ai/parser.d.ts +14 -0
- package/dist/ai/parser.js +109 -0
- package/dist/ai/prompt.d.ts +12 -0
- package/dist/ai/prompt.js +76 -0
- package/dist/ai/providers/index.d.ts +1 -0
- package/dist/ai/providers/index.js +2 -0
- package/dist/ai/providers/openrouter.d.ts +31 -0
- package/dist/ai/providers/openrouter.js +142 -0
- package/dist/ai/types.d.ts +46 -0
- package/dist/ai/types.js +15 -0
- package/dist/cli/index.d.ts +19 -0
- package/dist/cli/index.js +71 -0
- package/dist/config/index.d.ts +12 -0
- package/dist/config/index.js +363 -0
- package/dist/config/types.d.ts +76 -0
- package/dist/config/types.js +40 -0
- package/dist/context/directory.d.ts +18 -0
- package/dist/context/directory.js +71 -0
- package/dist/context/history.d.ts +16 -0
- package/dist/context/history.js +89 -0
- package/dist/context/index.d.ts +24 -0
- package/dist/context/index.js +61 -0
- package/dist/context/redaction.d.ts +14 -0
- package/dist/context/redaction.js +57 -0
- package/dist/context/stdin.d.ts +13 -0
- package/dist/context/stdin.js +86 -0
- package/dist/context/system.d.ts +11 -0
- package/dist/context/system.js +56 -0
- package/dist/context/types.d.ts +31 -0
- package/dist/context/types.js +10 -0
- package/dist/error/index.d.ts +30 -0
- package/dist/error/index.js +50 -0
- package/dist/logging/file-logger.d.ts +12 -0
- package/dist/logging/file-logger.js +66 -0
- package/dist/logging/index.d.ts +15 -0
- package/dist/logging/index.js +33 -0
- package/dist/logging/logger.d.ts +15 -0
- package/dist/logging/logger.js +60 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +192 -0
- package/dist/output/execute.d.ts +30 -0
- package/dist/output/execute.js +144 -0
- package/dist/output/index.d.ts +4 -0
- package/dist/output/index.js +7 -0
- package/dist/output/types.d.ts +48 -0
- package/dist/output/types.js +34 -0
- package/dist/output/validate.d.ts +23 -0
- package/dist/output/validate.js +42 -0
- package/dist/safety/index.d.ts +34 -0
- package/dist/safety/index.js +59 -0
- package/dist/safety/patterns.d.ts +23 -0
- package/dist/safety/patterns.js +96 -0
- package/dist/safety/types.d.ts +20 -0
- package/dist/safety/types.js +18 -0
- package/dist/signals/index.d.ts +4 -0
- package/dist/signals/index.js +35 -0
- package/dist/ui/App.d.ts +4 -0
- package/dist/ui/App.js +57 -0
- package/dist/ui/components/ActionPrompt.d.ts +8 -0
- package/dist/ui/components/ActionPrompt.js +9 -0
- package/dist/ui/components/CommandDisplay.d.ts +9 -0
- package/dist/ui/components/CommandDisplay.js +13 -0
- package/dist/ui/components/DangerousWarning.d.ts +6 -0
- package/dist/ui/components/DangerousWarning.js +6 -0
- package/dist/ui/components/Spinner.d.ts +15 -0
- package/dist/ui/components/Spinner.js +17 -0
- package/dist/ui/hooks/useAnimation.d.ts +27 -0
- package/dist/ui/hooks/useAnimation.js +85 -0
- package/dist/ui/hooks/useTerminalSize.d.ts +12 -0
- package/dist/ui/hooks/useTerminalSize.js +56 -0
- package/dist/ui/hooks/useTimeout.d.ts +9 -0
- package/dist/ui/hooks/useTimeout.js +31 -0
- package/dist/ui/index.d.ts +25 -0
- package/dist/ui/index.js +80 -0
- package/dist/ui/output.d.ts +20 -0
- package/dist/ui/output.js +56 -0
- package/dist/ui/spinner.d.ts +13 -0
- package/dist/ui/spinner.js +58 -0
- package/dist/ui/types.d.ts +105 -0
- package/dist/ui/types.js +60 -0
- package/dist/ui/utils/formatCommand.d.ts +50 -0
- package/dist/ui/utils/formatCommand.js +113 -0
- package/package.json +68 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// OpenRouter API Provider Implementation
|
|
2
|
+
// Supports retry logic with exponential backoff for 429 rate limit errors
|
|
3
|
+
import { AIError } from '../types.js';
|
|
4
|
+
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
5
|
+
const TIMEOUT_MS = 60_000;
|
|
6
|
+
const MAX_RETRIES = 3;
|
|
7
|
+
/**
|
|
8
|
+
* Sleep helper for retry delays
|
|
9
|
+
*/
|
|
10
|
+
function sleep(ms) {
|
|
11
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* OpenRouter provider implementation
|
|
15
|
+
* Handles API calls with authentication, timeout, and retry logic
|
|
16
|
+
*/
|
|
17
|
+
export class OpenRouterProvider {
|
|
18
|
+
name = 'openrouter';
|
|
19
|
+
apiKey;
|
|
20
|
+
constructor(apiKey) {
|
|
21
|
+
this.apiKey = apiKey;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Check if provider is available (has API key)
|
|
25
|
+
*/
|
|
26
|
+
isAvailable() {
|
|
27
|
+
return !!this.apiKey && this.apiKey.length > 0;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Send completion request to OpenRouter
|
|
31
|
+
* Retries on 429 rate limit with exponential backoff
|
|
32
|
+
*/
|
|
33
|
+
async complete(request) {
|
|
34
|
+
let lastError = null;
|
|
35
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
36
|
+
try {
|
|
37
|
+
const response = await this.makeRequest(request);
|
|
38
|
+
if (response.ok) {
|
|
39
|
+
const json = await response.json();
|
|
40
|
+
return this.parseResponse(json);
|
|
41
|
+
}
|
|
42
|
+
// Handle specific status codes
|
|
43
|
+
const body = await response.text();
|
|
44
|
+
// 429: Rate limited - retry with backoff
|
|
45
|
+
if (response.status === 429 && attempt < MAX_RETRIES - 1) {
|
|
46
|
+
const delay = 1000 * Math.pow(2, attempt); // 1s, 2s, 4s
|
|
47
|
+
await sleep(delay);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
// Other errors: throw immediately
|
|
51
|
+
throw this.mapError(response.status, body);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
// If it's already an AIError, rethrow immediately
|
|
55
|
+
if (err instanceof AIError) {
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
// Network or other errors
|
|
59
|
+
lastError = err;
|
|
60
|
+
// Only retry network errors on first attempt
|
|
61
|
+
if (attempt === 0 && lastError.message?.includes('fetch')) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
// Otherwise, throw on last attempt
|
|
65
|
+
if (attempt === MAX_RETRIES - 1) {
|
|
66
|
+
throw new AIError(`Network error: ${lastError.message || 'Unknown error'}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
throw lastError || new AIError('Unknown error during API call');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Make the actual HTTP request
|
|
74
|
+
*/
|
|
75
|
+
async makeRequest(request) {
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
78
|
+
try {
|
|
79
|
+
return await fetch(OPENROUTER_URL, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: {
|
|
82
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
83
|
+
'Content-Type': 'application/json',
|
|
84
|
+
'HTTP-Referer': 'https://github.com/clai',
|
|
85
|
+
'X-Title': 'clai',
|
|
86
|
+
},
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
model: request.model,
|
|
89
|
+
messages: request.messages,
|
|
90
|
+
...(request.temperature !== undefined && {
|
|
91
|
+
temperature: request.temperature,
|
|
92
|
+
}),
|
|
93
|
+
...(request.maxTokens !== undefined && {
|
|
94
|
+
max_tokens: request.maxTokens,
|
|
95
|
+
}),
|
|
96
|
+
}),
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
clearTimeout(timeoutId);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Map HTTP status to appropriate AIError
|
|
106
|
+
*/
|
|
107
|
+
mapError(status, body) {
|
|
108
|
+
switch (status) {
|
|
109
|
+
case 401:
|
|
110
|
+
case 403:
|
|
111
|
+
return new AIError(`Authentication error (${status}): ${body || 'Invalid API key'}`, status);
|
|
112
|
+
case 408:
|
|
113
|
+
case 504:
|
|
114
|
+
return new AIError(`Timeout error (${status}): ${body || 'Request timed out'}`, status);
|
|
115
|
+
case 429:
|
|
116
|
+
return new AIError(`Rate limit exceeded (${status}): ${body || 'Too many requests'}`, status);
|
|
117
|
+
default:
|
|
118
|
+
return new AIError(`API error (${status}): ${body || 'Unknown error'}`, status);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Parse successful response JSON
|
|
123
|
+
*/
|
|
124
|
+
parseResponse(json) {
|
|
125
|
+
const response = json;
|
|
126
|
+
const content = response.choices?.[0]?.message?.content;
|
|
127
|
+
if (!content) {
|
|
128
|
+
throw new AIError('Invalid response: no content in choices');
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
content,
|
|
132
|
+
model: response.model,
|
|
133
|
+
usage: response.usage
|
|
134
|
+
? {
|
|
135
|
+
promptTokens: response.usage.prompt_tokens ?? 0,
|
|
136
|
+
completionTokens: response.usage.completion_tokens ?? 0,
|
|
137
|
+
totalTokens: response.usage.total_tokens ?? 0,
|
|
138
|
+
}
|
|
139
|
+
: undefined,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ClaiError } from '../error/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Chat message for OpenAI-compatible API
|
|
4
|
+
*/
|
|
5
|
+
export interface ChatMessage {
|
|
6
|
+
role: 'system' | 'user' | 'assistant';
|
|
7
|
+
content: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Request to send to AI provider
|
|
11
|
+
*/
|
|
12
|
+
export interface ChatRequest {
|
|
13
|
+
model: string;
|
|
14
|
+
messages: ChatMessage[];
|
|
15
|
+
temperature?: number;
|
|
16
|
+
maxTokens?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Response from AI provider
|
|
20
|
+
*/
|
|
21
|
+
export interface ChatResponse {
|
|
22
|
+
content: string;
|
|
23
|
+
model?: string;
|
|
24
|
+
usage?: {
|
|
25
|
+
promptTokens: number;
|
|
26
|
+
completionTokens: number;
|
|
27
|
+
totalTokens: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Provider interface for future extensibility
|
|
32
|
+
* Allows adding other providers (Anthropic, Ollama, etc.) in the future
|
|
33
|
+
*/
|
|
34
|
+
export interface AIProvider {
|
|
35
|
+
name: string;
|
|
36
|
+
isAvailable(): boolean;
|
|
37
|
+
complete(request: ChatRequest): Promise<ChatResponse>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Error class for AI operations
|
|
41
|
+
* Exit code 4 as per PRD (API error)
|
|
42
|
+
*/
|
|
43
|
+
export declare class AIError extends ClaiError {
|
|
44
|
+
readonly statusCode?: number;
|
|
45
|
+
constructor(message: string, statusCode?: number, cause?: Error);
|
|
46
|
+
}
|
package/dist/ai/types.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// AI Types and Interfaces for the clai CLI
|
|
2
|
+
import { ClaiError } from '../error/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Error class for AI operations
|
|
5
|
+
* Exit code 4 as per PRD (API error)
|
|
6
|
+
*/
|
|
7
|
+
export class AIError extends ClaiError {
|
|
8
|
+
statusCode;
|
|
9
|
+
constructor(message, statusCode, cause) {
|
|
10
|
+
super(message, 4, cause);
|
|
11
|
+
this.name = 'AIError';
|
|
12
|
+
this.statusCode = statusCode;
|
|
13
|
+
Object.setPrototypeOf(this, AIError.prototype);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type ColorMode = 'auto' | 'always' | 'never';
|
|
2
|
+
export interface Cli {
|
|
3
|
+
instruction: string;
|
|
4
|
+
model?: string;
|
|
5
|
+
provider?: string;
|
|
6
|
+
quiet: boolean;
|
|
7
|
+
verbose: number;
|
|
8
|
+
noColor: boolean;
|
|
9
|
+
color: ColorMode;
|
|
10
|
+
interactive: boolean;
|
|
11
|
+
force: boolean;
|
|
12
|
+
dryRun: boolean;
|
|
13
|
+
context?: string;
|
|
14
|
+
offline: boolean;
|
|
15
|
+
numOptions: number;
|
|
16
|
+
debug: boolean;
|
|
17
|
+
debugFile?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function parseCli(argv?: string[]): Cli;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Command, InvalidArgumentError } from 'commander';
|
|
2
|
+
import { UsageError } from '../error/index.js';
|
|
3
|
+
function parseNumOptions(value) {
|
|
4
|
+
const num = parseInt(value, 10);
|
|
5
|
+
if (isNaN(num)) {
|
|
6
|
+
throw new InvalidArgumentError('Must be a number');
|
|
7
|
+
}
|
|
8
|
+
// Clamp to 1-10
|
|
9
|
+
return Math.max(1, Math.min(10, num));
|
|
10
|
+
}
|
|
11
|
+
function parseColorMode(value) {
|
|
12
|
+
if (value === 'auto' || value === 'always' || value === 'never') {
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
throw new InvalidArgumentError('Must be auto, always, or never');
|
|
16
|
+
}
|
|
17
|
+
export function parseCli(argv = process.argv) {
|
|
18
|
+
const program = new Command();
|
|
19
|
+
program
|
|
20
|
+
.name('clai')
|
|
21
|
+
.description('AI-powered CLI that converts natural language into executable shell commands')
|
|
22
|
+
.version('0.1.0', '-V, --version', 'Output the version number')
|
|
23
|
+
.argument('[instruction]', 'Natural language instruction')
|
|
24
|
+
.option('-m, --model <model>', 'Override AI model')
|
|
25
|
+
.option('-p, --provider <provider>', 'Override AI provider')
|
|
26
|
+
.option('-q, --quiet', 'Minimal output', false)
|
|
27
|
+
.option('-v, --verbose', 'Increase verbosity (can be repeated)', (_, prev) => prev + 1, 0)
|
|
28
|
+
.option('--no-color', 'Disable color output')
|
|
29
|
+
.option('--color <mode>', 'Color mode: auto, always, never', parseColorMode, 'auto')
|
|
30
|
+
.option('-i, --interactive', 'Interactive mode (prompt execute/copy/abort)', false)
|
|
31
|
+
.option('-f, --force', 'Skip dangerous command confirmation', false)
|
|
32
|
+
.option('-n, --dry-run', 'Only print command(s), no execute', false)
|
|
33
|
+
.option('-c, --context <file>', 'Optional context file path')
|
|
34
|
+
.option('--offline', 'Offline mode (not implemented)', false)
|
|
35
|
+
.option('-o, --options <count>', 'Number of command options (1-10)', parseNumOptions, 1)
|
|
36
|
+
.option('-d, --debug', 'Print prompt/request to stderr', false)
|
|
37
|
+
.option('--debug-file [path]', 'Enable file logging (optional path)')
|
|
38
|
+
.configureOutput({
|
|
39
|
+
writeOut: (str) => process.stdout.write(str),
|
|
40
|
+
writeErr: (str) => process.stderr.write(str),
|
|
41
|
+
outputError: (str, write) => write(`Error: ${str}`),
|
|
42
|
+
});
|
|
43
|
+
program.parse(argv);
|
|
44
|
+
const opts = program.opts();
|
|
45
|
+
const args = program.args;
|
|
46
|
+
// instruction is required unless help/version was shown
|
|
47
|
+
const instruction = args[0];
|
|
48
|
+
if (!instruction) {
|
|
49
|
+
throw new UsageError('missing required argument: instruction');
|
|
50
|
+
}
|
|
51
|
+
// --no-color overrides --color
|
|
52
|
+
const noColor = opts.color === false; // commander sets this when --no-color is used
|
|
53
|
+
const colorMode = noColor ? 'never' : opts.color;
|
|
54
|
+
return {
|
|
55
|
+
instruction,
|
|
56
|
+
model: opts.model,
|
|
57
|
+
provider: opts.provider,
|
|
58
|
+
quiet: opts.quiet,
|
|
59
|
+
verbose: opts.verbose,
|
|
60
|
+
noColor,
|
|
61
|
+
color: colorMode,
|
|
62
|
+
interactive: opts.interactive,
|
|
63
|
+
force: opts.force,
|
|
64
|
+
dryRun: opts.dryRun,
|
|
65
|
+
context: opts.context,
|
|
66
|
+
offline: opts.offline,
|
|
67
|
+
numOptions: opts.options,
|
|
68
|
+
debug: opts.debug,
|
|
69
|
+
debugFile: opts.debugFile,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { FileConfig, Config } from './types.js';
|
|
2
|
+
import { Cli } from '../cli/index.js';
|
|
3
|
+
import { ClaiError } from '../error/index.js';
|
|
4
|
+
export declare class ConfigError extends ClaiError {
|
|
5
|
+
constructor(message: string, code?: number);
|
|
6
|
+
}
|
|
7
|
+
export declare function loadFileConfig(): FileConfig;
|
|
8
|
+
export declare function clearConfigCache(): void;
|
|
9
|
+
export declare function buildConfig(fileConfig: FileConfig, cli: Cli): Config;
|
|
10
|
+
export declare function getConfig(cli: Cli): Config;
|
|
11
|
+
export declare function getProviderApiKey(providerName: string, config: Config): string | undefined;
|
|
12
|
+
export declare function getProviderModel(providerName: string, config: Config): string;
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { readFileSync, accessSync, constants, statSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { resolve, join } from 'path';
|
|
4
|
+
import TOML from '@iarna/toml';
|
|
5
|
+
import { FileConfigSchema, } from './types.js';
|
|
6
|
+
import { ClaiError } from '../error/index.js';
|
|
7
|
+
// Config cache to avoid reloading
|
|
8
|
+
let configCache = null;
|
|
9
|
+
// Default config values
|
|
10
|
+
const DEFAULT_CONFIG = {
|
|
11
|
+
provider: {
|
|
12
|
+
default: 'openrouter',
|
|
13
|
+
fallback: [],
|
|
14
|
+
},
|
|
15
|
+
context: {
|
|
16
|
+
maxFiles: 10,
|
|
17
|
+
maxHistory: 3,
|
|
18
|
+
redactPaths: false,
|
|
19
|
+
redactUsername: false,
|
|
20
|
+
},
|
|
21
|
+
safety: {
|
|
22
|
+
confirmDangerous: true,
|
|
23
|
+
dangerousPatterns: [],
|
|
24
|
+
},
|
|
25
|
+
ui: {
|
|
26
|
+
color: 'auto',
|
|
27
|
+
interactive: false,
|
|
28
|
+
promptTimeout: 30000,
|
|
29
|
+
},
|
|
30
|
+
providers: {},
|
|
31
|
+
};
|
|
32
|
+
// Config file paths in order of precedence (lowest to highest)
|
|
33
|
+
function getConfigPaths() {
|
|
34
|
+
const paths = [];
|
|
35
|
+
// 1. /etc/clai/config.toml (lowest priority)
|
|
36
|
+
paths.push('/etc/clai/config.toml');
|
|
37
|
+
// 2. ~/.config/clai/config.toml
|
|
38
|
+
const home = homedir();
|
|
39
|
+
if (home) {
|
|
40
|
+
paths.push(join(home, '.config', 'clai', 'config.toml'));
|
|
41
|
+
}
|
|
42
|
+
// 3. $XDG_CONFIG_HOME/clai/config.toml
|
|
43
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME;
|
|
44
|
+
if (xdgConfig) {
|
|
45
|
+
paths.push(join(xdgConfig, 'clai', 'config.toml'));
|
|
46
|
+
}
|
|
47
|
+
// 4. ./.clai.toml (highest priority)
|
|
48
|
+
paths.push(resolve('.clai.toml'));
|
|
49
|
+
return paths;
|
|
50
|
+
}
|
|
51
|
+
// Check if file has correct permissions (Unix 0600)
|
|
52
|
+
function checkFilePermissions(path) {
|
|
53
|
+
// Only check on Unix-like systems
|
|
54
|
+
if (process.platform === 'win32') {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const stats = statSync(path);
|
|
59
|
+
const mode = stats.mode;
|
|
60
|
+
// Check if permissions are 0600 (owner read/write only)
|
|
61
|
+
const expectedMode = 0o600;
|
|
62
|
+
const actualMode = mode & 0o777;
|
|
63
|
+
if (actualMode !== expectedMode) {
|
|
64
|
+
throw new ConfigError(`Config file ${path} has insecure permissions ${actualMode.toString(8)}. Must be 0600.`, 3);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
if (err instanceof ConfigError) {
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
// If we can't stat the file, that's ok - it might not exist
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Custom error class with exit code
|
|
75
|
+
export class ConfigError extends ClaiError {
|
|
76
|
+
constructor(message, code = 3) {
|
|
77
|
+
super(message, code);
|
|
78
|
+
this.name = 'ConfigError';
|
|
79
|
+
Object.setPrototypeOf(this, ConfigError.prototype);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Check if file exists and is readable
|
|
83
|
+
function fileExists(path) {
|
|
84
|
+
try {
|
|
85
|
+
accessSync(path, constants.R_OK);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Load a single config file
|
|
93
|
+
function loadConfigFile(path) {
|
|
94
|
+
if (!fileExists(path)) {
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
// Check permissions on Unix
|
|
98
|
+
checkFilePermissions(path);
|
|
99
|
+
try {
|
|
100
|
+
const content = readFileSync(path, 'utf-8');
|
|
101
|
+
const parsed = TOML.parse(content);
|
|
102
|
+
// Transform kebab-case to camelCase for compatibility
|
|
103
|
+
const transformed = transformConfig(parsed);
|
|
104
|
+
// Validate with Zod
|
|
105
|
+
const result = FileConfigSchema.safeParse(transformed);
|
|
106
|
+
if (!result.success) {
|
|
107
|
+
throw new ConfigError(`Invalid config file ${path}: ${result.error.message}`, 3);
|
|
108
|
+
}
|
|
109
|
+
return result.data;
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (err instanceof ConfigError) {
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
if (err instanceof Error) {
|
|
116
|
+
throw new ConfigError(`Failed to parse config file ${path}: ${err.message}`, 3);
|
|
117
|
+
}
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Transform config keys from kebab-case to camelCase
|
|
122
|
+
function transformConfig(obj) {
|
|
123
|
+
if (Array.isArray(obj)) {
|
|
124
|
+
return obj.map(transformConfig);
|
|
125
|
+
}
|
|
126
|
+
if (obj && typeof obj === 'object') {
|
|
127
|
+
const result = {};
|
|
128
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
129
|
+
// Convert kebab-case to camelCase
|
|
130
|
+
const camelKey = key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
131
|
+
result[camelKey] = transformConfig(value);
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
return obj;
|
|
136
|
+
}
|
|
137
|
+
// Deep merge two objects
|
|
138
|
+
function deepMerge(target, source) {
|
|
139
|
+
const result = { ...target };
|
|
140
|
+
for (const key in source) {
|
|
141
|
+
if (source[key] !== undefined) {
|
|
142
|
+
if (typeof source[key] === 'object' &&
|
|
143
|
+
!Array.isArray(source[key]) &&
|
|
144
|
+
source[key] !== null &&
|
|
145
|
+
typeof result[key] === 'object' &&
|
|
146
|
+
!Array.isArray(result[key]) &&
|
|
147
|
+
result[key] !== null) {
|
|
148
|
+
result[key] = deepMerge(result[key], source[key]);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
result[key] = source[key];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
// Load environment variables that override config
|
|
158
|
+
function loadEnvConfig() {
|
|
159
|
+
const envConfig = {};
|
|
160
|
+
// Provider settings
|
|
161
|
+
if (process.env.CLAI_PROVIDER_DEFAULT || process.env.CLAI_PROVIDER_FALLBACK) {
|
|
162
|
+
envConfig.provider = {
|
|
163
|
+
default: process.env.CLAI_PROVIDER_DEFAULT ?? DEFAULT_CONFIG.provider.default,
|
|
164
|
+
fallback: process.env.CLAI_PROVIDER_FALLBACK
|
|
165
|
+
? process.env.CLAI_PROVIDER_FALLBACK.split(',').map((s) => s.trim())
|
|
166
|
+
: DEFAULT_CONFIG.provider.fallback,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// Context settings
|
|
170
|
+
if (process.env.CLAI_CONTEXT_MAX_FILES ||
|
|
171
|
+
process.env.CLAI_CONTEXT_MAX_HISTORY ||
|
|
172
|
+
process.env.CLAI_CONTEXT_REDACT_PATHS ||
|
|
173
|
+
process.env.CLAI_CONTEXT_REDACT_USERNAME) {
|
|
174
|
+
envConfig.context = {
|
|
175
|
+
maxFiles: process.env.CLAI_CONTEXT_MAX_FILES
|
|
176
|
+
? parseInt(process.env.CLAI_CONTEXT_MAX_FILES, 10) ||
|
|
177
|
+
DEFAULT_CONFIG.context.maxFiles
|
|
178
|
+
: DEFAULT_CONFIG.context.maxFiles,
|
|
179
|
+
maxHistory: process.env.CLAI_CONTEXT_MAX_HISTORY
|
|
180
|
+
? parseInt(process.env.CLAI_CONTEXT_MAX_HISTORY, 10) ||
|
|
181
|
+
DEFAULT_CONFIG.context.maxHistory
|
|
182
|
+
: DEFAULT_CONFIG.context.maxHistory,
|
|
183
|
+
redactPaths: process.env.CLAI_CONTEXT_REDACT_PATHS
|
|
184
|
+
? process.env.CLAI_CONTEXT_REDACT_PATHS === 'true'
|
|
185
|
+
: DEFAULT_CONFIG.context.redactPaths,
|
|
186
|
+
redactUsername: process.env.CLAI_CONTEXT_REDACT_USERNAME
|
|
187
|
+
? process.env.CLAI_CONTEXT_REDACT_USERNAME === 'true'
|
|
188
|
+
: DEFAULT_CONFIG.context.redactUsername,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// Safety settings
|
|
192
|
+
if (process.env.CLAI_SAFETY_CONFIRM_DANGEROUS ||
|
|
193
|
+
process.env.CLAI_SAFETY_DANGEROUS_PATTERNS) {
|
|
194
|
+
envConfig.safety = {
|
|
195
|
+
confirmDangerous: process.env.CLAI_SAFETY_CONFIRM_DANGEROUS
|
|
196
|
+
? process.env.CLAI_SAFETY_CONFIRM_DANGEROUS !== 'false'
|
|
197
|
+
: DEFAULT_CONFIG.safety.confirmDangerous,
|
|
198
|
+
dangerousPatterns: process.env.CLAI_SAFETY_DANGEROUS_PATTERNS
|
|
199
|
+
? process.env.CLAI_SAFETY_DANGEROUS_PATTERNS.split(',').map((s) => s.trim())
|
|
200
|
+
: DEFAULT_CONFIG.safety.dangerousPatterns,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
// UI settings
|
|
204
|
+
if (process.env.CLAI_UI_COLOR || process.env.CLAI_UI_PROMPT_TIMEOUT) {
|
|
205
|
+
const color = process.env.CLAI_UI_COLOR;
|
|
206
|
+
const promptTimeout = process.env.CLAI_UI_PROMPT_TIMEOUT
|
|
207
|
+
? parseInt(process.env.CLAI_UI_PROMPT_TIMEOUT, 10)
|
|
208
|
+
: undefined;
|
|
209
|
+
envConfig.ui = {
|
|
210
|
+
color: color === 'auto' || color === 'always' || color === 'never'
|
|
211
|
+
? color
|
|
212
|
+
: DEFAULT_CONFIG.ui.color,
|
|
213
|
+
interactive: DEFAULT_CONFIG.ui.interactive,
|
|
214
|
+
debugLogFile: DEFAULT_CONFIG.ui.debugLogFile,
|
|
215
|
+
promptTimeout: promptTimeout !== undefined && !isNaN(promptTimeout)
|
|
216
|
+
? Math.max(0, Math.min(300000, promptTimeout))
|
|
217
|
+
: DEFAULT_CONFIG.ui.promptTimeout,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return envConfig;
|
|
221
|
+
}
|
|
222
|
+
// Load file config (with caching)
|
|
223
|
+
export function loadFileConfig() {
|
|
224
|
+
if (configCache) {
|
|
225
|
+
return configCache;
|
|
226
|
+
}
|
|
227
|
+
// Start with defaults
|
|
228
|
+
let config = { ...DEFAULT_CONFIG };
|
|
229
|
+
// Load config files in order (lowest to highest priority)
|
|
230
|
+
const configPaths = getConfigPaths();
|
|
231
|
+
for (const path of configPaths) {
|
|
232
|
+
try {
|
|
233
|
+
const fileConfig = loadConfigFile(path);
|
|
234
|
+
if (Object.keys(fileConfig).length > 0) {
|
|
235
|
+
config = deepMerge(config, fileConfig);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
if (err instanceof ConfigError && err.code === 3) {
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
// Non-fatal errors for missing files
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Apply environment overrides
|
|
246
|
+
const envConfig = loadEnvConfig();
|
|
247
|
+
config = deepMerge(config, envConfig);
|
|
248
|
+
// Cache the result
|
|
249
|
+
configCache = config;
|
|
250
|
+
return config;
|
|
251
|
+
}
|
|
252
|
+
// Clear config cache (useful for testing)
|
|
253
|
+
export function clearConfigCache() {
|
|
254
|
+
configCache = null;
|
|
255
|
+
}
|
|
256
|
+
// Build runtime config from file config + CLI
|
|
257
|
+
export function buildConfig(fileConfig, cli) {
|
|
258
|
+
// Determine effective color mode
|
|
259
|
+
let color = fileConfig.ui?.color ?? DEFAULT_CONFIG.ui.color;
|
|
260
|
+
if (cli.noColor) {
|
|
261
|
+
color = 'never';
|
|
262
|
+
}
|
|
263
|
+
else if (cli.color !== 'auto') {
|
|
264
|
+
color = cli.color;
|
|
265
|
+
}
|
|
266
|
+
// Interactive mode: file can enable, CLI can only add
|
|
267
|
+
const interactive = (fileConfig.ui?.interactive ?? DEFAULT_CONFIG.ui.interactive) ||
|
|
268
|
+
cli.interactive;
|
|
269
|
+
// Clamp numOptions to 1-10
|
|
270
|
+
const numOptions = Math.max(1, Math.min(10, cli.numOptions));
|
|
271
|
+
// Resolve debug log file path
|
|
272
|
+
let debugLogFile;
|
|
273
|
+
if (cli.debugFile !== undefined) {
|
|
274
|
+
// CLI --debug-file (empty string means use default)
|
|
275
|
+
if (cli.debugFile === '') {
|
|
276
|
+
debugLogFile = join(homedir(), '.cache', 'clai', 'debug.log');
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
debugLogFile = resolve(cli.debugFile);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else if (fileConfig.ui?.debugLogFile) {
|
|
283
|
+
// From file config
|
|
284
|
+
const home = homedir();
|
|
285
|
+
debugLogFile = fileConfig.ui.debugLogFile
|
|
286
|
+
.replace(/^~\//, home + '/')
|
|
287
|
+
.replace(/^~$/, home);
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
provider: fileConfig.provider ?? DEFAULT_CONFIG.provider,
|
|
291
|
+
context: fileConfig.context ?? DEFAULT_CONFIG.context,
|
|
292
|
+
safety: fileConfig.safety ?? DEFAULT_CONFIG.safety,
|
|
293
|
+
ui: {
|
|
294
|
+
color,
|
|
295
|
+
debugLogFile,
|
|
296
|
+
interactive,
|
|
297
|
+
numOptions,
|
|
298
|
+
promptTimeout: fileConfig.ui?.promptTimeout ?? DEFAULT_CONFIG.ui.promptTimeout,
|
|
299
|
+
},
|
|
300
|
+
providers: fileConfig.providers ?? DEFAULT_CONFIG.providers,
|
|
301
|
+
// CLI overrides
|
|
302
|
+
model: cli.model,
|
|
303
|
+
providerName: cli.provider,
|
|
304
|
+
quiet: cli.quiet,
|
|
305
|
+
verbose: cli.verbose,
|
|
306
|
+
force: cli.force,
|
|
307
|
+
dryRun: cli.dryRun,
|
|
308
|
+
contextFile: cli.context,
|
|
309
|
+
offline: cli.offline,
|
|
310
|
+
debug: cli.debug,
|
|
311
|
+
debugFile: cli.debugFile,
|
|
312
|
+
instruction: cli.instruction,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
// Main entry point: load and build complete config
|
|
316
|
+
export function getConfig(cli) {
|
|
317
|
+
const fileConfig = loadFileConfig();
|
|
318
|
+
return buildConfig(fileConfig, cli);
|
|
319
|
+
}
|
|
320
|
+
// Get API key for a provider (with env var resolution)
|
|
321
|
+
export function getProviderApiKey(providerName, config) {
|
|
322
|
+
const providerConfig = config.providers[providerName];
|
|
323
|
+
if (!providerConfig) {
|
|
324
|
+
// Fallback to OPENROUTER_API_KEY for openrouter
|
|
325
|
+
if (providerName === 'openrouter') {
|
|
326
|
+
return process.env.OPENROUTER_API_KEY;
|
|
327
|
+
}
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
// Priority: apiKey (with env var substitution) > apiKeyEnv > env var
|
|
331
|
+
if (providerConfig.apiKey) {
|
|
332
|
+
// Resolve env var references like ${VAR} or $VAR
|
|
333
|
+
let apiKey = providerConfig.apiKey;
|
|
334
|
+
apiKey = apiKey.replace(/\$\{([^}]+)\}/g, (_, varName) => process.env[varName] || '');
|
|
335
|
+
apiKey = apiKey.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, varName) => process.env[varName] || '');
|
|
336
|
+
if (apiKey)
|
|
337
|
+
return apiKey;
|
|
338
|
+
}
|
|
339
|
+
if (providerConfig.apiKeyEnv) {
|
|
340
|
+
return process.env[providerConfig.apiKeyEnv];
|
|
341
|
+
}
|
|
342
|
+
// Fallback for openrouter
|
|
343
|
+
if (providerName === 'openrouter') {
|
|
344
|
+
return process.env.OPENROUTER_API_KEY;
|
|
345
|
+
}
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
// Get model for a provider
|
|
349
|
+
export function getProviderModel(providerName, config) {
|
|
350
|
+
// Priority: CLI --model > provider config > default
|
|
351
|
+
if (config.model) {
|
|
352
|
+
return config.model;
|
|
353
|
+
}
|
|
354
|
+
const providerConfig = config.providers[providerName];
|
|
355
|
+
if (providerConfig?.model) {
|
|
356
|
+
return providerConfig.model;
|
|
357
|
+
}
|
|
358
|
+
// Default models by provider
|
|
359
|
+
if (providerName === 'openrouter') {
|
|
360
|
+
return 'qwen/qwen3-coder';
|
|
361
|
+
}
|
|
362
|
+
return 'gpt-4o-mini';
|
|
363
|
+
}
|