@iola_adm/iola-cli 0.1.2 → 0.1.4

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 +52 -0
  2. package/package.json +1 -1
  3. package/src/cli.js +343 -2
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,6 +74,9 @@ iola help
25
74
  ## Команды
26
75
 
27
76
  ```bash
77
+ iola banner
78
+ iola ai doctor
79
+ iola ai setup ollama
28
80
  iola health
29
81
  iola layers
30
82
  iola schools --limit 10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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,33 @@
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");
12
+ const BANNER = `\x1b[38;5;45m┌────────────────────────────────────────────────────────────────────────────┐
13
+ │\x1b[38;5;51m ____ _ ___ ____ ____ ___ _____ _ _______ \x1b[38;5;45m│
14
+ │\x1b[38;5;51m / ___| | |_ _| | _ \\| _ \\ / _ \\| ____| |/ /_ _| \x1b[38;5;45m│
15
+ │\x1b[38;5;51m | | | | | |_____| |_) | |_) | | | | _| | ' / | | \x1b[38;5;45m│
16
+ │\x1b[38;5;51m | |___| |___ | |_____| __/| _ <| |_| | |___| . \\ | | \x1b[38;5;45m│
17
+ │\x1b[38;5;51m \\____|_____|___| |_| |_| \\_\\\\___/|_____|_|\\_\\ |_| \x1b[38;5;45m│
18
+ │ │
19
+ │\x1b[38;5;213m Й О Ш К А Р - О Л Ы \x1b[38;5;45m│
20
+ │ │
21
+ │\x1b[38;5;250m открытые данные • MCP • локальный AI \x1b[38;5;45m│
22
+ │ │
23
+ │\x1b[38;5;82m > iola help \x1b[38;5;45m│
24
+ └────────────────────────────────────────────────────────────────────────────┘\x1b[0m`;
3
25
 
4
26
  const COMMANDS = new Map([
5
27
  ["help", showHelp],
6
28
  ["version", showVersion],
29
+ ["banner", showBanner],
30
+ ["ai", handleAi],
7
31
  ["health", checkHealth],
8
32
  ["layers", listLayers],
9
33
  ["schools", listSchools],
@@ -25,9 +49,14 @@ export async function main(argv) {
25
49
  }
26
50
 
27
51
  async function showHelp() {
52
+ showBanner();
28
53
  console.log(`iola - CLI для открытых данных городского округа "Город Йошкар-Ола"
29
54
 
30
55
  Usage:
56
+ iola banner
57
+ iola ai doctor [--json]
58
+ iola ai setup
59
+ iola ai setup ollama [--yes] [--model MODEL]
31
60
  iola health [--json]
32
61
  iola layers [--json]
33
62
  iola schools [--limit 10] [--search TEXT] [--json]
@@ -45,6 +74,16 @@ Environment:
45
74
  `);
46
75
  }
47
76
 
77
+ function showBanner() {
78
+ if (process.stdout.isTTY && process.env.NO_COLOR !== "1") {
79
+ console.log(BANNER);
80
+ return;
81
+ }
82
+
83
+ console.log("CLI-ПРОЕКТ ЙОШКАР-ОЛЫ");
84
+ console.log("открытые данные • MCP • локальный AI");
85
+ }
86
+
48
87
  async function showVersion() {
49
88
  const packageJson = await import("../package.json", { with: { type: "json" } });
50
89
  console.log(packageJson.default.version);
@@ -67,6 +106,136 @@ async function checkHealth(args) {
67
106
  });
68
107
  }
69
108
 
109
+ async function handleAi(args) {
110
+ const [subcommand = "help", ...rest] = args;
111
+
112
+ if (subcommand === "help") {
113
+ showBanner();
114
+ console.log(`AI-команды:
115
+ iola ai doctor [--json]
116
+ iola ai setup
117
+ iola ai setup ollama [--yes] [--model MODEL]
118
+
119
+ Локальная настройка сохраняется в ${CONFIG_FILE}`);
120
+ return;
121
+ }
122
+
123
+ if (subcommand === "doctor") {
124
+ await aiDoctor(rest);
125
+ return;
126
+ }
127
+
128
+ if (subcommand === "setup") {
129
+ await aiSetup(rest);
130
+ return;
131
+ }
132
+
133
+ throw new Error(`Unknown AI command: ${subcommand}\nRun "iola ai help" to see available commands.`);
134
+ }
135
+
136
+ async function aiDoctor(args) {
137
+ const options = parseOptions(args);
138
+ const diagnostics = await getLocalDiagnostics();
139
+ const recommendation = recommendOllamaModel(diagnostics);
140
+
141
+ if (options.json) {
142
+ printJson({ ...diagnostics, recommendation });
143
+ return;
144
+ }
145
+
146
+ printDiagnostics(diagnostics, recommendation);
147
+ }
148
+
149
+ async function aiSetup(args) {
150
+ const [provider] = args;
151
+
152
+ if (!provider) {
153
+ showBanner();
154
+ const selected = await chooseAiProvider();
155
+ await aiSetup([selected]);
156
+ return;
157
+ }
158
+
159
+ if (provider === "ollama") {
160
+ await setupOllama(args.slice(1));
161
+ return;
162
+ }
163
+
164
+ if (provider === "openai" || provider === "openrouter") {
165
+ console.log(`Для ${provider} используйте переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
166
+ console.log("AI-запросы через API будут добавлены отдельной командой iola ai ask.");
167
+ return;
168
+ }
169
+
170
+ if (provider === "codex") {
171
+ await setupClient(["codex"]);
172
+ return;
173
+ }
174
+
175
+ throw new Error(`Unknown AI provider: ${provider}`);
176
+ }
177
+
178
+ async function chooseAiProvider() {
179
+ console.log("Выберите режим AI:");
180
+ console.log("1. Локальная модель через Ollama");
181
+ console.log("2. OpenAI API");
182
+ console.log("3. OpenRouter API");
183
+ console.log("4. Codex/MCP");
184
+
185
+ const rl = readline.createInterface({ input, output });
186
+ try {
187
+ const answer = (await rl.question("Введите номер [1]: ")).trim() || "1";
188
+ return {
189
+ 1: "ollama",
190
+ 2: "openai",
191
+ 3: "openrouter",
192
+ 4: "codex",
193
+ }[answer] || "ollama";
194
+ } finally {
195
+ rl.close();
196
+ }
197
+ }
198
+
199
+ async function setupOllama(args) {
200
+ const options = parseOptions(args);
201
+ const diagnostics = await getLocalDiagnostics();
202
+ const recommendation = recommendOllamaModel(diagnostics);
203
+ const model = options.model || recommendation.model;
204
+
205
+ printDiagnostics(diagnostics, { ...recommendation, model });
206
+
207
+ if (!diagnostics.ollama.installed) {
208
+ console.log("");
209
+ console.log("Ollama не найден. Установите Ollama, затем повторите команду:");
210
+ console.log(" iola ai setup ollama");
211
+ console.log("");
212
+ console.log("Windows:");
213
+ console.log(" winget install Ollama.Ollama");
214
+ console.log("macOS:");
215
+ console.log(" brew install --cask ollama");
216
+ console.log("Linux:");
217
+ console.log(" curl -fsSL https://ollama.com/install.sh | sh");
218
+ return;
219
+ }
220
+
221
+ const shouldInstall = options.yes || (await confirm(`Установить модель ${model} через "ollama pull ${model}"? [Y/n] `));
222
+
223
+ if (shouldInstall) {
224
+ await runCommand("ollama", ["pull", model], { inherit: true });
225
+ }
226
+
227
+ await saveConfig({
228
+ ai: {
229
+ provider: "ollama",
230
+ model,
231
+ baseUrl: "http://127.0.0.1:11434",
232
+ },
233
+ });
234
+
235
+ console.log("");
236
+ console.log(`Готово. Локальный AI-профиль сохранен в ${CONFIG_FILE}`);
237
+ }
238
+
70
239
  async function listLayers(args) {
71
240
  const options = parseOptions(args);
72
241
  const info = await fetchJson(`${MCP_BASE_URL}/mcp-version`);
@@ -204,8 +373,8 @@ function parseOptions(args) {
204
373
 
205
374
  for (let index = 0; index < args.length; index += 1) {
206
375
  const arg = args[index];
207
- if (arg === "--json") {
208
- result.json = true;
376
+ if (arg === "--json" || arg === "--yes") {
377
+ result[arg.slice(2)] = true;
209
378
  } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn") {
210
379
  result[arg.slice(2)] = args[index + 1];
211
380
  index += 1;
@@ -252,6 +421,178 @@ function selectPublicSummary(item) {
252
421
  };
253
422
  }
254
423
 
424
+ async function getLocalDiagnostics() {
425
+ const [nvidia, windowsGpu, ollamaVersion] = await Promise.all([
426
+ getNvidiaGpu(),
427
+ process.platform === "win32" ? getWindowsGpu() : Promise.resolve(null),
428
+ getOllamaVersion(),
429
+ ]);
430
+ const gpu = nvidia || windowsGpu || { name: "-", vramGb: null, source: "not-detected" };
431
+
432
+ return {
433
+ os: `${os.type()} ${os.release()} (${process.arch})`,
434
+ cpu: os.cpus()?.[0]?.model || "-",
435
+ ramGb: roundGb(os.totalmem()),
436
+ gpu,
437
+ ollama: {
438
+ installed: Boolean(ollamaVersion),
439
+ version: ollamaVersion || "-",
440
+ },
441
+ };
442
+ }
443
+
444
+ async function getNvidiaGpu() {
445
+ try {
446
+ const { stdout } = await runCommand("nvidia-smi", [
447
+ "--query-gpu=name,memory.total",
448
+ "--format=csv,noheader,nounits",
449
+ ]);
450
+ const [line] = stdout.trim().split(/\r?\n/).filter(Boolean);
451
+
452
+ if (!line) {
453
+ return null;
454
+ }
455
+
456
+ const [name, memoryMb] = line.split(",").map((value) => value.trim());
457
+ return {
458
+ name,
459
+ vramGb: Math.round((Number(memoryMb) / 1024) * 10) / 10,
460
+ source: "nvidia-smi",
461
+ };
462
+ } catch {
463
+ return null;
464
+ }
465
+ }
466
+
467
+ async function getWindowsGpu() {
468
+ try {
469
+ const command = [
470
+ "$gpu = Get-CimInstance Win32_VideoController |",
471
+ "Sort-Object AdapterRAM -Descending |",
472
+ "Select-Object -First 1 Name,AdapterRAM;",
473
+ "$gpu | ConvertTo-Json -Compress",
474
+ ].join(" ");
475
+ const { stdout } = await runCommand("powershell.exe", ["-NoProfile", "-Command", command]);
476
+ const parsed = JSON.parse(stdout.trim());
477
+
478
+ return {
479
+ name: parsed.Name || "-",
480
+ vramGb: parsed.AdapterRAM ? roundGb(Number(parsed.AdapterRAM)) : null,
481
+ source: "Win32_VideoController",
482
+ };
483
+ } catch {
484
+ return null;
485
+ }
486
+ }
487
+
488
+ async function getOllamaVersion() {
489
+ try {
490
+ const { stdout } = await runCommand("ollama", ["--version"]);
491
+ return stdout.trim();
492
+ } catch {
493
+ return null;
494
+ }
495
+ }
496
+
497
+ function recommendOllamaModel(diagnostics) {
498
+ const ramGb = diagnostics.ramGb || 0;
499
+ const vramGb = diagnostics.gpu.vramGb || 0;
500
+
501
+ if (ramGb >= 32 && vramGb >= 8) {
502
+ return {
503
+ profile: "good",
504
+ model: "qwen3:8b",
505
+ reason: "достаточно RAM/VRAM для более качественной локальной модели.",
506
+ };
507
+ }
508
+
509
+ if (ramGb >= 16 && vramGb >= 4) {
510
+ return {
511
+ profile: "balanced",
512
+ model: "qwen3:4b",
513
+ reason: "баланс качества, скорости и памяти для работы с вопросами по данным.",
514
+ };
515
+ }
516
+
517
+ if (ramGb >= 12) {
518
+ return {
519
+ profile: "standard",
520
+ model: "llama3.2:3b",
521
+ reason: "достаточно оперативной памяти для компактной универсальной модели.",
522
+ };
523
+ }
524
+
525
+ return {
526
+ profile: "low",
527
+ model: "llama3.2:1b",
528
+ reason: "минимальная модель для слабого ПК или CPU-only режима.",
529
+ };
530
+ }
531
+
532
+ function printDiagnostics(diagnostics, recommendation) {
533
+ console.log("Диагностика системы");
534
+ printKeyValue({
535
+ os: diagnostics.os,
536
+ cpu: diagnostics.cpu,
537
+ ram: `${diagnostics.ramGb} GB`,
538
+ gpu: diagnostics.gpu.name,
539
+ vram: diagnostics.gpu.vramGb ? `${diagnostics.gpu.vramGb} GB` : "-",
540
+ ollama: diagnostics.ollama.installed ? diagnostics.ollama.version : "не установлен",
541
+ });
542
+ console.log("");
543
+ console.log("Рекомендация");
544
+ printKeyValue({
545
+ profile: recommendation.profile,
546
+ model: recommendation.model,
547
+ reason: recommendation.reason,
548
+ install: `ollama pull ${recommendation.model}`,
549
+ });
550
+ }
551
+
552
+ async function confirm(question) {
553
+ if (!process.stdin.isTTY) {
554
+ return false;
555
+ }
556
+
557
+ const rl = readline.createInterface({ input, output });
558
+ try {
559
+ const answer = (await rl.question(question)).trim().toLocaleLowerCase("ru-RU");
560
+ return answer === "" || answer === "y" || answer === "yes" || answer === "д" || answer === "да";
561
+ } finally {
562
+ rl.close();
563
+ }
564
+ }
565
+
566
+ async function saveConfig(value) {
567
+ await mkdir(CONFIG_DIR, { recursive: true });
568
+ await writeFile(CONFIG_FILE, `${JSON.stringify(value, null, 2)}\n`, "utf8");
569
+ }
570
+
571
+ function runCommand(command, args, options = {}) {
572
+ return new Promise((resolve, reject) => {
573
+ const child = execFile(command, args, {
574
+ windowsHide: true,
575
+ maxBuffer: 1024 * 1024 * 5,
576
+ }, (error, stdout, stderr) => {
577
+ if (error) {
578
+ reject(error);
579
+ return;
580
+ }
581
+
582
+ resolve({ stdout, stderr });
583
+ });
584
+
585
+ if (options.inherit) {
586
+ child.stdout?.pipe(process.stdout);
587
+ child.stderr?.pipe(process.stderr);
588
+ }
589
+ });
590
+ }
591
+
592
+ function roundGb(bytes) {
593
+ return Math.round((bytes / 1024 / 1024 / 1024) * 10) / 10;
594
+ }
595
+
255
596
  async function fetchJson(url) {
256
597
  const response = await fetch(url, {
257
598
  headers: {