@iola_adm/iola-cli 0.1.10 → 0.1.11

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 +43 -0
  2. package/package.json +1 -1
  3. package/src/cli.js +516 -24
package/README.md CHANGED
@@ -93,6 +93,12 @@ iola data schools --where address=Петрова --columns name,address,phone
93
93
  iola data schools --format csv
94
94
  iola ai doctor
95
95
  iola ai setup ollama
96
+ iola ai setup codex --model gpt-5.5
97
+ iola ai profiles
98
+ iola ai profile add router-qwen --provider openrouter --model qwen/qwen3-32b
99
+ iola ai profile use router-qwen
100
+ iola ai models openrouter --search qwen
101
+ iola ai models codex
96
102
  iola ai ask "Какие школы есть на улице Петрова?"
97
103
  iola ai context "школа 29"
98
104
  iola ai key set openai
@@ -137,6 +143,9 @@ iola agent
137
143
  /mcp-info
138
144
  /ai doctor
139
145
  /context школа 29
146
+ /profiles
147
+ /profile use local
148
+ /models openrouter --search qwen
140
149
  /use ollama
141
150
  /use openai
142
151
  /key status
@@ -180,6 +189,40 @@ iola ai setup openrouter --model openai/gpt-4.1-mini
180
189
  iola ai ask "Покажи контакты лицея"
181
190
  ```
182
191
 
192
+ Codex CLI:
193
+
194
+ ```bash
195
+ codex login
196
+ iola ai setup codex --model gpt-5.5
197
+ iola setup codex
198
+ iola ask "Назови ИНН школы 29"
199
+ ```
200
+
201
+ AI-профили позволяют держать локальную модель, OpenAI, OpenRouter и Codex
202
+ одновременно:
203
+
204
+ ```bash
205
+ iola ai profiles
206
+ iola ai profile add local-small --provider ollama --model llama3.2:1b
207
+ iola ai profile add gpt --provider openai --model gpt-4.1-mini
208
+ iola ai profile add router-qwen --provider openrouter --model qwen/qwen3-32b
209
+ iola ai profile add codex-read --provider codex --model gpt-5.5 --sandbox read-only
210
+ iola ai profile use router-qwen
211
+ ```
212
+
213
+ Списки моделей:
214
+
215
+ ```bash
216
+ iola ai models ollama
217
+ iola ai models openai
218
+ iola ai models openrouter --search qwen
219
+ iola ai models codex
220
+ ```
221
+
222
+ Для OpenAI список моделей требует сохраненный ключ. OpenRouter берется из
223
+ публичного API OpenRouter. Ollama читает локальные модели через `api/tags`, а
224
+ если Ollama не запущен, показывает рекомендуемые локальные модели.
225
+
183
226
  Проверить, какие данные попадут в AI-контекст:
184
227
 
185
228
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
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,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import readline from "node:readline/promises";
@@ -16,9 +16,34 @@ const DEFAULT_AI_CONFIG = {
16
16
  mcpBaseUrl: "https://apiiola.yasg.ru",
17
17
  },
18
18
  ai: {
19
+ activeProfile: "local",
19
20
  provider: "ollama",
20
21
  model: "llama3.2:1b",
21
22
  baseUrl: "http://127.0.0.1:11434",
23
+ profiles: {
24
+ local: {
25
+ provider: "ollama",
26
+ model: "llama3.2:1b",
27
+ baseUrl: "http://127.0.0.1:11434",
28
+ },
29
+ openai: {
30
+ provider: "openai",
31
+ model: "gpt-4.1-mini",
32
+ baseUrl: "https://api.openai.com/v1",
33
+ },
34
+ openrouter: {
35
+ provider: "openrouter",
36
+ model: "openai/gpt-4.1-mini",
37
+ baseUrl: "https://openrouter.ai/api/v1",
38
+ },
39
+ codex: {
40
+ provider: "codex",
41
+ model: "gpt-5.5",
42
+ sandbox: "read-only",
43
+ approval: "never",
44
+ cwd: ".",
45
+ },
46
+ },
22
47
  },
23
48
  };
24
49
  const DATASETS = {
@@ -101,6 +126,11 @@ Usage:
101
126
  iola ai key set openrouter
102
127
  iola ai key status
103
128
  iola ai key delete openai|openrouter
129
+ iola ai profiles
130
+ iola ai profile add NAME --provider PROVIDER --model MODEL
131
+ iola ai profile use NAME
132
+ iola ai profile delete NAME
133
+ iola ai models ollama|openai|openrouter|codex [--search TEXT]
104
134
  iola ai doctor [--json]
105
135
  iola ai setup
106
136
  iola ai setup ollama [--yes] [--model MODEL]
@@ -210,6 +240,21 @@ async function handleAgentLine(line, state) {
210
240
  return false;
211
241
  }
212
242
 
243
+ if (command === "profiles") {
244
+ await handleAiProfile(["list", ...args]);
245
+ return false;
246
+ }
247
+
248
+ if (command === "profile") {
249
+ await handleAiProfile(args);
250
+ return false;
251
+ }
252
+
253
+ if (command === "models") {
254
+ await aiModels(args);
255
+ return false;
256
+ }
257
+
213
258
  if (command === "use") {
214
259
  await useAiProvider(args);
215
260
  return false;
@@ -290,6 +335,9 @@ function printAgentHelp() {
290
335
  /search лицей --limit 3
291
336
  /mcp-info
292
337
  /context школа 29
338
+ /profiles
339
+ /profile use local
340
+ /models openrouter --search qwen
293
341
  /ai doctor
294
342
  /ai setup ollama
295
343
  /use openai
@@ -402,6 +450,7 @@ async function doctor(args = []) {
402
450
  const options = parseOptions(args);
403
451
  const packageJson = await import("../package.json", { with: { type: "json" } });
404
452
  const config = await loadConfig();
453
+ const activeAiProfile = resolveAiProfile(config);
405
454
  const secrets = await loadSecrets();
406
455
  const diagnostics = await getLocalDiagnostics();
407
456
  const latest = await getLatestNpmVersion(packageJson.default.name);
@@ -419,9 +468,10 @@ async function doctor(args = []) {
419
468
  health: await probeEndpoint(`${mcpBaseUrl}/mcp-health`),
420
469
  },
421
470
  ai: {
422
- provider: config.ai.provider,
423
- model: config.ai.model,
424
- modelAvailable: await checkConfiguredModel(config),
471
+ activeProfile: getActiveProfileName(config),
472
+ provider: activeAiProfile.provider,
473
+ model: activeAiProfile.model,
474
+ modelAvailable: await checkConfiguredModel({ ai: activeAiProfile }),
425
475
  openaiKey: process.env.OPENAI_API_KEY ? "env" : secrets.openai?.apiKey ? "local" : "missing",
426
476
  openrouterKey: process.env.OPENROUTER_API_KEY ? "env" : secrets.openrouter?.apiKey ? "local" : "missing",
427
477
  ollama: diagnostics.ollama.installed ? diagnostics.ollama.version : "not-installed",
@@ -531,6 +581,11 @@ async function handleAi(args) {
531
581
  iola ai key set openrouter
532
582
  iola ai key status
533
583
  iola ai key delete openai|openrouter
584
+ iola ai profiles
585
+ iola ai profile add NAME --provider ollama|openai|openrouter|codex --model MODEL
586
+ iola ai profile use NAME
587
+ iola ai profile delete NAME
588
+ iola ai models ollama|openai|openrouter|codex [--search TEXT]
534
589
  iola ai doctor [--json]
535
590
  iola ai setup
536
591
  iola ai setup ollama [--yes] [--model MODEL]
@@ -556,6 +611,21 @@ async function handleAi(args) {
556
611
  return;
557
612
  }
558
613
 
614
+ if (subcommand === "profiles") {
615
+ await handleAiProfile(["list", ...rest]);
616
+ return;
617
+ }
618
+
619
+ if (subcommand === "profile") {
620
+ await handleAiProfile(rest);
621
+ return;
622
+ }
623
+
624
+ if (subcommand === "models") {
625
+ await aiModels(rest);
626
+ return;
627
+ }
628
+
559
629
  if (subcommand === "doctor") {
560
630
  await aiDoctor(rest);
561
631
  return;
@@ -641,21 +711,50 @@ async function aiSetup(args) {
641
711
  if (provider === "openai" || provider === "openrouter") {
642
712
  const options = parseOptions(args.slice(1));
643
713
  const model = options.model || (provider === "openai" ? "gpt-4.1-mini" : "openai/gpt-4.1-mini");
714
+ const profileName = options.name || provider;
715
+ const profile = buildProfileFromOptions(provider, { ...options, model });
716
+ const config = await loadConfig();
644
717
  await saveConfig({
645
718
  ai: {
719
+ ...config.ai,
720
+ activeProfile: profileName,
646
721
  provider,
647
722
  model,
648
- baseUrl: provider === "openai" ? "https://api.openai.com/v1" : "https://openrouter.ai/api/v1",
723
+ baseUrl: profile.baseUrl,
724
+ profiles: {
725
+ ...(config.ai.profiles || {}),
726
+ [profileName]: profile,
727
+ },
649
728
  },
650
729
  });
651
- console.log(`AI-профиль ${provider} сохранен в ${CONFIG_FILE}`);
730
+ console.log(`AI-профиль ${profileName} сохранен и выбран в ${CONFIG_FILE}`);
652
731
  console.log(`Ключ сохраните командой: iola ai key set ${provider}`);
653
732
  console.log(`Также можно использовать переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
654
733
  return;
655
734
  }
656
735
 
657
736
  if (provider === "codex") {
658
- await setupClient(["codex"]);
737
+ const options = parseOptions(args.slice(1));
738
+ const profileName = options.name || "codex";
739
+ const profile = buildProfileFromOptions("codex", options);
740
+ const config = await loadConfig();
741
+ await saveConfig({
742
+ ai: {
743
+ ...config.ai,
744
+ activeProfile: profileName,
745
+ provider: "codex",
746
+ model: profile.model,
747
+ profiles: {
748
+ ...(config.ai.profiles || {}),
749
+ [profileName]: profile,
750
+ },
751
+ },
752
+ });
753
+ console.log(`AI-профиль ${profileName} сохранен и выбран.`);
754
+ console.log("Проверка Codex CLI:");
755
+ console.log(` ${await getCommandVersion("codex", ["--version"])}`);
756
+ console.log("MCP подключается отдельно командой:");
757
+ console.log(" iola setup codex");
659
758
  return;
660
759
  }
661
760
 
@@ -687,33 +786,318 @@ async function handleAiKey(args) {
687
786
  iola ai key delete openai|openrouter`);
688
787
  }
689
788
 
690
- async function useAiProvider(args) {
789
+ async function handleAiProfile(args) {
790
+ const [action = "list", name, ...rest] = args;
791
+
792
+ if (action === "list" || action === "ls") {
793
+ await printAiProfiles();
794
+ return;
795
+ }
796
+
797
+ if (action === "show") {
798
+ await showAiProfile(name);
799
+ return;
800
+ }
801
+
802
+ if (action === "use") {
803
+ await useAiProfile(name);
804
+ return;
805
+ }
806
+
807
+ if (action === "add" || action === "set") {
808
+ await addAiProfile(name, rest);
809
+ return;
810
+ }
811
+
812
+ if (action === "delete" || action === "remove" || action === "rm") {
813
+ await deleteAiProfile(name);
814
+ return;
815
+ }
816
+
817
+ throw new Error(`Unknown profile command. Use:
818
+ iola ai profiles
819
+ iola ai profile add NAME --provider PROVIDER --model MODEL
820
+ iola ai profile use NAME
821
+ iola ai profile delete NAME`);
822
+ }
823
+
824
+ async function aiModels(args) {
691
825
  const [provider] = args;
826
+ const options = parseOptions(args.slice(1));
827
+
828
+ if (!["ollama", "openai", "openrouter", "codex"].includes(provider)) {
829
+ throw new Error("Провайдер обязателен: iola ai models ollama|openai|openrouter|codex");
830
+ }
831
+
832
+ const models = await listAiModels(provider);
833
+ const filtered = options.search
834
+ ? models.filter((model) => model.id.toLocaleLowerCase("ru-RU").includes(options.search.toLocaleLowerCase("ru-RU")))
835
+ : models;
836
+
837
+ if (options.json) {
838
+ printJson(filtered);
839
+ return;
840
+ }
841
+
842
+ printTable(filtered, [
843
+ ["id", "Модель"],
844
+ ["provider", "Провайдер"],
845
+ ["note", "Примечание"],
846
+ ]);
847
+ }
848
+
849
+ async function listAiModels(provider) {
850
+ if (provider === "ollama") {
851
+ try {
852
+ const config = await loadConfig();
853
+ const response = await fetch(`${config.ai.profiles?.local?.baseUrl || "http://127.0.0.1:11434"}/api/tags`);
854
+
855
+ if (!response.ok) {
856
+ throw new Error(`${response.status} ${response.statusText}`);
857
+ }
858
+
859
+ const payload = await response.json();
860
+ return (payload.models || []).map((model) => ({
861
+ id: model.name,
862
+ provider: "ollama",
863
+ note: model.modified_at ? `updated ${model.modified_at}` : "local",
864
+ }));
865
+ } catch {
866
+ return [
867
+ { id: "llama3.2:1b", provider: "ollama", note: "recommended low RAM" },
868
+ { id: "llama3.2:3b", provider: "ollama", note: "recommended standard" },
869
+ { id: "qwen3:4b", provider: "ollama", note: "recommended balanced" },
870
+ { id: "qwen3:8b", provider: "ollama", note: "recommended good GPU" },
871
+ ];
872
+ }
873
+ }
874
+
875
+ if (provider === "openai") {
876
+ const apiKey = await getApiKey("openai");
877
+ if (!apiKey) {
878
+ throw new Error("OpenAI API key не найден. Выполните iola ai key set openai.");
879
+ }
880
+ const response = await fetch("https://api.openai.com/v1/models", {
881
+ headers: { authorization: `Bearer ${apiKey}` },
882
+ });
883
+
884
+ if (!response.ok) {
885
+ throw new Error(`OpenAI models request failed: ${response.status} ${response.statusText}`);
886
+ }
887
+
888
+ const payload = await response.json();
889
+ return (payload.data || [])
890
+ .map((model) => ({ id: model.id, provider: "openai", note: model.owned_by || "" }))
891
+ .sort((left, right) => left.id.localeCompare(right.id));
892
+ }
893
+
894
+ if (provider === "openrouter") {
895
+ const response = await fetch("https://openrouter.ai/api/v1/models", {
896
+ headers: { accept: "application/json" },
897
+ });
898
+
899
+ if (!response.ok) {
900
+ throw new Error(`OpenRouter models request failed: ${response.status} ${response.statusText}`);
901
+ }
902
+
903
+ const payload = await response.json();
904
+ return (payload.data || [])
905
+ .map((model) => ({
906
+ id: model.id,
907
+ provider: "openrouter",
908
+ note: model.name || "",
909
+ }))
910
+ .sort((left, right) => left.id.localeCompare(right.id));
911
+ }
912
+
913
+ const version = await getCommandVersion("codex", ["--version"]);
914
+ return [
915
+ { id: "gpt-5.5", provider: "codex", note: version },
916
+ { id: "gpt-5", provider: "codex", note: version },
917
+ { id: "gpt-5-codex", provider: "codex", note: version },
918
+ { id: "gpt-5-mini", provider: "codex", note: version },
919
+ ];
920
+ }
921
+
922
+ async function printAiProfiles() {
923
+ const config = await loadConfig();
924
+ const active = getActiveProfileName(config);
925
+ const rows = Object.entries(config.ai.profiles || {}).map(([name, profile]) => ({
926
+ active: name === active ? "*" : "",
927
+ name,
928
+ provider: profile.provider,
929
+ model: profile.model || "-",
930
+ baseUrl: profile.baseUrl || "-",
931
+ mode: profile.provider === "codex" ? `sandbox=${profile.sandbox || "read-only"}, approval=${profile.approval || "never"}` : "-",
932
+ }));
933
+
934
+ printTable(rows, [
935
+ ["active", ""],
936
+ ["name", "Профиль"],
937
+ ["provider", "Провайдер"],
938
+ ["model", "Модель"],
939
+ ["baseUrl", "Base URL"],
940
+ ["mode", "Режим"],
941
+ ]);
942
+ }
943
+
944
+ async function showAiProfile(name) {
945
+ const config = await loadConfig();
946
+ const profileName = name || getActiveProfileName(config);
947
+ const profile = config.ai.profiles?.[profileName];
948
+
949
+ if (!profile) {
950
+ throw new Error(`AI-профиль не найден: ${profileName}`);
951
+ }
952
+
953
+ printJson({ name: profileName, active: profileName === getActiveProfileName(config), ...profile });
954
+ }
955
+
956
+ async function addAiProfile(name, args) {
957
+ if (!name) {
958
+ throw new Error("Имя профиля обязательно. Пример: iola ai profile add router-qwen --provider openrouter --model qwen/qwen3-32b");
959
+ }
960
+
961
+ const options = parseOptions(args);
962
+ const provider = options.provider;
963
+
964
+ if (!["ollama", "openai", "openrouter", "codex"].includes(provider)) {
965
+ throw new Error("Провайдер должен быть ollama, openai, openrouter или codex.");
966
+ }
967
+
968
+ const profile = buildProfileFromOptions(provider, options);
969
+ const config = await loadConfig();
970
+ await saveConfig({
971
+ ai: {
972
+ ...config.ai,
973
+ profiles: {
974
+ ...(config.ai.profiles || {}),
975
+ [name]: profile,
976
+ },
977
+ },
978
+ });
692
979
 
693
- if (provider !== "ollama" && provider !== "openai" && provider !== "openrouter") {
694
- throw new Error("Провайдер должен быть ollama, openai или openrouter.");
980
+ console.log(`AI-профиль сохранен: ${name}`);
981
+ }
982
+
983
+ async function useAiProfile(name) {
984
+ if (!name) {
985
+ throw new Error("Имя профиля обязательно. Пример: iola ai profile use local");
695
986
  }
696
987
 
697
988
  const config = await loadConfig();
989
+ const profile = config.ai.profiles?.[name];
990
+
991
+ if (!profile) {
992
+ throw new Error(`AI-профиль не найден: ${name}`);
993
+ }
994
+
995
+ await saveConfig({
996
+ ai: {
997
+ ...config.ai,
998
+ activeProfile: name,
999
+ provider: profile.provider,
1000
+ model: profile.model,
1001
+ baseUrl: profile.baseUrl || config.ai.baseUrl,
1002
+ },
1003
+ });
1004
+
1005
+ console.log(`Активный AI-профиль: ${name} (${profile.provider}, ${profile.model || "-"})`);
1006
+ }
1007
+
1008
+ async function deleteAiProfile(name) {
1009
+ if (!name) {
1010
+ throw new Error("Имя профиля обязательно.");
1011
+ }
1012
+
1013
+ const config = await loadConfig();
1014
+ const profiles = { ...(config.ai.profiles || {}) };
1015
+
1016
+ if (!profiles[name]) {
1017
+ throw new Error(`AI-профиль не найден: ${name}`);
1018
+ }
1019
+
1020
+ delete profiles[name];
1021
+ const nextActive = config.ai.activeProfile === name ? Object.keys(profiles)[0] : config.ai.activeProfile;
1022
+ const activeProfile = profiles[nextActive] || DEFAULT_AI_CONFIG.ai.profiles.local;
1023
+
1024
+ await saveConfig({
1025
+ ai: {
1026
+ ...config.ai,
1027
+ profiles,
1028
+ activeProfile: nextActive || "local",
1029
+ provider: activeProfile.provider,
1030
+ model: activeProfile.model,
1031
+ baseUrl: activeProfile.baseUrl || config.ai.baseUrl,
1032
+ },
1033
+ });
1034
+
1035
+ console.log(`AI-профиль удален: ${name}`);
1036
+ }
1037
+
1038
+ function buildProfileFromOptions(provider, options) {
1039
+ const defaults = DEFAULT_AI_CONFIG.ai.profiles[provider === "ollama" ? "local" : provider];
1040
+ const profile = {
1041
+ ...defaults,
1042
+ provider,
1043
+ model: options.model || defaults.model,
1044
+ };
1045
+
1046
+ if (options["base-url"]) {
1047
+ profile.baseUrl = options["base-url"];
1048
+ }
1049
+
1050
+ if (provider === "codex") {
1051
+ profile.sandbox = options.sandbox || defaults.sandbox || "read-only";
1052
+ profile.approval = options.approval || defaults.approval || "never";
1053
+ profile.cwd = options.cwd || defaults.cwd || ".";
1054
+ if (options["codex-profile"]) {
1055
+ profile.codexProfile = options["codex-profile"];
1056
+ }
1057
+ }
1058
+
1059
+ return profile;
1060
+ }
1061
+
1062
+ async function useAiProvider(args) {
1063
+ const [providerOrProfile] = args;
1064
+ const config = await loadConfig();
1065
+
1066
+ if (config.ai.profiles?.[providerOrProfile]) {
1067
+ await useAiProfile(providerOrProfile);
1068
+ return;
1069
+ }
1070
+
1071
+ const provider = providerOrProfile;
1072
+
1073
+ if (provider !== "ollama" && provider !== "openai" && provider !== "openrouter" && provider !== "codex") {
1074
+ throw new Error("Провайдер должен быть ollama, openai, openrouter, codex или именем AI-профиля.");
1075
+ }
1076
+
698
1077
  const defaultModel = {
699
1078
  ollama: config.ai.provider === "ollama" ? config.ai.model : "llama3.2:1b",
700
1079
  openai: config.ai.provider === "openai" ? config.ai.model : "gpt-4.1-mini",
701
1080
  openrouter: config.ai.provider === "openrouter" ? config.ai.model : "openai/gpt-4.1-mini",
1081
+ codex: config.ai.provider === "codex" ? config.ai.model : "gpt-5.5",
702
1082
  }[provider];
1083
+ const profileName = provider === "ollama" ? "local" : provider;
1084
+ const profile = buildProfileFromOptions(provider, { model: defaultModel });
703
1085
 
704
1086
  await saveConfig({
705
1087
  ai: {
1088
+ ...config.ai,
1089
+ activeProfile: profileName,
706
1090
  provider,
707
1091
  model: defaultModel,
708
- baseUrl: provider === "ollama"
709
- ? "http://127.0.0.1:11434"
710
- : provider === "openai"
711
- ? "https://api.openai.com/v1"
712
- : "https://openrouter.ai/api/v1",
1092
+ baseUrl: profile.baseUrl,
1093
+ profiles: {
1094
+ ...(config.ai.profiles || {}),
1095
+ [profileName]: profile,
1096
+ },
713
1097
  },
714
1098
  });
715
1099
 
716
- console.log(`AI-провайдер переключен: ${provider}, модель: ${defaultModel}`);
1100
+ console.log(`AI-провайдер переключен: ${provider}, профиль: ${profileName}, модель: ${defaultModel}`);
717
1101
  }
718
1102
 
719
1103
  async function aiContext(args) {
@@ -838,11 +1222,23 @@ async function setupOllama(args) {
838
1222
  await runCommand("ollama", ["pull", model], { inherit: true });
839
1223
  }
840
1224
 
1225
+ const config = await loadConfig();
1226
+ const profileName = options.name || "local";
841
1227
  await saveConfig({
842
1228
  ai: {
1229
+ ...config.ai,
1230
+ activeProfile: profileName,
843
1231
  provider: "ollama",
844
1232
  model,
845
1233
  baseUrl: "http://127.0.0.1:11434",
1234
+ profiles: {
1235
+ ...(config.ai.profiles || {}),
1236
+ [profileName]: {
1237
+ provider: "ollama",
1238
+ model,
1239
+ baseUrl: "http://127.0.0.1:11434",
1240
+ },
1241
+ },
846
1242
  },
847
1243
  });
848
1244
 
@@ -859,13 +1255,7 @@ async function aiAsk(args, context = {}) {
859
1255
  }
860
1256
 
861
1257
  const config = await loadConfig();
862
- const provider = options.provider || config.ai.provider;
863
- const model = options.model || config.ai.model;
864
- const providerConfig = {
865
- ...config.ai,
866
- provider,
867
- model,
868
- };
1258
+ const providerConfig = resolveAiProfile(config, options);
869
1259
  const dataContext = await buildDataContext(question);
870
1260
  const messages = buildAiMessages(question, dataContext, context.history || []);
871
1261
  const answer = await callAiProvider(providerConfig, messages);
@@ -874,6 +1264,26 @@ async function aiAsk(args, context = {}) {
874
1264
  return answer;
875
1265
  }
876
1266
 
1267
+ function resolveAiProfile(config, options = {}) {
1268
+ const profileName = options.profile || (options.provider && config.ai.profiles?.[options.provider]
1269
+ ? options.provider
1270
+ : getActiveProfileName(config));
1271
+ const activeProfile = config.ai.profiles?.[profileName] || {
1272
+ provider: config.ai.provider,
1273
+ model: config.ai.model,
1274
+ baseUrl: config.ai.baseUrl,
1275
+ };
1276
+ const provider = options.provider && !config.ai.profiles?.[options.provider] ? options.provider : activeProfile.provider;
1277
+
1278
+ return {
1279
+ name: profileName,
1280
+ ...activeProfile,
1281
+ provider,
1282
+ model: options.model || activeProfile.model || config.ai.model,
1283
+ baseUrl: options["base-url"] || activeProfile.baseUrl || config.ai.baseUrl,
1284
+ };
1285
+ }
1286
+
877
1287
  async function buildDataContext(question) {
878
1288
  const apiBaseUrl = await getApiBaseUrl();
879
1289
  const mcpBaseUrl = await getMcpBaseUrl();
@@ -1048,9 +1458,49 @@ async function callAiProvider(config, messages) {
1048
1458
  return callOpenAiCompatible(config, messages, await getApiKey("openrouter"), "OpenRouter");
1049
1459
  }
1050
1460
 
1461
+ if (config.provider === "codex") {
1462
+ return callCodex(config, messages);
1463
+ }
1464
+
1051
1465
  throw new Error(`Неизвестный AI-провайдер: ${config.provider}`);
1052
1466
  }
1053
1467
 
1468
+ async function callCodex(config, messages) {
1469
+ const prompt = messages.map((message) => `${message.role.toUpperCase()}:\n${message.content}`).join("\n\n");
1470
+ const outputFile = path.join(os.tmpdir(), `iola-codex-${process.pid}-${Date.now()}.txt`);
1471
+ const args = [
1472
+ "exec",
1473
+ "--skip-git-repo-check",
1474
+ "--output-last-message",
1475
+ outputFile,
1476
+ "--cd",
1477
+ path.resolve(process.cwd(), config.cwd || "."),
1478
+ "--model",
1479
+ config.model || "gpt-5.5",
1480
+ "--sandbox",
1481
+ config.sandbox || "read-only",
1482
+ ];
1483
+
1484
+ if (config.codexProfile) {
1485
+ args.push("--profile", config.codexProfile);
1486
+ }
1487
+
1488
+ args.push("-");
1489
+
1490
+ try {
1491
+ const { stdout, stderr } = await runCommand("codex", args, { input: prompt });
1492
+ const answer = (await readFile(outputFile, "utf8")).trim();
1493
+ if (answer) {
1494
+ return answer;
1495
+ }
1496
+ return stdout.trim() || stderr.trim();
1497
+ } catch (error) {
1498
+ throw new Error(`Codex CLI недоступен или не авторизован. Проверьте "codex doctor" и "codex login".\n${error.message}`);
1499
+ } finally {
1500
+ await rm(outputFile, { force: true });
1501
+ }
1502
+ }
1503
+
1054
1504
  async function callOllama(config, messages) {
1055
1505
  let response;
1056
1506
 
@@ -1296,7 +1746,7 @@ function parseOptions(args) {
1296
1746
  result[arg.slice(2)] = true;
1297
1747
  } else if (arg === "--check") {
1298
1748
  result.check = true;
1299
- } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--format") {
1749
+ } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--base-url" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format") {
1300
1750
  result[arg.slice(2)] = args[index + 1];
1301
1751
  index += 1;
1302
1752
  } else {
@@ -1617,6 +2067,9 @@ async function confirm(question) {
1617
2067
  async function saveConfig(value) {
1618
2068
  const current = await loadConfig();
1619
2069
  const merged = mergeConfig(current, value);
2070
+ if (value.ai?.profiles) {
2071
+ merged.ai.profiles = value.ai.profiles;
2072
+ }
1620
2073
  await writeConfig(merged);
1621
2074
  }
1622
2075
 
@@ -1645,10 +2098,27 @@ function mergeConfig(base, override) {
1645
2098
  ai: {
1646
2099
  ...base.ai,
1647
2100
  ...(override.ai || {}),
2101
+ profiles: {
2102
+ ...(base.ai.profiles || {}),
2103
+ ...(override.ai?.profiles || {}),
2104
+ },
1648
2105
  },
1649
2106
  };
1650
2107
  }
1651
2108
 
2109
+ function getActiveProfileName(config) {
2110
+ if (config.ai.activeProfile && config.ai.profiles?.[config.ai.activeProfile]) {
2111
+ return config.ai.activeProfile;
2112
+ }
2113
+
2114
+ const provider = config.ai.provider === "ollama" ? "local" : config.ai.provider;
2115
+ if (provider && config.ai.profiles?.[provider]) {
2116
+ return provider;
2117
+ }
2118
+
2119
+ return Object.keys(config.ai.profiles || {})[0] || "local";
2120
+ }
2121
+
1652
2122
  async function getApiBaseUrl() {
1653
2123
  if (process.env.IOLA_API_BASE_URL) {
1654
2124
  return process.env.IOLA_API_BASE_URL;
@@ -1755,6 +2225,14 @@ function runCommand(command, args, options = {}) {
1755
2225
  maxBuffer: 1024 * 1024 * 5,
1756
2226
  }, (error, stdout, stderr) => {
1757
2227
  if (error) {
2228
+ if (process.platform === "win32" && (error.code === "ENOENT" || error.code === "EINVAL") && !options.cmdFallback) {
2229
+ runCommand(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", quoteWindowsCommand(command, args)], {
2230
+ ...options,
2231
+ cmdFallback: true,
2232
+ }).then(resolve, reject);
2233
+ return;
2234
+ }
2235
+
1758
2236
  reject(error);
1759
2237
  return;
1760
2238
  }
@@ -1766,9 +2244,23 @@ function runCommand(command, args, options = {}) {
1766
2244
  child.stdout?.pipe(process.stdout);
1767
2245
  child.stderr?.pipe(process.stderr);
1768
2246
  }
2247
+
2248
+ if (options.input) {
2249
+ child.stdin?.end(options.input);
2250
+ }
1769
2251
  });
1770
2252
  }
1771
2253
 
2254
+ function quoteWindowsCommand(command, args) {
2255
+ return [command, ...args].map((value) => {
2256
+ const text = String(value);
2257
+ if (/^[A-Za-z0-9_./:=\\-]+$/.test(text)) {
2258
+ return text;
2259
+ }
2260
+ return `"${text.replace(/"/g, "\\\"")}"`;
2261
+ }).join(" ");
2262
+ }
2263
+
1772
2264
  function roundGb(bytes) {
1773
2265
  return Math.round((bytes / 1024 / 1024 / 1024) * 10) / 10;
1774
2266
  }