@iola_adm/iola-cli 0.1.31 → 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
@@ -75,6 +75,7 @@ iola subagents list
75
75
  iola trajectory last
76
76
  iola review config
77
77
  iola browser status
78
+ iola gosuslugi status
78
79
  ```
79
80
 
80
81
  Локальная модель через Ollama:
@@ -102,6 +103,7 @@ iola version --check
102
103
  - [Рабочая среда агента](https://github.com/adm-iola/iola-cli/wiki/Рабочая-среда-агента)
103
104
  - [Платформа агента](https://github.com/adm-iola/iola-cli/wiki/Платформа-агента)
104
105
  - [Браузерный агент](https://github.com/adm-iola/iola-cli/wiki/Браузерный-агент)
106
+ - [Подключение Госуслуг](https://github.com/adm-iola/iola-cli/wiki/Подключение-Госуслуг)
105
107
  - [Расширения и локальные данные](https://github.com/adm-iola/iola-cli/wiki/Расширения-и-локальные-данные)
106
108
  - [Архивы и мастер настройки](https://github.com/adm-iola/iola-cli/wiki/Архивы-и-мастер-настройки)
107
109
  - [Daemon, RPC и cron](https://github.com/adm-iola/iola-cli/wiki/Daemon-RPC-и-cron)
@@ -119,6 +121,7 @@ iola version --check
119
121
  - subagents, skill bundles, layered settings, usage/budget accounting и trajectory export;
120
122
  - полноценный локальный MCP server по stdio/http: tools, resources и prompts;
121
123
  - браузерный runtime через Playwright: чтение страниц, скриншоты, PDF, клики, ввод и eval;
124
+ - локальный OAuth/OIDC-каркас для подключения Госуслуг/ЕСИА через официальный redirect flow;
122
125
  - управляемые локальные файловые операции с режимами `locked`, `read-only`, `workspace-write`, `full-access`;
123
126
  - планы выполнения, traces, tasks, artifacts, snapshots и policy-профили;
124
127
  - экспорт отчетов в Excel/Word-совместимые файлы;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.31",
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";
@@ -109,6 +110,18 @@ const DEFAULT_AI_CONFIG = {
109
110
  baseUrl: "https://apiiola.yasg.ru/api/v1",
110
111
  mcpBaseUrl: "https://apiiola.yasg.ru",
111
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
+ },
112
125
  ai: {
113
126
  activeProfile: "local",
114
127
  provider: "ollama",
@@ -262,6 +275,7 @@ const COMMANDS = new Map([
262
275
  ["fork", forkSession],
263
276
  ["features", handleFeatures],
264
277
  ["settings", handleSettings],
278
+ ["gosuslugi", handleGosuslugi],
265
279
  ["wiki", handleWiki],
266
280
  ["context", handleContext],
267
281
  ["skills", handleSkills],
@@ -388,6 +402,7 @@ Usage:
388
402
  iola fork SESSION_ID [TEXT]
389
403
  iola features list|enable|disable
390
404
  iola settings list|get|validate|doctor|init
405
+ iola gosuslugi configure|status|login|logout|userinfo
391
406
  iola wiki [open|links]
392
407
  iola context list|show|init
393
408
  iola skills list|show|paths|enable|disable|bundles|bundle|doctor
@@ -684,6 +699,11 @@ async function handleAgentLine(line, state) {
684
699
  return false;
685
700
  }
686
701
 
702
+ if (command === "gosuslugi") {
703
+ await handleGosuslugi(args);
704
+ return false;
705
+ }
706
+
687
707
  if (command === "workspace") {
688
708
  await handleWorkspace(args);
689
709
  return false;
@@ -833,6 +853,7 @@ async function handleAgentLine(line, state) {
833
853
  resume: ["resume", args],
834
854
  fork: ["fork", args],
835
855
  features: ["features", args],
856
+ gosuslugi: ["gosuslugi", args],
836
857
  wiki: ["wiki", args],
837
858
  context: ["context", args],
838
859
  skills: ["skills", args],
@@ -891,6 +912,7 @@ function printAgentHelp() {
891
912
  /sessions
892
913
  /resume SESSION_ID
893
914
  /features list
915
+ /gosuslugi status
894
916
  /wiki
895
917
  /context list
896
918
  /skills list
@@ -1700,6 +1722,74 @@ async function handleSettings(args) {
1700
1722
  throw new Error("Команды settings: list, get [KEY], validate, doctor, init.");
1701
1723
  }
1702
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
+
1703
1793
  async function handleWiki(args) {
1704
1794
  const [action = "links"] = args;
1705
1795
  const base = "https://github.com/adm-iola/iola-cli/wiki";
@@ -1714,6 +1804,7 @@ async function handleWiki(args) {
1714
1804
  ["Рабочая среда агента", `${base}/Рабочая-среда-агента`],
1715
1805
  ["Платформа агента", `${base}/Платформа-агента`],
1716
1806
  ["Браузерный агент", `${base}/Браузерный-агент`],
1807
+ ["Подключение Госуслуг", `${base}/Подключение-Госуслуг`],
1717
1808
  ["Расширения и локальные данные", `${base}/Расширения-и-локальные-данные`],
1718
1809
  ["Архивы и мастер настройки", `${base}/Архивы-и-мастер-настройки`],
1719
1810
  ["Daemon, RPC и cron", `${base}/Daemon-RPC-и-cron`],
@@ -2640,6 +2731,177 @@ async function openUrl(url) {
2640
2731
  await runCommand("xdg-open", [url], { inherit: false });
2641
2732
  }
2642
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
+
2643
2905
  async function handlePermissions(args) {
2644
2906
  const [action = "list", name] = args;
2645
2907
  const config = await loadConfig();
@@ -6001,7 +6263,7 @@ function parseOptions(args) {
6001
6263
  } else if (arg === "--check" || arg === "--upgrade-node") {
6002
6264
  result.check = true;
6003
6265
  result[arg.slice(2)] = true;
6004
- } 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 === "--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") {
6005
6267
  result[arg.slice(2)] = args[index + 1];
6006
6268
  index += 1;
6007
6269
  } else {
@@ -7769,6 +8031,10 @@ function mergeConfig(base, override) {
7769
8031
  ...base.api,
7770
8032
  ...(override.api || {}),
7771
8033
  },
8034
+ gosuslugi: {
8035
+ ...base.gosuslugi,
8036
+ ...(override.gosuslugi || {}),
8037
+ },
7772
8038
  ai: {
7773
8039
  ...base.ai,
7774
8040
  ...(override.ai || {}),
@@ -7843,6 +8109,9 @@ function validateConfig(config) {
7843
8109
  for (const toolset of config.toolsets?.enabled || []) {
7844
8110
  if (!TOOLSETS[toolset]) errors.push(`toolsets.enabled содержит неизвестный toolset: ${toolset}`);
7845
8111
  }
8112
+ if (config.gosuslugi?.enabled && !isGosuslugiConfigured(config)) {
8113
+ errors.push("gosuslugi включен, но authUrl/tokenUrl/clientId не заполнены");
8114
+ }
7846
8115
  return errors;
7847
8116
  }
7848
8117
 
@@ -7852,6 +8121,7 @@ function configSchema() {
7852
8121
  required: ["api", "ai"],
7853
8122
  properties: {
7854
8123
  api: { required: ["baseUrl", "mcpBaseUrl"] },
8124
+ gosuslugi: { requiredWhenEnabled: ["authUrl", "tokenUrl", "clientId"], optional: ["userinfoUrl", "clientSecret", "scope", "redirectHost", "redirectPort", "redirectPath"] },
7855
8125
  ai: { required: ["activeProfile", "profiles"], providers: ["ollama", "openai", "openrouter", "codex"] },
7856
8126
  permissions: { localTools: ALL_LOCAL_TOOLS, runtime: ["readFiles", "writeFiles", "editFiles", "deleteFiles", "sync", "externalApi", "externalAi", "codex"] },
7857
8127
  toolsets: { available: Object.keys(TOOLSETS) },
package/wiki/Home.md CHANGED
@@ -33,6 +33,7 @@ iola ask "найди школу 29"
33
33
  - [Рабочая среда агента](Рабочая-среда-агента)
34
34
  - [Платформа агента](Платформа-агента)
35
35
  - [Браузерный агент](Браузерный-агент)
36
+ - [Подключение Госуслуг](Подключение-Госуслуг)
36
37
  - [Расширения и локальные данные](Расширения-и-локальные-данные)
37
38
  - [Архивы и мастер настройки](Архивы-и-мастер-настройки)
38
39
  - [Daemon, RPC и cron](Daemon-RPC-и-cron)
@@ -92,6 +92,10 @@ iola context init
92
92
  iola context list
93
93
  iola settings list
94
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
95
99
  iola cron add "каждый день 09:00 -- quality"
96
100
  iola cron tick
97
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
+