@iola_adm/iola-cli 0.1.1 → 0.1.3

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 +57 -0
  2. package/package.json +1 -1
  3. package/src/cli.js +506 -12
package/README.md CHANGED
@@ -9,6 +9,55 @@ CLI для работы с открытыми данными городског
9
9
  - `https://apiiola.yasg.ru/api/v1`
10
10
  - `https://apiiola.yasg.ru/mcp`
11
11
 
12
+ ## Необходимые компоненты
13
+
14
+ Проверьте Node.js и npm:
15
+
16
+ ```bash
17
+ node --version
18
+ npm --version
19
+ ```
20
+
21
+ Нужен Node.js `18` или новее. Если Node.js не установлен:
22
+
23
+ ```bash
24
+ # Windows
25
+ winget install OpenJS.NodeJS.LTS
26
+
27
+ # macOS
28
+ brew install node
29
+
30
+ # Linux, вариант через NodeSource
31
+ curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
32
+ sudo apt-get install -y nodejs
33
+ ```
34
+
35
+ Для локального AI-режима нужен Ollama. Проверка:
36
+
37
+ ```bash
38
+ ollama --version
39
+ ```
40
+
41
+ Установка Ollama:
42
+
43
+ ```bash
44
+ # Windows
45
+ winget install Ollama.Ollama
46
+
47
+ # macOS
48
+ brew install --cask ollama
49
+
50
+ # Linux
51
+ curl -fsSL https://ollama.com/install.sh | sh
52
+ ```
53
+
54
+ Диагностика ПК и подбор локальной модели:
55
+
56
+ ```bash
57
+ npx -y @iola_adm/iola-cli ai doctor
58
+ npx -y @iola_adm/iola-cli ai setup ollama
59
+ ```
60
+
12
61
  ## Установка и запуск
13
62
 
14
63
  ```bash
@@ -25,14 +74,22 @@ iola help
25
74
  ## Команды
26
75
 
27
76
  ```bash
77
+ iola ai doctor
78
+ iola ai setup ollama
79
+ iola health
28
80
  iola layers
29
81
  iola schools --limit 10
82
+ iola schools get --inn 1215067180
30
83
  iola kindergartens --search "29"
84
+ iola kindergartens get --inn 1215077421 --json
31
85
  iola search "лицей"
32
86
  iola mcp-info
33
87
  iola setup codex
34
88
  ```
35
89
 
90
+ По умолчанию команды выводят компактную таблицу. Для полного ответа API
91
+ используйте `--json`.
92
+
36
93
  ## Назначение
37
94
 
38
95
  Первый релиз CLI дает прямой терминальный доступ к открытым данным и командам
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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,9 +1,20 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import readline from "node:readline/promises";
6
+ import { stdin as input, stdout as output } from "node:process";
7
+
1
8
  const API_BASE_URL = process.env.IOLA_API_BASE_URL || "https://apiiola.yasg.ru/api/v1";
2
9
  const MCP_BASE_URL = process.env.IOLA_MCP_BASE_URL || "https://apiiola.yasg.ru";
10
+ const CONFIG_DIR = path.join(os.homedir(), ".iola");
11
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
3
12
 
4
13
  const COMMANDS = new Map([
5
14
  ["help", showHelp],
6
15
  ["version", showVersion],
16
+ ["ai", handleAi],
17
+ ["health", checkHealth],
7
18
  ["layers", listLayers],
8
19
  ["schools", listSchools],
9
20
  ["kindergartens", listKindergartens],
@@ -27,11 +38,17 @@ async function showHelp() {
27
38
  console.log(`iola - CLI для открытых данных городского округа "Город Йошкар-Ола"
28
39
 
29
40
  Usage:
30
- iola layers
31
- iola schools [--limit 10] [--search TEXT]
32
- iola kindergartens [--limit 10] [--search TEXT]
33
- iola search TEXT [--limit 5]
34
- iola mcp-info
41
+ iola ai doctor [--json]
42
+ iola ai setup
43
+ iola ai setup ollama [--yes] [--model MODEL]
44
+ iola health [--json]
45
+ iola layers [--json]
46
+ iola schools [--limit 10] [--search TEXT] [--json]
47
+ iola schools get --inn INN [--json]
48
+ iola kindergartens [--limit 10] [--search TEXT] [--json]
49
+ iola kindergartens get --inn INN [--json]
50
+ iola search TEXT [--limit 5] [--json]
51
+ iola mcp-info [--json]
35
52
  iola setup codex
36
53
  iola version
37
54
 
@@ -46,14 +63,185 @@ async function showVersion() {
46
63
  console.log(packageJson.default.version);
47
64
  }
48
65
 
49
- async function listLayers() {
66
+ async function checkHealth(args) {
67
+ const options = parseOptions(args);
68
+ const health = await fetchJson(`${MCP_BASE_URL}/mcp-health`);
69
+
70
+ if (options.json) {
71
+ printJson(health);
72
+ return;
73
+ }
74
+
75
+ printKeyValue({
76
+ status: health.status,
77
+ server_version: health.server_version,
78
+ skill_version: health.skill_version,
79
+ mcp_endpoint: health.mcp_endpoint,
80
+ });
81
+ }
82
+
83
+ async function handleAi(args) {
84
+ const [subcommand = "help", ...rest] = args;
85
+
86
+ if (subcommand === "help") {
87
+ console.log(`AI-команды:
88
+ iola ai doctor [--json]
89
+ iola ai setup
90
+ iola ai setup ollama [--yes] [--model MODEL]
91
+
92
+ Локальная настройка сохраняется в ${CONFIG_FILE}`);
93
+ return;
94
+ }
95
+
96
+ if (subcommand === "doctor") {
97
+ await aiDoctor(rest);
98
+ return;
99
+ }
100
+
101
+ if (subcommand === "setup") {
102
+ await aiSetup(rest);
103
+ return;
104
+ }
105
+
106
+ throw new Error(`Unknown AI command: ${subcommand}\nRun "iola ai help" to see available commands.`);
107
+ }
108
+
109
+ async function aiDoctor(args) {
110
+ const options = parseOptions(args);
111
+ const diagnostics = await getLocalDiagnostics();
112
+ const recommendation = recommendOllamaModel(diagnostics);
113
+
114
+ if (options.json) {
115
+ printJson({ ...diagnostics, recommendation });
116
+ return;
117
+ }
118
+
119
+ printDiagnostics(diagnostics, recommendation);
120
+ }
121
+
122
+ async function aiSetup(args) {
123
+ const [provider] = args;
124
+
125
+ if (!provider) {
126
+ const selected = await chooseAiProvider();
127
+ await aiSetup([selected]);
128
+ return;
129
+ }
130
+
131
+ if (provider === "ollama") {
132
+ await setupOllama(args.slice(1));
133
+ return;
134
+ }
135
+
136
+ if (provider === "openai" || provider === "openrouter") {
137
+ console.log(`Для ${provider} используйте переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
138
+ console.log("AI-запросы через API будут добавлены отдельной командой iola ai ask.");
139
+ return;
140
+ }
141
+
142
+ if (provider === "codex") {
143
+ await setupClient(["codex"]);
144
+ return;
145
+ }
146
+
147
+ throw new Error(`Unknown AI provider: ${provider}`);
148
+ }
149
+
150
+ async function chooseAiProvider() {
151
+ console.log("Выберите режим AI:");
152
+ console.log("1. Локальная модель через Ollama");
153
+ console.log("2. OpenAI API");
154
+ console.log("3. OpenRouter API");
155
+ console.log("4. Codex/MCP");
156
+
157
+ const rl = readline.createInterface({ input, output });
158
+ try {
159
+ const answer = (await rl.question("Введите номер [1]: ")).trim() || "1";
160
+ return {
161
+ 1: "ollama",
162
+ 2: "openai",
163
+ 3: "openrouter",
164
+ 4: "codex",
165
+ }[answer] || "ollama";
166
+ } finally {
167
+ rl.close();
168
+ }
169
+ }
170
+
171
+ async function setupOllama(args) {
172
+ const options = parseOptions(args);
173
+ const diagnostics = await getLocalDiagnostics();
174
+ const recommendation = recommendOllamaModel(diagnostics);
175
+ const model = options.model || recommendation.model;
176
+
177
+ printDiagnostics(diagnostics, { ...recommendation, model });
178
+
179
+ if (!diagnostics.ollama.installed) {
180
+ console.log("");
181
+ console.log("Ollama не найден. Установите Ollama, затем повторите команду:");
182
+ console.log(" iola ai setup ollama");
183
+ console.log("");
184
+ console.log("Windows:");
185
+ console.log(" winget install Ollama.Ollama");
186
+ console.log("macOS:");
187
+ console.log(" brew install --cask ollama");
188
+ console.log("Linux:");
189
+ console.log(" curl -fsSL https://ollama.com/install.sh | sh");
190
+ return;
191
+ }
192
+
193
+ const shouldInstall = options.yes || (await confirm(`Установить модель ${model} через "ollama pull ${model}"? [Y/n] `));
194
+
195
+ if (shouldInstall) {
196
+ await runCommand("ollama", ["pull", model], { inherit: true });
197
+ }
198
+
199
+ await saveConfig({
200
+ ai: {
201
+ provider: "ollama",
202
+ model,
203
+ baseUrl: "http://127.0.0.1:11434",
204
+ },
205
+ });
206
+
207
+ console.log("");
208
+ console.log(`Готово. Локальный AI-профиль сохранен в ${CONFIG_FILE}`);
209
+ }
210
+
211
+ async function listLayers(args) {
212
+ const options = parseOptions(args);
50
213
  const info = await fetchJson(`${MCP_BASE_URL}/mcp-version`);
51
- printJson(info.data_layers);
214
+
215
+ if (options.json) {
216
+ printJson(info.data_layers);
217
+ return;
218
+ }
219
+
220
+ printTable(info.data_layers, [
221
+ ["id", "ID"],
222
+ ["name", "Название"],
223
+ ["category", "Категория"],
224
+ ["status", "Статус"],
225
+ ]);
52
226
  }
53
227
 
54
- async function showMcpInfo() {
228
+ async function showMcpInfo(args) {
229
+ const options = parseOptions(args);
55
230
  const info = await fetchJson(`${MCP_BASE_URL}/mcp-version`);
56
- printJson(info);
231
+
232
+ if (options.json) {
233
+ printJson(info);
234
+ return;
235
+ }
236
+
237
+ printKeyValue({
238
+ server_name: info.server_name,
239
+ server_version: info.server_version,
240
+ skill_version: info.skill_version,
241
+ npm_package: info.npm_package,
242
+ mcp_endpoint: info.mcp_endpoint,
243
+ layers: info.data_layers.map((layer) => layer.id).join(", "),
244
+ });
57
245
  }
58
246
 
59
247
  async function listSchools(args) {
@@ -66,6 +254,12 @@ async function listKindergartens(args) {
66
254
 
67
255
  async function listDataset(dataset, args) {
68
256
  const options = parseOptions(args);
257
+
258
+ if (options._[0] === "get") {
259
+ await getDatasetItem(dataset, options);
260
+ return;
261
+ }
262
+
69
263
  const params = new URLSearchParams();
70
264
  params.set("limit", options.limit || "20");
71
265
  params.set("offset", options.offset || "0");
@@ -73,7 +267,34 @@ async function listDataset(dataset, args) {
73
267
  const data = await fetchJson(`${API_BASE_URL}/${dataset}?${params}`);
74
268
  const items = normalizeItems(data);
75
269
  const filtered = options.search ? filterItems(items, options.search) : items;
76
- printJson(filtered.slice(0, Number(options.limit || 20)));
270
+ const limited = filtered.slice(0, Number(options.limit || 20));
271
+
272
+ if (options.json) {
273
+ printJson(limited);
274
+ return;
275
+ }
276
+
277
+ printDatasetTable(limited);
278
+ }
279
+
280
+ async function getDatasetItem(dataset, options) {
281
+ if (!options.inn) {
282
+ throw new Error(`INN is required. Example: iola ${dataset} get --inn 1215067180`);
283
+ }
284
+
285
+ const data = await fetchJson(`${API_BASE_URL}/${dataset}?limit=500&offset=0`);
286
+ const item = normalizeItems(data).find((entry) => String(entry.inn) === String(options.inn));
287
+
288
+ if (!item) {
289
+ throw new Error(`Record was not found in ${dataset}: inn=${options.inn}`);
290
+ }
291
+
292
+ if (options.json) {
293
+ printJson(item);
294
+ return;
295
+ }
296
+
297
+ printKeyValue(selectPublicSummary(item));
77
298
  }
78
299
 
79
300
  async function searchAll(args) {
@@ -95,7 +316,16 @@ async function searchAll(args) {
95
316
  kindergartens: filterItems(normalizeItems(kindergartens), query).slice(0, limit),
96
317
  };
97
318
 
98
- printJson(result);
319
+ if (options.json) {
320
+ printJson(result);
321
+ return;
322
+ }
323
+
324
+ console.log("Школы");
325
+ printDatasetTable(result.schools);
326
+ console.log("");
327
+ console.log("Детские сады");
328
+ printDatasetTable(result.kindergartens);
99
329
  }
100
330
 
101
331
  async function setupClient(args) {
@@ -115,7 +345,9 @@ function parseOptions(args) {
115
345
 
116
346
  for (let index = 0; index < args.length; index += 1) {
117
347
  const arg = args[index];
118
- if (arg === "--limit" || arg === "--offset" || arg === "--search") {
348
+ if (arg === "--json" || arg === "--yes") {
349
+ result[arg.slice(2)] = true;
350
+ } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn") {
119
351
  result[arg.slice(2)] = args[index + 1];
120
352
  index += 1;
121
353
  } else {
@@ -147,6 +379,192 @@ function normalizeItems(payload) {
147
379
  return [];
148
380
  }
149
381
 
382
+ function selectPublicSummary(item) {
383
+ return {
384
+ inn: item.inn,
385
+ name: item.fns_short_name || item.fns_full_name,
386
+ address: item.address || item.legal_address,
387
+ phone: item.phone,
388
+ email: item.email,
389
+ website: item.website,
390
+ head: item.fns_head_name,
391
+ license_number: item.license_number,
392
+ license_status: item.license_status,
393
+ };
394
+ }
395
+
396
+ async function getLocalDiagnostics() {
397
+ const [nvidia, windowsGpu, ollamaVersion] = await Promise.all([
398
+ getNvidiaGpu(),
399
+ process.platform === "win32" ? getWindowsGpu() : Promise.resolve(null),
400
+ getOllamaVersion(),
401
+ ]);
402
+ const gpu = nvidia || windowsGpu || { name: "-", vramGb: null, source: "not-detected" };
403
+
404
+ return {
405
+ os: `${os.type()} ${os.release()} (${process.arch})`,
406
+ cpu: os.cpus()?.[0]?.model || "-",
407
+ ramGb: roundGb(os.totalmem()),
408
+ gpu,
409
+ ollama: {
410
+ installed: Boolean(ollamaVersion),
411
+ version: ollamaVersion || "-",
412
+ },
413
+ };
414
+ }
415
+
416
+ async function getNvidiaGpu() {
417
+ try {
418
+ const { stdout } = await runCommand("nvidia-smi", [
419
+ "--query-gpu=name,memory.total",
420
+ "--format=csv,noheader,nounits",
421
+ ]);
422
+ const [line] = stdout.trim().split(/\r?\n/).filter(Boolean);
423
+
424
+ if (!line) {
425
+ return null;
426
+ }
427
+
428
+ const [name, memoryMb] = line.split(",").map((value) => value.trim());
429
+ return {
430
+ name,
431
+ vramGb: Math.round((Number(memoryMb) / 1024) * 10) / 10,
432
+ source: "nvidia-smi",
433
+ };
434
+ } catch {
435
+ return null;
436
+ }
437
+ }
438
+
439
+ async function getWindowsGpu() {
440
+ try {
441
+ const command = [
442
+ "$gpu = Get-CimInstance Win32_VideoController |",
443
+ "Sort-Object AdapterRAM -Descending |",
444
+ "Select-Object -First 1 Name,AdapterRAM;",
445
+ "$gpu | ConvertTo-Json -Compress",
446
+ ].join(" ");
447
+ const { stdout } = await runCommand("powershell.exe", ["-NoProfile", "-Command", command]);
448
+ const parsed = JSON.parse(stdout.trim());
449
+
450
+ return {
451
+ name: parsed.Name || "-",
452
+ vramGb: parsed.AdapterRAM ? roundGb(Number(parsed.AdapterRAM)) : null,
453
+ source: "Win32_VideoController",
454
+ };
455
+ } catch {
456
+ return null;
457
+ }
458
+ }
459
+
460
+ async function getOllamaVersion() {
461
+ try {
462
+ const { stdout } = await runCommand("ollama", ["--version"]);
463
+ return stdout.trim();
464
+ } catch {
465
+ return null;
466
+ }
467
+ }
468
+
469
+ function recommendOllamaModel(diagnostics) {
470
+ const ramGb = diagnostics.ramGb || 0;
471
+ const vramGb = diagnostics.gpu.vramGb || 0;
472
+
473
+ if (ramGb >= 32 && vramGb >= 8) {
474
+ return {
475
+ profile: "good",
476
+ model: "qwen3:8b",
477
+ reason: "достаточно RAM/VRAM для более качественной локальной модели.",
478
+ };
479
+ }
480
+
481
+ if (ramGb >= 16 && vramGb >= 4) {
482
+ return {
483
+ profile: "balanced",
484
+ model: "qwen3:4b",
485
+ reason: "баланс качества, скорости и памяти для работы с вопросами по данным.",
486
+ };
487
+ }
488
+
489
+ if (ramGb >= 12) {
490
+ return {
491
+ profile: "standard",
492
+ model: "llama3.2:3b",
493
+ reason: "достаточно оперативной памяти для компактной универсальной модели.",
494
+ };
495
+ }
496
+
497
+ return {
498
+ profile: "low",
499
+ model: "llama3.2:1b",
500
+ reason: "минимальная модель для слабого ПК или CPU-only режима.",
501
+ };
502
+ }
503
+
504
+ function printDiagnostics(diagnostics, recommendation) {
505
+ console.log("Диагностика системы");
506
+ printKeyValue({
507
+ os: diagnostics.os,
508
+ cpu: diagnostics.cpu,
509
+ ram: `${diagnostics.ramGb} GB`,
510
+ gpu: diagnostics.gpu.name,
511
+ vram: diagnostics.gpu.vramGb ? `${diagnostics.gpu.vramGb} GB` : "-",
512
+ ollama: diagnostics.ollama.installed ? diagnostics.ollama.version : "не установлен",
513
+ });
514
+ console.log("");
515
+ console.log("Рекомендация");
516
+ printKeyValue({
517
+ profile: recommendation.profile,
518
+ model: recommendation.model,
519
+ reason: recommendation.reason,
520
+ install: `ollama pull ${recommendation.model}`,
521
+ });
522
+ }
523
+
524
+ async function confirm(question) {
525
+ if (!process.stdin.isTTY) {
526
+ return false;
527
+ }
528
+
529
+ const rl = readline.createInterface({ input, output });
530
+ try {
531
+ const answer = (await rl.question(question)).trim().toLocaleLowerCase("ru-RU");
532
+ return answer === "" || answer === "y" || answer === "yes" || answer === "д" || answer === "да";
533
+ } finally {
534
+ rl.close();
535
+ }
536
+ }
537
+
538
+ async function saveConfig(value) {
539
+ await mkdir(CONFIG_DIR, { recursive: true });
540
+ await writeFile(CONFIG_FILE, `${JSON.stringify(value, null, 2)}\n`, "utf8");
541
+ }
542
+
543
+ function runCommand(command, args, options = {}) {
544
+ return new Promise((resolve, reject) => {
545
+ const child = execFile(command, args, {
546
+ windowsHide: true,
547
+ maxBuffer: 1024 * 1024 * 5,
548
+ }, (error, stdout, stderr) => {
549
+ if (error) {
550
+ reject(error);
551
+ return;
552
+ }
553
+
554
+ resolve({ stdout, stderr });
555
+ });
556
+
557
+ if (options.inherit) {
558
+ child.stdout?.pipe(process.stdout);
559
+ child.stderr?.pipe(process.stderr);
560
+ }
561
+ });
562
+ }
563
+
564
+ function roundGb(bytes) {
565
+ return Math.round((bytes / 1024 / 1024 / 1024) * 10) / 10;
566
+ }
567
+
150
568
  async function fetchJson(url) {
151
569
  const response = await fetch(url, {
152
570
  headers: {
@@ -165,3 +583,79 @@ async function fetchJson(url) {
165
583
  function printJson(value) {
166
584
  console.log(JSON.stringify(value, null, 2));
167
585
  }
586
+
587
+ function printDatasetTable(items) {
588
+ printTable(items.map(selectPublicSummary), [
589
+ ["inn", "ИНН"],
590
+ ["name", "Название"],
591
+ ["address", "Адрес"],
592
+ ["phone", "Телефон"],
593
+ ]);
594
+ }
595
+
596
+ function printKeyValue(value) {
597
+ const rows = Object.entries(value).map(([key, raw]) => ({
598
+ key,
599
+ value: raw == null || raw === "" ? "-" : String(raw),
600
+ }));
601
+
602
+ printTable(rows, [
603
+ ["key", "Поле"],
604
+ ["value", "Значение"],
605
+ ]);
606
+ }
607
+
608
+ function printTable(rows, columns) {
609
+ if (rows.length === 0) {
610
+ console.log("Нет данных.");
611
+ return;
612
+ }
613
+
614
+ const normalized = rows.map((row) =>
615
+ Object.fromEntries(
616
+ columns.map(([key]) => [key, formatCell(row[key])]),
617
+ ),
618
+ );
619
+ const widths = columns.map(([key, title]) =>
620
+ Math.min(
621
+ Math.max(
622
+ visibleLength(title),
623
+ ...normalized.map((row) => visibleLength(row[key])),
624
+ ),
625
+ 52,
626
+ ),
627
+ );
628
+ const header = columns.map(([, title], index) => padCell(title, widths[index])).join(" ");
629
+ const divider = widths.map((width) => "-".repeat(width)).join(" ");
630
+
631
+ console.log(header);
632
+ console.log(divider);
633
+
634
+ for (const row of normalized) {
635
+ console.log(columns.map(([key], index) => padCell(truncateCell(row[key], widths[index]), widths[index])).join(" "));
636
+ }
637
+ }
638
+
639
+ function formatCell(value) {
640
+ if (value == null || value === "") {
641
+ return "-";
642
+ }
643
+
644
+ return String(value).replace(/\s+/g, " ").trim();
645
+ }
646
+
647
+ function truncateCell(value, width) {
648
+ if (visibleLength(value) <= width) {
649
+ return value;
650
+ }
651
+
652
+ return `${value.slice(0, Math.max(0, width - 1))}…`;
653
+ }
654
+
655
+ function padCell(value, width) {
656
+ return value + " ".repeat(Math.max(0, width - visibleLength(value)));
657
+ }
658
+
659
+ function visibleLength(value) {
660
+ return String(value).length;
661
+ }