@iola_adm/iola-cli 0.1.7 → 0.1.9

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 +41 -6
  2. package/package.json +1 -1
  3. package/src/cli.js +443 -19
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,9 +78,17 @@ iola help
76
78
  ```bash
77
79
  iola banner
78
80
  iola agent
81
+ iola chat
82
+ iola init
83
+ iola update
84
+ iola version --check
85
+ iola data schools --limit 10
86
+ iola data kindergartens --search "29"
87
+ iola data schools --format csv
79
88
  iola ai doctor
80
89
  iola ai setup ollama
81
90
  iola ai ask "Какие школы есть на улице Петрова?"
91
+ iola ai context "школа 29"
82
92
  iola ai key set openai
83
93
  iola ai key status
84
94
  iola ai setup openai --model gpt-4.1-mini
@@ -86,6 +96,7 @@ iola ai setup openrouter --model openai/gpt-4.1-mini
86
96
  iola health
87
97
  iola layers
88
98
  iola schools --limit 10
99
+ iola schools --format csv
89
100
  iola schools get --inn 1215067180
90
101
  iola kindergartens --search "29"
91
102
  iola kindergartens get --inn 1215077421 --json
@@ -95,7 +106,8 @@ iola setup codex
95
106
  ```
96
107
 
97
108
  По умолчанию команды выводят компактную таблицу. Для полного ответа API
98
- используйте `--json`.
109
+ используйте `--json` или `--format json`. Для выгрузки используйте
110
+ `--format csv`.
99
111
 
100
112
  ## Интерактивный режим
101
113
 
@@ -109,21 +121,30 @@ iola agent
109
121
  /help
110
122
  /health
111
123
  /layers
124
+ /data schools --limit 10
112
125
  /schools --limit 10
113
126
  /schools get --inn 1215067180
114
127
  /kindergartens --search 29
115
128
  /search лицей --limit 3
116
129
  /mcp-info
117
130
  /ai doctor
131
+ /context школа 29
132
+ /use ollama
133
+ /use openai
134
+ /key status
135
+ /key set openai
118
136
  /model
119
137
  /provider
120
138
  /config
121
139
  /history
122
140
  /clear
141
+ /update
142
+ /init
123
143
  /exit
124
144
  ```
125
145
 
126
146
  Обычный текст без `/` в `iola agent` отправляется в настроенный AI-провайдер.
147
+ `iola chat` запускает тот же интерактивный режим.
127
148
 
128
149
  ## AI-запросы
129
150
 
@@ -150,6 +171,20 @@ iola ai setup openrouter --model openai/gpt-4.1-mini
150
171
  iola ai ask "Покажи контакты лицея"
151
172
  ```
152
173
 
174
+ Проверить, какие данные попадут в AI-контекст:
175
+
176
+ ```bash
177
+ iola ai context "школа 29"
178
+ iola ai context "1215067180" --json
179
+ iola ai context "улица Петрова"
180
+ ```
181
+
182
+ Поиск контекста учитывает номера учреждений, ИНН и улицы.
183
+
184
+ AI-ответ строится по контексту из публичного API. Ассистент получает краткий
185
+ список источников контекста и должен указывать слой, название и ИНН, если
186
+ отвечает по конкретным организациям.
187
+
153
188
  Ключи OpenAI/OpenRouter сохраняются локально на компьютере пользователя:
154
189
 
155
190
  ```text
@@ -169,14 +204,14 @@ iola ai key delete openai
169
204
  `OPENROUTER_API_KEY`) и сохранен локальный ключ, CLI использует переменную
170
205
  окружения как более приоритетную.
171
206
 
172
- AI-ответ строится по контексту из публичного API. Если данных в контексте
173
- недостаточно, ассистент должен сообщить об этом, а не выдумывать сведения.
207
+ Если данных в контексте недостаточно, ассистент должен сообщить об этом, а не
208
+ выдумывать сведения.
174
209
 
175
210
  ## Назначение
176
211
 
177
- Первый релиз CLI дает прямой терминальный доступ к открытым данным и командам
178
- подключения MCP/skill. Дальше планируется добавить режим AI-запросов через
179
- ключ пользователя для OpenAI/OpenRouter и интерактивный агентный режим.
212
+ CLI дает прямой терминальный доступ к открытым данным городского округа,
213
+ командам подключения MCP/skill, AI-запросам через Ollama/OpenAI/OpenRouter,
214
+ интерактивному агентному режиму, экспорту данных и проверке обновлений.
180
215
 
181
216
  ## Переменные окружения
182
217
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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
@@ -17,6 +17,16 @@ const DEFAULT_AI_CONFIG = {
17
17
  baseUrl: "http://127.0.0.1:11434",
18
18
  },
19
19
  };
20
+ const DATASETS = {
21
+ schools: {
22
+ title: "Школы",
23
+ endpoint: "schools",
24
+ },
25
+ kindergartens: {
26
+ title: "Детские сады",
27
+ endpoint: "kindergartens",
28
+ },
29
+ };
20
30
  const BANNER = `\x1b[38;5;45m┌────────────────────────────────────────────────────────────────────────────┐
21
31
  │\x1b[38;5;51m ____ _ ___ ____ ____ ___ _____ _ _______ \x1b[38;5;45m│
22
32
  │\x1b[38;5;51m / ___| | |_ _| | _ \\| _ \\ / _ \\| ____| |/ /_ _| \x1b[38;5;45m│
@@ -34,11 +44,15 @@ const BANNER = `\x1b[38;5;45m┌────────────────
34
44
  const COMMANDS = new Map([
35
45
  ["help", showHelp],
36
46
  ["version", showVersion],
47
+ ["update", checkUpdate],
37
48
  ["banner", showBanner],
38
49
  ["agent", startAgent],
50
+ ["chat", startAgent],
39
51
  ["ai", handleAi],
52
+ ["init", initCli],
40
53
  ["health", checkHealth],
41
54
  ["layers", listLayers],
55
+ ["data", handleData],
42
56
  ["schools", listSchools],
43
57
  ["kindergartens", listKindergartens],
44
58
  ["search", searchAll],
@@ -64,7 +78,12 @@ async function showHelp() {
64
78
  Usage:
65
79
  iola banner
66
80
  iola agent
81
+ iola chat
82
+ iola init
83
+ iola update
84
+ iola data LAYER [--limit 10] [--search TEXT] [--format table|json|csv]
67
85
  iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
86
+ iola ai context TEXT [--json]
68
87
  iola ai key set openai
69
88
  iola ai key set openrouter
70
89
  iola ai key status
@@ -74,11 +93,11 @@ Usage:
74
93
  iola ai setup ollama [--yes] [--model MODEL]
75
94
  iola health [--json]
76
95
  iola layers [--json]
77
- iola schools [--limit 10] [--search TEXT] [--json]
96
+ iola schools [--limit 10] [--search TEXT] [--format table|json|csv]
78
97
  iola schools get --inn INN [--json]
79
- iola kindergartens [--limit 10] [--search TEXT] [--json]
98
+ iola kindergartens [--limit 10] [--search TEXT] [--format table|json|csv]
80
99
  iola kindergartens get --inn INN [--json]
81
- iola search TEXT [--limit 5] [--json]
100
+ iola search TEXT [--limit 5] [--format table|json|csv]
82
101
  iola mcp-info [--json]
83
102
  iola setup codex
84
103
  iola version
@@ -163,6 +182,21 @@ async function handleAgentLine(line, state) {
163
182
  return false;
164
183
  }
165
184
 
185
+ if (command === "context") {
186
+ await aiContext(args);
187
+ return false;
188
+ }
189
+
190
+ if (command === "use") {
191
+ await useAiProvider(args);
192
+ return false;
193
+ }
194
+
195
+ if (command === "key") {
196
+ await handleAiKey(args);
197
+ return false;
198
+ }
199
+
166
200
  if (command === "provider") {
167
201
  await printAiConfigField("provider");
168
202
  return false;
@@ -178,6 +212,16 @@ async function handleAgentLine(line, state) {
178
212
  return false;
179
213
  }
180
214
 
215
+ if (command === "update") {
216
+ await checkUpdate(args);
217
+ return false;
218
+ }
219
+
220
+ if (command === "init") {
221
+ await initCli(args);
222
+ return false;
223
+ }
224
+
181
225
  if (command === "ai") {
182
226
  await handleAi(args);
183
227
  return false;
@@ -186,6 +230,7 @@ async function handleAgentLine(line, state) {
186
230
  const mapped = {
187
231
  health: ["health", args],
188
232
  layers: ["layers", args],
233
+ data: ["data", args],
189
234
  schools: ["schools", args],
190
235
  kindergartens: ["kindergartens", args],
191
236
  search: ["search", args],
@@ -209,20 +254,28 @@ function printAgentHelp() {
209
254
  /help
210
255
  /health
211
256
  /layers
257
+ /data schools --limit 10
212
258
  /schools --limit 10
213
259
  /schools get --inn 1215067180
214
260
  /kindergartens --search 29
215
261
  /kindergartens get --inn 1215077421
216
262
  /search лицей --limit 3
217
263
  /mcp-info
264
+ /context школа 29
218
265
  /ai doctor
219
266
  /ai setup ollama
267
+ /use openai
268
+ /use ollama
269
+ /key status
270
+ /key set openai
220
271
  /config
221
272
  /provider
222
273
  /model
223
274
  /history
224
275
  /clear
225
276
  /banner
277
+ /update
278
+ /init
226
279
  /exit
227
280
 
228
281
  Обычный текст без slash-команды отправляется в настроенный AI-провайдер.`);
@@ -261,9 +314,43 @@ function showBanner() {
261
314
  console.log("открытые данные • MCP • локальный AI");
262
315
  }
263
316
 
264
- async function showVersion() {
317
+ async function showVersion(args = []) {
318
+ const options = parseOptions(args);
265
319
  const packageJson = await import("../package.json", { with: { type: "json" } });
266
320
  console.log(packageJson.default.version);
321
+
322
+ if (options.check) {
323
+ await checkUpdate([]);
324
+ }
325
+ }
326
+
327
+ async function checkUpdate() {
328
+ const packageJson = await import("../package.json", { with: { type: "json" } });
329
+ const current = packageJson.default.version;
330
+ const latest = await getLatestNpmVersion(packageJson.default.name);
331
+
332
+ if (!latest) {
333
+ console.log("Не удалось проверить npm-версию.");
334
+ return;
335
+ }
336
+
337
+ const comparison = compareVersions(latest, current);
338
+
339
+ if (comparison > 0) {
340
+ console.log(`Доступна новая версия: ${latest}`);
341
+ console.log("Обновление:");
342
+ console.log(` npm install -g ${packageJson.default.name}@latest`);
343
+ console.log("Или запуск без установки:");
344
+ console.log(` npx -y ${packageJson.default.name}@latest help`);
345
+ return;
346
+ }
347
+
348
+ if (comparison < 0) {
349
+ console.log(`Локальная версия ${current} новее опубликованной npm latest ${latest}.`);
350
+ return;
351
+ }
352
+
353
+ console.log(`Установлена актуальная версия: ${current}`);
267
354
  }
268
355
 
269
356
  async function checkHealth(args) {
@@ -283,6 +370,41 @@ async function checkHealth(args) {
283
370
  });
284
371
  }
285
372
 
373
+ async function initCli(args = []) {
374
+ const options = parseOptions(args);
375
+
376
+ showBanner();
377
+ console.log("Проверка окружения");
378
+ printKeyValue({
379
+ node: process.version,
380
+ npm: await getCommandVersion("npm", ["--version"]),
381
+ api: await probeEndpoint(`${MCP_BASE_URL}/mcp-health`),
382
+ mcp: MCP_BASE_URL,
383
+ });
384
+ console.log("");
385
+
386
+ await aiDoctor(options.json ? ["--json"] : []);
387
+
388
+ if (!process.stdin.isTTY || options.yes) {
389
+ console.log("");
390
+ console.log("Для настройки AI используйте:");
391
+ console.log(" iola ai setup ollama");
392
+ console.log(" iola ai key set openai");
393
+ console.log(" iola ai setup openai --model gpt-4.1-mini");
394
+ return;
395
+ }
396
+
397
+ console.log("");
398
+ const configureAi = await confirm("Настроить AI-провайдер сейчас? [Y/n] ");
399
+
400
+ if (configureAi) {
401
+ await aiSetup([]);
402
+ }
403
+
404
+ console.log("");
405
+ await checkUpdate();
406
+ }
407
+
286
408
  async function handleAi(args) {
287
409
  const [subcommand = "help", ...rest] = args;
288
410
 
@@ -290,6 +412,7 @@ async function handleAi(args) {
290
412
  showBanner();
291
413
  console.log(`AI-команды:
292
414
  iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
415
+ iola ai context TEXT [--json]
293
416
  iola ai key set openai
294
417
  iola ai key set openrouter
295
418
  iola ai key status
@@ -309,6 +432,11 @@ async function handleAi(args) {
309
432
  return;
310
433
  }
311
434
 
435
+ if (subcommand === "context") {
436
+ await aiContext(rest);
437
+ return;
438
+ }
439
+
312
440
  if (subcommand === "key") {
313
441
  await handleAiKey(rest);
314
442
  return;
@@ -366,7 +494,8 @@ async function aiSetup(args) {
366
494
  },
367
495
  });
368
496
  console.log(`AI-профиль ${provider} сохранен в ${CONFIG_FILE}`);
369
- console.log(`Ключ задайте через переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
497
+ console.log(`Ключ сохраните командой: iola ai key set ${provider}`);
498
+ console.log(`Также можно использовать переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
370
499
  return;
371
500
  }
372
501
 
@@ -403,6 +532,53 @@ async function handleAiKey(args) {
403
532
  iola ai key delete openai|openrouter`);
404
533
  }
405
534
 
535
+ async function useAiProvider(args) {
536
+ const [provider] = args;
537
+
538
+ if (provider !== "ollama" && provider !== "openai" && provider !== "openrouter") {
539
+ throw new Error("Провайдер должен быть ollama, openai или openrouter.");
540
+ }
541
+
542
+ const config = await loadConfig();
543
+ const defaultModel = {
544
+ ollama: config.ai.provider === "ollama" ? config.ai.model : "llama3.2:1b",
545
+ openai: config.ai.provider === "openai" ? config.ai.model : "gpt-4.1-mini",
546
+ openrouter: config.ai.provider === "openrouter" ? config.ai.model : "openai/gpt-4.1-mini",
547
+ }[provider];
548
+
549
+ await saveConfig({
550
+ ai: {
551
+ provider,
552
+ 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",
558
+ },
559
+ });
560
+
561
+ console.log(`AI-провайдер переключен: ${provider}, модель: ${defaultModel}`);
562
+ }
563
+
564
+ async function aiContext(args) {
565
+ const options = parseOptions(args);
566
+ const query = options._.join(" ").trim();
567
+
568
+ if (!query) {
569
+ throw new Error('Текст запроса обязателен. Пример: iola ai context "школа 29"');
570
+ }
571
+
572
+ const context = await buildDataContext(query);
573
+
574
+ if (options.json) {
575
+ printJson(context);
576
+ return;
577
+ }
578
+
579
+ printContext(context);
580
+ }
581
+
406
582
  async function setAiKey(provider) {
407
583
  assertKeyProvider(provider);
408
584
 
@@ -550,11 +726,23 @@ async function buildDataContext(question) {
550
726
  fetchJson(`${API_BASE_URL}/kindergartens?limit=100&offset=0`),
551
727
  ]);
552
728
  const queryTerms = extractSearchTerms(question);
553
- const schoolItems = findRelevantItems(normalizeItems(schools), queryTerms).slice(0, 8);
554
- const kindergartenItems = findRelevantItems(normalizeItems(kindergartens), queryTerms).slice(0, 8);
729
+ const patterns = extractStructuredPatterns(question);
730
+ const includeSchools = patterns.targetLayers.length === 0 || patterns.targetLayers.includes("schools");
731
+ const includeKindergartens = patterns.targetLayers.length === 0 || patterns.targetLayers.includes("kindergartens");
732
+ const schoolItems = includeSchools
733
+ ? findRelevantItems(normalizeItems(schools), queryTerms, patterns, "schools").slice(0, 8)
734
+ : [];
735
+ const kindergartenItems = includeKindergartens
736
+ ? findRelevantItems(normalizeItems(kindergartens), queryTerms, patterns, "kindergartens").slice(0, 8)
737
+ : [];
555
738
 
556
739
  return {
557
740
  layers: layers.data_layers || [],
741
+ query: {
742
+ text: question,
743
+ terms: queryTerms,
744
+ patterns,
745
+ },
558
746
  schools: schoolItems.map(selectPublicSummary),
559
747
  kindergartens: kindergartenItems.map(selectPublicSummary),
560
748
  };
@@ -572,29 +760,94 @@ function extractSearchTerms(question) {
572
760
  return normalized.length > 0 ? normalized : [question];
573
761
  }
574
762
 
575
- function findRelevantItems(items, terms) {
763
+ function extractStructuredPatterns(question) {
764
+ const normalized = question.toLocaleLowerCase("ru-RU");
765
+ const numbers = [...new Set([...normalized.matchAll(/\b\d{1,3}\b/g)].map((match) => match[0]))];
766
+ const inns = [...new Set([...normalized.matchAll(/\b\d{10,12}\b/g)].map((match) => match[0]))];
767
+ const targetLayers = [];
768
+ if (/(^|[^а-яёa-z])(школа|школы|лицей|лицея|гимназия|гимназии)(?=$|[^а-яёa-z])/iu.test(normalized)) {
769
+ targetLayers.push("schools");
770
+ }
771
+ if (/(^|[^а-яёa-z])(сад|сады|детсад|детский|детские|доу|мбдоу)(?=$|[^а-яёa-z])/iu.test(normalized)) {
772
+ targetLayers.push("kindergartens");
773
+ }
774
+ const streetMatches = [
775
+ ...normalized.matchAll(/(?:улица|ул\.?)\s+([а-яёa-z0-9 .-]+)/giu),
776
+ ...normalized.matchAll(/([а-яёa-z0-9 .-]+)\s+(?:улица|ул\.?)/giu),
777
+ ];
778
+ const streets = [...new Set(streetMatches.map((match) => cleanupPattern(match[1])).filter(Boolean))];
779
+
780
+ return { numbers, inns, streets, targetLayers: [...new Set(targetLayers)] };
781
+ }
782
+
783
+ function cleanupPattern(value) {
784
+ return value
785
+ .replace(/\b(школа|школы|сад|детский|детские|сады|лицей|гимназия|контакты|телефон|адрес|найди|покажи)\b/giu, " ")
786
+ .replace(/\s+/g, " ")
787
+ .trim();
788
+ }
789
+
790
+ function findRelevantItems(items, terms, patterns, layer) {
576
791
  return items
577
792
  .map((item) => ({
578
793
  item,
579
- score: scoreItem(item, terms),
794
+ score: scoreItem(item, terms, patterns, layer),
580
795
  }))
581
796
  .filter((entry) => entry.score > 0)
582
797
  .sort((left, right) => right.score - left.score)
583
798
  .map((entry) => entry.item);
584
799
  }
585
800
 
586
- function scoreItem(item, terms) {
587
- const text = JSON.stringify(selectPublicSummary(item)).toLocaleLowerCase("ru-RU");
588
- return terms.reduce((score, term) => score + (text.includes(term.toLocaleLowerCase("ru-RU")) ? 1 : 0), 0);
801
+ function scoreItem(item, terms, patterns, layer) {
802
+ const summary = selectPublicSummary(item);
803
+ const text = JSON.stringify(summary).toLocaleLowerCase("ru-RU");
804
+ const name = String(summary.name || "").toLocaleLowerCase("ru-RU");
805
+ const address = String(summary.address || "").toLocaleLowerCase("ru-RU");
806
+ const generalTerms = terms.filter((term) => !/^\d+$/.test(term));
807
+ let score = generalTerms.reduce((value, term) => value + (text.includes(term.toLocaleLowerCase("ru-RU")) ? 1 : 0), 0);
808
+
809
+ for (const inn of patterns.inns) {
810
+ if (String(summary.inn) === inn) {
811
+ score += 20;
812
+ }
813
+ }
814
+
815
+ for (const number of patterns.numbers) {
816
+ const numberPatterns = [
817
+ `№ ${number}`,
818
+ `№${number}`,
819
+ `школа ${number}`,
820
+ `сад ${number}`,
821
+ `лицей ${number}`,
822
+ `гимназия ${number}`,
823
+ ];
824
+
825
+ if (numberPatterns.some((pattern) => name.includes(pattern))) {
826
+ score += 12;
827
+ if (patterns.targetLayers.length === 0 || patterns.targetLayers.includes(layer)) {
828
+ score += 5;
829
+ }
830
+ }
831
+ }
832
+
833
+ for (const street of patterns.streets) {
834
+ if (street && address.includes(street)) {
835
+ score += 8;
836
+ }
837
+ }
838
+
839
+ return score;
589
840
  }
590
841
 
591
842
  function buildAiMessages(question, dataContext, history) {
843
+ const sourceLines = buildSourceLines(dataContext);
592
844
  const system = [
593
845
  "Ты терминальный AI-ассистент CLI-проекта Йошкар-Олы.",
594
846
  "Отвечай на русском языке.",
595
847
  "Используй только данные из переданного контекста.",
596
848
  "Если в контексте нет нужных сведений, прямо напиши, что данных недостаточно.",
597
849
  "Не выдумывай адреса, телефоны, лицензии и руководителей.",
850
+ "Если отвечаешь по конкретным организациям, укажи источник в конце: слой, название и ИНН.",
598
851
  "Отвечай кратко и по делу.",
599
852
  ].join(" ");
600
853
  const contextText = JSON.stringify(dataContext, null, 2);
@@ -605,11 +858,26 @@ function buildAiMessages(question, dataContext, history) {
605
858
  ...recentHistory,
606
859
  {
607
860
  role: "user",
608
- content: `Контекст открытых данных городского округа "Город Йошкар-Ола":\n${contextText}\n\nВопрос пользователя: ${question}`,
861
+ content: `Контекст открытых данных городского округа "Город Йошкар-Ола":\n${contextText}\n\nКраткие источники контекста:\n${sourceLines}\n\nВопрос пользователя: ${question}`,
609
862
  },
610
863
  ];
611
864
  }
612
865
 
866
+ function buildSourceLines(dataContext) {
867
+ const rows = [
868
+ ...dataContext.schools.map((item) => ({ layer: "schools", ...item })),
869
+ ...dataContext.kindergartens.map((item) => ({ layer: "kindergartens", ...item })),
870
+ ];
871
+
872
+ if (rows.length === 0) {
873
+ return "Совпавших организаций нет.";
874
+ }
875
+
876
+ return rows
877
+ .map((item) => `- ${item.layer}: ${item.name || "-"}; ИНН ${item.inn || "-"}; адрес ${item.address || "-"}`)
878
+ .join("\n");
879
+ }
880
+
613
881
  async function callAiProvider(config, messages) {
614
882
  if (config.provider === "ollama") {
615
883
  return callOllama(config, messages);
@@ -737,6 +1005,28 @@ async function listKindergartens(args) {
737
1005
  await listDataset("kindergartens", args);
738
1006
  }
739
1007
 
1008
+ async function handleData(args) {
1009
+ const [dataset, ...rest] = args;
1010
+
1011
+ if (!dataset) {
1012
+ console.log("Доступные слои:");
1013
+ printTable(Object.entries(DATASETS).map(([id, value]) => ({ id, name: value.title })), [
1014
+ ["id", "ID"],
1015
+ ["name", "Название"],
1016
+ ]);
1017
+ console.log("");
1018
+ console.log("Пример:");
1019
+ console.log(" iola data schools --limit 10");
1020
+ return;
1021
+ }
1022
+
1023
+ if (!DATASETS[dataset]) {
1024
+ throw new Error(`Неизвестный слой: ${dataset}. Доступно: ${Object.keys(DATASETS).join(", ")}`);
1025
+ }
1026
+
1027
+ await listDataset(dataset, rest);
1028
+ }
1029
+
740
1030
  async function listDataset(dataset, args) {
741
1031
  const options = parseOptions(args);
742
1032
 
@@ -749,16 +1039,21 @@ async function listDataset(dataset, args) {
749
1039
  params.set("limit", options.limit || "20");
750
1040
  params.set("offset", options.offset || "0");
751
1041
 
752
- const data = await fetchJson(`${API_BASE_URL}/${dataset}?${params}`);
1042
+ const data = await fetchJson(`${API_BASE_URL}/${DATASETS[dataset].endpoint}?${params}`);
753
1043
  const items = normalizeItems(data);
754
1044
  const filtered = options.search ? filterItems(items, options.search) : items;
755
1045
  const limited = filtered.slice(0, Number(options.limit || 20));
756
1046
 
757
- if (options.json) {
1047
+ if (options.json || options.format === "json") {
758
1048
  printJson(limited);
759
1049
  return;
760
1050
  }
761
1051
 
1052
+ if (options.format === "csv") {
1053
+ printCsv(limited.map(selectPublicSummary));
1054
+ return;
1055
+ }
1056
+
762
1057
  printDatasetTable(limited);
763
1058
  }
764
1059
 
@@ -767,7 +1062,7 @@ async function getDatasetItem(dataset, options) {
767
1062
  throw new Error(`INN is required. Example: iola ${dataset} get --inn 1215067180`);
768
1063
  }
769
1064
 
770
- const data = await fetchJson(`${API_BASE_URL}/${dataset}?limit=500&offset=0`);
1065
+ const data = await fetchJson(`${API_BASE_URL}/${DATASETS[dataset].endpoint}?limit=500&offset=0`);
771
1066
  const item = normalizeItems(data).find((entry) => String(entry.inn) === String(options.inn));
772
1067
 
773
1068
  if (!item) {
@@ -801,11 +1096,19 @@ async function searchAll(args) {
801
1096
  kindergartens: filterItems(normalizeItems(kindergartens), query).slice(0, limit),
802
1097
  };
803
1098
 
804
- if (options.json) {
1099
+ if (options.json || options.format === "json") {
805
1100
  printJson(result);
806
1101
  return;
807
1102
  }
808
1103
 
1104
+ if (options.format === "csv") {
1105
+ printCsv([
1106
+ ...result.schools.map((item) => ({ layer: "schools", ...selectPublicSummary(item) })),
1107
+ ...result.kindergartens.map((item) => ({ layer: "kindergartens", ...selectPublicSummary(item) })),
1108
+ ]);
1109
+ return;
1110
+ }
1111
+
809
1112
  console.log("Школы");
810
1113
  printDatasetTable(result.schools);
811
1114
  console.log("");
@@ -832,7 +1135,9 @@ function parseOptions(args) {
832
1135
  const arg = args[index];
833
1136
  if (arg === "--json" || arg === "--yes") {
834
1137
  result[arg.slice(2)] = true;
835
- } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn" || arg === "--model" || arg === "--provider") {
1138
+ } else if (arg === "--check") {
1139
+ result.check = true;
1140
+ } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--format") {
836
1141
  result[arg.slice(2)] = args[index + 1];
837
1142
  index += 1;
838
1143
  } else {
@@ -985,6 +1290,66 @@ async function getOllamaVersion() {
985
1290
  }
986
1291
  }
987
1292
 
1293
+ async function getCommandVersion(command, args) {
1294
+ try {
1295
+ const { stdout } = await runCommand(command, args);
1296
+ return stdout.trim() || "installed";
1297
+ } catch {
1298
+ if (process.platform === "win32" && !command.endsWith(".cmd")) {
1299
+ try {
1300
+ const { stdout } = await runCommand(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `${command} ${args.join(" ")}`]);
1301
+ return stdout.trim() || "installed";
1302
+ } catch {
1303
+ return "не найден";
1304
+ }
1305
+ }
1306
+
1307
+ return "не найден";
1308
+ }
1309
+ }
1310
+
1311
+ async function probeEndpoint(url) {
1312
+ try {
1313
+ const response = await fetch(url, { headers: { accept: "application/json" } });
1314
+ return response.ok ? "доступен" : `${response.status} ${response.statusText}`;
1315
+ } catch {
1316
+ return "недоступен";
1317
+ }
1318
+ }
1319
+
1320
+ async function getLatestNpmVersion(packageName) {
1321
+ try {
1322
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, {
1323
+ headers: { accept: "application/json" },
1324
+ });
1325
+
1326
+ if (!response.ok) {
1327
+ return null;
1328
+ }
1329
+
1330
+ const payload = await response.json();
1331
+ return payload.version || null;
1332
+ } catch {
1333
+ return null;
1334
+ }
1335
+ }
1336
+
1337
+ function compareVersions(left, right) {
1338
+ const leftParts = String(left).split(".").map(Number);
1339
+ const rightParts = String(right).split(".").map(Number);
1340
+ const length = Math.max(leftParts.length, rightParts.length);
1341
+
1342
+ for (let index = 0; index < length; index += 1) {
1343
+ const diff = (leftParts[index] || 0) - (rightParts[index] || 0);
1344
+
1345
+ if (diff !== 0) {
1346
+ return diff > 0 ? 1 : -1;
1347
+ }
1348
+ }
1349
+
1350
+ return 0;
1351
+ }
1352
+
988
1353
  function recommendOllamaModel(diagnostics) {
989
1354
  const ramGb = diagnostics.ramGb || 0;
990
1355
  const vramGb = diagnostics.gpu.vramGb || 0;
@@ -1055,8 +1420,10 @@ async function confirm(question) {
1055
1420
  }
1056
1421
 
1057
1422
  async function saveConfig(value) {
1423
+ const current = await loadConfig();
1424
+ const merged = mergeConfig(current, value);
1058
1425
  await mkdir(CONFIG_DIR, { recursive: true });
1059
- await writeFile(CONFIG_FILE, `${JSON.stringify(value, null, 2)}\n`, "utf8");
1426
+ await writeFile(CONFIG_FILE, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
1060
1427
  }
1061
1428
 
1062
1429
  async function loadConfig() {
@@ -1100,6 +1467,45 @@ async function printAiConfig() {
1100
1467
  });
1101
1468
  }
1102
1469
 
1470
+ function printContext(context) {
1471
+ const layerNames = context.layers.map((layer) => layer.name || layer.id || String(layer));
1472
+ console.log(`Запрос: ${context.query.text}`);
1473
+ console.log(`Слова поиска: ${context.query.terms.length > 0 ? context.query.terms.join(", ") : "-"}`);
1474
+ console.log(`Номера: ${context.query.patterns.numbers.length > 0 ? context.query.patterns.numbers.join(", ") : "-"}`);
1475
+ console.log(`ИНН: ${context.query.patterns.inns.length > 0 ? context.query.patterns.inns.join(", ") : "-"}`);
1476
+ console.log(`Улицы: ${context.query.patterns.streets.length > 0 ? context.query.patterns.streets.join(", ") : "-"}`);
1477
+ console.log(`Целевые слои: ${context.query.patterns.targetLayers.length > 0 ? context.query.patterns.targetLayers.join(", ") : "все"}`);
1478
+ console.log("");
1479
+ console.log(`Слои данных: ${layerNames.length > 0 ? layerNames.join(", ") : "-"}`);
1480
+ console.log("");
1481
+
1482
+ if (context.schools.length > 0) {
1483
+ console.log("Школы в контексте:");
1484
+ printTable(context.schools, [
1485
+ ["name", "Название"],
1486
+ ["address", "Адрес"],
1487
+ ["phone", "Телефон"],
1488
+ ["inn", "ИНН"],
1489
+ ]);
1490
+ } else {
1491
+ console.log("Школы в контексте: нет совпадений");
1492
+ }
1493
+
1494
+ console.log("");
1495
+
1496
+ if (context.kindergartens.length > 0) {
1497
+ console.log("Детские сады в контексте:");
1498
+ printTable(context.kindergartens, [
1499
+ ["name", "Название"],
1500
+ ["address", "Адрес"],
1501
+ ["phone", "Телефон"],
1502
+ ["inn", "ИНН"],
1503
+ ]);
1504
+ } else {
1505
+ console.log("Детские сады в контексте: нет совпадений");
1506
+ }
1507
+ }
1508
+
1103
1509
  async function printAiConfigField(field) {
1104
1510
  const config = await loadConfig();
1105
1511
  console.log(config.ai[field] || "-");
@@ -1149,6 +1555,24 @@ function printJson(value) {
1149
1555
  console.log(JSON.stringify(value, null, 2));
1150
1556
  }
1151
1557
 
1558
+ function printCsv(rows) {
1559
+ if (rows.length === 0) {
1560
+ return;
1561
+ }
1562
+
1563
+ const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
1564
+ console.log(columns.map(csvCell).join(","));
1565
+
1566
+ for (const row of rows) {
1567
+ console.log(columns.map((column) => csvCell(row[column])).join(","));
1568
+ }
1569
+ }
1570
+
1571
+ function csvCell(value) {
1572
+ const text = value == null ? "" : String(value);
1573
+ return `"${text.replace(/"/g, "\"\"")}"`;
1574
+ }
1575
+
1152
1576
  function printDatasetTable(items) {
1153
1577
  printTable(items.map(selectPublicSummary), [
1154
1578
  ["inn", "ИНН"],