@iola_adm/iola-cli 0.2.2 → 0.2.4

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.2.2",
3
+ "version": "0.2.4",
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
@@ -10,6 +10,7 @@ import readline from "node:readline/promises";
10
10
  import { Readable } from "node:stream";
11
11
  import { stdin as input, stdout as output } from "node:process";
12
12
  import { DatabaseSync } from "node:sqlite";
13
+ import tls from "node:tls";
13
14
  import { fileURLToPath } from "node:url";
14
15
  import { inflateRawSync, inflateSync } from "node:zlib";
15
16
 
@@ -700,6 +701,7 @@ async function startAgent() {
700
701
  setTerminalTitle(`iola - ${path.basename(process.cwd()) || process.cwd()}`);
701
702
  await showBanner();
702
703
  await ensureAgentAiReady();
704
+ await printActiveAiModelLine();
703
705
  console.log("Интерактивный режим. Введите /help для списка команд, /master чтобы запустить мастер настройки, /exit для выхода.");
704
706
  await runHooks("SessionStart", { mode: "agent" });
705
707
 
@@ -713,6 +715,16 @@ async function startAgent() {
713
715
  await runHooks("SessionEnd", { mode: "agent" });
714
716
  }
715
717
 
718
+ async function printActiveAiModelLine() {
719
+ const config = await loadConfig();
720
+ const name = getActiveProfileName(config);
721
+ const profile = config.ai.profiles?.[name] || {
722
+ provider: config.ai.provider,
723
+ model: config.ai.model,
724
+ };
725
+ console.log(`Активная модель: ${name} (${profile.provider || "-"}, ${profile.model || "-"})`);
726
+ }
727
+
716
728
  async function ensureAgentAiReady() {
717
729
  const readiness = await getAiReadiness();
718
730
  if (readiness.ready) return readiness;
@@ -4851,17 +4863,38 @@ function normalizeModelMenuTarget(value = "") {
4851
4863
  }
4852
4864
 
4853
4865
  async function chooseModelTarget() {
4866
+ const active = await getActiveAiSummary();
4854
4867
  console.log("Выберите AI-подключение:");
4855
- console.log(" 1. Локальные модели");
4856
- console.log(" 2. Российские AI (YandexGPT/GigaChat)");
4857
- console.log(" 3. API (OpenAI/OpenRouter)");
4858
- console.log(" 4. Codex CLI");
4868
+ console.log(` 1. Локальные модели${active.group === "local" ? " [выбрано]" : ""}`);
4869
+ console.log(` 2. Российские AI (YandexGPT/GigaChat)${active.group === "russian" ? " [выбрано]" : ""}`);
4870
+ console.log(` 3. API (OpenAI/OpenRouter)${active.group === "api" ? " [выбрано]" : ""}`);
4871
+ console.log(` 4. Codex CLI${active.group === "codex" ? " [выбрано]" : ""}`);
4859
4872
  console.log(" 0. Отмена");
4860
4873
 
4861
4874
  const answer = await askText("Номер: ");
4862
4875
  return { 1: "local", 2: "russian", 3: "api", 4: "codex" }[answer.trim()] || "";
4863
4876
  }
4864
4877
 
4878
+ async function getActiveAiSummary() {
4879
+ const config = await loadConfig();
4880
+ const name = getActiveProfileName(config);
4881
+ const profile = config.ai.profiles?.[name] || {
4882
+ provider: config.ai.provider,
4883
+ model: config.ai.model,
4884
+ };
4885
+ const provider = profile.provider || "";
4886
+ const group = provider === "iola" || provider === "ollama"
4887
+ ? "local"
4888
+ : provider === "yandexgpt" || provider === "gigachat"
4889
+ ? "russian"
4890
+ : provider === "openai" || provider === "openrouter"
4891
+ ? "api"
4892
+ : provider === "codex"
4893
+ ? "codex"
4894
+ : "";
4895
+ return { name, provider, model: profile.model || "", group };
4896
+ }
4897
+
4865
4898
  async function openModelTargetMenu(target) {
4866
4899
  if (target === "local") {
4867
4900
  const selection = await chooseLocalModel();
@@ -4903,9 +4936,10 @@ async function openModelTargetMenu(target) {
4903
4936
 
4904
4937
  async function chooseApiProvider() {
4905
4938
  const config = await loadConfig();
4939
+ const active = await getActiveAiSummary();
4906
4940
  const apiProfiles = Object.entries(config.ai.profiles || {})
4907
4941
  .filter(([, profile]) => profile.provider === "openai" || profile.provider === "openrouter")
4908
- .map(([name, profile]) => ({ id: profile.provider, label: `${name}: ${profile.provider} (${profile.model || "-"})` }));
4942
+ .map(([name, profile]) => ({ id: profile.provider, label: `${name}: ${profile.provider} (${profile.model || "-"})${active.provider === profile.provider ? " [выбрано]" : ""}` }));
4909
4943
  const choices = [
4910
4944
  ...apiProfiles,
4911
4945
  { id: "openai", label: "OpenAI API" },
@@ -4922,9 +4956,10 @@ async function chooseApiProvider() {
4922
4956
 
4923
4957
  async function chooseRussianProvider() {
4924
4958
  const config = await loadConfig();
4959
+ const active = await getActiveAiSummary();
4925
4960
  const russianProfiles = Object.entries(config.ai.profiles || {})
4926
4961
  .filter(([, profile]) => profile.provider === "yandexgpt" || profile.provider === "gigachat")
4927
- .map(([name, profile]) => ({ id: profile.provider, label: `${name}: ${profile.provider} (${profile.model || "-"})` }));
4962
+ .map(([name, profile]) => ({ id: profile.provider, label: `${name}: ${profile.provider} (${profile.model || "-"})${active.provider === profile.provider ? " [выбрано]" : ""}` }));
4928
4963
  const choices = [
4929
4964
  ...russianProfiles,
4930
4965
  { id: "yandexgpt", label: "YandexGPT API" },
@@ -4948,15 +4983,16 @@ async function getDefaultApiProviderForModelSwitch() {
4948
4983
  }
4949
4984
 
4950
4985
  async function chooseLocalModel() {
4986
+ const active = await getActiveAiSummary();
4951
4987
  const models = await listAiModels("ollama");
4952
4988
  const choices = [
4953
- { id: IOLA_LOCAL_MODEL, provider: "iola", label: `${IOLA_LOCAL_MODEL} - IOLA local router` },
4989
+ { id: IOLA_LOCAL_MODEL, provider: "iola", label: `${IOLA_LOCAL_MODEL} - IOLA local router${active.provider === "iola" && active.model === IOLA_LOCAL_MODEL ? " [выбрано]" : ""}` },
4954
4990
  ...models
4955
4991
  .filter((model) => model.id !== IOLA_LOCAL_MODEL)
4956
4992
  .map((model) => ({
4957
4993
  id: model.id,
4958
4994
  provider: "ollama",
4959
- label: `${model.id}${model.note ? ` - ${model.note}` : ""}`,
4995
+ label: `${model.id}${model.note ? ` - ${model.note}` : ""}${active.provider === "ollama" && active.model === model.id ? " [выбрано]" : ""}`,
4960
4996
  })),
4961
4997
  { id: "__manual__", provider: "ollama", label: "Другая Ollama-модель: ввести имя вручную" },
4962
4998
  ].filter((item, index, array) => array.findIndex((candidate) => candidate.id === item.id) === index);
@@ -4979,6 +5015,7 @@ async function chooseLocalModel() {
4979
5015
  }
4980
5016
 
4981
5017
  async function chooseAiModel(provider) {
5018
+ const active = await getActiveAiSummary();
4982
5019
  if (provider === "openrouter") {
4983
5020
  return chooseOpenRouterModel();
4984
5021
  }
@@ -5024,7 +5061,8 @@ async function chooseAiModel(provider) {
5024
5061
  console.log("Выберите модель:");
5025
5062
  filtered.forEach((model, index) => {
5026
5063
  const date = model.releaseDate ? ` (${model.releaseDate})` : "";
5027
- console.log(` ${index + 1}. ${model.id}${date}${model.note ? ` - ${model.note}` : ""}`);
5064
+ const selected = active.provider === provider && active.model === model.id ? " [выбрано]" : "";
5065
+ console.log(` ${index + 1}. ${model.id}${date}${model.note ? ` - ${model.note}` : ""}${selected}`);
5028
5066
  });
5029
5067
  console.log(" 0. Отмена");
5030
5068
 
@@ -5033,6 +5071,7 @@ async function chooseAiModel(provider) {
5033
5071
  }
5034
5072
 
5035
5073
  async function chooseOpenRouterModel() {
5074
+ const active = await getActiveAiSummary();
5036
5075
  const ready = await ensureApiKeyForModelSelection("openrouter");
5037
5076
  if (!ready) return "";
5038
5077
 
@@ -5077,7 +5116,8 @@ async function chooseOpenRouterModel() {
5077
5116
  filtered.forEach((model, index) => {
5078
5117
  const date = model.releaseDate || "дата неизвестна";
5079
5118
  const context = model.contextLength ? `, ctx ${formatCompactNumber(model.contextLength)}` : "";
5080
- console.log(` ${index + 1}. ${model.id} (${date}${context}) - ${model.note || model.id}`);
5119
+ const selected = active.provider === "openrouter" && active.model === model.id ? " [выбрано]" : "";
5120
+ console.log(` ${index + 1}. ${model.id} (${date}${context}) - ${model.note || model.id}${selected}`);
5081
5121
  });
5082
5122
  console.log(" 0. Назад");
5083
5123
 
@@ -8803,18 +8843,23 @@ async function callGigaChat(config, messages) {
8803
8843
  }
8804
8844
 
8805
8845
  const token = await getGigaChatAccessToken(config, authKey);
8806
- const response = await fetch(`${String(config.baseUrl || "https://gigachat.devices.sberbank.ru/api/v1").replace(/\/+$/, "")}/chat/completions`, {
8807
- method: "POST",
8808
- headers: {
8809
- authorization: `Bearer ${token}`,
8810
- "content-type": "application/json",
8811
- },
8812
- body: JSON.stringify({
8813
- model: config.model || "GigaChat-2",
8814
- messages,
8815
- temperature: Number(config.temperature ?? 0.2),
8816
- }),
8817
- });
8846
+ let response;
8847
+ try {
8848
+ response = await fetch(`${String(config.baseUrl || "https://gigachat.devices.sberbank.ru/api/v1").replace(/\/+$/, "")}/chat/completions`, {
8849
+ method: "POST",
8850
+ headers: {
8851
+ authorization: `Bearer ${token}`,
8852
+ "content-type": "application/json",
8853
+ },
8854
+ body: JSON.stringify({
8855
+ model: config.model || "GigaChat-2",
8856
+ messages,
8857
+ temperature: Number(config.temperature ?? 0.2),
8858
+ }),
8859
+ });
8860
+ } catch (error) {
8861
+ throw new Error(formatProviderFetchError("GigaChat", error));
8862
+ }
8818
8863
 
8819
8864
  if (!response.ok) {
8820
8865
  const text = await response.text();
@@ -8826,17 +8871,23 @@ async function callGigaChat(config, messages) {
8826
8871
  }
8827
8872
 
8828
8873
  async function getGigaChatAccessToken(config, authKey) {
8874
+ enableSystemCaForGigaChat();
8829
8875
  const secrets = await loadSecrets();
8830
8876
  const scope = process.env.GIGACHAT_SCOPE || secrets.gigachat?.scope || config.scope || "GIGACHAT_API_PERS";
8831
- const response = await fetch(config.authUrl || "https://ngw.devices.sberbank.ru:9443/api/v2/oauth", {
8832
- method: "POST",
8833
- headers: {
8834
- authorization: `Basic ${authKey}`,
8835
- "content-type": "application/x-www-form-urlencoded",
8836
- RqUID: randomUUID(),
8837
- },
8838
- body: new URLSearchParams({ scope }).toString(),
8839
- });
8877
+ let response;
8878
+ try {
8879
+ response = await fetch(config.authUrl || "https://ngw.devices.sberbank.ru:9443/api/v2/oauth", {
8880
+ method: "POST",
8881
+ headers: {
8882
+ authorization: `Basic ${authKey}`,
8883
+ "content-type": "application/x-www-form-urlencoded",
8884
+ RqUID: randomUUID(),
8885
+ },
8886
+ body: new URLSearchParams({ scope }).toString(),
8887
+ });
8888
+ } catch (error) {
8889
+ throw new Error(formatProviderFetchError("GigaChat OAuth", error));
8890
+ }
8840
8891
 
8841
8892
  if (!response.ok) {
8842
8893
  const text = await response.text();
@@ -8848,6 +8899,37 @@ async function getGigaChatAccessToken(config, authKey) {
8848
8899
  return payload.access_token;
8849
8900
  }
8850
8901
 
8902
+ let systemCaForGigaChatEnabled = false;
8903
+
8904
+ function enableSystemCaForGigaChat() {
8905
+ if (systemCaForGigaChatEnabled || process.env.GIGACHAT_DISABLE_SYSTEM_CA === "1") return;
8906
+ systemCaForGigaChatEnabled = true;
8907
+
8908
+ try {
8909
+ if (typeof tls.getCACertificates !== "function" || typeof tls.setDefaultCACertificates !== "function") return;
8910
+ const certificates = [
8911
+ ...tls.getCACertificates("system"),
8912
+ ...tls.getCACertificates("bundled"),
8913
+ ...tls.getCACertificates("extra"),
8914
+ ];
8915
+ if (certificates.length > 0) tls.setDefaultCACertificates([...new Set(certificates)]);
8916
+ } catch {
8917
+ // Older Node builds may not expose system CA management. The fetch error below
8918
+ // will include the concrete TLS/network cause and the manual workaround.
8919
+ }
8920
+ }
8921
+
8922
+ function formatProviderFetchError(provider, error) {
8923
+ const cause = error?.cause;
8924
+ const causeCode = cause?.code ? `${cause.code}: ` : "";
8925
+ const causeMessage = cause?.message || "";
8926
+ const details = `${error?.message || "fetch failed"}${causeMessage ? ` (${causeCode}${causeMessage})` : ""}`;
8927
+ if (/SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_GET_ISSUER_CERT|CERT_/i.test(`${cause?.code || ""} ${causeMessage}`)) {
8928
+ return `${provider} network error: ${details}\nNode не доверяет цепочке сертификатов провайдера. CLI пробует использовать системные сертификаты ОС автоматически; если ошибка повторяется, обновите Node.js или запустите CLI с NODE_OPTIONS=--use-system-ca.`;
8929
+ }
8930
+ return `${provider} network error: ${details}`;
8931
+ }
8932
+
8851
8933
  function getAiNetworkMode(config = {}) {
8852
8934
  return validateAiNetworkMode(AI_NETWORK_MODE || config.networkMode || "gateway");
8853
8935
  }
@@ -99,12 +99,23 @@ CLI также понимает env-переменные `YANDEXGPT_API_KEY` и
99
99
  Пошаговая настройка:
100
100
 
101
101
  1. Перейдите на портал разработчиков Сбера: `https://developers.sber.ru/`.
102
- 2. Войдите в аккаунт.
103
- 3. Откройте раздел GigaChat.
104
- 4. Если нужно, подключите доступ к GigaChat API для физического лица или организации.
105
- 5. Откройте кабинет/проект, в котором доступны учетные данные GigaChat API.
106
- 6. Найдите authorization key для REST API. В документации GigaChat он используется в OAuth-запросе в заголовке `Authorization: Basic ...`.
107
- 7. Скопируйте authorization key и сохраните его локально в CLI:
102
+ 2. Войдите в аккаунт. Если вход еще не выполнен, портал откроет страницу авторизации: `https://developers.sber.ru/studio/login`.
103
+ 3. Откройте страницу GigaChat API. Прямой путь: `https://developers.sber.ru/studio/workspaces/my-space/get/gigachat-api`.
104
+ 4. Нажмите кнопку подключения или `Попробовать`. После входа откроется форма создания проекта.
105
+ 5. В форме `Почти готово` заполните `Название проекта`. Можно оставить предложенное название `Мой GigaChat API` или указать `iola-cli`.
106
+ 6. В блоке `Группа проекта` нажмите `Выбрать группу`.
107
+ 7. Если есть вариант `Без группы`, выберите его. Если создание проекта после этого недоступно, выберите `Создать группу`, задайте понятное имя, например `iola-cli`, и сохраните группу.
108
+ 8. Нажмите `Создать проект`.
109
+ 9. После создания проекта откроется страница GigaChat API. В верхней части будет название проекта, например `Мой GigaChat API`.
110
+ 10. На странице проекта нажмите `Настроить API`. Если кнопки не видно, откройте раздел настроек проекта GigaChat API в левом меню.
111
+ 11. На странице `Настройка API` найдите блок `Данные для авторизации запросов к API`.
112
+ 12. Проверьте поле `Scope`. Для личного Freemium-доступа обычно указан `GIGACHAT_API_PERS`. Это значение понадобится CLI.
113
+ 13. В блоке `Ключ авторизации` нажмите `Получить ключ`.
114
+ 14. Откроется окно `Сохраните ваш Authorization Key`. Не закрывайте его, пока не сохраните данные.
115
+ 15. Скопируйте поле `Authorization Key`. Это и есть значение `GIGACHAT_AUTH_KEY` для CLI.
116
+ 16. При желании также сохраните `Client ID` и `Client Secret`, но CLI они не нужны, если вы уже скопировали `Authorization Key`.
117
+ 17. Нажмите `Готово` только после сохранения ключа.
118
+ 18. Сохраните ключ локально в CLI:
108
119
 
109
120
  ```bash
110
121
  iola ai key set gigachat
@@ -115,6 +126,8 @@ CLI попросит ввести:
115
126
  - `GIGACHAT_AUTH_KEY` - authorization key;
116
127
  - `scope` - по умолчанию `GIGACHAT_API_PERS` для персонального доступа.
117
128
 
129
+ Важно: `Authorization Key` показывается один раз. Если закрыть окно и не сохранить ключ, его нельзя посмотреть повторно. В этом случае нажмите `Получить новый ключ` и сохраните новое значение. `Authorization Key` - это уже готовая строка для заголовка `Authorization: Basic ...`; вручную кодировать `Client ID:Client Secret` в base64 не нужно.
130
+
118
131
  После сохранения ключа выберите профиль и модель:
119
132
 
120
133
  ```bash