@iola_adm/iola-cli 0.1.30 → 0.1.32

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
@@ -74,6 +74,8 @@ iola budget status
74
74
  iola subagents list
75
75
  iola trajectory last
76
76
  iola review config
77
+ iola browser status
78
+ iola gosuslugi status
77
79
  ```
78
80
 
79
81
  Локальная модель через Ollama:
@@ -100,6 +102,8 @@ iola version --check
100
102
  - [Локальные файлы](https://github.com/adm-iola/iola-cli/wiki/Локальные-файлы)
101
103
  - [Рабочая среда агента](https://github.com/adm-iola/iola-cli/wiki/Рабочая-среда-агента)
102
104
  - [Платформа агента](https://github.com/adm-iola/iola-cli/wiki/Платформа-агента)
105
+ - [Браузерный агент](https://github.com/adm-iola/iola-cli/wiki/Браузерный-агент)
106
+ - [Подключение Госуслуг](https://github.com/adm-iola/iola-cli/wiki/Подключение-Госуслуг)
103
107
  - [Расширения и локальные данные](https://github.com/adm-iola/iola-cli/wiki/Расширения-и-локальные-данные)
104
108
  - [Архивы и мастер настройки](https://github.com/adm-iola/iola-cli/wiki/Архивы-и-мастер-настройки)
105
109
  - [Daemon, RPC и cron](https://github.com/adm-iola/iola-cli/wiki/Daemon-RPC-и-cron)
@@ -116,6 +120,8 @@ iola version --check
116
120
  - skills, toolsets, permissions, memory, hooks и готовые agents;
117
121
  - subagents, skill bundles, layered settings, usage/budget accounting и trajectory export;
118
122
  - полноценный локальный MCP server по stdio/http: tools, resources и prompts;
123
+ - браузерный runtime через Playwright: чтение страниц, скриншоты, PDF, клики, ввод и eval;
124
+ - локальный OAuth/OIDC-каркас для подключения Госуслуг/ЕСИА через официальный redirect flow;
119
125
  - управляемые локальные файловые операции с режимами `locked`, `read-only`, `workspace-write`, `full-access`;
120
126
  - планы выполнения, traces, tasks, artifacts, snapshots и policy-профили;
121
127
  - экспорт отчетов в Excel/Word-совместимые файлы;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
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
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
2
3
  import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
3
4
  import { createServer } from "node:http";
4
5
  import { appendFile, copyFile, cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
@@ -22,6 +23,8 @@ const DB_SCHEMA_VERSION = 8;
22
23
  const PROJECT_IOLA_DIR = path.join(process.cwd(), ".iola");
23
24
  const PROJECT_CONFIG_FILE = path.join(PROJECT_IOLA_DIR, "config.json");
24
25
  const LOCAL_CONFIG_FILE = path.join(PROJECT_IOLA_DIR, "local.json");
26
+ const BROWSER_RUNTIME_DIR = path.join(CONFIG_DIR, "browser-runtime");
27
+ const BROWSER_RUNTIME_PACKAGE = path.join(BROWSER_RUNTIME_DIR, "node_modules", "playwright", "package.json");
25
28
  const INDEXABLE_EXTENSIONS = /\.(md|txt|csv|json|html|docx|xlsx|pptx|pdf)$/i;
26
29
  const LOCAL_TOOLS = ["search_local", "get_card", "export_data", "run_report", "save_view"];
27
30
  const FILE_TOOLS = ["files_tree", "files_read", "files_search", "files_write", "files_patch"];
@@ -107,6 +110,18 @@ const DEFAULT_AI_CONFIG = {
107
110
  baseUrl: "https://apiiola.yasg.ru/api/v1",
108
111
  mcpBaseUrl: "https://apiiola.yasg.ru",
109
112
  },
113
+ gosuslugi: {
114
+ enabled: false,
115
+ authUrl: "",
116
+ tokenUrl: "",
117
+ userinfoUrl: "",
118
+ clientId: "",
119
+ clientSecret: "",
120
+ scope: "openid",
121
+ redirectHost: "127.0.0.1",
122
+ redirectPort: 18791,
123
+ redirectPath: "/gosuslugi/callback",
124
+ },
110
125
  ai: {
111
126
  activeProfile: "local",
112
127
  provider: "ollama",
@@ -260,6 +275,7 @@ const COMMANDS = new Map([
260
275
  ["fork", forkSession],
261
276
  ["features", handleFeatures],
262
277
  ["settings", handleSettings],
278
+ ["gosuslugi", handleGosuslugi],
263
279
  ["wiki", handleWiki],
264
280
  ["context", handleContext],
265
281
  ["skills", handleSkills],
@@ -271,6 +287,7 @@ const COMMANDS = new Map([
271
287
  ["index", handleIndex],
272
288
  ["reports", handleReports],
273
289
  ["plugins", handlePlugins],
290
+ ["browser", handleBrowser],
274
291
  ["workspace", handleWorkspace],
275
292
  ["tasks", handleTasks],
276
293
  ["artifacts", handleArtifacts],
@@ -385,6 +402,7 @@ Usage:
385
402
  iola fork SESSION_ID [TEXT]
386
403
  iola features list|enable|disable
387
404
  iola settings list|get|validate|doctor|init
405
+ iola gosuslugi configure|status|login|logout|userinfo
388
406
  iola wiki [open|links]
389
407
  iola context list|show|init
390
408
  iola skills list|show|paths|enable|disable|bundles|bundle|doctor
@@ -396,6 +414,7 @@ Usage:
396
414
  iola index folder|status|search
397
415
  iola reports list|run
398
416
  iola plugins list|install|run|remove
417
+ iola browser status|install|open|text|html|screenshot|pdf|click|type|eval
399
418
  iola workspace init|status|use|list
400
419
  iola tasks list|add|done|run
401
420
  iola artifacts list|show|open
@@ -680,6 +699,11 @@ async function handleAgentLine(line, state) {
680
699
  return false;
681
700
  }
682
701
 
702
+ if (command === "gosuslugi") {
703
+ await handleGosuslugi(args);
704
+ return false;
705
+ }
706
+
683
707
  if (command === "workspace") {
684
708
  await handleWorkspace(args);
685
709
  return false;
@@ -829,6 +853,7 @@ async function handleAgentLine(line, state) {
829
853
  resume: ["resume", args],
830
854
  fork: ["fork", args],
831
855
  features: ["features", args],
856
+ gosuslugi: ["gosuslugi", args],
832
857
  wiki: ["wiki", args],
833
858
  context: ["context", args],
834
859
  skills: ["skills", args],
@@ -887,6 +912,7 @@ function printAgentHelp() {
887
912
  /sessions
888
913
  /resume SESSION_ID
889
914
  /features list
915
+ /gosuslugi status
890
916
  /wiki
891
917
  /context list
892
918
  /skills list
@@ -1696,6 +1722,74 @@ async function handleSettings(args) {
1696
1722
  throw new Error("Команды settings: list, get [KEY], validate, doctor, init.");
1697
1723
  }
1698
1724
 
1725
+ async function handleGosuslugi(args) {
1726
+ const [action = "status", ...rest] = args;
1727
+ const options = parseOptions(rest);
1728
+
1729
+ if (action === "status") {
1730
+ const config = await loadConfig();
1731
+ const secrets = await loadSecrets();
1732
+ const tokens = secrets.gosuslugi?.tokens || null;
1733
+ printKeyValue({
1734
+ enabled: config.gosuslugi?.enabled ? "yes" : "no",
1735
+ configured: isGosuslugiConfigured(config) ? "yes" : "no",
1736
+ clientId: config.gosuslugi?.clientId ? maskSecret(config.gosuslugi.clientId) : "-",
1737
+ authUrl: config.gosuslugi?.authUrl || "-",
1738
+ tokenUrl: config.gosuslugi?.tokenUrl || "-",
1739
+ userinfoUrl: config.gosuslugi?.userinfoUrl || "-",
1740
+ redirectUri: gosuslugiRedirectUri(config),
1741
+ connected: tokens?.access_token ? "yes" : "no",
1742
+ savedAt: secrets.gosuslugi?.savedAt || "-",
1743
+ expiresAt: secrets.gosuslugi?.expiresAt || "-",
1744
+ });
1745
+ return;
1746
+ }
1747
+
1748
+ if (action === "configure") {
1749
+ const current = await loadConfig();
1750
+ const next = {
1751
+ ...(current.gosuslugi || {}),
1752
+ enabled: true,
1753
+ authUrl: options["auth-url"] || current.gosuslugi?.authUrl || "",
1754
+ tokenUrl: options["token-url"] || current.gosuslugi?.tokenUrl || "",
1755
+ userinfoUrl: options["userinfo-url"] || current.gosuslugi?.userinfoUrl || "",
1756
+ clientId: options["client-id"] || current.gosuslugi?.clientId || "",
1757
+ clientSecret: options["client-secret"] || current.gosuslugi?.clientSecret || "",
1758
+ scope: options.scope || current.gosuslugi?.scope || "openid",
1759
+ redirectHost: options["redirect-host"] || current.gosuslugi?.redirectHost || "127.0.0.1",
1760
+ redirectPort: Number(options["redirect-port"] || current.gosuslugi?.redirectPort || 18791),
1761
+ redirectPath: options["redirect-path"] || current.gosuslugi?.redirectPath || "/gosuslugi/callback",
1762
+ };
1763
+ await saveConfig({ gosuslugi: next });
1764
+ console.log("Настройки подключения к Госуслугам сохранены.");
1765
+ console.log(`Redirect URI: ${gosuslugiRedirectUri({ gosuslugi: next })}`);
1766
+ return;
1767
+ }
1768
+
1769
+ if (action === "login") {
1770
+ const result = await gosuslugiLogin(options);
1771
+ printKeyValue(result);
1772
+ return;
1773
+ }
1774
+
1775
+ if (action === "logout") {
1776
+ const secrets = await loadSecrets();
1777
+ delete secrets.gosuslugi;
1778
+ await saveSecrets(secrets);
1779
+ console.log("Локальное подключение Госуслуг удалено.");
1780
+ return;
1781
+ }
1782
+
1783
+ if (action === "userinfo" || action === "me") {
1784
+ const result = await gosuslugiUserinfo(options);
1785
+ if (options.json) printJson(result);
1786
+ else printKeyValue(flattenObjectForPrint(result));
1787
+ return;
1788
+ }
1789
+
1790
+ throw new Error("Команды gosuslugi: configure, status, login, logout, userinfo.");
1791
+ }
1792
+
1699
1793
  async function handleWiki(args) {
1700
1794
  const [action = "links"] = args;
1701
1795
  const base = "https://github.com/adm-iola/iola-cli/wiki";
@@ -1709,6 +1803,8 @@ async function handleWiki(args) {
1709
1803
  ["Локальные файлы", `${base}/Локальные-файлы`],
1710
1804
  ["Рабочая среда агента", `${base}/Рабочая-среда-агента`],
1711
1805
  ["Платформа агента", `${base}/Платформа-агента`],
1806
+ ["Браузерный агент", `${base}/Браузерный-агент`],
1807
+ ["Подключение Госуслуг", `${base}/Подключение-Госуслуг`],
1712
1808
  ["Расширения и локальные данные", `${base}/Расширения-и-локальные-данные`],
1713
1809
  ["Архивы и мастер настройки", `${base}/Архивы-и-мастер-настройки`],
1714
1810
  ["Daemon, RPC и cron", `${base}/Daemon-RPC-и-cron`],
@@ -2209,6 +2305,94 @@ async function handlePlugins(args) {
2209
2305
  throw new Error("Команды plugins: list, install NAME --command CMD, run NAME, remove NAME.");
2210
2306
  }
2211
2307
 
2308
+ async function handleBrowser(args) {
2309
+ const [action = "status", target, ...rest] = args;
2310
+ const options = parseOptions(rest);
2311
+
2312
+ if (action === "status") {
2313
+ printKeyValue(await getBrowserStatus());
2314
+ return;
2315
+ }
2316
+
2317
+ if (action === "install") {
2318
+ await installBrowserRuntime();
2319
+ printKeyValue(await getBrowserStatus());
2320
+ return;
2321
+ }
2322
+
2323
+ if (action === "open") {
2324
+ const url = target || options.url;
2325
+ if (!url) throw new Error("Пример: iola browser open https://example.com");
2326
+ if (options.system) {
2327
+ await openUrl(url);
2328
+ return;
2329
+ }
2330
+ await runBrowserAutomation("open", { url, headed: options.headless ? false : true, waitMs: Number(options.wait || 600000) });
2331
+ return;
2332
+ }
2333
+
2334
+ if (action === "text" || action === "html") {
2335
+ const url = target || options.url;
2336
+ if (!url) throw new Error(`Пример: iola browser ${action} https://example.com`);
2337
+ const result = await runBrowserAutomation(action, browserParams(url, options));
2338
+ if (options.output) {
2339
+ await writeFile(options.output, result, "utf8");
2340
+ console.log(`Файл сохранен: ${options.output}`);
2341
+ } else {
2342
+ console.log(result);
2343
+ }
2344
+ return;
2345
+ }
2346
+
2347
+ if (action === "screenshot" || action === "pdf") {
2348
+ const url = target || options.url;
2349
+ if (!url) throw new Error(`Пример: iola browser ${action} https://example.com --output page.${action === "pdf" ? "pdf" : "png"}`);
2350
+ const output = options.output || path.join(process.cwd(), action === "pdf" ? "browser-page.pdf" : "browser-page.png");
2351
+ await runBrowserAutomation(action, { ...browserParams(url, options), output: path.resolve(output) });
2352
+ saveArtifact(action === "pdf" ? "browser-pdf" : "browser-screenshot", url, path.resolve(output), { url });
2353
+ console.log(`Файл сохранен: ${output}`);
2354
+ return;
2355
+ }
2356
+
2357
+ if (action === "click") {
2358
+ const url = target || options.url;
2359
+ if (!url || !options.selector) throw new Error('Пример: iola browser click https://example.com --selector "button" --output after.png');
2360
+ const result = await runBrowserAutomation("click", { ...browserParams(url, options), selector: options.selector, output: options.output ? path.resolve(options.output) : "" });
2361
+ if (result) console.log(result);
2362
+ return;
2363
+ }
2364
+
2365
+ if (action === "type") {
2366
+ const url = target || options.url;
2367
+ if (!url || !options.selector || options.text === undefined) throw new Error('Пример: iola browser type https://example.com --selector "#q" --text "школа 29"');
2368
+ const result = await runBrowserAutomation("type", { ...browserParams(url, options), selector: options.selector, text: options.text, press: options.press || "", output: options.output ? path.resolve(options.output) : "" });
2369
+ if (result) console.log(result);
2370
+ return;
2371
+ }
2372
+
2373
+ if (action === "eval") {
2374
+ const url = target || options.url;
2375
+ const script = options.script || rest.join(" ");
2376
+ if (!url || !script) throw new Error('Пример: iola browser eval https://example.com --script "document.title"');
2377
+ const result = await runBrowserAutomation("eval", { ...browserParams(url, options), script });
2378
+ console.log(result);
2379
+ return;
2380
+ }
2381
+
2382
+ throw new Error("Команды browser: status, install, open URL, text URL, html URL, screenshot URL --output FILE, pdf URL --output FILE, click URL --selector SEL, type URL --selector SEL --text TEXT, eval URL --script JS.");
2383
+ }
2384
+
2385
+ function browserParams(url, options = {}) {
2386
+ return {
2387
+ url,
2388
+ headed: Boolean(options.headed),
2389
+ timeout: Number(options.timeout || 30000),
2390
+ waitMs: Number(options.wait || 0),
2391
+ selector: options.selector || "",
2392
+ viewport: options.viewport || "1366x768",
2393
+ };
2394
+ }
2395
+
2212
2396
  async function handleWorkspace(args) {
2213
2397
  const [action = "status", nameOrPath] = args;
2214
2398
  const config = await loadConfig();
@@ -2547,6 +2731,177 @@ async function openUrl(url) {
2547
2731
  await runCommand("xdg-open", [url], { inherit: false });
2548
2732
  }
2549
2733
 
2734
+ async function gosuslugiLogin(options = {}) {
2735
+ const config = await loadConfig();
2736
+ if (!isGosuslugiConfigured(config)) {
2737
+ throw new Error("Подключение не настроено. Пример: iola gosuslugi configure --auth-url URL --token-url URL --client-id ID --scope openid");
2738
+ }
2739
+
2740
+ const state = randomUrlSafe(24);
2741
+ const codeVerifier = randomUrlSafe(64);
2742
+ const codeChallenge = base64Url(createHash("sha256").update(codeVerifier).digest());
2743
+ const redirectUri = gosuslugiRedirectUri(config);
2744
+ const callback = waitForOAuthCallback(config.gosuslugi, state, Number(options.timeout || 180000));
2745
+ const authUrl = new URL(config.gosuslugi.authUrl);
2746
+ authUrl.searchParams.set("response_type", "code");
2747
+ authUrl.searchParams.set("client_id", config.gosuslugi.clientId);
2748
+ authUrl.searchParams.set("redirect_uri", redirectUri);
2749
+ authUrl.searchParams.set("scope", config.gosuslugi.scope || "openid");
2750
+ authUrl.searchParams.set("state", state);
2751
+ authUrl.searchParams.set("code_challenge", codeChallenge);
2752
+ authUrl.searchParams.set("code_challenge_method", "S256");
2753
+
2754
+ console.log("Открываю официальный экран входа Госуслуг/ЕСИА в браузере.");
2755
+ console.log("После входа CLI примет callback на локальном адресе и сохранит токены только на этом компьютере.");
2756
+ await openUrl(authUrl.toString());
2757
+ const params = await callback;
2758
+ if (params.error) throw new Error(`Госуслуги вернули ошибку: ${params.error} ${params.error_description || ""}`.trim());
2759
+ if (!params.code) throw new Error("Authorization code не получен.");
2760
+
2761
+ const tokens = await exchangeGosuslugiCode(config, {
2762
+ code: params.code,
2763
+ codeVerifier,
2764
+ redirectUri,
2765
+ });
2766
+ const secrets = await loadSecrets();
2767
+ const now = new Date();
2768
+ const expiresAt = tokens.expires_in ? new Date(now.getTime() + Number(tokens.expires_in) * 1000).toISOString() : "";
2769
+ secrets.gosuslugi = {
2770
+ savedAt: now.toISOString(),
2771
+ expiresAt,
2772
+ tokens,
2773
+ };
2774
+ await saveSecrets(secrets);
2775
+ return {
2776
+ connected: "yes",
2777
+ savedAt: secrets.gosuslugi.savedAt,
2778
+ expiresAt: expiresAt || "-",
2779
+ tokenType: tokens.token_type || "-",
2780
+ scope: tokens.scope || config.gosuslugi.scope || "-",
2781
+ };
2782
+ }
2783
+
2784
+ function waitForOAuthCallback(settings, expectedState, timeoutMs) {
2785
+ const host = settings.redirectHost || "127.0.0.1";
2786
+ const port = Number(settings.redirectPort || 18791);
2787
+ const callbackPath = settings.redirectPath || "/gosuslugi/callback";
2788
+ return new Promise((resolve, reject) => {
2789
+ const timer = setTimeout(() => {
2790
+ server.close(() => {});
2791
+ reject(new Error("Истекло время ожидания входа через Госуслуги."));
2792
+ }, timeoutMs);
2793
+ const server = createServer((req, res) => {
2794
+ const url = new URL(req.url || "/", `http://${host}:${port}`);
2795
+ if (url.pathname !== callbackPath) {
2796
+ res.statusCode = 404;
2797
+ res.end("Not found");
2798
+ return;
2799
+ }
2800
+ const params = Object.fromEntries(url.searchParams.entries());
2801
+ if (params.state !== expectedState) {
2802
+ res.statusCode = 400;
2803
+ res.end("Invalid state");
2804
+ clearTimeout(timer);
2805
+ server.close(() => {});
2806
+ reject(new Error("OAuth state не совпал. Вход отменен."));
2807
+ return;
2808
+ }
2809
+ res.setHeader("content-type", "text/html; charset=utf-8");
2810
+ res.end("<!doctype html><meta charset=\"utf-8\"><title>iola</title><body>Вход выполнен. Можно закрыть это окно и вернуться в терминал.</body>");
2811
+ clearTimeout(timer);
2812
+ server.close(() => resolve(params));
2813
+ });
2814
+ server.once("error", (error) => {
2815
+ clearTimeout(timer);
2816
+ reject(error);
2817
+ });
2818
+ server.listen(port, host);
2819
+ });
2820
+ }
2821
+
2822
+ async function exchangeGosuslugiCode(config, { code, codeVerifier, redirectUri }) {
2823
+ const body = new URLSearchParams();
2824
+ body.set("grant_type", "authorization_code");
2825
+ body.set("code", code);
2826
+ body.set("redirect_uri", redirectUri);
2827
+ body.set("client_id", config.gosuslugi.clientId);
2828
+ body.set("code_verifier", codeVerifier);
2829
+ if (config.gosuslugi.clientSecret) body.set("client_secret", config.gosuslugi.clientSecret);
2830
+
2831
+ const response = await fetch(config.gosuslugi.tokenUrl, {
2832
+ method: "POST",
2833
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
2834
+ body,
2835
+ });
2836
+ const text = await response.text();
2837
+ let payload = {};
2838
+ try {
2839
+ payload = text ? JSON.parse(text) : {};
2840
+ } catch {
2841
+ payload = { raw: text };
2842
+ }
2843
+ if (!response.ok) {
2844
+ throw new Error(`Token endpoint вернул ${response.status}: ${JSON.stringify(payload)}`);
2845
+ }
2846
+ return payload;
2847
+ }
2848
+
2849
+ async function gosuslugiUserinfo() {
2850
+ const config = await loadConfig();
2851
+ const secrets = await loadSecrets();
2852
+ const accessToken = secrets.gosuslugi?.tokens?.access_token;
2853
+ if (!accessToken) throw new Error("Госуслуги не подключены. Запустите: iola gosuslugi login");
2854
+ if (!config.gosuslugi?.userinfoUrl) throw new Error("userinfoUrl не настроен.");
2855
+ const response = await fetch(config.gosuslugi.userinfoUrl, {
2856
+ headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" },
2857
+ });
2858
+ const text = await response.text();
2859
+ let payload = {};
2860
+ try {
2861
+ payload = text ? JSON.parse(text) : {};
2862
+ } catch {
2863
+ payload = { raw: text };
2864
+ }
2865
+ if (!response.ok) throw new Error(`Userinfo endpoint вернул ${response.status}: ${JSON.stringify(payload)}`);
2866
+ return payload;
2867
+ }
2868
+
2869
+ function isGosuslugiConfigured(config) {
2870
+ return Boolean(config.gosuslugi?.authUrl && config.gosuslugi?.tokenUrl && config.gosuslugi?.clientId);
2871
+ }
2872
+
2873
+ function gosuslugiRedirectUri(config) {
2874
+ const settings = config.gosuslugi || DEFAULT_AI_CONFIG.gosuslugi;
2875
+ return `http://${settings.redirectHost || "127.0.0.1"}:${Number(settings.redirectPort || 18791)}${settings.redirectPath || "/gosuslugi/callback"}`;
2876
+ }
2877
+
2878
+ function randomUrlSafe(bytes) {
2879
+ return base64Url(randomBytes(bytes));
2880
+ }
2881
+
2882
+ function base64Url(buffer) {
2883
+ return Buffer.from(buffer).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
2884
+ }
2885
+
2886
+ function maskSecret(value) {
2887
+ const text = String(value || "");
2888
+ if (text.length <= 8) return text ? "***" : "-";
2889
+ return `${text.slice(0, 4)}...${text.slice(-4)}`;
2890
+ }
2891
+
2892
+ function flattenObjectForPrint(value, prefix = "") {
2893
+ const rows = {};
2894
+ for (const [key, item] of Object.entries(value || {})) {
2895
+ const name = prefix ? `${prefix}.${key}` : key;
2896
+ if (item && typeof item === "object" && !Array.isArray(item)) {
2897
+ Object.assign(rows, flattenObjectForPrint(item, name));
2898
+ } else {
2899
+ rows[name] = Array.isArray(item) ? item.join(", ") : item;
2900
+ }
2901
+ }
2902
+ return rows;
2903
+ }
2904
+
2550
2905
  async function handlePermissions(args) {
2551
2906
  const [action = "list", name] = args;
2552
2907
  const config = await loadConfig();
@@ -5903,12 +6258,12 @@ function parseOptions(args) {
5903
6258
 
5904
6259
  for (let index = 0; index < args.length; index += 1) {
5905
6260
  const arg = args[index];
5906
- if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--stream-json" || arg === "--stdio" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--files" || arg === "--plan" || arg === "--trace" || arg === "--diff" || arg === "--stage" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix" || arg === "--append") {
6261
+ if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--stream-json" || arg === "--stdio" || arg === "--system" || arg === "--headed" || arg === "--headless" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--files" || arg === "--plan" || arg === "--trace" || arg === "--diff" || arg === "--stage" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix" || arg === "--append") {
5907
6262
  result[arg.slice(2)] = true;
5908
6263
  } else if (arg === "--check" || arg === "--upgrade-node") {
5909
6264
  result.check = true;
5910
6265
  result[arg.slice(2)] = true;
5911
- } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--replace" || arg === "--text" || arg === "--path" || arg === "--depth" || arg === "--max-bytes" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--source" || arg === "--command" || arg === "--prompt" || arg === "--description" || 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" || arg === "--agent" || arg === "--scope" || arg === "--debug-file") {
6266
+ } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--replace" || arg === "--text" || arg === "--path" || arg === "--depth" || arg === "--max-bytes" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--source" || arg === "--command" || arg === "--prompt" || arg === "--description" || 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" || arg === "--agent" || arg === "--scope" || arg === "--selector" || arg === "--url" || arg === "--timeout" || arg === "--wait" || arg === "--viewport" || arg === "--press" || arg === "--script" || arg === "--auth-url" || arg === "--token-url" || arg === "--userinfo-url" || arg === "--client-id" || arg === "--client-secret" || arg === "--redirect-host" || arg === "--redirect-port" || arg === "--redirect-path" || arg === "--debug-file") {
5912
6267
  result[arg.slice(2)] = args[index + 1];
5913
6268
  index += 1;
5914
6269
  } else {
@@ -6285,6 +6640,105 @@ async function installCodexIfMissing() {
6285
6640
  await runCommand("npm", ["install", "-g", "@openai/codex"], { inherit: true });
6286
6641
  }
6287
6642
 
6643
+ async function getBrowserStatus() {
6644
+ const installed = existsSync(BROWSER_RUNTIME_PACKAGE);
6645
+ let playwright = "не установлен";
6646
+ if (installed) {
6647
+ try {
6648
+ playwright = JSON.parse(await readFile(BROWSER_RUNTIME_PACKAGE, "utf8")).version || "installed";
6649
+ } catch {
6650
+ playwright = "installed";
6651
+ }
6652
+ }
6653
+ return {
6654
+ runtime: BROWSER_RUNTIME_DIR,
6655
+ playwright,
6656
+ installed: installed ? "yes" : "no",
6657
+ install_command: "iola browser install",
6658
+ chromium: installed ? "managed by Playwright" : "not installed",
6659
+ };
6660
+ }
6661
+
6662
+ async function installBrowserRuntime() {
6663
+ await mkdir(BROWSER_RUNTIME_DIR, { recursive: true });
6664
+ const packageFile = path.join(BROWSER_RUNTIME_DIR, "package.json");
6665
+ if (!existsSync(packageFile)) {
6666
+ await writeFile(packageFile, `${JSON.stringify({ private: true, type: "module", dependencies: {} }, null, 2)}\n`, "utf8");
6667
+ }
6668
+ console.log(`Устанавливаю Playwright runtime: ${BROWSER_RUNTIME_DIR}`);
6669
+ await runPackageManager("npm", ["install", "playwright@latest"], { inherit: true, cwd: BROWSER_RUNTIME_DIR });
6670
+ await runPackageManager("npx", ["playwright", "install", "chromium"], { inherit: true, cwd: BROWSER_RUNTIME_DIR });
6671
+ }
6672
+
6673
+ function runPackageManager(command, args, options = {}) {
6674
+ if (process.platform === "win32") {
6675
+ return runCommand(process.env.ComSpec || "cmd.exe", ["/d", "/c", [command, ...args].join(" ")], options);
6676
+ }
6677
+ return runCommand(command, args, options);
6678
+ }
6679
+
6680
+ async function ensureBrowserRuntime() {
6681
+ if (existsSync(BROWSER_RUNTIME_PACKAGE)) return;
6682
+ throw new Error("Browser runtime не установлен. Запустите: iola browser install");
6683
+ }
6684
+
6685
+ async function runBrowserAutomation(action, params) {
6686
+ await ensureBrowserRuntime();
6687
+ const scriptFile = path.join(BROWSER_RUNTIME_DIR, `iola-browser-${Date.now()}-${Math.random().toString(16).slice(2)}.mjs`);
6688
+ await writeFile(scriptFile, browserAutomationScript(action, params), "utf8");
6689
+ try {
6690
+ const { stdout } = await runCommand(process.execPath, [scriptFile], { cwd: BROWSER_RUNTIME_DIR });
6691
+ return stdout.trim();
6692
+ } finally {
6693
+ await rm(scriptFile, { force: true }).catch(() => {});
6694
+ }
6695
+ }
6696
+
6697
+ function browserAutomationScript(action, params) {
6698
+ return `
6699
+ import { chromium } from "playwright";
6700
+ const action = ${JSON.stringify(action)};
6701
+ const params = ${JSON.stringify(params)};
6702
+ const [width, height] = String(params.viewport || "1366x768").split("x").map(Number);
6703
+ const browser = await chromium.launch({ headless: !params.headed });
6704
+ const page = await browser.newPage({ viewport: { width: width || 1366, height: height || 768 } });
6705
+ page.setDefaultTimeout(params.timeout || 30000);
6706
+ try {
6707
+ await page.goto(params.url, { waitUntil: "domcontentloaded", timeout: params.timeout || 30000 });
6708
+ if (params.waitMs) await page.waitForTimeout(params.waitMs);
6709
+ if (action === "open") {
6710
+ if (params.waitMs > 0) await page.waitForTimeout(params.waitMs);
6711
+ else if (!page.context().browser()?.isConnected()) {}
6712
+ } else if (action === "text") {
6713
+ console.log((await page.locator("body").innerText()).trim());
6714
+ } else if (action === "html") {
6715
+ console.log(await page.content());
6716
+ } else if (action === "screenshot") {
6717
+ await page.screenshot({ path: params.output, fullPage: true });
6718
+ } else if (action === "pdf") {
6719
+ await page.pdf({ path: params.output, format: "A4", printBackground: true });
6720
+ } else if (action === "click") {
6721
+ await page.locator(params.selector).first().click();
6722
+ if (params.waitMs) await page.waitForTimeout(params.waitMs);
6723
+ if (params.output) await page.screenshot({ path: params.output, fullPage: true });
6724
+ console.log((await page.locator("body").innerText()).trim().slice(0, 4000));
6725
+ } else if (action === "type") {
6726
+ const locator = page.locator(params.selector).first();
6727
+ await locator.fill(params.text || "");
6728
+ if (params.press) await locator.press(params.press);
6729
+ if (params.waitMs) await page.waitForTimeout(params.waitMs);
6730
+ if (params.output) await page.screenshot({ path: params.output, fullPage: true });
6731
+ console.log((await page.locator("body").innerText()).trim().slice(0, 4000));
6732
+ } else if (action === "eval") {
6733
+ const value = await page.evaluate(new Function("return (" + params.script + ")"));
6734
+ console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2));
6735
+ }
6736
+ } finally {
6737
+ await browser.close();
6738
+ }
6739
+ `;
6740
+ }
6741
+
6288
6742
  async function probeEndpoint(url) {
6289
6743
  try {
6290
6744
  const response = await fetch(url, { headers: { accept: "application/json" } });
@@ -6468,6 +6922,8 @@ function mcpTools() {
6468
6922
  { name: "files.search", description: "Поиск текста в файлах workspace.", inputSchema: schema({ query: { type: "string" }, path: { type: "string" }, limit: { type: "number" } }) },
6469
6923
  { name: "index.search", description: "Поиск по индексу локальных документов.", inputSchema: schema({ query: { type: "string" }, limit: { type: "number" } }) },
6470
6924
  { name: "report", description: "Запуск встроенного отчета.", inputSchema: schema({ name: { type: "string" }, format: { type: "string" }, output: { type: "string" } }) },
6925
+ { name: "browser.text", description: "Открыть страницу в headless Chromium и вернуть видимый текст.", inputSchema: schema({ url: { type: "string" }, waitMs: { type: "number" } }) },
6926
+ { name: "browser.screenshot", description: "Сделать скриншот страницы через Chromium.", inputSchema: schema({ url: { type: "string" }, output: { type: "string" }, waitMs: { type: "number" } }) },
6471
6927
  ];
6472
6928
  }
6473
6929
 
@@ -6497,6 +6953,14 @@ async function callMcpTool(name, args = {}) {
6497
6953
  await handleExport([args.name || "education-contacts", "--format", args.format || "xlsx", "--output", output]);
6498
6954
  return { output };
6499
6955
  }
6956
+ if (name === "browser.text") {
6957
+ return runBrowserAutomation("text", { url: args.url, waitMs: Number(args.waitMs || 0), timeout: Number(args.timeout || 30000), viewport: args.viewport || "1366x768" });
6958
+ }
6959
+ if (name === "browser.screenshot") {
6960
+ const output = path.resolve(args.output || "browser-page.png");
6961
+ await runBrowserAutomation("screenshot", { url: args.url, output, waitMs: Number(args.waitMs || 0), timeout: Number(args.timeout || 30000), viewport: args.viewport || "1366x768" });
6962
+ return { output };
6963
+ }
6500
6964
  return executeRpc(name, { ...args, _: [] });
6501
6965
  }
6502
6966
 
@@ -7567,6 +8031,10 @@ function mergeConfig(base, override) {
7567
8031
  ...base.api,
7568
8032
  ...(override.api || {}),
7569
8033
  },
8034
+ gosuslugi: {
8035
+ ...base.gosuslugi,
8036
+ ...(override.gosuslugi || {}),
8037
+ },
7570
8038
  ai: {
7571
8039
  ...base.ai,
7572
8040
  ...(override.ai || {}),
@@ -7641,6 +8109,9 @@ function validateConfig(config) {
7641
8109
  for (const toolset of config.toolsets?.enabled || []) {
7642
8110
  if (!TOOLSETS[toolset]) errors.push(`toolsets.enabled содержит неизвестный toolset: ${toolset}`);
7643
8111
  }
8112
+ if (config.gosuslugi?.enabled && !isGosuslugiConfigured(config)) {
8113
+ errors.push("gosuslugi включен, но authUrl/tokenUrl/clientId не заполнены");
8114
+ }
7644
8115
  return errors;
7645
8116
  }
7646
8117
 
@@ -7650,6 +8121,7 @@ function configSchema() {
7650
8121
  required: ["api", "ai"],
7651
8122
  properties: {
7652
8123
  api: { required: ["baseUrl", "mcpBaseUrl"] },
8124
+ gosuslugi: { requiredWhenEnabled: ["authUrl", "tokenUrl", "clientId"], optional: ["userinfoUrl", "clientSecret", "scope", "redirectHost", "redirectPort", "redirectPath"] },
7653
8125
  ai: { required: ["activeProfile", "profiles"], providers: ["ollama", "openai", "openrouter", "codex"] },
7654
8126
  permissions: { localTools: ALL_LOCAL_TOOLS, runtime: ["readFiles", "writeFiles", "editFiles", "deleteFiles", "sync", "externalApi", "externalAi", "codex"] },
7655
8127
  toolsets: { available: Object.keys(TOOLSETS) },
package/wiki/Home.md CHANGED
@@ -32,6 +32,8 @@ iola ask "найди школу 29"
32
32
  - [Локальные файлы](Локальные-файлы)
33
33
  - [Рабочая среда агента](Рабочая-среда-агента)
34
34
  - [Платформа агента](Платформа-агента)
35
+ - [Браузерный агент](Браузерный-агент)
36
+ - [Подключение Госуслуг](Подключение-Госуслуг)
35
37
  - [Расширения и локальные данные](Расширения-и-локальные-данные)
36
38
  - [Архивы и мастер настройки](Архивы-и-мастер-настройки)
37
39
  - [Daemon, RPC и cron](Daemon-RPC-и-cron)
@@ -0,0 +1,68 @@
1
+ # Браузерный агент
2
+
3
+ `iola-cli` умеет подключать локальный браузерный runtime на Playwright. Сам npm-пакет остается легким: Chromium и Playwright ставятся отдельно при первом включении браузера.
4
+
5
+ ## Установка
6
+
7
+ ```bash
8
+ iola browser status
9
+ iola browser install
10
+ ```
11
+
12
+ Runtime хранится в `~/.iola/browser-runtime`.
13
+
14
+ ## Базовые команды
15
+
16
+ ```bash
17
+ iola browser open https://example.com
18
+ iola browser text https://example.com
19
+ iola browser html https://example.com --output page.html
20
+ iola browser screenshot https://example.com --output page.png
21
+ iola browser pdf https://example.com --output page.pdf
22
+ ```
23
+
24
+ `open` запускает видимый Chromium и держит его открытым. По умолчанию окно живет 10 минут. Можно задать время:
25
+
26
+ ```bash
27
+ iola browser open https://example.com --wait 30000
28
+ ```
29
+
30
+ ## Действия на странице
31
+
32
+ Клик:
33
+
34
+ ```bash
35
+ iola browser click https://example.com --selector "button" --output after-click.png
36
+ ```
37
+
38
+ Ввод:
39
+
40
+ ```bash
41
+ iola browser type https://example.com --selector "#q" --text "школа 29" --press Enter --wait 2000 --output search.png
42
+ ```
43
+
44
+ Выполнение JavaScript-выражения:
45
+
46
+ ```bash
47
+ iola browser eval https://example.com --script "document.title"
48
+ ```
49
+
50
+ ## MCP
51
+
52
+ Локальный MCP server также отдает браузерные tools:
53
+
54
+ - `browser.text`;
55
+ - `browser.screenshot`.
56
+
57
+ Перед использованием этих tools нужно один раз выполнить:
58
+
59
+ ```bash
60
+ iola browser install
61
+ ```
62
+
63
+ ## Режимы
64
+
65
+ - Для чтения страниц используйте `text`, `html`, `screenshot`, `pdf`.
66
+ - Для действий используйте `click` и `type`.
67
+ - Для ручной авторизации или просмотра используйте `open`.
68
+
@@ -74,6 +74,10 @@ iola import file data.csv --dataset custom
74
74
  iola index folder ./docs
75
75
  iola reports list
76
76
  iola plugins list
77
+ iola browser status
78
+ iola browser install
79
+ iola browser text https://example.com
80
+ iola browser screenshot https://example.com --output page.png
77
81
  iola mcp serve
78
82
  iola mcp serve --stdio
79
83
  iola memory suggest
@@ -88,6 +92,10 @@ iola context init
88
92
  iola context list
89
93
  iola settings list
90
94
  iola settings validate
95
+ iola gosuslugi status
96
+ iola gosuslugi configure --auth-url URL --token-url URL --client-id ID --scope openid
97
+ iola gosuslugi login
98
+ iola gosuslugi userinfo --json
91
99
  iola cron add "каждый день 09:00 -- quality"
92
100
  iola cron tick
93
101
  iola daemon status
@@ -0,0 +1,73 @@
1
+ # Подключение Госуслуг
2
+
3
+ `iola-cli` поддерживает локальный OAuth/OIDC-каркас для подключения учетной записи через официальный flow ЕСИА/Госуслуг.
4
+
5
+ Важно: CLI не извлекает cookies, токены или session storage из браузера. Пользователь входит на официальном экране ЕСИА, после чего ЕСИА возвращает `authorization code` на локальный `redirect_uri`, а CLI обменивает его на токены через `token endpoint`.
6
+
7
+ ## Настройка
8
+
9
+ Для работы нужны параметры официально подключенной информационной системы:
10
+
11
+ - authorization endpoint;
12
+ - token endpoint;
13
+ - client ID;
14
+ - разрешенный redirect URI;
15
+ - scope;
16
+ - при необходимости client secret или иной официальный способ подписи/аутентификации клиента;
17
+ - optional userinfo endpoint.
18
+
19
+ Команда настройки:
20
+
21
+ ```bash
22
+ iola gosuslugi configure \
23
+ --auth-url "https://..." \
24
+ --token-url "https://..." \
25
+ --userinfo-url "https://..." \
26
+ --client-id "CLIENT_ID" \
27
+ --scope "openid" \
28
+ --redirect-port 18791
29
+ ```
30
+
31
+ CLI покажет redirect URI:
32
+
33
+ ```text
34
+ http://127.0.0.1:18791/gosuslugi/callback
35
+ ```
36
+
37
+ Этот URI должен быть разрешен в настройках подключенной информационной системы.
38
+
39
+ ## Вход
40
+
41
+ ```bash
42
+ iola gosuslugi login
43
+ ```
44
+
45
+ Что происходит:
46
+
47
+ 1. CLI запускает локальный callback server на `127.0.0.1`.
48
+ 2. Открывается официальный экран входа ЕСИА/Госуслуг.
49
+ 3. Пользователь сам вводит логин, пароль, SMS/2FA.
50
+ 4. ЕСИА возвращает `authorization code` на локальный callback.
51
+ 5. CLI обменивает code на токены и сохраняет их локально.
52
+
53
+ Токены хранятся в `~/.iola/secrets.json` на компьютере пользователя.
54
+
55
+ ## Проверка
56
+
57
+ ```bash
58
+ iola gosuslugi status
59
+ iola gosuslugi userinfo --json
60
+ ```
61
+
62
+ ## Выход
63
+
64
+ ```bash
65
+ iola gosuslugi logout
66
+ ```
67
+
68
+ Команда удаляет локально сохраненные токены.
69
+
70
+ ## Ограничения
71
+
72
+ Без официальных параметров ЕСИА команда `login` не сможет завершить подключение. Это сделано намеренно: CLI реализует легальный redirect flow, а не копирование живой браузерной сессии.
73
+