@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.
- package/package.json +1 -1
- package/src/cli.js +216 -23
package/package.json
CHANGED
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
1155
|
-
const
|
|
1156
|
-
|
|
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) {
|