@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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/dist/ai/index.d.ts +24 -0
  4. package/dist/ai/index.js +78 -0
  5. package/dist/ai/mock.d.ts +17 -0
  6. package/dist/ai/mock.js +49 -0
  7. package/dist/ai/parser.d.ts +14 -0
  8. package/dist/ai/parser.js +109 -0
  9. package/dist/ai/prompt.d.ts +12 -0
  10. package/dist/ai/prompt.js +76 -0
  11. package/dist/ai/providers/index.d.ts +1 -0
  12. package/dist/ai/providers/index.js +2 -0
  13. package/dist/ai/providers/openrouter.d.ts +31 -0
  14. package/dist/ai/providers/openrouter.js +142 -0
  15. package/dist/ai/types.d.ts +46 -0
  16. package/dist/ai/types.js +15 -0
  17. package/dist/cli/index.d.ts +19 -0
  18. package/dist/cli/index.js +71 -0
  19. package/dist/config/index.d.ts +12 -0
  20. package/dist/config/index.js +363 -0
  21. package/dist/config/types.d.ts +76 -0
  22. package/dist/config/types.js +40 -0
  23. package/dist/context/directory.d.ts +18 -0
  24. package/dist/context/directory.js +71 -0
  25. package/dist/context/history.d.ts +16 -0
  26. package/dist/context/history.js +89 -0
  27. package/dist/context/index.d.ts +24 -0
  28. package/dist/context/index.js +61 -0
  29. package/dist/context/redaction.d.ts +14 -0
  30. package/dist/context/redaction.js +57 -0
  31. package/dist/context/stdin.d.ts +13 -0
  32. package/dist/context/stdin.js +86 -0
  33. package/dist/context/system.d.ts +11 -0
  34. package/dist/context/system.js +56 -0
  35. package/dist/context/types.d.ts +31 -0
  36. package/dist/context/types.js +10 -0
  37. package/dist/error/index.d.ts +30 -0
  38. package/dist/error/index.js +50 -0
  39. package/dist/logging/file-logger.d.ts +12 -0
  40. package/dist/logging/file-logger.js +66 -0
  41. package/dist/logging/index.d.ts +15 -0
  42. package/dist/logging/index.js +33 -0
  43. package/dist/logging/logger.d.ts +15 -0
  44. package/dist/logging/logger.js +60 -0
  45. package/dist/main.d.ts +2 -0
  46. package/dist/main.js +192 -0
  47. package/dist/output/execute.d.ts +30 -0
  48. package/dist/output/execute.js +144 -0
  49. package/dist/output/index.d.ts +4 -0
  50. package/dist/output/index.js +7 -0
  51. package/dist/output/types.d.ts +48 -0
  52. package/dist/output/types.js +34 -0
  53. package/dist/output/validate.d.ts +23 -0
  54. package/dist/output/validate.js +42 -0
  55. package/dist/safety/index.d.ts +34 -0
  56. package/dist/safety/index.js +59 -0
  57. package/dist/safety/patterns.d.ts +23 -0
  58. package/dist/safety/patterns.js +96 -0
  59. package/dist/safety/types.d.ts +20 -0
  60. package/dist/safety/types.js +18 -0
  61. package/dist/signals/index.d.ts +4 -0
  62. package/dist/signals/index.js +35 -0
  63. package/dist/ui/App.d.ts +4 -0
  64. package/dist/ui/App.js +57 -0
  65. package/dist/ui/components/ActionPrompt.d.ts +8 -0
  66. package/dist/ui/components/ActionPrompt.js +9 -0
  67. package/dist/ui/components/CommandDisplay.d.ts +9 -0
  68. package/dist/ui/components/CommandDisplay.js +13 -0
  69. package/dist/ui/components/DangerousWarning.d.ts +6 -0
  70. package/dist/ui/components/DangerousWarning.js +6 -0
  71. package/dist/ui/components/Spinner.d.ts +15 -0
  72. package/dist/ui/components/Spinner.js +17 -0
  73. package/dist/ui/hooks/useAnimation.d.ts +27 -0
  74. package/dist/ui/hooks/useAnimation.js +85 -0
  75. package/dist/ui/hooks/useTerminalSize.d.ts +12 -0
  76. package/dist/ui/hooks/useTerminalSize.js +56 -0
  77. package/dist/ui/hooks/useTimeout.d.ts +9 -0
  78. package/dist/ui/hooks/useTimeout.js +31 -0
  79. package/dist/ui/index.d.ts +25 -0
  80. package/dist/ui/index.js +80 -0
  81. package/dist/ui/output.d.ts +20 -0
  82. package/dist/ui/output.js +56 -0
  83. package/dist/ui/spinner.d.ts +13 -0
  84. package/dist/ui/spinner.js +58 -0
  85. package/dist/ui/types.d.ts +105 -0
  86. package/dist/ui/types.js +60 -0
  87. package/dist/ui/utils/formatCommand.d.ts +50 -0
  88. package/dist/ui/utils/formatCommand.js +113 -0
  89. 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
+ }
@@ -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
+ }