@iola_adm/iola-cli 0.1.4 → 0.1.6

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 +60 -0
  2. package/package.json +1 -1
  3. package/src/cli.js +421 -4
package/README.md CHANGED
@@ -75,8 +75,12 @@ iola help
75
75
 
76
76
  ```bash
77
77
  iola banner
78
+ iola agent
78
79
  iola ai doctor
79
80
  iola ai setup ollama
81
+ iola ai ask "Какие школы есть на улице Петрова?"
82
+ iola ai setup openai --model gpt-4.1-mini
83
+ iola ai setup openrouter --model openai/gpt-4.1-mini
80
84
  iola health
81
85
  iola layers
82
86
  iola schools --limit 10
@@ -91,6 +95,62 @@ iola setup codex
91
95
  По умолчанию команды выводят компактную таблицу. Для полного ответа API
92
96
  используйте `--json`.
93
97
 
98
+ ## Интерактивный режим
99
+
100
+ ```bash
101
+ iola agent
102
+ ```
103
+
104
+ Внутри agent доступны slash-команды:
105
+
106
+ ```text
107
+ /help
108
+ /health
109
+ /layers
110
+ /schools --limit 10
111
+ /schools get --inn 1215067180
112
+ /kindergartens --search 29
113
+ /search лицей --limit 3
114
+ /mcp-info
115
+ /ai doctor
116
+ /model
117
+ /provider
118
+ /config
119
+ /history
120
+ /clear
121
+ /exit
122
+ ```
123
+
124
+ Обычный текст без `/` в `iola agent` отправляется в настроенный AI-провайдер.
125
+
126
+ ## AI-запросы
127
+
128
+ Локальная модель через Ollama:
129
+
130
+ ```bash
131
+ iola ai setup ollama
132
+ iola ai ask "Какие школы есть на улице Петрова?"
133
+ ```
134
+
135
+ OpenAI:
136
+
137
+ ```bash
138
+ set OPENAI_API_KEY=...
139
+ iola ai setup openai --model gpt-4.1-mini
140
+ iola ai ask "Найди школу 29"
141
+ ```
142
+
143
+ OpenRouter:
144
+
145
+ ```bash
146
+ set OPENROUTER_API_KEY=...
147
+ iola ai setup openrouter --model openai/gpt-4.1-mini
148
+ iola ai ask "Покажи контакты лицея"
149
+ ```
150
+
151
+ AI-ответ строится по контексту из публичного API. Если данных в контексте
152
+ недостаточно, ассистент должен сообщить об этом, а не выдумывать сведения.
153
+
94
154
  ## Назначение
95
155
 
96
156
  Первый релиз CLI дает прямой терминальный доступ к открытым данным и командам
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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,13 @@ 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 DEFAULT_AI_CONFIG = {
13
+ ai: {
14
+ provider: "ollama",
15
+ model: "llama3.2:1b",
16
+ baseUrl: "http://127.0.0.1:11434",
17
+ },
18
+ };
12
19
  const BANNER = `\x1b[38;5;45m┌────────────────────────────────────────────────────────────────────────────┐
13
20
  │\x1b[38;5;51m ____ _ ___ ____ ____ ___ _____ _ _______ \x1b[38;5;45m│
14
21
  │\x1b[38;5;51m / ___| | |_ _| | _ \\| _ \\ / _ \\| ____| |/ /_ _| \x1b[38;5;45m│
@@ -27,6 +34,7 @@ const COMMANDS = new Map([
27
34
  ["help", showHelp],
28
35
  ["version", showVersion],
29
36
  ["banner", showBanner],
37
+ ["agent", startAgent],
30
38
  ["ai", handleAi],
31
39
  ["health", checkHealth],
32
40
  ["layers", listLayers],
@@ -54,6 +62,8 @@ async function showHelp() {
54
62
 
55
63
  Usage:
56
64
  iola banner
65
+ iola agent
66
+ iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
57
67
  iola ai doctor [--json]
58
68
  iola ai setup
59
69
  iola ai setup ollama [--yes] [--model MODEL]
@@ -74,6 +84,168 @@ Environment:
74
84
  `);
75
85
  }
76
86
 
87
+ async function startAgent() {
88
+ showBanner();
89
+ console.log("Интерактивный режим. Введите /help для списка команд, /exit для выхода.");
90
+
91
+ const rl = readline.createInterface({ input, output, prompt: "iola> " });
92
+ const state = {
93
+ history: [],
94
+ };
95
+ let closed = false;
96
+ rl.on("close", () => {
97
+ closed = true;
98
+ });
99
+ safePrompt(rl);
100
+
101
+ for await (const rawLine of rl) {
102
+ const line = rawLine.trim();
103
+
104
+ if (!line) {
105
+ safePrompt(rl, closed);
106
+ continue;
107
+ }
108
+
109
+ try {
110
+ const shouldExit = await handleAgentLine(line, state);
111
+ if (shouldExit) {
112
+ break;
113
+ }
114
+ } catch (error) {
115
+ console.error(error instanceof Error ? error.message : String(error));
116
+ }
117
+
118
+ safePrompt(rl, closed);
119
+ }
120
+
121
+ if (!closed) {
122
+ rl.close();
123
+ }
124
+ }
125
+
126
+ async function handleAgentLine(line, state) {
127
+ if (!line.startsWith("/")) {
128
+ const answer = await aiAsk([line], { history: state.history });
129
+ state.history.push({ role: "user", content: line });
130
+ state.history.push({ role: "assistant", content: answer });
131
+ return false;
132
+ }
133
+
134
+ const [command, ...args] = splitCommandLine(line.slice(1));
135
+
136
+ if (command === "exit" || command === "quit") {
137
+ return true;
138
+ }
139
+
140
+ if (command === "help") {
141
+ printAgentHelp();
142
+ return false;
143
+ }
144
+
145
+ if (command === "clear") {
146
+ state.history = [];
147
+ console.log("История agent-сессии очищена.");
148
+ return false;
149
+ }
150
+
151
+ if (command === "history") {
152
+ printAgentHistory(state.history);
153
+ return false;
154
+ }
155
+
156
+ if (command === "config") {
157
+ await printAiConfig();
158
+ return false;
159
+ }
160
+
161
+ if (command === "provider") {
162
+ await printAiConfigField("provider");
163
+ return false;
164
+ }
165
+
166
+ if (command === "model") {
167
+ await printAiConfigField("model");
168
+ return false;
169
+ }
170
+
171
+ if (command === "banner") {
172
+ showBanner();
173
+ return false;
174
+ }
175
+
176
+ if (command === "ai") {
177
+ await handleAi(args);
178
+ return false;
179
+ }
180
+
181
+ const mapped = {
182
+ health: ["health", args],
183
+ layers: ["layers", args],
184
+ schools: ["schools", args],
185
+ kindergartens: ["kindergartens", args],
186
+ search: ["search", args],
187
+ "mcp-info": ["mcp-info", args],
188
+ setup: ["setup", args],
189
+ }[command];
190
+
191
+ if (!mapped) {
192
+ console.log(`Неизвестная slash-команда: /${command}`);
193
+ printAgentHelp();
194
+ return false;
195
+ }
196
+
197
+ const [cliCommand, cliArgs] = mapped;
198
+ await COMMANDS.get(cliCommand)(cliArgs);
199
+ return false;
200
+ }
201
+
202
+ function printAgentHelp() {
203
+ console.log(`Slash-команды:
204
+ /help
205
+ /health
206
+ /layers
207
+ /schools --limit 10
208
+ /schools get --inn 1215067180
209
+ /kindergartens --search 29
210
+ /kindergartens get --inn 1215077421
211
+ /search лицей --limit 3
212
+ /mcp-info
213
+ /ai doctor
214
+ /ai setup ollama
215
+ /config
216
+ /provider
217
+ /model
218
+ /history
219
+ /clear
220
+ /banner
221
+ /exit
222
+
223
+ Обычный текст без slash-команды отправляется в настроенный AI-провайдер.`);
224
+ }
225
+
226
+ function printAgentHistory(history) {
227
+ if (history.length === 0) {
228
+ console.log("История пуста.");
229
+ return;
230
+ }
231
+
232
+ for (const item of history.slice(-10)) {
233
+ console.log(`${item.role}: ${item.content}`);
234
+ }
235
+ }
236
+
237
+ function safePrompt(rl, closed = false) {
238
+ if (closed) {
239
+ return;
240
+ }
241
+
242
+ try {
243
+ rl.prompt();
244
+ } catch {
245
+ // The input stream can close while an async slash-command is still running.
246
+ }
247
+ }
248
+
77
249
  function showBanner() {
78
250
  if (process.stdout.isTTY && process.env.NO_COLOR !== "1") {
79
251
  console.log(BANNER);
@@ -112,14 +284,22 @@ async function handleAi(args) {
112
284
  if (subcommand === "help") {
113
285
  showBanner();
114
286
  console.log(`AI-команды:
287
+ iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
115
288
  iola ai doctor [--json]
116
289
  iola ai setup
117
290
  iola ai setup ollama [--yes] [--model MODEL]
291
+ iola ai setup openai [--model MODEL]
292
+ iola ai setup openrouter [--model MODEL]
118
293
 
119
294
  Локальная настройка сохраняется в ${CONFIG_FILE}`);
120
295
  return;
121
296
  }
122
297
 
298
+ if (subcommand === "ask") {
299
+ await aiAsk(rest);
300
+ return;
301
+ }
302
+
123
303
  if (subcommand === "doctor") {
124
304
  await aiDoctor(rest);
125
305
  return;
@@ -162,8 +342,17 @@ async function aiSetup(args) {
162
342
  }
163
343
 
164
344
  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.");
345
+ const options = parseOptions(args.slice(1));
346
+ const model = options.model || (provider === "openai" ? "gpt-4.1-mini" : "openai/gpt-4.1-mini");
347
+ await saveConfig({
348
+ ai: {
349
+ provider,
350
+ model,
351
+ baseUrl: provider === "openai" ? "https://api.openai.com/v1" : "https://openrouter.ai/api/v1",
352
+ },
353
+ });
354
+ console.log(`AI-профиль ${provider} сохранен в ${CONFIG_FILE}`);
355
+ console.log(`Ключ задайте через переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
167
356
  return;
168
357
  }
169
358
 
@@ -236,6 +425,167 @@ async function setupOllama(args) {
236
425
  console.log(`Готово. Локальный AI-профиль сохранен в ${CONFIG_FILE}`);
237
426
  }
238
427
 
428
+ async function aiAsk(args, context = {}) {
429
+ const options = parseOptions(args);
430
+ const question = options._.join(" ").trim();
431
+
432
+ if (!question) {
433
+ throw new Error('Текст вопроса обязателен. Пример: iola ai ask "Какие школы есть на улице Петрова?"');
434
+ }
435
+
436
+ const config = await loadConfig();
437
+ const provider = options.provider || config.ai.provider;
438
+ const model = options.model || config.ai.model;
439
+ const providerConfig = {
440
+ ...config.ai,
441
+ provider,
442
+ model,
443
+ };
444
+ const dataContext = await buildDataContext(question);
445
+ const messages = buildAiMessages(question, dataContext, context.history || []);
446
+ const answer = await callAiProvider(providerConfig, messages);
447
+
448
+ console.log(answer);
449
+ return answer;
450
+ }
451
+
452
+ async function buildDataContext(question) {
453
+ const [layers, schools, kindergartens] = await Promise.all([
454
+ fetchJson(`${MCP_BASE_URL}/mcp-version`),
455
+ fetchJson(`${API_BASE_URL}/schools?limit=100&offset=0`),
456
+ fetchJson(`${API_BASE_URL}/kindergartens?limit=100&offset=0`),
457
+ ]);
458
+ const queryTerms = extractSearchTerms(question);
459
+ const schoolItems = findRelevantItems(normalizeItems(schools), queryTerms).slice(0, 8);
460
+ const kindergartenItems = findRelevantItems(normalizeItems(kindergartens), queryTerms).slice(0, 8);
461
+
462
+ return {
463
+ layers: layers.data_layers || [],
464
+ schools: schoolItems.map(selectPublicSummary),
465
+ kindergartens: kindergartenItems.map(selectPublicSummary),
466
+ };
467
+ }
468
+
469
+ function extractSearchTerms(question) {
470
+ const normalized = question
471
+ .toLocaleLowerCase("ru-RU")
472
+ .replace(/[^\p{L}\p{N}\s.-]/gu, " ")
473
+ .split(/\s+/)
474
+ .map((term) => term.trim())
475
+ .filter(Boolean)
476
+ .filter((term) => !["какие", "какая", "какой", "есть", "найди", "покажи", "контакты", "адрес", "телефон", "школы", "школа", "сад", "детский", "детские", "сады", "улица", "ул"].includes(term));
477
+
478
+ return normalized.length > 0 ? normalized : [question];
479
+ }
480
+
481
+ function findRelevantItems(items, terms) {
482
+ return items
483
+ .map((item) => ({
484
+ item,
485
+ score: scoreItem(item, terms),
486
+ }))
487
+ .filter((entry) => entry.score > 0)
488
+ .sort((left, right) => right.score - left.score)
489
+ .map((entry) => entry.item);
490
+ }
491
+
492
+ function scoreItem(item, terms) {
493
+ const text = JSON.stringify(selectPublicSummary(item)).toLocaleLowerCase("ru-RU");
494
+ return terms.reduce((score, term) => score + (text.includes(term.toLocaleLowerCase("ru-RU")) ? 1 : 0), 0);
495
+ }
496
+
497
+ function buildAiMessages(question, dataContext, history) {
498
+ const system = [
499
+ "Ты терминальный AI-ассистент CLI-проекта Йошкар-Олы.",
500
+ "Отвечай на русском языке.",
501
+ "Используй только данные из переданного контекста.",
502
+ "Если в контексте нет нужных сведений, прямо напиши, что данных недостаточно.",
503
+ "Не выдумывай адреса, телефоны, лицензии и руководителей.",
504
+ "Отвечай кратко и по делу.",
505
+ ].join(" ");
506
+ const contextText = JSON.stringify(dataContext, null, 2);
507
+ const recentHistory = history.slice(-6);
508
+
509
+ return [
510
+ { role: "system", content: system },
511
+ ...recentHistory,
512
+ {
513
+ role: "user",
514
+ content: `Контекст открытых данных городского округа "Город Йошкар-Ола":\n${contextText}\n\nВопрос пользователя: ${question}`,
515
+ },
516
+ ];
517
+ }
518
+
519
+ async function callAiProvider(config, messages) {
520
+ if (config.provider === "ollama") {
521
+ return callOllama(config, messages);
522
+ }
523
+
524
+ if (config.provider === "openai") {
525
+ return callOpenAiCompatible(config, messages, process.env.OPENAI_API_KEY, "OpenAI");
526
+ }
527
+
528
+ if (config.provider === "openrouter") {
529
+ return callOpenAiCompatible(config, messages, process.env.OPENROUTER_API_KEY, "OpenRouter");
530
+ }
531
+
532
+ throw new Error(`Неизвестный AI-провайдер: ${config.provider}`);
533
+ }
534
+
535
+ async function callOllama(config, messages) {
536
+ let response;
537
+
538
+ try {
539
+ response = await fetch(`${config.baseUrl || "http://127.0.0.1:11434"}/api/chat`, {
540
+ method: "POST",
541
+ headers: { "content-type": "application/json" },
542
+ body: JSON.stringify({
543
+ model: config.model || "llama3.2:1b",
544
+ messages,
545
+ stream: false,
546
+ }),
547
+ });
548
+ } catch {
549
+ throw new Error("Ollama недоступен. Запустите Ollama и проверьте: ollama --version");
550
+ }
551
+
552
+ if (!response.ok) {
553
+ throw new Error(`Ollama request failed: ${response.status} ${response.statusText}. Проверьте "ollama serve" и модель.`);
554
+ }
555
+
556
+ const payload = await response.json();
557
+ return payload.message?.content || "";
558
+ }
559
+
560
+ async function callOpenAiCompatible(config, messages, apiKey, providerName) {
561
+ if (!apiKey) {
562
+ throw new Error(`${providerName} API key не найден. Задайте ${providerName === "OpenAI" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
563
+ }
564
+
565
+ const response = await fetch(`${config.baseUrl}/chat/completions`, {
566
+ method: "POST",
567
+ headers: {
568
+ authorization: `Bearer ${apiKey}`,
569
+ "content-type": "application/json",
570
+ "http-referer": "https://github.com/adm-iola/iola-cli",
571
+ "x-title": "iola-cli",
572
+ },
573
+ body: JSON.stringify({
574
+ model: config.model,
575
+ messages,
576
+ temperature: 0.2,
577
+ }),
578
+ });
579
+
580
+ if (!response.ok) {
581
+ const text = await response.text();
582
+ throw new Error(`${providerName} request failed: ${response.status} ${response.statusText}\n${text}`);
583
+ }
584
+
585
+ const payload = await response.json();
586
+ return payload.choices?.[0]?.message?.content || "";
587
+ }
588
+
239
589
  async function listLayers(args) {
240
590
  const options = parseOptions(args);
241
591
  const info = await fetchJson(`${MCP_BASE_URL}/mcp-version`);
@@ -375,7 +725,7 @@ function parseOptions(args) {
375
725
  const arg = args[index];
376
726
  if (arg === "--json" || arg === "--yes") {
377
727
  result[arg.slice(2)] = true;
378
- } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn") {
728
+ } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn" || arg === "--model" || arg === "--provider") {
379
729
  result[arg.slice(2)] = args[index + 1];
380
730
  index += 1;
381
731
  } else {
@@ -386,6 +736,40 @@ function parseOptions(args) {
386
736
  return result;
387
737
  }
388
738
 
739
+ function splitCommandLine(line) {
740
+ const result = [];
741
+ let current = "";
742
+ let quote = null;
743
+
744
+ for (const char of line) {
745
+ if ((char === "\"" || char === "'") && quote === null) {
746
+ quote = char;
747
+ continue;
748
+ }
749
+
750
+ if (char === quote) {
751
+ quote = null;
752
+ continue;
753
+ }
754
+
755
+ if (/\s/.test(char) && quote === null) {
756
+ if (current) {
757
+ result.push(current);
758
+ current = "";
759
+ }
760
+ continue;
761
+ }
762
+
763
+ current += char;
764
+ }
765
+
766
+ if (current) {
767
+ result.push(current);
768
+ }
769
+
770
+ return result;
771
+ }
772
+
389
773
  function filterItems(items, query) {
390
774
  const normalized = query.toLocaleLowerCase("ru-RU");
391
775
  return items.filter((item) => JSON.stringify(item).toLocaleLowerCase("ru-RU").includes(normalized));
@@ -568,6 +952,39 @@ async function saveConfig(value) {
568
952
  await writeFile(CONFIG_FILE, `${JSON.stringify(value, null, 2)}\n`, "utf8");
569
953
  }
570
954
 
955
+ async function loadConfig() {
956
+ try {
957
+ const text = await readFile(CONFIG_FILE, "utf8");
958
+ return mergeConfig(DEFAULT_AI_CONFIG, JSON.parse(text));
959
+ } catch {
960
+ return DEFAULT_AI_CONFIG;
961
+ }
962
+ }
963
+
964
+ function mergeConfig(base, override) {
965
+ return {
966
+ ...base,
967
+ ...override,
968
+ ai: {
969
+ ...base.ai,
970
+ ...(override.ai || {}),
971
+ },
972
+ };
973
+ }
974
+
975
+ async function printAiConfig() {
976
+ const config = await loadConfig();
977
+ printJson({
978
+ file: CONFIG_FILE,
979
+ ai: config.ai,
980
+ });
981
+ }
982
+
983
+ async function printAiConfigField(field) {
984
+ const config = await loadConfig();
985
+ console.log(config.ai[field] || "-");
986
+ }
987
+
571
988
  function runCommand(command, args, options = {}) {
572
989
  return new Promise((resolve, reject) => {
573
990
  const child = execFile(command, args, {