@iola_adm/iola-cli 0.1.37 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +216 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
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
@@ -5,6 +5,7 @@ import { createServer } from "node:http";
5
5
  import { appendFile, copyFile, cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
+ import { emitKeypressEvents } from "node:readline";
8
9
  import readline from "node:readline/promises";
9
10
  import { stdin as input, stdout as output } from "node:process";
10
11
  import { DatabaseSync } from "node:sqlite";
@@ -329,20 +330,7 @@ const SLASH_COMMANDS = [
329
330
  { command: "/init", description: "проверить окружение" },
330
331
  { command: "/exit", description: "выйти" },
331
332
  ];
332
- const BANNER = `\x1b[38;5;45m┌────────────────────────────────────────────────────────────────────────────┐
333
- │ │
334
- │\x1b[38;5;51m ____ _ ___ __ ______ ____ _ _ _ __ _ ____ \x1b[38;5;45m│
335
- │\x1b[38;5;51m / ___| | |_ _| \\ \\ / / ___|| _ \\| | | | |/ / / \\ | _ \\ \x1b[38;5;45m│
336
- │\x1b[38;5;51m | | | | | | _____ \\ V /\\___ \\| | | | |_| | ' / / _ \\ | |_) | \x1b[38;5;45m│
337
- │\x1b[38;5;51m | |___| |___ | | |_____| | | ___) | |_| | _ | . \\ / ___ \\| _ < \x1b[38;5;45m│
338
- │\x1b[38;5;51m \\____|_____|___| |_| |____/|____/|_| |_|_|\\_\\/_/ \\_\\_| \\_\\ \x1b[38;5;45m│
339
- │ │
340
- │\x1b[38;5;213m CLI-ЙОШКАР-ОЛА \x1b[38;5;45m│
341
- │ │
342
- │\x1b[38;5;250m открытые данные • MCP • локальный AI \x1b[38;5;45m│
343
- │ │
344
- │\x1b[38;5;82m VERSION_LINE \x1b[38;5;45m│
345
- └────────────────────────────────────────────────────────────────────────────┘\x1b[0m`;
333
+ const BANNER_WIDTH = 76;
346
334
 
347
335
  const COMMANDS = new Map([
348
336
  ["help", showHelp],
@@ -637,6 +625,17 @@ async function startAgent() {
637
625
  console.log("Интерактивный режим. Введите /help для списка команд, /exit для выхода.");
638
626
  await runHooks("SessionStart", { mode: "agent" });
639
627
 
628
+ if (input.isTTY && output.isTTY) {
629
+ await startAgentRawInput();
630
+ await runHooks("SessionEnd", { mode: "agent" });
631
+ return;
632
+ }
633
+
634
+ await startAgentReadline();
635
+ await runHooks("SessionEnd", { mode: "agent" });
636
+ }
637
+
638
+ async function startAgentReadline() {
640
639
  const rl = readline.createInterface({ input, output, prompt: "iola> " });
641
640
  const state = {
642
641
  history: [],
@@ -645,6 +644,7 @@ async function startAgent() {
645
644
  rl.on("close", () => {
646
645
  closed = true;
647
646
  });
647
+ const detachSlashSuggestions = attachSlashSuggestions(rl);
648
648
  safePrompt(rl);
649
649
 
650
650
  for await (const rawLine of rl) {
@@ -670,7 +670,87 @@ async function startAgent() {
670
670
  if (!closed) {
671
671
  rl.close();
672
672
  }
673
- await runHooks("SessionEnd", { mode: "agent" });
673
+ detachSlashSuggestions();
674
+ }
675
+
676
+ async function startAgentRawInput() {
677
+ const state = { history: [], buffer: "", selected: 0, slashOpen: false, running: false };
678
+ emitKeypressEvents(input);
679
+ const wasRaw = input.isRaw;
680
+ input.setRawMode(true);
681
+ input.resume();
682
+
683
+ const render = () => renderAgentInput(state);
684
+ render();
685
+
686
+ try {
687
+ while (true) {
688
+ const { str, key } = await readKeypress();
689
+ if (key?.ctrl && key.name === "c") break;
690
+ if (key?.name === "escape") {
691
+ state.slashOpen = false;
692
+ render();
693
+ continue;
694
+ }
695
+ if (key?.name === "backspace") {
696
+ state.buffer = [...state.buffer].slice(0, -1).join("");
697
+ updateSlashState(state);
698
+ render();
699
+ continue;
700
+ }
701
+ if (key?.name === "up" && state.slashOpen) {
702
+ const matches = currentSlashMatches(state);
703
+ state.selected = Math.max(0, Math.min(matches.length - 1, state.selected - 1));
704
+ render();
705
+ continue;
706
+ }
707
+ if (key?.name === "down" && state.slashOpen) {
708
+ const matches = currentSlashMatches(state);
709
+ state.selected = Math.max(0, Math.min(matches.length - 1, state.selected + 1));
710
+ render();
711
+ continue;
712
+ }
713
+ if (isShiftEnter(str, key)) {
714
+ state.buffer += "\n";
715
+ state.slashOpen = false;
716
+ render();
717
+ continue;
718
+ }
719
+ if (key?.name === "return" || key?.name === "enter") {
720
+ const matches = currentSlashMatches(state);
721
+ const selected = matches[state.selected];
722
+ if (state.slashOpen && selected && state.buffer.trim() !== selected.command) {
723
+ state.buffer = selected.command;
724
+ state.slashOpen = false;
725
+ render();
726
+ continue;
727
+ }
728
+ const line = state.buffer.trim();
729
+ state.buffer = "";
730
+ state.slashOpen = false;
731
+ clearAgentInputArea();
732
+ if (!line) {
733
+ render();
734
+ continue;
735
+ }
736
+ try {
737
+ const shouldExit = await handleAgentLine(line, state);
738
+ if (shouldExit) break;
739
+ } catch (error) {
740
+ console.error(error instanceof Error ? error.message : String(error));
741
+ }
742
+ render();
743
+ continue;
744
+ }
745
+ if (str && !key?.ctrl && !key?.meta) {
746
+ state.buffer += str;
747
+ updateSlashState(state);
748
+ render();
749
+ }
750
+ }
751
+ } finally {
752
+ if (!wasRaw) input.setRawMode(false);
753
+ }
674
754
  }
675
755
 
676
756
  async function handleAgentLine(line, state) {
@@ -1064,10 +1144,10 @@ function printAgentHelp() {
1064
1144
  console.log("Обычный текст без slash-команды отправляется в настроенный AI-провайдер.");
1065
1145
  }
1066
1146
 
1067
- function printSlashMenu(filter = "") {
1147
+ function printSlashMenu(filter = "", options = {}) {
1068
1148
  const normalized = String(filter || "").replace(/^\//, "");
1069
1149
  const rows = getSlashCommandMatches(normalized)
1070
- .slice(0, 30)
1150
+ .slice(0, Number(options.limit || 30))
1071
1151
  .map((item) => ({ command: item.command, description: item.description }));
1072
1152
  if (rows.length === 0) {
1073
1153
  console.log(`Нет slash-команд по фильтру: ${filter}`);
@@ -1076,7 +1156,7 @@ function printSlashMenu(filter = "") {
1076
1156
  }
1077
1157
  console.log(normalized ? `Slash-команды по фильтру "${filter}":` : "Slash-команды:");
1078
1158
  printTable(rows, [["command", "Команда"], ["description", "Описание"]]);
1079
- if (SLASH_COMMANDS.length > rows.length && !normalized) {
1159
+ if (!options.compact && SLASH_COMMANDS.length > rows.length && !normalized) {
1080
1160
  console.log(`Показано ${rows.length} из ${SLASH_COMMANDS.length}. Введите /текст для фильтра.`);
1081
1161
  }
1082
1162
  }
@@ -1088,6 +1168,59 @@ function getSlashCommandMatches(filter = "") {
1088
1168
  || item.description.toLocaleLowerCase("ru-RU").includes(normalized));
1089
1169
  }
1090
1170
 
1171
+ function updateSlashState(state) {
1172
+ state.slashOpen = state.buffer.startsWith("/");
1173
+ state.selected = 0;
1174
+ }
1175
+
1176
+ function currentSlashMatches(state) {
1177
+ if (!state.buffer.startsWith("/")) return [];
1178
+ return getSlashCommandMatches(state.buffer.slice(1)).slice(0, 10);
1179
+ }
1180
+
1181
+ function renderAgentInput(state) {
1182
+ clearAgentInputArea();
1183
+ const prompt = "iola> ";
1184
+ const lines = state.buffer.split("\n");
1185
+ output.write(`${prompt}${lines[0] || ""}\n`);
1186
+ for (const line of lines.slice(1)) output.write(` ${line}\n`);
1187
+ if (state.slashOpen) {
1188
+ const matches = currentSlashMatches(state);
1189
+ if (matches.length === 0) {
1190
+ output.write(" нет команд\n");
1191
+ } else {
1192
+ for (let index = 0; index < matches.length; index += 1) {
1193
+ const marker = index === state.selected ? ">" : " ";
1194
+ output.write(`${marker} ${matches[index].command.padEnd(24)} ${matches[index].description}\n`);
1195
+ }
1196
+ output.write(" ↑/↓ выбрать • Enter вставить/выполнить • Esc закрыть\n");
1197
+ }
1198
+ }
1199
+ writePromptBottomPadding();
1200
+ }
1201
+
1202
+ function clearAgentInputArea() {
1203
+ if (!output.isTTY) return;
1204
+ output.write("\x1b[2K\r");
1205
+ }
1206
+
1207
+ function readKeypress() {
1208
+ return new Promise((resolve) => {
1209
+ const handler = (str, key) => {
1210
+ input.off("keypress", handler);
1211
+ resolve({ str, key });
1212
+ };
1213
+ input.on("keypress", handler);
1214
+ });
1215
+ }
1216
+
1217
+ function isShiftEnter(str, key) {
1218
+ return (key?.name === "return" && key.shift)
1219
+ || (key?.name === "enter" && key.shift)
1220
+ || String(str || "").includes("[13;2")
1221
+ || String(str || "").includes("[27;2;13");
1222
+ }
1223
+
1091
1224
  function printAgentHistory(history) {
1092
1225
  if (history.length === 0) {
1093
1226
  console.log("История пуста.");
@@ -1126,19 +1259,51 @@ function safePrompt(rl, closed = false) {
1126
1259
  }
1127
1260
 
1128
1261
  try {
1262
+ writePromptBottomPadding();
1129
1263
  rl.prompt();
1130
1264
  } catch {
1131
1265
  // The input stream can close while an async slash-command is still running.
1132
1266
  }
1133
1267
  }
1134
1268
 
1269
+ function attachSlashSuggestions(rl) {
1270
+ if (!input.isTTY) return () => {};
1271
+ emitKeypressEvents(input, rl);
1272
+ let lastFilter = null;
1273
+ const onKeypress = () => {
1274
+ setTimeout(() => {
1275
+ const line = rl.line || "";
1276
+ if (!line.startsWith("/")) {
1277
+ lastFilter = null;
1278
+ return;
1279
+ }
1280
+ const filter = line.slice(1);
1281
+ if (filter === lastFilter) return;
1282
+ lastFilter = filter;
1283
+ output.write("\n");
1284
+ printSlashMenu(filter, { compact: true, limit: 10 });
1285
+ writePromptBottomPadding();
1286
+ rl.prompt(true);
1287
+ }, 0);
1288
+ };
1289
+ input.on("keypress", onKeypress);
1290
+ return () => input.off("keypress", onKeypress);
1291
+ }
1292
+
1293
+ function writePromptBottomPadding() {
1294
+ if (!output.isTTY) return;
1295
+ const padding = Math.max(0, Math.min(5, Number(process.env.IOLA_PROMPT_BOTTOM_PADDING || 2)));
1296
+ if (padding === 0) return;
1297
+ output.write(`${"\n".repeat(padding)}\x1b[${padding}A`);
1298
+ }
1299
+
1135
1300
  async function showBanner(options = {}) {
1136
1301
  const version = getPackageVersion();
1137
1302
  const latest = options.skipUpdate ? null : await getLatestNpmVersion("@iola_adm/iola-cli");
1138
1303
  const updateAvailable = latest && compareVersions(latest, version) > 0;
1139
1304
  const versionLine = updateAvailable ? `v${version} -> v${latest} • npm install -g @iola_adm/iola-cli@latest` : `v${version} • iola help`;
1140
1305
  if (process.stdout.isTTY && process.env.NO_COLOR !== "1") {
1141
- console.log(BANNER.replace("VERSION_LINE", padBannerLine(versionLine)));
1306
+ console.log(renderBanner(versionLine, true));
1142
1307
  if (updateAvailable) {
1143
1308
  console.log(`Доступно обновление: v${version} -> v${latest}`);
1144
1309
  console.log("Обновить: npm install -g @iola_adm/iola-cli@latest");
@@ -1146,14 +1311,42 @@ async function showBanner(options = {}) {
1146
1311
  return;
1147
1312
  }
1148
1313
 
1149
- console.log(`CLI-ЙОШКАР-ОЛА ${updateAvailable ? `v${version} -> v${latest}` : `v${version}`}`);
1314
+ console.log(`CLI-Йошкар-Ола ${updateAvailable ? `v${version} -> v${latest}` : `v${version}`}`);
1150
1315
  console.log("открытые данные • MCP • локальный AI");
1151
1316
  if (updateAvailable) console.log("Обновить: npm install -g @iola_adm/iola-cli@latest");
1152
1317
  }
1153
1318
 
1154
- function padBannerLine(value) {
1155
- const text = String(value).slice(0, 62);
1156
- return `${text}${" ".repeat(Math.max(0, 62 - bannerVisibleLength(text)))}`;
1319
+ function renderBanner(versionLine, color = false) {
1320
+ const c = color ? {
1321
+ border: "\x1b[38;5;45m",
1322
+ title: "\x1b[38;5;213m",
1323
+ muted: "\x1b[38;5;250m",
1324
+ version: "\x1b[38;5;82m",
1325
+ reset: "\x1b[0m",
1326
+ } : { border: "", title: "", muted: "", version: "", reset: "" };
1327
+ const line = (text = "", style = "") => {
1328
+ const value = centerBannerText(text);
1329
+ return `${c.border}│${style}${value}${c.border}│`;
1330
+ };
1331
+ return [
1332
+ `${c.border}┌${"─".repeat(BANNER_WIDTH)}┐`,
1333
+ line(),
1334
+ line("CLI-Йошкар-Ола", c.title),
1335
+ line(),
1336
+ line("открытые данные • MCP • локальный AI", c.muted),
1337
+ line(),
1338
+ line(versionLine, c.version),
1339
+ `${c.border}└${"─".repeat(BANNER_WIDTH)}┘${c.reset}`,
1340
+ ].join("\n");
1341
+ }
1342
+
1343
+ function centerBannerText(value) {
1344
+ const text = String(value || "");
1345
+ const length = bannerVisibleLength(text);
1346
+ if (length >= BANNER_WIDTH) return [...text].slice(0, BANNER_WIDTH).join("");
1347
+ const left = Math.floor((BANNER_WIDTH - length) / 2);
1348
+ const right = BANNER_WIDTH - length - left;
1349
+ return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
1157
1350
  }
1158
1351
 
1159
1352
  function bannerVisibleLength(value) {