@iola_adm/iola-cli 0.1.12 → 0.1.13

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
@@ -87,6 +87,10 @@ iola agent
87
87
  iola chat
88
88
  iola init
89
89
  iola doctor
90
+ iola db status
91
+ iola db init
92
+ iola history --limit 20
93
+ iola history clear
90
94
  iola config get
91
95
  iola config set api.baseUrl https://apiiola.yasg.ru/api/v1
92
96
  iola config reset
@@ -139,6 +143,7 @@ iola agent
139
143
  /help
140
144
  /health
141
145
  /doctor
146
+ /db status
142
147
  /config get
143
148
  /layers
144
149
  /data schools --limit 10
@@ -160,6 +165,7 @@ iola agent
160
165
  /provider
161
166
  /config
162
167
  /history
168
+ /history --limit 20
163
169
  /clear
164
170
  /update
165
171
  /init
@@ -271,6 +277,34 @@ CLI дает прямой терминальный доступ к открыт
271
277
  командам подключения MCP/skill, AI-запросам через Ollama/OpenAI/OpenRouter,
272
278
  интерактивному агентному режиму, экспорту данных и проверке обновлений.
273
279
 
280
+ ## Локальная SQLite-БД
281
+
282
+ CLI использует встроенный `node:sqlite` и хранит локальную БД в профиле
283
+ пользователя:
284
+
285
+ ```text
286
+ %USERPROFILE%\.iola\iola.db
287
+ ```
288
+
289
+ БД создается автоматически при установке npm-пакета и при `iola init`.
290
+ В ней хранятся история AI-запросов, контекст ответа, ошибки выполнения,
291
+ служебная таблица версии схемы, а также подготовлены таблицы для кеша API и
292
+ сохраненных выборок.
293
+
294
+ Команды:
295
+
296
+ ```bash
297
+ iola db status
298
+ iola db init
299
+ iola db reset
300
+ iola history --limit 20
301
+ iola history --json
302
+ iola history clear
303
+ ```
304
+
305
+ Ключи OpenAI/OpenRouter в SQLite не сохраняются. Они остаются в локальном
306
+ `secrets.json` или в переменных окружения.
307
+
274
308
  ## Переменные окружения
275
309
 
276
310
  ```bash
package/bin/iola.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env -S node --no-warnings
2
2
 
3
3
  import { main } from "../src/cli.js";
4
4
 
@@ -6,4 +6,3 @@ main(process.argv.slice(2)).catch((error) => {
6
6
  console.error(error instanceof Error ? error.message : String(error));
7
7
  process.exitCode = 1;
8
8
  });
9
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "CLI и AI-агент для работы с открытыми данными городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
@@ -15,6 +15,11 @@
15
15
  "bin": {
16
16
  "iola": "bin/iola.js"
17
17
  },
18
+ "scripts": {
19
+ "postinstall": "node --no-warnings bin/iola.js db init --silent || true",
20
+ "start": "node --no-warnings bin/iola.js",
21
+ "test": "node --no-warnings --check bin/iola.js && node --no-warnings --check src/cli.js"
22
+ },
18
23
  "files": [
19
24
  "bin",
20
25
  "src",
@@ -25,10 +30,6 @@
25
30
  "publishConfig": {
26
31
  "access": "public"
27
32
  },
28
- "scripts": {
29
- "start": "node bin/iola.js",
30
- "test": "node --check bin/iola.js && node --check src/cli.js"
31
- },
32
33
  "keywords": [
33
34
  "yoshkar-ola",
34
35
  "open-data",
package/src/cli.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { mkdirSync } from "node:fs";
2
3
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
  import readline from "node:readline/promises";
6
7
  import { stdin as input, stdout as output } from "node:process";
8
+ import { DatabaseSync } from "node:sqlite";
7
9
 
8
10
  const API_BASE_URL = process.env.IOLA_API_BASE_URL || "https://apiiola.yasg.ru/api/v1";
9
11
  const MCP_BASE_URL = process.env.IOLA_MCP_BASE_URL || "https://apiiola.yasg.ru";
@@ -11,6 +13,8 @@ const MIN_NODE_VERSION = "22.5.0";
11
13
  const CONFIG_DIR = path.join(os.homedir(), ".iola");
12
14
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
13
15
  const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
16
+ const DB_FILE = path.join(CONFIG_DIR, "iola.db");
17
+ const DB_SCHEMA_VERSION = 1;
14
18
  const DEFAULT_AI_CONFIG = {
15
19
  api: {
16
20
  baseUrl: "https://apiiola.yasg.ru/api/v1",
@@ -76,6 +80,8 @@ const COMMANDS = new Map([
76
80
  ["version", showVersion],
77
81
  ["update", checkUpdate],
78
82
  ["doctor", doctor],
83
+ ["db", handleDb],
84
+ ["history", handleHistory],
79
85
  ["config", handleConfig],
80
86
  ["banner", showBanner],
81
87
  ["agent", startAgent],
@@ -119,6 +125,10 @@ Usage:
119
125
  iola chat
120
126
  iola init
121
127
  iola doctor
128
+ iola db status
129
+ iola db init
130
+ iola history [--limit 20]
131
+ iola history clear
122
132
  iola config get
123
133
  iola config set api.baseUrl URL
124
134
  iola config set api.mcpBaseUrl URL
@@ -225,7 +235,16 @@ async function handleAgentLine(line, state) {
225
235
  }
226
236
 
227
237
  if (command === "history") {
228
- printAgentHistory(state.history);
238
+ if (args.length > 0) {
239
+ await handleHistory(args);
240
+ } else {
241
+ printAgentHistory(state.history);
242
+ }
243
+ return false;
244
+ }
245
+
246
+ if (command === "db") {
247
+ await handleDb(args);
229
248
  return false;
230
249
  }
231
250
 
@@ -307,6 +326,8 @@ async function handleAgentLine(line, state) {
307
326
  const mapped = {
308
327
  health: ["health", args],
309
328
  doctor: ["doctor", args],
329
+ db: ["db", args],
330
+ history: ["history", args],
310
331
  config: ["config", args],
311
332
  layers: ["layers", args],
312
333
  data: ["data", args],
@@ -333,6 +354,7 @@ function printAgentHelp() {
333
354
  /help
334
355
  /health
335
356
  /doctor
357
+ /db status
336
358
  /config get
337
359
  /config set api.baseUrl URL
338
360
  /layers
@@ -357,6 +379,7 @@ function printAgentHelp() {
357
379
  /provider
358
380
  /model
359
381
  /history
382
+ /history --limit 20
360
383
  /clear
361
384
  /banner
362
385
  /update
@@ -474,6 +497,7 @@ async function doctor(args = []) {
474
497
  nodeRequired: `>=${MIN_NODE_VERSION}`,
475
498
  nodeStatus: getNodeRequirementStatus().ok ? "ok" : "upgrade-required",
476
499
  },
500
+ db: getDbStatus(),
477
501
  api: {
478
502
  baseUrl: apiBaseUrl,
479
503
  mcpBaseUrl,
@@ -499,6 +523,9 @@ async function doctor(args = []) {
499
523
  console.log("CLI");
500
524
  printKeyValue(report.cli);
501
525
  console.log("");
526
+ console.log("SQLite");
527
+ printKeyValue(report.db);
528
+ console.log("");
502
529
  console.log("API/MCP");
503
530
  printKeyValue(report.api);
504
531
  console.log("");
@@ -615,6 +642,8 @@ async function initCli(args = []) {
615
642
 
616
643
  showBanner();
617
644
  console.log("Проверка окружения");
645
+ initDatabase();
646
+ const dbStatus = getDbStatus();
618
647
  printKeyValue({
619
648
  node: process.version,
620
649
  node_required: `>=${MIN_NODE_VERSION}`,
@@ -622,6 +651,8 @@ async function initCli(args = []) {
622
651
  npm: await getCommandVersion("npm", ["--version"]),
623
652
  api: await probeEndpoint(`${await getMcpBaseUrl()}/mcp-health`),
624
653
  mcp: await getMcpBaseUrl(),
654
+ sqlite: dbStatus.status,
655
+ sqlite_file: dbStatus.file,
625
656
  });
626
657
  console.log("");
627
658
 
@@ -763,6 +794,65 @@ async function handleConfig(args) {
763
794
  throw new Error("Команды config: get, set, reset.");
764
795
  }
765
796
 
797
+ async function handleDb(args) {
798
+ const [action = "status"] = args;
799
+ const options = parseOptions(args);
800
+
801
+ if (action === "init") {
802
+ initDatabase();
803
+ if (!options.silent) {
804
+ console.log(`SQLite-БД готова: ${DB_FILE}`);
805
+ }
806
+ return;
807
+ }
808
+
809
+ if (action === "status") {
810
+ printKeyValue(getDbStatus());
811
+ return;
812
+ }
813
+
814
+ if (action === "reset") {
815
+ const shouldReset = await confirm("Удалить локальную SQLite-БД iola.db? [y/N] ");
816
+ if (!shouldReset) {
817
+ console.log("Сброс отменен.");
818
+ return;
819
+ }
820
+ await rm(DB_FILE, { force: true });
821
+ initDatabase();
822
+ console.log(`SQLite-БД пересоздана: ${DB_FILE}`);
823
+ return;
824
+ }
825
+
826
+ throw new Error("Команды db: status, init, reset.");
827
+ }
828
+
829
+ async function handleHistory(args) {
830
+ const [action] = args;
831
+ const options = parseOptions(args);
832
+
833
+ if (action === "clear") {
834
+ clearHistory();
835
+ console.log("История очищена.");
836
+ return;
837
+ }
838
+
839
+ const rows = listHistory(Number(options.limit || 20));
840
+
841
+ if (options.json) {
842
+ printJson(rows);
843
+ return;
844
+ }
845
+
846
+ printTable(rows, [
847
+ ["id", "ID"],
848
+ ["created_at", "Дата"],
849
+ ["profile", "Профиль"],
850
+ ["provider", "Провайдер"],
851
+ ["question", "Вопрос"],
852
+ ["answer", "Ответ"],
853
+ ]);
854
+ }
855
+
766
856
  async function aiDoctor(args) {
767
857
  const options = parseOptions(args);
768
858
  const diagnostics = await getLocalDiagnostics();
@@ -1250,6 +1340,142 @@ async function deleteAiKey(provider) {
1250
1340
  console.log(`Локальный ключ ${provider} удален.`);
1251
1341
  }
1252
1342
 
1343
+ function openDatabase() {
1344
+ const db = new DatabaseSync(DB_FILE);
1345
+ db.exec("PRAGMA busy_timeout = 5000;");
1346
+ return db;
1347
+ }
1348
+
1349
+ function initDatabase() {
1350
+ mkdirSyncSafe(CONFIG_DIR);
1351
+ const db = openDatabase();
1352
+ try {
1353
+ db.exec(`
1354
+ CREATE TABLE IF NOT EXISTS meta (
1355
+ key TEXT PRIMARY KEY,
1356
+ value TEXT NOT NULL
1357
+ );
1358
+ CREATE TABLE IF NOT EXISTS ask_history (
1359
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1360
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1361
+ profile TEXT,
1362
+ provider TEXT,
1363
+ model TEXT,
1364
+ question TEXT NOT NULL,
1365
+ answer TEXT NOT NULL,
1366
+ context_json TEXT NOT NULL,
1367
+ error TEXT
1368
+ );
1369
+ CREATE INDEX IF NOT EXISTS idx_ask_history_created_at ON ask_history(created_at DESC);
1370
+ CREATE TABLE IF NOT EXISTS api_cache (
1371
+ key TEXT PRIMARY KEY,
1372
+ url TEXT NOT NULL,
1373
+ response_json TEXT NOT NULL,
1374
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1375
+ expires_at TEXT
1376
+ );
1377
+ CREATE TABLE IF NOT EXISTS saved_views (
1378
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1379
+ name TEXT NOT NULL UNIQUE,
1380
+ dataset TEXT NOT NULL,
1381
+ query_json TEXT NOT NULL,
1382
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
1383
+ );
1384
+ `);
1385
+ db.prepare(`
1386
+ INSERT INTO meta(key, value) VALUES ('schema_version', ?)
1387
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
1388
+ `).run(String(DB_SCHEMA_VERSION));
1389
+ } finally {
1390
+ db.close();
1391
+ }
1392
+ }
1393
+
1394
+ function mkdirSyncSafe(directory) {
1395
+ try {
1396
+ mkdirSync(directory, { recursive: true });
1397
+ } catch {
1398
+ // Directory creation is retried by write operations where needed.
1399
+ }
1400
+ }
1401
+
1402
+ function getDbStatus() {
1403
+ try {
1404
+ initDatabase();
1405
+ const db = openDatabase();
1406
+ try {
1407
+ const schema = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
1408
+ const history = db.prepare("SELECT COUNT(*) AS count FROM ask_history").get();
1409
+ return {
1410
+ status: "ok",
1411
+ file: DB_FILE,
1412
+ schema: schema?.value || "-",
1413
+ history: history?.count ?? 0,
1414
+ };
1415
+ } finally {
1416
+ db.close();
1417
+ }
1418
+ } catch (error) {
1419
+ return {
1420
+ status: "error",
1421
+ file: DB_FILE,
1422
+ schema: "-",
1423
+ history: "-",
1424
+ error: error instanceof Error ? error.message : String(error),
1425
+ };
1426
+ }
1427
+ }
1428
+
1429
+ function recordAskHistory({ question, answer, providerConfig, dataContext, error }) {
1430
+ try {
1431
+ initDatabase();
1432
+ const db = openDatabase();
1433
+ try {
1434
+ db.prepare(`
1435
+ INSERT INTO ask_history(profile, provider, model, question, answer, context_json, error)
1436
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1437
+ `).run(
1438
+ providerConfig.name || "",
1439
+ providerConfig.provider || "",
1440
+ providerConfig.model || "",
1441
+ question,
1442
+ answer,
1443
+ JSON.stringify(dataContext),
1444
+ error || "",
1445
+ );
1446
+ } finally {
1447
+ db.close();
1448
+ }
1449
+ } catch {
1450
+ // History must never break the main answer path.
1451
+ }
1452
+ }
1453
+
1454
+ function listHistory(limit) {
1455
+ initDatabase();
1456
+ const db = openDatabase();
1457
+ try {
1458
+ return db.prepare(`
1459
+ SELECT id, created_at, profile, provider, model, question, answer, error
1460
+ FROM ask_history
1461
+ ORDER BY id DESC
1462
+ LIMIT ?
1463
+ `).all(limit);
1464
+ } finally {
1465
+ db.close();
1466
+ }
1467
+ }
1468
+
1469
+ function clearHistory() {
1470
+ initDatabase();
1471
+ const db = openDatabase();
1472
+ try {
1473
+ db.exec("DELETE FROM ask_history");
1474
+ } finally {
1475
+ db.close();
1476
+ }
1477
+ }
1478
+
1253
1479
  function assertKeyProvider(provider) {
1254
1480
  if (provider !== "openai" && provider !== "openrouter") {
1255
1481
  throw new Error("Провайдер должен быть openai или openrouter.");
@@ -1341,7 +1567,18 @@ async function aiAsk(args, context = {}) {
1341
1567
  const providerConfig = resolveAiProfile(config, options);
1342
1568
  const dataContext = await buildDataContext(question);
1343
1569
  const messages = buildAiMessages(question, dataContext, context.history || []);
1344
- const answer = await callAiProvider(providerConfig, messages);
1570
+ let answer = "";
1571
+ let errorMessage = "";
1572
+
1573
+ try {
1574
+ answer = await callAiProvider(providerConfig, messages);
1575
+ } catch (error) {
1576
+ errorMessage = error instanceof Error ? error.message : String(error);
1577
+ recordAskHistory({ question, answer: "", providerConfig, dataContext, error: errorMessage });
1578
+ throw error;
1579
+ }
1580
+
1581
+ recordAskHistory({ question, answer, providerConfig, dataContext, error: "" });
1345
1582
 
1346
1583
  console.log(answer);
1347
1584
  return answer;
@@ -1825,7 +2062,7 @@ function parseOptions(args) {
1825
2062
 
1826
2063
  for (let index = 0; index < args.length; index += 1) {
1827
2064
  const arg = args[index];
1828
- if (arg === "--json" || arg === "--yes") {
2065
+ if (arg === "--json" || arg === "--yes" || arg === "--silent") {
1829
2066
  result[arg.slice(2)] = true;
1830
2067
  } else if (arg === "--check" || arg === "--upgrade-node") {
1831
2068
  result.check = true;