@iola_adm/iola-cli 0.1.8 → 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 +24 -5
  2. package/package.json +1 -1
  3. package/src/cli.js +248 -12
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,13 @@ 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 "Какие школы есть на улице Петрова?"
@@ -87,6 +96,7 @@ iola ai setup openrouter --model openai/gpt-4.1-mini
87
96
  iola health
88
97
  iola layers
89
98
  iola schools --limit 10
99
+ iola schools --format csv
90
100
  iola schools get --inn 1215067180
91
101
  iola kindergartens --search "29"
92
102
  iola kindergartens get --inn 1215077421 --json
@@ -96,7 +106,8 @@ iola setup codex
96
106
  ```
97
107
 
98
108
  По умолчанию команды выводят компактную таблицу. Для полного ответа API
99
- используйте `--json`.
109
+ используйте `--json` или `--format json`. Для выгрузки используйте
110
+ `--format csv`.
100
111
 
101
112
  ## Интерактивный режим
102
113
 
@@ -110,6 +121,7 @@ iola agent
110
121
  /help
111
122
  /health
112
123
  /layers
124
+ /data schools --limit 10
113
125
  /schools --limit 10
114
126
  /schools get --inn 1215067180
115
127
  /kindergartens --search 29
@@ -126,10 +138,13 @@ iola agent
126
138
  /config
127
139
  /history
128
140
  /clear
141
+ /update
142
+ /init
129
143
  /exit
130
144
  ```
131
145
 
132
146
  Обычный текст без `/` в `iola agent` отправляется в настроенный AI-провайдер.
147
+ `iola chat` запускает тот же интерактивный режим.
133
148
 
134
149
  ## AI-запросы
135
150
 
@@ -166,6 +181,10 @@ iola ai context "улица Петрова"
166
181
 
167
182
  Поиск контекста учитывает номера учреждений, ИНН и улицы.
168
183
 
184
+ AI-ответ строится по контексту из публичного API. Ассистент получает краткий
185
+ список источников контекста и должен указывать слой, название и ИНН, если
186
+ отвечает по конкретным организациям.
187
+
169
188
  Ключи OpenAI/OpenRouter сохраняются локально на компьютере пользователя:
170
189
 
171
190
  ```text
@@ -185,14 +204,14 @@ iola ai key delete openai
185
204
  `OPENROUTER_API_KEY`) и сохранен локальный ключ, CLI использует переменную
186
205
  окружения как более приоритетную.
187
206
 
188
- AI-ответ строится по контексту из публичного API. Если данных в контексте
189
- недостаточно, ассистент должен сообщить об этом, а не выдумывать сведения.
207
+ Если данных в контексте недостаточно, ассистент должен сообщить об этом, а не
208
+ выдумывать сведения.
190
209
 
191
210
  ## Назначение
192
211
 
193
212
  CLI дает прямой терминальный доступ к открытым данным городского округа,
194
- командам подключения MCP/skill, AI-запросам через Ollama/OpenAI/OpenRouter и
195
- интерактивному агентному режиму.
213
+ командам подключения MCP/skill, AI-запросам через Ollama/OpenAI/OpenRouter,
214
+ интерактивному агентному режиму, экспорту данных и проверке обновлений.
196
215
 
197
216
  ## Переменные окружения
198
217
 
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.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,6 +78,10 @@ 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]
68
86
  iola ai context TEXT [--json]
69
87
  iola ai key set openai
@@ -75,11 +93,11 @@ Usage:
75
93
  iola ai setup ollama [--yes] [--model MODEL]
76
94
  iola health [--json]
77
95
  iola layers [--json]
78
- iola schools [--limit 10] [--search TEXT] [--json]
96
+ iola schools [--limit 10] [--search TEXT] [--format table|json|csv]
79
97
  iola schools get --inn INN [--json]
80
- iola kindergartens [--limit 10] [--search TEXT] [--json]
98
+ iola kindergartens [--limit 10] [--search TEXT] [--format table|json|csv]
81
99
  iola kindergartens get --inn INN [--json]
82
- iola search TEXT [--limit 5] [--json]
100
+ iola search TEXT [--limit 5] [--format table|json|csv]
83
101
  iola mcp-info [--json]
84
102
  iola setup codex
85
103
  iola version
@@ -194,6 +212,16 @@ async function handleAgentLine(line, state) {
194
212
  return false;
195
213
  }
196
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
+
197
225
  if (command === "ai") {
198
226
  await handleAi(args);
199
227
  return false;
@@ -202,6 +230,7 @@ async function handleAgentLine(line, state) {
202
230
  const mapped = {
203
231
  health: ["health", args],
204
232
  layers: ["layers", args],
233
+ data: ["data", args],
205
234
  schools: ["schools", args],
206
235
  kindergartens: ["kindergartens", args],
207
236
  search: ["search", args],
@@ -225,6 +254,7 @@ function printAgentHelp() {
225
254
  /help
226
255
  /health
227
256
  /layers
257
+ /data schools --limit 10
228
258
  /schools --limit 10
229
259
  /schools get --inn 1215067180
230
260
  /kindergartens --search 29
@@ -244,6 +274,8 @@ function printAgentHelp() {
244
274
  /history
245
275
  /clear
246
276
  /banner
277
+ /update
278
+ /init
247
279
  /exit
248
280
 
249
281
  Обычный текст без slash-команды отправляется в настроенный AI-провайдер.`);
@@ -282,9 +314,43 @@ function showBanner() {
282
314
  console.log("открытые данные • MCP • локальный AI");
283
315
  }
284
316
 
285
- async function showVersion() {
317
+ async function showVersion(args = []) {
318
+ const options = parseOptions(args);
286
319
  const packageJson = await import("../package.json", { with: { type: "json" } });
287
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}`);
288
354
  }
289
355
 
290
356
  async function checkHealth(args) {
@@ -304,6 +370,41 @@ async function checkHealth(args) {
304
370
  });
305
371
  }
306
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
+
307
408
  async function handleAi(args) {
308
409
  const [subcommand = "help", ...rest] = args;
309
410
 
@@ -393,7 +494,8 @@ async function aiSetup(args) {
393
494
  },
394
495
  });
395
496
  console.log(`AI-профиль ${provider} сохранен в ${CONFIG_FILE}`);
396
- 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"}.`);
397
499
  return;
398
500
  }
399
501
 
@@ -738,12 +840,14 @@ function scoreItem(item, terms, patterns, layer) {
738
840
  }
739
841
 
740
842
  function buildAiMessages(question, dataContext, history) {
843
+ const sourceLines = buildSourceLines(dataContext);
741
844
  const system = [
742
845
  "Ты терминальный AI-ассистент CLI-проекта Йошкар-Олы.",
743
846
  "Отвечай на русском языке.",
744
847
  "Используй только данные из переданного контекста.",
745
848
  "Если в контексте нет нужных сведений, прямо напиши, что данных недостаточно.",
746
849
  "Не выдумывай адреса, телефоны, лицензии и руководителей.",
850
+ "Если отвечаешь по конкретным организациям, укажи источник в конце: слой, название и ИНН.",
747
851
  "Отвечай кратко и по делу.",
748
852
  ].join(" ");
749
853
  const contextText = JSON.stringify(dataContext, null, 2);
@@ -754,11 +858,26 @@ function buildAiMessages(question, dataContext, history) {
754
858
  ...recentHistory,
755
859
  {
756
860
  role: "user",
757
- content: `Контекст открытых данных городского округа "Город Йошкар-Ола":\n${contextText}\n\nВопрос пользователя: ${question}`,
861
+ content: `Контекст открытых данных городского округа "Город Йошкар-Ола":\n${contextText}\n\nКраткие источники контекста:\n${sourceLines}\n\nВопрос пользователя: ${question}`,
758
862
  },
759
863
  ];
760
864
  }
761
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
+
762
881
  async function callAiProvider(config, messages) {
763
882
  if (config.provider === "ollama") {
764
883
  return callOllama(config, messages);
@@ -886,6 +1005,28 @@ async function listKindergartens(args) {
886
1005
  await listDataset("kindergartens", args);
887
1006
  }
888
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
+
889
1030
  async function listDataset(dataset, args) {
890
1031
  const options = parseOptions(args);
891
1032
 
@@ -898,16 +1039,21 @@ async function listDataset(dataset, args) {
898
1039
  params.set("limit", options.limit || "20");
899
1040
  params.set("offset", options.offset || "0");
900
1041
 
901
- const data = await fetchJson(`${API_BASE_URL}/${dataset}?${params}`);
1042
+ const data = await fetchJson(`${API_BASE_URL}/${DATASETS[dataset].endpoint}?${params}`);
902
1043
  const items = normalizeItems(data);
903
1044
  const filtered = options.search ? filterItems(items, options.search) : items;
904
1045
  const limited = filtered.slice(0, Number(options.limit || 20));
905
1046
 
906
- if (options.json) {
1047
+ if (options.json || options.format === "json") {
907
1048
  printJson(limited);
908
1049
  return;
909
1050
  }
910
1051
 
1052
+ if (options.format === "csv") {
1053
+ printCsv(limited.map(selectPublicSummary));
1054
+ return;
1055
+ }
1056
+
911
1057
  printDatasetTable(limited);
912
1058
  }
913
1059
 
@@ -916,7 +1062,7 @@ async function getDatasetItem(dataset, options) {
916
1062
  throw new Error(`INN is required. Example: iola ${dataset} get --inn 1215067180`);
917
1063
  }
918
1064
 
919
- 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`);
920
1066
  const item = normalizeItems(data).find((entry) => String(entry.inn) === String(options.inn));
921
1067
 
922
1068
  if (!item) {
@@ -950,11 +1096,19 @@ async function searchAll(args) {
950
1096
  kindergartens: filterItems(normalizeItems(kindergartens), query).slice(0, limit),
951
1097
  };
952
1098
 
953
- if (options.json) {
1099
+ if (options.json || options.format === "json") {
954
1100
  printJson(result);
955
1101
  return;
956
1102
  }
957
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
+
958
1112
  console.log("Школы");
959
1113
  printDatasetTable(result.schools);
960
1114
  console.log("");
@@ -981,7 +1135,9 @@ function parseOptions(args) {
981
1135
  const arg = args[index];
982
1136
  if (arg === "--json" || arg === "--yes") {
983
1137
  result[arg.slice(2)] = true;
984
- } 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") {
985
1141
  result[arg.slice(2)] = args[index + 1];
986
1142
  index += 1;
987
1143
  } else {
@@ -1134,6 +1290,66 @@ async function getOllamaVersion() {
1134
1290
  }
1135
1291
  }
1136
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
+
1137
1353
  function recommendOllamaModel(diagnostics) {
1138
1354
  const ramGb = diagnostics.ramGb || 0;
1139
1355
  const vramGb = diagnostics.gpu.vramGb || 0;
@@ -1204,8 +1420,10 @@ async function confirm(question) {
1204
1420
  }
1205
1421
 
1206
1422
  async function saveConfig(value) {
1423
+ const current = await loadConfig();
1424
+ const merged = mergeConfig(current, value);
1207
1425
  await mkdir(CONFIG_DIR, { recursive: true });
1208
- await writeFile(CONFIG_FILE, `${JSON.stringify(value, null, 2)}\n`, "utf8");
1426
+ await writeFile(CONFIG_FILE, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
1209
1427
  }
1210
1428
 
1211
1429
  async function loadConfig() {
@@ -1337,6 +1555,24 @@ function printJson(value) {
1337
1555
  console.log(JSON.stringify(value, null, 2));
1338
1556
  }
1339
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
+
1340
1576
  function printDatasetTable(items) {
1341
1577
  printTable(items.map(selectPublicSummary), [
1342
1578
  ["inn", "ИНН"],