@iola_adm/iola-cli 0.1.9 → 0.1.11

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 +61 -0
  2. package/package.json +1 -1
  3. package/src/cli.js +790 -51
package/README.md CHANGED
@@ -80,13 +80,25 @@ iola banner
80
80
  iola agent
81
81
  iola chat
82
82
  iola init
83
+ iola doctor
84
+ iola config get
85
+ iola config set api.baseUrl https://apiiola.yasg.ru/api/v1
86
+ iola config reset
83
87
  iola update
84
88
  iola version --check
89
+ iola ask "Найди школу 29"
85
90
  iola data schools --limit 10
86
91
  iola data kindergartens --search "29"
92
+ iola data schools --where address=Петрова --columns name,address,phone
87
93
  iola data schools --format csv
88
94
  iola ai doctor
89
95
  iola ai setup ollama
96
+ iola ai setup codex --model gpt-5.5
97
+ iola ai profiles
98
+ iola ai profile add router-qwen --provider openrouter --model qwen/qwen3-32b
99
+ iola ai profile use router-qwen
100
+ iola ai models openrouter --search qwen
101
+ iola ai models codex
90
102
  iola ai ask "Какие школы есть на улице Петрова?"
91
103
  iola ai context "школа 29"
92
104
  iola ai key set openai
@@ -120,6 +132,8 @@ iola agent
120
132
  ```text
121
133
  /help
122
134
  /health
135
+ /doctor
136
+ /config get
123
137
  /layers
124
138
  /data schools --limit 10
125
139
  /schools --limit 10
@@ -129,6 +143,9 @@ iola agent
129
143
  /mcp-info
130
144
  /ai doctor
131
145
  /context школа 29
146
+ /profiles
147
+ /profile use local
148
+ /models openrouter --search qwen
132
149
  /use ollama
133
150
  /use openai
134
151
  /key status
@@ -153,6 +170,7 @@ iola agent
153
170
  ```bash
154
171
  iola ai setup ollama
155
172
  iola ai ask "Какие школы есть на улице Петрова?"
173
+ iola ask "Какие школы есть на улице Петрова?"
156
174
  ```
157
175
 
158
176
  OpenAI:
@@ -171,6 +189,40 @@ iola ai setup openrouter --model openai/gpt-4.1-mini
171
189
  iola ai ask "Покажи контакты лицея"
172
190
  ```
173
191
 
192
+ Codex CLI:
193
+
194
+ ```bash
195
+ codex login
196
+ iola ai setup codex --model gpt-5.5
197
+ iola setup codex
198
+ iola ask "Назови ИНН школы 29"
199
+ ```
200
+
201
+ AI-профили позволяют держать локальную модель, OpenAI, OpenRouter и Codex
202
+ одновременно:
203
+
204
+ ```bash
205
+ iola ai profiles
206
+ iola ai profile add local-small --provider ollama --model llama3.2:1b
207
+ iola ai profile add gpt --provider openai --model gpt-4.1-mini
208
+ iola ai profile add router-qwen --provider openrouter --model qwen/qwen3-32b
209
+ iola ai profile add codex-read --provider codex --model gpt-5.5 --sandbox read-only
210
+ iola ai profile use router-qwen
211
+ ```
212
+
213
+ Списки моделей:
214
+
215
+ ```bash
216
+ iola ai models ollama
217
+ iola ai models openai
218
+ iola ai models openrouter --search qwen
219
+ iola ai models codex
220
+ ```
221
+
222
+ Для OpenAI список моделей требует сохраненный ключ. OpenRouter берется из
223
+ публичного API OpenRouter. Ollama читает локальные модели через `api/tags`, а
224
+ если Ollama не запущен, показывает рекомендуемые локальные модели.
225
+
174
226
  Проверить, какие данные попадут в AI-контекст:
175
227
 
176
228
  ```bash
@@ -219,3 +271,12 @@ CLI дает прямой терминальный доступ к открыт
219
271
  IOLA_API_BASE_URL=https://apiiola.yasg.ru/api/v1
220
272
  IOLA_MCP_BASE_URL=https://apiiola.yasg.ru
221
273
  ```
274
+
275
+ Переменные окружения имеют приоритет над локальной конфигурацией. Локальные
276
+ endpoints можно настроить так:
277
+
278
+ ```bash
279
+ iola config set api.baseUrl https://apiiola.yasg.ru/api/v1
280
+ iola config set api.mcpBaseUrl https://apiiola.yasg.ru
281
+ iola config get
282
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "CLI и AI-агент для работы с открытыми данными городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
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";
@@ -11,10 +11,39 @@ const CONFIG_DIR = path.join(os.homedir(), ".iola");
11
11
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
12
12
  const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
13
13
  const DEFAULT_AI_CONFIG = {
14
+ api: {
15
+ baseUrl: "https://apiiola.yasg.ru/api/v1",
16
+ mcpBaseUrl: "https://apiiola.yasg.ru",
17
+ },
14
18
  ai: {
19
+ activeProfile: "local",
15
20
  provider: "ollama",
16
21
  model: "llama3.2:1b",
17
22
  baseUrl: "http://127.0.0.1:11434",
23
+ profiles: {
24
+ local: {
25
+ provider: "ollama",
26
+ model: "llama3.2:1b",
27
+ baseUrl: "http://127.0.0.1:11434",
28
+ },
29
+ openai: {
30
+ provider: "openai",
31
+ model: "gpt-4.1-mini",
32
+ baseUrl: "https://api.openai.com/v1",
33
+ },
34
+ openrouter: {
35
+ provider: "openrouter",
36
+ model: "openai/gpt-4.1-mini",
37
+ baseUrl: "https://openrouter.ai/api/v1",
38
+ },
39
+ codex: {
40
+ provider: "codex",
41
+ model: "gpt-5.5",
42
+ sandbox: "read-only",
43
+ approval: "never",
44
+ cwd: ".",
45
+ },
46
+ },
18
47
  },
19
48
  };
20
49
  const DATASETS = {
@@ -45,9 +74,12 @@ const COMMANDS = new Map([
45
74
  ["help", showHelp],
46
75
  ["version", showVersion],
47
76
  ["update", checkUpdate],
77
+ ["doctor", doctor],
78
+ ["config", handleConfig],
48
79
  ["banner", showBanner],
49
80
  ["agent", startAgent],
50
81
  ["chat", startAgent],
82
+ ["ask", aiAsk],
51
83
  ["ai", handleAi],
52
84
  ["init", initCli],
53
85
  ["health", checkHealth],
@@ -80,22 +112,33 @@ Usage:
80
112
  iola agent
81
113
  iola chat
82
114
  iola init
115
+ iola doctor
116
+ iola config get
117
+ iola config set api.baseUrl URL
118
+ iola config set api.mcpBaseUrl URL
119
+ iola config reset
83
120
  iola update
84
- iola data LAYER [--limit 10] [--search TEXT] [--format table|json|csv]
121
+ iola ask TEXT
122
+ iola data LAYER [--limit 10] [--search TEXT] [--where FIELD=VALUE] [--columns a,b,c] [--format table|json|csv]
85
123
  iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
86
124
  iola ai context TEXT [--json]
87
125
  iola ai key set openai
88
126
  iola ai key set openrouter
89
127
  iola ai key status
90
128
  iola ai key delete openai|openrouter
129
+ iola ai profiles
130
+ iola ai profile add NAME --provider PROVIDER --model MODEL
131
+ iola ai profile use NAME
132
+ iola ai profile delete NAME
133
+ iola ai models ollama|openai|openrouter|codex [--search TEXT]
91
134
  iola ai doctor [--json]
92
135
  iola ai setup
93
136
  iola ai setup ollama [--yes] [--model MODEL]
94
137
  iola health [--json]
95
138
  iola layers [--json]
96
- iola schools [--limit 10] [--search TEXT] [--format table|json|csv]
139
+ iola schools [--limit 10] [--search TEXT] [--where FIELD=VALUE] [--columns a,b,c] [--format table|json|csv]
97
140
  iola schools get --inn INN [--json]
98
- iola kindergartens [--limit 10] [--search TEXT] [--format table|json|csv]
141
+ iola kindergartens [--limit 10] [--search TEXT] [--where FIELD=VALUE] [--columns a,b,c] [--format table|json|csv]
99
142
  iola kindergartens get --inn INN [--json]
100
143
  iola search TEXT [--limit 5] [--format table|json|csv]
101
144
  iola mcp-info [--json]
@@ -178,7 +221,17 @@ async function handleAgentLine(line, state) {
178
221
  }
179
222
 
180
223
  if (command === "config") {
181
- await printAiConfig();
224
+ await handleConfig(args.length > 0 ? args : ["get"]);
225
+ return false;
226
+ }
227
+
228
+ if (command === "doctor") {
229
+ await doctor(args);
230
+ return false;
231
+ }
232
+
233
+ if (command === "cfg" || command === "settings") {
234
+ await handleConfig(args);
182
235
  return false;
183
236
  }
184
237
 
@@ -187,6 +240,21 @@ async function handleAgentLine(line, state) {
187
240
  return false;
188
241
  }
189
242
 
243
+ if (command === "profiles") {
244
+ await handleAiProfile(["list", ...args]);
245
+ return false;
246
+ }
247
+
248
+ if (command === "profile") {
249
+ await handleAiProfile(args);
250
+ return false;
251
+ }
252
+
253
+ if (command === "models") {
254
+ await aiModels(args);
255
+ return false;
256
+ }
257
+
190
258
  if (command === "use") {
191
259
  await useAiProvider(args);
192
260
  return false;
@@ -229,6 +297,8 @@ async function handleAgentLine(line, state) {
229
297
 
230
298
  const mapped = {
231
299
  health: ["health", args],
300
+ doctor: ["doctor", args],
301
+ config: ["config", args],
232
302
  layers: ["layers", args],
233
303
  data: ["data", args],
234
304
  schools: ["schools", args],
@@ -253,6 +323,9 @@ function printAgentHelp() {
253
323
  console.log(`Slash-команды:
254
324
  /help
255
325
  /health
326
+ /doctor
327
+ /config get
328
+ /config set api.baseUrl URL
256
329
  /layers
257
330
  /data schools --limit 10
258
331
  /schools --limit 10
@@ -262,6 +335,9 @@ function printAgentHelp() {
262
335
  /search лицей --limit 3
263
336
  /mcp-info
264
337
  /context школа 29
338
+ /profiles
339
+ /profile use local
340
+ /models openrouter --search qwen
265
341
  /ai doctor
266
342
  /ai setup ollama
267
343
  /use openai
@@ -355,7 +431,7 @@ async function checkUpdate() {
355
431
 
356
432
  async function checkHealth(args) {
357
433
  const options = parseOptions(args);
358
- const health = await fetchJson(`${MCP_BASE_URL}/mcp-health`);
434
+ const health = await fetchJson(`${await getMcpBaseUrl()}/mcp-health`);
359
435
 
360
436
  if (options.json) {
361
437
  printJson(health);
@@ -370,6 +446,94 @@ async function checkHealth(args) {
370
446
  });
371
447
  }
372
448
 
449
+ async function doctor(args = []) {
450
+ const options = parseOptions(args);
451
+ const packageJson = await import("../package.json", { with: { type: "json" } });
452
+ const config = await loadConfig();
453
+ const activeAiProfile = resolveAiProfile(config);
454
+ const secrets = await loadSecrets();
455
+ const diagnostics = await getLocalDiagnostics();
456
+ const latest = await getLatestNpmVersion(packageJson.default.name);
457
+ const apiBaseUrl = await getApiBaseUrl();
458
+ const mcpBaseUrl = await getMcpBaseUrl();
459
+ const report = {
460
+ cli: {
461
+ version: packageJson.default.version,
462
+ npmLatest: latest || "-",
463
+ update: getUpdateStatus(packageJson.default.version, latest),
464
+ },
465
+ api: {
466
+ baseUrl: apiBaseUrl,
467
+ mcpBaseUrl,
468
+ health: await probeEndpoint(`${mcpBaseUrl}/mcp-health`),
469
+ },
470
+ ai: {
471
+ activeProfile: getActiveProfileName(config),
472
+ provider: activeAiProfile.provider,
473
+ model: activeAiProfile.model,
474
+ modelAvailable: await checkConfiguredModel({ ai: activeAiProfile }),
475
+ openaiKey: process.env.OPENAI_API_KEY ? "env" : secrets.openai?.apiKey ? "local" : "missing",
476
+ openrouterKey: process.env.OPENROUTER_API_KEY ? "env" : secrets.openrouter?.apiKey ? "local" : "missing",
477
+ ollama: diagnostics.ollama.installed ? diagnostics.ollama.version : "not-installed",
478
+ },
479
+ system: diagnostics,
480
+ };
481
+
482
+ if (options.json) {
483
+ printJson(report);
484
+ return;
485
+ }
486
+
487
+ console.log("CLI");
488
+ printKeyValue(report.cli);
489
+ console.log("");
490
+ console.log("API/MCP");
491
+ printKeyValue(report.api);
492
+ console.log("");
493
+ console.log("AI");
494
+ printKeyValue(report.ai);
495
+ console.log("");
496
+ printDiagnostics(diagnostics, recommendOllamaModel(diagnostics));
497
+ }
498
+
499
+ function getUpdateStatus(current, latest) {
500
+ if (!latest) {
501
+ return "unknown";
502
+ }
503
+
504
+ const comparison = compareVersions(latest, current);
505
+
506
+ if (comparison > 0) {
507
+ return "available";
508
+ }
509
+
510
+ if (comparison < 0) {
511
+ return "local-newer";
512
+ }
513
+
514
+ return "ok";
515
+ }
516
+
517
+ async function checkConfiguredModel(config) {
518
+ if (config.ai.provider !== "ollama") {
519
+ return "external-api";
520
+ }
521
+
522
+ try {
523
+ const response = await fetch(`${config.ai.baseUrl || "http://127.0.0.1:11434"}/api/tags`);
524
+
525
+ if (!response.ok) {
526
+ return "unknown";
527
+ }
528
+
529
+ const payload = await response.json();
530
+ const models = payload.models || [];
531
+ return models.some((model) => model.name === config.ai.model) ? "installed" : "missing";
532
+ } catch {
533
+ return "ollama-unavailable";
534
+ }
535
+ }
536
+
373
537
  async function initCli(args = []) {
374
538
  const options = parseOptions(args);
375
539
 
@@ -378,8 +542,8 @@ async function initCli(args = []) {
378
542
  printKeyValue({
379
543
  node: process.version,
380
544
  npm: await getCommandVersion("npm", ["--version"]),
381
- api: await probeEndpoint(`${MCP_BASE_URL}/mcp-health`),
382
- mcp: MCP_BASE_URL,
545
+ api: await probeEndpoint(`${await getMcpBaseUrl()}/mcp-health`),
546
+ mcp: await getMcpBaseUrl(),
383
547
  });
384
548
  console.log("");
385
549
 
@@ -417,6 +581,11 @@ async function handleAi(args) {
417
581
  iola ai key set openrouter
418
582
  iola ai key status
419
583
  iola ai key delete openai|openrouter
584
+ iola ai profiles
585
+ iola ai profile add NAME --provider ollama|openai|openrouter|codex --model MODEL
586
+ iola ai profile use NAME
587
+ iola ai profile delete NAME
588
+ iola ai models ollama|openai|openrouter|codex [--search TEXT]
420
589
  iola ai doctor [--json]
421
590
  iola ai setup
422
591
  iola ai setup ollama [--yes] [--model MODEL]
@@ -442,6 +611,21 @@ async function handleAi(args) {
442
611
  return;
443
612
  }
444
613
 
614
+ if (subcommand === "profiles") {
615
+ await handleAiProfile(["list", ...rest]);
616
+ return;
617
+ }
618
+
619
+ if (subcommand === "profile") {
620
+ await handleAiProfile(rest);
621
+ return;
622
+ }
623
+
624
+ if (subcommand === "models") {
625
+ await aiModels(rest);
626
+ return;
627
+ }
628
+
445
629
  if (subcommand === "doctor") {
446
630
  await aiDoctor(rest);
447
631
  return;
@@ -455,6 +639,47 @@ async function handleAi(args) {
455
639
  throw new Error(`Unknown AI command: ${subcommand}\nRun "iola ai help" to see available commands.`);
456
640
  }
457
641
 
642
+ async function handleConfig(args) {
643
+ const [action = "get", key, ...rest] = args;
644
+
645
+ if (action === "get") {
646
+ const config = await loadConfig();
647
+ if (key) {
648
+ console.log(getConfigValue(config, key) ?? "-");
649
+ return;
650
+ }
651
+ printJson({
652
+ file: CONFIG_FILE,
653
+ config,
654
+ effective: {
655
+ apiBaseUrl: await getApiBaseUrl(),
656
+ mcpBaseUrl: await getMcpBaseUrl(),
657
+ },
658
+ });
659
+ return;
660
+ }
661
+
662
+ if (action === "set") {
663
+ const value = rest.join(" ").trim();
664
+ if (!key || !value) {
665
+ throw new Error("Пример: iola config set api.baseUrl https://apiiola.yasg.ru/api/v1");
666
+ }
667
+ const config = await loadConfig();
668
+ setConfigValue(config, key, value);
669
+ await saveConfig(config);
670
+ console.log(`Сохранено: ${key} = ${value}`);
671
+ return;
672
+ }
673
+
674
+ if (action === "reset") {
675
+ await writeConfig(DEFAULT_AI_CONFIG);
676
+ console.log(`Конфигурация сброшена: ${CONFIG_FILE}`);
677
+ return;
678
+ }
679
+
680
+ throw new Error("Команды config: get, set, reset.");
681
+ }
682
+
458
683
  async function aiDoctor(args) {
459
684
  const options = parseOptions(args);
460
685
  const diagnostics = await getLocalDiagnostics();
@@ -486,21 +711,50 @@ async function aiSetup(args) {
486
711
  if (provider === "openai" || provider === "openrouter") {
487
712
  const options = parseOptions(args.slice(1));
488
713
  const model = options.model || (provider === "openai" ? "gpt-4.1-mini" : "openai/gpt-4.1-mini");
714
+ const profileName = options.name || provider;
715
+ const profile = buildProfileFromOptions(provider, { ...options, model });
716
+ const config = await loadConfig();
489
717
  await saveConfig({
490
718
  ai: {
719
+ ...config.ai,
720
+ activeProfile: profileName,
491
721
  provider,
492
722
  model,
493
- baseUrl: provider === "openai" ? "https://api.openai.com/v1" : "https://openrouter.ai/api/v1",
723
+ baseUrl: profile.baseUrl,
724
+ profiles: {
725
+ ...(config.ai.profiles || {}),
726
+ [profileName]: profile,
727
+ },
494
728
  },
495
729
  });
496
- console.log(`AI-профиль ${provider} сохранен в ${CONFIG_FILE}`);
730
+ console.log(`AI-профиль ${profileName} сохранен и выбран в ${CONFIG_FILE}`);
497
731
  console.log(`Ключ сохраните командой: iola ai key set ${provider}`);
498
732
  console.log(`Также можно использовать переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
499
733
  return;
500
734
  }
501
735
 
502
736
  if (provider === "codex") {
503
- await setupClient(["codex"]);
737
+ const options = parseOptions(args.slice(1));
738
+ const profileName = options.name || "codex";
739
+ const profile = buildProfileFromOptions("codex", options);
740
+ const config = await loadConfig();
741
+ await saveConfig({
742
+ ai: {
743
+ ...config.ai,
744
+ activeProfile: profileName,
745
+ provider: "codex",
746
+ model: profile.model,
747
+ profiles: {
748
+ ...(config.ai.profiles || {}),
749
+ [profileName]: profile,
750
+ },
751
+ },
752
+ });
753
+ console.log(`AI-профиль ${profileName} сохранен и выбран.`);
754
+ console.log("Проверка Codex CLI:");
755
+ console.log(` ${await getCommandVersion("codex", ["--version"])}`);
756
+ console.log("MCP подключается отдельно командой:");
757
+ console.log(" iola setup codex");
504
758
  return;
505
759
  }
506
760
 
@@ -532,33 +786,318 @@ async function handleAiKey(args) {
532
786
  iola ai key delete openai|openrouter`);
533
787
  }
534
788
 
535
- async function useAiProvider(args) {
789
+ async function handleAiProfile(args) {
790
+ const [action = "list", name, ...rest] = args;
791
+
792
+ if (action === "list" || action === "ls") {
793
+ await printAiProfiles();
794
+ return;
795
+ }
796
+
797
+ if (action === "show") {
798
+ await showAiProfile(name);
799
+ return;
800
+ }
801
+
802
+ if (action === "use") {
803
+ await useAiProfile(name);
804
+ return;
805
+ }
806
+
807
+ if (action === "add" || action === "set") {
808
+ await addAiProfile(name, rest);
809
+ return;
810
+ }
811
+
812
+ if (action === "delete" || action === "remove" || action === "rm") {
813
+ await deleteAiProfile(name);
814
+ return;
815
+ }
816
+
817
+ throw new Error(`Unknown profile command. Use:
818
+ iola ai profiles
819
+ iola ai profile add NAME --provider PROVIDER --model MODEL
820
+ iola ai profile use NAME
821
+ iola ai profile delete NAME`);
822
+ }
823
+
824
+ async function aiModels(args) {
536
825
  const [provider] = args;
826
+ const options = parseOptions(args.slice(1));
827
+
828
+ if (!["ollama", "openai", "openrouter", "codex"].includes(provider)) {
829
+ throw new Error("Провайдер обязателен: iola ai models ollama|openai|openrouter|codex");
830
+ }
831
+
832
+ const models = await listAiModels(provider);
833
+ const filtered = options.search
834
+ ? models.filter((model) => model.id.toLocaleLowerCase("ru-RU").includes(options.search.toLocaleLowerCase("ru-RU")))
835
+ : models;
836
+
837
+ if (options.json) {
838
+ printJson(filtered);
839
+ return;
840
+ }
841
+
842
+ printTable(filtered, [
843
+ ["id", "Модель"],
844
+ ["provider", "Провайдер"],
845
+ ["note", "Примечание"],
846
+ ]);
847
+ }
848
+
849
+ async function listAiModels(provider) {
850
+ if (provider === "ollama") {
851
+ try {
852
+ const config = await loadConfig();
853
+ const response = await fetch(`${config.ai.profiles?.local?.baseUrl || "http://127.0.0.1:11434"}/api/tags`);
854
+
855
+ if (!response.ok) {
856
+ throw new Error(`${response.status} ${response.statusText}`);
857
+ }
858
+
859
+ const payload = await response.json();
860
+ return (payload.models || []).map((model) => ({
861
+ id: model.name,
862
+ provider: "ollama",
863
+ note: model.modified_at ? `updated ${model.modified_at}` : "local",
864
+ }));
865
+ } catch {
866
+ return [
867
+ { id: "llama3.2:1b", provider: "ollama", note: "recommended low RAM" },
868
+ { id: "llama3.2:3b", provider: "ollama", note: "recommended standard" },
869
+ { id: "qwen3:4b", provider: "ollama", note: "recommended balanced" },
870
+ { id: "qwen3:8b", provider: "ollama", note: "recommended good GPU" },
871
+ ];
872
+ }
873
+ }
874
+
875
+ if (provider === "openai") {
876
+ const apiKey = await getApiKey("openai");
877
+ if (!apiKey) {
878
+ throw new Error("OpenAI API key не найден. Выполните iola ai key set openai.");
879
+ }
880
+ const response = await fetch("https://api.openai.com/v1/models", {
881
+ headers: { authorization: `Bearer ${apiKey}` },
882
+ });
883
+
884
+ if (!response.ok) {
885
+ throw new Error(`OpenAI models request failed: ${response.status} ${response.statusText}`);
886
+ }
887
+
888
+ const payload = await response.json();
889
+ return (payload.data || [])
890
+ .map((model) => ({ id: model.id, provider: "openai", note: model.owned_by || "" }))
891
+ .sort((left, right) => left.id.localeCompare(right.id));
892
+ }
893
+
894
+ if (provider === "openrouter") {
895
+ const response = await fetch("https://openrouter.ai/api/v1/models", {
896
+ headers: { accept: "application/json" },
897
+ });
898
+
899
+ if (!response.ok) {
900
+ throw new Error(`OpenRouter models request failed: ${response.status} ${response.statusText}`);
901
+ }
902
+
903
+ const payload = await response.json();
904
+ return (payload.data || [])
905
+ .map((model) => ({
906
+ id: model.id,
907
+ provider: "openrouter",
908
+ note: model.name || "",
909
+ }))
910
+ .sort((left, right) => left.id.localeCompare(right.id));
911
+ }
912
+
913
+ const version = await getCommandVersion("codex", ["--version"]);
914
+ return [
915
+ { id: "gpt-5.5", provider: "codex", note: version },
916
+ { id: "gpt-5", provider: "codex", note: version },
917
+ { id: "gpt-5-codex", provider: "codex", note: version },
918
+ { id: "gpt-5-mini", provider: "codex", note: version },
919
+ ];
920
+ }
921
+
922
+ async function printAiProfiles() {
923
+ const config = await loadConfig();
924
+ const active = getActiveProfileName(config);
925
+ const rows = Object.entries(config.ai.profiles || {}).map(([name, profile]) => ({
926
+ active: name === active ? "*" : "",
927
+ name,
928
+ provider: profile.provider,
929
+ model: profile.model || "-",
930
+ baseUrl: profile.baseUrl || "-",
931
+ mode: profile.provider === "codex" ? `sandbox=${profile.sandbox || "read-only"}, approval=${profile.approval || "never"}` : "-",
932
+ }));
933
+
934
+ printTable(rows, [
935
+ ["active", ""],
936
+ ["name", "Профиль"],
937
+ ["provider", "Провайдер"],
938
+ ["model", "Модель"],
939
+ ["baseUrl", "Base URL"],
940
+ ["mode", "Режим"],
941
+ ]);
942
+ }
943
+
944
+ async function showAiProfile(name) {
945
+ const config = await loadConfig();
946
+ const profileName = name || getActiveProfileName(config);
947
+ const profile = config.ai.profiles?.[profileName];
948
+
949
+ if (!profile) {
950
+ throw new Error(`AI-профиль не найден: ${profileName}`);
951
+ }
952
+
953
+ printJson({ name: profileName, active: profileName === getActiveProfileName(config), ...profile });
954
+ }
955
+
956
+ async function addAiProfile(name, args) {
957
+ if (!name) {
958
+ throw new Error("Имя профиля обязательно. Пример: iola ai profile add router-qwen --provider openrouter --model qwen/qwen3-32b");
959
+ }
960
+
961
+ const options = parseOptions(args);
962
+ const provider = options.provider;
963
+
964
+ if (!["ollama", "openai", "openrouter", "codex"].includes(provider)) {
965
+ throw new Error("Провайдер должен быть ollama, openai, openrouter или codex.");
966
+ }
967
+
968
+ const profile = buildProfileFromOptions(provider, options);
969
+ const config = await loadConfig();
970
+ await saveConfig({
971
+ ai: {
972
+ ...config.ai,
973
+ profiles: {
974
+ ...(config.ai.profiles || {}),
975
+ [name]: profile,
976
+ },
977
+ },
978
+ });
979
+
980
+ console.log(`AI-профиль сохранен: ${name}`);
981
+ }
982
+
983
+ async function useAiProfile(name) {
984
+ if (!name) {
985
+ throw new Error("Имя профиля обязательно. Пример: iola ai profile use local");
986
+ }
987
+
988
+ const config = await loadConfig();
989
+ const profile = config.ai.profiles?.[name];
990
+
991
+ if (!profile) {
992
+ throw new Error(`AI-профиль не найден: ${name}`);
993
+ }
994
+
995
+ await saveConfig({
996
+ ai: {
997
+ ...config.ai,
998
+ activeProfile: name,
999
+ provider: profile.provider,
1000
+ model: profile.model,
1001
+ baseUrl: profile.baseUrl || config.ai.baseUrl,
1002
+ },
1003
+ });
1004
+
1005
+ console.log(`Активный AI-профиль: ${name} (${profile.provider}, ${profile.model || "-"})`);
1006
+ }
537
1007
 
538
- if (provider !== "ollama" && provider !== "openai" && provider !== "openrouter") {
539
- throw new Error("Провайдер должен быть ollama, openai или openrouter.");
1008
+ async function deleteAiProfile(name) {
1009
+ if (!name) {
1010
+ throw new Error("Имя профиля обязательно.");
540
1011
  }
541
1012
 
542
1013
  const config = await loadConfig();
1014
+ const profiles = { ...(config.ai.profiles || {}) };
1015
+
1016
+ if (!profiles[name]) {
1017
+ throw new Error(`AI-профиль не найден: ${name}`);
1018
+ }
1019
+
1020
+ delete profiles[name];
1021
+ const nextActive = config.ai.activeProfile === name ? Object.keys(profiles)[0] : config.ai.activeProfile;
1022
+ const activeProfile = profiles[nextActive] || DEFAULT_AI_CONFIG.ai.profiles.local;
1023
+
1024
+ await saveConfig({
1025
+ ai: {
1026
+ ...config.ai,
1027
+ profiles,
1028
+ activeProfile: nextActive || "local",
1029
+ provider: activeProfile.provider,
1030
+ model: activeProfile.model,
1031
+ baseUrl: activeProfile.baseUrl || config.ai.baseUrl,
1032
+ },
1033
+ });
1034
+
1035
+ console.log(`AI-профиль удален: ${name}`);
1036
+ }
1037
+
1038
+ function buildProfileFromOptions(provider, options) {
1039
+ const defaults = DEFAULT_AI_CONFIG.ai.profiles[provider === "ollama" ? "local" : provider];
1040
+ const profile = {
1041
+ ...defaults,
1042
+ provider,
1043
+ model: options.model || defaults.model,
1044
+ };
1045
+
1046
+ if (options["base-url"]) {
1047
+ profile.baseUrl = options["base-url"];
1048
+ }
1049
+
1050
+ if (provider === "codex") {
1051
+ profile.sandbox = options.sandbox || defaults.sandbox || "read-only";
1052
+ profile.approval = options.approval || defaults.approval || "never";
1053
+ profile.cwd = options.cwd || defaults.cwd || ".";
1054
+ if (options["codex-profile"]) {
1055
+ profile.codexProfile = options["codex-profile"];
1056
+ }
1057
+ }
1058
+
1059
+ return profile;
1060
+ }
1061
+
1062
+ async function useAiProvider(args) {
1063
+ const [providerOrProfile] = args;
1064
+ const config = await loadConfig();
1065
+
1066
+ if (config.ai.profiles?.[providerOrProfile]) {
1067
+ await useAiProfile(providerOrProfile);
1068
+ return;
1069
+ }
1070
+
1071
+ const provider = providerOrProfile;
1072
+
1073
+ if (provider !== "ollama" && provider !== "openai" && provider !== "openrouter" && provider !== "codex") {
1074
+ throw new Error("Провайдер должен быть ollama, openai, openrouter, codex или именем AI-профиля.");
1075
+ }
1076
+
543
1077
  const defaultModel = {
544
1078
  ollama: config.ai.provider === "ollama" ? config.ai.model : "llama3.2:1b",
545
1079
  openai: config.ai.provider === "openai" ? config.ai.model : "gpt-4.1-mini",
546
1080
  openrouter: config.ai.provider === "openrouter" ? config.ai.model : "openai/gpt-4.1-mini",
1081
+ codex: config.ai.provider === "codex" ? config.ai.model : "gpt-5.5",
547
1082
  }[provider];
1083
+ const profileName = provider === "ollama" ? "local" : provider;
1084
+ const profile = buildProfileFromOptions(provider, { model: defaultModel });
548
1085
 
549
1086
  await saveConfig({
550
1087
  ai: {
1088
+ ...config.ai,
1089
+ activeProfile: profileName,
551
1090
  provider,
552
1091
  model: defaultModel,
553
- baseUrl: provider === "ollama"
554
- ? "http://127.0.0.1:11434"
555
- : provider === "openai"
556
- ? "https://api.openai.com/v1"
557
- : "https://openrouter.ai/api/v1",
1092
+ baseUrl: profile.baseUrl,
1093
+ profiles: {
1094
+ ...(config.ai.profiles || {}),
1095
+ [profileName]: profile,
1096
+ },
558
1097
  },
559
1098
  });
560
1099
 
561
- console.log(`AI-провайдер переключен: ${provider}, модель: ${defaultModel}`);
1100
+ console.log(`AI-провайдер переключен: ${provider}, профиль: ${profileName}, модель: ${defaultModel}`);
562
1101
  }
563
1102
 
564
1103
  async function aiContext(args) {
@@ -683,11 +1222,23 @@ async function setupOllama(args) {
683
1222
  await runCommand("ollama", ["pull", model], { inherit: true });
684
1223
  }
685
1224
 
1225
+ const config = await loadConfig();
1226
+ const profileName = options.name || "local";
686
1227
  await saveConfig({
687
1228
  ai: {
1229
+ ...config.ai,
1230
+ activeProfile: profileName,
688
1231
  provider: "ollama",
689
1232
  model,
690
1233
  baseUrl: "http://127.0.0.1:11434",
1234
+ profiles: {
1235
+ ...(config.ai.profiles || {}),
1236
+ [profileName]: {
1237
+ provider: "ollama",
1238
+ model,
1239
+ baseUrl: "http://127.0.0.1:11434",
1240
+ },
1241
+ },
691
1242
  },
692
1243
  });
693
1244
 
@@ -704,13 +1255,7 @@ async function aiAsk(args, context = {}) {
704
1255
  }
705
1256
 
706
1257
  const config = await loadConfig();
707
- const provider = options.provider || config.ai.provider;
708
- const model = options.model || config.ai.model;
709
- const providerConfig = {
710
- ...config.ai,
711
- provider,
712
- model,
713
- };
1258
+ const providerConfig = resolveAiProfile(config, options);
714
1259
  const dataContext = await buildDataContext(question);
715
1260
  const messages = buildAiMessages(question, dataContext, context.history || []);
716
1261
  const answer = await callAiProvider(providerConfig, messages);
@@ -719,11 +1264,33 @@ async function aiAsk(args, context = {}) {
719
1264
  return answer;
720
1265
  }
721
1266
 
1267
+ function resolveAiProfile(config, options = {}) {
1268
+ const profileName = options.profile || (options.provider && config.ai.profiles?.[options.provider]
1269
+ ? options.provider
1270
+ : getActiveProfileName(config));
1271
+ const activeProfile = config.ai.profiles?.[profileName] || {
1272
+ provider: config.ai.provider,
1273
+ model: config.ai.model,
1274
+ baseUrl: config.ai.baseUrl,
1275
+ };
1276
+ const provider = options.provider && !config.ai.profiles?.[options.provider] ? options.provider : activeProfile.provider;
1277
+
1278
+ return {
1279
+ name: profileName,
1280
+ ...activeProfile,
1281
+ provider,
1282
+ model: options.model || activeProfile.model || config.ai.model,
1283
+ baseUrl: options["base-url"] || activeProfile.baseUrl || config.ai.baseUrl,
1284
+ };
1285
+ }
1286
+
722
1287
  async function buildDataContext(question) {
1288
+ const apiBaseUrl = await getApiBaseUrl();
1289
+ const mcpBaseUrl = await getMcpBaseUrl();
723
1290
  const [layers, schools, kindergartens] = await Promise.all([
724
- fetchJson(`${MCP_BASE_URL}/mcp-version`),
725
- fetchJson(`${API_BASE_URL}/schools?limit=100&offset=0`),
726
- fetchJson(`${API_BASE_URL}/kindergartens?limit=100&offset=0`),
1291
+ fetchJson(`${mcpBaseUrl}/mcp-version`),
1292
+ fetchJson(`${apiBaseUrl}/schools?limit=100&offset=0`),
1293
+ fetchJson(`${apiBaseUrl}/kindergartens?limit=100&offset=0`),
727
1294
  ]);
728
1295
  const queryTerms = extractSearchTerms(question);
729
1296
  const patterns = extractStructuredPatterns(question);
@@ -891,9 +1458,49 @@ async function callAiProvider(config, messages) {
891
1458
  return callOpenAiCompatible(config, messages, await getApiKey("openrouter"), "OpenRouter");
892
1459
  }
893
1460
 
1461
+ if (config.provider === "codex") {
1462
+ return callCodex(config, messages);
1463
+ }
1464
+
894
1465
  throw new Error(`Неизвестный AI-провайдер: ${config.provider}`);
895
1466
  }
896
1467
 
1468
+ async function callCodex(config, messages) {
1469
+ const prompt = messages.map((message) => `${message.role.toUpperCase()}:\n${message.content}`).join("\n\n");
1470
+ const outputFile = path.join(os.tmpdir(), `iola-codex-${process.pid}-${Date.now()}.txt`);
1471
+ const args = [
1472
+ "exec",
1473
+ "--skip-git-repo-check",
1474
+ "--output-last-message",
1475
+ outputFile,
1476
+ "--cd",
1477
+ path.resolve(process.cwd(), config.cwd || "."),
1478
+ "--model",
1479
+ config.model || "gpt-5.5",
1480
+ "--sandbox",
1481
+ config.sandbox || "read-only",
1482
+ ];
1483
+
1484
+ if (config.codexProfile) {
1485
+ args.push("--profile", config.codexProfile);
1486
+ }
1487
+
1488
+ args.push("-");
1489
+
1490
+ try {
1491
+ const { stdout, stderr } = await runCommand("codex", args, { input: prompt });
1492
+ const answer = (await readFile(outputFile, "utf8")).trim();
1493
+ if (answer) {
1494
+ return answer;
1495
+ }
1496
+ return stdout.trim() || stderr.trim();
1497
+ } catch (error) {
1498
+ throw new Error(`Codex CLI недоступен или не авторизован. Проверьте "codex doctor" и "codex login".\n${error.message}`);
1499
+ } finally {
1500
+ await rm(outputFile, { force: true });
1501
+ }
1502
+ }
1503
+
897
1504
  async function callOllama(config, messages) {
898
1505
  let response;
899
1506
 
@@ -921,7 +1528,7 @@ async function callOllama(config, messages) {
921
1528
 
922
1529
  async function callOpenAiCompatible(config, messages, apiKey, providerName) {
923
1530
  if (!apiKey) {
924
- throw new Error(`${providerName} API key не найден. Задайте ${providerName === "OpenAI" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
1531
+ throw new Error(`${providerName} API key не найден. Выполните iola ai key set ${providerName === "OpenAI" ? "openai" : "openrouter"} или задайте ${providerName === "OpenAI" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
925
1532
  }
926
1533
 
927
1534
  const response = await fetch(`${config.baseUrl}/chat/completions`, {
@@ -963,7 +1570,7 @@ async function getApiKey(provider) {
963
1570
 
964
1571
  async function listLayers(args) {
965
1572
  const options = parseOptions(args);
966
- const info = await fetchJson(`${MCP_BASE_URL}/mcp-version`);
1573
+ const info = await fetchJson(`${await getMcpBaseUrl()}/mcp-version`);
967
1574
 
968
1575
  if (options.json) {
969
1576
  printJson(info.data_layers);
@@ -980,7 +1587,7 @@ async function listLayers(args) {
980
1587
 
981
1588
  async function showMcpInfo(args) {
982
1589
  const options = parseOptions(args);
983
- const info = await fetchJson(`${MCP_BASE_URL}/mcp-version`);
1590
+ const info = await fetchJson(`${await getMcpBaseUrl()}/mcp-version`);
984
1591
 
985
1592
  if (options.json) {
986
1593
  printJson(info);
@@ -1039,22 +1646,24 @@ async function listDataset(dataset, args) {
1039
1646
  params.set("limit", options.limit || "20");
1040
1647
  params.set("offset", options.offset || "0");
1041
1648
 
1042
- const data = await fetchJson(`${API_BASE_URL}/${DATASETS[dataset].endpoint}?${params}`);
1649
+ const data = await fetchJson(`${await getApiBaseUrl()}/${DATASETS[dataset].endpoint}?${params}`);
1043
1650
  const items = normalizeItems(data);
1044
- const filtered = options.search ? filterItems(items, options.search) : items;
1651
+ const filtered = applyDatasetFilters(items, options);
1045
1652
  const limited = filtered.slice(0, Number(options.limit || 20));
1653
+ const summarized = limited.map(selectPublicSummary);
1654
+ const projected = projectColumns(summarized, options.columns);
1046
1655
 
1047
1656
  if (options.json || options.format === "json") {
1048
- printJson(limited);
1657
+ printJson(projected);
1049
1658
  return;
1050
1659
  }
1051
1660
 
1052
1661
  if (options.format === "csv") {
1053
- printCsv(limited.map(selectPublicSummary));
1662
+ printCsv(projected);
1054
1663
  return;
1055
1664
  }
1056
1665
 
1057
- printDatasetTable(limited);
1666
+ printDatasetTable(projected, options.columns);
1058
1667
  }
1059
1668
 
1060
1669
  async function getDatasetItem(dataset, options) {
@@ -1062,7 +1671,7 @@ async function getDatasetItem(dataset, options) {
1062
1671
  throw new Error(`INN is required. Example: iola ${dataset} get --inn 1215067180`);
1063
1672
  }
1064
1673
 
1065
- const data = await fetchJson(`${API_BASE_URL}/${DATASETS[dataset].endpoint}?limit=500&offset=0`);
1674
+ const data = await fetchJson(`${await getApiBaseUrl()}/${DATASETS[dataset].endpoint}?limit=500&offset=0`);
1066
1675
  const item = normalizeItems(data).find((entry) => String(entry.inn) === String(options.inn));
1067
1676
 
1068
1677
  if (!item) {
@@ -1086,14 +1695,14 @@ async function searchAll(args) {
1086
1695
  }
1087
1696
 
1088
1697
  const [schools, kindergartens] = await Promise.all([
1089
- fetchJson(`${API_BASE_URL}/schools?limit=100&offset=0`),
1090
- fetchJson(`${API_BASE_URL}/kindergartens?limit=100&offset=0`),
1698
+ fetchJson(`${await getApiBaseUrl()}/schools?limit=100&offset=0`),
1699
+ fetchJson(`${await getApiBaseUrl()}/kindergartens?limit=100&offset=0`),
1091
1700
  ]);
1092
1701
 
1093
1702
  const limit = Number(options.limit || 5);
1094
1703
  const result = {
1095
- schools: filterItems(normalizeItems(schools), query).slice(0, limit),
1096
- kindergartens: filterItems(normalizeItems(kindergartens), query).slice(0, limit),
1704
+ schools: projectColumns(filterItems(normalizeItems(schools), query).slice(0, limit).map(selectPublicSummary), options.columns),
1705
+ kindergartens: projectColumns(filterItems(normalizeItems(kindergartens), query).slice(0, limit).map(selectPublicSummary), options.columns),
1097
1706
  };
1098
1707
 
1099
1708
  if (options.json || options.format === "json") {
@@ -1103,17 +1712,17 @@ async function searchAll(args) {
1103
1712
 
1104
1713
  if (options.format === "csv") {
1105
1714
  printCsv([
1106
- ...result.schools.map((item) => ({ layer: "schools", ...selectPublicSummary(item) })),
1107
- ...result.kindergartens.map((item) => ({ layer: "kindergartens", ...selectPublicSummary(item) })),
1715
+ ...result.schools.map((item) => ({ layer: "schools", ...item })),
1716
+ ...result.kindergartens.map((item) => ({ layer: "kindergartens", ...item })),
1108
1717
  ]);
1109
1718
  return;
1110
1719
  }
1111
1720
 
1112
1721
  console.log("Школы");
1113
- printDatasetTable(result.schools);
1722
+ printDatasetTable(result.schools, options.columns);
1114
1723
  console.log("");
1115
1724
  console.log("Детские сады");
1116
- printDatasetTable(result.kindergartens);
1725
+ printDatasetTable(result.kindergartens, options.columns);
1117
1726
  }
1118
1727
 
1119
1728
  async function setupClient(args) {
@@ -1137,7 +1746,7 @@ function parseOptions(args) {
1137
1746
  result[arg.slice(2)] = true;
1138
1747
  } else if (arg === "--check") {
1139
1748
  result.check = true;
1140
- } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--format") {
1749
+ } 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") {
1141
1750
  result[arg.slice(2)] = args[index + 1];
1142
1751
  index += 1;
1143
1752
  } else {
@@ -1187,6 +1796,42 @@ function filterItems(items, query) {
1187
1796
  return items.filter((item) => JSON.stringify(item).toLocaleLowerCase("ru-RU").includes(normalized));
1188
1797
  }
1189
1798
 
1799
+ function applyDatasetFilters(items, options) {
1800
+ let result = options.search ? filterItems(items, options.search) : items;
1801
+
1802
+ if (options.where) {
1803
+ const [field, ...valueParts] = String(options.where).split("=");
1804
+ const value = valueParts.join("=").trim().toLocaleLowerCase("ru-RU");
1805
+ const key = field.trim();
1806
+
1807
+ if (!key || !value) {
1808
+ throw new Error('Фильтр --where должен быть в формате field=value. Пример: --where address=Петрова');
1809
+ }
1810
+
1811
+ result = result.filter((item) => {
1812
+ const summary = selectPublicSummary(item);
1813
+ const raw = summary[key] ?? item[key];
1814
+ return String(raw ?? "").toLocaleLowerCase("ru-RU").includes(value);
1815
+ });
1816
+ }
1817
+
1818
+ return result;
1819
+ }
1820
+
1821
+ function projectColumns(rows, columnsValue) {
1822
+ if (!columnsValue) {
1823
+ return rows;
1824
+ }
1825
+
1826
+ const columns = String(columnsValue).split(",").map((column) => column.trim()).filter(Boolean);
1827
+
1828
+ if (columns.length === 0) {
1829
+ return rows;
1830
+ }
1831
+
1832
+ return rows.map((row) => Object.fromEntries(columns.map((column) => [column, row[column] ?? ""])));
1833
+ }
1834
+
1190
1835
  function normalizeItems(payload) {
1191
1836
  if (Array.isArray(payload)) {
1192
1837
  return payload;
@@ -1206,7 +1851,7 @@ function normalizeItems(payload) {
1206
1851
  function selectPublicSummary(item) {
1207
1852
  return {
1208
1853
  inn: item.inn,
1209
- name: item.fns_short_name || item.fns_full_name,
1854
+ name: item.name || item.fns_short_name || item.fns_full_name,
1210
1855
  address: item.address || item.legal_address,
1211
1856
  phone: item.phone,
1212
1857
  email: item.email,
@@ -1422,8 +2067,15 @@ async function confirm(question) {
1422
2067
  async function saveConfig(value) {
1423
2068
  const current = await loadConfig();
1424
2069
  const merged = mergeConfig(current, value);
2070
+ if (value.ai?.profiles) {
2071
+ merged.ai.profiles = value.ai.profiles;
2072
+ }
2073
+ await writeConfig(merged);
2074
+ }
2075
+
2076
+ async function writeConfig(value) {
1425
2077
  await mkdir(CONFIG_DIR, { recursive: true });
1426
- await writeFile(CONFIG_FILE, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
2078
+ await writeFile(CONFIG_FILE, `${JSON.stringify(value, null, 2)}\n`, "utf8");
1427
2079
  }
1428
2080
 
1429
2081
  async function loadConfig() {
@@ -1439,13 +2091,68 @@ function mergeConfig(base, override) {
1439
2091
  return {
1440
2092
  ...base,
1441
2093
  ...override,
2094
+ api: {
2095
+ ...base.api,
2096
+ ...(override.api || {}),
2097
+ },
1442
2098
  ai: {
1443
2099
  ...base.ai,
1444
2100
  ...(override.ai || {}),
2101
+ profiles: {
2102
+ ...(base.ai.profiles || {}),
2103
+ ...(override.ai?.profiles || {}),
2104
+ },
1445
2105
  },
1446
2106
  };
1447
2107
  }
1448
2108
 
2109
+ function getActiveProfileName(config) {
2110
+ if (config.ai.activeProfile && config.ai.profiles?.[config.ai.activeProfile]) {
2111
+ return config.ai.activeProfile;
2112
+ }
2113
+
2114
+ const provider = config.ai.provider === "ollama" ? "local" : config.ai.provider;
2115
+ if (provider && config.ai.profiles?.[provider]) {
2116
+ return provider;
2117
+ }
2118
+
2119
+ return Object.keys(config.ai.profiles || {})[0] || "local";
2120
+ }
2121
+
2122
+ async function getApiBaseUrl() {
2123
+ if (process.env.IOLA_API_BASE_URL) {
2124
+ return process.env.IOLA_API_BASE_URL;
2125
+ }
2126
+
2127
+ const config = await loadConfig();
2128
+ return config.api.baseUrl;
2129
+ }
2130
+
2131
+ async function getMcpBaseUrl() {
2132
+ if (process.env.IOLA_MCP_BASE_URL) {
2133
+ return process.env.IOLA_MCP_BASE_URL;
2134
+ }
2135
+
2136
+ const config = await loadConfig();
2137
+ return config.api.mcpBaseUrl;
2138
+ }
2139
+
2140
+ function getConfigValue(config, key) {
2141
+ return key.split(".").reduce((value, part) => value?.[part], config);
2142
+ }
2143
+
2144
+ function setConfigValue(config, key, value) {
2145
+ const parts = key.split(".");
2146
+ let current = config;
2147
+
2148
+ for (const part of parts.slice(0, -1)) {
2149
+ current[part] = current[part] && typeof current[part] === "object" ? current[part] : {};
2150
+ current = current[part];
2151
+ }
2152
+
2153
+ current[parts.at(-1)] = value;
2154
+ }
2155
+
1449
2156
  async function loadSecrets() {
1450
2157
  try {
1451
2158
  return JSON.parse(await readFile(SECRETS_FILE, "utf8"));
@@ -1518,6 +2225,14 @@ function runCommand(command, args, options = {}) {
1518
2225
  maxBuffer: 1024 * 1024 * 5,
1519
2226
  }, (error, stdout, stderr) => {
1520
2227
  if (error) {
2228
+ if (process.platform === "win32" && (error.code === "ENOENT" || error.code === "EINVAL") && !options.cmdFallback) {
2229
+ runCommand(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", quoteWindowsCommand(command, args)], {
2230
+ ...options,
2231
+ cmdFallback: true,
2232
+ }).then(resolve, reject);
2233
+ return;
2234
+ }
2235
+
1521
2236
  reject(error);
1522
2237
  return;
1523
2238
  }
@@ -1529,9 +2244,23 @@ function runCommand(command, args, options = {}) {
1529
2244
  child.stdout?.pipe(process.stdout);
1530
2245
  child.stderr?.pipe(process.stderr);
1531
2246
  }
2247
+
2248
+ if (options.input) {
2249
+ child.stdin?.end(options.input);
2250
+ }
1532
2251
  });
1533
2252
  }
1534
2253
 
2254
+ function quoteWindowsCommand(command, args) {
2255
+ return [command, ...args].map((value) => {
2256
+ const text = String(value);
2257
+ if (/^[A-Za-z0-9_./:=\\-]+$/.test(text)) {
2258
+ return text;
2259
+ }
2260
+ return `"${text.replace(/"/g, "\\\"")}"`;
2261
+ }).join(" ");
2262
+ }
2263
+
1535
2264
  function roundGb(bytes) {
1536
2265
  return Math.round((bytes / 1024 / 1024 / 1024) * 10) / 10;
1537
2266
  }
@@ -1573,7 +2302,17 @@ function csvCell(value) {
1573
2302
  return `"${text.replace(/"/g, "\"\"")}"`;
1574
2303
  }
1575
2304
 
1576
- function printDatasetTable(items) {
2305
+ function printDatasetTable(items, columnsValue) {
2306
+ if (columnsValue) {
2307
+ const columns = String(columnsValue)
2308
+ .split(",")
2309
+ .map((column) => column.trim())
2310
+ .filter(Boolean)
2311
+ .map((column) => [column, column]);
2312
+ printTable(items, columns);
2313
+ return;
2314
+ }
2315
+
1577
2316
  printTable(items.map(selectPublicSummary), [
1578
2317
  ["inn", "ИНН"],
1579
2318
  ["name", "Название"],