@iola_adm/iola-cli 0.1.14 → 0.1.17

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
@@ -99,6 +99,26 @@ iola features enable api-cache
99
99
  iola mcp status
100
100
  iola mcp list
101
101
  iola mcp install codex
102
+ iola cache status
103
+ iola cache warm
104
+ iola cache clear
105
+ iola sync
106
+ iola sync status
107
+ iola diff schools
108
+ iola search "Петрова" --local
109
+ iola search "Петрова" --local --fts
110
+ iola card "школа 29"
111
+ iola quality
112
+ iola quality missing-phones
113
+ iola data schools --where address=Петрова --save schools-petrova
114
+ iola views
115
+ iola view schools-petrova --format csv --output schools-petrova.csv
116
+ iola views delete schools-petrova
117
+ iola report missing-phones
118
+ iola privacy
119
+ iola backup create
120
+ iola alias add petrova "data schools --where address=Петрова --columns name,address,phone"
121
+ iola run "выгрузи школы на Петрова в csv"
102
122
  iola config get
103
123
  iola config set api.baseUrl https://apiiola.yasg.ru/api/v1
104
124
  iola config reset
@@ -107,6 +127,8 @@ iola version --check
107
127
  iola ask "Найди школу 29"
108
128
  iola ask "Найди школу 29" --profile codex --events --output answer.txt
109
129
  iola ask "Найди школу 29" --schema json --no-history
130
+ iola ask "выгрузи школы на Петрова в csv" --profile local --tools --reasoning verify
131
+ iola data schools --format csv --output schools.csv
110
132
  iola data schools --limit 10
111
133
  iola data kindergartens --search "29"
112
134
  iola data schools --where address=Петрова --columns name,address,phone
@@ -350,6 +372,102 @@ iola ask "Найди школу 29" --schema json
350
372
  iola ask "Найди школу 29" --output answer.txt
351
373
  ```
352
374
 
375
+ ## Кеш, локальный поиск и выборки
376
+
377
+ API-ответы можно кешировать локально:
378
+
379
+ ```bash
380
+ iola cache status
381
+ iola cache warm
382
+ iola cache clear
383
+ iola data schools --cache
384
+ ```
385
+
386
+ Локальная синхронизация сохраняет открытые слои в SQLite и позволяет искать без
387
+ повторного обращения к API:
388
+
389
+ ```bash
390
+ iola sync
391
+ iola sync status
392
+ iola diff
393
+ iola search "Петрова" --local
394
+ iola search "школа Петрова" --local --fts
395
+ iola data schools --local --search "лицей"
396
+ ```
397
+
398
+ Сохраненные выборки:
399
+
400
+ ```bash
401
+ iola data schools --where address=Петрова --columns name,address,phone --save schools-petrova
402
+ iola views
403
+ iola view schools-petrova
404
+ iola view schools-petrova --format csv --output schools-petrova.csv
405
+ iola views delete schools-petrova
406
+ ```
407
+
408
+ Отчеты, backup и алиасы:
409
+
410
+ ```bash
411
+ iola report schools-summary
412
+ iola report education-contacts
413
+ iola report missing-phones
414
+ iola report licenses
415
+ iola privacy
416
+ iola backup create
417
+ iola alias add petrova "data schools --where address=Петрова --columns name,address,phone"
418
+ iola petrova
419
+ iola run "выгрузи школы на Петрова в csv"
420
+ ```
421
+
422
+ ## Локальный tool-agent для слабых моделей
423
+
424
+ Для локального профиля Ollama доступен режим `--tools`. Он сделан специально
425
+ для маленьких моделей, которые хуже отвечают свободным текстом, но могут быть
426
+ полезны как планировщик действий.
427
+
428
+ В этом режиме CLI не доверяет модели напрямую. Модель предлагает JSON-план,
429
+ CLI валидирует список разрешенных tools и сам выполняет действия через
430
+ проверенные локальные функции:
431
+
432
+ - `search_local`
433
+ - `get_card`
434
+ - `export_data`
435
+ - `run_report`
436
+ - `save_view`
437
+
438
+ Пример:
439
+
440
+ ```bash
441
+ iola ask "выгрузи школы на Петрова в csv" --profile local --tools
442
+ iola ask "найди детсады без телефона" --profile local --tools --reasoning verify
443
+ ```
444
+
445
+ Режимы:
446
+
447
+ ```bash
448
+ --reasoning fast # один план
449
+ --reasoning verify # план с валидацией результата
450
+ --reasoning vote # несколько вариантов, выбирается валидный
451
+ ```
452
+
453
+ OpenAI, OpenRouter и Codex работают как раньше. `--tools` применяется только
454
+ к локальному Ollama-профилю, чтобы не менять поведение внешних провайдеров.
455
+
456
+ Карточки, качество данных и изменения:
457
+
458
+ ```bash
459
+ iola card schools 1215067180
460
+ iola card "школа 29"
461
+ iola quality
462
+ iola quality schools
463
+ iola quality missing-phones
464
+ iola quality invalid-emails
465
+ iola quality duplicate-inn
466
+ iola sync status
467
+ iola diff
468
+ iola diff schools
469
+ ```
470
+
353
471
  ## Переменные окружения
354
472
 
355
473
  ```bash
package/bin/iola.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.14",
3
+ "version": "0.1.17",
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
@@ -95,6 +95,18 @@ const COMMANDS = new Map([
95
95
  ["fork", forkSession],
96
96
  ["features", handleFeatures],
97
97
  ["mcp", handleMcp],
98
+ ["cache", handleCache],
99
+ ["sync", handleSync],
100
+ ["diff", handleDiff],
101
+ ["views", handleViews],
102
+ ["view", handleView],
103
+ ["card", handleCard],
104
+ ["quality", handleQuality],
105
+ ["report", handleReport],
106
+ ["privacy", handlePrivacy],
107
+ ["backup", handleBackup],
108
+ ["alias", handleAlias],
109
+ ["run", runNaturalLanguage],
98
110
  ["config", handleConfig],
99
111
  ["banner", showBanner],
100
112
  ["agent", startAgent],
@@ -122,6 +134,11 @@ export async function main(argv) {
122
134
  const handler = COMMANDS.get(command);
123
135
 
124
136
  if (!handler) {
137
+ const alias = getAlias(command);
138
+ if (alias) {
139
+ await main([...splitCommandLine(alias.command), ...args]);
140
+ return;
141
+ }
125
142
  throw new Error(`Unknown command: ${command}\nRun "iola help" to see available commands.`);
126
143
  }
127
144
 
@@ -147,12 +164,26 @@ Usage:
147
164
  iola fork SESSION_ID [TEXT]
148
165
  iola features list|enable|disable
149
166
  iola mcp list|status|install|remove
167
+ iola cache status|warm|clear
168
+ iola sync [--dataset schools|kindergartens]
169
+ iola sync status
170
+ iola diff [schools|kindergartens]
171
+ iola card schools 1215067180
172
+ iola card "школа 29"
173
+ iola quality [schools|kindergartens|missing-phones|invalid-emails|duplicate-inn]
174
+ iola views
175
+ iola view NAME [--format table|json|csv] [--output FILE]
176
+ iola report schools-summary|education-contacts|missing-phones|licenses
177
+ iola privacy
178
+ iola backup create
179
+ iola alias add NAME COMMAND
180
+ iola run "выгрузи школы на Петрова в csv"
150
181
  iola config get
151
182
  iola config set api.baseUrl URL
152
183
  iola config set api.mcpBaseUrl URL
153
184
  iola config reset
154
185
  iola update
155
- iola ask TEXT [--profile NAME] [--model MODEL] [--output FILE] [--schema json|table] [--events] [--no-history]
186
+ iola ask TEXT [--profile NAME] [--model MODEL] [--tools] [--reasoning fast|verify|vote] [--output FILE] [--schema json|table] [--events] [--no-history]
156
187
  iola data LAYER [--limit 10] [--search TEXT] [--where FIELD=VALUE] [--columns a,b,c] [--format table|json|csv]
157
188
  iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
158
189
  iola ai context TEXT [--json]
@@ -291,6 +322,21 @@ async function handleAgentLine(line, state) {
291
322
  return false;
292
323
  }
293
324
 
325
+ if (command === "cache") {
326
+ await handleCache(args);
327
+ return false;
328
+ }
329
+
330
+ if (command === "sync") {
331
+ await handleSync(args);
332
+ return false;
333
+ }
334
+
335
+ if (command === "diff" || command === "card" || command === "quality" || command === "views" || command === "view" || command === "report" || command === "privacy" || command === "backup" || command === "alias" || command === "run") {
336
+ await COMMANDS.get(command)(args);
337
+ return false;
338
+ }
339
+
294
340
  if (command === "config") {
295
341
  await handleConfig(args.length > 0 ? args : ["get"]);
296
342
  return false;
@@ -376,6 +422,9 @@ async function handleAgentLine(line, state) {
376
422
  fork: ["fork", args],
377
423
  features: ["features", args],
378
424
  mcp: ["mcp", args],
425
+ cache: ["cache", args],
426
+ sync: ["sync", args],
427
+ diff: ["diff", args],
379
428
  config: ["config", args],
380
429
  layers: ["layers", args],
381
430
  data: ["data", args],
@@ -407,6 +456,12 @@ function printAgentHelp() {
407
456
  /resume SESSION_ID
408
457
  /features list
409
458
  /mcp status
459
+ /cache status
460
+ /sync
461
+ /diff
462
+ /card школа 29
463
+ /quality
464
+ /views
410
465
  /config get
411
466
  /config set api.baseUrl URL
412
467
  /layers
@@ -1046,6 +1101,196 @@ async function handleMcp(args) {
1046
1101
  throw new Error("Команды mcp: status, list, install codex, remove codex.");
1047
1102
  }
1048
1103
 
1104
+ async function handleCache(args) {
1105
+ const [action = "status"] = args;
1106
+ if (action === "status") {
1107
+ printKeyValue(getCacheStatus());
1108
+ return;
1109
+ }
1110
+ if (action === "clear") {
1111
+ clearCache();
1112
+ console.log("Кеш очищен.");
1113
+ return;
1114
+ }
1115
+ if (action === "warm") {
1116
+ const result = await warmCache();
1117
+ printKeyValue(result);
1118
+ return;
1119
+ }
1120
+ throw new Error("Команды cache: status, warm, clear.");
1121
+ }
1122
+
1123
+ async function handleSync(args) {
1124
+ const [action] = args;
1125
+ if (action === "status") {
1126
+ printTable(getSyncStatus(), [
1127
+ ["dataset", "Слой"],
1128
+ ["records", "Записей"],
1129
+ ["last_sync", "Последний sync"],
1130
+ ["status", "Статус"],
1131
+ ]);
1132
+ return;
1133
+ }
1134
+ const options = parseOptions(args);
1135
+ const datasets = options.dataset ? [options.dataset] : Object.keys(DATASETS);
1136
+ const rows = [];
1137
+ for (const dataset of datasets) {
1138
+ rows.push(await syncDataset(dataset));
1139
+ }
1140
+ printTable(rows, [
1141
+ ["dataset", "Слой"],
1142
+ ["records", "Записей"],
1143
+ ["status", "Статус"],
1144
+ ["message", "Сообщение"],
1145
+ ]);
1146
+ }
1147
+
1148
+ async function handleDiff(args) {
1149
+ const [dataset] = args;
1150
+ const rows = listSyncChanges(dataset);
1151
+ printTable(rows, [
1152
+ ["created_at", "Дата"],
1153
+ ["dataset", "Слой"],
1154
+ ["change_type", "Тип"],
1155
+ ["record_key", "Ключ"],
1156
+ ["summary", "Сводка"],
1157
+ ]);
1158
+ }
1159
+
1160
+ async function handleCard(args) {
1161
+ await ensureLocalData();
1162
+ const options = parseOptions(args);
1163
+ const query = args.join(" ").trim();
1164
+ if (!query) throw new Error('Пример: iola card "школа 29"');
1165
+ const item = findCard(query);
1166
+ if (!item) throw new Error(`Объект не найден: ${query}`);
1167
+ if (options.json) {
1168
+ printJson(item);
1169
+ return;
1170
+ }
1171
+ printKeyValue(item);
1172
+ }
1173
+
1174
+ async function handleQuality(args) {
1175
+ const [scope = "all"] = args;
1176
+ await ensureLocalData();
1177
+ const rows = runQuality(scope);
1178
+ printTable(rows, [
1179
+ ["check", "Проверка"],
1180
+ ["dataset", "Слой"],
1181
+ ["count", "Кол-во"],
1182
+ ["sample", "Пример"],
1183
+ ]);
1184
+ }
1185
+
1186
+ async function handleViews(args) {
1187
+ const [action, name] = args;
1188
+ if (action === "delete" || action === "remove") {
1189
+ deleteSavedView(name);
1190
+ console.log(`View удален: ${name}`);
1191
+ return;
1192
+ }
1193
+ const rows = listSavedViews();
1194
+ printTable(rows, [
1195
+ ["name", "Имя"],
1196
+ ["dataset", "Слой"],
1197
+ ["created_at", "Создано"],
1198
+ ]);
1199
+ }
1200
+
1201
+ async function handleView(args) {
1202
+ const [name, ...rest] = args;
1203
+ if (!name) {
1204
+ throw new Error("Имя view обязательно.");
1205
+ }
1206
+ const view = getSavedView(name);
1207
+ const query = JSON.parse(view.query_json);
1208
+ await listDataset(view.dataset, [...(query.args || []), ...rest]);
1209
+ }
1210
+
1211
+ async function handleReport(args) {
1212
+ const [name] = args;
1213
+ await ensureLocalData();
1214
+ if (name === "schools-summary") {
1215
+ printTable(getLocalSummaryRows("schools"), [["metric", "Показатель"], ["value", "Значение"]]);
1216
+ return;
1217
+ }
1218
+ if (name === "education-contacts") {
1219
+ printDatasetTable(searchLocalRecords("", { dataset: "all", limit: 500 }), "name,address,phone,email,website");
1220
+ return;
1221
+ }
1222
+ if (name === "missing-phones") {
1223
+ printDatasetTable(searchLocalRecords("", { dataset: "all", limit: 500 }).filter((item) => !item.phone || item.phone === "-"));
1224
+ return;
1225
+ }
1226
+ if (name === "licenses") {
1227
+ printDatasetTable(searchLocalRecords("", { dataset: "all", limit: 500 }), "name,license_number,license_status");
1228
+ return;
1229
+ }
1230
+ throw new Error("Отчеты: schools-summary, education-contacts, missing-phones, licenses.");
1231
+ }
1232
+
1233
+ async function handlePrivacy() {
1234
+ printKeyValue({
1235
+ config: CONFIG_FILE,
1236
+ secrets: SECRETS_FILE,
1237
+ sqlite: DB_FILE,
1238
+ api: await getApiBaseUrl(),
1239
+ mcp: await getMcpBaseUrl(),
1240
+ keys_in_sqlite: "no",
1241
+ history_clear: "iola history clear",
1242
+ db_reset: "iola db reset",
1243
+ delete_openai_key: "iola ai key delete openai",
1244
+ });
1245
+ }
1246
+
1247
+ async function handleBackup(args) {
1248
+ const [action = "create", fileArg] = args;
1249
+ if (action !== "create") {
1250
+ throw new Error("Пока доступно: iola backup create [FILE]");
1251
+ }
1252
+ const file = fileArg || path.join(CONFIG_DIR, `iola-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
1253
+ const payload = {
1254
+ created_at: new Date().toISOString(),
1255
+ config: await loadConfig(),
1256
+ db: exportDbSnapshot(),
1257
+ };
1258
+ await writeFile(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
1259
+ console.log(`Backup создан: ${file}`);
1260
+ }
1261
+
1262
+ async function handleAlias(args) {
1263
+ const [action, name, ...commandParts] = args;
1264
+ if (action === "list" || !action) {
1265
+ printTable(listAliases(), [["name", "Алиас"], ["command", "Команда"]]);
1266
+ return;
1267
+ }
1268
+ if (action === "add") {
1269
+ if (!name || commandParts.length === 0) {
1270
+ throw new Error('Пример: iola alias add petrova "data schools --where address=Петрова"');
1271
+ }
1272
+ saveAlias(name, commandParts.join(" "));
1273
+ console.log(`Алиас сохранен: ${name}`);
1274
+ return;
1275
+ }
1276
+ if (action === "delete" || action === "remove") {
1277
+ deleteAlias(name);
1278
+ console.log(`Алиас удален: ${name}`);
1279
+ return;
1280
+ }
1281
+ throw new Error("Команды alias: list, add NAME COMMAND, delete NAME.");
1282
+ }
1283
+
1284
+ async function runNaturalLanguage(args) {
1285
+ const text = args.join(" ").trim();
1286
+ if (!text) {
1287
+ throw new Error('Пример: iola run "выгрузи школы на Петрова в csv"');
1288
+ }
1289
+ const command = inferCommandFromText(text);
1290
+ console.log(`> iola ${command.join(" ")}`);
1291
+ await main(command);
1292
+ }
1293
+
1049
1294
  async function aiDoctor(args) {
1050
1295
  const options = parseOptions(args);
1051
1296
  const diagnostics = await getLocalDiagnostics();
@@ -1600,6 +1845,39 @@ function initDatabase() {
1600
1845
  query_json TEXT NOT NULL,
1601
1846
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
1602
1847
  );
1848
+ CREATE TABLE IF NOT EXISTS local_records (
1849
+ dataset TEXT NOT NULL,
1850
+ record_key TEXT NOT NULL,
1851
+ record_json TEXT NOT NULL,
1852
+ searchable_text TEXT NOT NULL,
1853
+ synced_at TEXT NOT NULL DEFAULT (datetime('now')),
1854
+ PRIMARY KEY(dataset, record_key)
1855
+ );
1856
+ CREATE INDEX IF NOT EXISTS idx_local_records_dataset ON local_records(dataset);
1857
+ CREATE VIRTUAL TABLE IF NOT EXISTS local_records_fts USING fts5(dataset, record_key, searchable_text);
1858
+ CREATE TABLE IF NOT EXISTS sync_runs (
1859
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1860
+ dataset TEXT NOT NULL,
1861
+ records INTEGER NOT NULL,
1862
+ status TEXT NOT NULL,
1863
+ message TEXT,
1864
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
1865
+ );
1866
+ CREATE TABLE IF NOT EXISTS sync_changes (
1867
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1868
+ dataset TEXT NOT NULL,
1869
+ record_key TEXT NOT NULL,
1870
+ change_type TEXT NOT NULL,
1871
+ old_json TEXT,
1872
+ new_json TEXT,
1873
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
1874
+ );
1875
+ CREATE INDEX IF NOT EXISTS idx_sync_changes_dataset_created_at ON sync_changes(dataset, created_at DESC);
1876
+ CREATE TABLE IF NOT EXISTS aliases (
1877
+ name TEXT PRIMARY KEY,
1878
+ command TEXT NOT NULL,
1879
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
1880
+ );
1603
1881
  `);
1604
1882
  db.prepare(`
1605
1883
  INSERT INTO meta(key, value) VALUES ('schema_version', ?)
@@ -1626,12 +1904,16 @@ function getDbStatus() {
1626
1904
  const schema = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
1627
1905
  const history = db.prepare("SELECT COUNT(*) AS count FROM ask_history").get();
1628
1906
  const sessions = db.prepare("SELECT COUNT(*) AS count FROM sessions").get();
1907
+ const local = db.prepare("SELECT COUNT(*) AS count FROM local_records").get();
1908
+ const cache = db.prepare("SELECT COUNT(*) AS count FROM api_cache").get();
1629
1909
  return {
1630
1910
  status: "ok",
1631
1911
  file: DB_FILE,
1632
1912
  schema: schema?.value || "-",
1633
1913
  history: history?.count ?? 0,
1634
1914
  sessions: sessions?.count ?? 0,
1915
+ local_records: local?.count ?? 0,
1916
+ cache: cache?.count ?? 0,
1635
1917
  };
1636
1918
  } finally {
1637
1919
  db.close();
@@ -1862,6 +2144,399 @@ function setFeatureEnabled(name, enabled) {
1862
2144
  }
1863
2145
  }
1864
2146
 
2147
+ async function fetchJsonMaybeCached(url, options = {}) {
2148
+ if (!options.cache && !isFeatureEnabled("api-cache")) {
2149
+ return fetchJson(url);
2150
+ }
2151
+ const cached = getCachedResponse(url);
2152
+ if (cached) {
2153
+ return cached;
2154
+ }
2155
+ const payload = await fetchJson(url);
2156
+ setCachedResponse(url, payload, 3600);
2157
+ return payload;
2158
+ }
2159
+
2160
+ function getCachedResponse(url) {
2161
+ initDatabase();
2162
+ const db = openDatabase();
2163
+ try {
2164
+ const row = db.prepare("SELECT response_json, expires_at FROM api_cache WHERE key = ?").get(cacheKey(url));
2165
+ if (!row) return null;
2166
+ if (row.expires_at && new Date(row.expires_at) < new Date()) return null;
2167
+ return JSON.parse(row.response_json);
2168
+ } finally {
2169
+ db.close();
2170
+ }
2171
+ }
2172
+
2173
+ function setCachedResponse(url, payload, ttlSeconds) {
2174
+ initDatabase();
2175
+ const db = openDatabase();
2176
+ try {
2177
+ const expires = new Date(Date.now() + ttlSeconds * 1000).toISOString();
2178
+ db.prepare(`
2179
+ INSERT INTO api_cache(key, url, response_json, expires_at, created_at)
2180
+ VALUES (?, ?, ?, ?, datetime('now'))
2181
+ ON CONFLICT(key) DO UPDATE SET response_json = excluded.response_json, expires_at = excluded.expires_at, created_at = excluded.created_at
2182
+ `).run(cacheKey(url), url, JSON.stringify(payload), expires);
2183
+ } finally {
2184
+ db.close();
2185
+ }
2186
+ }
2187
+
2188
+ function cacheKey(url) {
2189
+ return Buffer.from(url).toString("base64url");
2190
+ }
2191
+
2192
+ function getCacheStatus() {
2193
+ initDatabase();
2194
+ const db = openDatabase();
2195
+ try {
2196
+ const row = db.prepare("SELECT COUNT(*) AS count FROM api_cache").get();
2197
+ return { status: "ok", entries: row?.count ?? 0 };
2198
+ } finally {
2199
+ db.close();
2200
+ }
2201
+ }
2202
+
2203
+ function clearCache() {
2204
+ initDatabase();
2205
+ const db = openDatabase();
2206
+ try {
2207
+ db.exec("DELETE FROM api_cache");
2208
+ } finally {
2209
+ db.close();
2210
+ }
2211
+ }
2212
+
2213
+ async function warmCache() {
2214
+ const urls = [
2215
+ `${await getMcpBaseUrl()}/mcp-version`,
2216
+ `${await getMcpBaseUrl()}/mcp-health`,
2217
+ `${await getApiBaseUrl()}/schools?limit=100&offset=0`,
2218
+ `${await getApiBaseUrl()}/kindergartens?limit=100&offset=0`,
2219
+ ];
2220
+ for (const url of urls) {
2221
+ setCachedResponse(url, await fetchJson(url), 3600);
2222
+ }
2223
+ return { status: "ok", entries: urls.length };
2224
+ }
2225
+
2226
+ async function syncDataset(dataset) {
2227
+ if (!DATASETS[dataset]) {
2228
+ throw new Error(`Неизвестный слой: ${dataset}`);
2229
+ }
2230
+ try {
2231
+ const payload = await fetchJson(`${await getApiBaseUrl()}/${DATASETS[dataset].endpoint}?limit=500&offset=0`);
2232
+ const items = normalizeItems(payload);
2233
+ saveLocalRecords(dataset, items);
2234
+ recordSyncRun(dataset, items.length, "ok", "");
2235
+ return { dataset, records: items.length, status: "ok", message: "" };
2236
+ } catch (error) {
2237
+ const message = error instanceof Error ? error.message : String(error);
2238
+ recordSyncRun(dataset, 0, "error", message);
2239
+ return { dataset, records: 0, status: "error", message };
2240
+ }
2241
+ }
2242
+
2243
+ function saveLocalRecords(dataset, items) {
2244
+ initDatabase();
2245
+ const db = openDatabase();
2246
+ try {
2247
+ const oldRows = db.prepare("SELECT record_key, record_json FROM local_records WHERE dataset = ?").all(dataset);
2248
+ const oldMap = new Map(oldRows.map((row) => [row.record_key, row.record_json]));
2249
+ const newKeys = new Set();
2250
+ db.prepare("DELETE FROM local_records WHERE dataset = ?").run(dataset);
2251
+ db.prepare("DELETE FROM local_records_fts WHERE dataset = ?").run(dataset);
2252
+ const insert = db.prepare("INSERT INTO local_records(dataset, record_key, record_json, searchable_text, synced_at) VALUES (?, ?, ?, ?, datetime('now'))");
2253
+ const insertFts = db.prepare("INSERT INTO local_records_fts(dataset, record_key, searchable_text) VALUES (?, ?, ?)");
2254
+ const insertChange = db.prepare("INSERT INTO sync_changes(dataset, record_key, change_type, old_json, new_json) VALUES (?, ?, ?, ?, ?)");
2255
+ for (const item of items) {
2256
+ const summary = selectPublicSummary(item);
2257
+ const key = String(summary.inn || item.id || `${dataset}-${Math.random()}`);
2258
+ newKeys.add(key);
2259
+ const newJson = JSON.stringify(item);
2260
+ const oldJson = oldMap.get(key);
2261
+ if (!oldJson) insertChange.run(dataset, key, "added", null, newJson);
2262
+ else if (oldJson !== newJson) insertChange.run(dataset, key, "changed", oldJson, newJson);
2263
+ const text = JSON.stringify(summary).toLocaleLowerCase("ru-RU");
2264
+ insert.run(dataset, key, newJson, text);
2265
+ insertFts.run(dataset, key, text);
2266
+ }
2267
+ for (const [key, oldJson] of oldMap.entries()) {
2268
+ if (!newKeys.has(key)) insertChange.run(dataset, key, "removed", oldJson, null);
2269
+ }
2270
+ } finally {
2271
+ db.close();
2272
+ }
2273
+ }
2274
+
2275
+ function recordSyncRun(dataset, records, status, message) {
2276
+ initDatabase();
2277
+ const db = openDatabase();
2278
+ try {
2279
+ db.prepare("INSERT INTO sync_runs(dataset, records, status, message) VALUES (?, ?, ?, ?)").run(dataset, records, status, message);
2280
+ } finally {
2281
+ db.close();
2282
+ }
2283
+ }
2284
+
2285
+ async function ensureLocalData() {
2286
+ const status = getDbStatus();
2287
+ if (Number(status.local_records || 0) === 0) {
2288
+ await handleSync([]);
2289
+ }
2290
+ }
2291
+
2292
+ function searchLocalRecords(query, options = {}) {
2293
+ initDatabase();
2294
+ const db = openDatabase();
2295
+ const dataset = options.dataset || "all";
2296
+ const limit = Number(options.limit || 20);
2297
+ try {
2298
+ if (options.fts && query) {
2299
+ const ftsQuery = query.split(/\s+/).filter(Boolean).map((term) => `"${term.replace(/"/g, "")}"`).join(" ");
2300
+ const params = dataset === "all" ? [ftsQuery, limit] : [ftsQuery, dataset, limit];
2301
+ const sql = dataset === "all"
2302
+ ? "SELECT r.record_json FROM local_records_fts f JOIN local_records r ON r.dataset=f.dataset AND r.record_key=f.record_key WHERE local_records_fts MATCH ? LIMIT ?"
2303
+ : "SELECT r.record_json FROM local_records_fts f JOIN local_records r ON r.dataset=f.dataset AND r.record_key=f.record_key WHERE local_records_fts MATCH ? AND f.dataset = ? LIMIT ?";
2304
+ return db.prepare(sql).all(...params).map((row) => selectPublicSummary(JSON.parse(row.record_json)));
2305
+ }
2306
+ const params = [];
2307
+ let sql = "SELECT dataset, record_json FROM local_records";
2308
+ const where = [];
2309
+ if (dataset !== "all") {
2310
+ where.push("dataset = ?");
2311
+ params.push(dataset);
2312
+ }
2313
+ if (query) {
2314
+ where.push("searchable_text LIKE ?");
2315
+ params.push(`%${query.toLocaleLowerCase("ru-RU")}%`);
2316
+ }
2317
+ if (where.length) sql += ` WHERE ${where.join(" AND ")}`;
2318
+ sql += " ORDER BY dataset, record_key LIMIT ?";
2319
+ params.push(limit);
2320
+ return db.prepare(sql).all(...params).map((row) => selectPublicSummary(JSON.parse(row.record_json)));
2321
+ } finally {
2322
+ db.close();
2323
+ }
2324
+ }
2325
+
2326
+ function getSyncStatus() {
2327
+ initDatabase();
2328
+ const db = openDatabase();
2329
+ try {
2330
+ return Object.keys(DATASETS).map((dataset) => {
2331
+ const records = db.prepare("SELECT COUNT(*) AS count FROM local_records WHERE dataset = ?").get(dataset);
2332
+ const run = db.prepare("SELECT status, created_at FROM sync_runs WHERE dataset = ? ORDER BY id DESC LIMIT 1").get(dataset);
2333
+ return { dataset, records: records?.count || 0, last_sync: run?.created_at || "-", status: run?.status || "never" };
2334
+ });
2335
+ } finally {
2336
+ db.close();
2337
+ }
2338
+ }
2339
+
2340
+ function listSyncChanges(dataset) {
2341
+ initDatabase();
2342
+ const db = openDatabase();
2343
+ try {
2344
+ const rows = dataset
2345
+ ? db.prepare("SELECT * FROM sync_changes WHERE dataset = ? ORDER BY id DESC LIMIT 50").all(dataset)
2346
+ : db.prepare("SELECT * FROM sync_changes ORDER BY id DESC LIMIT 50").all();
2347
+ return rows.map((row) => ({
2348
+ ...row,
2349
+ summary: summarizeChange(row),
2350
+ }));
2351
+ } finally {
2352
+ db.close();
2353
+ }
2354
+ }
2355
+
2356
+ function summarizeChange(row) {
2357
+ const payload = row.new_json || row.old_json;
2358
+ if (!payload) return "-";
2359
+ try {
2360
+ const item = selectPublicSummary(JSON.parse(payload));
2361
+ return item.name || item.inn || "-";
2362
+ } catch {
2363
+ return "-";
2364
+ }
2365
+ }
2366
+
2367
+ function findCard(query) {
2368
+ const normalized = query.toLocaleLowerCase("ru-RU");
2369
+ const dataset = normalized.includes("сад") ? "kindergartens" : normalized.includes("школ") || normalized.includes("лицей") ? "schools" : "all";
2370
+ const inn = normalized.match(/\b\d{10,12}\b/)?.[0];
2371
+ const number = normalized.match(/\b\d{1,3}\b/)?.[0];
2372
+ const rows = searchLocalRecords(inn || number || query, { dataset, limit: 20, fts: false });
2373
+ if (inn) return rows.find((row) => String(row.inn) === inn) || null;
2374
+ if (number) return rows.find((row) => String(row.name || "").includes(`№ ${number}`) || String(row.name || "").includes(`№${number}`)) || rows[0] || null;
2375
+ return rows[0] || null;
2376
+ }
2377
+
2378
+ function runQuality(scope) {
2379
+ const datasets = ["schools", "kindergartens"];
2380
+ const rows = [];
2381
+ for (const dataset of datasets) {
2382
+ if (scope !== "all" && scope !== dataset && !scope.includes("-")) continue;
2383
+ const records = searchLocalRecords("", { dataset, limit: 1000 });
2384
+ const missingPhones = records.filter((item) => !item.phone || item.phone === "-");
2385
+ const invalidEmails = records.filter((item) => item.email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(item.email));
2386
+ const innCounts = new Map();
2387
+ for (const item of records) innCounts.set(item.inn, (innCounts.get(item.inn) || 0) + 1);
2388
+ const duplicateInn = records.filter((item) => item.inn && innCounts.get(item.inn) > 1);
2389
+ if (scope === "all" || scope === dataset || scope === "missing-phones") rows.push({ check: "missing-phones", dataset, count: missingPhones.length, sample: missingPhones[0]?.name || "-" });
2390
+ if (scope === "all" || scope === dataset || scope === "invalid-emails") rows.push({ check: "invalid-emails", dataset, count: invalidEmails.length, sample: invalidEmails[0]?.email || "-" });
2391
+ if (scope === "all" || scope === dataset || scope === "duplicate-inn") rows.push({ check: "duplicate-inn", dataset, count: duplicateInn.length, sample: duplicateInn[0]?.inn || "-" });
2392
+ }
2393
+ return rows;
2394
+ }
2395
+
2396
+ function saveView(name, dataset, args) {
2397
+ initDatabase();
2398
+ const db = openDatabase();
2399
+ try {
2400
+ db.prepare(`
2401
+ INSERT INTO saved_views(name, dataset, query_json) VALUES (?, ?, ?)
2402
+ ON CONFLICT(name) DO UPDATE SET dataset = excluded.dataset, query_json = excluded.query_json
2403
+ `).run(name, dataset, JSON.stringify({ args }));
2404
+ } finally {
2405
+ db.close();
2406
+ }
2407
+ }
2408
+
2409
+ function listSavedViews() {
2410
+ initDatabase();
2411
+ const db = openDatabase();
2412
+ try {
2413
+ return db.prepare("SELECT name, dataset, created_at FROM saved_views ORDER BY name").all();
2414
+ } finally {
2415
+ db.close();
2416
+ }
2417
+ }
2418
+
2419
+ function getSavedView(name) {
2420
+ initDatabase();
2421
+ const db = openDatabase();
2422
+ try {
2423
+ const row = db.prepare("SELECT * FROM saved_views WHERE name = ?").get(name);
2424
+ if (!row) throw new Error(`View не найден: ${name}`);
2425
+ return row;
2426
+ } finally {
2427
+ db.close();
2428
+ }
2429
+ }
2430
+
2431
+ function deleteSavedView(name) {
2432
+ if (!name) {
2433
+ throw new Error("Имя view обязательно.");
2434
+ }
2435
+ initDatabase();
2436
+ const db = openDatabase();
2437
+ try {
2438
+ db.prepare("DELETE FROM saved_views WHERE name = ?").run(name);
2439
+ } finally {
2440
+ db.close();
2441
+ }
2442
+ }
2443
+
2444
+ function getLocalSummaryRows(dataset) {
2445
+ const rows = searchLocalRecords("", { dataset, limit: 1000 });
2446
+ return [
2447
+ { metric: "records", value: rows.length },
2448
+ { metric: "with_phone", value: rows.filter((row) => row.phone && row.phone !== "-").length },
2449
+ { metric: "with_email", value: rows.filter((row) => row.email).length },
2450
+ { metric: "with_website", value: rows.filter((row) => row.website).length },
2451
+ ];
2452
+ }
2453
+
2454
+ function exportDbSnapshot() {
2455
+ initDatabase();
2456
+ return {
2457
+ db: getDbStatus(),
2458
+ views: listSavedViews(),
2459
+ aliases: listAliases(),
2460
+ features: listFeatures(),
2461
+ sessions: listSessions(100),
2462
+ history: listHistory(100),
2463
+ };
2464
+ }
2465
+
2466
+ function listAliases() {
2467
+ initDatabase();
2468
+ const db = openDatabase();
2469
+ try {
2470
+ return db.prepare("SELECT name, command FROM aliases ORDER BY name").all();
2471
+ } finally {
2472
+ db.close();
2473
+ }
2474
+ }
2475
+
2476
+ function getAlias(name) {
2477
+ try {
2478
+ initDatabase();
2479
+ const db = openDatabase();
2480
+ try {
2481
+ return db.prepare("SELECT name, command FROM aliases WHERE name = ?").get(name);
2482
+ } finally {
2483
+ db.close();
2484
+ }
2485
+ } catch {
2486
+ return null;
2487
+ }
2488
+ }
2489
+
2490
+ function saveAlias(name, command) {
2491
+ initDatabase();
2492
+ const db = openDatabase();
2493
+ try {
2494
+ db.prepare("INSERT INTO aliases(name, command) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET command = excluded.command").run(name, command);
2495
+ } finally {
2496
+ db.close();
2497
+ }
2498
+ }
2499
+
2500
+ function deleteAlias(name) {
2501
+ initDatabase();
2502
+ const db = openDatabase();
2503
+ try {
2504
+ db.prepare("DELETE FROM aliases WHERE name = ?").run(name);
2505
+ } finally {
2506
+ db.close();
2507
+ }
2508
+ }
2509
+
2510
+ function inferCommandFromText(text) {
2511
+ const normalized = text.toLocaleLowerCase("ru-RU");
2512
+ const dataset = normalized.includes("сад") ? "kindergartens" : "schools";
2513
+ const command = ["data", dataset];
2514
+ const street = normalized.match(/(?:на|по|улица|ул\.?)\s+([а-яёa-z-]+)/iu)?.[1];
2515
+ if (street) command.push("--where", `address=${street}`);
2516
+ if (normalized.includes("csv")) command.push("--format", "csv");
2517
+ if (normalized.includes("json")) command.push("--format", "json");
2518
+ const output = text.match(/(?:в файл|файл)\s+([^\s]+)/iu)?.[1];
2519
+ if (output) command.push("--output", output);
2520
+ return command;
2521
+ }
2522
+
2523
+ async function outputData(value, options, format) {
2524
+ const text = format === "csv" ? toCsv(value) : `${JSON.stringify(value, null, 2)}\n`;
2525
+ if (options.output) {
2526
+ await writeFile(options.output, text, "utf8");
2527
+ console.log(`Файл сохранен: ${options.output}`);
2528
+ return;
2529
+ }
2530
+ process.stdout.write(text);
2531
+ }
2532
+
2533
+ function toCsv(rows) {
2534
+ const list = Array.isArray(rows) ? rows : [rows];
2535
+ if (list.length === 0) return "";
2536
+ const columns = [...new Set(list.flatMap((row) => Object.keys(row)))];
2537
+ return `${columns.map(csvCell).join(",")}\n${list.map((row) => columns.map((column) => csvCell(row[column])).join(",")).join("\n")}\n`;
2538
+ }
2539
+
1865
2540
  function assertKeyProvider(provider) {
1866
2541
  if (provider !== "openai" && provider !== "openrouter") {
1867
2542
  throw new Error("Провайдер должен быть openai или openrouter.");
@@ -1951,6 +2626,9 @@ async function aiAsk(args, context = {}) {
1951
2626
 
1952
2627
  const config = await loadConfig();
1953
2628
  const providerConfig = resolveAiProfile(config, options);
2629
+ if (options.tools && providerConfig.provider === "ollama") {
2630
+ return localToolAsk(question, providerConfig, options);
2631
+ }
1954
2632
  applyRuntimeConfig(providerConfig, options.config);
1955
2633
  const dataContext = await buildDataContext(question);
1956
2634
  emitEvent(options, "context_loaded", { schools: dataContext.schools.length, kindergartens: dataContext.kindergartens.length });
@@ -2014,6 +2692,132 @@ function resolveAiProfile(config, options = {}) {
2014
2692
  };
2015
2693
  }
2016
2694
 
2695
+ async function localToolAsk(question, providerConfig, options) {
2696
+ await ensureLocalData();
2697
+ const plan = await buildLocalToolPlan(question, providerConfig, options);
2698
+ const validated = validateToolPlan(plan);
2699
+ const result = await executeToolPlan(validated);
2700
+ const answer = formatToolResult(result, options);
2701
+
2702
+ if (!options["no-history"] && isFeatureEnabled("sqlite-history")) {
2703
+ recordAskHistory({
2704
+ question,
2705
+ answer,
2706
+ providerConfig,
2707
+ dataContext: { tool_plan: validated, tool_result: result },
2708
+ error: "",
2709
+ sessionId: null,
2710
+ });
2711
+ }
2712
+
2713
+ emitEvent(options, "tool_plan", { plan: validated });
2714
+ if (options.output) await writeFile(options.output, answer, "utf8");
2715
+ if (options.format === "json" || options.schema === "json") {
2716
+ printJson({ answer, plan: validated, result });
2717
+ } else {
2718
+ console.log(answer);
2719
+ }
2720
+ return answer;
2721
+ }
2722
+
2723
+ async function buildLocalToolPlan(question, providerConfig, options) {
2724
+ const mode = options.reasoning || "verify";
2725
+ const prompt = [
2726
+ "Ты планировщик CLI iola. Верни только JSON.",
2727
+ "Доступные tools: search_local, get_card, export_data, run_report, save_view.",
2728
+ "Схема: {\"steps\":[{\"tool\":\"search_local\",\"args\":{\"dataset\":\"schools|kindergartens|all\",\"query\":\"text\",\"limit\":10}}]}",
2729
+ "Для выгрузки CSV добавь export_data с format=csv и output, если пользователь назвал файл.",
2730
+ `Вопрос: ${question}`,
2731
+ ].join("\n");
2732
+
2733
+ try {
2734
+ const raw = await callOllama(providerConfig, [{ role: "user", content: prompt }]);
2735
+ const parsed = parseJsonObject(raw);
2736
+ if (mode === "vote") {
2737
+ return chooseBestPlan([parsed, inferToolPlan(question)]);
2738
+ }
2739
+ return parsed;
2740
+ } catch {
2741
+ return inferToolPlan(question);
2742
+ }
2743
+ }
2744
+
2745
+ function parseJsonObject(text) {
2746
+ const match = String(text).match(/\{[\s\S]*\}/);
2747
+ if (!match) throw new Error("JSON-план не найден.");
2748
+ return JSON.parse(match[0]);
2749
+ }
2750
+
2751
+ function inferToolPlan(question) {
2752
+ const normalized = question.toLocaleLowerCase("ru-RU");
2753
+ const dataset = normalized.includes("сад") ? "kindergartens" : normalized.includes("школ") || normalized.includes("лицей") ? "schools" : "all";
2754
+ const steps = [];
2755
+ if (normalized.includes("без телефона")) {
2756
+ steps.push({ tool: "run_report", args: { name: "missing-phones" } });
2757
+ } else {
2758
+ const query = normalized.match(/петрова|школ[а-яё ]*\d+|сад[а-яё ]*\d+|лицей[а-яё ]*\d+/iu)?.[0] || question;
2759
+ steps.push({ tool: "search_local", args: { dataset, query, limit: 20 } });
2760
+ }
2761
+ if (normalized.includes("csv") || normalized.includes("выгруз")) {
2762
+ steps.push({ tool: "export_data", args: { format: "csv", output: normalized.match(/([a-z0-9_-]+\.csv)/i)?.[1] || "iola-export.csv" } });
2763
+ }
2764
+ return { steps };
2765
+ }
2766
+
2767
+ function chooseBestPlan(plans) {
2768
+ return plans.find((plan) => {
2769
+ try {
2770
+ validateToolPlan(plan);
2771
+ return true;
2772
+ } catch {
2773
+ return false;
2774
+ }
2775
+ }) || plans.at(-1);
2776
+ }
2777
+
2778
+ function validateToolPlan(plan) {
2779
+ const allowed = new Set(["search_local", "get_card", "export_data", "run_report", "save_view"]);
2780
+ if (!plan || !Array.isArray(plan.steps)) throw new Error("Некорректный tool-plan.");
2781
+ for (const step of plan.steps) {
2782
+ if (!allowed.has(step.tool)) throw new Error(`Недопустимый tool: ${step.tool}`);
2783
+ }
2784
+ return plan;
2785
+ }
2786
+
2787
+ async function executeToolPlan(plan) {
2788
+ let current = [];
2789
+ const outputs = [];
2790
+ for (const step of plan.steps) {
2791
+ if (step.tool === "search_local") {
2792
+ current = searchLocalRecords(step.args?.query || "", { dataset: step.args?.dataset || "all", limit: step.args?.limit || 20, fts: true });
2793
+ outputs.push({ tool: step.tool, rows: current.length });
2794
+ } else if (step.tool === "get_card") {
2795
+ const card = findCard(step.args?.query || "");
2796
+ current = card ? [card] : [];
2797
+ outputs.push({ tool: step.tool, rows: current.length });
2798
+ } else if (step.tool === "run_report") {
2799
+ current = runQuality(step.args?.name || "all");
2800
+ outputs.push({ tool: step.tool, rows: current.length });
2801
+ } else if (step.tool === "save_view") {
2802
+ saveView(step.args?.name, step.args?.dataset || "all", step.args?.args || []);
2803
+ outputs.push({ tool: step.tool, saved: step.args?.name });
2804
+ } else if (step.tool === "export_data") {
2805
+ const text = step.args?.format === "json" ? JSON.stringify(current, null, 2) : toCsv(current);
2806
+ await writeFile(step.args?.output || "iola-export.csv", text, "utf8");
2807
+ outputs.push({ tool: step.tool, output: step.args?.output || "iola-export.csv", rows: current.length });
2808
+ }
2809
+ }
2810
+ return { rows: current, outputs };
2811
+ }
2812
+
2813
+ function formatToolResult(result, options) {
2814
+ if (options.schema === "json") return JSON.stringify(result, null, 2);
2815
+ const exported = result.outputs.find((item) => item.output);
2816
+ if (exported) return `Готово. Файл сохранен: ${exported.output}. Записей: ${exported.rows}`;
2817
+ if (!result.rows.length) return "Данных не найдено.";
2818
+ return result.rows.slice(0, 10).map((row) => `${row.name || row.check}: ${row.address || row.count || ""}`).join("\n");
2819
+ }
2820
+
2017
2821
  function applyRuntimeConfig(target, value) {
2018
2822
  if (!value) {
2019
2823
  return;
@@ -2396,20 +3200,26 @@ async function listDataset(dataset, args) {
2396
3200
  params.set("limit", options.limit || "20");
2397
3201
  params.set("offset", options.offset || "0");
2398
3202
 
2399
- const data = await fetchJson(`${await getApiBaseUrl()}/${DATASETS[dataset].endpoint}?${params}`);
2400
- const items = normalizeItems(data);
3203
+ const data = options.local
3204
+ ? searchLocalRecords(options.search || options._.join(" ") || "", { dataset, limit: Number(options.limit || 20), fts: options.fts })
3205
+ : normalizeItems(await fetchJsonMaybeCached(`${await getApiBaseUrl()}/${DATASETS[dataset].endpoint}?${params}`, options));
3206
+ const items = data;
2401
3207
  const filtered = applyDatasetFilters(items, options);
2402
3208
  const limited = filtered.slice(0, Number(options.limit || 20));
2403
3209
  const summarized = limited.map(selectPublicSummary);
2404
3210
  const projected = projectColumns(summarized, options.columns);
3211
+ if (options.save) {
3212
+ saveView(options.save, dataset, args.filter((arg) => arg !== "--save" && arg !== options.save));
3213
+ console.log(`View сохранен: ${options.save}`);
3214
+ }
2405
3215
 
2406
3216
  if (options.json || options.format === "json") {
2407
- printJson(projected);
3217
+ await outputData(projected, options, "json");
2408
3218
  return;
2409
3219
  }
2410
3220
 
2411
3221
  if (options.format === "csv") {
2412
- printCsv(projected);
3222
+ await outputData(projected, options, "csv");
2413
3223
  return;
2414
3224
  }
2415
3225
 
@@ -2444,27 +3254,32 @@ async function searchAll(args) {
2444
3254
  throw new Error('Search text is required. Example: iola search "лицей"');
2445
3255
  }
2446
3256
 
2447
- const [schools, kindergartens] = await Promise.all([
2448
- fetchJson(`${await getApiBaseUrl()}/schools?limit=100&offset=0`),
2449
- fetchJson(`${await getApiBaseUrl()}/kindergartens?limit=100&offset=0`),
2450
- ]);
2451
-
2452
3257
  const limit = Number(options.limit || 5);
3258
+ const [schools, kindergartens] = options.local
3259
+ ? [
3260
+ searchLocalRecords(query, { dataset: "schools", limit, fts: options.fts }),
3261
+ searchLocalRecords(query, { dataset: "kindergartens", limit, fts: options.fts }),
3262
+ ]
3263
+ : await Promise.all([
3264
+ fetchJsonMaybeCached(`${await getApiBaseUrl()}/schools?limit=100&offset=0`, options),
3265
+ fetchJsonMaybeCached(`${await getApiBaseUrl()}/kindergartens?limit=100&offset=0`, options),
3266
+ ]);
3267
+
2453
3268
  const result = {
2454
3269
  schools: projectColumns(filterItems(normalizeItems(schools), query).slice(0, limit).map(selectPublicSummary), options.columns),
2455
3270
  kindergartens: projectColumns(filterItems(normalizeItems(kindergartens), query).slice(0, limit).map(selectPublicSummary), options.columns),
2456
3271
  };
2457
3272
 
2458
3273
  if (options.json || options.format === "json") {
2459
- printJson(result);
3274
+ await outputData(result, options, "json");
2460
3275
  return;
2461
3276
  }
2462
3277
 
2463
3278
  if (options.format === "csv") {
2464
- printCsv([
3279
+ await outputData([
2465
3280
  ...result.schools.map((item) => ({ layer: "schools", ...item })),
2466
3281
  ...result.kindergartens.map((item) => ({ layer: "kindergartens", ...item })),
2467
- ]);
3282
+ ], options, "csv");
2468
3283
  return;
2469
3284
  }
2470
3285
 
@@ -2492,12 +3307,12 @@ function parseOptions(args) {
2492
3307
 
2493
3308
  for (let index = 0; index < args.length; index += 1) {
2494
3309
  const arg = args[index];
2495
- if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all") {
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") {
2496
3311
  result[arg.slice(2)] = true;
2497
3312
  } else if (arg === "--check" || arg === "--upgrade-node") {
2498
3313
  result.check = true;
2499
3314
  result[arg.slice(2)] = true;
2500
- } 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") {
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") {
2501
3316
  result[arg.slice(2)] = args[index + 1];
2502
3317
  index += 1;
2503
3318
  } else {