agentsw 0.6.2 → 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 +22 -0
- package/README.zh-CN.md +21 -0
- package/dist/commands.js +30 -40
- package/dist/config-transaction.js +138 -0
- package/dist/envfile.js +27 -0
- package/dist/i18n.js +24 -3
- package/dist/import.js +35 -21
- package/dist/index.js +16 -3
- package/dist/menu.js +90 -9
- package/dist/provider-actions.js +37 -0
- package/dist/remove.js +651 -0
- package/dist/rename.js +458 -0
- package/dist/slug.js +17 -8
- package/dist/sources/ccswitch.js +4 -2
- package/dist/targets/claudecode.js +4 -3
- package/dist/targets/workbuddy.js +11 -10
- package/package.json +1 -1
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 {
|
|
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.
|
|
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
|
-
? ["
|
|
163
|
-
: ["
|
|
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 ??
|
|
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
|
|
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
|
|
216
|
-
name: answers.name ||
|
|
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
|
|
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
|
|
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
|
|
314
|
-
const
|
|
315
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/envfile.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
8
|
-
const hasApiVersion = (baseUrl) => /\/v\d+(?:beta\d*)
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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.
|
|
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
|
|
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)
|
|
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,
|
|
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
|
|
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
|
-
.
|
|
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"))
|