@semalt-ai/code 1.5.0 → 1.7.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/README.md CHANGED
@@ -49,7 +49,7 @@ semalt-code
49
49
  Create the CLI config:
50
50
 
51
51
  ```bash
52
- semalt-code init --api-base http://127.0.0.1:8800 --api-key any --default-model default
52
+ semalt-code init --api-base http://127.0.0.1:8800 --api-key any --dashboard-url https://cli.semalt.ai --default-model default
53
53
  ```
54
54
 
55
55
  This writes configuration to:
@@ -64,6 +64,8 @@ Example config:
64
64
  {
65
65
  "api_base": "http://127.0.0.1:8800",
66
66
  "api_key": "any",
67
+ "dashboard_url": "https://cli.semalt.ai",
68
+ "auth_token": "",
67
69
  "default_model": "default",
68
70
  "temperature": 0.7,
69
71
  "request_timeout_ms": 900000,
@@ -94,8 +96,17 @@ semalt-code [command] [options]
94
96
  - `semalt-code shell <command>`
95
97
  Runs a shell command with approval prompts.
96
98
 
99
+ - `semalt-code login`
100
+ Starts browser-based login and stores the confirmed CLI token in `~/.semalt-ai/config.json`.
101
+
102
+ - `semalt-code whoami`
103
+ Shows the current user associated with the saved CLI auth token.
104
+
105
+ - `semalt-code logout`
106
+ Logs out the current CLI user and clears the saved local auth token.
107
+
97
108
  - `semalt-code models`
98
- Lists all saved model profiles.
109
+ Lists your models from the dashboard and lets you choose the current one for the CLI.
99
110
 
100
111
  - `semalt-code models add`
101
112
  Opens an interactive flow to add an API base URL, API key, and model ID as a reusable model profile.
@@ -137,7 +148,10 @@ Available interactive commands:
137
148
 
138
149
  - `/help`
139
150
  - `/file <path>`
151
+ - `/whoami`
152
+ - `/logout`
140
153
  - `/model`
154
+ - `/login`
141
155
  - `/model <name>`
142
156
  - `/models`
143
157
  - `/clear`
package/index.js CHANGED
@@ -74,7 +74,10 @@ Commands:
74
74
  code <prompt> Generate code from a prompt
75
75
  edit <file> <instruction> Edit a file with AI
76
76
  shell <command> Run and optionally analyze a shell command
77
- models List saved model profiles
77
+ login Authorize CLI via browser
78
+ whoami Show current authorized user
79
+ logout Clear current CLI login
80
+ models Choose one of your dashboard models
78
81
  models add Add a saved model profile
79
82
  init Initialize config
80
83
 
@@ -85,6 +88,7 @@ Options:
85
88
  --dry-run Don't save changes (edit command)
86
89
  --api-base <url> API base URL (init)
87
90
  --api-key <key> API key (init)
91
+ --dashboard-url <url> Dashboard URL (init)
88
92
  --default-model <name> Default model (init)
89
93
  -v, --version Show CLI version
90
94
 
@@ -110,6 +114,12 @@ Config: ${CONFIG_PATH}
110
114
  } else if (command === 'shell') {
111
115
  const { opts, positional } = parseArgs(rawArgs.slice(1));
112
116
  await commands.cmdShell(opts, positional);
117
+ } else if (command === 'login') {
118
+ await commands.cmdLogin();
119
+ } else if (command === 'whoami') {
120
+ await commands.cmdWhoAmI();
121
+ } else if (command === 'logout') {
122
+ await commands.cmdLogout();
113
123
  } else if (command === 'models') {
114
124
  if (rawArgs[1] === 'add') await commands.cmdModelsAdd();
115
125
  else await commands.cmdModels();
package/lib/api.js CHANGED
@@ -27,10 +27,27 @@ function createApiClient({ getConfig, saveConfig, ui }) {
27
27
  return `${normalizedBase}${normalizedPath}`;
28
28
  }
29
29
 
30
+ function dashboardUrl(urlPath) {
31
+ const config = getConfig();
32
+ const base = (config.dashboard_url || '').replace(/\/$/, '');
33
+ const normalizedPath = urlPath.startsWith('/') ? urlPath : `/${urlPath}`;
34
+ return `${base}${normalizedPath}`;
35
+ }
36
+
30
37
  function describeModelProfile(profile) {
31
38
  return `${profile.model} @ ${profile.api_base}`;
32
39
  }
33
40
 
41
+ function requireAuthToken() {
42
+ const config = getConfig();
43
+ if (!config.auth_token) {
44
+ const error = new Error('Not logged in. Run semalt login first.');
45
+ error.statusCode = 401;
46
+ throw error;
47
+ }
48
+ return config.auth_token;
49
+ }
50
+
34
51
  function setActiveModelProfile(profile) {
35
52
  const config = getConfig();
36
53
  config.api_base = profile.api_base;
@@ -105,6 +122,107 @@ function createApiClient({ getConfig, saveConfig, ui }) {
105
122
  });
106
123
  }
107
124
 
125
+ async function requestJson(urlStr, { method = 'GET', timeout, headers = {}, body } = {}) {
126
+ const requestBody = body === undefined ? undefined : JSON.stringify(body);
127
+ const finalHeaders = { ...headers };
128
+ if (requestBody !== undefined) {
129
+ finalHeaders['Content-Type'] = 'application/json';
130
+ finalHeaders['Content-Length'] = Buffer.byteLength(requestBody);
131
+ }
132
+
133
+ const res = await httpRequest(urlStr, {
134
+ method,
135
+ timeout: timeout || getConfig().request_timeout_ms,
136
+ headers: finalHeaders,
137
+ }, requestBody);
138
+
139
+ return new Promise((resolve, reject) => {
140
+ let data = '';
141
+ res.setEncoding('utf8');
142
+ res.on('data', (chunk) => {
143
+ data += chunk;
144
+ });
145
+ res.on('end', () => {
146
+ let parsed = null;
147
+ try {
148
+ parsed = data ? JSON.parse(data) : null;
149
+ } catch {
150
+ parsed = data ? { error: data } : null;
151
+ }
152
+
153
+ if (res.statusCode < 200 || res.statusCode >= 300) {
154
+ const error = new Error((parsed && parsed.error) || `HTTP ${res.statusCode}`);
155
+ error.statusCode = res.statusCode;
156
+ error.data = parsed;
157
+ reject(error);
158
+ return;
159
+ }
160
+
161
+ resolve(parsed);
162
+ });
163
+ res.on('error', reject);
164
+ });
165
+ }
166
+
167
+ function requestCliLogin() {
168
+ return requestJson(dashboardUrl('/api/auth/cli/request'), {
169
+ method: 'POST',
170
+ timeout: 15000,
171
+ });
172
+ }
173
+
174
+ function getCliLoginStatus(id, hash) {
175
+ return requestJson(dashboardUrl('/api/auth/cli/status'), {
176
+ method: 'POST',
177
+ timeout: 15000,
178
+ body: { id, hash },
179
+ });
180
+ }
181
+
182
+ function dashboardWhoAmI() {
183
+ const authToken = requireAuthToken();
184
+ return requestJson(dashboardUrl('/api/auth/me'), {
185
+ method: 'GET',
186
+ timeout: 15000,
187
+ headers: {
188
+ 'Authorization': `Bearer ${authToken}`,
189
+ },
190
+ });
191
+ }
192
+
193
+ function dashboardLogout() {
194
+ const authToken = requireAuthToken();
195
+ return requestJson(dashboardUrl('/api/auth/logout'), {
196
+ method: 'POST',
197
+ timeout: 15000,
198
+ headers: {
199
+ 'Authorization': `Bearer ${authToken}`,
200
+ },
201
+ });
202
+ }
203
+
204
+ function dashboardListModels() {
205
+ const authToken = requireAuthToken();
206
+ return requestJson(dashboardUrl('/api/models'), {
207
+ method: 'GET',
208
+ timeout: 15000,
209
+ headers: {
210
+ 'Authorization': `Bearer ${authToken}`,
211
+ },
212
+ });
213
+ }
214
+
215
+ function dashboardGetModelForCli(id) {
216
+ const authToken = requireAuthToken();
217
+ return requestJson(dashboardUrl(`/api/models/${encodeURIComponent(String(id))}/cli`), {
218
+ method: 'GET',
219
+ timeout: 15000,
220
+ headers: {
221
+ 'Authorization': `Bearer ${authToken}`,
222
+ },
223
+ });
224
+ }
225
+
108
226
  async function chatStream(messages, { model, temperature, maxTokens } = {}) {
109
227
  const config = getConfig();
110
228
  const payload = {
@@ -271,8 +389,14 @@ function createApiClient({ getConfig, saveConfig, ui }) {
271
389
  chatStream,
272
390
  chatSync,
273
391
  chooseSavedModelProfile,
392
+ dashboardGetModelForCli,
393
+ dashboardListModels,
394
+ dashboardLogout,
395
+ dashboardWhoAmI,
274
396
  describeModelProfile,
275
397
  estimateTokens,
398
+ getCliLoginStatus,
399
+ requestCliLogin,
276
400
  setActiveModelProfile,
277
401
  };
278
402
  }
package/lib/args.js CHANGED
@@ -28,6 +28,9 @@ function parseArgs(argv) {
28
28
  case '--api-key':
29
29
  opts.apiKey = argv[++i];
30
30
  break;
31
+ case '--dashboard-url':
32
+ opts.dashboardUrl = argv[++i];
33
+ break;
31
34
  case '--default-model':
32
35
  opts.defaultModel = argv[++i];
33
36
  break;
package/lib/commands.js CHANGED
@@ -36,12 +36,24 @@ function createCommands({
36
36
  const {
37
37
  chatStream,
38
38
  chatSync,
39
- chooseSavedModelProfile,
39
+ dashboardGetModelForCli,
40
+ dashboardListModels,
41
+ dashboardLogout,
42
+ dashboardWhoAmI,
40
43
  describeModelProfile,
41
44
  estimateTokens,
45
+ getCliLoginStatus,
46
+ requestCliLogin,
42
47
  setActiveModelProfile,
43
48
  } = apiClient;
44
49
 
50
+ const LOGIN_POLL_INTERVAL_MS = 2000;
51
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
52
+
53
+ function formatUserLine(label, value) {
54
+ return ` ${FG_CYAN}${label}:${RST} ${FG_GRAY}${value}${RST}`;
55
+ }
56
+
45
57
  async function cmdChat(opts) {
46
58
  printBanner();
47
59
  const cwd = process.cwd();
@@ -87,9 +99,12 @@ function createCommands({
87
99
  console.log(`
88
100
  ${FG_BLUE}${BOLD}Commands:${RST}
89
101
  ${FG_CYAN}/file <path>${RST} ${FG_GRAY}Load file or dir into context${RST}
90
- ${FG_CYAN}/model${RST} ${FG_GRAY}Choose saved model profile${RST}
102
+ ${FG_CYAN}/login${RST} ${FG_GRAY}Authorize this CLI via browser${RST}
103
+ ${FG_CYAN}/whoami${RST} ${FG_GRAY}Show current authorized user${RST}
104
+ ${FG_CYAN}/logout${RST} ${FG_GRAY}Clear current CLI login${RST}
105
+ ${FG_CYAN}/model${RST} ${FG_GRAY}Show or set current model id${RST}
91
106
  ${FG_CYAN}/model <name>${RST} ${FG_GRAY}Switch model manually${RST}
92
- ${FG_CYAN}/models${RST} ${FG_GRAY}Choose saved model profile${RST}
107
+ ${FG_CYAN}/models${RST} ${FG_GRAY}Choose one of your dashboard models${RST}
93
108
  ${FG_CYAN}/clear${RST} ${FG_GRAY}Clear conversation${RST}
94
109
  ${FG_CYAN}/compact${RST} ${FG_GRAY}Show token usage${RST}
95
110
  ${FG_CYAN}/shell <cmd>${RST} ${FG_GRAY}Run shell command directly${RST}
@@ -103,6 +118,23 @@ function createCommands({
103
118
  continue;
104
119
  }
105
120
 
121
+ if (text === '/login') {
122
+ await cmdLogin();
123
+ printStatusBar(currentModel, cwd);
124
+ continue;
125
+ }
126
+
127
+ if (text === '/whoami') {
128
+ await cmdWhoAmI();
129
+ continue;
130
+ }
131
+
132
+ if (text === '/logout') {
133
+ await cmdLogout();
134
+ printStatusBar(currentModel, cwd);
135
+ continue;
136
+ }
137
+
106
138
  if (text.startsWith('/file ')) {
107
139
  const fp = text.slice(6).trim();
108
140
  const ctx = readFileContext([fp], ui);
@@ -110,19 +142,13 @@ function createCommands({
110
142
  continue;
111
143
  }
112
144
 
113
- if (text === '/model' || text === '/models') {
114
- await new Promise((resolve) => {
115
- const rl = readline.createInterface({
116
- input: process.stdin,
117
- output: process.stdout,
118
- terminal: true,
119
- });
120
- chooseSavedModelProfile(rl, currentModel, cwd, (nextModel) => {
121
- currentModel = nextModel;
122
- rl.close();
123
- resolve();
124
- });
125
- });
145
+ if (text === '/models') {
146
+ currentModel = await cmdModels(currentModel, cwd);
147
+ continue;
148
+ }
149
+
150
+ if (text === '/model') {
151
+ console.log(` ${FG_GRAY}Current model: ${currentModel}${RST}\n`);
126
152
  continue;
127
153
  }
128
154
 
@@ -255,22 +281,71 @@ function createCommands({
255
281
 
256
282
  async function cmdModels() {
257
283
  const config = getConfig();
258
- if (!config.models.length) {
259
- console.log(` ${FG_RED}✗${RST} ${FG_GRAY}No saved model profiles. Use semalt-code models add first.${RST}`);
260
- return;
284
+ let response;
285
+ try {
286
+ response = await dashboardListModels();
287
+ } catch (error) {
288
+ console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${error.message}${RST}\n`);
289
+ return config.default_model;
290
+ }
291
+
292
+ const models = Array.isArray(response && response.models) ? response.models : [];
293
+ if (!models.length) {
294
+ console.log(` ${FG_RED}✗${RST} ${FG_GRAY}No models found in your dashboard account.${RST}\n`);
295
+ return config.default_model;
261
296
  }
262
297
 
263
298
  console.log();
264
- console.log(` ${FG_TEAL}${BOLD}◆ Saved Models${RST}`);
265
- console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
266
- config.models.forEach((profile, index) => {
267
- const active = profile.api_base === config.api_base &&
268
- profile.api_key === config.api_key &&
269
- profile.model === config.default_model;
299
+ console.log(` ${FG_TEAL}${BOLD}◆ Your Models${RST}`);
300
+ console.log(` ${FG_DARK}${'─'.repeat(60)}${RST}`);
301
+ models.forEach((model, index) => {
302
+ const active = model.base_url === config.api_base && model.model_id === config.default_model;
270
303
  const marker = active ? `${FG_GREEN}●${RST}` : `${FG_DARK}○${RST}`;
271
- console.log(` ${marker} ${FG_CYAN}${index + 1}.${RST} ${describeModelProfile(profile)}`);
304
+ console.log(` ${marker} ${FG_CYAN}${index + 1}.${RST} ${FG_GRAY}${model.name} · ${model.model_id} @ ${model.base_url}${RST}`);
272
305
  });
273
306
  console.log();
307
+
308
+ const rl = readline.createInterface({
309
+ input: process.stdin,
310
+ output: process.stdout,
311
+ terminal: true,
312
+ });
313
+
314
+ const selectedIndex = await new Promise((resolve) => {
315
+ rl.question(` ${FG_TEAL}${BOLD}Select model>${RST} `, (answer) => {
316
+ rl.close();
317
+ resolve(Number((answer || '').trim()));
318
+ });
319
+ });
320
+
321
+ if (!Number.isInteger(selectedIndex) || selectedIndex < 1 || selectedIndex > models.length) {
322
+ console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Invalid selection${RST}\n`);
323
+ return config.default_model;
324
+ }
325
+
326
+ const selectedModel = models[selectedIndex - 1];
327
+ let credentialsResponse;
328
+ try {
329
+ credentialsResponse = await dashboardGetModelForCli(selectedModel.id);
330
+ } catch (error) {
331
+ console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${error.message}${RST}\n`);
332
+ return config.default_model;
333
+ }
334
+
335
+ const model = credentialsResponse && credentialsResponse.model ? credentialsResponse.model : null;
336
+ if (!model) {
337
+ console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to load selected model.${RST}\n`);
338
+ return config.default_model;
339
+ }
340
+
341
+ setConfig({
342
+ ...config,
343
+ api_base: model.base_url,
344
+ api_key: model.api_key,
345
+ default_model: model.model_id,
346
+ });
347
+ console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Current model → ${model.name} (${model.model_id})${RST}\n`);
348
+ return model.model_id;
274
349
  }
275
350
 
276
351
  async function cmdModelsAdd() {
@@ -317,6 +392,8 @@ function createCommands({
317
392
  const cfg = {
318
393
  api_base: opts.apiBase || 'http://127.0.0.1:8800',
319
394
  api_key: opts.apiKey || 'any',
395
+ dashboard_url: opts.dashboardUrl || current.dashboard_url,
396
+ auth_token: current.auth_token || '',
320
397
  default_model: opts.defaultModel || 'default',
321
398
  temperature: 0.7,
322
399
  request_timeout_ms: DEFAULT_API_TIMEOUT_MS,
@@ -328,14 +405,120 @@ function createCommands({
328
405
  console.log(` ${FG_GRAY}${JSON.stringify(cfg, null, 2)}${RST}\n`);
329
406
  }
330
407
 
408
+ async function cmdLogin() {
409
+ console.log();
410
+ console.log(` ${FG_TEAL}${BOLD}◆ CLI Login${RST}`);
411
+ console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
412
+
413
+ let loginRequest;
414
+ try {
415
+ loginRequest = await requestCliLogin();
416
+ } catch (error) {
417
+ console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to start login via ${getConfig().dashboard_url}: ${error.message}${RST}\n`);
418
+ return;
419
+ }
420
+
421
+ console.log(` ${FG_GRAY}Open this URL in your browser and confirm the login:${RST}`);
422
+ console.log(` ${FG_CYAN}${loginRequest.verification_url}${RST}`);
423
+ console.log(` ${FG_DARK}Waiting for confirmation...${RST}`);
424
+
425
+ const startedAt = Date.now();
426
+ while (Date.now() - startedAt < LOGIN_TIMEOUT_MS) {
427
+ await new Promise((resolve) => setTimeout(resolve, LOGIN_POLL_INTERVAL_MS));
428
+
429
+ let status;
430
+ try {
431
+ status = await getCliLoginStatus(loginRequest.id, loginRequest.hash);
432
+ } catch (error) {
433
+ if (error.statusCode === 404 || error.statusCode === 410) {
434
+ console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Login token is no longer valid.${RST}\n`);
435
+ return;
436
+ }
437
+ continue;
438
+ }
439
+
440
+ if (status.status === 'authorized') {
441
+ const config = getConfig();
442
+ setConfig({
443
+ ...config,
444
+ dashboard_url: config.dashboard_url,
445
+ auth_token: loginRequest.token,
446
+ });
447
+ console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}CLI token saved to ${CONFIG_PATH}${RST}\n`);
448
+ return;
449
+ }
450
+
451
+ if (status.status === 'expired') {
452
+ console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Login token expired. Run semalt login again.${RST}\n`);
453
+ return;
454
+ }
455
+ }
456
+
457
+ console.log(` ${FG_YELLOW}⚠${RST} ${FG_GRAY}Login timed out. The URL may still work for a short time.${RST}\n`);
458
+ }
459
+
460
+ async function cmdWhoAmI() {
461
+ let response;
462
+ try {
463
+ response = await dashboardWhoAmI();
464
+ } catch (error) {
465
+ console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${error.message}${RST}\n`);
466
+ return;
467
+ }
468
+
469
+ const user = response && response.user ? response.user : null;
470
+ if (!user) {
471
+ console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to load current user.${RST}\n`);
472
+ return;
473
+ }
474
+
475
+ console.log();
476
+ console.log(` ${FG_TEAL}${BOLD}◆ Current User${RST}`);
477
+ console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
478
+ console.log(formatUserLine('ID', user.id));
479
+ console.log(formatUserLine('Email', user.email || '-'));
480
+ console.log(formatUserLine('Name', user.name || '-'));
481
+ console.log(formatUserLine('Provider', user.provider || '-'));
482
+ if (user.avatar_url) {
483
+ console.log(formatUserLine('Avatar', user.avatar_url));
484
+ }
485
+ console.log();
486
+ }
487
+
488
+ async function cmdLogout() {
489
+ const config = getConfig();
490
+ if (!config.auth_token) {
491
+ console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt login first.${RST}\n`);
492
+ return;
493
+ }
494
+
495
+ try {
496
+ await dashboardLogout();
497
+ } catch (error) {
498
+ if (error.statusCode !== 401) {
499
+ console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${error.message}${RST}\n`);
500
+ return;
501
+ }
502
+ }
503
+
504
+ setConfig({
505
+ ...config,
506
+ auth_token: '',
507
+ });
508
+ console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Logged out and cleared local CLI token.${RST}\n`);
509
+ }
510
+
331
511
  return {
332
512
  cmdChat,
333
513
  cmdCode,
334
514
  cmdEdit,
335
515
  cmdInit,
516
+ cmdLogin,
336
517
  cmdModels,
337
518
  cmdModelsAdd,
338
519
  cmdShell,
520
+ cmdLogout,
521
+ cmdWhoAmI,
339
522
  };
340
523
  }
341
524
 
package/lib/config.js CHANGED
@@ -7,6 +7,23 @@ const { CONFIG_PATH, DEFAULT_CONFIG } = require('./constants');
7
7
 
8
8
  function normalizeConfig(cfg = {}) {
9
9
  const merged = { ...DEFAULT_CONFIG, ...cfg };
10
+ const legacyDashboardUrl = typeof cfg.semalt_base_url === 'string' && cfg.semalt_base_url.trim()
11
+ ? cfg.semalt_base_url.trim()
12
+ : '';
13
+ const requestedDashboardUrl = typeof cfg.dashboard_url === 'string' ? cfg.dashboard_url.trim() : '';
14
+ merged.api_base = typeof merged.api_base === 'string' ? merged.api_base.trim() : DEFAULT_CONFIG.api_base;
15
+ merged.api_key = typeof merged.api_key === 'string' ? merged.api_key : DEFAULT_CONFIG.api_key;
16
+ if (requestedDashboardUrl) {
17
+ merged.dashboard_url = requestedDashboardUrl;
18
+ } else if (legacyDashboardUrl) {
19
+ merged.dashboard_url = legacyDashboardUrl;
20
+ } else {
21
+ merged.dashboard_url = DEFAULT_CONFIG.dashboard_url;
22
+ }
23
+ merged.auth_token = typeof merged.auth_token === 'string' ? merged.auth_token : '';
24
+ merged.default_model = typeof merged.default_model === 'string' && merged.default_model.trim()
25
+ ? merged.default_model.trim()
26
+ : DEFAULT_CONFIG.default_model;
10
27
  merged.models = Array.isArray(cfg.models)
11
28
  ? cfg.models
12
29
  .filter((entry) => entry &&
package/lib/constants.js CHANGED
@@ -10,6 +10,8 @@ const DEFAULT_API_TIMEOUT_MS = 15 * 60 * 1000;
10
10
  const DEFAULT_CONFIG = {
11
11
  api_base: 'http://127.0.0.1:8800',
12
12
  api_key: 'any',
13
+ dashboard_url: 'https://cli.semalt.ai',
14
+ auth_token: '',
13
15
  default_model: 'default',
14
16
  temperature: 0.7,
15
17
  request_timeout_ms: DEFAULT_API_TIMEOUT_MS,
package/lib/ui.js CHANGED
@@ -111,133 +111,279 @@ function readInteractiveInput(promptText, options = {}) {
111
111
  let done = false;
112
112
  let historyIndex = Array.isArray(history) ? history.length : -1;
113
113
  let historyActive = false;
114
+ let pasting = false;
115
+ let lastLineCount = 1;
116
+ let lastCursorRow = 0;
117
+ let lastWasCR = false;
114
118
 
115
- readline.emitKeypressEvents(process.stdin);
116
119
  process.stdin.setRawMode(true);
117
120
  process.stdin.resume();
121
+ process.stdout.write('\x1b[?2004h'); // enable bracketed paste mode
122
+
123
+ const promptWidth = stripAnsi(promptText).length;
118
124
 
119
125
  const render = () => {
126
+ const lines = buffer.split('\n');
127
+ const newLineCount = lines.length;
128
+
129
+ if (lastLineCount > 1) {
130
+ readline.moveCursor(process.stdout, 0, -(lastLineCount - 1));
131
+ }
132
+
120
133
  readline.cursorTo(process.stdout, 0);
121
134
  readline.clearLine(process.stdout, 0);
122
- process.stdout.write(`${promptText}${buffer}`);
123
- const promptWidth = stripAnsi(promptText).length;
124
- readline.cursorTo(process.stdout, promptWidth + cursor);
135
+ process.stdout.write(`${promptText}${lines[0]}`);
136
+
137
+ for (let i = 1; i < newLineCount; i++) {
138
+ process.stdout.write('\n');
139
+ readline.cursorTo(process.stdout, 0);
140
+ readline.clearLine(process.stdout, 0);
141
+ process.stdout.write(lines[i]);
142
+ }
143
+
144
+ // Clear any extra lines from a previous longer render
145
+ for (let i = newLineCount; i < lastLineCount; i++) {
146
+ process.stdout.write('\n');
147
+ readline.cursorTo(process.stdout, 0);
148
+ readline.clearLine(process.stdout, 0);
149
+ }
150
+
151
+ lastLineCount = newLineCount;
152
+
153
+ const beforeCursor = Array.from(buffer).slice(0, cursor).join('');
154
+ const cursorLines = beforeCursor.split('\n');
155
+ lastCursorRow = cursorLines.length - 1;
156
+ const cursorCol = (lastCursorRow === 0 ? promptWidth : 0) + cursorLines[lastCursorRow].length;
157
+
158
+ const rowsFromBottom = newLineCount - 1 - lastCursorRow;
159
+ if (rowsFromBottom > 0) {
160
+ readline.moveCursor(process.stdout, 0, -rowsFromBottom);
161
+ }
162
+ readline.cursorTo(process.stdout, cursorCol);
125
163
  };
126
164
 
127
165
  const finish = (result, addNewline = true) => {
128
166
  if (done) return;
129
167
  done = true;
168
+ process.stdout.write('\x1b[?2004l'); // disable bracketed paste mode
130
169
  process.stdin.setRawMode(wasRaw);
131
- process.stdin.removeListener('keypress', onKeypress);
132
- if (addNewline) process.stdout.write('\n');
170
+ process.stdin.removeListener('data', onData);
171
+ if (addNewline) {
172
+ const rowsToBottom = lastLineCount - 1 - lastCursorRow;
173
+ if (rowsToBottom > 0) {
174
+ readline.moveCursor(process.stdout, 0, rowsToBottom);
175
+ }
176
+ process.stdout.write('\n');
177
+ }
133
178
  resolve(result);
134
179
  };
135
180
 
136
- const onKeypress = (str, key = {}) => {
137
- if (key.ctrl && key.name === 'c') {
138
- if (buffer) {
139
- buffer = '';
140
- cursor = 0;
141
- render();
142
- return;
181
+ // Parse raw stdin data directly so we can intercept bracketed paste markers
182
+ // (\x1b[200~ ... \x1b[201~) before any higher-level key parser sees them.
183
+ const onData = (chunk) => {
184
+ if (done) return;
185
+ const data = chunk.toString('utf8');
186
+ let i = 0;
187
+
188
+ while (i < data.length) {
189
+ // --- Bracketed paste markers ---
190
+ if (data.startsWith('\x1b[200~', i)) {
191
+ pasting = true;
192
+ lastWasCR = false;
193
+ i += 6;
194
+ continue;
195
+ }
196
+ if (data.startsWith('\x1b[201~', i)) {
197
+ pasting = false;
198
+ lastWasCR = false;
199
+ i += 6;
200
+ continue;
143
201
  }
144
- finish({ type: 'sigint' }, false);
145
- return;
146
- }
147
202
 
148
- if (key.ctrl && key.name === 'd') {
149
- if (!buffer) finish({ type: 'eof' }, false);
150
- return;
151
- }
203
+ const ch = data[i];
204
+
205
+ // --- Escape sequences ---
206
+ if (ch === '\x1b') {
207
+ lastWasCR = false;
208
+ const next = data[i + 1];
209
+
210
+ // CSI sequences: \x1b[...
211
+ if (next === '[') {
212
+ const seq3 = data.slice(i, i + 3);
213
+ const seq4 = data.slice(i, i + 4);
214
+
215
+ if (seq3 === '\x1b[A') { // up
216
+ if (Array.isArray(history) && history.length) {
217
+ if (historyActive || buffer.length === 0) {
218
+ historyActive = true;
219
+ historyIndex = Math.max(0, historyIndex - 1);
220
+ buffer = historyIndex >= history.length ? '' : history[historyIndex];
221
+ cursor = Array.from(buffer).length;
222
+ render();
223
+ }
224
+ }
225
+ i += 3; continue;
226
+ }
227
+ if (seq3 === '\x1b[B') { // down
228
+ if (Array.isArray(history) && history.length) {
229
+ if (historyActive || buffer.length === 0) {
230
+ historyActive = true;
231
+ historyIndex = Math.min(history.length, historyIndex + 1);
232
+ buffer = historyIndex >= history.length ? '' : history[historyIndex];
233
+ cursor = Array.from(buffer).length;
234
+ render();
235
+ }
236
+ }
237
+ i += 3; continue;
238
+ }
239
+ if (seq3 === '\x1b[C') { // right
240
+ if (allowCursorNavigation && cursor < Array.from(buffer).length) { cursor++; render(); }
241
+ i += 3; continue;
242
+ }
243
+ if (seq3 === '\x1b[D') { // left
244
+ if (allowCursorNavigation && cursor > 0) { cursor--; render(); }
245
+ i += 3; continue;
246
+ }
247
+ if (seq3 === '\x1b[H' || seq4 === '\x1b[1~') { // home
248
+ if (allowCursorNavigation) { cursor = 0; render(); }
249
+ i += (seq3 === '\x1b[H' ? 3 : 4); continue;
250
+ }
251
+ if (seq3 === '\x1b[F' || seq4 === '\x1b[4~') { // end
252
+ if (allowCursorNavigation) { cursor = Array.from(buffer).length; render(); }
253
+ i += (seq3 === '\x1b[F' ? 3 : 4); continue;
254
+ }
255
+ if (seq4 === '\x1b[3~') { // delete
256
+ if (cursor < Array.from(buffer).length) {
257
+ buffer = removeCharAt(buffer, cursor);
258
+ historyActive = false;
259
+ render();
260
+ }
261
+ i += 4; continue;
262
+ }
263
+ // Skip unknown CSI sequence (read until terminating byte)
264
+ let j = i + 2;
265
+ while (j < data.length && (data.charCodeAt(j) < 0x40 || data.charCodeAt(j) > 0x7e)) j++;
266
+ i = j + 1;
267
+ continue;
268
+ }
269
+
270
+ // SS3 sequences: \x1bO... (application cursor keys)
271
+ if (next === 'O') {
272
+ const c = data[i + 2];
273
+ if (c === 'A') { // up
274
+ if (Array.isArray(history) && history.length && (historyActive || buffer.length === 0)) {
275
+ historyActive = true;
276
+ historyIndex = Math.max(0, historyIndex - 1);
277
+ buffer = historyIndex >= history.length ? '' : history[historyIndex];
278
+ cursor = Array.from(buffer).length;
279
+ render();
280
+ }
281
+ i += 3; continue;
282
+ }
283
+ if (c === 'B') { // down
284
+ if (Array.isArray(history) && history.length && (historyActive || buffer.length === 0)) {
285
+ historyActive = true;
286
+ historyIndex = Math.min(history.length, historyIndex + 1);
287
+ buffer = historyIndex >= history.length ? '' : history[historyIndex];
288
+ cursor = Array.from(buffer).length;
289
+ render();
290
+ }
291
+ i += 3; continue;
292
+ }
293
+ if (c === 'C' && allowCursorNavigation) { if (cursor < Array.from(buffer).length) { cursor++; render(); } i += 3; continue; }
294
+ if (c === 'D' && allowCursorNavigation) { if (cursor > 0) { cursor--; render(); } i += 3; continue; }
295
+ if (c === 'H' && allowCursorNavigation) { cursor = 0; render(); i += 3; continue; }
296
+ if (c === 'F' && allowCursorNavigation) { cursor = Array.from(buffer).length; render(); i += 3; continue; }
297
+ i += 3; continue;
298
+ }
299
+
300
+ // Unknown escape - skip
301
+ i += 2;
302
+ continue;
303
+ }
152
304
 
153
- if (key.name === 'return' || key.name === 'enter') {
154
- finish({ type: 'submit', value: trim ? buffer.trim() : buffer });
155
- return;
156
- }
305
+ // --- Ctrl+C ---
306
+ if (ch === '\x03') {
307
+ lastWasCR = false;
308
+ if (buffer) { buffer = ''; cursor = 0; pasting = false; render(); }
309
+ else { finish({ type: 'sigint' }, false); return; }
310
+ i++; continue;
311
+ }
157
312
 
158
- if (key.name === 'backspace' || key.name === 'delete') {
159
- if (key.name === 'backspace' && cursor > 0) {
160
- buffer = removeCharAt(buffer, cursor - 1);
161
- cursor--;
162
- historyActive = false;
163
- render();
164
- } else if (key.name === 'delete' && cursor < Array.from(buffer).length) {
165
- buffer = removeCharAt(buffer, cursor);
166
- historyActive = false;
167
- render();
313
+ // --- Ctrl+D ---
314
+ if (ch === '\x04') {
315
+ lastWasCR = false;
316
+ if (!buffer) { finish({ type: 'eof' }, false); return; }
317
+ i++; continue;
168
318
  }
169
- return;
170
- }
171
319
 
172
- if (allowCursorNavigation && key.name === 'left') {
173
- if (cursor > 0) {
174
- cursor--;
175
- render();
320
+ // --- Enter / newline ---
321
+ if (ch === '\r' || ch === '\n') {
322
+ // Collapse CRLF into a single newline
323
+ if (ch === '\n' && lastWasCR) { lastWasCR = false; i++; continue; }
324
+ lastWasCR = (ch === '\r');
325
+
326
+ if (pasting) {
327
+ buffer = insertCharAt(buffer, cursor, '\n');
328
+ cursor++;
329
+ historyActive = false;
330
+ render();
331
+ } else {
332
+ finish({ type: 'submit', value: trim ? buffer.trim() : buffer });
333
+ return;
334
+ }
335
+ i++; continue;
336
+ }
337
+ lastWasCR = false;
338
+
339
+ // --- Backspace ---
340
+ if (ch === '\x7f' || ch === '\x08') {
341
+ if (cursor > 0) {
342
+ buffer = removeCharAt(buffer, cursor - 1);
343
+ cursor--;
344
+ historyActive = false;
345
+ render();
346
+ }
347
+ i++; continue;
176
348
  }
177
- return;
178
- }
179
349
 
180
- if (allowCursorNavigation && key.name === 'right') {
181
- if (cursor < Array.from(buffer).length) {
182
- cursor++;
183
- render();
350
+ // --- Tab ---
351
+ if (ch === '\t') {
352
+ if (pasting) {
353
+ buffer = insertCharAt(buffer, cursor, '\t');
354
+ cursor++;
355
+ render();
356
+ }
357
+ i++; continue;
184
358
  }
185
- return;
186
- }
187
359
 
188
- if (allowCursorNavigation && key.name === 'home') {
189
- cursor = 0;
190
- render();
191
- return;
192
- }
360
+ // --- Other control characters: skip ---
361
+ if (ch.charCodeAt(0) < 0x20) { i++; continue; }
193
362
 
194
- if (allowCursorNavigation && key.name === 'end') {
195
- cursor = Array.from(buffer).length;
196
- render();
197
- return;
198
- }
363
+ // --- Printable character (handles multi-byte Unicode via code points) ---
364
+ const cp = data.codePointAt(i);
365
+ const char = String.fromCodePoint(cp);
199
366
 
200
- if (Array.isArray(history) && (key.name === 'up' || key.name === 'down')) {
201
- const canEnterHistory = buffer.length === 0;
202
- if (!history.length) return;
203
- if (!historyActive && !canEnterHistory) return;
367
+ if (allowed && !allowed.includes(char)) { i += char.length; continue; }
204
368
 
205
- historyActive = true;
206
- if (key.name === 'up') {
207
- historyIndex = Math.max(0, historyIndex - 1);
208
- } else {
209
- historyIndex = Math.min(history.length, historyIndex + 1);
369
+ if (immediate) {
370
+ buffer = char;
371
+ cursor = 1;
372
+ historyActive = false;
373
+ render();
374
+ finish({ type: 'submit', value: char });
375
+ return;
210
376
  }
211
377
 
212
- buffer = historyIndex >= history.length ? '' : history[historyIndex];
213
- cursor = Array.from(buffer).length;
214
- render();
215
- return;
216
- }
217
-
218
- if (key.name && ['up', 'down', 'left', 'right', 'home', 'end', 'pageup', 'pagedown', 'escape', 'tab'].includes(key.name)) {
219
- return;
220
- }
221
-
222
- if (!isPrintableKey(str, key)) return;
223
- if (allowed && !allowed.includes(str)) return;
224
-
225
- if (immediate) {
226
- buffer = str;
227
- cursor = Array.from(buffer).length;
378
+ buffer = insertCharAt(buffer, cursor, char);
379
+ cursor++;
228
380
  historyActive = false;
229
381
  render();
230
- finish({ type: 'submit', value: str });
231
- return;
382
+ i += char.length;
232
383
  }
233
-
234
- buffer = insertCharAt(buffer, cursor, str);
235
- cursor++;
236
- historyActive = false;
237
- render();
238
384
  };
239
385
 
240
- process.stdin.on('keypress', onKeypress);
386
+ process.stdin.on('data', onData);
241
387
  render();
242
388
  });
243
389
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semalt-ai/code",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Self-hosted AI Coding Assistant CLI",
5
5
  "main": "index.js",
6
6
  "bin": {