@iola_adm/iola-cli 0.1.17 → 0.1.19

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.
package/README.md CHANGED
@@ -96,6 +96,15 @@ iola resume 1 "продолжи"
96
96
  iola fork 1 "новый вопрос"
97
97
  iola features list
98
98
  iola features enable api-cache
99
+ iola permissions list
100
+ iola permissions deny export_data
101
+ iola permissions allow export_data
102
+ iola memory add "Отвечай кратко и указывай источник данных"
103
+ iola memory show
104
+ iola hooks events
105
+ iola hooks add AfterSync "iola quality"
106
+ iola agents list
107
+ iola agents run quality-checker "проверь школы"
99
108
  iola mcp status
100
109
  iola mcp list
101
110
  iola mcp install codex
@@ -127,6 +136,7 @@ iola version --check
127
136
  iola ask "Найди школу 29"
128
137
  iola ask "Найди школу 29" --profile codex --events --output answer.txt
129
138
  iola ask "Найди школу 29" --schema json --no-history
139
+ iola ask "Найди школу 29" --bare --quiet
130
140
  iola ask "выгрузи школы на Петрова в csv" --profile local --tools --reasoning verify
131
141
  iola data schools --format csv --output schools.csv
132
142
  iola data schools --limit 10
@@ -179,6 +189,11 @@ iola agent
179
189
  /sessions
180
190
  /resume 1
181
191
  /features list
192
+ /permissions
193
+ /tools
194
+ /memory show
195
+ /hooks list
196
+ /agents list
182
197
  /mcp status
183
198
  /config get
184
199
  /layers
@@ -468,6 +483,62 @@ iola diff
468
483
  iola diff schools
469
484
  ```
470
485
 
486
+ ## Permissions, memory, hooks и agents
487
+
488
+ Permissions ограничивают, что может делать локальный tool-agent:
489
+
490
+ ```bash
491
+ iola permissions list
492
+ iola permissions deny export_data
493
+ iola permissions allow export_data
494
+ ```
495
+
496
+ Memory хранит пользовательские предпочтения в локальной SQLite-БД и добавляет их
497
+ в AI-контекст, кроме режима `--bare`:
498
+
499
+ ```bash
500
+ iola memory add "Если найден конкретный объект, показывай ИНН"
501
+ iola memory show
502
+ iola memory export
503
+ ```
504
+
505
+ Hooks запускают локальные команды на события CLI:
506
+
507
+ ```bash
508
+ iola hooks events
509
+ iola hooks add AfterSync "iola quality"
510
+ iola hooks list
511
+ ```
512
+
513
+ Поддерживаемые события: `SessionStart`, `BeforeTool`, `AfterTool`,
514
+ `AfterSync`, `BeforeExport`, `SessionEnd`.
515
+
516
+ Agents - готовые режимы работы поверх AI-профилей и локальных инструментов:
517
+
518
+ ```bash
519
+ iola agents list
520
+ iola agents run quality-checker "проверь школы"
521
+ iola agents run exporter "выгрузи школы на Петрова в csv"
522
+ ```
523
+
524
+ Для скриптов доступны минимальные режимы:
525
+
526
+ ```bash
527
+ iola ask "Найди школу 29" --bare
528
+ iola ask "Найди школу 29" --quiet
529
+ iola ask "Найди школу 29" --schema json --fail-on-empty
530
+ iola --debug --debug-file iola-debug.log doctor
531
+ ```
532
+
533
+ ## Wiki
534
+
535
+ Подробные пользовательские инструкции ведутся в GitHub Wiki. Исходники страниц
536
+ лежат в папке `wiki/`, чтобы их можно было редактировать и коммитить как обычные
537
+ Markdown-файлы.
538
+
539
+ Рекомендуемая структура: `Home`, `Установка`, `Первый-запуск`, `AI-профили`,
540
+ `Локальный-инструментальный-агент`, `Команды`, `Решение-проблем`.
541
+
471
542
  ## Переменные окружения
472
543
 
473
544
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "CLI и AI-агент для работы с открытыми данными городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
@@ -23,6 +23,7 @@
23
23
  "files": [
24
24
  "bin",
25
25
  "src",
26
+ "wiki",
26
27
  "docs/assets/readme-header.png",
27
28
  "README.md",
28
29
  "LICENSE"
package/src/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { mkdirSync } from "node:fs";
3
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import readline from "node:readline/promises";
@@ -14,7 +14,9 @@ const CONFIG_DIR = path.join(os.homedir(), ".iola");
14
14
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
15
15
  const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
16
16
  const DB_FILE = path.join(CONFIG_DIR, "iola.db");
17
- const DB_SCHEMA_VERSION = 2;
17
+ const DB_SCHEMA_VERSION = 3;
18
+ const LOCAL_TOOLS = ["search_local", "get_card", "export_data", "run_report", "save_view"];
19
+ const HOOK_EVENTS = ["SessionStart", "BeforeTool", "AfterTool", "AfterSync", "BeforeExport", "SessionEnd"];
18
20
  const FEATURES = {
19
21
  "sqlite-history": { stage: "stable", defaultEnabled: true, description: "Запись истории AI-запросов в SQLite." },
20
22
  sessions: { stage: "stable", defaultEnabled: true, description: "Сессии, resume и fork для AI-диалогов." },
@@ -58,6 +60,63 @@ const DEFAULT_AI_CONFIG = {
58
60
  },
59
61
  },
60
62
  },
63
+ permissions: {
64
+ localTools: {
65
+ search_local: true,
66
+ get_card: true,
67
+ export_data: true,
68
+ run_report: true,
69
+ save_view: true,
70
+ },
71
+ writeFiles: true,
72
+ sync: true,
73
+ externalApi: true,
74
+ externalAi: true,
75
+ codex: true,
76
+ },
77
+ memory: {
78
+ enabled: true,
79
+ },
80
+ hooks: {},
81
+ };
82
+ const AGENTS = {
83
+ "data-analyst": {
84
+ profile: null,
85
+ tools: true,
86
+ reasoning: "verify",
87
+ description: "Анализирует открытые данные, ищет объекты и отвечает с опорой на локальные данные.",
88
+ },
89
+ "quality-checker": {
90
+ profile: "local",
91
+ tools: true,
92
+ reasoning: "verify",
93
+ prefix: "Проверь качество данных и укажи найденные проблемы: ",
94
+ description: "Проверяет телефоны, email, ИНН и неполные карточки.",
95
+ },
96
+ exporter: {
97
+ profile: "local",
98
+ tools: true,
99
+ reasoning: "fast",
100
+ prefix: "Подготовь выгрузку данных: ",
101
+ description: "Готовит CSV/JSON выгрузки через локальные инструменты.",
102
+ },
103
+ "mcp-helper": {
104
+ profile: null,
105
+ tools: false,
106
+ description: "Помогает с MCP, профилями AI и диагностикой подключения.",
107
+ },
108
+ "local-fast": {
109
+ profile: "local",
110
+ tools: true,
111
+ reasoning: "fast",
112
+ description: "Быстрый локальный режим для простых запросов.",
113
+ },
114
+ reviewer: {
115
+ profile: null,
116
+ tools: false,
117
+ prefix: "Проверь ответ и найди слабые места: ",
118
+ description: "Режим проверки и уточнения ответов.",
119
+ },
61
120
  };
62
121
  const DATASETS = {
63
122
  schools: {
@@ -94,6 +153,10 @@ const COMMANDS = new Map([
94
153
  ["resume", resumeSession],
95
154
  ["fork", forkSession],
96
155
  ["features", handleFeatures],
156
+ ["permissions", handlePermissions],
157
+ ["memory", handleMemory],
158
+ ["hooks", handleHooks],
159
+ ["agents", handleAgents],
97
160
  ["mcp", handleMcp],
98
161
  ["cache", handleCache],
99
162
  ["sync", handleSync],
@@ -125,6 +188,28 @@ const COMMANDS = new Map([
125
188
  ]);
126
189
 
127
190
  export async function main(argv) {
191
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
192
+ await showHelp();
193
+ return;
194
+ }
195
+
196
+ const runtime = parseGlobalOptions(argv);
197
+ if (runtime.help) {
198
+ await showHelp();
199
+ return;
200
+ }
201
+ if (runtime.debug) {
202
+ process.env.IOLA_DEBUG = "1";
203
+ }
204
+ if (runtime.debugFile) {
205
+ process.env.IOLA_DEBUG = "1";
206
+ process.env.IOLA_DEBUG_FILE = runtime.debugFile;
207
+ }
208
+ if (runtime.noColor) {
209
+ process.env.NO_COLOR = "1";
210
+ }
211
+
212
+ argv = runtime.args;
128
213
  const [command = "help", ...args] = argv;
129
214
  const nodeStatus = getNodeRequirementStatus();
130
215
  if (!nodeStatus.ok && !["help", "version", "doctor", "init"].includes(command)) {
@@ -142,7 +227,7 @@ export async function main(argv) {
142
227
  throw new Error(`Unknown command: ${command}\nRun "iola help" to see available commands.`);
143
228
  }
144
229
 
145
- await handler(args);
230
+ await handler(runtime.debugFile ? [...args, "--debug-file", runtime.debugFile] : args);
146
231
  }
147
232
 
148
233
  async function showHelp() {
@@ -163,6 +248,10 @@ Usage:
163
248
  iola resume SESSION_ID [TEXT]
164
249
  iola fork SESSION_ID [TEXT]
165
250
  iola features list|enable|disable
251
+ iola permissions list|allow|deny
252
+ iola memory show|add|set|clear|export
253
+ iola hooks list|add|delete|run
254
+ iola agents list|run
166
255
  iola mcp list|status|install|remove
167
256
  iola cache status|warm|clear
168
257
  iola sync [--dataset schools|kindergartens]
@@ -183,7 +272,7 @@ Usage:
183
272
  iola config set api.mcpBaseUrl URL
184
273
  iola config reset
185
274
  iola update
186
- iola ask TEXT [--profile NAME] [--model MODEL] [--tools] [--reasoning fast|verify|vote] [--output FILE] [--schema json|table] [--events] [--no-history]
275
+ iola ask TEXT [--profile NAME] [--model MODEL] [--tools] [--reasoning fast|verify|vote] [--output FILE] [--schema json|table] [--events] [--no-history] [--bare] [--quiet] [--no-color] [--fail-on-empty]
187
276
  iola data LAYER [--limit 10] [--search TEXT] [--where FIELD=VALUE] [--columns a,b,c] [--format table|json|csv]
188
277
  iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
189
278
  iola ai context TEXT [--json]
@@ -222,6 +311,7 @@ Requirements:
222
311
  async function startAgent() {
223
312
  showBanner();
224
313
  console.log("Интерактивный режим. Введите /help для списка команд, /exit для выхода.");
314
+ await runHooks("SessionStart", { mode: "agent" });
225
315
 
226
316
  const rl = readline.createInterface({ input, output, prompt: "iola> " });
227
317
  const state = {
@@ -256,6 +346,7 @@ async function startAgent() {
256
346
  if (!closed) {
257
347
  rl.close();
258
348
  }
349
+ await runHooks("SessionEnd", { mode: "agent" });
259
350
  }
260
351
 
261
352
  async function handleAgentLine(line, state) {
@@ -317,6 +408,31 @@ async function handleAgentLine(line, state) {
317
408
  return false;
318
409
  }
319
410
 
411
+ if (command === "permissions") {
412
+ await handlePermissions(args);
413
+ return false;
414
+ }
415
+
416
+ if (command === "memory") {
417
+ await handleMemory(args);
418
+ return false;
419
+ }
420
+
421
+ if (command === "hooks") {
422
+ await handleHooks(args);
423
+ return false;
424
+ }
425
+
426
+ if (command === "agents") {
427
+ await handleAgents(args);
428
+ return false;
429
+ }
430
+
431
+ if (command === "tools") {
432
+ await handlePermissions(["tools"]);
433
+ return false;
434
+ }
435
+
320
436
  if (command === "mcp") {
321
437
  await handleMcp(args);
322
438
  return false;
@@ -421,6 +537,11 @@ async function handleAgentLine(line, state) {
421
537
  resume: ["resume", args],
422
538
  fork: ["fork", args],
423
539
  features: ["features", args],
540
+ permissions: ["permissions", args],
541
+ memory: ["memory", args],
542
+ hooks: ["hooks", args],
543
+ agents: ["agents", args],
544
+ tools: ["permissions", ["tools", ...args]],
424
545
  mcp: ["mcp", args],
425
546
  cache: ["cache", args],
426
547
  sync: ["sync", args],
@@ -455,6 +576,11 @@ function printAgentHelp() {
455
576
  /sessions
456
577
  /resume SESSION_ID
457
578
  /features list
579
+ /permissions
580
+ /tools
581
+ /memory show
582
+ /hooks list
583
+ /agents list
458
584
  /mcp status
459
585
  /cache status
460
586
  /sync
@@ -1063,6 +1189,208 @@ async function handleFeatures(args) {
1063
1189
  throw new Error("Команды features: list, enable NAME, disable NAME.");
1064
1190
  }
1065
1191
 
1192
+ async function handlePermissions(args) {
1193
+ const [action = "list", name] = args;
1194
+ const config = await loadConfig();
1195
+
1196
+ if (action === "list" || action === "ls" || action === "tools") {
1197
+ const permissions = config.permissions || DEFAULT_AI_CONFIG.permissions;
1198
+ const rows = [
1199
+ ...LOCAL_TOOLS.map((tool) => ({
1200
+ permission: `localTools.${tool}`,
1201
+ value: permissions.localTools?.[tool] === false ? "deny" : "allow",
1202
+ scope: "local-tool",
1203
+ })),
1204
+ { permission: "writeFiles", value: permissions.writeFiles === false ? "deny" : "allow", scope: "runtime" },
1205
+ { permission: "sync", value: permissions.sync === false ? "deny" : "allow", scope: "runtime" },
1206
+ { permission: "externalApi", value: permissions.externalApi === false ? "deny" : "allow", scope: "network" },
1207
+ { permission: "externalAi", value: permissions.externalAi === false ? "deny" : "allow", scope: "network" },
1208
+ { permission: "codex", value: permissions.codex === false ? "deny" : "allow", scope: "external-cli" },
1209
+ ];
1210
+ printTable(rows, [
1211
+ ["permission", "Разрешение"],
1212
+ ["value", "Статус"],
1213
+ ["scope", "Область"],
1214
+ ]);
1215
+ return;
1216
+ }
1217
+
1218
+ if (action === "allow" || action === "deny") {
1219
+ if (!name) {
1220
+ throw new Error("Пример: iola permissions deny export_data");
1221
+ }
1222
+ const allow = action === "allow";
1223
+ const next = { ...(config.permissions || DEFAULT_AI_CONFIG.permissions) };
1224
+ next.localTools = { ...(next.localTools || {}) };
1225
+ if (LOCAL_TOOLS.includes(name)) {
1226
+ next.localTools[name] = allow;
1227
+ } else if (name in DEFAULT_AI_CONFIG.permissions) {
1228
+ next[name] = allow;
1229
+ } else {
1230
+ throw new Error(`Неизвестное разрешение: ${name}. Доступно: ${[...LOCAL_TOOLS, "writeFiles", "sync", "externalApi", "externalAi", "codex"].join(", ")}`);
1231
+ }
1232
+ await saveConfig({ permissions: next });
1233
+ console.log(`${name}: ${allow ? "allow" : "deny"}`);
1234
+ return;
1235
+ }
1236
+
1237
+ throw new Error("Команды permissions: list, tools, allow NAME, deny NAME.");
1238
+ }
1239
+
1240
+ async function handleMemory(args) {
1241
+ const [action = "show", ...rest] = args;
1242
+ const options = parseOptions(rest);
1243
+
1244
+ if (action === "show" || action === "list" || action === "ls") {
1245
+ const rows = listMemory(Number(options.limit || 50));
1246
+ if (options.json) {
1247
+ printJson(rows);
1248
+ return;
1249
+ }
1250
+ printTable(rows, [
1251
+ ["id", "ID"],
1252
+ ["scope", "Область"],
1253
+ ["content", "Память"],
1254
+ ["created_at", "Дата"],
1255
+ ]);
1256
+ return;
1257
+ }
1258
+
1259
+ if (action === "add" || action === "set") {
1260
+ const text = rest.join(" ").trim();
1261
+ if (!text) {
1262
+ throw new Error('Пример: iola memory add "Отвечай кратко и по данным Йошкар-Олы"');
1263
+ }
1264
+ const id = addMemory(text, options.scope || "user");
1265
+ console.log(`Память сохранена: ${id}`);
1266
+ return;
1267
+ }
1268
+
1269
+ if (action === "delete" || action === "remove" || action === "rm") {
1270
+ const id = rest[0];
1271
+ if (!id) throw new Error("Пример: iola memory delete 1");
1272
+ deleteMemory(Number(id));
1273
+ console.log(`Память удалена: ${id}`);
1274
+ return;
1275
+ }
1276
+
1277
+ if (action === "clear") {
1278
+ clearMemory();
1279
+ console.log("Память очищена.");
1280
+ return;
1281
+ }
1282
+
1283
+ if (action === "export") {
1284
+ const rows = listMemory(1000);
1285
+ const file = rest[0] || path.join(CONFIG_DIR, "memory-export.json");
1286
+ await writeFile(file, `${JSON.stringify(rows, null, 2)}\n`, "utf8");
1287
+ console.log(`Память экспортирована: ${file}`);
1288
+ return;
1289
+ }
1290
+
1291
+ throw new Error("Команды memory: show, add TEXT, delete ID, clear, export [FILE].");
1292
+ }
1293
+
1294
+ async function handleHooks(args) {
1295
+ const [action = "list", event, ...commandParts] = args;
1296
+ const config = await loadConfig();
1297
+
1298
+ if (action === "list" || action === "ls") {
1299
+ const rows = Object.entries(config.hooks || {}).flatMap(([hookEvent, commands]) =>
1300
+ (commands || []).map((command, index) => ({ event: hookEvent, index, command })));
1301
+ printTable(rows, [
1302
+ ["event", "Событие"],
1303
+ ["index", "#"],
1304
+ ["command", "Команда"],
1305
+ ]);
1306
+ return;
1307
+ }
1308
+
1309
+ if (action === "events") {
1310
+ printTable(HOOK_EVENTS.map((name) => ({ name })), [["name", "Событие"]]);
1311
+ return;
1312
+ }
1313
+
1314
+ if (action === "add") {
1315
+ if (!HOOK_EVENTS.includes(event) || commandParts.length === 0) {
1316
+ throw new Error(`Пример: iola hooks add AfterSync "iola quality" Доступно: ${HOOK_EVENTS.join(", ")}`);
1317
+ }
1318
+ const hooks = { ...(config.hooks || {}) };
1319
+ hooks[event] = [...(hooks[event] || []), commandParts.join(" ")];
1320
+ await saveConfig({ hooks });
1321
+ console.log(`Hook добавлен: ${event}`);
1322
+ return;
1323
+ }
1324
+
1325
+ if (action === "delete" || action === "remove") {
1326
+ const index = Number(commandParts[0] ?? event);
1327
+ const hookEvent = Number.isFinite(Number(event)) ? null : event;
1328
+ const hooks = { ...(config.hooks || {}) };
1329
+ if (hookEvent) {
1330
+ hooks[hookEvent] = (hooks[hookEvent] || []).filter((_, itemIndex) => itemIndex !== index);
1331
+ } else {
1332
+ for (const key of Object.keys(hooks)) hooks[key] = (hooks[key] || []).filter((_, itemIndex) => itemIndex !== index);
1333
+ }
1334
+ await saveConfig({ hooks });
1335
+ console.log("Hook удален.");
1336
+ return;
1337
+ }
1338
+
1339
+ if (action === "run") {
1340
+ if (!HOOK_EVENTS.includes(event)) throw new Error(`Событие обязательно: ${HOOK_EVENTS.join(", ")}`);
1341
+ await runHooks(event, { manual: true });
1342
+ return;
1343
+ }
1344
+
1345
+ throw new Error("Команды hooks: list, events, add EVENT COMMAND, delete EVENT INDEX, run EVENT.");
1346
+ }
1347
+
1348
+ async function handleAgents(args) {
1349
+ const [action = "list", name, ...rest] = args;
1350
+
1351
+ if (action === "list" || action === "ls") {
1352
+ const rows = Object.entries(AGENTS).map(([agent, meta]) => ({
1353
+ agent,
1354
+ profile: meta.profile || "active",
1355
+ tools: meta.tools ? "yes" : "no",
1356
+ reasoning: meta.reasoning || "-",
1357
+ description: meta.description,
1358
+ }));
1359
+ printTable(rows, [
1360
+ ["agent", "Агент"],
1361
+ ["profile", "Профиль"],
1362
+ ["tools", "Tools"],
1363
+ ["reasoning", "Reasoning"],
1364
+ ["description", "Описание"],
1365
+ ]);
1366
+ return;
1367
+ }
1368
+
1369
+ if (action === "run") {
1370
+ if (!AGENTS[name]) {
1371
+ throw new Error(`Неизвестный агент: ${name}. Доступно: ${Object.keys(AGENTS).join(", ")}`);
1372
+ }
1373
+ const agent = AGENTS[name];
1374
+ const options = parseOptions(rest);
1375
+ const question = options._.join(" ").trim();
1376
+ if (!question) throw new Error(`Пример: iola agents run ${name} "найди школы на Петрова"`);
1377
+ const askArgs = [agent.prefix ? `${agent.prefix}${question}` : question, "--agent", name];
1378
+ if (agent.profile) askArgs.push("--profile", agent.profile);
1379
+ if (agent.tools) askArgs.push("--tools");
1380
+ if (agent.reasoning) askArgs.push("--reasoning", agent.reasoning);
1381
+ for (const flag of ["no-history", "quiet", "bare", "events", "fail-on-empty"]) {
1382
+ if (options[flag]) askArgs.push(`--${flag}`);
1383
+ }
1384
+ for (const flag of ["profile", "model", "output", "schema", "format", "reasoning"]) {
1385
+ if (options[flag]) askArgs.push(`--${flag}`, options[flag]);
1386
+ }
1387
+ await aiAsk(askArgs);
1388
+ return;
1389
+ }
1390
+
1391
+ throw new Error("Команды agents: list, run NAME TEXT.");
1392
+ }
1393
+
1066
1394
  async function handleMcp(args) {
1067
1395
  const [action = "status", target = "codex"] = args;
1068
1396
 
@@ -1131,12 +1459,14 @@ async function handleSync(args) {
1131
1459
  ]);
1132
1460
  return;
1133
1461
  }
1462
+ await assertPermission("sync");
1134
1463
  const options = parseOptions(args);
1135
1464
  const datasets = options.dataset ? [options.dataset] : Object.keys(DATASETS);
1136
1465
  const rows = [];
1137
1466
  for (const dataset of datasets) {
1138
1467
  rows.push(await syncDataset(dataset));
1139
1468
  }
1469
+ await runHooks("AfterSync", { datasets, rows });
1140
1470
  printTable(rows, [
1141
1471
  ["dataset", "Слой"],
1142
1472
  ["records", "Записей"],
@@ -1878,6 +2208,13 @@ function initDatabase() {
1878
2208
  command TEXT NOT NULL,
1879
2209
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
1880
2210
  );
2211
+ CREATE TABLE IF NOT EXISTS memory (
2212
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2213
+ scope TEXT NOT NULL DEFAULT 'user',
2214
+ content TEXT NOT NULL,
2215
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
2216
+ );
2217
+ CREATE INDEX IF NOT EXISTS idx_memory_created_at ON memory(created_at DESC);
1881
2218
  `);
1882
2219
  db.prepare(`
1883
2220
  INSERT INTO meta(key, value) VALUES ('schema_version', ?)
@@ -1906,6 +2243,7 @@ function getDbStatus() {
1906
2243
  const sessions = db.prepare("SELECT COUNT(*) AS count FROM sessions").get();
1907
2244
  const local = db.prepare("SELECT COUNT(*) AS count FROM local_records").get();
1908
2245
  const cache = db.prepare("SELECT COUNT(*) AS count FROM api_cache").get();
2246
+ const memory = db.prepare("SELECT COUNT(*) AS count FROM memory").get();
1909
2247
  return {
1910
2248
  status: "ok",
1911
2249
  file: DB_FILE,
@@ -1914,6 +2252,7 @@ function getDbStatus() {
1914
2252
  sessions: sessions?.count ?? 0,
1915
2253
  local_records: local?.count ?? 0,
1916
2254
  cache: cache?.count ?? 0,
2255
+ memory: memory?.count ?? 0,
1917
2256
  };
1918
2257
  } finally {
1919
2258
  db.close();
@@ -2227,6 +2566,7 @@ async function syncDataset(dataset) {
2227
2566
  if (!DATASETS[dataset]) {
2228
2567
  throw new Error(`Неизвестный слой: ${dataset}`);
2229
2568
  }
2569
+ await assertPermission("externalApi");
2230
2570
  try {
2231
2571
  const payload = await fetchJson(`${await getApiBaseUrl()}/${DATASETS[dataset].endpoint}?limit=500&offset=0`);
2232
2572
  const items = normalizeItems(payload);
@@ -2458,11 +2798,58 @@ function exportDbSnapshot() {
2458
2798
  views: listSavedViews(),
2459
2799
  aliases: listAliases(),
2460
2800
  features: listFeatures(),
2801
+ memory: listMemory(1000),
2461
2802
  sessions: listSessions(100),
2462
2803
  history: listHistory(100),
2463
2804
  };
2464
2805
  }
2465
2806
 
2807
+ function listMemory(limit = 50) {
2808
+ initDatabase();
2809
+ const db = openDatabase();
2810
+ try {
2811
+ return db.prepare("SELECT id, scope, content, created_at FROM memory ORDER BY id DESC LIMIT ?").all(limit);
2812
+ } finally {
2813
+ db.close();
2814
+ }
2815
+ }
2816
+
2817
+ function addMemory(content, scope = "user") {
2818
+ initDatabase();
2819
+ const db = openDatabase();
2820
+ try {
2821
+ const result = db.prepare("INSERT INTO memory(scope, content) VALUES (?, ?)").run(scope, content);
2822
+ return Number(result.lastInsertRowid);
2823
+ } finally {
2824
+ db.close();
2825
+ }
2826
+ }
2827
+
2828
+ function deleteMemory(id) {
2829
+ initDatabase();
2830
+ const db = openDatabase();
2831
+ try {
2832
+ db.prepare("DELETE FROM memory WHERE id = ?").run(id);
2833
+ } finally {
2834
+ db.close();
2835
+ }
2836
+ }
2837
+
2838
+ function clearMemory() {
2839
+ initDatabase();
2840
+ const db = openDatabase();
2841
+ try {
2842
+ db.exec("DELETE FROM memory");
2843
+ } finally {
2844
+ db.close();
2845
+ }
2846
+ }
2847
+
2848
+ function buildMemoryText(limit = 20) {
2849
+ const rows = listMemory(limit).reverse();
2850
+ return rows.map((row) => `- ${row.content}`).join("\n");
2851
+ }
2852
+
2466
2853
  function listAliases() {
2467
2854
  initDatabase();
2468
2855
  const db = openDatabase();
@@ -2523,6 +2910,7 @@ function inferCommandFromText(text) {
2523
2910
  async function outputData(value, options, format) {
2524
2911
  const text = format === "csv" ? toCsv(value) : `${JSON.stringify(value, null, 2)}\n`;
2525
2912
  if (options.output) {
2913
+ await assertPermission("writeFiles");
2526
2914
  await writeFile(options.output, text, "utf8");
2527
2915
  console.log(`Файл сохранен: ${options.output}`);
2528
2916
  return;
@@ -2626,13 +3014,15 @@ async function aiAsk(args, context = {}) {
2626
3014
 
2627
3015
  const config = await loadConfig();
2628
3016
  const providerConfig = resolveAiProfile(config, options);
3017
+ if (providerConfig.provider === "codex") await assertPermission("codex");
3018
+ if (providerConfig.provider !== "ollama") await assertPermission("externalAi");
2629
3019
  if (options.tools && providerConfig.provider === "ollama") {
2630
3020
  return localToolAsk(question, providerConfig, options);
2631
3021
  }
2632
3022
  applyRuntimeConfig(providerConfig, options.config);
2633
- const dataContext = await buildDataContext(question);
3023
+ const dataContext = options.bare ? { layers: [], query: { text: question, terms: [], patterns: { numbers: [], inns: [], streets: [], targetLayers: [] } }, schools: [], kindergartens: [] } : await buildDataContext(question);
2634
3024
  emitEvent(options, "context_loaded", { schools: dataContext.schools.length, kindergartens: dataContext.kindergartens.length });
2635
- const historyEnabled = !options["no-history"] && isFeatureEnabled("sqlite-history");
3025
+ const historyEnabled = !options.bare && !options["no-history"] && isFeatureEnabled("sqlite-history");
2636
3026
  const sessionId = historyEnabled && isFeatureEnabled("sessions") ? ensureSessionForAsk(options, providerConfig, question) : null;
2637
3027
  const history = context.history || (sessionId ? getSessionAiHistory(sessionId) : []);
2638
3028
  const messages = buildAiMessages(question, dataContext, history, options);
@@ -2659,15 +3049,20 @@ async function aiAsk(args, context = {}) {
2659
3049
  emitEvent(options, "answer", { length: answer.length, sessionId });
2660
3050
 
2661
3051
  if (options.output) {
3052
+ await assertPermission("writeFiles");
2662
3053
  await writeFile(options.output, answer, "utf8");
2663
3054
  }
2664
3055
 
3056
+ if (options["fail-on-empty"] && !answer.trim()) {
3057
+ throw new Error("AI вернул пустой ответ.");
3058
+ }
3059
+
2665
3060
  if (options.format === "json" || options.schema === "json") {
2666
3061
  printJson({ answer, profile: providerConfig.name, provider: providerConfig.provider, model: providerConfig.model, sessionId, context: dataContext });
2667
3062
  return answer;
2668
3063
  }
2669
3064
 
2670
- console.log(answer);
3065
+ if (!options.quiet) console.log(answer);
2671
3066
  return answer;
2672
3067
  }
2673
3068
 
@@ -2711,11 +3106,14 @@ async function localToolAsk(question, providerConfig, options) {
2711
3106
  }
2712
3107
 
2713
3108
  emitEvent(options, "tool_plan", { plan: validated });
2714
- if (options.output) await writeFile(options.output, answer, "utf8");
3109
+ if (options.output) {
3110
+ await assertPermission("writeFiles");
3111
+ await writeFile(options.output, answer, "utf8");
3112
+ }
2715
3113
  if (options.format === "json" || options.schema === "json") {
2716
3114
  printJson({ answer, plan: validated, result });
2717
3115
  } else {
2718
- console.log(answer);
3116
+ if (!options.quiet) console.log(answer);
2719
3117
  }
2720
3118
  return answer;
2721
3119
  }
@@ -2776,7 +3174,7 @@ function chooseBestPlan(plans) {
2776
3174
  }
2777
3175
 
2778
3176
  function validateToolPlan(plan) {
2779
- const allowed = new Set(["search_local", "get_card", "export_data", "run_report", "save_view"]);
3177
+ const allowed = new Set(LOCAL_TOOLS);
2780
3178
  if (!plan || !Array.isArray(plan.steps)) throw new Error("Некорректный tool-plan.");
2781
3179
  for (const step of plan.steps) {
2782
3180
  if (!allowed.has(step.tool)) throw new Error(`Недопустимый tool: ${step.tool}`);
@@ -2788,6 +3186,8 @@ async function executeToolPlan(plan) {
2788
3186
  let current = [];
2789
3187
  const outputs = [];
2790
3188
  for (const step of plan.steps) {
3189
+ await assertPermission(step.tool);
3190
+ await runHooks("BeforeTool", { tool: step.tool, args: step.args || {} });
2791
3191
  if (step.tool === "search_local") {
2792
3192
  current = searchLocalRecords(step.args?.query || "", { dataset: step.args?.dataset || "all", limit: step.args?.limit || 20, fts: true });
2793
3193
  outputs.push({ tool: step.tool, rows: current.length });
@@ -2802,10 +3202,13 @@ async function executeToolPlan(plan) {
2802
3202
  saveView(step.args?.name, step.args?.dataset || "all", step.args?.args || []);
2803
3203
  outputs.push({ tool: step.tool, saved: step.args?.name });
2804
3204
  } else if (step.tool === "export_data") {
3205
+ await assertPermission("writeFiles");
3206
+ await runHooks("BeforeExport", { output: step.args?.output || "iola-export.csv", format: step.args?.format || "csv", rows: current.length });
2805
3207
  const text = step.args?.format === "json" ? JSON.stringify(current, null, 2) : toCsv(current);
2806
3208
  await writeFile(step.args?.output || "iola-export.csv", text, "utf8");
2807
3209
  outputs.push({ tool: step.tool, output: step.args?.output || "iola-export.csv", rows: current.length });
2808
3210
  }
3211
+ await runHooks("AfterTool", { tool: step.tool, rows: current.length });
2809
3212
  }
2810
3213
  return { rows: current, outputs };
2811
3214
  }
@@ -2829,6 +3232,36 @@ function applyRuntimeConfig(target, value) {
2829
3232
  setConfigValue(target, key, parts.join("="));
2830
3233
  }
2831
3234
 
3235
+ async function runHooks(event, payload = {}) {
3236
+ const config = await loadConfig();
3237
+ const commands = config.hooks?.[event] || [];
3238
+ for (const command of commands) {
3239
+ const parts = splitCommandLine(command);
3240
+ if (parts.length === 0) continue;
3241
+ await runCommand(parts[0], parts.slice(1), {
3242
+ inherit: true,
3243
+ env: {
3244
+ IOLA_HOOK_EVENT: event,
3245
+ IOLA_HOOK_PAYLOAD: JSON.stringify(payload),
3246
+ },
3247
+ });
3248
+ }
3249
+ }
3250
+
3251
+ async function assertPermission(name) {
3252
+ const config = await loadConfig();
3253
+ const permissions = config.permissions || DEFAULT_AI_CONFIG.permissions;
3254
+ if (LOCAL_TOOLS.includes(name)) {
3255
+ if (permissions.localTools?.[name] === false) {
3256
+ throw new Error(`Tool запрещен политикой permissions: ${name}`);
3257
+ }
3258
+ return;
3259
+ }
3260
+ if (permissions[name] === false) {
3261
+ throw new Error(`Действие запрещено политикой permissions: ${name}`);
3262
+ }
3263
+ }
3264
+
2832
3265
  function emitEvent(options, type, data) {
2833
3266
  if (!options.events) {
2834
3267
  return;
@@ -2837,6 +3270,7 @@ function emitEvent(options, type, data) {
2837
3270
  }
2838
3271
 
2839
3272
  async function buildDataContext(question) {
3273
+ await assertPermission("externalApi");
2840
3274
  const apiBaseUrl = await getApiBaseUrl();
2841
3275
  const mcpBaseUrl = await getMcpBaseUrl();
2842
3276
  const [layers, schools, kindergartens] = await Promise.all([
@@ -2960,6 +3394,7 @@ function scoreItem(item, terms, patterns, layer) {
2960
3394
 
2961
3395
  function buildAiMessages(question, dataContext, history, options = {}) {
2962
3396
  const sourceLines = buildSourceLines(dataContext);
3397
+ const memoryText = options.bare ? "" : buildMemoryText();
2963
3398
  const system = [
2964
3399
  "Ты терминальный AI-ассистент CLI-проекта Йошкар-Олы.",
2965
3400
  "Отвечай на русском языке.",
@@ -2969,6 +3404,7 @@ function buildAiMessages(question, dataContext, history, options = {}) {
2969
3404
  "Если отвечаешь по конкретным организациям, укажи источник в конце: слой, название и ИНН.",
2970
3405
  options.schema === "json" ? "Верни валидный JSON без markdown-обертки." : "",
2971
3406
  options.schema === "table" ? "Если уместно, верни ответ в виде markdown-таблицы." : "",
3407
+ memoryText ? `Учитывай пользовательскую память:\n${memoryText}` : "",
2972
3408
  "Отвечай кратко и по делу.",
2973
3409
  ].filter(Boolean).join(" ");
2974
3410
  const contextText = JSON.stringify(dataContext, null, 2);
@@ -3307,12 +3743,12 @@ function parseOptions(args) {
3307
3743
 
3308
3744
  for (let index = 0; index < args.length; index += 1) {
3309
3745
  const arg = args[index];
3310
- if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--fts") {
3746
+ if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug") {
3311
3747
  result[arg.slice(2)] = true;
3312
3748
  } else if (arg === "--check" || arg === "--upgrade-node") {
3313
3749
  result.check = true;
3314
3750
  result[arg.slice(2)] = true;
3315
- } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--base-url" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config" || arg === "--dataset" || arg === "--save" || arg === "--reasoning") {
3751
+ } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--base-url" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config" || arg === "--dataset" || arg === "--save" || arg === "--reasoning" || arg === "--agent" || arg === "--scope" || arg === "--debug-file") {
3316
3752
  result[arg.slice(2)] = args[index + 1];
3317
3753
  index += 1;
3318
3754
  } else {
@@ -3323,6 +3759,21 @@ function parseOptions(args) {
3323
3759
  return result;
3324
3760
  }
3325
3761
 
3762
+ function parseGlobalOptions(argv) {
3763
+ const result = { args: [] };
3764
+ for (let index = 0; index < argv.length; index += 1) {
3765
+ const arg = argv[index];
3766
+ if (arg === "--help" || arg === "-h") result.help = true;
3767
+ else if (arg === "--debug") result.debug = true;
3768
+ else if (arg === "--no-color") result.noColor = true;
3769
+ else if (arg === "--debug-file") {
3770
+ result.debugFile = argv[index + 1];
3771
+ index += 1;
3772
+ } else result.args.push(arg);
3773
+ }
3774
+ return result;
3775
+ }
3776
+
3326
3777
  function splitCommandLine(line) {
3327
3778
  const result = [];
3328
3779
  let current = "";
@@ -3786,9 +4237,17 @@ async function printAiConfigField(field) {
3786
4237
 
3787
4238
  function runCommand(command, args, options = {}) {
3788
4239
  return new Promise((resolve, reject) => {
4240
+ if (process.env.IOLA_DEBUG) {
4241
+ console.error(`[debug] run: ${command} ${args.join(" ")}`);
4242
+ debugLog(`run: ${command} ${args.join(" ")}`);
4243
+ }
3789
4244
  const child = execFile(command, args, {
3790
4245
  windowsHide: true,
3791
4246
  maxBuffer: 1024 * 1024 * 5,
4247
+ env: {
4248
+ ...process.env,
4249
+ ...(options.env || {}),
4250
+ },
3792
4251
  }, (error, stdout, stderr) => {
3793
4252
  if (error) {
3794
4253
  if (process.platform === "win32" && (error.code === "ENOENT" || error.code === "EINVAL") && !options.cmdFallback) {
@@ -3817,6 +4276,11 @@ function runCommand(command, args, options = {}) {
3817
4276
  });
3818
4277
  }
3819
4278
 
4279
+ function debugLog(message) {
4280
+ if (!process.env.IOLA_DEBUG_FILE) return;
4281
+ appendFile(process.env.IOLA_DEBUG_FILE, `[${new Date().toISOString()}] ${message}\n`, "utf8").catch(() => {});
4282
+ }
4283
+
3820
4284
  function quoteWindowsCommand(command, args) {
3821
4285
  return [command, ...args].map((value) => {
3822
4286
  const text = String(value);
@@ -0,0 +1,44 @@
1
+ # AI profiles
2
+
3
+ CLI поддерживает несколько AI-профилей одновременно.
4
+
5
+ ## Локальная модель
6
+
7
+ ```bash
8
+ iola ai setup ollama
9
+ iola ai profile use local
10
+ iola ask "найди школы на Петрова"
11
+ ```
12
+
13
+ ## OpenAI
14
+
15
+ ```bash
16
+ iola ai key set openai
17
+ iola ai setup openai --model gpt-4.1-mini
18
+ iola ask "найди школу 29" --profile openai
19
+ ```
20
+
21
+ ## OpenRouter
22
+
23
+ ```bash
24
+ iola ai key set openrouter
25
+ iola ai setup openrouter --model openai/gpt-4.1-mini
26
+ iola ai models openrouter --search qwen
27
+ ```
28
+
29
+ ## Codex CLI
30
+
31
+ ```bash
32
+ codex login
33
+ iola ai setup codex --model gpt-5.5
34
+ iola setup codex
35
+ iola ask "проверь данные школы 29" --profile codex
36
+ ```
37
+
38
+ ## Переключение
39
+
40
+ ```bash
41
+ iola ai profiles
42
+ iola ai profile use local
43
+ iola ai profile use openrouter
44
+ ```
package/wiki/Home.md ADDED
@@ -0,0 +1,32 @@
1
+ # iola-cli
2
+
3
+ `iola-cli` - терминальный инструмент и AI-агент для работы с открытыми данными городского округа "Город Йошкар-Ола".
4
+
5
+ Основные сценарии:
6
+
7
+ - поиск школ и детских садов;
8
+ - просмотр карточек организаций;
9
+ - локальная синхронизация данных в SQLite;
10
+ - проверка качества данных;
11
+ - выгрузка CSV/JSON;
12
+ - работа с локальной моделью Ollama;
13
+ - работа с OpenAI, OpenRouter и Codex CLI;
14
+ - подключение публичного MCP-сервера.
15
+
16
+ Быстрый старт:
17
+
18
+ ```bash
19
+ npm install -g @iola_adm/iola-cli
20
+ iola init
21
+ iola search "Петрова"
22
+ iola ask "найди школу 29"
23
+ ```
24
+
25
+ Подробные страницы:
26
+
27
+ - [Установка](Установка)
28
+ - [Первый запуск](Первый-запуск)
29
+ - [AI-профили](AI-профили)
30
+ - [Локальный инструментальный агент](Локальный-инструментальный-агент)
31
+ - [Команды](Команды)
32
+ - [Решение проблем](Решение-проблем)
@@ -0,0 +1,60 @@
1
+ # Commands
2
+
3
+ Основные команды:
4
+
5
+ ```bash
6
+ iola --help
7
+ iola init
8
+ iola doctor
9
+ iola update
10
+ iola version --check
11
+ ```
12
+
13
+ Данные:
14
+
15
+ ```bash
16
+ iola search "Петрова"
17
+ iola search "Петрова" --local --fts
18
+ iola schools --limit 10
19
+ iola kindergartens --search "29"
20
+ iola card "школа 29"
21
+ iola data schools --format csv --output schools.csv
22
+ ```
23
+
24
+ Локальная БД:
25
+
26
+ ```bash
27
+ iola db status
28
+ iola sync
29
+ iola sync status
30
+ iola diff
31
+ iola quality
32
+ ```
33
+
34
+ AI:
35
+
36
+ ```bash
37
+ iola ask "найди школу 29"
38
+ iola ask "найди школу 29" --schema json
39
+ iola ask "найди школу 29" --bare --quiet
40
+ iola agents list
41
+ iola agents run quality-checker "проверь школы"
42
+ ```
43
+
44
+ Интерактивный режим:
45
+
46
+ ```bash
47
+ iola agent
48
+ ```
49
+
50
+ Внутри agent:
51
+
52
+ ```text
53
+ /help
54
+ /tools
55
+ /memory show
56
+ /permissions
57
+ /quality
58
+ /sync
59
+ /exit
60
+ ```
@@ -0,0 +1,36 @@
1
+ # Local tool-agent
2
+
3
+ Режим `--tools` предназначен для локальных моделей Ollama. Маленькая модель не выполняет действия напрямую, а предлагает JSON-план. CLI проверяет план и выполняет только разрешенные встроенные tools.
4
+
5
+ Пример:
6
+
7
+ ```bash
8
+ iola ask "выгрузи школы на Петрова в csv" --profile local --tools
9
+ iola ask "найди детсады без телефона" --profile local --tools --reasoning verify
10
+ ```
11
+
12
+ Доступные tools:
13
+
14
+ - `search_local`
15
+ - `get_card`
16
+ - `export_data`
17
+ - `run_report`
18
+ - `save_view`
19
+
20
+ Управление разрешениями:
21
+
22
+ ```bash
23
+ iola permissions list
24
+ iola permissions deny export_data
25
+ iola permissions allow export_data
26
+ ```
27
+
28
+ Режимы планирования:
29
+
30
+ ```bash
31
+ --reasoning fast
32
+ --reasoning verify
33
+ --reasoning vote
34
+ ```
35
+
36
+ Если Ollama недоступна или модель вернула некорректный JSON, CLI использует fallback-планировщик для типовых запросов.
@@ -0,0 +1,38 @@
1
+ # First run
2
+
3
+ Первый запуск:
4
+
5
+ ```bash
6
+ iola init
7
+ ```
8
+
9
+ Команда проверит:
10
+
11
+ - Node.js и npm;
12
+ - локальную SQLite-БД;
13
+ - доступность публичного API/MCP;
14
+ - Ollama и подходящую локальную модель;
15
+ - обновления npm-пакета.
16
+
17
+ Диагностика:
18
+
19
+ ```bash
20
+ iola doctor
21
+ iola doctor --summary
22
+ iola ai doctor
23
+ iola db status
24
+ ```
25
+
26
+ Синхронизация локальной базы:
27
+
28
+ ```bash
29
+ iola sync
30
+ iola sync status
31
+ ```
32
+
33
+ Проверка данных:
34
+
35
+ ```bash
36
+ iola quality
37
+ iola diff
38
+ ```
@@ -0,0 +1,47 @@
1
+ # Troubleshooting
2
+
3
+ ## Команда iola не найдена
4
+
5
+ Проверьте глобальную установку:
6
+
7
+ ```bash
8
+ npm install -g @iola_adm/iola-cli
9
+ npm bin -g
10
+ ```
11
+
12
+ ## Нужна новая версия Node.js
13
+
14
+ ```bash
15
+ node --version
16
+ iola init --upgrade-node
17
+ ```
18
+
19
+ ## Ollama недоступна
20
+
21
+ ```bash
22
+ ollama --version
23
+ ollama serve
24
+ iola ai doctor
25
+ ```
26
+
27
+ ## OpenAI/OpenRouter key не найден
28
+
29
+ ```bash
30
+ iola ai key status
31
+ iola ai key set openai
32
+ iola ai key set openrouter
33
+ ```
34
+
35
+ ## Нет локальных данных
36
+
37
+ ```bash
38
+ iola sync
39
+ iola sync status
40
+ iola search "школа" --local
41
+ ```
42
+
43
+ ## Нужен подробный лог
44
+
45
+ ```bash
46
+ iola --debug --debug-file iola-debug.log doctor
47
+ ```
@@ -0,0 +1,58 @@
1
+ # Installation
2
+
3
+ ## Требования
4
+
5
+ Нужен Node.js `22.5.0` или новее:
6
+
7
+ ```bash
8
+ node --version
9
+ npm --version
10
+ ```
11
+
12
+ Установка Node.js:
13
+
14
+ ```bash
15
+ # Windows
16
+ winget install OpenJS.NodeJS.LTS
17
+
18
+ # macOS
19
+ brew install node
20
+
21
+ # Linux
22
+ curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
23
+ sudo apt-get install -y nodejs
24
+ ```
25
+
26
+ ## Установка CLI
27
+
28
+ ```bash
29
+ npm install -g @iola_adm/iola-cli
30
+ iola --help
31
+ ```
32
+
33
+ Без глобальной установки:
34
+
35
+ ```bash
36
+ npx -y @iola_adm/iola-cli init
37
+ ```
38
+
39
+ ## Ollama для локальной модели
40
+
41
+ ```bash
42
+ # Windows
43
+ winget install Ollama.Ollama
44
+
45
+ # macOS
46
+ brew install --cask ollama
47
+
48
+ # Linux
49
+ curl -fsSL https://ollama.com/install.sh | sh
50
+ ```
51
+
52
+ Проверка:
53
+
54
+ ```bash
55
+ ollama --version
56
+ iola ai doctor
57
+ iola ai setup ollama
58
+ ```