@iola_adm/iola-cli 0.1.5 → 0.1.7

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 +59 -0
  2. package/package.json +1 -1
  3. package/src/cli.js +396 -8
package/README.md CHANGED
@@ -78,6 +78,11 @@ iola banner
78
78
  iola agent
79
79
  iola ai doctor
80
80
  iola ai setup ollama
81
+ iola ai ask "Какие школы есть на улице Петрова?"
82
+ iola ai key set openai
83
+ iola ai key status
84
+ iola ai setup openai --model gpt-4.1-mini
85
+ iola ai setup openrouter --model openai/gpt-4.1-mini
81
86
  iola health
82
87
  iola layers
83
88
  iola schools --limit 10
@@ -110,9 +115,63 @@ iola agent
110
115
  /search лицей --limit 3
111
116
  /mcp-info
112
117
  /ai doctor
118
+ /model
119
+ /provider
120
+ /config
121
+ /history
122
+ /clear
113
123
  /exit
114
124
  ```
115
125
 
126
+ Обычный текст без `/` в `iola agent` отправляется в настроенный AI-провайдер.
127
+
128
+ ## AI-запросы
129
+
130
+ Локальная модель через Ollama:
131
+
132
+ ```bash
133
+ iola ai setup ollama
134
+ iola ai ask "Какие школы есть на улице Петрова?"
135
+ ```
136
+
137
+ OpenAI:
138
+
139
+ ```bash
140
+ iola ai key set openai
141
+ iola ai setup openai --model gpt-4.1-mini
142
+ iola ai ask "Найди школу 29"
143
+ ```
144
+
145
+ OpenRouter:
146
+
147
+ ```bash
148
+ iola ai key set openrouter
149
+ iola ai setup openrouter --model openai/gpt-4.1-mini
150
+ iola ai ask "Покажи контакты лицея"
151
+ ```
152
+
153
+ Ключи OpenAI/OpenRouter сохраняются локально на компьютере пользователя:
154
+
155
+ ```text
156
+ %USERPROFILE%\.iola\secrets.json
157
+ ```
158
+
159
+ Управление ключами:
160
+
161
+ ```bash
162
+ iola ai key set openai
163
+ iola ai key set openrouter
164
+ iola ai key status
165
+ iola ai key delete openai
166
+ ```
167
+
168
+ Если одновременно задана переменная окружения (`OPENAI_API_KEY` или
169
+ `OPENROUTER_API_KEY`) и сохранен локальный ключ, CLI использует переменную
170
+ окружения как более приоритетную.
171
+
172
+ AI-ответ строится по контексту из публичного API. Если данных в контексте
173
+ недостаточно, ассистент должен сообщить об этом, а не выдумывать сведения.
174
+
116
175
  ## Назначение
117
176
 
118
177
  Первый релиз CLI дает прямой терминальный доступ к открытым данным и командам
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { mkdir, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import readline from "node:readline/promises";
@@ -9,6 +9,14 @@ const API_BASE_URL = process.env.IOLA_API_BASE_URL || "https://apiiola.yasg.ru/a
9
9
  const MCP_BASE_URL = process.env.IOLA_MCP_BASE_URL || "https://apiiola.yasg.ru";
10
10
  const CONFIG_DIR = path.join(os.homedir(), ".iola");
11
11
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
12
+ const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
13
+ const DEFAULT_AI_CONFIG = {
14
+ ai: {
15
+ provider: "ollama",
16
+ model: "llama3.2:1b",
17
+ baseUrl: "http://127.0.0.1:11434",
18
+ },
19
+ };
12
20
  const BANNER = `\x1b[38;5;45m┌────────────────────────────────────────────────────────────────────────────┐
13
21
  │\x1b[38;5;51m ____ _ ___ ____ ____ ___ _____ _ _______ \x1b[38;5;45m│
14
22
  │\x1b[38;5;51m / ___| | |_ _| | _ \\| _ \\ / _ \\| ____| |/ /_ _| \x1b[38;5;45m│
@@ -56,6 +64,11 @@ async function showHelp() {
56
64
  Usage:
57
65
  iola banner
58
66
  iola agent
67
+ iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
68
+ iola ai key set openai
69
+ iola ai key set openrouter
70
+ iola ai key status
71
+ iola ai key delete openai|openrouter
59
72
  iola ai doctor [--json]
60
73
  iola ai setup
61
74
  iola ai setup ollama [--yes] [--model MODEL]
@@ -81,6 +94,9 @@ async function startAgent() {
81
94
  console.log("Интерактивный режим. Введите /help для списка команд, /exit для выхода.");
82
95
 
83
96
  const rl = readline.createInterface({ input, output, prompt: "iola> " });
97
+ const state = {
98
+ history: [],
99
+ };
84
100
  let closed = false;
85
101
  rl.on("close", () => {
86
102
  closed = true;
@@ -96,7 +112,7 @@ async function startAgent() {
96
112
  }
97
113
 
98
114
  try {
99
- const shouldExit = await handleAgentLine(line);
115
+ const shouldExit = await handleAgentLine(line, state);
100
116
  if (shouldExit) {
101
117
  break;
102
118
  }
@@ -112,9 +128,11 @@ async function startAgent() {
112
128
  }
113
129
  }
114
130
 
115
- async function handleAgentLine(line) {
131
+ async function handleAgentLine(line, state) {
116
132
  if (!line.startsWith("/")) {
117
- console.log("AI-ответы будут добавлены следующим этапом. Сейчас используйте slash-команды, например /search лицей.");
133
+ const answer = await aiAsk([line], { history: state.history });
134
+ state.history.push({ role: "user", content: line });
135
+ state.history.push({ role: "assistant", content: answer });
118
136
  return false;
119
137
  }
120
138
 
@@ -129,6 +147,32 @@ async function handleAgentLine(line) {
129
147
  return false;
130
148
  }
131
149
 
150
+ if (command === "clear") {
151
+ state.history = [];
152
+ console.log("История agent-сессии очищена.");
153
+ return false;
154
+ }
155
+
156
+ if (command === "history") {
157
+ printAgentHistory(state.history);
158
+ return false;
159
+ }
160
+
161
+ if (command === "config") {
162
+ await printAiConfig();
163
+ return false;
164
+ }
165
+
166
+ if (command === "provider") {
167
+ await printAiConfigField("provider");
168
+ return false;
169
+ }
170
+
171
+ if (command === "model") {
172
+ await printAiConfigField("model");
173
+ return false;
174
+ }
175
+
132
176
  if (command === "banner") {
133
177
  showBanner();
134
178
  return false;
@@ -173,8 +217,26 @@ function printAgentHelp() {
173
217
  /mcp-info
174
218
  /ai doctor
175
219
  /ai setup ollama
220
+ /config
221
+ /provider
222
+ /model
223
+ /history
224
+ /clear
176
225
  /banner
177
- /exit`);
226
+ /exit
227
+
228
+ Обычный текст без slash-команды отправляется в настроенный AI-провайдер.`);
229
+ }
230
+
231
+ function printAgentHistory(history) {
232
+ if (history.length === 0) {
233
+ console.log("История пуста.");
234
+ return;
235
+ }
236
+
237
+ for (const item of history.slice(-10)) {
238
+ console.log(`${item.role}: ${item.content}`);
239
+ }
178
240
  }
179
241
 
180
242
  function safePrompt(rl, closed = false) {
@@ -227,14 +289,31 @@ async function handleAi(args) {
227
289
  if (subcommand === "help") {
228
290
  showBanner();
229
291
  console.log(`AI-команды:
292
+ iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
293
+ iola ai key set openai
294
+ iola ai key set openrouter
295
+ iola ai key status
296
+ iola ai key delete openai|openrouter
230
297
  iola ai doctor [--json]
231
298
  iola ai setup
232
299
  iola ai setup ollama [--yes] [--model MODEL]
300
+ iola ai setup openai [--model MODEL]
301
+ iola ai setup openrouter [--model MODEL]
233
302
 
234
303
  Локальная настройка сохраняется в ${CONFIG_FILE}`);
235
304
  return;
236
305
  }
237
306
 
307
+ if (subcommand === "ask") {
308
+ await aiAsk(rest);
309
+ return;
310
+ }
311
+
312
+ if (subcommand === "key") {
313
+ await handleAiKey(rest);
314
+ return;
315
+ }
316
+
238
317
  if (subcommand === "doctor") {
239
318
  await aiDoctor(rest);
240
319
  return;
@@ -277,8 +356,17 @@ async function aiSetup(args) {
277
356
  }
278
357
 
279
358
  if (provider === "openai" || provider === "openrouter") {
280
- console.log(`Для ${provider} используйте переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
281
- console.log("AI-запросы через API будут добавлены отдельной командой iola ai ask.");
359
+ const options = parseOptions(args.slice(1));
360
+ const model = options.model || (provider === "openai" ? "gpt-4.1-mini" : "openai/gpt-4.1-mini");
361
+ await saveConfig({
362
+ ai: {
363
+ provider,
364
+ model,
365
+ baseUrl: provider === "openai" ? "https://api.openai.com/v1" : "https://openrouter.ai/api/v1",
366
+ },
367
+ });
368
+ console.log(`AI-профиль ${provider} сохранен в ${CONFIG_FILE}`);
369
+ console.log(`Ключ задайте через переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
282
370
  return;
283
371
  }
284
372
 
@@ -290,6 +378,86 @@ async function aiSetup(args) {
290
378
  throw new Error(`Unknown AI provider: ${provider}`);
291
379
  }
292
380
 
381
+ async function handleAiKey(args) {
382
+ const [action, provider] = args;
383
+
384
+ if (action === "set") {
385
+ await setAiKey(provider);
386
+ return;
387
+ }
388
+
389
+ if (action === "status") {
390
+ await printAiKeyStatus();
391
+ return;
392
+ }
393
+
394
+ if (action === "delete") {
395
+ await deleteAiKey(provider);
396
+ return;
397
+ }
398
+
399
+ throw new Error(`Unknown key command. Use:
400
+ iola ai key set openai
401
+ iola ai key set openrouter
402
+ iola ai key status
403
+ iola ai key delete openai|openrouter`);
404
+ }
405
+
406
+ async function setAiKey(provider) {
407
+ assertKeyProvider(provider);
408
+
409
+ if (!process.stdin.isTTY) {
410
+ throw new Error("Для сохранения ключа запустите команду в интерактивном терминале.");
411
+ }
412
+
413
+ const envName = provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY";
414
+ const rl = readline.createInterface({ input, output });
415
+
416
+ try {
417
+ const key = (await rl.question(`Введите ${envName}: `)).trim();
418
+
419
+ if (!key) {
420
+ throw new Error("Ключ пустой, сохранение отменено.");
421
+ }
422
+
423
+ const secrets = await loadSecrets();
424
+ secrets[provider] = { apiKey: key };
425
+ await saveSecrets(secrets);
426
+ console.log(`Ключ ${provider} сохранен локально: ${SECRETS_FILE}`);
427
+ } finally {
428
+ rl.close();
429
+ }
430
+ }
431
+
432
+ async function printAiKeyStatus() {
433
+ const secrets = await loadSecrets();
434
+ const rows = ["openai", "openrouter"].map((provider) => ({
435
+ provider,
436
+ env: provider === "openai" ? (process.env.OPENAI_API_KEY ? "yes" : "no") : (process.env.OPENROUTER_API_KEY ? "yes" : "no"),
437
+ local: secrets[provider]?.apiKey ? "yes" : "no",
438
+ }));
439
+
440
+ printTable(rows, [
441
+ ["provider", "Провайдер"],
442
+ ["env", "Env"],
443
+ ["local", "Локально"],
444
+ ]);
445
+ }
446
+
447
+ async function deleteAiKey(provider) {
448
+ assertKeyProvider(provider);
449
+ const secrets = await loadSecrets();
450
+ delete secrets[provider];
451
+ await saveSecrets(secrets);
452
+ console.log(`Локальный ключ ${provider} удален.`);
453
+ }
454
+
455
+ function assertKeyProvider(provider) {
456
+ if (provider !== "openai" && provider !== "openrouter") {
457
+ throw new Error("Провайдер должен быть openai или openrouter.");
458
+ }
459
+ }
460
+
293
461
  async function chooseAiProvider() {
294
462
  console.log("Выберите режим AI:");
295
463
  console.log("1. Локальная модель через Ollama");
@@ -351,6 +519,180 @@ async function setupOllama(args) {
351
519
  console.log(`Готово. Локальный AI-профиль сохранен в ${CONFIG_FILE}`);
352
520
  }
353
521
 
522
+ async function aiAsk(args, context = {}) {
523
+ const options = parseOptions(args);
524
+ const question = options._.join(" ").trim();
525
+
526
+ if (!question) {
527
+ throw new Error('Текст вопроса обязателен. Пример: iola ai ask "Какие школы есть на улице Петрова?"');
528
+ }
529
+
530
+ const config = await loadConfig();
531
+ const provider = options.provider || config.ai.provider;
532
+ const model = options.model || config.ai.model;
533
+ const providerConfig = {
534
+ ...config.ai,
535
+ provider,
536
+ model,
537
+ };
538
+ const dataContext = await buildDataContext(question);
539
+ const messages = buildAiMessages(question, dataContext, context.history || []);
540
+ const answer = await callAiProvider(providerConfig, messages);
541
+
542
+ console.log(answer);
543
+ return answer;
544
+ }
545
+
546
+ async function buildDataContext(question) {
547
+ const [layers, schools, kindergartens] = await Promise.all([
548
+ fetchJson(`${MCP_BASE_URL}/mcp-version`),
549
+ fetchJson(`${API_BASE_URL}/schools?limit=100&offset=0`),
550
+ fetchJson(`${API_BASE_URL}/kindergartens?limit=100&offset=0`),
551
+ ]);
552
+ const queryTerms = extractSearchTerms(question);
553
+ const schoolItems = findRelevantItems(normalizeItems(schools), queryTerms).slice(0, 8);
554
+ const kindergartenItems = findRelevantItems(normalizeItems(kindergartens), queryTerms).slice(0, 8);
555
+
556
+ return {
557
+ layers: layers.data_layers || [],
558
+ schools: schoolItems.map(selectPublicSummary),
559
+ kindergartens: kindergartenItems.map(selectPublicSummary),
560
+ };
561
+ }
562
+
563
+ function extractSearchTerms(question) {
564
+ const normalized = question
565
+ .toLocaleLowerCase("ru-RU")
566
+ .replace(/[^\p{L}\p{N}\s.-]/gu, " ")
567
+ .split(/\s+/)
568
+ .map((term) => term.trim())
569
+ .filter(Boolean)
570
+ .filter((term) => !["какие", "какая", "какой", "есть", "найди", "покажи", "контакты", "адрес", "телефон", "школы", "школа", "сад", "детский", "детские", "сады", "улица", "ул"].includes(term));
571
+
572
+ return normalized.length > 0 ? normalized : [question];
573
+ }
574
+
575
+ function findRelevantItems(items, terms) {
576
+ return items
577
+ .map((item) => ({
578
+ item,
579
+ score: scoreItem(item, terms),
580
+ }))
581
+ .filter((entry) => entry.score > 0)
582
+ .sort((left, right) => right.score - left.score)
583
+ .map((entry) => entry.item);
584
+ }
585
+
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);
589
+ }
590
+
591
+ function buildAiMessages(question, dataContext, history) {
592
+ const system = [
593
+ "Ты терминальный AI-ассистент CLI-проекта Йошкар-Олы.",
594
+ "Отвечай на русском языке.",
595
+ "Используй только данные из переданного контекста.",
596
+ "Если в контексте нет нужных сведений, прямо напиши, что данных недостаточно.",
597
+ "Не выдумывай адреса, телефоны, лицензии и руководителей.",
598
+ "Отвечай кратко и по делу.",
599
+ ].join(" ");
600
+ const contextText = JSON.stringify(dataContext, null, 2);
601
+ const recentHistory = history.slice(-6);
602
+
603
+ return [
604
+ { role: "system", content: system },
605
+ ...recentHistory,
606
+ {
607
+ role: "user",
608
+ content: `Контекст открытых данных городского округа "Город Йошкар-Ола":\n${contextText}\n\nВопрос пользователя: ${question}`,
609
+ },
610
+ ];
611
+ }
612
+
613
+ async function callAiProvider(config, messages) {
614
+ if (config.provider === "ollama") {
615
+ return callOllama(config, messages);
616
+ }
617
+
618
+ if (config.provider === "openai") {
619
+ return callOpenAiCompatible(config, messages, await getApiKey("openai"), "OpenAI");
620
+ }
621
+
622
+ if (config.provider === "openrouter") {
623
+ return callOpenAiCompatible(config, messages, await getApiKey("openrouter"), "OpenRouter");
624
+ }
625
+
626
+ throw new Error(`Неизвестный AI-провайдер: ${config.provider}`);
627
+ }
628
+
629
+ async function callOllama(config, messages) {
630
+ let response;
631
+
632
+ try {
633
+ response = await fetch(`${config.baseUrl || "http://127.0.0.1:11434"}/api/chat`, {
634
+ method: "POST",
635
+ headers: { "content-type": "application/json" },
636
+ body: JSON.stringify({
637
+ model: config.model || "llama3.2:1b",
638
+ messages,
639
+ stream: false,
640
+ }),
641
+ });
642
+ } catch {
643
+ throw new Error("Ollama недоступен. Запустите Ollama и проверьте: ollama --version");
644
+ }
645
+
646
+ if (!response.ok) {
647
+ throw new Error(`Ollama request failed: ${response.status} ${response.statusText}. Проверьте "ollama serve" и модель.`);
648
+ }
649
+
650
+ const payload = await response.json();
651
+ return payload.message?.content || "";
652
+ }
653
+
654
+ async function callOpenAiCompatible(config, messages, apiKey, providerName) {
655
+ if (!apiKey) {
656
+ throw new Error(`${providerName} API key не найден. Задайте ${providerName === "OpenAI" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
657
+ }
658
+
659
+ const response = await fetch(`${config.baseUrl}/chat/completions`, {
660
+ method: "POST",
661
+ headers: {
662
+ authorization: `Bearer ${apiKey}`,
663
+ "content-type": "application/json",
664
+ "http-referer": "https://github.com/adm-iola/iola-cli",
665
+ "x-title": "iola-cli",
666
+ },
667
+ body: JSON.stringify({
668
+ model: config.model,
669
+ messages,
670
+ temperature: 0.2,
671
+ }),
672
+ });
673
+
674
+ if (!response.ok) {
675
+ const text = await response.text();
676
+ throw new Error(`${providerName} request failed: ${response.status} ${response.statusText}\n${text}`);
677
+ }
678
+
679
+ const payload = await response.json();
680
+ return payload.choices?.[0]?.message?.content || "";
681
+ }
682
+
683
+ async function getApiKey(provider) {
684
+ if (provider === "openai" && process.env.OPENAI_API_KEY) {
685
+ return process.env.OPENAI_API_KEY;
686
+ }
687
+
688
+ if (provider === "openrouter" && process.env.OPENROUTER_API_KEY) {
689
+ return process.env.OPENROUTER_API_KEY;
690
+ }
691
+
692
+ const secrets = await loadSecrets();
693
+ return secrets[provider]?.apiKey || "";
694
+ }
695
+
354
696
  async function listLayers(args) {
355
697
  const options = parseOptions(args);
356
698
  const info = await fetchJson(`${MCP_BASE_URL}/mcp-version`);
@@ -490,7 +832,7 @@ function parseOptions(args) {
490
832
  const arg = args[index];
491
833
  if (arg === "--json" || arg === "--yes") {
492
834
  result[arg.slice(2)] = true;
493
- } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn") {
835
+ } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn" || arg === "--model" || arg === "--provider") {
494
836
  result[arg.slice(2)] = args[index + 1];
495
837
  index += 1;
496
838
  } else {
@@ -717,6 +1059,52 @@ async function saveConfig(value) {
717
1059
  await writeFile(CONFIG_FILE, `${JSON.stringify(value, null, 2)}\n`, "utf8");
718
1060
  }
719
1061
 
1062
+ async function loadConfig() {
1063
+ try {
1064
+ const text = await readFile(CONFIG_FILE, "utf8");
1065
+ return mergeConfig(DEFAULT_AI_CONFIG, JSON.parse(text));
1066
+ } catch {
1067
+ return DEFAULT_AI_CONFIG;
1068
+ }
1069
+ }
1070
+
1071
+ function mergeConfig(base, override) {
1072
+ return {
1073
+ ...base,
1074
+ ...override,
1075
+ ai: {
1076
+ ...base.ai,
1077
+ ...(override.ai || {}),
1078
+ },
1079
+ };
1080
+ }
1081
+
1082
+ async function loadSecrets() {
1083
+ try {
1084
+ return JSON.parse(await readFile(SECRETS_FILE, "utf8"));
1085
+ } catch {
1086
+ return {};
1087
+ }
1088
+ }
1089
+
1090
+ async function saveSecrets(value) {
1091
+ await mkdir(CONFIG_DIR, { recursive: true });
1092
+ await writeFile(SECRETS_FILE, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
1093
+ }
1094
+
1095
+ async function printAiConfig() {
1096
+ const config = await loadConfig();
1097
+ printJson({
1098
+ file: CONFIG_FILE,
1099
+ ai: config.ai,
1100
+ });
1101
+ }
1102
+
1103
+ async function printAiConfigField(field) {
1104
+ const config = await loadConfig();
1105
+ console.log(config.ai[field] || "-");
1106
+ }
1107
+
720
1108
  function runCommand(command, args, options = {}) {
721
1109
  return new Promise((resolve, reject) => {
722
1110
  const child = execFile(command, args, {