agentsw 0.6.1 → 0.7.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.
package/README.md CHANGED
@@ -164,14 +164,18 @@ Explicit `--models` entries and the default model are never filtered out.
164
164
  |---|---|
165
165
  | `agentsw` | interactive menu (no arguments) |
166
166
  | `add` | add or update a provider; `--discover` fills the model list |
167
+ | `quick` | discover protocols/models from URL + API key; auto-name by hostname + protocol |
167
168
  | `import [--all]` | adopt providers from your agents' configs and cc-switch |
168
169
  | `list` / `status` | configured providers / what each agent points at |
170
+ | `list --apps omp,prime` | list agent-local provider IDs, including entries absent from agentsw |
171
+ | `rename <id> <new-id>` | back up and migrate the ID and config references; supports `--dry-run` |
169
172
  | `use <id>` | switch every detected agent; `-a codex,omp`, `-m <model>`, `--dry-run` |
170
173
  | `sync` | re-apply the active provider (after an agent update, say) |
171
174
  | `discover <id> [--sync]` | refresh the model list + metadata from `/v1/models` |
172
175
  | `models [query]` | search the models.dev catalog |
173
176
  | `refresh` | re-fetch metadata for every configured provider |
174
177
  | `prune <id>` / `remove <id> [--prune]` | remove from app configs / from the store |
178
+ | `remove <id> --apps omp` | delete only the selected app's entry; supports `--dry-run` |
175
179
  | `apps` / `install <app>` / `upgrade` | agent version manager |
176
180
 
177
181
  ```bash
@@ -180,6 +184,24 @@ asw discover myproxy --sync
180
184
  asw remove myproxy --prune
181
185
  ```
182
186
 
187
+ New automatic IDs include the full hostname and protocol, e.g. `api-example-com-openai`.
188
+ An explicit `--id` is retained, and syncing never renames an existing provider. Import
189
+ deduplication compares endpoint, protocol, and credentials; different accounts remain separate.
190
+
191
+ ```bash
192
+ asw rename myproxy api-example-com-openai --dry-run
193
+ asw rename myproxy api-example-com-openai
194
+ asw list --apps omp
195
+ asw remove unused-provider --apps omp --dry-run
196
+ asw remove unused-provider --apps omp
197
+ ```
198
+
199
+ `remove` alone changes only the agentsw store; `--prune` also cleans matching app configs.
200
+ `--apps` is instead app-only and can remove a provider never imported into agentsw.
201
+ Do not combine `--apps` and `--prune`. Renames and removals preflight changes and create
202
+ private backups before writes. App-only removal leaves the store intact, so a later explicit
203
+ sync of a managed provider can add it back to that app.
204
+
183
205
  ## How your configs are treated
184
206
 
185
207
  - Every modified file is backed up first to `~/.config/agentsw/backups/`.
package/README.zh-CN.md CHANGED
@@ -161,14 +161,18 @@ asw discover myproxy --no-filter # 清除规则
161
161
  |---|---|
162
162
  | `agentsw` | 交互菜单(不带参数) |
163
163
  | `add` | 添加或更新供应商;`--discover` 自动填模型列表 |
164
+ | `quick` | 只需 URL + API key,自动探测协议与模型,按完整域名 + 协议命名 |
164
165
  | `import [--all]` | 接管各智能体配置与 cc-switch 里已有的供应商 |
165
166
  | `list` / `status` | 已配置的供应商 / 各智能体当前指向 |
167
+ | `list --apps omp,prime` | 列出智能体本地供应商 ID,包括未导入 agentsw 的条目 |
168
+ | `rename <id> <new-id>` | 备份并迁移 ID 和配置引用,支持 `--dry-run` |
166
169
  | `use <id>` | 切换所有检测到的智能体;`-a codex,omp`、`-m <model>`、`--dry-run` |
167
170
  | `sync` | 重新应用当前供应商(比如某个智能体升级之后) |
168
171
  | `discover <id> [--sync]` | 从 `/v1/models` 刷新模型列表与元数据 |
169
172
  | `models [query]` | 搜索 models.dev 目录 |
170
173
  | `refresh` | 重新拉取所有供应商的元数据 |
171
174
  | `prune <id>` / `remove <id> [--prune]` | 从各应用配置中清除 / 从存储中删除 |
175
+ | `remove <id> --apps omp` | 仅删除指定智能体内的条目,支持 `--dry-run` |
172
176
  | `apps` / `install <app>` / `upgrade` | 智能体版本管理 |
173
177
 
174
178
  ```bash
@@ -177,6 +181,23 @@ asw discover myproxy --sync
177
181
  asw remove myproxy --prune
178
182
  ```
179
183
 
184
+ 新建供应商的自动 ID 使用完整域名和协议,例如 `api-example-com-openai`。
185
+ 显式指定的 `--id` 会保留,同步不会自动重命名。导入去重会同时比较端点、协议和凭据,
186
+ 不同账号保持独立;同一账号的自定义名称优先于自动生成名称。
187
+
188
+ ```bash
189
+ asw rename myproxy api-example-com-openai --dry-run
190
+ asw rename myproxy api-example-com-openai
191
+ asw list --apps omp
192
+ asw remove unused-provider --apps omp --dry-run
193
+ asw remove unused-provider --apps omp
194
+ ```
195
+
196
+ 单独 `remove` 只删除 agentsw 中央条目;`--prune` 还会清理匹配的智能体配置。
197
+ `--apps` 则仅作用于指定智能体,也能删除从未导入 agentsw 的供应商,请勿和 `--prune` 同用。
198
+ 重命名和删除会先检查全部计划变更,再创建私有备份并写入。
199
+ 仅从智能体删除时中央条目仍保留,之后主动同步该供应商会重新写入该智能体。
200
+
180
201
  ## 它如何对待你的配置
181
202
 
182
203
  - 每个被修改的文件先备份到 `~/.config/agentsw/backups/`。
package/dist/commands.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import pc from "picocolors";
2
2
  import prompts from "prompts";
3
3
  import { loadStore, saveStore, getProvider, configFile } from "./store.js";
4
- import { scanCandidates, normalizeUrl } from "./import.js";
4
+ import { scanCandidates, normalizeUrl, findMatchingProvider } from "./import.js";
5
5
  import { enrichModels, loadCatalog, searchCatalog } from "./modelsdev.js";
6
6
  import { resolveTargets, supportsProtocol, targets } from "./targets/index.js";
7
7
  import { discoverProviderModels, probeProtocols } from "./discover.js";
8
8
  import { appCommand, appPackages, installedVersion, isNewer, latestVersion, runShell } from "./apps.js";
9
9
  import { drainPendingWrites, readTextIfExists, setDryRun } from "./fsutil.js";
10
10
  import { applyModelFilter } from "./filter.js";
11
- import { slugFromBaseUrl } from "./slug.js";
11
+ import { availableProviderId, providerIdFromBaseUrl, providerNameFromBaseUrl } from "./slug.js";
12
12
  import { t } from "./i18n.js";
13
13
  function fail(message) {
14
14
  process.stderr.write(pc.red(`error: ${message}\n`));
@@ -123,8 +123,8 @@ export async function cmdAdd(opts) {
123
123
  {
124
124
  type: "text",
125
125
  name: "id",
126
- message: t("add.id"),
127
- validate: (v) => (/^[a-z0-9][a-z0-9_-]*$/.test(v) ? true : t("add.idInvalid")),
126
+ message: t("add.idAuto"),
127
+ validate: (v) => (!v || /^[a-z0-9][a-z0-9_-]*$/.test(v) ? true : t("add.idInvalid")),
128
128
  },
129
129
  { type: "text", name: "name", message: t("add.name"), initial: (prev) => prev },
130
130
  {
@@ -159,15 +159,15 @@ export async function cmdAdd(opts) {
159
159
  }
160
160
  else {
161
161
  const required = opts.discover
162
- ? ["id", "protocol", "baseUrl", "apiKey"]
163
- : ["id", "protocol", "baseUrl", "apiKey", "models"];
162
+ ? ["protocol", "baseUrl", "apiKey"]
163
+ : ["protocol", "baseUrl", "apiKey", "models"];
164
164
  for (const field of required) {
165
165
  if (!opts[field])
166
166
  fail(t("add.fieldRequired", { field: field === "baseUrl" ? "base-url" : field === "apiKey" ? "api-key" : field }));
167
167
  }
168
168
  answers = {
169
- id: opts.id,
170
- name: opts.name ?? opts.id,
169
+ id: opts.id ?? "",
170
+ name: opts.name ?? "",
171
171
  protocol: opts.protocol,
172
172
  baseUrl: opts.baseUrl,
173
173
  apiKey: opts.apiKey,
@@ -182,7 +182,7 @@ export async function cmdAdd(opts) {
182
182
  if (wire && wire !== "completions" && wire !== "responses")
183
183
  fail(t("add.openaiApiInvalid"));
184
184
  const openaiApi = protocol === "openai" ? wire : undefined;
185
- const baseUrl = answers.baseUrl.replace(/\/+$/, "");
185
+ const baseUrl = normalizeUrl(answers.baseUrl);
186
186
  const manualIds = (answers.models ?? "").split(",").map((s) => s.trim()).filter(Boolean);
187
187
  let modelIds = manualIds;
188
188
  const modelFilter = parseFilterOpts(opts);
@@ -211,9 +211,12 @@ export async function cmdAdd(opts) {
211
211
  fail(t("add.defaultMissing", { model: defaultModel }));
212
212
  if (opts.smallModel && !modelIds.includes(opts.smallModel))
213
213
  fail(t("add.smallMissing", { model: opts.smallModel }));
214
+ const explicitId = answers.id || undefined;
215
+ const existing = explicitId ? undefined : findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey: answers.apiKey });
216
+ const id = explicitId ?? existing?.id ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
214
217
  const provider = {
215
- id: answers.id,
216
- name: answers.name || answers.id,
218
+ id,
219
+ name: answers.name || existing?.name || (explicitId ? explicitId : providerNameFromBaseUrl(baseUrl, protocol)),
217
220
  protocol,
218
221
  ...(openaiApi ? { openaiApi } : {}),
219
222
  baseUrl,
@@ -282,24 +285,21 @@ export async function cmdQuickAdd(opts) {
282
285
  const interactive = process.stdin.isTTY && !opts.yes;
283
286
  let baseUrl;
284
287
  let apiKey;
285
- let baseId;
286
288
  if (interactive) {
287
289
  const answers = await prompts([
288
290
  { type: "text", name: "baseUrl", message: t("add.baseUrl"), validate: (v) => (/^https?:\/\//.test(v) ? true : t("add.baseUrlInvalid")) },
289
291
  { type: "password", name: "apiKey", message: t("add.apiKey") },
290
292
  ], { onCancel: () => fail(t("add.cancelled")) });
291
- baseUrl = answers.baseUrl.replace(/\/+$/, "");
293
+ baseUrl = normalizeUrl(answers.baseUrl);
292
294
  apiKey = answers.apiKey;
293
- baseId = opts.id ?? slugFromBaseUrl(baseUrl);
294
295
  }
295
296
  else {
296
297
  if (!opts.baseUrl)
297
298
  fail(t("add.fieldRequired", { field: "base-url" }));
298
299
  if (!opts.apiKey)
299
300
  fail(t("add.fieldRequired", { field: "api-key" }));
300
- baseUrl = opts.baseUrl.replace(/\/+$/, "");
301
+ baseUrl = normalizeUrl(opts.baseUrl);
301
302
  apiKey = opts.apiKey;
302
- baseId = opts.id ?? slugFromBaseUrl(baseUrl);
303
303
  }
304
304
  process.stderr.write(pc.dim(t("quick.probing", { url: baseUrl }) + "\n"));
305
305
  const protocols = await probeProtocols({ baseUrl, apiKey });
@@ -310,9 +310,10 @@ export async function cmdQuickAdd(opts) {
310
310
  const multi = protocols.length > 1;
311
311
  const createdIds = [];
312
312
  for (const protocol of protocols) {
313
- const suffix = multi ? `-${protocol}` : "";
314
- const id = `${baseId}${suffix}`;
315
- const name = multi ? `${baseId} (${protocol})` : baseId;
313
+ const explicitId = opts.id === undefined ? undefined : `${opts.id}${multi ? `-${protocol}` : ""}`;
314
+ const existing = explicitId === undefined ? findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey }) : undefined;
315
+ const id = explicitId ?? existing?.id ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
316
+ const name = existing?.name ?? (opts.id === undefined ? providerNameFromBaseUrl(baseUrl, protocol) : multi ? `${opts.id} (${protocol})` : opts.id);
316
317
  process.stderr.write(pc.dim(`${t("add.discovering", { url: baseUrl })} [${protocol}]\n`));
317
318
  const discovered = await discoverProviderModels({ baseUrl, apiKey, protocol });
318
319
  console.log(t("add.providerLists", { count: discovered.length }));
@@ -366,22 +367,6 @@ export function cmdList() {
366
367
  });
367
368
  console.log(table(rows, [" ", "ID", t("table.protocol"), "BASE URL", t("table.defaultModel"), t("table.models")]));
368
369
  }
369
- export async function cmdRemove(id, opts) {
370
- const store = loadStore();
371
- const provider = getProvider(store, id);
372
- if (opts.prune) {
373
- console.log(`${t("remove.pruning", { id: pc.bold(id) })}\n`);
374
- reportResults(await runTargets("prune", provider, undefined));
375
- console.log("");
376
- }
377
- delete store.providers[id];
378
- if (store.active === id)
379
- store.active = undefined;
380
- saveStore(store);
381
- console.log(pc.green(t("remove.removed", { id })));
382
- if (!opts.prune)
383
- console.log(pc.dim(t("remove.note")));
384
- }
385
370
  export async function cmdPrune(id, opts) {
386
371
  const store = loadStore();
387
372
  const provider = getProvider(store, id);
@@ -686,7 +671,7 @@ export async function cmdUpgrade(ids) {
686
671
  }
687
672
  }
688
673
  }
689
- /** Collect custom providers from app configs, dedupe by protocol+baseUrl, import what is new. */
674
+ /** Collect custom providers, dedupe by endpoint and credentials, and import what is new. */
690
675
  export async function cmdImport(opts) {
691
676
  const rows = scanCandidates();
692
677
  const fresh = rows.filter((r) => !r.configured);
@@ -748,6 +733,12 @@ export async function cmdImport(opts) {
748
733
  }, { onCancel: () => fail(t("add.cancelled")) });
749
734
  apiKey = a.key;
750
735
  }
736
+ // A prompted credential can identify a provider imported earlier in this same batch.
737
+ const existing = findMatchingProvider(Object.values(store.providers), { protocol: c.protocol, baseUrl: c.baseUrl, apiKey });
738
+ if (existing) {
739
+ console.log(`${pc.yellow(t("import.skip"))} ${pc.bold(c.id)} · ${c.protocol} · ${c.baseUrl} — ${t("import.already", { id: pc.bold(existing.id) })}`);
740
+ continue;
741
+ }
751
742
  let ids = [...c.models];
752
743
  if (ids.length === 0) {
753
744
  process.stderr.write(pc.dim(`${t("import.discovering", { id: c.id })}\n`));
@@ -761,9 +752,7 @@ export async function cmdImport(opts) {
761
752
  }
762
753
  if (ids.length === 0)
763
754
  fail(t("import.noModelsImport", { id: c.id }));
764
- let id = c.id;
765
- for (let n = 2; store.providers[id]; n++)
766
- id = `${c.id}-${n}`;
755
+ const id = availableProviderId(c.id, store.providers);
767
756
  const hint = guessProviderHint(catalog, c.baseUrl);
768
757
  const models = enrichModels(catalog, ids, hint);
769
758
  const defaultModel = c.defaultModel && ids.includes(c.defaultModel) ? c.defaultModel : ids[0];
@@ -785,5 +774,6 @@ export async function cmdImport(opts) {
785
774
  console.log(`${pc.green(t(existed ? "import.updated" : "import.imported"))} ${pc.bold(id)} · ${c.protocol} · ${c.baseUrl} · ${t("import.modelsCount", { count: ids.length })} [${t("import.from")} ${c.sources.join(", ")}]`);
786
775
  }
787
776
  saveStore(store);
788
- console.log(pc.dim(`\n${t("import.next", { id: imported[0] })}`));
777
+ if (imported.length)
778
+ console.log(pc.dim(`\n${t("import.next", { id: imported[0] })}`));
789
779
  }
@@ -0,0 +1,138 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { backupsDir } from "./fsutil.js";
5
+ function snapshot(file) {
6
+ try {
7
+ const stat = fs.lstatSync(file);
8
+ if (!stat.isFile())
9
+ throw new Error("not a regular file");
10
+ return { text: fs.readFileSync(file, "utf8"), mode: stat.mode & 0o777, identity: `${stat.dev}:${stat.ino}` };
11
+ }
12
+ catch (error) {
13
+ if (error.code === "ENOENT")
14
+ return { text: undefined };
15
+ throw new Error(`${file}: cannot read a regular configuration file`);
16
+ }
17
+ }
18
+ function atomicWrite(file, text, mode) {
19
+ const temp = path.join(path.dirname(file), `.${path.basename(file)}.agentsw-${randomUUID()}.tmp`);
20
+ try {
21
+ fs.writeFileSync(temp, text, { flag: "wx", mode });
22
+ fs.chmodSync(temp, mode);
23
+ fs.renameSync(temp, file);
24
+ }
25
+ finally {
26
+ fs.rmSync(temp, { force: true });
27
+ }
28
+ }
29
+ /** Validate the entire plan and save every original before mutating any configuration. */
30
+ export function commitFileChanges(changes, opts = {}) {
31
+ const paths = new Set();
32
+ const identities = new Set();
33
+ const plan = changes.map((change) => {
34
+ const file = path.resolve(change.file);
35
+ // Resolve the parent too, so directory symlinks cannot disguise duplicate plans.
36
+ let canonical = file;
37
+ try {
38
+ canonical = path.join(fs.realpathSync(path.dirname(file)), path.basename(file));
39
+ }
40
+ catch (error) {
41
+ if (error.code !== "ENOENT") {
42
+ throw new Error(`${file}: cannot resolve configuration directory`);
43
+ }
44
+ }
45
+ if (paths.has(canonical))
46
+ throw new Error(`${file}: duplicate file plan`);
47
+ paths.add(canonical);
48
+ const current = snapshot(file);
49
+ if (current.identity && identities.has(current.identity))
50
+ throw new Error(`${file}: duplicate file plan`);
51
+ if (current.identity)
52
+ identities.add(current.identity);
53
+ if (current.text !== change.before)
54
+ throw new Error(`${file}: configuration changed since it was read`);
55
+ if (change.mode !== undefined && (!Number.isInteger(change.mode) || change.mode < 0 || change.mode > 0o777)) {
56
+ throw new Error(`${file}: invalid file permissions`);
57
+ }
58
+ return { ...change, file, originalMode: current.mode };
59
+ }).filter((change) => change.before !== change.after);
60
+ const files = plan.map((change) => change.file);
61
+ if (!plan.length || opts.dryRun)
62
+ return { files };
63
+ let backupDir;
64
+ let currentFile = backupsDir;
65
+ try {
66
+ fs.mkdirSync(backupsDir, { recursive: true, mode: 0o700 });
67
+ backupDir = fs.mkdtempSync(path.join(backupsDir, "transaction-"));
68
+ fs.chmodSync(backupDir, 0o700);
69
+ for (const [index, change] of plan.entries()) {
70
+ currentFile = change.file;
71
+ if (change.before === undefined)
72
+ continue;
73
+ const backup = path.join(backupDir, `${index}-${path.basename(change.file)}`);
74
+ fs.writeFileSync(backup, change.before, { flag: "wx", mode: 0o600 });
75
+ fs.chmodSync(backup, 0o600);
76
+ }
77
+ fs.writeFileSync(path.join(backupDir, "manifest.json"), JSON.stringify(plan.map((change, index) => ({
78
+ file: change.file,
79
+ backup: change.before === undefined ? null : `${index}-${path.basename(change.file)}`,
80
+ mode: change.originalMode,
81
+ })), null, 2) + "\n", { flag: "wx", mode: 0o600 });
82
+ for (const change of plan) {
83
+ currentFile = change.file;
84
+ if (snapshot(change.file).text !== change.before)
85
+ throw new Error("stale snapshot");
86
+ }
87
+ }
88
+ catch {
89
+ throw new Error(`${currentFile}: backup or pre-write validation failed; configuration files were not changed`);
90
+ }
91
+ const applied = [];
92
+ const createdDirectories = [];
93
+ try {
94
+ for (const change of plan) {
95
+ currentFile = change.file;
96
+ if (snapshot(change.file).text !== change.before)
97
+ throw new Error("stale snapshot");
98
+ if (change.after === undefined)
99
+ fs.unlinkSync(change.file);
100
+ else {
101
+ const missing = [];
102
+ for (let dir = path.dirname(change.file); !fs.existsSync(dir); dir = path.dirname(dir))
103
+ missing.push(dir);
104
+ for (const dir of missing.reverse()) {
105
+ fs.mkdirSync(dir, { mode: 0o700 });
106
+ createdDirectories.push(dir);
107
+ }
108
+ atomicWrite(change.file, change.after, change.mode ?? change.originalMode ?? 0o600);
109
+ }
110
+ applied.push(change);
111
+ }
112
+ }
113
+ catch {
114
+ const failed = [];
115
+ for (const change of applied.reverse()) {
116
+ try {
117
+ // Do not overwrite a third party's intervening edit while rolling back.
118
+ if (snapshot(change.file).text !== change.after)
119
+ throw new Error("stale rollback snapshot");
120
+ if (change.before === undefined)
121
+ fs.unlinkSync(change.file);
122
+ else
123
+ atomicWrite(change.file, change.before, change.originalMode ?? 0o600);
124
+ }
125
+ catch {
126
+ failed.push(change.file);
127
+ }
128
+ }
129
+ for (const dir of createdDirectories.reverse()) {
130
+ try {
131
+ fs.rmdirSync(dir);
132
+ }
133
+ catch { /* Only remove empty directories created by this transaction. */ }
134
+ }
135
+ throw new Error(`${currentFile}: configuration transaction failed; ${failed.length ? `rollback incomplete for ${failed.join(", ")}` : "previous writes rolled back"}; backups: ${backupDir}`);
136
+ }
137
+ return { files, backupDir };
138
+ }
@@ -0,0 +1,27 @@
1
+ /** Tokenize complete dotenv assignments, including quoted multiline values. */
2
+ export function envAssignments(file, text) {
3
+ const result = [];
4
+ const pattern = /^([\t ]*(?:export[\t ]+)?)([A-Za-z_][A-Za-z0-9_]*)([\t ]*=[\t ]*)("(?:\\[\s\S]|[^"\\])*"|'[^']*'|[^\r\n]*)(?:[\t ]*#[^\r\n]*)?[\t ]*(?:\r?\n|$)/gm;
5
+ let end = 0;
6
+ const trivia = (text) => text.split(/\r?\n/).every((line) => !line.trim() || line.trimStart().startsWith("#"));
7
+ for (const match of text.matchAll(pattern)) {
8
+ if (!trivia(text.slice(end, match.index)))
9
+ throw new Error(`${file}: invalid environment configuration`);
10
+ let value = match[4];
11
+ if (value.startsWith('"') || value.startsWith("'")) {
12
+ if (value.at(-1) !== value[0])
13
+ throw new Error(`${file}: invalid quoted environment value`);
14
+ const quote = value[0];
15
+ value = value.slice(1, -1);
16
+ if (quote === '"')
17
+ value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r");
18
+ }
19
+ else
20
+ value = value.split("#", 1)[0].trim();
21
+ result.push({ name: match[2], offset: match.index + match[1].length, value, start: match.index, length: match[0].length });
22
+ end = match.index + match[0].length;
23
+ }
24
+ if (!trivia(text.slice(end)))
25
+ throw new Error(`${file}: invalid environment configuration`);
26
+ return result;
27
+ }
package/dist/i18n.js CHANGED
@@ -8,9 +8,10 @@ const messages = {
8
8
  "error.unknownCommand": { en: "unknown command '{value}', see --help", "zh-CN": "未知命令 '{value}',请查看 --help" },
9
9
  "cmd.add": { en: "add or update a provider (interactive when flags are omitted)", "zh-CN": "添加或更新供应商(省略参数时进入交互流程)" },
10
10
  "cmd.quick": { en: "quick add — auto-detect protocol(s) and models from just base URL + API key", "zh-CN": "快速添加 — 只需 base URL + API key,自动探测协议和模型" },
11
- "cmd.list": { en: "list configured providers", "zh-CN": "列出已配置供应商" },
12
- "cmd.remove": { en: "remove a provider", "zh-CN": "删除供应商" },
11
+ "cmd.list": { en: "list configured providers (or agent-local entries with --apps)", "zh-CN": "列出已配置供应商(--apps 可查看智能体独有配置)" },
12
+ "cmd.remove": { en: "remove a store provider, or only agent-local entries with --apps", "zh-CN": "删除中央供应商,或用 --apps 仅删除指定智能体的供应商" },
13
13
  "cmd.prune": { en: "remove a provider's entries from app configs (keeps it in agentsw)", "zh-CN": "从应用配置移除供应商条目(仍保留在 agentsw)" },
14
+ "cmd.rename": { en: "rename a provider and migrate config references with backups", "zh-CN": "重命名供应商并迁移配置引用(自动备份)" },
14
15
  "cmd.use": { en: "set the active provider and write it into app configs", "zh-CN": "设为当前供应商并写入应用配置" },
15
16
  "cmd.sync": { en: "re-apply the active provider (or --provider) to app configs", "zh-CN": "将当前供应商(或 --provider)重新应用到应用配置" },
16
17
  "cmd.status": { en: "show detected apps and what they currently point at", "zh-CN": "显示检测到的应用及当前供应商" },
@@ -21,7 +22,7 @@ const messages = {
21
22
  "cmd.install": { en: "install an agent CLI", "zh-CN": "安装智能体 CLI" },
22
23
  "cmd.upgrade": { en: "upgrade agent CLIs (no args = everything outdated)", "zh-CN": "升级智能体 CLI(无参数时升级全部过期项)" },
23
24
  "cmd.refresh": { en: "re-fetch models.dev metadata for all providers", "zh-CN": "重新拉取所有供应商的 models.dev 元数据" },
24
- "opt.id": { en: "provider id, e.g. glm, my-proxy", "zh-CN": "供应商 id,例如 glm、my-proxy" },
25
+ "opt.id": { en: "custom provider id (default: full hostname + protocol)", "zh-CN": "自定义供应商 ID(默认:完整域名 + 协议)" },
25
26
  "opt.name": { en: "display name", "zh-CN": "显示名称" },
26
27
  "opt.protocol": { en: "wire protocol: openai | anthropic", "zh-CN": "接口协议: openai | anthropic" },
27
28
  "opt.openaiApi": {
@@ -42,6 +43,8 @@ const messages = {
42
43
  "opt.yes": { en: "non-interactive; require all flags", "zh-CN": "非交互模式;要求提供全部参数" },
43
44
  "opt.prune": { en: "also remove entries from app configs", "zh-CN": "同时从应用配置中移除条目" },
44
45
  "opt.apps": { en: "comma-separated apps or 'all'", "zh-CN": "逗号分隔的应用或 'all'" },
46
+ "opt.removeApps": { en: "delete only from these apps; keep the agentsw store and other apps unchanged", "zh-CN": "仅从这些智能体删除,保留 agentsw 中央配置和其他智能体配置" },
47
+ "opt.manageDryRun": { en: "preview affected paths without changing files or creating backups", "zh-CN": "仅预览受影响路径,不修改文件或创建备份" },
45
48
  "opt.appsDetailed": { en: "comma-separated apps (claude,codex,omp,pi,prime,opencode,hermes,workbuddy) or 'all'", "zh-CN": "逗号分隔的应用(claude,codex,omp,pi,prime,opencode,hermes,workbuddy)或 'all'" },
46
49
  "opt.model": { en: "override default model while switching", "zh-CN": "切换时覆盖默认模型" },
47
50
  "opt.dryRun": { en: "preview config diff without writing", "zh-CN": "仅预览配置差异,不写入" },
@@ -72,6 +75,17 @@ const messages = {
72
75
  "menu.sync": { en: "sync active provider (re-apply to app configs)", "zh-CN": "同步当前供应商 (重新应用到应用配置)" },
73
76
  "menu.discover": { en: "discover models (refresh provider list + metadata)", "zh-CN": "发现模型 (刷新供应商模型与元数据)" },
74
77
  "menu.remove": { en: "remove provider", "zh-CN": "删除供应商" },
78
+ "menu.rename": { en: "rename provider (migrate references with backups)", "zh-CN": "重命名供应商 (备份并迁移引用)" },
79
+ "menu.renameProvider": { en: "which provider to rename?", "zh-CN": "重命名哪个供应商?" },
80
+ "menu.newId": { en: "new provider ID", "zh-CN": "新的供应商 ID" },
81
+ "menu.renameConfirm": { en: "rename {oldId} to {newId} across configured apps?", "zh-CN": "将 {oldId} 重命名为 {newId},并迁移已配置智能体的引用?" },
82
+ "menu.removeScope": { en: "where should this provider be removed?", "zh-CN": "从哪里删除供应商?" },
83
+ "menu.removeStore": { en: "agentsw store only (keep app configs)", "zh-CN": "仅从 agentsw 删除(保留智能体配置)" },
84
+ "menu.removeEverywhere": { en: "agentsw and matching app configurations", "zh-CN": "从 agentsw 和匹配的智能体配置中删除" },
85
+ "menu.removeLocal": { en: "one agent only (including entries not imported into agentsw)", "zh-CN": "仅从一个智能体删除(包括未导入 agentsw 的供应商)" },
86
+ "menu.noRemovable": { en: "no providers found in this scope", "zh-CN": "这个范围内没有可删除的供应商" },
87
+ "menu.removeApp": { en: "which agent should this provider be removed from?", "zh-CN": "从哪个智能体删除供应商?" },
88
+ "menu.removeConfirmScope": { en: "remove {id} from {scope}? Default references will be cleared; files are backed up first.", "zh-CN": "从 {scope} 删除 {id}?相关默认引用将被清理,文件会先备份。" },
75
89
  "menu.apps": { en: "agent versions (check installed CLIs / upgrade)", "zh-CN": "智能体版本 (检查已安装 CLI / 升级)" },
76
90
  "menu.language": { en: "language / 语言", "zh-CN": "语言 / Language" },
77
91
  "menu.quit": { en: "quit", "zh-CN": "退出" },
@@ -96,6 +110,7 @@ const messages = {
96
110
  "menu.allInstalled": { en: "all agents already installed", "zh-CN": "所有智能体均已安装" },
97
111
  "menu.bye": { en: "bye", "zh-CN": "再见" },
98
112
  "add.id": { en: "provider id (slug)", "zh-CN": "供应商 id(slug)" },
113
+ "add.idAuto": { en: "provider id (leave blank for hostname + protocol)", "zh-CN": "供应商 ID(留空使用完整域名 + 协议)" },
99
114
  "add.idInvalid": { en: "lowercase slug, e.g. glm or my-proxy", "zh-CN": "请用小写 slug,例如 glm 或 my-proxy" },
100
115
  "add.name": { en: "display name", "zh-CN": "显示名称" },
101
116
  "add.protocol": { en: "wire protocol", "zh-CN": "接口协议" },
@@ -171,6 +186,12 @@ const messages = {
171
186
  "remove.pruning": { en: "pruning {id} from app configs", "zh-CN": "正在从应用配置清理 {id}" },
172
187
  "remove.removed": { en: "removed provider {id}", "zh-CN": "已删除供应商 {id}" },
173
188
  "remove.note": { en: "note: app configs are unchanged; use --prune to clean them", "zh-CN": "注意:应用配置未改动;可使用 --prune 清理" },
189
+ "manage.preview": { en: "preview: {count} file(s) would change", "zh-CN": "预览:将修改 {count} 个文件" },
190
+ "manage.changed": { en: "updated {count} file(s)", "zh-CN": "已更新 {count} 个文件" },
191
+ "manage.backup": { en: "backup directory: {path}", "zh-CN": "备份目录:{path}" },
192
+ "rename.done": { en: "renamed {oldId} to {newId}", "zh-CN": "已将 {oldId} 重命名为 {newId}" },
193
+ "remove.localDone": { en: "removed {id} only from {apps}; agentsw and other apps are unchanged", "zh-CN": "已仅从 {apps} 删除 {id};agentsw 和其他智能体保持不变" },
194
+ "list.localNone": { en: "no agent-local provider entries found", "zh-CN": "未发现智能体本地供应商条目" },
174
195
  "common.skip": { en: "skip", "zh-CN": "跳过" },
175
196
  "common.yes": { en: "yes", "zh-CN": "是" },
176
197
  "common.no": { en: "no", "zh-CN": "否" },
package/dist/import.js CHANGED
@@ -1,38 +1,52 @@
1
1
  import { loadStore } from "./store.js";
2
2
  import { ccSwitchCandidates } from "./sources/ccswitch.js";
3
3
  import { targets } from "./targets/index.js";
4
+ /** Normalize host casing and trailing path slashes without rewriting paths or explicit ports. */
4
5
  export function normalizeUrl(u) {
5
- return u.replace(/\/+$/, "");
6
+ const parts = /^([a-z][a-z0-9+.-]*:\/\/)([^/?#]*)([^?#]*)([?#].*)?$/i.exec(u);
7
+ if (!parts)
8
+ return u.replace(/\/+$/, "");
9
+ const [, scheme, authority, pathname, suffix] = parts;
10
+ const at = authority.lastIndexOf("@") + 1;
11
+ return `${scheme.toLowerCase()}${authority.slice(0, at)}${authority.slice(at).toLowerCase()}${pathname.replace(/\/+$/, "")}${suffix ?? ""}`;
6
12
  }
7
- /** True when a base URL already names the API version, e.g. `.../v1`. */
8
- const hasApiVersion = (baseUrl) => /\/v\d+(?:beta\d*)?$/i.test(baseUrl);
9
- /**
10
- * Endpoint identity for dedupe. Apps disagree about who owns the `/v1` segment:
11
- * omp/pi/opencode store it in the base URL, while Codex (and cc-switch's Codex
12
- * rows) leave it off because the client appends it. `https://host` and
13
- * `https://host/v1` on one protocol are the same upstream, so they must merge
14
- * into one provider instead of importing twice.
15
- */
16
- const matchKey = (protocol, baseUrl) => `${protocol}|${normalizeUrl(baseUrl).replace(/\/v\d+(?:beta\d*)?$/i, "")}`;
17
- /** pure merge: dedupe candidates by protocol+baseUrl, union models, keep first resolved key */
13
+ /** True when the path already names the API version, e.g. `.../v1`. */
14
+ const hasApiVersion = (baseUrl) => /\/v\d+(?:beta\d*)?$/.test(baseUrl.split(/[?#]/, 1)[0]);
15
+ /** Apps may append their own API version; all other URL components remain significant. */
16
+ export function endpointKey(baseUrl) {
17
+ const normalized = normalizeUrl(baseUrl);
18
+ const suffixAt = normalized.search(/[?#]/);
19
+ const path = suffixAt < 0 ? normalized : normalized.slice(0, suffixAt);
20
+ return path.replace(/\/v\d+(?:beta\d*)?$/, "") + (suffixAt < 0 ? "" : normalized.slice(suffixAt));
21
+ }
22
+ /** An unresolved credential is not evidence that two configurations use the same account. */
23
+ export function findMatchingProvider(existing, candidate) {
24
+ if (!candidate.apiKey)
25
+ return undefined;
26
+ const endpoint = endpointKey(candidate.baseUrl);
27
+ return existing.find((p) => p.protocol === candidate.protocol && p.apiKey === candidate.apiKey && endpointKey(p.baseUrl) === endpoint);
28
+ }
29
+ /** Pure merge: union models only within the same credential-qualified endpoint. */
18
30
  export function mergeCandidates(rows, existing) {
19
31
  const merged = new Map();
20
32
  for (const { source, ...c } of rows) {
21
- const key = matchKey(c.protocol, c.baseUrl);
33
+ const credentials = c.apiKey ? ["literal", c.apiKey] : c.keyEnv ? ["env", c.keyEnv] : ["missing", source, c.id];
34
+ const key = JSON.stringify([c.protocol, endpointKey(c.baseUrl), credentials]);
22
35
  const cur = merged.get(key);
23
36
  if (!cur) {
24
37
  merged.set(key, { ...c, baseUrl: normalizeUrl(c.baseUrl), models: [...new Set(c.models)], sources: [source] });
25
38
  continue;
26
39
  }
40
+ // Explicit app identities outrank endpoint-derived suggestions, regardless of scan order.
41
+ if (cur.generatedId && !c.generatedId) {
42
+ cur.id = c.id;
43
+ cur.name = c.name;
44
+ delete cur.generatedId;
45
+ }
27
46
  if (!cur.sources.includes(source))
28
47
  cur.sources.push(source);
29
- if (!cur.apiKey && c.apiKey) {
30
- cur.apiKey = c.apiKey;
48
+ if (!cur.keyEnv && c.keyEnv)
31
49
  cur.keyEnv = c.keyEnv;
32
- }
33
- else if (!cur.keyEnv && c.keyEnv) {
34
- cur.keyEnv = c.keyEnv;
35
- }
36
50
  for (const m of c.models)
37
51
  if (!cur.models.includes(m))
38
52
  cur.models.push(m);
@@ -49,7 +63,7 @@ export function mergeCandidates(rows, existing) {
49
63
  }
50
64
  const out = [...merged.values()];
51
65
  for (const m of out) {
52
- const hit = existing.find((e) => matchKey(e.protocol, e.baseUrl) === matchKey(m.protocol, m.baseUrl));
66
+ const hit = findMatchingProvider(existing, m);
53
67
  if (hit)
54
68
  m.configured = hit.id;
55
69
  }
@@ -75,5 +89,5 @@ export function scanCandidates() {
75
89
  // a cc-switch store this build cannot read is not this scan's problem
76
90
  }
77
91
  const store = loadStore();
78
- return mergeCandidates(rows, Object.values(store.providers).map((p) => ({ id: p.id, protocol: p.protocol, baseUrl: p.baseUrl })));
92
+ return mergeCandidates(rows, Object.values(store.providers));
79
93
  }
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
3
  import { Command, InvalidArgumentError } from "commander";
4
- import { cmdAdd, cmdQuickAdd, cmdApps, cmdDiscover, cmdImport, cmdInstall, cmdList, cmdModels, cmdRefreshMeta, cmdPrune, cmdRemove, cmdStatus, cmdSync, cmdUpgrade, cmdUse, } from "./commands.js";
4
+ import { cmdAdd, cmdQuickAdd, cmdApps, cmdDiscover, cmdImport, cmdInstall, cmdList, cmdModels, cmdRefreshMeta, cmdPrune, cmdStatus, cmdSync, cmdUpgrade, cmdUse, } from "./commands.js";
5
5
  import { detectSystemLocale, extractCliLocale, normalizeLocale, setLocale, t } from "./i18n.js";
6
6
  import { cmdMenu } from "./menu.js";
7
7
  import { loadStore } from "./store.js";
8
+ import { cmdListLocalProviders, cmdRemoveProvider, cmdRename } from "./provider-actions.js";
8
9
  // Locale precedence: CLI flag > environment > saved preference > system locale.
9
10
  setLocale(extractCliLocale() ?? process.env.AGENTSW_LANG ?? loadStore().language ?? detectSystemLocale());
10
11
  function parseLocale(value) {
@@ -68,13 +69,25 @@ program
68
69
  .option("--no-dedup", t("opt.noDedup"))
69
70
  .option("-y, --yes", t("opt.yes"))
70
71
  .action(cmdQuickAdd);
71
- program.command("list").alias("ls").description(t("cmd.list")).action(cmdList);
72
+ program
73
+ .command("list")
74
+ .alias("ls")
75
+ .description(t("cmd.list"))
76
+ .option("-a, --apps <apps>", t("opt.apps"))
77
+ .action((opts) => opts.apps ? cmdListLocalProviders(opts.apps) : cmdList());
78
+ program
79
+ .command("rename <id> <new-id>")
80
+ .description(t("cmd.rename"))
81
+ .option("-n, --dry-run", t("opt.manageDryRun"))
82
+ .action(cmdRename);
72
83
  program
73
84
  .command("remove <id>")
74
85
  .alias("rm")
75
86
  .description(t("cmd.remove"))
76
87
  .option("--prune", t("opt.prune"))
77
- .action(cmdRemove);
88
+ .option("-a, --apps <apps>", t("opt.removeApps"))
89
+ .option("-n, --dry-run", t("opt.manageDryRun"))
90
+ .action(cmdRemoveProvider);
78
91
  program
79
92
  .command("prune <id>")
80
93
  .description(t("cmd.prune"))