@iola_adm/iola-cli 0.1.2 → 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 +51 -0
  2. package/package.json +1 -1
  3. package/src/cli.js +315 -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,8 @@ iola help
25
74
  ## Команды
26
75
 
27
76
  ```bash
77
+ iola ai doctor
78
+ iola ai setup ollama
28
79
  iola health
29
80
  iola layers
30
81
  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.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,19 @@
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],
7
17
  ["health", checkHealth],
8
18
  ["layers", listLayers],
9
19
  ["schools", listSchools],
@@ -28,6 +38,9 @@ async function showHelp() {
28
38
  console.log(`iola - CLI для открытых данных городского округа "Город Йошкар-Ола"
29
39
 
30
40
  Usage:
41
+ iola ai doctor [--json]
42
+ iola ai setup
43
+ iola ai setup ollama [--yes] [--model MODEL]
31
44
  iola health [--json]
32
45
  iola layers [--json]
33
46
  iola schools [--limit 10] [--search TEXT] [--json]
@@ -67,6 +80,134 @@ async function checkHealth(args) {
67
80
  });
68
81
  }
69
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
+
70
211
  async function listLayers(args) {
71
212
  const options = parseOptions(args);
72
213
  const info = await fetchJson(`${MCP_BASE_URL}/mcp-version`);
@@ -204,8 +345,8 @@ function parseOptions(args) {
204
345
 
205
346
  for (let index = 0; index < args.length; index += 1) {
206
347
  const arg = args[index];
207
- if (arg === "--json") {
208
- result.json = true;
348
+ if (arg === "--json" || arg === "--yes") {
349
+ result[arg.slice(2)] = true;
209
350
  } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn") {
210
351
  result[arg.slice(2)] = args[index + 1];
211
352
  index += 1;
@@ -252,6 +393,178 @@ function selectPublicSummary(item) {
252
393
  };
253
394
  }
254
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
+
255
568
  async function fetchJson(url) {
256
569
  const response = await fetch(url, {
257
570
  headers: {