@semalt-ai/code 1.4.3 → 1.5.0

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/index.js CHANGED
@@ -1,1218 +1,62 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- const os = require('os');
5
- const fs = require('fs');
6
- const path = require('path');
7
- const readline = require('readline');
8
- const { spawnSync } = require('child_process');
9
- const http = require('http');
10
- const https = require('https');
11
- const { URL } = require('url');
12
-
13
- const PACKAGE_JSON = require('./package.json');
14
-
15
- const DEFAULT_API_TIMEOUT_MS = 15 * 60 * 1000;
16
-
17
- // ── Config ────────────────────────────────────────────────────────────────────
18
-
19
- const DEFAULT_CONFIG = {
20
- api_base: 'http://127.0.0.1:8800',
21
- api_key: 'any',
22
- default_model: 'default',
23
- temperature: 0.7,
24
- request_timeout_ms: DEFAULT_API_TIMEOUT_MS,
25
- stream: true,
26
- models: [],
27
- };
28
-
29
- const CONFIG_PATH = path.join(os.homedir(), '.semalt-ai', 'config.json');
30
-
31
- function normalizeConfig(cfg = {}) {
32
- const merged = { ...DEFAULT_CONFIG, ...cfg };
33
- merged.models = Array.isArray(cfg.models)
34
- ? cfg.models
35
- .filter((entry) => entry &&
36
- typeof entry.api_base === 'string' &&
37
- typeof entry.api_key === 'string' &&
38
- typeof entry.model === 'string' &&
39
- entry.api_base.trim() &&
40
- entry.model.trim())
41
- .map((entry) => ({
42
- api_base: entry.api_base.trim(),
43
- api_key: entry.api_key,
44
- model: entry.model.trim(),
45
- }))
46
- : [];
47
- return merged;
48
- }
49
-
50
- function loadConfig() {
51
- if (fs.existsSync(CONFIG_PATH)) {
52
- try {
53
- const data = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
54
- return normalizeConfig(data);
55
- } catch {}
56
- }
57
- return normalizeConfig();
58
- }
59
-
60
- function saveConfig(cfg) {
61
- fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
62
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(normalizeConfig(cfg), null, 2));
63
- }
4
+ const { PACKAGE_JSON } = require('./lib/constants');
5
+ const { loadConfig, saveConfig } = require('./lib/config');
6
+ const ui = require('./lib/ui');
7
+ const { createPermissionManager } = require('./lib/permissions');
8
+ const { createToolExecutor, extractToolCalls } = require('./lib/tools');
9
+ const { readFileContext } = require('./lib/context');
10
+ const { createApiClient } = require('./lib/api');
11
+ const { createAgentRunner } = require('./lib/agent');
12
+ const { createCommands } = require('./lib/commands');
13
+ const { parseArgs } = require('./lib/args');
14
+ const { CONFIG_PATH } = require('./lib/constants');
64
15
 
65
16
  let config = loadConfig();
66
17
 
67
- // ── Terminal ──────────────────────────────────────────────────────────────────
68
-
69
- function getCols() {
70
- return process.stdout.columns || 80;
71
- }
72
-
73
- const RST = '\x1b[0m';
74
- const BOLD = '\x1b[1m';
75
- const DIM = '\x1b[2m';
76
-
77
- const FG_GRAY = '\x1b[38;5;245m';
78
- const FG_DARK = '\x1b[38;5;240m';
79
- const FG_BLUE = '\x1b[38;5;75m';
80
- const FG_CYAN = '\x1b[38;5;116m';
81
- const FG_GREEN = '\x1b[38;5;114m';
82
- const FG_YELLOW = '\x1b[38;5;222m';
83
- const FG_RED = '\x1b[38;5;203m';
84
- const FG_TEAL = '\x1b[38;5;73m';
85
-
86
- const BOX_H = '─';
87
- const BOX_V = '│';
88
- const BOX_TL = '╭';
89
- const BOX_TR = '╮';
90
- const BOX_BL = '╰';
91
- const BOX_BR = '╯';
92
-
93
- function hr(char = '─', color = FG_DARK) {
94
- process.stdout.write(`${color}${char.repeat(getCols())}${RST}\n`);
95
- }
96
-
97
- function stripAnsi(str) {
98
- return str.replace(/\x1b\[[^m]*m/g, '');
99
- }
100
-
101
- function boxLine(text, width) {
102
- const w = width || (getCols() - 4);
103
- const visible = stripAnsi(text).length;
104
- const pad = Math.max(0, w - visible);
105
- return ` ${FG_DARK}${BOX_V}${RST} ${text}${' '.repeat(pad)}${FG_DARK}${BOX_V}${RST}`;
106
- }
107
-
108
- // ── Permission system ─────────────────────────────────────────────────────────
109
-
110
- let AUTO_APPROVE_SHELL = false;
111
- let AUTO_APPROVE_FILE = false;
112
-
113
- function askPermissionLine(actionType) {
114
- return actionType === 'shell'
115
- ? ' 1. Yes 2. Yes, always for shell 3. No'
116
- : ' 1. Yes 2. Yes, always for files 3. No';
117
- }
118
-
119
- function readPermissionChoice() {
120
- return new Promise((resolve) => {
121
- if (!process.stdin.isTTY) {
122
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
123
- rl.question(` ${FG_YELLOW}?${RST} `, (answer) => {
124
- rl.close();
125
- resolve((answer || '').trim());
126
- });
127
- return;
128
- }
129
-
130
- const wasRaw = typeof process.stdin.isRaw === 'boolean' ? process.stdin.isRaw : false;
131
- readline.emitKeypressEvents(process.stdin);
132
- process.stdin.setRawMode(true);
133
- process.stdin.resume();
134
- process.stdout.write(` ${FG_YELLOW}?${RST} `);
135
-
136
- const onKeypress = (str, key = {}) => {
137
- if (key.ctrl && key.name === 'c') {
138
- process.stdin.setRawMode(wasRaw);
139
- process.stdin.removeListener('keypress', onKeypress);
140
- process.stdout.write('^C\n');
141
- process.kill(process.pid, 'SIGINT');
142
- return;
143
- }
144
-
145
- const value = (str || '').trim();
146
- if (!value) return;
147
-
148
- process.stdin.setRawMode(wasRaw);
149
- process.stdin.removeListener('keypress', onKeypress);
150
- process.stdout.write(`${value}\n`);
151
- resolve(value);
152
- };
153
-
154
- process.stdin.on('keypress', onKeypress);
155
- });
156
- }
157
-
158
- function askPermission(actionType, description) {
159
- return new Promise((resolve) => {
160
- if (actionType === 'shell' && AUTO_APPROVE_SHELL) {
161
- console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approved: ${description}${RST}`);
162
- return resolve(true);
163
- }
164
- if (actionType === 'file' && AUTO_APPROVE_FILE) {
165
- console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approved: ${description}${RST}`);
166
- return resolve(true);
167
- }
168
-
169
- console.log();
170
- console.log(` ${FG_YELLOW}${BOLD}⚠ Permission required${RST}`);
171
- console.log(` ${FG_GRAY}${actionType}: ${description}${RST}`);
172
- console.log();
173
- console.log(` ${FG_CYAN}${askPermissionLine(actionType)}${RST}`);
174
- console.log();
175
-
176
- readPermissionChoice().then((answer) => {
177
- const choice = (answer || '').trim().toLowerCase();
178
- if (choice === '1' || choice === 'y' || choice === 'yes') {
179
- resolve(true);
180
- } else if (choice === '2' || choice === 'a' || choice === 'always') {
181
- if (actionType === 'shell') AUTO_APPROVE_SHELL = true;
182
- else AUTO_APPROVE_FILE = true;
183
- console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approve enabled for ${actionType} operations${RST}`);
184
- resolve(true);
185
- } else {
186
- console.log(` ${FG_RED}✗${RST} ${FG_DARK}Denied${RST}`);
187
- resolve(false);
188
- }
189
- });
190
- });
191
- }
192
-
193
- // ── Agent: execute tools ──────────────────────────────────────────────────────
194
-
195
- async function agentExecShell(command) {
196
- const approved = await askPermission('shell', command);
197
- if (!approved) {
198
- return { exit_code: -1, stdout: '', stderr: 'Permission denied by user' };
199
- }
200
-
201
- console.log(` ${FG_DARK}$ ${command}${RST}`);
202
- try {
203
- const result = spawnSync(command, { shell: true, encoding: 'utf8', timeout: 60000 });
204
- const stdout = result.stdout || '';
205
- const stderr = result.stderr || '';
206
- const combined = stdout + (stderr ? '\n' + stderr : '');
207
- const lines = combined.trim().split('\n').filter(l => l !== '');
208
-
209
- if (lines.length > 20) {
210
- lines.slice(0, 15).forEach(l => console.log(` ${FG_GRAY}${l}${RST}`));
211
- console.log(` ${FG_DARK}... (${lines.length - 15} more lines)${RST}`);
212
- } else {
213
- lines.forEach(l => console.log(` ${FG_GRAY}${l}${RST}`));
214
- }
215
- console.log();
216
-
217
- return { exit_code: result.status ?? 0, stdout, stderr };
218
- } catch (e) {
219
- console.log(` ${FG_RED}✗ ${e.message}${RST}`);
220
- return { exit_code: -1, stdout: '', stderr: e.message };
221
- }
222
- }
223
-
224
- async function agentExecFile(action, filePath, content = null) {
225
- if (action === 'read') {
226
- const approved = await askPermission('file', `Read ${filePath}`);
227
- if (!approved) return { error: 'Permission denied' };
228
- try {
229
- const data = fs.readFileSync(filePath, 'utf8');
230
- const lines = data.split('\n').length;
231
- if (lines > 10) {
232
- console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath} (${lines} lines, ${data.length} chars)${RST}`);
233
- } else {
234
- console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath}${RST}`);
235
- }
236
- return { content: data, path: filePath };
237
- } catch (e) {
238
- console.log(` ${FG_RED}✗ ${e.message}${RST}`);
239
- return { error: e.message };
240
- }
241
- }
242
-
243
- if (action === 'write' || action === 'append') {
244
- let desc = `${action === 'write' ? 'Write' : 'Append to'} ${filePath}`;
245
- if (content) desc += ` (${content.length} chars)`;
246
- const approved = await askPermission('file', desc);
247
- if (!approved) return { error: 'Permission denied' };
248
- try {
249
- const dir = path.dirname(filePath);
250
- if (dir && dir !== '.') fs.mkdirSync(dir, { recursive: true });
251
- if (action === 'write') {
252
- fs.writeFileSync(filePath, content || '');
253
- } else {
254
- fs.appendFileSync(filePath, content || '');
255
- }
256
- const verb = action === 'write' ? 'Wrote' : 'Appended to';
257
- console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}${verb} ${filePath}${RST}`);
258
- return { status: 'ok', path: filePath, bytes: (content || '').length };
259
- } catch (e) {
260
- console.log(` ${FG_RED}✗ ${e.message}${RST}`);
261
- return { error: e.message };
262
- }
263
- }
264
-
265
- return { error: `Unknown action: ${action}` };
266
- }
267
-
268
- // ── Tool call extraction ──────────────────────────────────────────────────────
269
-
270
- function extractToolCalls(text) {
271
- const calls = [];
272
-
273
- // ```bash / ```shell / ```sh blocks
274
- for (const m of text.matchAll(/```(?:shell|bash|sh)\n([\s\S]*?)```/g)) {
275
- for (const line of m[1].trim().split('\n')) {
276
- const cmd = line.trim();
277
- if (cmd && !cmd.startsWith('#')) calls.push(['shell', cmd]);
278
- }
279
- }
280
-
281
- // <shell> tags
282
- for (const m of text.matchAll(/<shell>([\s\S]*?)<\/shell>/g))
283
- calls.push(['shell', m[1].trim()]);
284
-
285
- // <exec> tags
286
- for (const m of text.matchAll(/<exec>([\s\S]*?)<\/exec>/g))
287
- calls.push(['shell', m[1].trim()]);
288
-
289
- // <read_file> tags
290
- for (const m of text.matchAll(/<read_file>([\s\S]*?)<\/read_file>/g))
291
- calls.push(['read', m[1].trim()]);
292
-
293
- // <write_file> tags
294
- for (const m of text.matchAll(/<write_file\s+path="([^"]+)">([\s\S]*?)<\/write_file>/g))
295
- calls.push(['write', m[1], m[2]]);
296
-
297
- return calls;
18
+ function getConfig() {
19
+ return config;
298
20
  }
299
21
 
300
- // ── Stream Renderer ───────────────────────────────────────────────────────────
301
-
302
- const FG_CODE_BG = '\x1b[48;5;236m';
303
- const FG_CODE_BORDER = '\x1b[38;5;240m';
304
- const FG_CODE_LANG = '\x1b[38;5;75m';
305
- const FG_INLINE_CODE = '\x1b[38;5;215m';
306
- const FG_HEADING = '\x1b[38;5;75m';
307
- const FG_BULLET = '\x1b[38;5;114m';
308
- const FG_BOLD_TEXT = '\x1b[38;5;255m';
309
- const FG_DIFF_ADD = '\x1b[38;5;114m';
310
- const FG_DIFF_DEL = '\x1b[38;5;203m';
311
- const FG_DIFF_HDR = '\x1b[38;5;75m';
312
- const FG_FILEPATH = '\x1b[38;5;222m';
313
- const FG_TAG = '\x1b[38;5;176m';
314
-
315
- const KEYWORDS = new Set([
316
- 'def', 'class', 'import', 'from', 'return', 'if', 'else', 'elif',
317
- 'for', 'while', 'try', 'except', 'finally', 'with', 'as', 'in',
318
- 'not', 'and', 'or', 'is', 'None', 'True', 'False', 'async', 'await',
319
- 'function', 'const', 'let', 'var', 'export', 'require', 'func',
320
- 'type', 'struct', 'interface', 'package', 'fn', 'pub', 'use',
321
- 'mod', 'impl', 'match', 'enum', 'self', 'print', 'len', 'range',
322
- 'yield', 'lambda', 'raise', 'pass', 'break', 'continue', 'del',
323
- 'global', 'nonlocal', 'assert',
324
- ]);
325
-
326
- class StreamRenderer {
327
- constructor() {
328
- this.buffer = '';
329
- this.inCodeBlock = false;
330
- this.codeLang = '';
331
- }
332
-
333
- feed(chunk) {
334
- this.buffer += chunk;
335
- while (this.buffer.includes('\n')) {
336
- const idx = this.buffer.indexOf('\n');
337
- const line = this.buffer.slice(0, idx);
338
- this.buffer = this.buffer.slice(idx + 1);
339
- this._renderLine(line);
340
- }
341
- }
342
-
343
- flush() {
344
- if (this.buffer) {
345
- this._renderLine(this.buffer);
346
- this.buffer = '';
347
- }
348
- if (this.inCodeBlock) {
349
- process.stdout.write(` ${FG_CODE_BORDER}╰${'─'.repeat(50)}${RST}\n`);
350
- this.inCodeBlock = false;
351
- }
352
- }
353
-
354
- _renderLine(line) {
355
- // Code block start
356
- if (line.startsWith('```') && !this.inCodeBlock) {
357
- this.inCodeBlock = true;
358
- this.codeLang = line.slice(3).trim();
359
- const label = this.codeLang ? ` ${this.codeLang} ` : '';
360
- process.stdout.write(
361
- ` ${FG_CODE_BORDER}╭${'─'.repeat(20)}${RST}${FG_CODE_LANG}${label}${RST}` +
362
- `${FG_CODE_BORDER}${'─'.repeat(Math.max(1, 30 - label.length))}${RST}\n`
363
- );
364
- return;
365
- }
366
-
367
- // Code block end
368
- if (line.trim() === '```' && this.inCodeBlock) {
369
- process.stdout.write(` ${FG_CODE_BORDER}╰${'─'.repeat(50)}${RST}\n`);
370
- this.inCodeBlock = false;
371
- return;
372
- }
373
-
374
- // Inside code block
375
- if (this.inCodeBlock) {
376
- process.stdout.write(` ${FG_CODE_BORDER}│${RST} ${FG_CODE_BG}${this._colorizeCode(line)}${RST}\n`);
377
- return;
378
- }
379
-
380
- // Diff lines
381
- if (/^\+(?!\+\+)/.test(line)) { process.stdout.write(` ${FG_DIFF_ADD}${line}${RST}\n`); return; }
382
- if (/^-(?!--)/.test(line)) { process.stdout.write(` ${FG_DIFF_DEL}${line}${RST}\n`); return; }
383
- if (line.startsWith('@@')) { process.stdout.write(` ${FG_DIFF_HDR}${line}${RST}\n`); return; }
384
-
385
- // Tool tags
386
- if (/^<(exec|shell|read_file|write_file)/.test(line)) { process.stdout.write(` ${FG_TAG}${line}${RST}\n`); return; }
387
- if (/^<\/(exec|shell|read_file|write_file)/.test(line)){ process.stdout.write(` ${FG_TAG}${line}${RST}\n`); return; }
388
-
389
- // Headings
390
- const hm = line.match(/^(#{1,4})\s+(.*)/);
391
- if (hm) {
392
- const text = hm[2];
393
- if (hm[1].length <= 2) {
394
- process.stdout.write(` ${FG_HEADING}${BOLD}${hm[1]} ${text}${RST}\n`);
395
- } else {
396
- process.stdout.write(` ${FG_HEADING}${hm[1]} ${text}${RST}\n`);
397
- }
398
- return;
399
- }
400
-
401
- // Bullet points
402
- const bm = line.match(/^(\s*)([-*•])\s+(.*)/);
403
- if (bm) {
404
- process.stdout.write(` ${bm[1]}${FG_BULLET}•${RST} ${this._inlineFormat(bm[3])}\n`);
405
- return;
406
- }
407
-
408
- // Numbered list
409
- const nm = line.match(/^(\s*)(\d+\.)\s+(.*)/);
410
- if (nm) {
411
- process.stdout.write(` ${nm[1]}${FG_CYAN}${nm[2]}${RST} ${this._inlineFormat(nm[3])}\n`);
412
- return;
413
- }
414
-
415
- // Regular text
416
- process.stdout.write(` ${this._inlineFormat(line)}\n`);
417
- }
418
-
419
- _inlineFormat(text) {
420
- text = text.replace(/\*\*(.+?)\*\*/g, `${FG_BOLD_TEXT}${BOLD}$1${RST}`);
421
- text = text.replace(/`([^`]+)`/g, `${FG_INLINE_CODE}$1${RST}`);
422
- text = text.replace(/(?<!\w)((?:\/[\w\-.]+)+(?:\.\w+)?)/g, `${FG_FILEPATH}$1${RST}`);
423
- return text;
424
- }
425
-
426
- _colorizeCode(line) {
427
- const C_KW = '\x1b[38;5;176m';
428
- const C_STR = '\x1b[38;5;114m';
429
- const C_CMT = '\x1b[38;5;242m';
430
- const C_NUM = '\x1b[38;5;215m';
431
- const C_RST = `${RST}${FG_CODE_BG}`;
432
-
433
- let result = '';
434
- let i = 0;
435
-
436
- while (i < line.length) {
437
- // Comment
438
- if (line[i] === '#' || (line[i] === '/' && line[i + 1] === '/')) {
439
- result += `${C_CMT}${line.slice(i)}${C_RST}`;
440
- break;
441
- }
442
-
443
- // String
444
- if (line[i] === '"' || line[i] === "'") {
445
- const q = line[i];
446
- let j = i + 1;
447
- while (j < line.length && line[j] !== q) {
448
- if (line[j] === '\\') j++;
449
- j++;
450
- }
451
- j = Math.min(j + 1, line.length);
452
- result += `${C_STR}${line.slice(i, j)}${C_RST}`;
453
- i = j;
454
- continue;
455
- }
456
-
457
- // Identifier / keyword
458
- if (/[a-zA-Z_]/.test(line[i])) {
459
- let j = i;
460
- while (j < line.length && /\w/.test(line[j])) j++;
461
- const word = line.slice(i, j);
462
- result += KEYWORDS.has(word) ? `${C_KW}${word}${C_RST}` : word;
463
- i = j;
464
- continue;
465
- }
466
-
467
- // Number
468
- if (/\d/.test(line[i])) {
469
- let j = i;
470
- while (j < line.length && /[\d.]/.test(line[j])) j++;
471
- result += `${C_NUM}${line.slice(i, j)}${C_RST}`;
472
- i = j;
473
- continue;
474
- }
475
-
476
- result += line[i++];
477
- }
478
-
479
- return result;
480
- }
481
- }
482
-
483
- // ── API Client ────────────────────────────────────────────────────────────────
484
-
485
- function apiUrl(urlPath) {
486
- const base = (config.api_base || '').replace(/\/$/, '');
487
- const normalizedBase = /\/v1$/i.test(base) ? base : `${base}/v1`;
488
- const normalizedPath = urlPath.startsWith('/v1/') ? urlPath.slice(3) : urlPath;
489
- return `${normalizedBase}${normalizedPath}`;
490
- }
491
-
492
- function describeModelProfile(profile) {
493
- return `${profile.model} @ ${profile.api_base}`;
494
- }
495
-
496
- function setActiveModelProfile(profile) {
497
- config.api_base = profile.api_base;
498
- config.api_key = profile.api_key;
499
- config.default_model = profile.model;
22
+ function setConfig(nextConfig) {
23
+ config = nextConfig;
500
24
  saveConfig(config);
501
25
  }
502
26
 
503
- function chooseSavedModelProfile(rl, currentModel, cwd, onDone) {
504
- if (!config.models.length) {
505
- console.log(` ${FG_RED}✗${RST} ${FG_GRAY}No saved model profiles. Use semalt-code models add first.${RST}`);
506
- onDone(currentModel);
507
- return;
508
- }
509
-
510
- console.log();
511
- console.log(` ${FG_TEAL}${BOLD}◆ Saved Models${RST}`);
512
- console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
513
- config.models.forEach((profile, index) => {
514
- const active = profile.api_base === config.api_base &&
515
- profile.api_key === config.api_key &&
516
- profile.model === currentModel;
517
- const marker = active ? `${FG_GREEN}●${RST}` : `${FG_DARK}○${RST}`;
518
- console.log(` ${marker} ${FG_CYAN}${index + 1}.${RST} ${describeModelProfile(profile)}`);
519
- });
520
- console.log();
521
-
522
- rl.question(` ${FG_TEAL}${BOLD}Select model>${RST} `, (answer) => {
523
- const selected = Number((answer || '').trim());
524
- if (!Number.isInteger(selected) || selected < 1 || selected > config.models.length) {
525
- console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Invalid selection${RST}`);
526
- onDone(currentModel);
527
- return;
528
- }
529
-
530
- const profile = config.models[selected - 1];
531
- setActiveModelProfile(profile);
532
- console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Model profile → ${describeModelProfile(profile)}${RST}`);
533
- printStatusBar(profile.model, cwd);
534
- onDone(profile.model);
535
- });
536
- }
537
-
538
- function estimateTokens(text) {
539
- return Math.floor((text || '').length / 4);
540
- }
541
-
542
- function httpRequest(urlStr, options, body) {
543
- return new Promise((resolve, reject) => {
544
- const url = new URL(urlStr);
545
- const lib = url.protocol === 'https:' ? https : http;
546
- const reqOpts = {
547
- hostname: url.hostname,
548
- port: url.port || (url.protocol === 'https:' ? 443 : 80),
549
- path: url.pathname + url.search,
550
- method: options.method || 'GET',
551
- headers: options.headers || {},
552
- };
553
-
554
- const req = lib.request(reqOpts, (res) => {
555
- resolve(res);
556
- });
557
-
558
- req.on('error', reject);
559
-
560
- if (options.timeout) {
561
- req.setTimeout(options.timeout, () => {
562
- req.destroy(new Error('Request timed out'));
563
- });
564
- }
565
-
566
- if (body) req.write(body);
567
- req.end();
568
- });
569
- }
570
-
571
- async function chatStream(messages, { model, temperature, maxTokens } = {}) {
572
- const payload = {
573
- model: model || config.default_model,
574
- messages,
575
- temperature: temperature !== undefined ? temperature : config.temperature,
576
- stream: true,
577
- };
578
-
579
- if (maxTokens !== undefined) {
580
- payload.max_tokens = maxTokens;
581
- }
582
-
583
- const body = JSON.stringify(payload);
584
- let res;
585
-
586
- try {
587
- res = await httpRequest(apiUrl('/v1/chat/completions'), {
588
- method: 'POST',
589
- timeout: config.request_timeout_ms,
590
- headers: {
591
- 'Content-Type': 'application/json',
592
- 'Authorization': `Bearer ${config.api_key}`,
593
- 'Content-Length': Buffer.byteLength(body),
594
- },
595
- }, body);
596
- } catch (e) {
597
- process.stdout.write(`\n ${FG_RED}✗ ${e.message}${RST}\n`);
598
- return '';
599
- }
600
-
601
- if (res.statusCode !== 200) {
602
- process.stdout.write(`\n ${FG_RED}✗ Error: HTTP ${res.statusCode}${RST}\n`);
603
- res.resume();
604
- return '';
605
- }
606
-
607
- return new Promise((resolve) => {
608
- const startTime = Date.now();
609
- let fullText = '';
610
- let reasoningText = '';
611
- let tokenCount = 0;
612
- let inReasoning = false;
613
- const renderer = new StreamRenderer();
614
- let lineBuffer = '';
615
-
616
- res.setEncoding('utf8');
617
-
618
- res.on('data', (chunk) => {
619
- lineBuffer += chunk;
620
- const lines = lineBuffer.split('\n');
621
- lineBuffer = lines.pop();
622
-
623
- for (const line of lines) {
624
- if (!line.startsWith('data: ')) continue;
625
- const data = line.slice(6).trim();
626
- if (data === '[DONE]') continue;
627
- try {
628
- const obj = JSON.parse(data);
629
- const delta = ((obj.choices || [])[0] || {}).delta || {};
630
-
631
- const reasoning = delta.reasoning_content || '';
632
- if (reasoning) {
633
- if (!inReasoning) {
634
- inReasoning = true;
635
- process.stdout.write(`\n ${FG_DARK}${DIM}⟨thinking⟩${RST}`);
636
- }
637
- reasoningText += reasoning;
638
- tokenCount++;
639
- if (tokenCount % 20 === 0) process.stdout.write(`${FG_DARK}.${RST}`);
640
- }
641
-
642
- const content = delta.content || '';
643
- if (content) {
644
- if (inReasoning) {
645
- inReasoning = false;
646
- process.stdout.write(`${FG_DARK}⟨/thinking⟩${RST}\n`);
647
- }
648
- renderer.feed(content);
649
- fullText += content;
650
- tokenCount++;
651
- }
652
- } catch {}
653
- }
654
- });
655
-
656
- res.on('end', () => {
657
- renderer.flush();
658
- const elapsed = (Date.now() - startTime) / 1000;
659
- const estTokens = estimateTokens(fullText + reasoningText);
660
- const tps = tokenCount / (elapsed || 1);
661
- const cols = getCols();
662
- process.stdout.write(`\n ${FG_DARK}${'─'.repeat(Math.min(cols, 60) - 4)}${RST}\n`);
663
- let costLine = `${FG_DARK}~${estTokens} tokens · ${elapsed.toFixed(1)}s · ${Math.round(tps)} tok/s${RST}`;
664
- if (reasoningText) costLine += ` ${FG_DARK}· ${estimateTokens(reasoningText)} thinking${RST}`;
665
- process.stdout.write(` ${costLine}\n`);
666
- resolve(fullText);
667
- });
668
-
669
- res.on('error', (e) => {
670
- process.stdout.write(`\n ${FG_RED}✗ ${e.message}${RST}\n`);
671
- resolve('');
672
- });
673
- });
674
- }
675
-
676
- async function chatSync(messages, { model } = {}) {
677
- const payload = {
678
- model: model || config.default_model,
679
- messages,
680
- temperature: config.temperature,
681
- stream: false,
682
- };
683
-
684
- const body = JSON.stringify(payload);
685
- let res;
686
-
687
- try {
688
- res = await httpRequest(apiUrl('/v1/chat/completions'), {
689
- method: 'POST',
690
- timeout: config.request_timeout_ms,
691
- headers: {
692
- 'Content-Type': 'application/json',
693
- 'Authorization': `Bearer ${config.api_key}`,
694
- 'Content-Length': Buffer.byteLength(body),
695
- },
696
- }, body);
697
- } catch (e) {
698
- console.log(` ${FG_RED}✗ ${e.message}${RST}`);
699
- return '';
700
- }
701
-
702
- return new Promise((resolve) => {
703
- let data = '';
704
- res.setEncoding('utf8');
705
- res.on('data', chunk => data += chunk);
706
- res.on('end', () => {
707
- if (res.statusCode !== 200) {
708
- console.log(` ${FG_RED}✗ Error: HTTP ${res.statusCode} — ${data}${RST}`);
709
- return resolve('');
710
- }
711
- try {
712
- const parsed = JSON.parse(data);
713
- const content = parsed.choices[0].message.content;
714
- console.log(content);
715
- resolve(content);
716
- } catch (e) {
717
- console.log(` ${FG_RED}✗ Parse error: ${e.message}${RST}`);
718
- resolve('');
719
- }
720
- });
721
- res.on('error', (e) => {
722
- console.log(` ${FG_RED}✗ ${e.message}${RST}`);
723
- resolve('');
724
- });
725
- });
726
- }
727
-
728
- // ── Agent loop ────────────────────────────────────────────────────────────────
729
-
730
- async function runAgentLoop(messages, model, maxIterations = 10) {
731
- const cols = getCols();
732
-
733
- for (let iteration = 0; iteration < maxIterations; iteration++) {
734
- console.log();
735
- console.log(` ${FG_DARK}${'─'.repeat(Math.min(cols, 70) - 4)}${RST}`);
736
- process.stdout.write(` ${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`);
737
- if (iteration > 0) process.stdout.write(` ${FG_DARK}(step ${iteration + 1})${RST}`);
738
- console.log();
739
- console.log(` ${FG_DARK}${'─'.repeat(Math.min(cols, 70) - 4)}${RST}`);
740
- console.log();
741
- process.stdout.write(' ');
742
-
743
- const reply = await chatStream(messages, { model });
744
- if (!reply) break;
745
-
746
- messages.push({ role: 'assistant', content: reply });
747
-
748
- const toolCalls = extractToolCalls(reply);
749
- if (toolCalls.length === 0) break;
750
-
751
- console.log(`\n ${FG_TEAL}◆${RST} ${FG_GRAY}Found ${toolCalls.length} action(s) to execute${RST}`);
752
-
753
- const results = [];
754
- let aborted = false;
755
-
756
- for (const call of toolCalls) {
757
- if (call[0] === 'shell') {
758
- const result = await agentExecShell(call[1]);
759
- if (result.stderr === 'Permission denied by user') {
760
- results.push(`Command \`${call[1]}\`: Permission denied by user.`);
761
- aborted = true;
762
- } else {
763
- let out = result.stdout;
764
- if (result.stderr) out += '\nSTDERR: ' + result.stderr;
765
- results.push(`Command \`${call[1]}\`:\nExit code: ${result.exit_code}\n${out}`);
766
- }
767
- } else if (call[0] === 'read') {
768
- const result = await agentExecFile('read', call[1]);
769
- if (result.error) results.push(`Read ${call[1]}: Error — ${result.error}`);
770
- else results.push(`File ${call[1]}:\n${result.content}`);
771
- } else if (call[0] === 'write') {
772
- const result = await agentExecFile('write', call[1], call[2]);
773
- if (result.error) results.push(`Write ${call[1]}: Error — ${result.error}`);
774
- else results.push(`Wrote ${result.bytes} bytes to ${call[1]}`);
775
- }
776
- }
777
-
778
- const feedback = results.join('\n\n');
779
- messages.push({
780
- role: 'user',
781
- content: `Tool execution results:\n\n${feedback}\n\nContinue with the task. If everything is done, summarize what was accomplished.`,
782
- });
783
-
784
- if (aborted) {
785
- console.log(`\n ${FG_YELLOW}⚠${RST} ${FG_GRAY}Some actions were denied. Continuing with partial results.${RST}`);
786
- }
787
- }
788
-
789
- return messages;
790
- }
791
-
792
- // ── Banner / UI ───────────────────────────────────────────────────────────────
793
-
794
- function printBanner() {
795
- const w = Math.min(getCols() - 4, 60);
796
- console.log();
797
- console.log(` ${FG_DARK}${BOX_TL}${BOX_H.repeat(w + 2)}${BOX_TR}${RST}`);
798
- console.log(boxLine('', w));
799
- console.log(boxLine(`${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`, w));
800
- console.log(boxLine(`${FG_GRAY}Self-hosted AI coding assistant${RST}`, w));
801
- console.log(boxLine('', w));
802
- console.log(` ${FG_DARK}${BOX_BL}${BOX_H.repeat(w + 2)}${BOX_BR}${RST}`);
803
- console.log();
804
- }
805
-
806
- function printStatusBar(model, cwd) {
807
- const left = `${FG_TEAL}${BOLD}◆${RST} ${FG_GRAY}${model}${RST}`;
808
- const right = `${FG_DARK}${cwd}${RST}`;
809
- console.log(` ${left} ${FG_DARK}│${RST} ${right}`);
810
- hr();
811
- }
812
-
813
- function printHelpHints() {
814
- const hints = [
815
- [`${FG_BLUE}/help${RST}`, 'commands'],
816
- [`${FG_BLUE}/model${RST}`, 'switch'],
817
- [`${FG_BLUE}/file${RST}`, 'context'],
818
- [`${FG_BLUE}/clear${RST}`, 'reset'],
819
- ];
820
- process.stdout.write(` ${FG_DARK}Tips:${RST}`);
821
- for (const [cmd, desc] of hints) {
822
- process.stdout.write(` ${cmd} ${FG_DARK}${desc}${RST}`);
823
- }
824
- console.log();
825
- console.log();
826
- }
827
-
828
- // ── File context ──────────────────────────────────────────────────────────────
829
-
830
- function readFileContext(filePaths) {
831
- let context = '';
832
- for (const fp of filePaths) {
833
- if (!fs.existsSync(fp)) {
834
- console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Not found: ${fp}${RST}`);
835
- continue;
836
- }
837
- const stat = fs.statSync(fp);
838
- if (stat.isFile()) {
839
- try {
840
- const content = fs.readFileSync(fp, 'utf8');
841
- context += `\n--- File: ${fp} ---\n${content}\n`;
842
- console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Loaded ${fp} (${content.length} chars)${RST}`);
843
- } catch (e) {
844
- console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${fp}: ${e.message}${RST}`);
845
- }
846
- } else if (stat.isDirectory()) {
847
- let count = 0;
848
- function walkDir(dir) {
849
- if (count >= 50) return;
850
- let entries;
851
- try { entries = fs.readdirSync(dir).sort(); } catch { return; }
852
- for (const entry of entries) {
853
- if (entry.startsWith('.')) continue;
854
- const full = path.join(dir, entry);
855
- let s;
856
- try { s = fs.statSync(full); } catch { continue; }
857
- if (s.isFile()) {
858
- try {
859
- const content = fs.readFileSync(full, 'utf8').slice(0, 10000);
860
- context += `\n--- File: ${full} ---\n${content}\n`;
861
- count++;
862
- } catch {}
863
- } else if (s.isDirectory()) {
864
- walkDir(full);
865
- }
866
- }
867
- }
868
- walkDir(fp);
869
- console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Loaded ${count} files from ${fp}${RST}`);
870
- }
871
- }
872
- return context;
873
- }
874
-
875
- // ── System prompt ─────────────────────────────────────────────────────────────
876
-
877
- function getSystemPrompt() {
878
- return `You are Semalt.AI, an expert AI coding assistant running in the user's terminal. You have the ability to execute shell commands and file operations.
879
-
880
- IMPORTANT: You CAN execute commands on the user's system. When you need to run a command, use this exact format:
881
-
882
- To run a shell command:
883
- <exec>command here</exec>
884
-
885
- To read a file:
886
- <read_file>/path/to/file</read_file>
887
-
888
- To write a file:
889
- <write_file path="/path/to/file">file content here</write_file>
890
-
891
- Rules:
892
- - When the user asks you to do something on their system (create files, install packages, check status, etc.), USE the tools above — do NOT just print instructions.
893
- - Each command will be shown to the user for approval before execution.
894
- - After execution, you will receive the output and can continue working.
895
- - You can chain multiple operations in one response.
896
- - Be concise. Provide working solutions.
897
- - Use markdown for code blocks in explanations.
898
- - Current working directory: ${process.cwd()}`;
899
- }
900
-
901
- // ── Commands ──────────────────────────────────────────────────────────────────
902
-
903
- async function cmdChat(opts) {
904
- printBanner();
905
- const cwd = process.cwd();
906
- let currentModel = opts.model || config.default_model;
907
- let isRunningAgent = false;
908
-
909
- printStatusBar(currentModel, cwd);
910
- printHelpHints();
911
-
912
- let messages = [{ role: 'system', content: getSystemPrompt() }];
913
- const cols = getCols();
914
-
915
- const rl = readline.createInterface({
916
- input: process.stdin,
917
- output: process.stdout,
918
- terminal: true,
919
- });
920
-
921
- rl.on('close', () => {
922
- console.log(`\n ${FG_GRAY}Goodbye!${RST}\n`);
923
- process.exit(0);
924
- });
925
-
926
- rl.on('SIGINT', () => {
927
- if (isRunningAgent) return;
928
- console.log(`\n ${FG_YELLOW}Use Ctrl+D or type exit to quit.${RST}`);
929
- rl.prompt(true);
930
- });
931
-
932
- async function prompt() {
933
- rl.setPrompt(` ${FG_TEAL}${BOLD}>${RST} `);
934
- rl.question(rl.getPrompt(), async (input) => {
935
- const text = (input || '').trim();
936
-
937
- if (!text) return prompt();
938
-
939
- if (['exit', 'quit', '/exit', '/quit'].includes(text.toLowerCase())) {
940
- console.log(`\n ${FG_GRAY}Goodbye!${RST}\n`);
941
- rl.close();
942
- return;
943
- }
944
-
945
- if (text === '/help') {
946
- console.log(`
947
- ${FG_BLUE}${BOLD}Commands:${RST}
948
- ${FG_CYAN}/file <path>${RST} ${FG_GRAY}Load file or dir into context${RST}
949
- ${FG_CYAN}/model${RST} ${FG_GRAY}Choose saved model profile${RST}
950
- ${FG_CYAN}/model <name>${RST} ${FG_GRAY}Switch model manually${RST}
951
- ${FG_CYAN}/models${RST} ${FG_GRAY}Choose saved model profile${RST}
952
- ${FG_CYAN}/clear${RST} ${FG_GRAY}Clear conversation${RST}
953
- ${FG_CYAN}/compact${RST} ${FG_GRAY}Show token usage${RST}
954
- ${FG_CYAN}/shell <cmd>${RST} ${FG_GRAY}Run shell command directly${RST}
955
- ${FG_CYAN}!<cmd>${RST} ${FG_GRAY}Run shell command directly${RST}
956
- ${FG_CYAN}/approve${RST} ${FG_GRAY}Toggle auto-approve for all actions${RST}
957
- ${FG_CYAN}/config${RST} ${FG_GRAY}Show config${RST}
958
- ${FG_CYAN}exit${RST} ${FG_GRAY}Quit${RST}
959
-
960
- ${FG_DARK}The AI can execute commands — you'll be asked to approve each one.${RST}
961
- `);
962
- return prompt();
963
- }
964
-
965
- if (text.startsWith('/file ')) {
966
- const fp = text.slice(6).trim();
967
- const ctx = readFileContext([fp]);
968
- if (ctx) messages.push({ role: 'user', content: `Here is the file context:\n${ctx}` });
969
- return prompt();
970
- }
971
-
972
- if (text === '/model' || text === '/models') {
973
- chooseSavedModelProfile(rl, currentModel, cwd, (nextModel) => {
974
- currentModel = nextModel;
975
- prompt();
976
- });
977
- return;
978
- }
979
-
980
- if (text.startsWith('/model ')) {
981
- currentModel = text.slice(7).trim();
982
- console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Model → ${currentModel}${RST}`);
983
- printStatusBar(currentModel, cwd);
984
- return prompt();
985
- }
986
-
987
- if (text === '/clear') {
988
- messages = [{ role: 'system', content: getSystemPrompt() }];
989
- AUTO_APPROVE_SHELL = false;
990
- AUTO_APPROVE_FILE = false;
991
- console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Conversation and approvals cleared${RST}\n`);
992
- return prompt();
993
- }
994
-
995
- if (text === '/compact' || text === '/cost') {
996
- const total = messages.reduce((s, m) => s + estimateTokens(m.content), 0);
997
- console.log(` ${FG_GRAY}${messages.length - 1} messages · ~${total} tokens${RST}\n`);
998
- return prompt();
999
- }
1000
-
1001
- if (text === '/config') {
1002
- console.log(` ${FG_GRAY}${JSON.stringify(config, null, 2)}${RST}\n`);
1003
- return prompt();
1004
- }
1005
-
1006
- if (text === '/approve') {
1007
- AUTO_APPROVE_SHELL = !AUTO_APPROVE_SHELL;
1008
- AUTO_APPROVE_FILE = !AUTO_APPROVE_FILE;
1009
- const state = AUTO_APPROVE_SHELL ? 'ON' : 'OFF';
1010
- const color = AUTO_APPROVE_SHELL ? FG_GREEN : FG_RED;
1011
- console.log(` ${color}●${RST} ${FG_GRAY}Auto-approve: ${state}${RST}\n`);
1012
- return prompt();
1013
- }
1014
-
1015
- if (text.startsWith('/shell ') || text.startsWith('!')) {
1016
- const cmd = text.startsWith('/shell ') ? text.slice(7).trim() : text.slice(1).trim();
1017
- await agentExecShell(cmd);
1018
- return prompt();
1019
- }
1020
-
1021
- messages.push({ role: 'user', content: text });
1022
- console.log(` ${FG_DARK}${'─'.repeat(Math.min(cols, 70) - 4)}${RST}`);
1023
-
1024
- rl.pause();
1025
- isRunningAgent = true;
1026
- messages = await runAgentLoop(messages, currentModel);
1027
- isRunningAgent = false;
1028
- rl.resume();
1029
-
1030
- console.log(` ${FG_DARK}${'━'.repeat(Math.min(cols, 70) - 4)}${RST}`);
1031
- console.log();
1032
-
1033
- prompt();
1034
- });
1035
- }
1036
-
1037
- prompt();
1038
- }
1039
-
1040
- async function cmdCode(opts, promptArgs) {
1041
- if (!promptArgs.length) {
1042
- console.log(` ${FG_RED}Usage: semalt-code code <prompt>${RST}`);
1043
- return;
1044
- }
1045
- const userPrompt = promptArgs.join(' ');
1046
- const context = opts.file ? readFileContext(opts.file) : '';
1047
- const fullPrompt = context ? `Context files:\n${context}\n\nTask: ${userPrompt}` : userPrompt;
1048
-
1049
- let messages = [
1050
- { role: 'system', content: getSystemPrompt() },
1051
- { role: 'user', content: fullPrompt },
1052
- ];
1053
- messages = await runAgentLoop(messages, opts.model || config.default_model);
1054
- console.log();
1055
- }
1056
-
1057
- async function cmdEdit(opts, filePath, instructionArgs) {
1058
- if (!filePath) {
1059
- console.log(` ${FG_RED}Usage: semalt-code edit <file> <instruction>${RST}`);
1060
- return;
1061
- }
1062
- if (!fs.existsSync(filePath)) {
1063
- console.log(` ${FG_RED}✗ File not found: ${filePath}${RST}`);
1064
- return;
1065
- }
1066
-
1067
- const content = fs.readFileSync(filePath, 'utf8');
1068
- const instruction = instructionArgs.join(' ');
1069
-
1070
- const messages = [
1071
- { role: 'system', content: 'You are Semalt.AI. Output ONLY the modified file. No explanations, no fences.' },
1072
- { role: 'user', content: `File: ${filePath}\n\n\`\`\`\n${content}\n\`\`\`\n\nInstruction: ${instruction}` },
1073
- ];
1074
-
1075
- console.log(` ${FG_GRAY}Editing ${filePath}...${RST}`);
1076
- let result = await chatSync(messages, { model: opts.model });
1077
-
1078
- if (result && !opts.dryRun) {
1079
- if (result.startsWith('```')) {
1080
- const lines = result.split('\n');
1081
- result = lines.at(-1).trim() === '```'
1082
- ? lines.slice(1, -1).join('\n')
1083
- : lines.slice(1).join('\n');
1084
- }
1085
- fs.writeFileSync(filePath, result);
1086
- console.log(` ${FG_GREEN}✓ Saved: ${filePath}${RST}`);
1087
- } else if (opts.dryRun) {
1088
- console.log(` ${FG_YELLOW}⚠ Dry run — not modified${RST}`);
1089
- }
1090
- }
1091
-
1092
- async function cmdShell(opts, commandArgs) {
1093
- const command = commandArgs.join(' ');
1094
- if (!command) {
1095
- console.log(` ${FG_RED}Usage: semalt-code shell <command>${RST}`);
1096
- return;
1097
- }
1098
- const result = await agentExecShell(command);
1099
-
1100
- if (opts.analyze) {
1101
- const messages = [
1102
- { role: 'system', content: 'You are Semalt.AI. Analyze the command output concisely.' },
1103
- { role: 'user', content: `Command: ${command}\nExit: ${result.exit_code}\nStdout:\n${result.stdout}\nStderr:\n${result.stderr}` },
1104
- ];
1105
- console.log();
1106
- console.log(` ${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`);
1107
- console.log();
1108
- process.stdout.write(' ');
1109
- await chatStream(messages, { model: opts.model });
1110
- console.log();
1111
- }
1112
- }
1113
-
1114
- async function cmdModels() {
1115
- if (!config.models.length) {
1116
- console.log(` ${FG_RED}✗${RST} ${FG_GRAY}No saved model profiles. Use semalt-code models add first.${RST}`);
1117
- return;
1118
- }
1119
-
1120
- console.log();
1121
- console.log(` ${FG_TEAL}${BOLD}◆ Saved Models${RST}`);
1122
- console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
1123
- config.models.forEach((profile, index) => {
1124
- const active = profile.api_base === config.api_base &&
1125
- profile.api_key === config.api_key &&
1126
- profile.model === config.default_model;
1127
- const marker = active ? `${FG_GREEN}●${RST}` : `${FG_DARK}○${RST}`;
1128
- console.log(` ${marker} ${FG_CYAN}${index + 1}.${RST} ${describeModelProfile(profile)}`);
1129
- });
1130
- console.log();
1131
- }
1132
-
1133
- async function cmdModelsAdd() {
1134
- const rl = readline.createInterface({
1135
- input: process.stdin,
1136
- output: process.stdout,
1137
- terminal: true,
1138
- });
1139
-
1140
- function ask(question) {
1141
- return new Promise((resolve) => {
1142
- rl.question(question, (answer) => resolve((answer || '').trim()));
1143
- });
1144
- }
1145
-
1146
- console.log();
1147
- console.log(` ${FG_TEAL}${BOLD}◆ Add Model Profile${RST}`);
1148
- console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
1149
-
1150
- const apiBase = await ask(` ${FG_CYAN}API Base URL:${RST} `);
1151
- const apiKey = await ask(` ${FG_CYAN}API Key:${RST} `);
1152
- const modelId = await ask(` ${FG_CYAN}Model ID:${RST} `);
1153
- rl.close();
1154
-
1155
- if (!apiBase || !modelId) {
1156
- console.log(`\n ${FG_RED}✗${RST} ${FG_GRAY}API Base URL and Model ID are required.${RST}\n`);
1157
- return;
1158
- }
1159
-
1160
- const profile = {
1161
- api_base: apiBase,
1162
- api_key: apiKey || 'any',
1163
- model: modelId,
1164
- };
1165
-
1166
- config.models.push(profile);
1167
- setActiveModelProfile(profile);
1168
- console.log(`\n ${FG_GREEN}✓${RST} Saved model profile: ${describeModelProfile(profile)}\n`);
1169
- }
1170
-
1171
- function cmdInit(opts) {
1172
- const cfg = {
1173
- api_base: opts.apiBase || 'http://127.0.0.1:8800',
1174
- api_key: opts.apiKey || 'any',
1175
- default_model: opts.defaultModel || 'default',
1176
- temperature: 0.7,
1177
- request_timeout_ms: DEFAULT_API_TIMEOUT_MS,
1178
- stream: true,
1179
- models: config.models,
1180
- };
1181
- saveConfig(cfg);
1182
- config = cfg;
1183
- console.log(`\n ${FG_GREEN}✓${RST} Config saved to ${CONFIG_PATH}`);
1184
- console.log(` ${FG_GRAY}${JSON.stringify(cfg, null, 2)}${RST}\n`);
1185
- }
1186
-
1187
- // ── CLI arg parser ────────────────────────────────────────────────────────────
1188
-
1189
- function parseArgs(argv) {
1190
- const opts = {};
1191
- const positional = [];
1192
- let i = 0;
1193
- while (i < argv.length) {
1194
- switch (argv[i]) {
1195
- case '-m': case '--model': opts.model = argv[++i]; break;
1196
- case '-f': case '--file': (opts.file = opts.file || []).push(argv[++i]); break;
1197
- case '-a': case '--analyze': opts.analyze = true; break;
1198
- case '--dry-run': opts.dryRun = true; break;
1199
- case '--api-base': opts.apiBase = argv[++i]; break;
1200
- case '--api-key': opts.apiKey = argv[++i]; break;
1201
- case '--default-model': opts.defaultModel = argv[++i]; break;
1202
- default: positional.push(argv[i]);
1203
- }
1204
- i++;
1205
- }
1206
- return { opts, positional };
1207
- }
1208
-
1209
- // ── Main ──────────────────────────────────────────────────────────────────────
27
+ const permissionManager = createPermissionManager(ui);
28
+ const { agentExecShell, agentExecFile } = createToolExecutor(permissionManager, ui);
29
+ const apiClient = createApiClient({
30
+ getConfig,
31
+ saveConfig: (nextConfig) => {
32
+ saveConfig(nextConfig);
33
+ config = nextConfig;
34
+ },
35
+ ui,
36
+ });
37
+ const { runAgentLoop } = createAgentRunner({
38
+ chatStream: apiClient.chatStream,
39
+ extractToolCalls,
40
+ agentExecShell,
41
+ agentExecFile,
42
+ ui,
43
+ });
44
+ const commands = createCommands({
45
+ getConfig,
46
+ setConfig,
47
+ permissionManager,
48
+ ui,
49
+ apiClient,
50
+ runAgentLoop,
51
+ readFileContext,
52
+ agentExecShell,
53
+ });
1210
54
 
1211
55
  async function main() {
1212
56
  const rawArgs = process.argv.slice(2);
1213
57
 
1214
58
  if (rawArgs.length === 0) {
1215
- await cmdChat({});
59
+ await commands.cmdChat({});
1216
60
  return;
1217
61
  }
1218
62
 
@@ -1256,30 +100,29 @@ Config: ${CONFIG_PATH}
1256
100
 
1257
101
  if (command === 'chat') {
1258
102
  const { opts } = parseArgs(rawArgs.slice(1));
1259
- await cmdChat(opts);
103
+ await commands.cmdChat(opts);
1260
104
  } else if (command === 'code') {
1261
105
  const { opts, positional } = parseArgs(rawArgs.slice(1));
1262
- await cmdCode(opts, positional);
106
+ await commands.cmdCode(opts, positional);
1263
107
  } else if (command === 'edit') {
1264
108
  const { opts, positional } = parseArgs(rawArgs.slice(1));
1265
- await cmdEdit(opts, positional[0], positional.slice(1));
109
+ await commands.cmdEdit(opts, positional[0], positional.slice(1));
1266
110
  } else if (command === 'shell') {
1267
111
  const { opts, positional } = parseArgs(rawArgs.slice(1));
1268
- await cmdShell(opts, positional);
112
+ await commands.cmdShell(opts, positional);
1269
113
  } else if (command === 'models') {
1270
- if (rawArgs[1] === 'add') await cmdModelsAdd();
1271
- else await cmdModels();
114
+ if (rawArgs[1] === 'add') await commands.cmdModelsAdd();
115
+ else await commands.cmdModels();
1272
116
  } else if (command === 'init') {
1273
117
  const { opts } = parseArgs(rawArgs.slice(1));
1274
- cmdInit(opts);
118
+ commands.cmdInit(opts);
1275
119
  } else {
1276
- // Unknown command — treat all args as chat mode
1277
120
  const { opts } = parseArgs(rawArgs);
1278
- await cmdChat(opts);
121
+ await commands.cmdChat(opts);
1279
122
  }
1280
123
  }
1281
124
 
1282
- main().catch((e) => {
1283
- process.stderr.write(`\n ${FG_RED}✗ Fatal: ${e.message}${RST}\n\n`);
125
+ main().catch((error) => {
126
+ process.stderr.write(`\n ${ui.FG_RED}✗ Fatal: ${error.message}${ui.RST}\n\n`);
1284
127
  process.exit(1);
1285
128
  });