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