@iola_adm/iola-cli 0.1.65 → 0.1.67

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.65",
3
+ "version": "0.1.67",
4
4
  "description": "CLI и AI-агент городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
@@ -18,11 +18,12 @@
18
18
  "scripts": {
19
19
  "postinstall": "node --no-warnings bin/iola.js db init --silent && node --no-warnings bin/iola.js browser install",
20
20
  "start": "node --no-warnings bin/iola.js",
21
- "test": "node --no-warnings --check bin/iola.js && node --no-warnings --check src/cli.js"
21
+ "test": "node --no-warnings --check bin/iola.js && node --no-warnings --check src/cli.js && node --no-warnings test/smoke-test.js"
22
22
  },
23
23
  "files": [
24
24
  "bin",
25
25
  "src",
26
+ "test",
26
27
  "skills",
27
28
  "wiki",
28
29
  "docs/assets/readme-header.png",
package/src/cli.js CHANGED
@@ -289,9 +289,14 @@ const SLASH_COMMANDS = [
289
289
  { command: "/search лицей --limit 3", description: "поиск" },
290
290
  { command: "/mcp-info", description: "публичный MCP" },
291
291
  { command: "/profiles", description: "AI-профили" },
292
+ { command: "/model", description: "переключить AI: local/API/Codex" },
293
+ { command: "/model codex", description: "выбрать модель Codex" },
294
+ { command: "/model api", description: "выбрать API-модель" },
292
295
  { command: "/models openrouter --search qwen", description: "модели" },
293
296
  { command: "/ai doctor", description: "AI diagnostics" },
294
297
  { command: "/ai setup ollama", description: "настройка Ollama" },
298
+ { command: "/use codex", description: "выбрать Codex CLI" },
299
+ { command: "/use local", description: "выбрать локальный профиль" },
295
300
  { command: "/use openai", description: "выбрать OpenAI" },
296
301
  { command: "/use ollama", description: "выбрать Ollama" },
297
302
  { command: "/key status", description: "API-ключи" },
@@ -761,10 +766,11 @@ async function startAgentReadline() {
761
766
  }
762
767
 
763
768
  async function startAgentRawInput() {
764
- const state = { history: [], buffer: "", selected: 0, slashOpen: false, running: false, renderedInputLines: 0, rawMode: true, pendingOutput: "" };
769
+ const state = { history: [], buffer: "", selected: 0, slashOffset: 0, slashOpen: false, running: false, renderedInputLines: 0, rawMode: true, pendingOutput: "", aiStatus: null };
765
770
  const wasRaw = input.isRaw;
766
771
  activateRawInput(input);
767
772
 
773
+ await refreshAgentAiStatus(state);
768
774
  const render = () => renderAgentInput(state);
769
775
  render();
770
776
 
@@ -785,13 +791,19 @@ async function startAgentRawInput() {
785
791
  }
786
792
  if (key?.name === "up" && state.slashOpen) {
787
793
  const matches = currentSlashMatches(state);
788
- state.selected = Math.max(0, Math.min(matches.length - 1, state.selected - 1));
794
+ const nextSelected = Math.max(0, state.selected - 1);
795
+ state.selected = nextSelected;
796
+ if (state.selected < state.slashOffset) state.slashOffset = state.selected;
789
797
  render();
790
798
  continue;
791
799
  }
792
800
  if (key?.name === "down" && state.slashOpen) {
793
801
  const matches = currentSlashMatches(state);
794
- state.selected = Math.max(0, Math.min(matches.length - 1, state.selected + 1));
802
+ const visibleLimit = getSlashVisibleLimit();
803
+ const nextSelected = Math.min(matches.length - 1, state.selected + 1);
804
+ state.selected = Math.max(0, nextSelected);
805
+ if (state.selected >= state.slashOffset + visibleLimit) state.slashOffset = state.selected - visibleLimit + 1;
806
+ state.slashOffset = Math.max(0, Math.min(state.slashOffset, Math.max(0, matches.length - visibleLimit)));
795
807
  render();
796
808
  continue;
797
809
  }
@@ -819,6 +831,7 @@ async function startAgentRawInput() {
819
831
  const shouldExit = await handleAgentLine(line, state);
820
832
  stopActivity();
821
833
  flushPendingAgentOutput(state);
834
+ await refreshAgentAiStatus(state);
822
835
  if (!shouldExit) restoreRawInput();
823
836
  if (shouldExit) break;
824
837
  } catch (error) {
@@ -1118,6 +1131,11 @@ async function handleAgentLine(line, state) {
1118
1131
  return false;
1119
1132
  }
1120
1133
 
1134
+ if (command === "model") {
1135
+ await slashModelMenu(args);
1136
+ return false;
1137
+ }
1138
+
1121
1139
  if (command === "use") {
1122
1140
  await useAiProvider(args);
1123
1141
  return false;
@@ -1232,8 +1250,9 @@ function printAgentHelp() {
1232
1250
 
1233
1251
  function printSlashMenu(filter = "", options = {}) {
1234
1252
  const normalized = String(filter || "").replace(/^\//, "");
1253
+ const limit = options.limit === undefined ? Infinity : Number(options.limit);
1235
1254
  const rows = getSlashCommandMatches(normalized)
1236
- .slice(0, Number(options.limit || 30))
1255
+ .slice(0, limit)
1237
1256
  .map((item) => ({ command: item.command, description: item.description }));
1238
1257
  if (rows.length === 0) {
1239
1258
  console.log(`Нет slash-команд по фильтру: ${filter}`);
@@ -1261,11 +1280,16 @@ function getSlashCommandMatches(filter = "") {
1261
1280
  function updateSlashState(state) {
1262
1281
  state.slashOpen = state.buffer.startsWith("/");
1263
1282
  state.selected = 0;
1283
+ state.slashOffset = 0;
1264
1284
  }
1265
1285
 
1266
1286
  function currentSlashMatches(state) {
1267
1287
  if (!state.buffer.startsWith("/")) return [];
1268
- return getSlashCommandMatches(state.buffer.slice(1)).slice(0, 10);
1288
+ return getSlashCommandMatches(state.buffer.slice(1));
1289
+ }
1290
+
1291
+ function getSlashVisibleLimit() {
1292
+ return 10;
1269
1293
  }
1270
1294
 
1271
1295
  function renderAgentInput(state) {
@@ -1273,20 +1297,25 @@ function renderAgentInput(state) {
1273
1297
  const prompt = "> ";
1274
1298
  const lines = state.buffer.split("\n");
1275
1299
  const inputLines = [`${prompt}${lines[0] || ""}`, ...lines.slice(1).map((line) => ` ${line}`)];
1276
- const cwdLine = colorMuted(` ${process.cwd()}`);
1300
+ const cwdLine = colorMuted(` ${buildAgentStatusLine(state)}`);
1277
1301
  const menuLines = [];
1278
1302
  if (state.slashOpen) {
1279
1303
  const matches = currentSlashMatches(state);
1280
1304
  if (matches.length === 0) {
1281
1305
  menuLines.push(" нет команд");
1282
1306
  } else {
1283
- for (let index = 0; index < matches.length; index += 1) {
1284
- const selected = index === state.selected;
1307
+ const visibleLimit = getSlashVisibleLimit();
1308
+ const offset = Math.max(0, Math.min(state.slashOffset || 0, Math.max(0, matches.length - visibleLimit)));
1309
+ const visibleMatches = matches.slice(offset, offset + visibleLimit);
1310
+ for (let index = 0; index < visibleMatches.length; index += 1) {
1311
+ const absoluteIndex = offset + index;
1312
+ const selected = absoluteIndex === state.selected;
1285
1313
  const marker = selected ? ">" : " ";
1286
- const row = `${marker} ${matches[index].command.padEnd(24)} ${matches[index].description}`;
1314
+ const row = `${marker} ${visibleMatches[index].command.padEnd(24)} ${visibleMatches[index].description}`;
1287
1315
  menuLines.push(selected ? colorSlashSelection(row) : ` ${row.slice(2)}`);
1288
1316
  }
1289
- menuLines.push(" ↑/↓ выбрать Enter вставить/выполнить Esc закрыть");
1317
+ const shownTo = Math.min(offset + visibleLimit, matches.length);
1318
+ menuLines.push(` ↑/↓ выбрать • Enter выполнить • Esc закрыть • ${offset + 1}-${shownTo} из ${matches.length}`);
1290
1319
  }
1291
1320
  }
1292
1321
 
@@ -1400,6 +1429,35 @@ function printAgentHistory(history) {
1400
1429
  }
1401
1430
  }
1402
1431
 
1432
+ async function refreshAgentAiStatus(state) {
1433
+ try {
1434
+ const config = await loadConfig();
1435
+ const name = getActiveProfileName(config);
1436
+ const profile = config.ai.profiles?.[name] || {
1437
+ provider: config.ai.provider,
1438
+ model: config.ai.model,
1439
+ baseUrl: config.ai.baseUrl,
1440
+ };
1441
+ state.aiStatus = { name, provider: profile.provider || "-", model: profile.model || "-" };
1442
+ } catch {
1443
+ state.aiStatus = null;
1444
+ }
1445
+ }
1446
+
1447
+ function buildAgentStatusLine(state) {
1448
+ const cwd = process.cwd();
1449
+ const ai = state.aiStatus;
1450
+ if (!ai) return cwd;
1451
+ const kind = {
1452
+ ollama: "локальная",
1453
+ openai: "API",
1454
+ openrouter: "API",
1455
+ codex: "Codex",
1456
+ }[ai.provider] || ai.provider;
1457
+ const model = ai.model && ai.model !== "-" ? ` • ${ai.model}` : "";
1458
+ return `${cwd} | AI: ${kind}${model} (${ai.name})`;
1459
+ }
1460
+
1403
1461
  function compactAgentHistory(history) {
1404
1462
  if (history.length <= 8) return history;
1405
1463
  const summary = history.slice(0, -6)
@@ -4267,6 +4325,171 @@ async function useAiProvider(args) {
4267
4325
  console.log(`AI-провайдер переключен: ${provider}, профиль: ${profileName}, модель: ${defaultModel}`);
4268
4326
  }
4269
4327
 
4328
+ async function slashModelMenu(args = []) {
4329
+ const [target, maybeModel] = args;
4330
+ const normalizedTarget = normalizeModelMenuTarget(target);
4331
+
4332
+ if (normalizedTarget && maybeModel) {
4333
+ const directTarget = normalizedTarget === "api" ? await getDefaultApiProviderForModelSwitch() : normalizedTarget;
4334
+ await switchModelTarget(directTarget, maybeModel);
4335
+ return;
4336
+ }
4337
+
4338
+ const selectedTarget = normalizedTarget || await chooseModelTarget();
4339
+ if (!selectedTarget) return;
4340
+
4341
+ await openModelTargetMenu(selectedTarget);
4342
+ }
4343
+
4344
+ function normalizeModelMenuTarget(value = "") {
4345
+ const normalized = String(value || "").trim().toLocaleLowerCase("ru-RU");
4346
+ if (!normalized) return "";
4347
+ if (["local", "локальная", "локально", "ollama"].includes(normalized)) return "local";
4348
+ if (["api", "апи"].includes(normalized)) return "api";
4349
+ if (normalized === "openai") return "openai";
4350
+ if (normalized === "openrouter" || normalized === "router") return "openrouter";
4351
+ if (["codex", "кодекс"].includes(normalized)) return "codex";
4352
+ return "";
4353
+ }
4354
+
4355
+ async function chooseModelTarget() {
4356
+ console.log("Выберите AI-подключение:");
4357
+ console.log(" 1. Локальная модель (Ollama)");
4358
+ console.log(" 2. API (OpenAI/OpenRouter)");
4359
+ console.log(" 3. Codex CLI");
4360
+ console.log(" 0. Отмена");
4361
+
4362
+ const answer = await askText("Номер: ");
4363
+ return { 1: "local", 2: "api", 3: "codex" }[answer.trim()] || "";
4364
+ }
4365
+
4366
+ async function openModelTargetMenu(target) {
4367
+ if (target === "local") {
4368
+ const model = await chooseAiModel("ollama");
4369
+ if (model) await switchModelTarget("local", model);
4370
+ return;
4371
+ }
4372
+
4373
+ if (target === "codex") {
4374
+ const model = await chooseAiModel("codex");
4375
+ if (model) await switchModelTarget("codex", model);
4376
+ return;
4377
+ }
4378
+
4379
+ if (target === "openai" || target === "openrouter") {
4380
+ const model = await chooseAiModel(target);
4381
+ if (model) await switchModelTarget(target, model);
4382
+ return;
4383
+ }
4384
+
4385
+ const provider = await chooseApiProvider();
4386
+ if (!provider) return;
4387
+ const model = await chooseAiModel(provider);
4388
+ if (model) await switchModelTarget(provider, model);
4389
+ }
4390
+
4391
+ async function chooseApiProvider() {
4392
+ const config = await loadConfig();
4393
+ const apiProfiles = Object.entries(config.ai.profiles || {})
4394
+ .filter(([, profile]) => profile.provider === "openai" || profile.provider === "openrouter")
4395
+ .map(([name, profile]) => ({ id: profile.provider, label: `${name}: ${profile.provider} (${profile.model || "-"})` }));
4396
+ const choices = [
4397
+ ...apiProfiles,
4398
+ { id: "openai", label: "OpenAI API" },
4399
+ { id: "openrouter", label: "OpenRouter API" },
4400
+ ].filter((item, index, array) => array.findIndex((candidate) => candidate.id === item.id) === index);
4401
+
4402
+ console.log("Выберите API-подключение:");
4403
+ choices.forEach((item, index) => console.log(` ${index + 1}. ${item.label}`));
4404
+ console.log(" 0. Отмена");
4405
+
4406
+ const answer = Number(await askText("Номер: "));
4407
+ return choices[answer - 1]?.id || "";
4408
+ }
4409
+
4410
+ async function getDefaultApiProviderForModelSwitch() {
4411
+ const config = await loadConfig();
4412
+ const activeProfile = config.ai.profiles?.[getActiveProfileName(config)];
4413
+ if (activeProfile?.provider === "openai" || activeProfile?.provider === "openrouter") return activeProfile.provider;
4414
+ const apiProfile = Object.values(config.ai.profiles || {}).find((profile) => profile.provider === "openai" || profile.provider === "openrouter");
4415
+ return apiProfile?.provider || "openai";
4416
+ }
4417
+
4418
+ async function chooseAiModel(provider) {
4419
+ let search = "";
4420
+ if (provider === "openrouter" || provider === "openai") {
4421
+ search = (await askText("Фильтр моделей (Enter - без фильтра): ")).trim();
4422
+ }
4423
+
4424
+ let models;
4425
+ try {
4426
+ models = await listAiModels(provider);
4427
+ } catch (error) {
4428
+ console.log(error instanceof Error ? error.message : String(error));
4429
+ return "";
4430
+ }
4431
+
4432
+ let filtered = search
4433
+ ? models.filter((model) => model.id.toLocaleLowerCase("ru-RU").includes(search.toLocaleLowerCase("ru-RU")))
4434
+ : models;
4435
+
4436
+ if (filtered.length === 0) {
4437
+ console.log("Модели не найдены.");
4438
+ return "";
4439
+ }
4440
+
4441
+ const limit = 25;
4442
+ if (filtered.length > limit) {
4443
+ filtered = filtered.slice(0, limit);
4444
+ console.log(`Показаны первые ${limit} моделей. Для точного выбора запустите /model и задайте фильтр.`);
4445
+ }
4446
+
4447
+ console.log("Выберите модель:");
4448
+ filtered.forEach((model, index) => console.log(` ${index + 1}. ${model.id}${model.note ? ` - ${model.note}` : ""}`));
4449
+ console.log(" 0. Отмена");
4450
+
4451
+ const answer = Number(await askText("Номер: "));
4452
+ return filtered[answer - 1]?.id || "";
4453
+ }
4454
+
4455
+ async function switchModelTarget(target, model) {
4456
+ const config = await loadConfig();
4457
+ const provider = target === "local" ? "ollama" : target;
4458
+ const profileName = provider === "ollama" ? "local" : provider;
4459
+ const currentProfile = config.ai.profiles?.[profileName] || buildProfileFromOptions(provider, { model });
4460
+ const profile = {
4461
+ ...currentProfile,
4462
+ provider,
4463
+ model,
4464
+ };
4465
+
4466
+ await saveConfig({
4467
+ ai: {
4468
+ ...config.ai,
4469
+ activeProfile: profileName,
4470
+ provider,
4471
+ model,
4472
+ baseUrl: profile.baseUrl || config.ai.baseUrl,
4473
+ profiles: {
4474
+ ...(config.ai.profiles || {}),
4475
+ [profileName]: profile,
4476
+ },
4477
+ },
4478
+ });
4479
+
4480
+ console.log(`Активная модель: ${profileName} (${provider}, ${model})`);
4481
+ }
4482
+
4483
+ async function askText(question) {
4484
+ if (!process.stdin.isTTY) return "";
4485
+ const rl = readline.createInterface({ input, output });
4486
+ try {
4487
+ return await rl.question(question);
4488
+ } finally {
4489
+ rl.close();
4490
+ }
4491
+ }
4492
+
4270
4493
  async function aiContext(args) {
4271
4494
  const options = parseOptions(args);
4272
4495
  const query = options._.join(" ").trim();
@@ -8548,7 +8771,8 @@ async function saveConfig(value) {
8548
8771
  }
8549
8772
 
8550
8773
  async function writeConfig(value) {
8551
- const errors = validateConfig(value);
8774
+ const sanitized = sanitizeConfig(value);
8775
+ const errors = validateConfig(sanitized);
8552
8776
  if (errors.length > 0) {
8553
8777
  throw new Error(`Конфигурация не сохранена: ${errors.join("; ")}`);
8554
8778
  }
@@ -8556,7 +8780,7 @@ async function writeConfig(value) {
8556
8780
  if (existsSync(CONFIG_FILE)) {
8557
8781
  await copyFile(CONFIG_FILE, LAST_GOOD_CONFIG_FILE).catch(() => {});
8558
8782
  }
8559
- await writeFile(CONFIG_FILE, `${JSON.stringify(value, null, 2)}\n`, "utf8");
8783
+ await writeFile(CONFIG_FILE, `${JSON.stringify(sanitized, null, 2)}\n`, "utf8");
8560
8784
  }
8561
8785
 
8562
8786
  async function loadConfig() {
@@ -8565,7 +8789,7 @@ async function loadConfig() {
8565
8789
  const value = await readConfigLayer(layer);
8566
8790
  if (value) config = mergeConfig(config, value);
8567
8791
  }
8568
- return config;
8792
+ return sanitizeConfig(config);
8569
8793
  }
8570
8794
 
8571
8795
  async function loadConfigLayers() {
@@ -8582,7 +8806,7 @@ async function loadConfigLayers() {
8582
8806
  continue;
8583
8807
  }
8584
8808
  const value = await readConfigLayer(layer.file);
8585
- rows.push({ ...layer, exists: Boolean(value), value, errors: value ? validateConfig(mergeConfig(DEFAULT_AI_CONFIG, value)) : [] });
8809
+ rows.push({ ...layer, exists: Boolean(value), value, errors: value ? validateConfig(sanitizeConfig(mergeConfig(DEFAULT_AI_CONFIG, value))) : [] });
8586
8810
  }
8587
8811
  rows.push({ scope: "runtime", file: "process.env", exists: true, value: { IOLA_API_BASE_URL: process.env.IOLA_API_BASE_URL || "", IOLA_MCP_BASE_URL: process.env.IOLA_MCP_BASE_URL || "" }, errors: [] });
8588
8812
  return rows;
@@ -8677,6 +8901,18 @@ function mergeConfig(base, override) {
8677
8901
  };
8678
8902
  }
8679
8903
 
8904
+ function sanitizeConfig(config) {
8905
+ const next = JSON.parse(JSON.stringify(config || {}));
8906
+ if (next.permissions?.localTools && typeof next.permissions.localTools === "object") {
8907
+ for (const tool of Object.keys(next.permissions.localTools)) {
8908
+ if (!ALL_TOOL_ALIASES.includes(tool)) {
8909
+ delete next.permissions.localTools[tool];
8910
+ }
8911
+ }
8912
+ }
8913
+ return next;
8914
+ }
8915
+
8680
8916
  function validateConfig(config) {
8681
8917
  const errors = [];
8682
8918
  if (!config || typeof config !== "object") errors.push("config must be object");
@@ -0,0 +1,64 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7
+ const binPath = resolve(rootDir, "bin", "iola.js");
8
+ const packageJson = JSON.parse(await readFile(resolve(rootDir, "package.json"), "utf8"));
9
+
10
+ function runCli(args) {
11
+ return new Promise((resolvePromise, reject) => {
12
+ execFile(
13
+ process.execPath,
14
+ ["--no-warnings", binPath, ...args],
15
+ { cwd: rootDir, encoding: "utf8", timeout: 15_000 },
16
+ (error, stdout, stderr) => {
17
+ if (error) {
18
+ reject(new Error(`iola ${args.join(" ")} failed\n${stdout}${stderr}`));
19
+ return;
20
+ }
21
+ resolvePromise(stdout);
22
+ },
23
+ );
24
+ });
25
+ }
26
+
27
+ function assertIncludes(text, expected, label) {
28
+ if (!text.includes(expected)) {
29
+ throw new Error(`${label} should include ${JSON.stringify(expected)}`);
30
+ }
31
+ }
32
+
33
+ function assertNotIncludes(text, unexpected, label) {
34
+ if (text.includes(unexpected)) {
35
+ throw new Error(`${label} should not include ${JSON.stringify(unexpected)}`);
36
+ }
37
+ }
38
+
39
+ const version = (await runCli(["version"])).trim();
40
+ if (version !== packageJson.version) {
41
+ throw new Error(`version command returned ${version}, expected ${packageJson.version}`);
42
+ }
43
+
44
+ const help = await runCli(["--help"]);
45
+ assertIncludes(help, "iola master", "help");
46
+ assertIncludes(help, "iola ask", "help");
47
+
48
+ const commands = await runCli(["commands"]);
49
+ assertIncludes(commands, "iola browser status|install|open|text|html|screenshot|pdf|click|type|eval", "commands");
50
+ assertIncludes(commands, "iola mcp list|status|install|remove|serve [--stdio]", "commands");
51
+ assertNotIncludes(commands, "Госуслуг", "commands");
52
+ assertNotIncludes(commands, "gosuslugi", "commands");
53
+
54
+ const schema = JSON.parse(await runCli(["config", "schema"]));
55
+ if (!schema.properties?.api || !schema.properties?.ai) {
56
+ throw new Error("config schema should expose api and ai sections");
57
+ }
58
+
59
+ const skills = await runCli(["skills", "list"]);
60
+ assertIncludes(skills, "open-data", "skills list");
61
+ assertIncludes(skills, "reports", "skills list");
62
+ assertNotIncludes(skills, "gosuslugi", "skills list");
63
+
64
+ console.log("smoke tests passed");