@semalt-ai/code 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +23 -1
  2. package/index.js +140 -37
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -67,6 +67,7 @@ Example config:
67
67
  "default_model": "default",
68
68
  "temperature": 0.7,
69
69
  "max_tokens": 4096,
70
+ "request_timeout_ms": 900000,
70
71
  "stream": true
71
72
  }
72
73
  ```
@@ -95,7 +96,10 @@ semalt-code [command] [options]
95
96
  Runs a shell command with approval prompts.
96
97
 
97
98
  - `semalt-code models`
98
- Lists models exposed by the configured API.
99
+ Lists all saved model profiles.
100
+
101
+ - `semalt-code models add`
102
+ Opens an interactive flow to add an API base URL, API key, and model ID as a reusable model profile.
99
103
 
100
104
  - `semalt-code init`
101
105
  Creates or updates the local config file.
@@ -134,7 +138,9 @@ Available interactive commands:
134
138
 
135
139
  - `/help`
136
140
  - `/file <path>`
141
+ - `/model`
137
142
  - `/model <name>`
143
+ - `/models`
138
144
  - `/clear`
139
145
  - `/compact`
140
146
  - `/cost`
@@ -205,6 +211,22 @@ semalt-code shell -a "npm test"
205
211
  semalt-code models
206
212
  ```
207
213
 
214
+ ### Add a saved model profile
215
+
216
+ ```bash
217
+ semalt-code models add
218
+ ```
219
+
220
+ The CLI will ask for:
221
+
222
+ - API Base URL
223
+ - API Key
224
+ - Model ID
225
+
226
+ Each saved profile is appended to the profile list.
227
+
228
+ Saved profiles can then be selected inside chat mode with `/model` or `/models`.
229
+
208
230
  ### Show the current version
209
231
 
210
232
  ```bash
package/index.js CHANGED
@@ -12,6 +12,8 @@ const { URL } = require('url');
12
12
 
13
13
  const PACKAGE_JSON = require('./package.json');
14
14
 
15
+ const DEFAULT_API_TIMEOUT_MS = 15 * 60 * 1000;
16
+
15
17
  // ── Config ────────────────────────────────────────────────────────────────────
16
18
 
17
19
  const DEFAULT_CONFIG = {
@@ -20,24 +22,45 @@ const DEFAULT_CONFIG = {
20
22
  default_model: 'default',
21
23
  temperature: 0.7,
22
24
  max_tokens: 4096,
25
+ request_timeout_ms: DEFAULT_API_TIMEOUT_MS,
23
26
  stream: true,
27
+ models: [],
24
28
  };
25
29
 
26
30
  const CONFIG_PATH = path.join(os.homedir(), '.semalt-ai', 'config.json');
27
31
 
32
+ function normalizeConfig(cfg = {}) {
33
+ const merged = { ...DEFAULT_CONFIG, ...cfg };
34
+ merged.models = Array.isArray(cfg.models)
35
+ ? cfg.models
36
+ .filter((entry) => entry &&
37
+ typeof entry.api_base === 'string' &&
38
+ typeof entry.api_key === 'string' &&
39
+ typeof entry.model === 'string' &&
40
+ entry.api_base.trim() &&
41
+ entry.model.trim())
42
+ .map((entry) => ({
43
+ api_base: entry.api_base.trim(),
44
+ api_key: entry.api_key,
45
+ model: entry.model.trim(),
46
+ }))
47
+ : [];
48
+ return merged;
49
+ }
50
+
28
51
  function loadConfig() {
29
52
  if (fs.existsSync(CONFIG_PATH)) {
30
53
  try {
31
54
  const data = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
32
- return { ...DEFAULT_CONFIG, ...data };
55
+ return normalizeConfig(data);
33
56
  } catch {}
34
57
  }
35
- return { ...DEFAULT_CONFIG };
58
+ return normalizeConfig();
36
59
  }
37
60
 
38
61
  function saveConfig(cfg) {
39
62
  fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
40
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
63
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(normalizeConfig(cfg), null, 2));
41
64
  }
42
65
 
43
66
  let config = loadConfig();
@@ -424,6 +447,52 @@ function apiUrl(urlPath) {
424
447
  return `${normalizedBase}${normalizedPath}`;
425
448
  }
426
449
 
450
+ function describeModelProfile(profile) {
451
+ return `${profile.model} @ ${profile.api_base}`;
452
+ }
453
+
454
+ function setActiveModelProfile(profile) {
455
+ config.api_base = profile.api_base;
456
+ config.api_key = profile.api_key;
457
+ config.default_model = profile.model;
458
+ saveConfig(config);
459
+ }
460
+
461
+ function chooseSavedModelProfile(rl, currentModel, cwd, onDone) {
462
+ if (!config.models.length) {
463
+ console.log(` ${FG_RED}✗${RST} ${FG_GRAY}No saved model profiles. Use semalt-code models add first.${RST}`);
464
+ onDone(currentModel);
465
+ return;
466
+ }
467
+
468
+ console.log();
469
+ console.log(` ${FG_TEAL}${BOLD}◆ Saved Models${RST}`);
470
+ console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
471
+ config.models.forEach((profile, index) => {
472
+ const active = profile.api_base === config.api_base &&
473
+ profile.api_key === config.api_key &&
474
+ profile.model === currentModel;
475
+ const marker = active ? `${FG_GREEN}●${RST}` : `${FG_DARK}○${RST}`;
476
+ console.log(` ${marker} ${FG_CYAN}${index + 1}.${RST} ${describeModelProfile(profile)}`);
477
+ });
478
+ console.log();
479
+
480
+ rl.question(` ${FG_TEAL}${BOLD}Select model>${RST} `, (answer) => {
481
+ const selected = Number((answer || '').trim());
482
+ if (!Number.isInteger(selected) || selected < 1 || selected > config.models.length) {
483
+ console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Invalid selection${RST}`);
484
+ onDone(currentModel);
485
+ return;
486
+ }
487
+
488
+ const profile = config.models[selected - 1];
489
+ setActiveModelProfile(profile);
490
+ console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Model profile → ${describeModelProfile(profile)}${RST}`);
491
+ printStatusBar(profile.model, cwd);
492
+ onDone(profile.model);
493
+ });
494
+ }
495
+
427
496
  function estimateTokens(text) {
428
497
  return Math.floor((text || '').length / 4);
429
498
  }
@@ -472,6 +541,7 @@ async function chatStream(messages, { model, temperature, maxTokens } = {}) {
472
541
  try {
473
542
  res = await httpRequest(apiUrl('/v1/chat/completions'), {
474
543
  method: 'POST',
544
+ timeout: config.request_timeout_ms,
475
545
  headers: {
476
546
  'Content-Type': 'application/json',
477
547
  'Authorization': `Bearer ${config.api_key}`,
@@ -573,6 +643,7 @@ async function chatSync(messages, { model } = {}) {
573
643
  try {
574
644
  res = await httpRequest(apiUrl('/v1/chat/completions'), {
575
645
  method: 'POST',
646
+ timeout: config.request_timeout_ms,
576
647
  headers: {
577
648
  'Content-Type': 'application/json',
578
649
  'Authorization': `Bearer ${config.api_key}`,
@@ -831,7 +902,9 @@ async function cmdChat(opts) {
831
902
  console.log(`
832
903
  ${FG_BLUE}${BOLD}Commands:${RST}
833
904
  ${FG_CYAN}/file <path>${RST} ${FG_GRAY}Load file or dir into context${RST}
834
- ${FG_CYAN}/model <name>${RST} ${FG_GRAY}Switch model${RST}
905
+ ${FG_CYAN}/model${RST} ${FG_GRAY}Choose saved model profile${RST}
906
+ ${FG_CYAN}/model <name>${RST} ${FG_GRAY}Switch model manually${RST}
907
+ ${FG_CYAN}/models${RST} ${FG_GRAY}Choose saved model profile${RST}
835
908
  ${FG_CYAN}/clear${RST} ${FG_GRAY}Clear conversation${RST}
836
909
  ${FG_CYAN}/compact${RST} ${FG_GRAY}Show token usage${RST}
837
910
  ${FG_CYAN}/shell <cmd>${RST} ${FG_GRAY}Run shell command directly${RST}
@@ -852,6 +925,14 @@ async function cmdChat(opts) {
852
925
  return prompt();
853
926
  }
854
927
 
928
+ if (text === '/model' || text === '/models') {
929
+ chooseSavedModelProfile(rl, currentModel, cwd, (nextModel) => {
930
+ currentModel = nextModel;
931
+ prompt();
932
+ });
933
+ return;
934
+ }
935
+
855
936
  if (text.startsWith('/model ')) {
856
937
  currentModel = text.slice(7).trim();
857
938
  console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Model → ${currentModel}${RST}`);
@@ -987,41 +1068,60 @@ async function cmdShell(opts, commandArgs) {
987
1068
  }
988
1069
 
989
1070
  async function cmdModels() {
990
- const url = apiUrl('/v1/models');
991
- let res;
992
- try {
993
- res = await httpRequest(url, {
994
- method: 'GET',
995
- headers: { 'Authorization': `Bearer ${config.api_key}` },
996
- timeout: 10000,
997
- }, null);
998
- } catch (e) {
999
- console.log(` ${FG_RED}✗ ${e.message}${RST}`);
1071
+ if (!config.models.length) {
1072
+ console.log(` ${FG_RED}✗${RST} ${FG_GRAY}No saved model profiles. Use semalt-code models add first.${RST}`);
1000
1073
  return;
1001
1074
  }
1002
1075
 
1003
- return new Promise((resolve) => {
1004
- let data = '';
1005
- res.setEncoding('utf8');
1006
- res.on('data', chunk => data += chunk);
1007
- res.on('end', () => {
1008
- try {
1009
- const parsed = JSON.parse(data);
1010
- console.log();
1011
- console.log(` ${FG_TEAL}${BOLD} Available Models${RST}`);
1012
- console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
1013
- for (const m of (parsed.data || [])) console.log(` ${FG_GREEN}●${RST} ${m.id}`);
1014
- console.log();
1015
- } catch (e) {
1016
- console.log(` ${FG_RED}✗ ${e.message}${RST}`);
1017
- }
1018
- resolve();
1019
- });
1020
- res.on('error', (e) => {
1021
- console.log(` ${FG_RED}✗ ${e.message}${RST}`);
1022
- resolve();
1023
- });
1076
+ console.log();
1077
+ console.log(` ${FG_TEAL}${BOLD}◆ Saved Models${RST}`);
1078
+ console.log(` ${FG_DARK}${''.repeat(40)}${RST}`);
1079
+ config.models.forEach((profile, index) => {
1080
+ const active = profile.api_base === config.api_base &&
1081
+ profile.api_key === config.api_key &&
1082
+ profile.model === config.default_model;
1083
+ const marker = active ? `${FG_GREEN}●${RST}` : `${FG_DARK}○${RST}`;
1084
+ console.log(` ${marker} ${FG_CYAN}${index + 1}.${RST} ${describeModelProfile(profile)}`);
1085
+ });
1086
+ console.log();
1087
+ }
1088
+
1089
+ async function cmdModelsAdd() {
1090
+ const rl = readline.createInterface({
1091
+ input: process.stdin,
1092
+ output: process.stdout,
1093
+ terminal: true,
1024
1094
  });
1095
+
1096
+ function ask(question) {
1097
+ return new Promise((resolve) => {
1098
+ rl.question(question, (answer) => resolve((answer || '').trim()));
1099
+ });
1100
+ }
1101
+
1102
+ console.log();
1103
+ console.log(` ${FG_TEAL}${BOLD}◆ Add Model Profile${RST}`);
1104
+ console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
1105
+
1106
+ const apiBase = await ask(` ${FG_CYAN}API Base URL:${RST} `);
1107
+ const apiKey = await ask(` ${FG_CYAN}API Key:${RST} `);
1108
+ const modelId = await ask(` ${FG_CYAN}Model ID:${RST} `);
1109
+ rl.close();
1110
+
1111
+ if (!apiBase || !modelId) {
1112
+ console.log(`\n ${FG_RED}✗${RST} ${FG_GRAY}API Base URL and Model ID are required.${RST}\n`);
1113
+ return;
1114
+ }
1115
+
1116
+ const profile = {
1117
+ api_base: apiBase,
1118
+ api_key: apiKey || 'any',
1119
+ model: modelId,
1120
+ };
1121
+
1122
+ config.models.push(profile);
1123
+ setActiveModelProfile(profile);
1124
+ console.log(`\n ${FG_GREEN}✓${RST} Saved model profile: ${describeModelProfile(profile)}\n`);
1025
1125
  }
1026
1126
 
1027
1127
  function cmdInit(opts) {
@@ -1032,6 +1132,7 @@ function cmdInit(opts) {
1032
1132
  temperature: 0.7,
1033
1133
  max_tokens: 4096,
1034
1134
  stream: true,
1135
+ models: config.models,
1035
1136
  };
1036
1137
  saveConfig(cfg);
1037
1138
  config = cfg;
@@ -1085,7 +1186,8 @@ Commands:
1085
1186
  code <prompt> Generate code from a prompt
1086
1187
  edit <file> <instruction> Edit a file with AI
1087
1188
  shell <command> Run and optionally analyze a shell command
1088
- models List available models
1189
+ models List saved model profiles
1190
+ models add Add a saved model profile
1089
1191
  init Initialize config
1090
1192
 
1091
1193
  Options:
@@ -1121,7 +1223,8 @@ Config: ${CONFIG_PATH}
1121
1223
  const { opts, positional } = parseArgs(rawArgs.slice(1));
1122
1224
  await cmdShell(opts, positional);
1123
1225
  } else if (command === 'models') {
1124
- await cmdModels();
1226
+ if (rawArgs[1] === 'add') await cmdModelsAdd();
1227
+ else await cmdModels();
1125
1228
  } else if (command === 'init') {
1126
1229
  const { opts } = parseArgs(rawArgs.slice(1));
1127
1230
  cmdInit(opts);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semalt-ai/code",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "Self-hosted AI Coding Assistant CLI",
5
5
  "main": "index.js",
6
6
  "bin": {