@iola_adm/iola-cli 0.1.8 → 0.1.10

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 +42 -5
  2. package/package.json +1 -1
  3. package/src/cli.js +513 -30
package/README.md CHANGED
@@ -62,6 +62,7 @@ npx -y @iola_adm/iola-cli ai setup ollama
62
62
 
63
63
  ```bash
64
64
  npx -y @iola_adm/iola-cli help
65
+ npx -y @iola_adm/iola-cli init --yes
65
66
  ```
66
67
 
67
68
  Глобальная установка:
@@ -69,6 +70,7 @@ npx -y @iola_adm/iola-cli help
69
70
  ```bash
70
71
  npm install -g @iola_adm/iola-cli
71
72
  iola help
73
+ iola init
72
74
  ```
73
75
 
74
76
  ## Команды
@@ -76,6 +78,19 @@ iola help
76
78
  ```bash
77
79
  iola banner
78
80
  iola agent
81
+ iola chat
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
87
+ iola update
88
+ iola version --check
89
+ iola ask "Найди школу 29"
90
+ iola data schools --limit 10
91
+ iola data kindergartens --search "29"
92
+ iola data schools --where address=Петрова --columns name,address,phone
93
+ iola data schools --format csv
79
94
  iola ai doctor
80
95
  iola ai setup ollama
81
96
  iola ai ask "Какие школы есть на улице Петрова?"
@@ -87,6 +102,7 @@ iola ai setup openrouter --model openai/gpt-4.1-mini
87
102
  iola health
88
103
  iola layers
89
104
  iola schools --limit 10
105
+ iola schools --format csv
90
106
  iola schools get --inn 1215067180
91
107
  iola kindergartens --search "29"
92
108
  iola kindergartens get --inn 1215077421 --json
@@ -96,7 +112,8 @@ iola setup codex
96
112
  ```
97
113
 
98
114
  По умолчанию команды выводят компактную таблицу. Для полного ответа API
99
- используйте `--json`.
115
+ используйте `--json` или `--format json`. Для выгрузки используйте
116
+ `--format csv`.
100
117
 
101
118
  ## Интерактивный режим
102
119
 
@@ -109,7 +126,10 @@ iola agent
109
126
  ```text
110
127
  /help
111
128
  /health
129
+ /doctor
130
+ /config get
112
131
  /layers
132
+ /data schools --limit 10
113
133
  /schools --limit 10
114
134
  /schools get --inn 1215067180
115
135
  /kindergartens --search 29
@@ -126,10 +146,13 @@ iola agent
126
146
  /config
127
147
  /history
128
148
  /clear
149
+ /update
150
+ /init
129
151
  /exit
130
152
  ```
131
153
 
132
154
  Обычный текст без `/` в `iola agent` отправляется в настроенный AI-провайдер.
155
+ `iola chat` запускает тот же интерактивный режим.
133
156
 
134
157
  ## AI-запросы
135
158
 
@@ -138,6 +161,7 @@ iola agent
138
161
  ```bash
139
162
  iola ai setup ollama
140
163
  iola ai ask "Какие школы есть на улице Петрова?"
164
+ iola ask "Какие школы есть на улице Петрова?"
141
165
  ```
142
166
 
143
167
  OpenAI:
@@ -166,6 +190,10 @@ iola ai context "улица Петрова"
166
190
 
167
191
  Поиск контекста учитывает номера учреждений, ИНН и улицы.
168
192
 
193
+ AI-ответ строится по контексту из публичного API. Ассистент получает краткий
194
+ список источников контекста и должен указывать слой, название и ИНН, если
195
+ отвечает по конкретным организациям.
196
+
169
197
  Ключи OpenAI/OpenRouter сохраняются локально на компьютере пользователя:
170
198
 
171
199
  ```text
@@ -185,14 +213,14 @@ iola ai key delete openai
185
213
  `OPENROUTER_API_KEY`) и сохранен локальный ключ, CLI использует переменную
186
214
  окружения как более приоритетную.
187
215
 
188
- AI-ответ строится по контексту из публичного API. Если данных в контексте
189
- недостаточно, ассистент должен сообщить об этом, а не выдумывать сведения.
216
+ Если данных в контексте недостаточно, ассистент должен сообщить об этом, а не
217
+ выдумывать сведения.
190
218
 
191
219
  ## Назначение
192
220
 
193
221
  CLI дает прямой терминальный доступ к открытым данным городского округа,
194
- командам подключения MCP/skill, AI-запросам через Ollama/OpenAI/OpenRouter и
195
- интерактивному агентному режиму.
222
+ командам подключения MCP/skill, AI-запросам через Ollama/OpenAI/OpenRouter,
223
+ интерактивному агентному режиму, экспорту данных и проверке обновлений.
196
224
 
197
225
  ## Переменные окружения
198
226
 
@@ -200,3 +228,12 @@ CLI дает прямой терминальный доступ к открыт
200
228
  IOLA_API_BASE_URL=https://apiiola.yasg.ru/api/v1
201
229
  IOLA_MCP_BASE_URL=https://apiiola.yasg.ru
202
230
  ```
231
+
232
+ Переменные окружения имеют приоритет над локальной конфигурацией. Локальные
233
+ endpoints можно настроить так:
234
+
235
+ ```bash
236
+ iola config set api.baseUrl https://apiiola.yasg.ru/api/v1
237
+ iola config set api.mcpBaseUrl https://apiiola.yasg.ru
238
+ iola config get
239
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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
@@ -11,12 +11,26 @@ 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: {
15
19
  provider: "ollama",
16
20
  model: "llama3.2:1b",
17
21
  baseUrl: "http://127.0.0.1:11434",
18
22
  },
19
23
  };
24
+ const DATASETS = {
25
+ schools: {
26
+ title: "Школы",
27
+ endpoint: "schools",
28
+ },
29
+ kindergartens: {
30
+ title: "Детские сады",
31
+ endpoint: "kindergartens",
32
+ },
33
+ };
20
34
  const BANNER = `\x1b[38;5;45m┌────────────────────────────────────────────────────────────────────────────┐
21
35
  │\x1b[38;5;51m ____ _ ___ ____ ____ ___ _____ _ _______ \x1b[38;5;45m│
22
36
  │\x1b[38;5;51m / ___| | |_ _| | _ \\| _ \\ / _ \\| ____| |/ /_ _| \x1b[38;5;45m│
@@ -34,11 +48,18 @@ const BANNER = `\x1b[38;5;45m┌────────────────
34
48
  const COMMANDS = new Map([
35
49
  ["help", showHelp],
36
50
  ["version", showVersion],
51
+ ["update", checkUpdate],
52
+ ["doctor", doctor],
53
+ ["config", handleConfig],
37
54
  ["banner", showBanner],
38
55
  ["agent", startAgent],
56
+ ["chat", startAgent],
57
+ ["ask", aiAsk],
39
58
  ["ai", handleAi],
59
+ ["init", initCli],
40
60
  ["health", checkHealth],
41
61
  ["layers", listLayers],
62
+ ["data", handleData],
42
63
  ["schools", listSchools],
43
64
  ["kindergartens", listKindergartens],
44
65
  ["search", searchAll],
@@ -64,6 +85,16 @@ async function showHelp() {
64
85
  Usage:
65
86
  iola banner
66
87
  iola agent
88
+ iola chat
89
+ iola init
90
+ iola doctor
91
+ iola config get
92
+ iola config set api.baseUrl URL
93
+ iola config set api.mcpBaseUrl URL
94
+ iola config reset
95
+ iola update
96
+ iola ask TEXT
97
+ iola data LAYER [--limit 10] [--search TEXT] [--where FIELD=VALUE] [--columns a,b,c] [--format table|json|csv]
67
98
  iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
68
99
  iola ai context TEXT [--json]
69
100
  iola ai key set openai
@@ -75,11 +106,11 @@ Usage:
75
106
  iola ai setup ollama [--yes] [--model MODEL]
76
107
  iola health [--json]
77
108
  iola layers [--json]
78
- iola schools [--limit 10] [--search TEXT] [--json]
109
+ iola schools [--limit 10] [--search TEXT] [--where FIELD=VALUE] [--columns a,b,c] [--format table|json|csv]
79
110
  iola schools get --inn INN [--json]
80
- iola kindergartens [--limit 10] [--search TEXT] [--json]
111
+ iola kindergartens [--limit 10] [--search TEXT] [--where FIELD=VALUE] [--columns a,b,c] [--format table|json|csv]
81
112
  iola kindergartens get --inn INN [--json]
82
- iola search TEXT [--limit 5] [--json]
113
+ iola search TEXT [--limit 5] [--format table|json|csv]
83
114
  iola mcp-info [--json]
84
115
  iola setup codex
85
116
  iola version
@@ -160,7 +191,17 @@ async function handleAgentLine(line, state) {
160
191
  }
161
192
 
162
193
  if (command === "config") {
163
- await printAiConfig();
194
+ await handleConfig(args.length > 0 ? args : ["get"]);
195
+ return false;
196
+ }
197
+
198
+ if (command === "doctor") {
199
+ await doctor(args);
200
+ return false;
201
+ }
202
+
203
+ if (command === "cfg" || command === "settings") {
204
+ await handleConfig(args);
164
205
  return false;
165
206
  }
166
207
 
@@ -194,6 +235,16 @@ async function handleAgentLine(line, state) {
194
235
  return false;
195
236
  }
196
237
 
238
+ if (command === "update") {
239
+ await checkUpdate(args);
240
+ return false;
241
+ }
242
+
243
+ if (command === "init") {
244
+ await initCli(args);
245
+ return false;
246
+ }
247
+
197
248
  if (command === "ai") {
198
249
  await handleAi(args);
199
250
  return false;
@@ -201,7 +252,10 @@ async function handleAgentLine(line, state) {
201
252
 
202
253
  const mapped = {
203
254
  health: ["health", args],
255
+ doctor: ["doctor", args],
256
+ config: ["config", args],
204
257
  layers: ["layers", args],
258
+ data: ["data", args],
205
259
  schools: ["schools", args],
206
260
  kindergartens: ["kindergartens", args],
207
261
  search: ["search", args],
@@ -224,7 +278,11 @@ function printAgentHelp() {
224
278
  console.log(`Slash-команды:
225
279
  /help
226
280
  /health
281
+ /doctor
282
+ /config get
283
+ /config set api.baseUrl URL
227
284
  /layers
285
+ /data schools --limit 10
228
286
  /schools --limit 10
229
287
  /schools get --inn 1215067180
230
288
  /kindergartens --search 29
@@ -244,6 +302,8 @@ function printAgentHelp() {
244
302
  /history
245
303
  /clear
246
304
  /banner
305
+ /update
306
+ /init
247
307
  /exit
248
308
 
249
309
  Обычный текст без slash-команды отправляется в настроенный AI-провайдер.`);
@@ -282,14 +342,48 @@ function showBanner() {
282
342
  console.log("открытые данные • MCP • локальный AI");
283
343
  }
284
344
 
285
- async function showVersion() {
345
+ async function showVersion(args = []) {
346
+ const options = parseOptions(args);
286
347
  const packageJson = await import("../package.json", { with: { type: "json" } });
287
348
  console.log(packageJson.default.version);
349
+
350
+ if (options.check) {
351
+ await checkUpdate([]);
352
+ }
353
+ }
354
+
355
+ async function checkUpdate() {
356
+ const packageJson = await import("../package.json", { with: { type: "json" } });
357
+ const current = packageJson.default.version;
358
+ const latest = await getLatestNpmVersion(packageJson.default.name);
359
+
360
+ if (!latest) {
361
+ console.log("Не удалось проверить npm-версию.");
362
+ return;
363
+ }
364
+
365
+ const comparison = compareVersions(latest, current);
366
+
367
+ if (comparison > 0) {
368
+ console.log(`Доступна новая версия: ${latest}`);
369
+ console.log("Обновление:");
370
+ console.log(` npm install -g ${packageJson.default.name}@latest`);
371
+ console.log("Или запуск без установки:");
372
+ console.log(` npx -y ${packageJson.default.name}@latest help`);
373
+ return;
374
+ }
375
+
376
+ if (comparison < 0) {
377
+ console.log(`Локальная версия ${current} новее опубликованной npm latest ${latest}.`);
378
+ return;
379
+ }
380
+
381
+ console.log(`Установлена актуальная версия: ${current}`);
288
382
  }
289
383
 
290
384
  async function checkHealth(args) {
291
385
  const options = parseOptions(args);
292
- const health = await fetchJson(`${MCP_BASE_URL}/mcp-health`);
386
+ const health = await fetchJson(`${await getMcpBaseUrl()}/mcp-health`);
293
387
 
294
388
  if (options.json) {
295
389
  printJson(health);
@@ -304,6 +398,127 @@ async function checkHealth(args) {
304
398
  });
305
399
  }
306
400
 
401
+ async function doctor(args = []) {
402
+ const options = parseOptions(args);
403
+ const packageJson = await import("../package.json", { with: { type: "json" } });
404
+ const config = await loadConfig();
405
+ const secrets = await loadSecrets();
406
+ const diagnostics = await getLocalDiagnostics();
407
+ const latest = await getLatestNpmVersion(packageJson.default.name);
408
+ const apiBaseUrl = await getApiBaseUrl();
409
+ const mcpBaseUrl = await getMcpBaseUrl();
410
+ const report = {
411
+ cli: {
412
+ version: packageJson.default.version,
413
+ npmLatest: latest || "-",
414
+ update: getUpdateStatus(packageJson.default.version, latest),
415
+ },
416
+ api: {
417
+ baseUrl: apiBaseUrl,
418
+ mcpBaseUrl,
419
+ health: await probeEndpoint(`${mcpBaseUrl}/mcp-health`),
420
+ },
421
+ ai: {
422
+ provider: config.ai.provider,
423
+ model: config.ai.model,
424
+ modelAvailable: await checkConfiguredModel(config),
425
+ openaiKey: process.env.OPENAI_API_KEY ? "env" : secrets.openai?.apiKey ? "local" : "missing",
426
+ openrouterKey: process.env.OPENROUTER_API_KEY ? "env" : secrets.openrouter?.apiKey ? "local" : "missing",
427
+ ollama: diagnostics.ollama.installed ? diagnostics.ollama.version : "not-installed",
428
+ },
429
+ system: diagnostics,
430
+ };
431
+
432
+ if (options.json) {
433
+ printJson(report);
434
+ return;
435
+ }
436
+
437
+ console.log("CLI");
438
+ printKeyValue(report.cli);
439
+ console.log("");
440
+ console.log("API/MCP");
441
+ printKeyValue(report.api);
442
+ console.log("");
443
+ console.log("AI");
444
+ printKeyValue(report.ai);
445
+ console.log("");
446
+ printDiagnostics(diagnostics, recommendOllamaModel(diagnostics));
447
+ }
448
+
449
+ function getUpdateStatus(current, latest) {
450
+ if (!latest) {
451
+ return "unknown";
452
+ }
453
+
454
+ const comparison = compareVersions(latest, current);
455
+
456
+ if (comparison > 0) {
457
+ return "available";
458
+ }
459
+
460
+ if (comparison < 0) {
461
+ return "local-newer";
462
+ }
463
+
464
+ return "ok";
465
+ }
466
+
467
+ async function checkConfiguredModel(config) {
468
+ if (config.ai.provider !== "ollama") {
469
+ return "external-api";
470
+ }
471
+
472
+ try {
473
+ const response = await fetch(`${config.ai.baseUrl || "http://127.0.0.1:11434"}/api/tags`);
474
+
475
+ if (!response.ok) {
476
+ return "unknown";
477
+ }
478
+
479
+ const payload = await response.json();
480
+ const models = payload.models || [];
481
+ return models.some((model) => model.name === config.ai.model) ? "installed" : "missing";
482
+ } catch {
483
+ return "ollama-unavailable";
484
+ }
485
+ }
486
+
487
+ async function initCli(args = []) {
488
+ const options = parseOptions(args);
489
+
490
+ showBanner();
491
+ console.log("Проверка окружения");
492
+ printKeyValue({
493
+ node: process.version,
494
+ npm: await getCommandVersion("npm", ["--version"]),
495
+ api: await probeEndpoint(`${await getMcpBaseUrl()}/mcp-health`),
496
+ mcp: await getMcpBaseUrl(),
497
+ });
498
+ console.log("");
499
+
500
+ await aiDoctor(options.json ? ["--json"] : []);
501
+
502
+ if (!process.stdin.isTTY || options.yes) {
503
+ console.log("");
504
+ console.log("Для настройки AI используйте:");
505
+ console.log(" iola ai setup ollama");
506
+ console.log(" iola ai key set openai");
507
+ console.log(" iola ai setup openai --model gpt-4.1-mini");
508
+ return;
509
+ }
510
+
511
+ console.log("");
512
+ const configureAi = await confirm("Настроить AI-провайдер сейчас? [Y/n] ");
513
+
514
+ if (configureAi) {
515
+ await aiSetup([]);
516
+ }
517
+
518
+ console.log("");
519
+ await checkUpdate();
520
+ }
521
+
307
522
  async function handleAi(args) {
308
523
  const [subcommand = "help", ...rest] = args;
309
524
 
@@ -354,6 +569,47 @@ async function handleAi(args) {
354
569
  throw new Error(`Unknown AI command: ${subcommand}\nRun "iola ai help" to see available commands.`);
355
570
  }
356
571
 
572
+ async function handleConfig(args) {
573
+ const [action = "get", key, ...rest] = args;
574
+
575
+ if (action === "get") {
576
+ const config = await loadConfig();
577
+ if (key) {
578
+ console.log(getConfigValue(config, key) ?? "-");
579
+ return;
580
+ }
581
+ printJson({
582
+ file: CONFIG_FILE,
583
+ config,
584
+ effective: {
585
+ apiBaseUrl: await getApiBaseUrl(),
586
+ mcpBaseUrl: await getMcpBaseUrl(),
587
+ },
588
+ });
589
+ return;
590
+ }
591
+
592
+ if (action === "set") {
593
+ const value = rest.join(" ").trim();
594
+ if (!key || !value) {
595
+ throw new Error("Пример: iola config set api.baseUrl https://apiiola.yasg.ru/api/v1");
596
+ }
597
+ const config = await loadConfig();
598
+ setConfigValue(config, key, value);
599
+ await saveConfig(config);
600
+ console.log(`Сохранено: ${key} = ${value}`);
601
+ return;
602
+ }
603
+
604
+ if (action === "reset") {
605
+ await writeConfig(DEFAULT_AI_CONFIG);
606
+ console.log(`Конфигурация сброшена: ${CONFIG_FILE}`);
607
+ return;
608
+ }
609
+
610
+ throw new Error("Команды config: get, set, reset.");
611
+ }
612
+
357
613
  async function aiDoctor(args) {
358
614
  const options = parseOptions(args);
359
615
  const diagnostics = await getLocalDiagnostics();
@@ -393,7 +649,8 @@ async function aiSetup(args) {
393
649
  },
394
650
  });
395
651
  console.log(`AI-профиль ${provider} сохранен в ${CONFIG_FILE}`);
396
- console.log(`Ключ задайте через переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
652
+ console.log(`Ключ сохраните командой: iola ai key set ${provider}`);
653
+ console.log(`Также можно использовать переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
397
654
  return;
398
655
  }
399
656
 
@@ -618,10 +875,12 @@ async function aiAsk(args, context = {}) {
618
875
  }
619
876
 
620
877
  async function buildDataContext(question) {
878
+ const apiBaseUrl = await getApiBaseUrl();
879
+ const mcpBaseUrl = await getMcpBaseUrl();
621
880
  const [layers, schools, kindergartens] = await Promise.all([
622
- fetchJson(`${MCP_BASE_URL}/mcp-version`),
623
- fetchJson(`${API_BASE_URL}/schools?limit=100&offset=0`),
624
- fetchJson(`${API_BASE_URL}/kindergartens?limit=100&offset=0`),
881
+ fetchJson(`${mcpBaseUrl}/mcp-version`),
882
+ fetchJson(`${apiBaseUrl}/schools?limit=100&offset=0`),
883
+ fetchJson(`${apiBaseUrl}/kindergartens?limit=100&offset=0`),
625
884
  ]);
626
885
  const queryTerms = extractSearchTerms(question);
627
886
  const patterns = extractStructuredPatterns(question);
@@ -738,12 +997,14 @@ function scoreItem(item, terms, patterns, layer) {
738
997
  }
739
998
 
740
999
  function buildAiMessages(question, dataContext, history) {
1000
+ const sourceLines = buildSourceLines(dataContext);
741
1001
  const system = [
742
1002
  "Ты терминальный AI-ассистент CLI-проекта Йошкар-Олы.",
743
1003
  "Отвечай на русском языке.",
744
1004
  "Используй только данные из переданного контекста.",
745
1005
  "Если в контексте нет нужных сведений, прямо напиши, что данных недостаточно.",
746
1006
  "Не выдумывай адреса, телефоны, лицензии и руководителей.",
1007
+ "Если отвечаешь по конкретным организациям, укажи источник в конце: слой, название и ИНН.",
747
1008
  "Отвечай кратко и по делу.",
748
1009
  ].join(" ");
749
1010
  const contextText = JSON.stringify(dataContext, null, 2);
@@ -754,11 +1015,26 @@ function buildAiMessages(question, dataContext, history) {
754
1015
  ...recentHistory,
755
1016
  {
756
1017
  role: "user",
757
- content: `Контекст открытых данных городского округа "Город Йошкар-Ола":\n${contextText}\n\nВопрос пользователя: ${question}`,
1018
+ content: `Контекст открытых данных городского округа "Город Йошкар-Ола":\n${contextText}\n\nКраткие источники контекста:\n${sourceLines}\n\nВопрос пользователя: ${question}`,
758
1019
  },
759
1020
  ];
760
1021
  }
761
1022
 
1023
+ function buildSourceLines(dataContext) {
1024
+ const rows = [
1025
+ ...dataContext.schools.map((item) => ({ layer: "schools", ...item })),
1026
+ ...dataContext.kindergartens.map((item) => ({ layer: "kindergartens", ...item })),
1027
+ ];
1028
+
1029
+ if (rows.length === 0) {
1030
+ return "Совпавших организаций нет.";
1031
+ }
1032
+
1033
+ return rows
1034
+ .map((item) => `- ${item.layer}: ${item.name || "-"}; ИНН ${item.inn || "-"}; адрес ${item.address || "-"}`)
1035
+ .join("\n");
1036
+ }
1037
+
762
1038
  async function callAiProvider(config, messages) {
763
1039
  if (config.provider === "ollama") {
764
1040
  return callOllama(config, messages);
@@ -802,7 +1078,7 @@ async function callOllama(config, messages) {
802
1078
 
803
1079
  async function callOpenAiCompatible(config, messages, apiKey, providerName) {
804
1080
  if (!apiKey) {
805
- throw new Error(`${providerName} API key не найден. Задайте ${providerName === "OpenAI" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
1081
+ throw new Error(`${providerName} API key не найден. Выполните iola ai key set ${providerName === "OpenAI" ? "openai" : "openrouter"} или задайте ${providerName === "OpenAI" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
806
1082
  }
807
1083
 
808
1084
  const response = await fetch(`${config.baseUrl}/chat/completions`, {
@@ -844,7 +1120,7 @@ async function getApiKey(provider) {
844
1120
 
845
1121
  async function listLayers(args) {
846
1122
  const options = parseOptions(args);
847
- const info = await fetchJson(`${MCP_BASE_URL}/mcp-version`);
1123
+ const info = await fetchJson(`${await getMcpBaseUrl()}/mcp-version`);
848
1124
 
849
1125
  if (options.json) {
850
1126
  printJson(info.data_layers);
@@ -861,7 +1137,7 @@ async function listLayers(args) {
861
1137
 
862
1138
  async function showMcpInfo(args) {
863
1139
  const options = parseOptions(args);
864
- const info = await fetchJson(`${MCP_BASE_URL}/mcp-version`);
1140
+ const info = await fetchJson(`${await getMcpBaseUrl()}/mcp-version`);
865
1141
 
866
1142
  if (options.json) {
867
1143
  printJson(info);
@@ -886,6 +1162,28 @@ async function listKindergartens(args) {
886
1162
  await listDataset("kindergartens", args);
887
1163
  }
888
1164
 
1165
+ async function handleData(args) {
1166
+ const [dataset, ...rest] = args;
1167
+
1168
+ if (!dataset) {
1169
+ console.log("Доступные слои:");
1170
+ printTable(Object.entries(DATASETS).map(([id, value]) => ({ id, name: value.title })), [
1171
+ ["id", "ID"],
1172
+ ["name", "Название"],
1173
+ ]);
1174
+ console.log("");
1175
+ console.log("Пример:");
1176
+ console.log(" iola data schools --limit 10");
1177
+ return;
1178
+ }
1179
+
1180
+ if (!DATASETS[dataset]) {
1181
+ throw new Error(`Неизвестный слой: ${dataset}. Доступно: ${Object.keys(DATASETS).join(", ")}`);
1182
+ }
1183
+
1184
+ await listDataset(dataset, rest);
1185
+ }
1186
+
889
1187
  async function listDataset(dataset, args) {
890
1188
  const options = parseOptions(args);
891
1189
 
@@ -898,17 +1196,24 @@ async function listDataset(dataset, args) {
898
1196
  params.set("limit", options.limit || "20");
899
1197
  params.set("offset", options.offset || "0");
900
1198
 
901
- const data = await fetchJson(`${API_BASE_URL}/${dataset}?${params}`);
1199
+ const data = await fetchJson(`${await getApiBaseUrl()}/${DATASETS[dataset].endpoint}?${params}`);
902
1200
  const items = normalizeItems(data);
903
- const filtered = options.search ? filterItems(items, options.search) : items;
1201
+ const filtered = applyDatasetFilters(items, options);
904
1202
  const limited = filtered.slice(0, Number(options.limit || 20));
1203
+ const summarized = limited.map(selectPublicSummary);
1204
+ const projected = projectColumns(summarized, options.columns);
905
1205
 
906
- if (options.json) {
907
- printJson(limited);
1206
+ if (options.json || options.format === "json") {
1207
+ printJson(projected);
908
1208
  return;
909
1209
  }
910
1210
 
911
- printDatasetTable(limited);
1211
+ if (options.format === "csv") {
1212
+ printCsv(projected);
1213
+ return;
1214
+ }
1215
+
1216
+ printDatasetTable(projected, options.columns);
912
1217
  }
913
1218
 
914
1219
  async function getDatasetItem(dataset, options) {
@@ -916,7 +1221,7 @@ async function getDatasetItem(dataset, options) {
916
1221
  throw new Error(`INN is required. Example: iola ${dataset} get --inn 1215067180`);
917
1222
  }
918
1223
 
919
- const data = await fetchJson(`${API_BASE_URL}/${dataset}?limit=500&offset=0`);
1224
+ const data = await fetchJson(`${await getApiBaseUrl()}/${DATASETS[dataset].endpoint}?limit=500&offset=0`);
920
1225
  const item = normalizeItems(data).find((entry) => String(entry.inn) === String(options.inn));
921
1226
 
922
1227
  if (!item) {
@@ -940,26 +1245,34 @@ async function searchAll(args) {
940
1245
  }
941
1246
 
942
1247
  const [schools, kindergartens] = await Promise.all([
943
- fetchJson(`${API_BASE_URL}/schools?limit=100&offset=0`),
944
- fetchJson(`${API_BASE_URL}/kindergartens?limit=100&offset=0`),
1248
+ fetchJson(`${await getApiBaseUrl()}/schools?limit=100&offset=0`),
1249
+ fetchJson(`${await getApiBaseUrl()}/kindergartens?limit=100&offset=0`),
945
1250
  ]);
946
1251
 
947
1252
  const limit = Number(options.limit || 5);
948
1253
  const result = {
949
- schools: filterItems(normalizeItems(schools), query).slice(0, limit),
950
- kindergartens: filterItems(normalizeItems(kindergartens), query).slice(0, limit),
1254
+ schools: projectColumns(filterItems(normalizeItems(schools), query).slice(0, limit).map(selectPublicSummary), options.columns),
1255
+ kindergartens: projectColumns(filterItems(normalizeItems(kindergartens), query).slice(0, limit).map(selectPublicSummary), options.columns),
951
1256
  };
952
1257
 
953
- if (options.json) {
1258
+ if (options.json || options.format === "json") {
954
1259
  printJson(result);
955
1260
  return;
956
1261
  }
957
1262
 
1263
+ if (options.format === "csv") {
1264
+ printCsv([
1265
+ ...result.schools.map((item) => ({ layer: "schools", ...item })),
1266
+ ...result.kindergartens.map((item) => ({ layer: "kindergartens", ...item })),
1267
+ ]);
1268
+ return;
1269
+ }
1270
+
958
1271
  console.log("Школы");
959
- printDatasetTable(result.schools);
1272
+ printDatasetTable(result.schools, options.columns);
960
1273
  console.log("");
961
1274
  console.log("Детские сады");
962
- printDatasetTable(result.kindergartens);
1275
+ printDatasetTable(result.kindergartens, options.columns);
963
1276
  }
964
1277
 
965
1278
  async function setupClient(args) {
@@ -981,7 +1294,9 @@ function parseOptions(args) {
981
1294
  const arg = args[index];
982
1295
  if (arg === "--json" || arg === "--yes") {
983
1296
  result[arg.slice(2)] = true;
984
- } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn" || arg === "--model" || arg === "--provider") {
1297
+ } else if (arg === "--check") {
1298
+ result.check = true;
1299
+ } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--format") {
985
1300
  result[arg.slice(2)] = args[index + 1];
986
1301
  index += 1;
987
1302
  } else {
@@ -1031,6 +1346,42 @@ function filterItems(items, query) {
1031
1346
  return items.filter((item) => JSON.stringify(item).toLocaleLowerCase("ru-RU").includes(normalized));
1032
1347
  }
1033
1348
 
1349
+ function applyDatasetFilters(items, options) {
1350
+ let result = options.search ? filterItems(items, options.search) : items;
1351
+
1352
+ if (options.where) {
1353
+ const [field, ...valueParts] = String(options.where).split("=");
1354
+ const value = valueParts.join("=").trim().toLocaleLowerCase("ru-RU");
1355
+ const key = field.trim();
1356
+
1357
+ if (!key || !value) {
1358
+ throw new Error('Фильтр --where должен быть в формате field=value. Пример: --where address=Петрова');
1359
+ }
1360
+
1361
+ result = result.filter((item) => {
1362
+ const summary = selectPublicSummary(item);
1363
+ const raw = summary[key] ?? item[key];
1364
+ return String(raw ?? "").toLocaleLowerCase("ru-RU").includes(value);
1365
+ });
1366
+ }
1367
+
1368
+ return result;
1369
+ }
1370
+
1371
+ function projectColumns(rows, columnsValue) {
1372
+ if (!columnsValue) {
1373
+ return rows;
1374
+ }
1375
+
1376
+ const columns = String(columnsValue).split(",").map((column) => column.trim()).filter(Boolean);
1377
+
1378
+ if (columns.length === 0) {
1379
+ return rows;
1380
+ }
1381
+
1382
+ return rows.map((row) => Object.fromEntries(columns.map((column) => [column, row[column] ?? ""])));
1383
+ }
1384
+
1034
1385
  function normalizeItems(payload) {
1035
1386
  if (Array.isArray(payload)) {
1036
1387
  return payload;
@@ -1050,7 +1401,7 @@ function normalizeItems(payload) {
1050
1401
  function selectPublicSummary(item) {
1051
1402
  return {
1052
1403
  inn: item.inn,
1053
- name: item.fns_short_name || item.fns_full_name,
1404
+ name: item.name || item.fns_short_name || item.fns_full_name,
1054
1405
  address: item.address || item.legal_address,
1055
1406
  phone: item.phone,
1056
1407
  email: item.email,
@@ -1134,6 +1485,66 @@ async function getOllamaVersion() {
1134
1485
  }
1135
1486
  }
1136
1487
 
1488
+ async function getCommandVersion(command, args) {
1489
+ try {
1490
+ const { stdout } = await runCommand(command, args);
1491
+ return stdout.trim() || "installed";
1492
+ } catch {
1493
+ if (process.platform === "win32" && !command.endsWith(".cmd")) {
1494
+ try {
1495
+ const { stdout } = await runCommand(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `${command} ${args.join(" ")}`]);
1496
+ return stdout.trim() || "installed";
1497
+ } catch {
1498
+ return "не найден";
1499
+ }
1500
+ }
1501
+
1502
+ return "не найден";
1503
+ }
1504
+ }
1505
+
1506
+ async function probeEndpoint(url) {
1507
+ try {
1508
+ const response = await fetch(url, { headers: { accept: "application/json" } });
1509
+ return response.ok ? "доступен" : `${response.status} ${response.statusText}`;
1510
+ } catch {
1511
+ return "недоступен";
1512
+ }
1513
+ }
1514
+
1515
+ async function getLatestNpmVersion(packageName) {
1516
+ try {
1517
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, {
1518
+ headers: { accept: "application/json" },
1519
+ });
1520
+
1521
+ if (!response.ok) {
1522
+ return null;
1523
+ }
1524
+
1525
+ const payload = await response.json();
1526
+ return payload.version || null;
1527
+ } catch {
1528
+ return null;
1529
+ }
1530
+ }
1531
+
1532
+ function compareVersions(left, right) {
1533
+ const leftParts = String(left).split(".").map(Number);
1534
+ const rightParts = String(right).split(".").map(Number);
1535
+ const length = Math.max(leftParts.length, rightParts.length);
1536
+
1537
+ for (let index = 0; index < length; index += 1) {
1538
+ const diff = (leftParts[index] || 0) - (rightParts[index] || 0);
1539
+
1540
+ if (diff !== 0) {
1541
+ return diff > 0 ? 1 : -1;
1542
+ }
1543
+ }
1544
+
1545
+ return 0;
1546
+ }
1547
+
1137
1548
  function recommendOllamaModel(diagnostics) {
1138
1549
  const ramGb = diagnostics.ramGb || 0;
1139
1550
  const vramGb = diagnostics.gpu.vramGb || 0;
@@ -1204,6 +1615,12 @@ async function confirm(question) {
1204
1615
  }
1205
1616
 
1206
1617
  async function saveConfig(value) {
1618
+ const current = await loadConfig();
1619
+ const merged = mergeConfig(current, value);
1620
+ await writeConfig(merged);
1621
+ }
1622
+
1623
+ async function writeConfig(value) {
1207
1624
  await mkdir(CONFIG_DIR, { recursive: true });
1208
1625
  await writeFile(CONFIG_FILE, `${JSON.stringify(value, null, 2)}\n`, "utf8");
1209
1626
  }
@@ -1221,6 +1638,10 @@ function mergeConfig(base, override) {
1221
1638
  return {
1222
1639
  ...base,
1223
1640
  ...override,
1641
+ api: {
1642
+ ...base.api,
1643
+ ...(override.api || {}),
1644
+ },
1224
1645
  ai: {
1225
1646
  ...base.ai,
1226
1647
  ...(override.ai || {}),
@@ -1228,6 +1649,40 @@ function mergeConfig(base, override) {
1228
1649
  };
1229
1650
  }
1230
1651
 
1652
+ async function getApiBaseUrl() {
1653
+ if (process.env.IOLA_API_BASE_URL) {
1654
+ return process.env.IOLA_API_BASE_URL;
1655
+ }
1656
+
1657
+ const config = await loadConfig();
1658
+ return config.api.baseUrl;
1659
+ }
1660
+
1661
+ async function getMcpBaseUrl() {
1662
+ if (process.env.IOLA_MCP_BASE_URL) {
1663
+ return process.env.IOLA_MCP_BASE_URL;
1664
+ }
1665
+
1666
+ const config = await loadConfig();
1667
+ return config.api.mcpBaseUrl;
1668
+ }
1669
+
1670
+ function getConfigValue(config, key) {
1671
+ return key.split(".").reduce((value, part) => value?.[part], config);
1672
+ }
1673
+
1674
+ function setConfigValue(config, key, value) {
1675
+ const parts = key.split(".");
1676
+ let current = config;
1677
+
1678
+ for (const part of parts.slice(0, -1)) {
1679
+ current[part] = current[part] && typeof current[part] === "object" ? current[part] : {};
1680
+ current = current[part];
1681
+ }
1682
+
1683
+ current[parts.at(-1)] = value;
1684
+ }
1685
+
1231
1686
  async function loadSecrets() {
1232
1687
  try {
1233
1688
  return JSON.parse(await readFile(SECRETS_FILE, "utf8"));
@@ -1337,7 +1792,35 @@ function printJson(value) {
1337
1792
  console.log(JSON.stringify(value, null, 2));
1338
1793
  }
1339
1794
 
1340
- function printDatasetTable(items) {
1795
+ function printCsv(rows) {
1796
+ if (rows.length === 0) {
1797
+ return;
1798
+ }
1799
+
1800
+ const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
1801
+ console.log(columns.map(csvCell).join(","));
1802
+
1803
+ for (const row of rows) {
1804
+ console.log(columns.map((column) => csvCell(row[column])).join(","));
1805
+ }
1806
+ }
1807
+
1808
+ function csvCell(value) {
1809
+ const text = value == null ? "" : String(value);
1810
+ return `"${text.replace(/"/g, "\"\"")}"`;
1811
+ }
1812
+
1813
+ function printDatasetTable(items, columnsValue) {
1814
+ if (columnsValue) {
1815
+ const columns = String(columnsValue)
1816
+ .split(",")
1817
+ .map((column) => column.trim())
1818
+ .filter(Boolean)
1819
+ .map((column) => [column, column]);
1820
+ printTable(items, columns);
1821
+ return;
1822
+ }
1823
+
1341
1824
  printTable(items.map(selectPublicSummary), [
1342
1825
  ["inn", "ИНН"],
1343
1826
  ["name", "Название"],