@armadra/agent 0.2.1 → 0.3.0

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 (80) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +55 -11
  3. package/dist/agent/session-cache.js +5 -2
  4. package/dist/agent/session-state.js +2 -1
  5. package/dist/agent/session-sync.js +9 -2
  6. package/dist/agent/session.js +8 -7
  7. package/dist/ai/apis/openai-responses.js +2 -1
  8. package/dist/ai/cache/fingerprint.d.ts +1 -1
  9. package/dist/ai/cache/fingerprint.js +1 -1
  10. package/dist/ai/cache/reporting.js +2 -1
  11. package/dist/ai/providers/channels.d.ts +51 -0
  12. package/dist/ai/providers/channels.js +96 -0
  13. package/dist/ai/providers/enrich.d.ts +34 -0
  14. package/dist/ai/providers/enrich.js +86 -0
  15. package/dist/ai/providers/models-dev-cache.d.ts +53 -0
  16. package/dist/ai/providers/models-dev-cache.js +147 -0
  17. package/dist/ai/providers/models-dev.d.ts +99 -0
  18. package/dist/ai/providers/models-dev.js +315 -0
  19. package/dist/ai/providers/registry.d.ts +35 -4
  20. package/dist/ai/providers/registry.js +177 -24
  21. package/dist/ai/types.d.ts +21 -1
  22. package/dist/bundle/ama.cjs +4075 -2008
  23. package/dist/cli/args.d.ts +6 -3
  24. package/dist/cli/args.js +30 -3
  25. package/dist/cli/bootstrap.js +1 -0
  26. package/dist/cli/compose-providers.js +3 -0
  27. package/dist/cli/compose-session.d.ts +3 -0
  28. package/dist/cli/compose.d.ts +3 -1
  29. package/dist/cli/compose.js +24 -9
  30. package/dist/cli/deps.d.ts +2 -0
  31. package/dist/cli/main.d.ts +2 -1
  32. package/dist/cli/main.js +13 -1
  33. package/dist/cli/startup-steps.js +6 -1
  34. package/dist/cli/subcommands/config.d.ts +11 -2
  35. package/dist/cli/subcommands/config.js +80 -4
  36. package/dist/cli/subcommands/context.js +1 -0
  37. package/dist/cli/subcommands/init.d.ts +6 -0
  38. package/dist/cli/subcommands/init.js +22 -0
  39. package/dist/cli/subcommands/model-meta.d.ts +16 -0
  40. package/dist/cli/subcommands/model-meta.js +54 -0
  41. package/dist/cli/subcommands/models-cache-probe.js +1 -1
  42. package/dist/cli/subcommands/models-discover.d.ts +6 -0
  43. package/dist/cli/subcommands/models-discover.js +37 -9
  44. package/dist/cli/subcommands/models.js +26 -18
  45. package/dist/cli/subcommands/providers-list.d.ts +8 -0
  46. package/dist/cli/subcommands/providers-list.js +97 -0
  47. package/dist/cli/subcommands/providers-plan.d.ts +79 -0
  48. package/dist/cli/subcommands/providers-plan.js +215 -0
  49. package/dist/cli/subcommands/providers.d.ts +27 -0
  50. package/dist/cli/subcommands/providers.js +433 -0
  51. package/dist/config/init.d.ts +30 -0
  52. package/dist/config/init.js +92 -0
  53. package/dist/config/json-schema.d.ts +14 -0
  54. package/dist/config/json-schema.js +182 -0
  55. package/dist/config/schema.d.ts +1 -1
  56. package/dist/config/schema.js +74 -11
  57. package/dist/config/types.d.ts +23 -5
  58. package/dist/config/types.js +2 -0
  59. package/dist/modes/image-input.d.ts +27 -0
  60. package/dist/modes/image-input.js +78 -0
  61. package/dist/modes/interactive/commands.js +2 -3
  62. package/dist/modes/interactive/interactive-mode.js +5 -1
  63. package/dist/modes/interactive/line/line-mode.js +3 -1
  64. package/dist/modes/interactive/startup-ui.d.ts +7 -2
  65. package/dist/modes/interactive/startup-ui.js +41 -11
  66. package/dist/modes/print/print-mode.d.ts +2 -0
  67. package/dist/modes/print/print-mode.js +12 -1
  68. package/dist/modes/rpc/commands.js +7 -2
  69. package/dist/rpc.d.ts +2 -0
  70. package/dist/session/projection.js +5 -1
  71. package/dist/session/types.d.ts +2 -0
  72. package/dist/tools/image-file.d.ts +31 -0
  73. package/dist/tools/image-file.js +114 -0
  74. package/dist/tools/read.d.ts +4 -8
  75. package/dist/tools/read.js +15 -64
  76. package/docs/providers.md +216 -1
  77. package/docs/rpc.md +3 -3
  78. package/docs/session-format.md +1 -1
  79. package/docs/tui.md +2 -0
  80. package/package.json +1 -1
@@ -0,0 +1,433 @@
1
+ /**
2
+ * `ama providers add|list|channels|remove|refresh`(docs/providers.md「一键接入」「渠道」)。
3
+ *
4
+ * - add:只给 baseUrl 与 key,列出中转的模型(`GET /models`)、用 models.dev 补元数据、可选逐渠道
5
+ * 探测,把渠道与模型追加进用户级 config.json(先备份);key 从 stdin 读(不回显)存 auth.json,
6
+ * 或 `--key-env VAR` 写 `"apiKey": "$VAR"`。对已存在的供应商只追加,不改已有条目。
7
+ * - refresh:重拉 `/models` 与 models.dev,只追加新模型;已下架的只提示。
8
+ * - list / channels:供应商 → 渠道 → 模型数与 key 来源(从不显示 key)。remove:删配置与 auth.json 条目。
9
+ *
10
+ * 计费:`--probe` 每模型每渠道一次最小请求;执行前打印预估,TTY 问 y/N,非 TTY 必须 `--yes`。
11
+ */
12
+ import { existsSync } from "node:fs";
13
+ import { createInterface } from "node:readline/promises";
14
+ import { withCustomDefaults } from "../../ai/providers/catalog.js";
15
+ import { describeRefresh, refreshModelsDev } from "../../ai/providers/models-dev-cache.js";
16
+ import { modelsDevFields } from "../../ai/providers/models-dev.js";
17
+ import { materializeModel } from "../../ai/providers/registry.js";
18
+ import { PROVIDER_ID_PATTERN, setAuthKey } from "../../config/auth-file.js";
19
+ import { loadConfigFile } from "../../config/load.js";
20
+ import { CONFIG_FILE_VERSION } from "../../config/types.js";
21
+ import { writeConfigFile } from "../../config/write.js";
22
+ import { parseSubArgs, UsageError } from "../args.js";
23
+ import { ExitCode } from "../exit-codes.js";
24
+ import { extractKey } from "./auth.js";
25
+ import { listChannels, listProviders, removeProvider } from "./providers-list.js";
26
+ import { buildRegistry, loadUserLevel } from "./context.js";
27
+ import { attempt, DISCOVER_TIMEOUT_MS, FATAL_STATUS } from "./models-discover.js";
28
+ import { DEFAULT_PREFER, capRequests, defaultChannels, hintedChannels, listingTarget, mergeProvider, orderChannels, parseChannelSpec, parseModelList, probeSelection, renderTable, } from "./providers-plan.js";
29
+ export const DEFAULT_MAX_REQUESTS = 60;
30
+ export const DEFAULT_LIMIT = 30;
31
+ export const PROVIDERS_USAGE = `用法:ama providers add <id> --base-url <url> [--channel <名字>=<协议>@<地址> …]
32
+ [--api openai-completions|openai-responses|anthropic-messages|auto]
33
+ [--key-env <VAR>] [--probe] [--limit N] [--probe-models a,b,…]
34
+ [--max-requests N] [--prefer chat,responses,messages]
35
+ [--include-no-tools] [--yes]
36
+ ama providers list
37
+ ama providers channels <id>
38
+ ama providers remove <id>
39
+ ama providers refresh <id> [--probe] [--limit N] [--probe-models a,b,…] [--max-requests N] [--yes]
40
+ `;
41
+ const VALUE_OPTIONS = [
42
+ "base-url",
43
+ "api",
44
+ "key-env",
45
+ "limit",
46
+ "probe-models",
47
+ "max-requests",
48
+ "prefer",
49
+ "name",
50
+ "profile",
51
+ "auth-file",
52
+ ];
53
+ const FLAG_OPTIONS = ["probe", "include-no-tools", "yes"];
54
+ function intValue(ctx, name, fallback) {
55
+ const raw = ctx.values.get(name);
56
+ if (raw === undefined)
57
+ return fallback;
58
+ const n = Number(raw);
59
+ if (!Number.isInteger(n) || n < 1)
60
+ throw new UsageError(`--${name} 需要正整数:${raw}`);
61
+ return n;
62
+ }
63
+ function list(raw) {
64
+ return raw
65
+ ?.split(",")
66
+ .map((s) => s.trim())
67
+ .filter((s) => s !== "");
68
+ }
69
+ export function userConfig(level) {
70
+ return structuredClone(loadConfigFile("config", level.userConfigPath)?.value ?? { version: CONFIG_FILE_VERSION });
71
+ }
72
+ async function confirm(io, question) {
73
+ io.stderr(question);
74
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
75
+ try {
76
+ return /^y(es)?$/i.test((await rl.question("继续?[y/N] ")).trim());
77
+ }
78
+ finally {
79
+ rl.close();
80
+ }
81
+ }
82
+ /** --yes / TTY 确认;非 TTY 且没有 --yes 返回 false 并提示(调用方退出 2)。 */
83
+ async function approved(ctx, what) {
84
+ if (ctx.flags.has("yes"))
85
+ return true;
86
+ if (!ctx.io.stdinIsTTY) {
87
+ ctx.io.stderr(`ama: ${what},非交互环境需加 --yes\n`);
88
+ return "usage";
89
+ }
90
+ return confirm(ctx.io, `${what}\n`);
91
+ }
92
+ /**
93
+ * `GET /models`。OpenAI 系带 `Authorization: Bearer`;Anthropic 形状的渠道先试 Bearer(中转多半只认它),
94
+ * 401 / 403 再试 `x-api-key` + `anthropic-version`(官方形状)。
95
+ */
96
+ async function fetchModelList(target, apiKey) {
97
+ const get = (headers) => fetch(target.url, {
98
+ headers: { accept: "application/json", ...headers },
99
+ signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS),
100
+ });
101
+ let response = await get(apiKey !== undefined ? { authorization: `Bearer ${apiKey}` } : {});
102
+ if ((response.status === 401 || response.status === 403) &&
103
+ apiKey !== undefined &&
104
+ target.channel.api === "anthropic-messages")
105
+ response = await get({ "x-api-key": apiKey, "anthropic-version": "2023-06-01" });
106
+ if (!response.ok)
107
+ throw new Error(`HTTP ${response.status} ${target.url}`);
108
+ return parseModelList(await response.json());
109
+ }
110
+ function channelsOfConfig(config) {
111
+ if (config.channels !== undefined)
112
+ return Object.entries(config.channels).map(([name, c]) => ({
113
+ name,
114
+ api: c.api,
115
+ baseUrl: c.baseUrl,
116
+ }));
117
+ if (config.baseUrl === undefined)
118
+ return [];
119
+ const api = config.api ?? "openai-completions";
120
+ return [{ name: "default", api, baseUrl: config.baseUrl }];
121
+ }
122
+ /** 渠道的探测用供应商数据(key 由调用方传入)。 */
123
+ function probeProvider(id, channel) {
124
+ return {
125
+ id,
126
+ name: id,
127
+ api: channel.api,
128
+ baseUrl: channel.baseUrl,
129
+ envKeys: [],
130
+ models: [],
131
+ requiresApiKey: true,
132
+ builtin: false,
133
+ };
134
+ }
135
+ /** 逐模型定渠道(含探测);返回各模型的计划与是否因鉴权 / 限流提前停止。 */
136
+ async function plan(ctx, input) {
137
+ const { io } = ctx;
138
+ const prefer = list(ctx.values.get("prefer")) ?? [...DEFAULT_PREFER];
139
+ const byId = new Map(input.listed.map((m) => [m.id, m]));
140
+ const fresh = input.listed.filter((m) => !input.existingIds.has(m.id));
141
+ const hint = (id) => {
142
+ const listed = byId.get(id);
143
+ return listed !== undefined ? hintedChannels(listed, input.candidates) : undefined;
144
+ };
145
+ const tryChannels = (id) => orderChannels(hint(id) ?? input.candidates.map((c) => c.name), prefer);
146
+ const probeable = (id) => tryChannels(id).length > 0;
147
+ const results = new Map();
148
+ let stopped;
149
+ if (input.probe) {
150
+ const only = list(ctx.values.get("probe-models"));
151
+ const selected = probeSelection(fresh.map((m) => m.id).filter(probeable), intValue(ctx, "limit", DEFAULT_LIMIT), only);
152
+ const max = intValue(ctx, "max-requests", DEFAULT_MAX_REQUESTS);
153
+ const capped = capRequests(selected, tryChannels, max);
154
+ io.stdout(`\n探测:${capped.ids.length} 个模型、${capped.requests} 次最小请求(每模型每渠道 1 次,上限 ${max})` +
155
+ `${capped.dropped > 0 ? `;另有 ${capped.dropped} 个超出上限未探测` : ""}\n`);
156
+ const ok = await approved(ctx, `将发 ${capped.requests} 次计费请求并写入 ${ctx.level.userConfigPath}`);
157
+ if (ok === "usage")
158
+ return { plans: [], cancelled: ExitCode.Usage };
159
+ if (!ok)
160
+ return { plans: [], cancelled: ExitCode.Ok };
161
+ outer: for (const id of capped.ids) {
162
+ const result = { ok: [] };
163
+ const failed = [];
164
+ results.set(id, result);
165
+ for (const name of tryChannels(id)) {
166
+ const channel = input.candidates.find((c) => c.name === name);
167
+ const provider = probeProvider(input.id, channel);
168
+ const model = materializeModel(withCustomDefaults({ id }, input.id, channel.api), provider);
169
+ const error = await attempt(input.registry, model, input.apiKey);
170
+ if (error === undefined)
171
+ result.ok.push(name);
172
+ else {
173
+ result.error = error.split("\n")[0]?.slice(0, 120) ?? error;
174
+ failed.push(`${name}:${result.error}`);
175
+ if (FATAL_STATUS.test(error)) {
176
+ stopped = error;
177
+ break outer;
178
+ }
179
+ }
180
+ }
181
+ io.stdout(` ${id} ${result.ok.length > 0 ? result.ok.join(", ") : "全部失败"}` +
182
+ `${failed.length > 0 ? `(失败 ${failed.join(";")})` : ""}\n`);
183
+ }
184
+ }
185
+ const includeNoTools = ctx.flags.has("include-no-tools");
186
+ const plans = input.listed.map((listed) => {
187
+ const match = input.index?.match(listed.id);
188
+ const fields = match !== undefined ? modelsDevFields(match.model) : {};
189
+ const base = { id: listed.id, fields, match };
190
+ if (input.existingIds.has(listed.id))
191
+ return { ...base, channels: [], status: "existing" };
192
+ const probed = results.get(listed.id);
193
+ if (probed !== undefined) {
194
+ if (probed.ok.length === 0)
195
+ return {
196
+ ...base,
197
+ channels: [],
198
+ status: "failed",
199
+ ...(probed.error ? { error: probed.error } : {}),
200
+ };
201
+ return { ...base, channels: orderChannels(probed.ok, prefer), status: "ok" };
202
+ }
203
+ const hinted = hint(listed.id);
204
+ if (hinted !== undefined && hinted.length === 0)
205
+ return { ...base, channels: [], status: "no-channel" };
206
+ const channels = hinted !== undefined
207
+ ? orderChannels(hinted, prefer)
208
+ : orderChannels(input.candidates.map((c) => c.name), prefer).slice(0, 1);
209
+ if (fields.toolCall === false && !includeNoTools)
210
+ return { ...base, channels, status: "no-tools" };
211
+ return { ...base, channels, status: "unprobed" };
212
+ });
213
+ for (const p of plans)
214
+ if (p.status === "ok" && p.fields.toolCall === false && !includeNoTools)
215
+ p.status = "no-tools";
216
+ return { plans, ...(stopped !== undefined ? { stopped } : {}) };
217
+ }
218
+ async function obtainKey(ctx, id, registry) {
219
+ const keyEnv = ctx.values.get("key-env");
220
+ if (keyEnv !== undefined) {
221
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(keyEnv))
222
+ throw new UsageError(`--key-env 不是合法的变量名:${keyEnv}`);
223
+ const value = ctx.io.env[keyEnv]?.trim();
224
+ if (!value)
225
+ ctx.io.stderr(`ama: 警告:环境变量 ${keyEnv} 未设置,列模型与探测将不带 key\n`);
226
+ return { apiKey: value || undefined, configValue: `$${keyEnv}` };
227
+ }
228
+ if (registry.get(id) !== undefined) {
229
+ const existing = await registry.resolveApiKey(id);
230
+ if (existing.apiKey !== undefined)
231
+ return { apiKey: existing.apiKey };
232
+ }
233
+ if (ctx.io.stdinIsTTY)
234
+ ctx.io.stderr(`输入 ${id} 的 API key(不回显),回车结束:`);
235
+ const key = extractKey(await ctx.io.readStdin());
236
+ if (ctx.io.stdinIsTTY)
237
+ ctx.io.stderr("\n");
238
+ if (key === "")
239
+ throw new UsageError("没有从 stdin 读到 key(或用 --key-env <VAR>)");
240
+ return { apiKey: key, store: key };
241
+ }
242
+ async function add(ctx, id, refresh) {
243
+ const { io, level } = ctx;
244
+ if (!PROVIDER_ID_PATTERN.test(id) || id.includes("@"))
245
+ throw new UsageError(`供应商 id 不合法:${id}`);
246
+ const config = userConfig(level);
247
+ const existing = config.providers?.[id];
248
+ if (refresh && existing === undefined) {
249
+ io.stderr(`ama: ${level.userConfigPath} 里没有供应商 ${id}(先 ama providers add)\n`);
250
+ return ExitCode.Usage;
251
+ }
252
+ const registry = await buildRegistry(level, io, ctx.deps);
253
+ let candidates;
254
+ if (refresh)
255
+ candidates = channelsOfConfig(existing);
256
+ else if (ctx.channelSpecs.length > 0)
257
+ candidates = ctx.channelSpecs.map(parseChannelSpec);
258
+ else {
259
+ const baseUrl = ctx.values.get("base-url");
260
+ if (baseUrl === undefined)
261
+ throw new UsageError("ama providers add 需要 --base-url <url> 或 --channel");
262
+ if (!/^https?:\/\//.test(baseUrl))
263
+ throw new UsageError(`--base-url 应为 http(s) URL:${baseUrl}`);
264
+ candidates = defaultChannels(baseUrl, ctx.values.get("api"));
265
+ }
266
+ const target = listingTarget(candidates);
267
+ if (target === undefined)
268
+ throw new UsageError(`${id} 没有可用的渠道`);
269
+ const key = await obtainKey(ctx, id, registry);
270
+ let listed;
271
+ try {
272
+ listed = await fetchModelList(target, key.apiKey);
273
+ }
274
+ catch (error) {
275
+ io.stderr(`ama: ${id} 模型列表获取失败:${error.message}\n`);
276
+ return ExitCode.RuntimeError;
277
+ }
278
+ io.stdout(`${id}:发现 ${listed.length} 个模型(${target.url})\n`);
279
+ const md = await refreshModelsDev({ dataDir: level.dataDir, env: io.env, force: refresh });
280
+ io.stdout(`${describeRefresh(md)}\n`);
281
+ if (md.warning !== undefined)
282
+ io.stderr(`ama: 警告:${md.warning}\n`);
283
+ io.stdout(`候选渠道:${candidates.map((c) => `${c.name}(${c.api} ${c.baseUrl})`).join(" · ")}\n`);
284
+ const existingIds = new Set((existing?.models ?? []).map((m) => m.id));
285
+ const result = await plan(ctx, {
286
+ id,
287
+ candidates,
288
+ listed,
289
+ index: md.index,
290
+ existingIds,
291
+ apiKey: key.apiKey,
292
+ registry,
293
+ probe: ctx.flags.has("probe"),
294
+ });
295
+ if (result.cancelled !== undefined)
296
+ return result.cancelled;
297
+ io.stdout(`\n${renderTable(result.plans)}`);
298
+ if (refresh) {
299
+ const gone = [...existingIds].filter((m) => !listed.some((l) => l.id === m));
300
+ if (gone.length > 0)
301
+ io.stdout(`上游已不再列出(未删除):${gone.join(", ")}\n`);
302
+ }
303
+ const writable = result.plans.filter((p) => p.status === "ok" || p.status === "unprobed");
304
+ const legacy = existing !== undefined && existing.channels === undefined;
305
+ const merged = legacy && refresh
306
+ ? legacyMerge(existing, writable.map((p) => p.id))
307
+ : mergeProvider(legacy ? asChannels(existing) : existing, {
308
+ ...(ctx.values.get("name") !== undefined
309
+ ? { name: ctx.values.get("name") }
310
+ : {}),
311
+ ...(key.configValue !== undefined ? { apiKey: key.configValue } : {}),
312
+ channels: candidates,
313
+ models: writable,
314
+ prefer: list(ctx.values.get("prefer")) ?? [...DEFAULT_PREFER],
315
+ });
316
+ const keyNote = key.store !== undefined ? `;key → ${level.authFile}(0600)` : "";
317
+ const summary = `\n将写入 ${level.userConfigPath}:${id} 新增渠道 ${merged.addedChannels.join(", ") || "无"},` +
318
+ `新增模型 ${merged.addedModels.length} 个${keyNote}`;
319
+ io.stdout(`${summary}\n`);
320
+ if (merged.addedModels.length === 0 &&
321
+ merged.addedChannels.length === 0 &&
322
+ key.store === undefined) {
323
+ io.stdout("没有要写入的内容\n");
324
+ return result.stopped !== undefined ? ExitCode.RuntimeError : ExitCode.Ok;
325
+ }
326
+ if (!ctx.flags.has("probe")) {
327
+ const ok = await approved(ctx, "确认写入");
328
+ if (ok === "usage")
329
+ return ExitCode.Usage;
330
+ if (!ok) {
331
+ io.stderr("ama: 已取消\n");
332
+ return ExitCode.Ok;
333
+ }
334
+ }
335
+ const providers = (config.providers ??= {});
336
+ providers[id] = merged.config;
337
+ const backup = existsSync(level.userConfigPath);
338
+ writeConfigFile(level.userConfigPath, config, { backup: true });
339
+ if (key.store !== undefined)
340
+ setAuthKey(level.authFile, id, key.store);
341
+ io.stdout(`已写入 ${level.userConfigPath}${backup ? "(原文件备份为 config.json.bak)" : ""}\n`);
342
+ const sample = result.plans.find((p) => p.status === "ok" && merged.addedModels.includes(p.id))?.id ??
343
+ merged.addedModels[0];
344
+ if (sample !== undefined)
345
+ io.stdout(`试试:ama -p "hi" --model ${id}/${sample}\n`);
346
+ if (result.stopped !== undefined) {
347
+ io.stderr(`ama: 探测提前停止(${result.stopped})\n`);
348
+ return ExitCode.RuntimeError;
349
+ }
350
+ return ExitCode.Ok;
351
+ }
352
+ /** 旧写法(无 channels)的供应商:先把供应商级 api + baseUrl 变成 `default` 渠道。 */
353
+ function asChannels(config) {
354
+ const { api, baseUrl, ...rest } = structuredClone(config);
355
+ if (baseUrl === undefined)
356
+ return rest;
357
+ return {
358
+ ...rest,
359
+ channels: { default: { api: api ?? "openai-completions", baseUrl } },
360
+ defaultChannel: "default",
361
+ };
362
+ }
363
+ /** 旧写法供应商的 refresh:只追加 `{ id }`。 */
364
+ function legacyMerge(config, ids) {
365
+ const next = structuredClone(config);
366
+ const models = (next.models ??= []);
367
+ const known = new Set(models.map((m) => m.id));
368
+ const addedModels = ids.filter((id) => !known.has(id));
369
+ for (const id of addedModels)
370
+ models.push({ id });
371
+ return { config: next, addedChannels: [], addedModels };
372
+ }
373
+ /** `--channel` 可重复:先从 argv 里取出来,其余交给 parseSubArgs。 */
374
+ function takeChannels(argv) {
375
+ const rest = [];
376
+ const specs = [];
377
+ for (let i = 0; i < argv.length; i++) {
378
+ const token = argv[i] ?? "";
379
+ if (token === "--channel") {
380
+ const value = argv[++i];
381
+ if (value === undefined || value === "")
382
+ throw new UsageError("--channel 需要一个值");
383
+ specs.push(value);
384
+ }
385
+ else if (token.startsWith("--channel="))
386
+ specs.push(token.slice("--channel=".length));
387
+ else
388
+ rest.push(token);
389
+ }
390
+ return { rest, specs };
391
+ }
392
+ export async function runProviders(argv, io, deps) {
393
+ const { rest, specs } = takeChannels(argv);
394
+ const { positionals, values, flags } = parseSubArgs(rest, VALUE_OPTIONS, FLAG_OPTIONS);
395
+ const action = positionals[0];
396
+ if (flags.has("help") || action === undefined) {
397
+ io.stdout(PROVIDERS_USAGE);
398
+ return flags.has("help") ? ExitCode.Ok : ExitCode.Usage;
399
+ }
400
+ if (deps === undefined) {
401
+ io.stderr("ama: providers 尚未装配(供应商注册表由集成批次注入)\n");
402
+ return ExitCode.RuntimeError;
403
+ }
404
+ const level = loadUserLevel(io, {
405
+ profile: values.get("profile"),
406
+ authFile: values.get("auth-file"),
407
+ });
408
+ for (const warning of level.warnings)
409
+ io.stderr(`ama: 警告:${warning}\n`);
410
+ const ctx = { io, deps, level, values, flags, channelSpecs: specs };
411
+ const target = positionals[1];
412
+ const need = () => {
413
+ if (target === undefined)
414
+ throw new UsageError(`ama providers ${action} 需要 <id>`);
415
+ if (positionals.length > 2)
416
+ throw new UsageError(`多余的参数:${positionals.slice(2).join(" ")}`);
417
+ return target;
418
+ };
419
+ switch (action) {
420
+ case "add":
421
+ return add(ctx, need(), false);
422
+ case "refresh":
423
+ return add(ctx, need(), true);
424
+ case "list":
425
+ return listProviders(ctx);
426
+ case "channels":
427
+ return listChannels(ctx, need());
428
+ case "remove":
429
+ return removeProvider(ctx, need());
430
+ default:
431
+ throw new UsageError(`未知的 providers 子命令:${action}`);
432
+ }
433
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 配置目录初始化(docs/providers.md「配置目录」):`ama init` 与 CLI 首次运行的自动初始化。
3
+ *
4
+ * - 目录 0700;`config.json` 只在不存在时写最小内容(`--force` 时先备份再重写);
5
+ * - `config.schema.json` 不是用户文件,每次 init 都按当前版本重写(内容相同则不动);
6
+ * - 不创建 auth.json(只有 `ama auth set` / `ama providers add` 才写,0600)。
7
+ * - 自动初始化只在 CLI 里、配置目录不存在时触发,`AMA_NO_INIT=1` 关闭;SDK 与测试不走这里。
8
+ */
9
+ import { type AmaConfig } from "./types.js";
10
+ export declare const NO_INIT_ENV = "AMA_NO_INIT";
11
+ /** 最小 config.json:常用键的缺省值(与 DEFAULT_CONFIG 相同,写出来方便直接改)。 */
12
+ export declare function minimalConfig(): AmaConfig & {
13
+ $schema: string;
14
+ };
15
+ export type InitStatus = "created" | "exists" | "updated" | "unchanged" | "overwritten";
16
+ export interface InitResult {
17
+ dir: string;
18
+ dirCreated: boolean;
19
+ files: {
20
+ path: string;
21
+ status: InitStatus;
22
+ }[];
23
+ }
24
+ /** 建目录并补齐缺失文件;已有的 config.json 不覆盖(`force` 时备份为 .bak 再重写)。 */
25
+ export declare function initConfigDir(dir: string, options?: {
26
+ force?: boolean;
27
+ }): InitResult;
28
+ /** CLI 启动时:配置目录不存在就静默初始化;返回是否初始化了。失败不影响启动。 */
29
+ export declare function autoInitConfigDir(dir: string, env: Readonly<Record<string, string | undefined>>): boolean;
30
+ export declare function describeInit(result: InitResult): string;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 配置目录初始化(docs/providers.md「配置目录」):`ama init` 与 CLI 首次运行的自动初始化。
3
+ *
4
+ * - 目录 0700;`config.json` 只在不存在时写最小内容(`--force` 时先备份再重写);
5
+ * - `config.schema.json` 不是用户文件,每次 init 都按当前版本重写(内容相同则不动);
6
+ * - 不创建 auth.json(只有 `ama auth set` / `ama providers add` 才写,0600)。
7
+ * - 自动初始化只在 CLI 里、配置目录不存在时触发,`AMA_NO_INIT=1` 关闭;SDK 与测试不走这里。
8
+ */
9
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { CONFIG_SCHEMA_FILE, configSchemaText } from "./json-schema.js";
12
+ import { AUTH_FILE, CONFIG_FILE } from "./paths.js";
13
+ import { CONFIG_FILE_VERSION } from "./types.js";
14
+ export const NO_INIT_ENV = "AMA_NO_INIT";
15
+ /** 最小 config.json:常用键的缺省值(与 DEFAULT_CONFIG 相同,写出来方便直接改)。 */
16
+ export function minimalConfig() {
17
+ return {
18
+ $schema: `./${CONFIG_SCHEMA_FILE}`,
19
+ version: CONFIG_FILE_VERSION,
20
+ thinkingLevel: "medium",
21
+ permission: { mode: "default" },
22
+ tools: { preset: "default" },
23
+ providers: {},
24
+ };
25
+ }
26
+ function writeAtomic(path, text, mode) {
27
+ const tmp = `${path}.${process.pid}.tmp`;
28
+ writeFileSync(tmp, text, { mode });
29
+ renameSync(tmp, path);
30
+ }
31
+ /** 建目录并补齐缺失文件;已有的 config.json 不覆盖(`force` 时备份为 .bak 再重写)。 */
32
+ export function initConfigDir(dir, options = {}) {
33
+ const dirCreated = !existsSync(dir);
34
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
35
+ if (dirCreated && process.platform !== "win32")
36
+ chmodSync(dir, 0o700);
37
+ const files = [];
38
+ const config = join(dir, CONFIG_FILE);
39
+ const text = `${JSON.stringify(minimalConfig(), null, 2)}\n`;
40
+ if (!existsSync(config)) {
41
+ writeAtomic(config, text, 0o644);
42
+ files.push({ path: config, status: "created" });
43
+ }
44
+ else if (options.force) {
45
+ copyFileSync(config, `${config}.bak`);
46
+ writeAtomic(config, text, 0o644);
47
+ files.push({ path: config, status: "overwritten" });
48
+ }
49
+ else
50
+ files.push({ path: config, status: "exists" });
51
+ const schema = join(dir, CONFIG_SCHEMA_FILE);
52
+ const schemaText = configSchemaText();
53
+ if (!existsSync(schema)) {
54
+ writeAtomic(schema, schemaText, 0o644);
55
+ files.push({ path: schema, status: "created" });
56
+ }
57
+ else if (readFileSync(schema, "utf8") !== schemaText) {
58
+ writeAtomic(schema, schemaText, 0o644);
59
+ files.push({ path: schema, status: "updated" });
60
+ }
61
+ else
62
+ files.push({ path: schema, status: "unchanged" });
63
+ const auth = join(dir, AUTH_FILE);
64
+ if (existsSync(auth))
65
+ files.push({ path: auth, status: "exists" });
66
+ return { dir, dirCreated, files };
67
+ }
68
+ /** CLI 启动时:配置目录不存在就静默初始化;返回是否初始化了。失败不影响启动。 */
69
+ export function autoInitConfigDir(dir, env) {
70
+ if (env[NO_INIT_ENV] === "1" || existsSync(dir))
71
+ return false;
72
+ try {
73
+ initConfigDir(dir);
74
+ return true;
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ const STATUS_TEXT = {
81
+ created: "已创建",
82
+ exists: "已存在,未改动",
83
+ updated: "已更新为当前版本",
84
+ unchanged: "已是当前版本",
85
+ overwritten: "已重写(原文件备份为 .bak)",
86
+ };
87
+ export function describeInit(result) {
88
+ const lines = [`${result.dir}${result.dirCreated ? " 已创建(0700)" : " 已存在"}`];
89
+ for (const file of result.files)
90
+ lines.push(` ${file.path} ${STATUS_TEXT[file.status]}`);
91
+ return `${lines.join("\n")}\n`;
92
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * config.json 的 JSON Schema(draft-07 子集),写到配置目录的 `config.schema.json` 给编辑器补全与校验
3
+ * (docs/providers.md「配置目录」)。规则与 schema.ts 的 `validateConfig` 一一对应:未知字段在那里是
4
+ * warning、这里是 `additionalProperties: false`;渠道引用(模型 `channels` 指向已定义的渠道)这类跨字段
5
+ * 规则 JSON Schema 表达不了,只在 `validateConfig` 里查。一致性由 json-schema.test.ts 的正反例守住。
6
+ */
7
+ type Schema = Record<string, unknown>;
8
+ export declare const CONFIG_SCHEMA_FILE = "config.schema.json";
9
+ export declare const CONFIG_SCHEMA_ID = "https://github.com/Owlbay/armadra-agent/config.schema.json";
10
+ /** 生成 config.json 的 JSON Schema。 */
11
+ export declare function buildConfigJsonSchema(): Schema;
12
+ /** 写进文件的文本(2 空格缩进 + 换行结尾)。 */
13
+ export declare function configSchemaText(): string;
14
+ export {};