@iola_adm/iola-cli 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +23 -2
  2. package/package.json +1 -1
  3. package/src/cli.js +122 -2
package/README.md CHANGED
@@ -79,6 +79,8 @@ iola agent
79
79
  iola ai doctor
80
80
  iola ai setup ollama
81
81
  iola ai ask "Какие школы есть на улице Петрова?"
82
+ iola ai key set openai
83
+ iola ai key status
82
84
  iola ai setup openai --model gpt-4.1-mini
83
85
  iola ai setup openrouter --model openai/gpt-4.1-mini
84
86
  iola health
@@ -135,7 +137,7 @@ iola ai ask "Какие школы есть на улице Петрова?"
135
137
  OpenAI:
136
138
 
137
139
  ```bash
138
- set OPENAI_API_KEY=...
140
+ iola ai key set openai
139
141
  iola ai setup openai --model gpt-4.1-mini
140
142
  iola ai ask "Найди школу 29"
141
143
  ```
@@ -143,11 +145,30 @@ iola ai ask "Найди школу 29"
143
145
  OpenRouter:
144
146
 
145
147
  ```bash
146
- set OPENROUTER_API_KEY=...
148
+ iola ai key set openrouter
147
149
  iola ai setup openrouter --model openai/gpt-4.1-mini
148
150
  iola ai ask "Покажи контакты лицея"
149
151
  ```
150
152
 
153
+ Ключи OpenAI/OpenRouter сохраняются локально на компьютере пользователя:
154
+
155
+ ```text
156
+ %USERPROFILE%\.iola\secrets.json
157
+ ```
158
+
159
+ Управление ключами:
160
+
161
+ ```bash
162
+ iola ai key set openai
163
+ iola ai key set openrouter
164
+ iola ai key status
165
+ iola ai key delete openai
166
+ ```
167
+
168
+ Если одновременно задана переменная окружения (`OPENAI_API_KEY` или
169
+ `OPENROUTER_API_KEY`) и сохранен локальный ключ, CLI использует переменную
170
+ окружения как более приоритетную.
171
+
151
172
  AI-ответ строится по контексту из публичного API. Если данных в контексте
152
173
  недостаточно, ассистент должен сообщить об этом, а не выдумывать сведения.
153
174
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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
@@ -9,6 +9,7 @@ const API_BASE_URL = process.env.IOLA_API_BASE_URL || "https://apiiola.yasg.ru/a
9
9
  const MCP_BASE_URL = process.env.IOLA_MCP_BASE_URL || "https://apiiola.yasg.ru";
10
10
  const CONFIG_DIR = path.join(os.homedir(), ".iola");
11
11
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
12
+ const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
12
13
  const DEFAULT_AI_CONFIG = {
13
14
  ai: {
14
15
  provider: "ollama",
@@ -64,6 +65,10 @@ Usage:
64
65
  iola banner
65
66
  iola agent
66
67
  iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
68
+ iola ai key set openai
69
+ iola ai key set openrouter
70
+ iola ai key status
71
+ iola ai key delete openai|openrouter
67
72
  iola ai doctor [--json]
68
73
  iola ai setup
69
74
  iola ai setup ollama [--yes] [--model MODEL]
@@ -285,6 +290,10 @@ async function handleAi(args) {
285
290
  showBanner();
286
291
  console.log(`AI-команды:
287
292
  iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
293
+ iola ai key set openai
294
+ iola ai key set openrouter
295
+ iola ai key status
296
+ iola ai key delete openai|openrouter
288
297
  iola ai doctor [--json]
289
298
  iola ai setup
290
299
  iola ai setup ollama [--yes] [--model MODEL]
@@ -300,6 +309,11 @@ async function handleAi(args) {
300
309
  return;
301
310
  }
302
311
 
312
+ if (subcommand === "key") {
313
+ await handleAiKey(rest);
314
+ return;
315
+ }
316
+
303
317
  if (subcommand === "doctor") {
304
318
  await aiDoctor(rest);
305
319
  return;
@@ -364,6 +378,86 @@ async function aiSetup(args) {
364
378
  throw new Error(`Unknown AI provider: ${provider}`);
365
379
  }
366
380
 
381
+ async function handleAiKey(args) {
382
+ const [action, provider] = args;
383
+
384
+ if (action === "set") {
385
+ await setAiKey(provider);
386
+ return;
387
+ }
388
+
389
+ if (action === "status") {
390
+ await printAiKeyStatus();
391
+ return;
392
+ }
393
+
394
+ if (action === "delete") {
395
+ await deleteAiKey(provider);
396
+ return;
397
+ }
398
+
399
+ throw new Error(`Unknown key command. Use:
400
+ iola ai key set openai
401
+ iola ai key set openrouter
402
+ iola ai key status
403
+ iola ai key delete openai|openrouter`);
404
+ }
405
+
406
+ async function setAiKey(provider) {
407
+ assertKeyProvider(provider);
408
+
409
+ if (!process.stdin.isTTY) {
410
+ throw new Error("Для сохранения ключа запустите команду в интерактивном терминале.");
411
+ }
412
+
413
+ const envName = provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY";
414
+ const rl = readline.createInterface({ input, output });
415
+
416
+ try {
417
+ const key = (await rl.question(`Введите ${envName}: `)).trim();
418
+
419
+ if (!key) {
420
+ throw new Error("Ключ пустой, сохранение отменено.");
421
+ }
422
+
423
+ const secrets = await loadSecrets();
424
+ secrets[provider] = { apiKey: key };
425
+ await saveSecrets(secrets);
426
+ console.log(`Ключ ${provider} сохранен локально: ${SECRETS_FILE}`);
427
+ } finally {
428
+ rl.close();
429
+ }
430
+ }
431
+
432
+ async function printAiKeyStatus() {
433
+ const secrets = await loadSecrets();
434
+ const rows = ["openai", "openrouter"].map((provider) => ({
435
+ provider,
436
+ env: provider === "openai" ? (process.env.OPENAI_API_KEY ? "yes" : "no") : (process.env.OPENROUTER_API_KEY ? "yes" : "no"),
437
+ local: secrets[provider]?.apiKey ? "yes" : "no",
438
+ }));
439
+
440
+ printTable(rows, [
441
+ ["provider", "Провайдер"],
442
+ ["env", "Env"],
443
+ ["local", "Локально"],
444
+ ]);
445
+ }
446
+
447
+ async function deleteAiKey(provider) {
448
+ assertKeyProvider(provider);
449
+ const secrets = await loadSecrets();
450
+ delete secrets[provider];
451
+ await saveSecrets(secrets);
452
+ console.log(`Локальный ключ ${provider} удален.`);
453
+ }
454
+
455
+ function assertKeyProvider(provider) {
456
+ if (provider !== "openai" && provider !== "openrouter") {
457
+ throw new Error("Провайдер должен быть openai или openrouter.");
458
+ }
459
+ }
460
+
367
461
  async function chooseAiProvider() {
368
462
  console.log("Выберите режим AI:");
369
463
  console.log("1. Локальная модель через Ollama");
@@ -522,11 +616,11 @@ async function callAiProvider(config, messages) {
522
616
  }
523
617
 
524
618
  if (config.provider === "openai") {
525
- return callOpenAiCompatible(config, messages, process.env.OPENAI_API_KEY, "OpenAI");
619
+ return callOpenAiCompatible(config, messages, await getApiKey("openai"), "OpenAI");
526
620
  }
527
621
 
528
622
  if (config.provider === "openrouter") {
529
- return callOpenAiCompatible(config, messages, process.env.OPENROUTER_API_KEY, "OpenRouter");
623
+ return callOpenAiCompatible(config, messages, await getApiKey("openrouter"), "OpenRouter");
530
624
  }
531
625
 
532
626
  throw new Error(`Неизвестный AI-провайдер: ${config.provider}`);
@@ -586,6 +680,19 @@ async function callOpenAiCompatible(config, messages, apiKey, providerName) {
586
680
  return payload.choices?.[0]?.message?.content || "";
587
681
  }
588
682
 
683
+ async function getApiKey(provider) {
684
+ if (provider === "openai" && process.env.OPENAI_API_KEY) {
685
+ return process.env.OPENAI_API_KEY;
686
+ }
687
+
688
+ if (provider === "openrouter" && process.env.OPENROUTER_API_KEY) {
689
+ return process.env.OPENROUTER_API_KEY;
690
+ }
691
+
692
+ const secrets = await loadSecrets();
693
+ return secrets[provider]?.apiKey || "";
694
+ }
695
+
589
696
  async function listLayers(args) {
590
697
  const options = parseOptions(args);
591
698
  const info = await fetchJson(`${MCP_BASE_URL}/mcp-version`);
@@ -972,6 +1079,19 @@ function mergeConfig(base, override) {
972
1079
  };
973
1080
  }
974
1081
 
1082
+ async function loadSecrets() {
1083
+ try {
1084
+ return JSON.parse(await readFile(SECRETS_FILE, "utf8"));
1085
+ } catch {
1086
+ return {};
1087
+ }
1088
+ }
1089
+
1090
+ async function saveSecrets(value) {
1091
+ await mkdir(CONFIG_DIR, { recursive: true });
1092
+ await writeFile(SECRETS_FILE, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
1093
+ }
1094
+
975
1095
  async function printAiConfig() {
976
1096
  const config = await loadConfig();
977
1097
  printJson({