@iola_adm/iola-cli 0.1.10 → 0.1.12

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 +50 -1
  2. package/package.json +2 -2
  3. package/src/cli.js +601 -25
package/README.md CHANGED
@@ -18,7 +18,11 @@ node --version
18
18
  npm --version
19
19
  ```
20
20
 
21
- Нужен Node.js `18` или новее. Если Node.js не установлен:
21
+ Нужен Node.js `22.5.0` или новее. Это нужно для встроенной SQLite-БД
22
+ `node:sqlite`, которую CLI будет использовать для локальной истории, кеша и
23
+ сессий без дополнительных нативных зависимостей.
24
+
25
+ Если Node.js не установлен или версия ниже `22.5.0`:
22
26
 
23
27
  ```bash
24
28
  # Windows
@@ -54,6 +58,8 @@ curl -fsSL https://ollama.com/install.sh | sh
54
58
  Диагностика ПК и подбор локальной модели:
55
59
 
56
60
  ```bash
61
+ npx -y @iola_adm/iola-cli init
62
+ npx -y @iola_adm/iola-cli init --upgrade-node
57
63
  npx -y @iola_adm/iola-cli ai doctor
58
64
  npx -y @iola_adm/iola-cli ai setup ollama
59
65
  ```
@@ -93,6 +99,12 @@ iola data schools --where address=Петрова --columns name,address,phone
93
99
  iola data schools --format csv
94
100
  iola ai doctor
95
101
  iola ai setup ollama
102
+ iola ai setup codex --model gpt-5.5
103
+ iola ai profiles
104
+ iola ai profile add router-qwen --provider openrouter --model qwen/qwen3-32b
105
+ iola ai profile use router-qwen
106
+ iola ai models openrouter --search qwen
107
+ iola ai models codex
96
108
  iola ai ask "Какие школы есть на улице Петрова?"
97
109
  iola ai context "школа 29"
98
110
  iola ai key set openai
@@ -137,6 +149,9 @@ iola agent
137
149
  /mcp-info
138
150
  /ai doctor
139
151
  /context школа 29
152
+ /profiles
153
+ /profile use local
154
+ /models openrouter --search qwen
140
155
  /use ollama
141
156
  /use openai
142
157
  /key status
@@ -180,6 +195,40 @@ iola ai setup openrouter --model openai/gpt-4.1-mini
180
195
  iola ai ask "Покажи контакты лицея"
181
196
  ```
182
197
 
198
+ Codex CLI:
199
+
200
+ ```bash
201
+ codex login
202
+ iola ai setup codex --model gpt-5.5
203
+ iola setup codex
204
+ iola ask "Назови ИНН школы 29"
205
+ ```
206
+
207
+ AI-профили позволяют держать локальную модель, OpenAI, OpenRouter и Codex
208
+ одновременно:
209
+
210
+ ```bash
211
+ iola ai profiles
212
+ iola ai profile add local-small --provider ollama --model llama3.2:1b
213
+ iola ai profile add gpt --provider openai --model gpt-4.1-mini
214
+ iola ai profile add router-qwen --provider openrouter --model qwen/qwen3-32b
215
+ iola ai profile add codex-read --provider codex --model gpt-5.5 --sandbox read-only
216
+ iola ai profile use router-qwen
217
+ ```
218
+
219
+ Списки моделей:
220
+
221
+ ```bash
222
+ iola ai models ollama
223
+ iola ai models openai
224
+ iola ai models openrouter --search qwen
225
+ iola ai models codex
226
+ ```
227
+
228
+ Для OpenAI список моделей требует сохраненный ключ. OpenRouter берется из
229
+ публичного API OpenRouter. Ollama читает локальные модели через `api/tags`, а
230
+ если Ollama не запущен, показывает рекомендуемые локальные модели.
231
+
183
232
  Проверить, какие данные попадут в AI-контекст:
184
233
 
185
234
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "CLI и AI-агент для работы с открытыми данными городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
@@ -36,6 +36,6 @@
36
36
  "cli"
37
37
  ],
38
38
  "engines": {
39
- "node": ">=18"
39
+ "node": ">=22.5.0"
40
40
  }
41
41
  }
package/src/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import readline from "node:readline/promises";
@@ -7,6 +7,7 @@ import { stdin as input, stdout as output } from "node:process";
7
7
 
8
8
  const API_BASE_URL = process.env.IOLA_API_BASE_URL || "https://apiiola.yasg.ru/api/v1";
9
9
  const MCP_BASE_URL = process.env.IOLA_MCP_BASE_URL || "https://apiiola.yasg.ru";
10
+ const MIN_NODE_VERSION = "22.5.0";
10
11
  const CONFIG_DIR = path.join(os.homedir(), ".iola");
11
12
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
12
13
  const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
@@ -16,9 +17,34 @@ const DEFAULT_AI_CONFIG = {
16
17
  mcpBaseUrl: "https://apiiola.yasg.ru",
17
18
  },
18
19
  ai: {
20
+ activeProfile: "local",
19
21
  provider: "ollama",
20
22
  model: "llama3.2:1b",
21
23
  baseUrl: "http://127.0.0.1:11434",
24
+ profiles: {
25
+ local: {
26
+ provider: "ollama",
27
+ model: "llama3.2:1b",
28
+ baseUrl: "http://127.0.0.1:11434",
29
+ },
30
+ openai: {
31
+ provider: "openai",
32
+ model: "gpt-4.1-mini",
33
+ baseUrl: "https://api.openai.com/v1",
34
+ },
35
+ openrouter: {
36
+ provider: "openrouter",
37
+ model: "openai/gpt-4.1-mini",
38
+ baseUrl: "https://openrouter.ai/api/v1",
39
+ },
40
+ codex: {
41
+ provider: "codex",
42
+ model: "gpt-5.5",
43
+ sandbox: "read-only",
44
+ approval: "never",
45
+ cwd: ".",
46
+ },
47
+ },
22
48
  },
23
49
  };
24
50
  const DATASETS = {
@@ -69,6 +95,11 @@ const COMMANDS = new Map([
69
95
 
70
96
  export async function main(argv) {
71
97
  const [command = "help", ...args] = argv;
98
+ const nodeStatus = getNodeRequirementStatus();
99
+ if (!nodeStatus.ok && !["help", "version", "doctor", "init"].includes(command)) {
100
+ throw new Error(`Нужен Node.js ${MIN_NODE_VERSION} или новее. Сейчас: ${nodeStatus.current}. Запустите: iola init --upgrade-node`);
101
+ }
102
+
72
103
  const handler = COMMANDS.get(command);
73
104
 
74
105
  if (!handler) {
@@ -101,6 +132,11 @@ Usage:
101
132
  iola ai key set openrouter
102
133
  iola ai key status
103
134
  iola ai key delete openai|openrouter
135
+ iola ai profiles
136
+ iola ai profile add NAME --provider PROVIDER --model MODEL
137
+ iola ai profile use NAME
138
+ iola ai profile delete NAME
139
+ iola ai models ollama|openai|openrouter|codex [--search TEXT]
104
140
  iola ai doctor [--json]
105
141
  iola ai setup
106
142
  iola ai setup ollama [--yes] [--model MODEL]
@@ -118,6 +154,9 @@ Usage:
118
154
  Environment:
119
155
  IOLA_API_BASE_URL default: ${API_BASE_URL}
120
156
  IOLA_MCP_BASE_URL default: ${MCP_BASE_URL}
157
+
158
+ Requirements:
159
+ Node.js >= ${MIN_NODE_VERSION}
121
160
  `);
122
161
  }
123
162
 
@@ -210,6 +249,21 @@ async function handleAgentLine(line, state) {
210
249
  return false;
211
250
  }
212
251
 
252
+ if (command === "profiles") {
253
+ await handleAiProfile(["list", ...args]);
254
+ return false;
255
+ }
256
+
257
+ if (command === "profile") {
258
+ await handleAiProfile(args);
259
+ return false;
260
+ }
261
+
262
+ if (command === "models") {
263
+ await aiModels(args);
264
+ return false;
265
+ }
266
+
213
267
  if (command === "use") {
214
268
  await useAiProvider(args);
215
269
  return false;
@@ -290,6 +344,9 @@ function printAgentHelp() {
290
344
  /search лицей --limit 3
291
345
  /mcp-info
292
346
  /context школа 29
347
+ /profiles
348
+ /profile use local
349
+ /models openrouter --search qwen
293
350
  /ai doctor
294
351
  /ai setup ollama
295
352
  /use openai
@@ -402,6 +459,7 @@ async function doctor(args = []) {
402
459
  const options = parseOptions(args);
403
460
  const packageJson = await import("../package.json", { with: { type: "json" } });
404
461
  const config = await loadConfig();
462
+ const activeAiProfile = resolveAiProfile(config);
405
463
  const secrets = await loadSecrets();
406
464
  const diagnostics = await getLocalDiagnostics();
407
465
  const latest = await getLatestNpmVersion(packageJson.default.name);
@@ -412,6 +470,9 @@ async function doctor(args = []) {
412
470
  version: packageJson.default.version,
413
471
  npmLatest: latest || "-",
414
472
  update: getUpdateStatus(packageJson.default.version, latest),
473
+ node: process.version,
474
+ nodeRequired: `>=${MIN_NODE_VERSION}`,
475
+ nodeStatus: getNodeRequirementStatus().ok ? "ok" : "upgrade-required",
415
476
  },
416
477
  api: {
417
478
  baseUrl: apiBaseUrl,
@@ -419,9 +480,10 @@ async function doctor(args = []) {
419
480
  health: await probeEndpoint(`${mcpBaseUrl}/mcp-health`),
420
481
  },
421
482
  ai: {
422
- provider: config.ai.provider,
423
- model: config.ai.model,
424
- modelAvailable: await checkConfiguredModel(config),
483
+ activeProfile: getActiveProfileName(config),
484
+ provider: activeAiProfile.provider,
485
+ model: activeAiProfile.model,
486
+ modelAvailable: await checkConfiguredModel({ ai: activeAiProfile }),
425
487
  openaiKey: process.env.OPENAI_API_KEY ? "env" : secrets.openai?.apiKey ? "local" : "missing",
426
488
  openrouterKey: process.env.OPENROUTER_API_KEY ? "env" : secrets.openrouter?.apiKey ? "local" : "missing",
427
489
  ollama: diagnostics.ollama.installed ? diagnostics.ollama.version : "not-installed",
@@ -464,6 +526,69 @@ function getUpdateStatus(current, latest) {
464
526
  return "ok";
465
527
  }
466
528
 
529
+ function getNodeRequirementStatus() {
530
+ const current = process.versions.node;
531
+ return {
532
+ current,
533
+ required: MIN_NODE_VERSION,
534
+ ok: compareVersions(current, MIN_NODE_VERSION) >= 0,
535
+ };
536
+ }
537
+
538
+ async function offerNodeUpgrade(options, status) {
539
+ console.log(`Текущая версия Node.js: ${status.current}. Нужна ${MIN_NODE_VERSION} или новее.`);
540
+
541
+ if (!process.stdin.isTTY && !options["upgrade-node"]) {
542
+ printNodeUpgradeInstructions();
543
+ return;
544
+ }
545
+
546
+ const shouldUpgrade = options["upgrade-node"] || (await confirm("Обновить Node.js установщиком сейчас? [y/N] "));
547
+
548
+ if (!shouldUpgrade) {
549
+ printNodeUpgradeInstructions();
550
+ return;
551
+ }
552
+
553
+ await upgradeNodeWithInstaller();
554
+ console.log("");
555
+ console.log("После обновления перезапустите терминал и проверьте:");
556
+ console.log(" node --version");
557
+ console.log(" iola init");
558
+ }
559
+
560
+ function printNodeUpgradeInstructions() {
561
+ console.log("Обновите Node.js:");
562
+ console.log(" Windows: winget install OpenJS.NodeJS.LTS");
563
+ console.log(" macOS: brew install node");
564
+ console.log(" Linux: curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - && sudo apt-get install -y nodejs");
565
+ }
566
+
567
+ async function upgradeNodeWithInstaller() {
568
+ if (process.platform === "win32") {
569
+ try {
570
+ await runCommand("winget", ["upgrade", "OpenJS.NodeJS.LTS", "--accept-package-agreements", "--accept-source-agreements"], { inherit: true });
571
+ } catch {
572
+ await runCommand("winget", ["install", "OpenJS.NodeJS.LTS", "--accept-package-agreements", "--accept-source-agreements"], { inherit: true });
573
+ }
574
+ return;
575
+ }
576
+
577
+ if (process.platform === "darwin") {
578
+ try {
579
+ await runCommand("brew", ["upgrade", "node"], { inherit: true });
580
+ } catch {
581
+ await runCommand("brew", ["install", "node"], { inherit: true });
582
+ }
583
+ return;
584
+ }
585
+
586
+ await runCommand("sh", [
587
+ "-c",
588
+ "curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - && sudo apt-get install -y nodejs",
589
+ ], { inherit: true });
590
+ }
591
+
467
592
  async function checkConfiguredModel(config) {
468
593
  if (config.ai.provider !== "ollama") {
469
594
  return "external-api";
@@ -486,17 +611,25 @@ async function checkConfiguredModel(config) {
486
611
 
487
612
  async function initCli(args = []) {
488
613
  const options = parseOptions(args);
614
+ const nodeStatus = getNodeRequirementStatus();
489
615
 
490
616
  showBanner();
491
617
  console.log("Проверка окружения");
492
618
  printKeyValue({
493
619
  node: process.version,
620
+ node_required: `>=${MIN_NODE_VERSION}`,
621
+ node_status: nodeStatus.ok ? "ok" : "нужно обновить",
494
622
  npm: await getCommandVersion("npm", ["--version"]),
495
623
  api: await probeEndpoint(`${await getMcpBaseUrl()}/mcp-health`),
496
624
  mcp: await getMcpBaseUrl(),
497
625
  });
498
626
  console.log("");
499
627
 
628
+ if (!nodeStatus.ok) {
629
+ await offerNodeUpgrade(options, nodeStatus);
630
+ console.log("");
631
+ }
632
+
500
633
  await aiDoctor(options.json ? ["--json"] : []);
501
634
 
502
635
  if (!process.stdin.isTTY || options.yes) {
@@ -531,6 +664,11 @@ async function handleAi(args) {
531
664
  iola ai key set openrouter
532
665
  iola ai key status
533
666
  iola ai key delete openai|openrouter
667
+ iola ai profiles
668
+ iola ai profile add NAME --provider ollama|openai|openrouter|codex --model MODEL
669
+ iola ai profile use NAME
670
+ iola ai profile delete NAME
671
+ iola ai models ollama|openai|openrouter|codex [--search TEXT]
534
672
  iola ai doctor [--json]
535
673
  iola ai setup
536
674
  iola ai setup ollama [--yes] [--model MODEL]
@@ -556,6 +694,21 @@ async function handleAi(args) {
556
694
  return;
557
695
  }
558
696
 
697
+ if (subcommand === "profiles") {
698
+ await handleAiProfile(["list", ...rest]);
699
+ return;
700
+ }
701
+
702
+ if (subcommand === "profile") {
703
+ await handleAiProfile(rest);
704
+ return;
705
+ }
706
+
707
+ if (subcommand === "models") {
708
+ await aiModels(rest);
709
+ return;
710
+ }
711
+
559
712
  if (subcommand === "doctor") {
560
713
  await aiDoctor(rest);
561
714
  return;
@@ -641,21 +794,50 @@ async function aiSetup(args) {
641
794
  if (provider === "openai" || provider === "openrouter") {
642
795
  const options = parseOptions(args.slice(1));
643
796
  const model = options.model || (provider === "openai" ? "gpt-4.1-mini" : "openai/gpt-4.1-mini");
797
+ const profileName = options.name || provider;
798
+ const profile = buildProfileFromOptions(provider, { ...options, model });
799
+ const config = await loadConfig();
644
800
  await saveConfig({
645
801
  ai: {
802
+ ...config.ai,
803
+ activeProfile: profileName,
646
804
  provider,
647
805
  model,
648
- baseUrl: provider === "openai" ? "https://api.openai.com/v1" : "https://openrouter.ai/api/v1",
806
+ baseUrl: profile.baseUrl,
807
+ profiles: {
808
+ ...(config.ai.profiles || {}),
809
+ [profileName]: profile,
810
+ },
649
811
  },
650
812
  });
651
- console.log(`AI-профиль ${provider} сохранен в ${CONFIG_FILE}`);
813
+ console.log(`AI-профиль ${profileName} сохранен и выбран в ${CONFIG_FILE}`);
652
814
  console.log(`Ключ сохраните командой: iola ai key set ${provider}`);
653
815
  console.log(`Также можно использовать переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
654
816
  return;
655
817
  }
656
818
 
657
819
  if (provider === "codex") {
658
- await setupClient(["codex"]);
820
+ const options = parseOptions(args.slice(1));
821
+ const profileName = options.name || "codex";
822
+ const profile = buildProfileFromOptions("codex", options);
823
+ const config = await loadConfig();
824
+ await saveConfig({
825
+ ai: {
826
+ ...config.ai,
827
+ activeProfile: profileName,
828
+ provider: "codex",
829
+ model: profile.model,
830
+ profiles: {
831
+ ...(config.ai.profiles || {}),
832
+ [profileName]: profile,
833
+ },
834
+ },
835
+ });
836
+ console.log(`AI-профиль ${profileName} сохранен и выбран.`);
837
+ console.log("Проверка Codex CLI:");
838
+ console.log(` ${await getCommandVersion("codex", ["--version"])}`);
839
+ console.log("MCP подключается отдельно командой:");
840
+ console.log(" iola setup codex");
659
841
  return;
660
842
  }
661
843
 
@@ -687,33 +869,318 @@ async function handleAiKey(args) {
687
869
  iola ai key delete openai|openrouter`);
688
870
  }
689
871
 
690
- async function useAiProvider(args) {
872
+ async function handleAiProfile(args) {
873
+ const [action = "list", name, ...rest] = args;
874
+
875
+ if (action === "list" || action === "ls") {
876
+ await printAiProfiles();
877
+ return;
878
+ }
879
+
880
+ if (action === "show") {
881
+ await showAiProfile(name);
882
+ return;
883
+ }
884
+
885
+ if (action === "use") {
886
+ await useAiProfile(name);
887
+ return;
888
+ }
889
+
890
+ if (action === "add" || action === "set") {
891
+ await addAiProfile(name, rest);
892
+ return;
893
+ }
894
+
895
+ if (action === "delete" || action === "remove" || action === "rm") {
896
+ await deleteAiProfile(name);
897
+ return;
898
+ }
899
+
900
+ throw new Error(`Unknown profile command. Use:
901
+ iola ai profiles
902
+ iola ai profile add NAME --provider PROVIDER --model MODEL
903
+ iola ai profile use NAME
904
+ iola ai profile delete NAME`);
905
+ }
906
+
907
+ async function aiModels(args) {
691
908
  const [provider] = args;
909
+ const options = parseOptions(args.slice(1));
910
+
911
+ if (!["ollama", "openai", "openrouter", "codex"].includes(provider)) {
912
+ throw new Error("Провайдер обязателен: iola ai models ollama|openai|openrouter|codex");
913
+ }
914
+
915
+ const models = await listAiModels(provider);
916
+ const filtered = options.search
917
+ ? models.filter((model) => model.id.toLocaleLowerCase("ru-RU").includes(options.search.toLocaleLowerCase("ru-RU")))
918
+ : models;
919
+
920
+ if (options.json) {
921
+ printJson(filtered);
922
+ return;
923
+ }
924
+
925
+ printTable(filtered, [
926
+ ["id", "Модель"],
927
+ ["provider", "Провайдер"],
928
+ ["note", "Примечание"],
929
+ ]);
930
+ }
931
+
932
+ async function listAiModels(provider) {
933
+ if (provider === "ollama") {
934
+ try {
935
+ const config = await loadConfig();
936
+ const response = await fetch(`${config.ai.profiles?.local?.baseUrl || "http://127.0.0.1:11434"}/api/tags`);
937
+
938
+ if (!response.ok) {
939
+ throw new Error(`${response.status} ${response.statusText}`);
940
+ }
941
+
942
+ const payload = await response.json();
943
+ return (payload.models || []).map((model) => ({
944
+ id: model.name,
945
+ provider: "ollama",
946
+ note: model.modified_at ? `updated ${model.modified_at}` : "local",
947
+ }));
948
+ } catch {
949
+ return [
950
+ { id: "llama3.2:1b", provider: "ollama", note: "recommended low RAM" },
951
+ { id: "llama3.2:3b", provider: "ollama", note: "recommended standard" },
952
+ { id: "qwen3:4b", provider: "ollama", note: "recommended balanced" },
953
+ { id: "qwen3:8b", provider: "ollama", note: "recommended good GPU" },
954
+ ];
955
+ }
956
+ }
957
+
958
+ if (provider === "openai") {
959
+ const apiKey = await getApiKey("openai");
960
+ if (!apiKey) {
961
+ throw new Error("OpenAI API key не найден. Выполните iola ai key set openai.");
962
+ }
963
+ const response = await fetch("https://api.openai.com/v1/models", {
964
+ headers: { authorization: `Bearer ${apiKey}` },
965
+ });
966
+
967
+ if (!response.ok) {
968
+ throw new Error(`OpenAI models request failed: ${response.status} ${response.statusText}`);
969
+ }
970
+
971
+ const payload = await response.json();
972
+ return (payload.data || [])
973
+ .map((model) => ({ id: model.id, provider: "openai", note: model.owned_by || "" }))
974
+ .sort((left, right) => left.id.localeCompare(right.id));
975
+ }
976
+
977
+ if (provider === "openrouter") {
978
+ const response = await fetch("https://openrouter.ai/api/v1/models", {
979
+ headers: { accept: "application/json" },
980
+ });
981
+
982
+ if (!response.ok) {
983
+ throw new Error(`OpenRouter models request failed: ${response.status} ${response.statusText}`);
984
+ }
985
+
986
+ const payload = await response.json();
987
+ return (payload.data || [])
988
+ .map((model) => ({
989
+ id: model.id,
990
+ provider: "openrouter",
991
+ note: model.name || "",
992
+ }))
993
+ .sort((left, right) => left.id.localeCompare(right.id));
994
+ }
995
+
996
+ const version = await getCommandVersion("codex", ["--version"]);
997
+ return [
998
+ { id: "gpt-5.5", provider: "codex", note: version },
999
+ { id: "gpt-5", provider: "codex", note: version },
1000
+ { id: "gpt-5-codex", provider: "codex", note: version },
1001
+ { id: "gpt-5-mini", provider: "codex", note: version },
1002
+ ];
1003
+ }
1004
+
1005
+ async function printAiProfiles() {
1006
+ const config = await loadConfig();
1007
+ const active = getActiveProfileName(config);
1008
+ const rows = Object.entries(config.ai.profiles || {}).map(([name, profile]) => ({
1009
+ active: name === active ? "*" : "",
1010
+ name,
1011
+ provider: profile.provider,
1012
+ model: profile.model || "-",
1013
+ baseUrl: profile.baseUrl || "-",
1014
+ mode: profile.provider === "codex" ? `sandbox=${profile.sandbox || "read-only"}, approval=${profile.approval || "never"}` : "-",
1015
+ }));
1016
+
1017
+ printTable(rows, [
1018
+ ["active", ""],
1019
+ ["name", "Профиль"],
1020
+ ["provider", "Провайдер"],
1021
+ ["model", "Модель"],
1022
+ ["baseUrl", "Base URL"],
1023
+ ["mode", "Режим"],
1024
+ ]);
1025
+ }
1026
+
1027
+ async function showAiProfile(name) {
1028
+ const config = await loadConfig();
1029
+ const profileName = name || getActiveProfileName(config);
1030
+ const profile = config.ai.profiles?.[profileName];
1031
+
1032
+ if (!profile) {
1033
+ throw new Error(`AI-профиль не найден: ${profileName}`);
1034
+ }
1035
+
1036
+ printJson({ name: profileName, active: profileName === getActiveProfileName(config), ...profile });
1037
+ }
1038
+
1039
+ async function addAiProfile(name, args) {
1040
+ if (!name) {
1041
+ throw new Error("Имя профиля обязательно. Пример: iola ai profile add router-qwen --provider openrouter --model qwen/qwen3-32b");
1042
+ }
1043
+
1044
+ const options = parseOptions(args);
1045
+ const provider = options.provider;
1046
+
1047
+ if (!["ollama", "openai", "openrouter", "codex"].includes(provider)) {
1048
+ throw new Error("Провайдер должен быть ollama, openai, openrouter или codex.");
1049
+ }
1050
+
1051
+ const profile = buildProfileFromOptions(provider, options);
1052
+ const config = await loadConfig();
1053
+ await saveConfig({
1054
+ ai: {
1055
+ ...config.ai,
1056
+ profiles: {
1057
+ ...(config.ai.profiles || {}),
1058
+ [name]: profile,
1059
+ },
1060
+ },
1061
+ });
1062
+
1063
+ console.log(`AI-профиль сохранен: ${name}`);
1064
+ }
1065
+
1066
+ async function useAiProfile(name) {
1067
+ if (!name) {
1068
+ throw new Error("Имя профиля обязательно. Пример: iola ai profile use local");
1069
+ }
1070
+
1071
+ const config = await loadConfig();
1072
+ const profile = config.ai.profiles?.[name];
1073
+
1074
+ if (!profile) {
1075
+ throw new Error(`AI-профиль не найден: ${name}`);
1076
+ }
1077
+
1078
+ await saveConfig({
1079
+ ai: {
1080
+ ...config.ai,
1081
+ activeProfile: name,
1082
+ provider: profile.provider,
1083
+ model: profile.model,
1084
+ baseUrl: profile.baseUrl || config.ai.baseUrl,
1085
+ },
1086
+ });
1087
+
1088
+ console.log(`Активный AI-профиль: ${name} (${profile.provider}, ${profile.model || "-"})`);
1089
+ }
692
1090
 
693
- if (provider !== "ollama" && provider !== "openai" && provider !== "openrouter") {
694
- throw new Error("Провайдер должен быть ollama, openai или openrouter.");
1091
+ async function deleteAiProfile(name) {
1092
+ if (!name) {
1093
+ throw new Error("Имя профиля обязательно.");
695
1094
  }
696
1095
 
697
1096
  const config = await loadConfig();
1097
+ const profiles = { ...(config.ai.profiles || {}) };
1098
+
1099
+ if (!profiles[name]) {
1100
+ throw new Error(`AI-профиль не найден: ${name}`);
1101
+ }
1102
+
1103
+ delete profiles[name];
1104
+ const nextActive = config.ai.activeProfile === name ? Object.keys(profiles)[0] : config.ai.activeProfile;
1105
+ const activeProfile = profiles[nextActive] || DEFAULT_AI_CONFIG.ai.profiles.local;
1106
+
1107
+ await saveConfig({
1108
+ ai: {
1109
+ ...config.ai,
1110
+ profiles,
1111
+ activeProfile: nextActive || "local",
1112
+ provider: activeProfile.provider,
1113
+ model: activeProfile.model,
1114
+ baseUrl: activeProfile.baseUrl || config.ai.baseUrl,
1115
+ },
1116
+ });
1117
+
1118
+ console.log(`AI-профиль удален: ${name}`);
1119
+ }
1120
+
1121
+ function buildProfileFromOptions(provider, options) {
1122
+ const defaults = DEFAULT_AI_CONFIG.ai.profiles[provider === "ollama" ? "local" : provider];
1123
+ const profile = {
1124
+ ...defaults,
1125
+ provider,
1126
+ model: options.model || defaults.model,
1127
+ };
1128
+
1129
+ if (options["base-url"]) {
1130
+ profile.baseUrl = options["base-url"];
1131
+ }
1132
+
1133
+ if (provider === "codex") {
1134
+ profile.sandbox = options.sandbox || defaults.sandbox || "read-only";
1135
+ profile.approval = options.approval || defaults.approval || "never";
1136
+ profile.cwd = options.cwd || defaults.cwd || ".";
1137
+ if (options["codex-profile"]) {
1138
+ profile.codexProfile = options["codex-profile"];
1139
+ }
1140
+ }
1141
+
1142
+ return profile;
1143
+ }
1144
+
1145
+ async function useAiProvider(args) {
1146
+ const [providerOrProfile] = args;
1147
+ const config = await loadConfig();
1148
+
1149
+ if (config.ai.profiles?.[providerOrProfile]) {
1150
+ await useAiProfile(providerOrProfile);
1151
+ return;
1152
+ }
1153
+
1154
+ const provider = providerOrProfile;
1155
+
1156
+ if (provider !== "ollama" && provider !== "openai" && provider !== "openrouter" && provider !== "codex") {
1157
+ throw new Error("Провайдер должен быть ollama, openai, openrouter, codex или именем AI-профиля.");
1158
+ }
1159
+
698
1160
  const defaultModel = {
699
1161
  ollama: config.ai.provider === "ollama" ? config.ai.model : "llama3.2:1b",
700
1162
  openai: config.ai.provider === "openai" ? config.ai.model : "gpt-4.1-mini",
701
1163
  openrouter: config.ai.provider === "openrouter" ? config.ai.model : "openai/gpt-4.1-mini",
1164
+ codex: config.ai.provider === "codex" ? config.ai.model : "gpt-5.5",
702
1165
  }[provider];
1166
+ const profileName = provider === "ollama" ? "local" : provider;
1167
+ const profile = buildProfileFromOptions(provider, { model: defaultModel });
703
1168
 
704
1169
  await saveConfig({
705
1170
  ai: {
1171
+ ...config.ai,
1172
+ activeProfile: profileName,
706
1173
  provider,
707
1174
  model: defaultModel,
708
- baseUrl: provider === "ollama"
709
- ? "http://127.0.0.1:11434"
710
- : provider === "openai"
711
- ? "https://api.openai.com/v1"
712
- : "https://openrouter.ai/api/v1",
1175
+ baseUrl: profile.baseUrl,
1176
+ profiles: {
1177
+ ...(config.ai.profiles || {}),
1178
+ [profileName]: profile,
1179
+ },
713
1180
  },
714
1181
  });
715
1182
 
716
- console.log(`AI-провайдер переключен: ${provider}, модель: ${defaultModel}`);
1183
+ console.log(`AI-провайдер переключен: ${provider}, профиль: ${profileName}, модель: ${defaultModel}`);
717
1184
  }
718
1185
 
719
1186
  async function aiContext(args) {
@@ -838,11 +1305,23 @@ async function setupOllama(args) {
838
1305
  await runCommand("ollama", ["pull", model], { inherit: true });
839
1306
  }
840
1307
 
1308
+ const config = await loadConfig();
1309
+ const profileName = options.name || "local";
841
1310
  await saveConfig({
842
1311
  ai: {
1312
+ ...config.ai,
1313
+ activeProfile: profileName,
843
1314
  provider: "ollama",
844
1315
  model,
845
1316
  baseUrl: "http://127.0.0.1:11434",
1317
+ profiles: {
1318
+ ...(config.ai.profiles || {}),
1319
+ [profileName]: {
1320
+ provider: "ollama",
1321
+ model,
1322
+ baseUrl: "http://127.0.0.1:11434",
1323
+ },
1324
+ },
846
1325
  },
847
1326
  });
848
1327
 
@@ -859,13 +1338,7 @@ async function aiAsk(args, context = {}) {
859
1338
  }
860
1339
 
861
1340
  const config = await loadConfig();
862
- const provider = options.provider || config.ai.provider;
863
- const model = options.model || config.ai.model;
864
- const providerConfig = {
865
- ...config.ai,
866
- provider,
867
- model,
868
- };
1341
+ const providerConfig = resolveAiProfile(config, options);
869
1342
  const dataContext = await buildDataContext(question);
870
1343
  const messages = buildAiMessages(question, dataContext, context.history || []);
871
1344
  const answer = await callAiProvider(providerConfig, messages);
@@ -874,6 +1347,26 @@ async function aiAsk(args, context = {}) {
874
1347
  return answer;
875
1348
  }
876
1349
 
1350
+ function resolveAiProfile(config, options = {}) {
1351
+ const profileName = options.profile || (options.provider && config.ai.profiles?.[options.provider]
1352
+ ? options.provider
1353
+ : getActiveProfileName(config));
1354
+ const activeProfile = config.ai.profiles?.[profileName] || {
1355
+ provider: config.ai.provider,
1356
+ model: config.ai.model,
1357
+ baseUrl: config.ai.baseUrl,
1358
+ };
1359
+ const provider = options.provider && !config.ai.profiles?.[options.provider] ? options.provider : activeProfile.provider;
1360
+
1361
+ return {
1362
+ name: profileName,
1363
+ ...activeProfile,
1364
+ provider,
1365
+ model: options.model || activeProfile.model || config.ai.model,
1366
+ baseUrl: options["base-url"] || activeProfile.baseUrl || config.ai.baseUrl,
1367
+ };
1368
+ }
1369
+
877
1370
  async function buildDataContext(question) {
878
1371
  const apiBaseUrl = await getApiBaseUrl();
879
1372
  const mcpBaseUrl = await getMcpBaseUrl();
@@ -1048,9 +1541,49 @@ async function callAiProvider(config, messages) {
1048
1541
  return callOpenAiCompatible(config, messages, await getApiKey("openrouter"), "OpenRouter");
1049
1542
  }
1050
1543
 
1544
+ if (config.provider === "codex") {
1545
+ return callCodex(config, messages);
1546
+ }
1547
+
1051
1548
  throw new Error(`Неизвестный AI-провайдер: ${config.provider}`);
1052
1549
  }
1053
1550
 
1551
+ async function callCodex(config, messages) {
1552
+ const prompt = messages.map((message) => `${message.role.toUpperCase()}:\n${message.content}`).join("\n\n");
1553
+ const outputFile = path.join(os.tmpdir(), `iola-codex-${process.pid}-${Date.now()}.txt`);
1554
+ const args = [
1555
+ "exec",
1556
+ "--skip-git-repo-check",
1557
+ "--output-last-message",
1558
+ outputFile,
1559
+ "--cd",
1560
+ path.resolve(process.cwd(), config.cwd || "."),
1561
+ "--model",
1562
+ config.model || "gpt-5.5",
1563
+ "--sandbox",
1564
+ config.sandbox || "read-only",
1565
+ ];
1566
+
1567
+ if (config.codexProfile) {
1568
+ args.push("--profile", config.codexProfile);
1569
+ }
1570
+
1571
+ args.push("-");
1572
+
1573
+ try {
1574
+ const { stdout, stderr } = await runCommand("codex", args, { input: prompt });
1575
+ const answer = (await readFile(outputFile, "utf8")).trim();
1576
+ if (answer) {
1577
+ return answer;
1578
+ }
1579
+ return stdout.trim() || stderr.trim();
1580
+ } catch (error) {
1581
+ throw new Error(`Codex CLI недоступен или не авторизован. Проверьте "codex doctor" и "codex login".\n${error.message}`);
1582
+ } finally {
1583
+ await rm(outputFile, { force: true });
1584
+ }
1585
+ }
1586
+
1054
1587
  async function callOllama(config, messages) {
1055
1588
  let response;
1056
1589
 
@@ -1294,9 +1827,10 @@ function parseOptions(args) {
1294
1827
  const arg = args[index];
1295
1828
  if (arg === "--json" || arg === "--yes") {
1296
1829
  result[arg.slice(2)] = true;
1297
- } else if (arg === "--check") {
1830
+ } else if (arg === "--check" || arg === "--upgrade-node") {
1298
1831
  result.check = true;
1299
- } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--format") {
1832
+ result[arg.slice(2)] = true;
1833
+ } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--base-url" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format") {
1300
1834
  result[arg.slice(2)] = args[index + 1];
1301
1835
  index += 1;
1302
1836
  } else {
@@ -1617,6 +2151,9 @@ async function confirm(question) {
1617
2151
  async function saveConfig(value) {
1618
2152
  const current = await loadConfig();
1619
2153
  const merged = mergeConfig(current, value);
2154
+ if (value.ai?.profiles) {
2155
+ merged.ai.profiles = value.ai.profiles;
2156
+ }
1620
2157
  await writeConfig(merged);
1621
2158
  }
1622
2159
 
@@ -1645,10 +2182,27 @@ function mergeConfig(base, override) {
1645
2182
  ai: {
1646
2183
  ...base.ai,
1647
2184
  ...(override.ai || {}),
2185
+ profiles: {
2186
+ ...(base.ai.profiles || {}),
2187
+ ...(override.ai?.profiles || {}),
2188
+ },
1648
2189
  },
1649
2190
  };
1650
2191
  }
1651
2192
 
2193
+ function getActiveProfileName(config) {
2194
+ if (config.ai.activeProfile && config.ai.profiles?.[config.ai.activeProfile]) {
2195
+ return config.ai.activeProfile;
2196
+ }
2197
+
2198
+ const provider = config.ai.provider === "ollama" ? "local" : config.ai.provider;
2199
+ if (provider && config.ai.profiles?.[provider]) {
2200
+ return provider;
2201
+ }
2202
+
2203
+ return Object.keys(config.ai.profiles || {})[0] || "local";
2204
+ }
2205
+
1652
2206
  async function getApiBaseUrl() {
1653
2207
  if (process.env.IOLA_API_BASE_URL) {
1654
2208
  return process.env.IOLA_API_BASE_URL;
@@ -1755,6 +2309,14 @@ function runCommand(command, args, options = {}) {
1755
2309
  maxBuffer: 1024 * 1024 * 5,
1756
2310
  }, (error, stdout, stderr) => {
1757
2311
  if (error) {
2312
+ if (process.platform === "win32" && (error.code === "ENOENT" || error.code === "EINVAL") && !options.cmdFallback) {
2313
+ runCommand(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", quoteWindowsCommand(command, args)], {
2314
+ ...options,
2315
+ cmdFallback: true,
2316
+ }).then(resolve, reject);
2317
+ return;
2318
+ }
2319
+
1758
2320
  reject(error);
1759
2321
  return;
1760
2322
  }
@@ -1766,9 +2328,23 @@ function runCommand(command, args, options = {}) {
1766
2328
  child.stdout?.pipe(process.stdout);
1767
2329
  child.stderr?.pipe(process.stderr);
1768
2330
  }
2331
+
2332
+ if (options.input) {
2333
+ child.stdin?.end(options.input);
2334
+ }
1769
2335
  });
1770
2336
  }
1771
2337
 
2338
+ function quoteWindowsCommand(command, args) {
2339
+ return [command, ...args].map((value) => {
2340
+ const text = String(value);
2341
+ if (/^[A-Za-z0-9_./:=\\-]+$/.test(text)) {
2342
+ return text;
2343
+ }
2344
+ return `"${text.replace(/"/g, "\\\"")}"`;
2345
+ }).join(" ");
2346
+ }
2347
+
1772
2348
  function roundGb(bytes) {
1773
2349
  return Math.round((bytes / 1024 / 1024 / 1024) * 10) / 10;
1774
2350
  }