agentsw 0.3.0 → 0.5.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
@@ -65,6 +65,11 @@ npx agentsw # zero install: opens the interactive menu
65
65
  npm install -g agentsw # requires Node >= 22.12
66
66
  ```
67
67
 
68
+ The provider sync and config management work on Linux, macOS, and Windows. On Windows,
69
+ agentsw stores its own state under `%APPDATA%\agentsw`; native app-data locations are
70
+ used for agents that require them. Set `AGENTSW_HOME` when running from Git Bash/WSL or
71
+ when a portable home directory is desired.
72
+
68
73
  ## Quick start
69
74
 
70
75
  ```bash
@@ -195,7 +200,11 @@ asw remove myproxy --prune
195
200
  `apiKeyEnv: AGENTSW_<ID>_API_KEY`, the key goes into `$DSH_HOME/.credentials.yaml`
196
201
  (`refs:`, mode 0600). `$DSH_HOME` defaults to `~/.dsh`.
197
202
  - cc-switch's database is opened read-only and never written to.
198
- - The apps manager (install/upgrade) currently targets macOS/Linux.
203
+ - The apps manager supports native Windows commands for Claude Code, Codex CLI, pi,
204
+ opencode, Hermes, and DeepSeek Harness. Oh My Pi and prime-agent still need their own
205
+ Windows installation method; WorkBuddy is installed and updated by its desktop app.
206
+ - `OPENCODE_CONFIG_DIR`, `WORKBUDDY_CONFIG_DIR`, `CODEBUDDY_CONFIG_DIR`, `HERMES_HOME`,
207
+ and `DSH_HOME` can override native config locations.
199
208
 
200
209
  ## Language
201
210
 
package/README.zh-CN.md CHANGED
@@ -64,6 +64,10 @@ npx agentsw # 免安装,直接打开交互菜单
64
64
  npm install -g agentsw # 需要 Node >= 22.12
65
65
  ```
66
66
 
67
+ 供应商同步与配置管理支持 Linux、macOS 和 Windows。Windows 下 agentsw 自身状态默认存放在
68
+ `%APPDATA%\agentsw`,需要原生应用数据目录的智能体会使用对应目录。通过 Git Bash/WSL 运行,
69
+ 或需要可移植目录时,可以设置 `AGENTSW_HOME`。
70
+
67
71
  ## 快速开始
68
72
 
69
73
  ```bash
@@ -190,7 +194,11 @@ asw remove myproxy --prune
190
194
  `apiKeyEnv: AGENTSW_<ID>_API_KEY` 引用,密钥写入 `$DSH_HOME/.credentials.yaml`
191
195
  的 `refs:`(权限 0600)。`$DSH_HOME` 默认为 `~/.dsh`。
192
196
  - cc-switch 的数据库以只读方式打开,永不写入。
193
- - 安装管理器(install/upgrade)目前仅支持 macOS/Linux。
197
+ - 安装管理器在 Windows 下为 Claude Code、Codex CLI、pi、opencode、Hermes 和 DeepSeek Harness
198
+ 使用原生安装命令。Oh My Pi 与 prime-agent 仍需使用各自的 Windows 安装方式;WorkBuddy
199
+ 由桌面应用自行安装和更新。
200
+ - `OPENCODE_CONFIG_DIR`、`WORKBUDDY_CONFIG_DIR`、`CODEBUDDY_CONFIG_DIR`、`HERMES_HOME` 与
201
+ `DSH_HOME` 可覆盖原生配置目录。
194
202
 
195
203
  ## 语言
196
204
 
package/dist/apps.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execFileSync, execSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { home, readJsonIfExists } from "./fsutil.js";
4
+ import { appDataDir, home, readJsonIfExists } from "./fsutil.js";
5
5
  export const appPackages = [
6
6
  {
7
7
  id: "claude",
@@ -10,6 +10,7 @@ export const appPackages = [
10
10
  latest: { kind: "npm", name: "@anthropic-ai/claude-code" },
11
11
  installCmd: "curl -fsSL https://claude.ai/install.sh | bash",
12
12
  upgradeCmd: "claude update",
13
+ windowsInstallCmd: "npm install -g @anthropic-ai/claude-code@latest",
13
14
  },
14
15
  {
15
16
  id: "codex",
@@ -17,6 +18,7 @@ export const appPackages = [
17
18
  binary: "codex",
18
19
  latest: { kind: "npm", name: "@openai/codex" },
19
20
  installCmd: "npm install -g @openai/codex@latest",
21
+ windowsInstallCmd: "npm install -g @openai/codex@latest",
20
22
  },
21
23
  {
22
24
  id: "omp",
@@ -32,6 +34,7 @@ export const appPackages = [
32
34
  binary: "pi",
33
35
  latest: { kind: "npm", name: "@earendil-works/pi-coding-agent" },
34
36
  installCmd: "npm install -g @earendil-works/pi-coding-agent@latest",
37
+ windowsInstallCmd: "npm install -g @earendil-works/pi-coding-agent@latest",
35
38
  },
36
39
  {
37
40
  id: "prime",
@@ -47,6 +50,7 @@ export const appPackages = [
47
50
  latest: { kind: "npm", name: "opencode-ai" },
48
51
  installCmd: "curl -fsSL https://opencode.ai/install | bash",
49
52
  upgradeCmd: "opencode upgrade",
53
+ windowsInstallCmd: "npm install -g opencode-ai@latest",
50
54
  },
51
55
  {
52
56
  id: "hermes",
@@ -55,17 +59,20 @@ export const appPackages = [
55
59
  latest: { kind: "pypi", name: "hermes-agent" },
56
60
  installCmd: "uv tool install hermes-agent || pipx install hermes-agent",
57
61
  upgradeCmd: "uv tool upgrade hermes-agent || pipx upgrade hermes-agent",
62
+ windowsInstallCmd: "uv tool install hermes-agent || pipx install hermes-agent",
63
+ windowsUpgradeCmd: "uv tool upgrade hermes-agent || pipx upgrade hermes-agent",
58
64
  },
59
65
  {
60
66
  id: "workbuddy",
61
67
  name: "WorkBuddy",
62
68
  // Electron desktop app: version from its data dir; managed by its own updater.
63
69
  localVersion: () => {
64
- const j = readJsonIfExists(path.join(home, ".workbuddy", "last-launch.json"));
70
+ const dir = workbuddyDataDir();
71
+ const j = readJsonIfExists(path.join(dir, "last-launch.json"));
65
72
  const v = (j?.version ?? j?.appVersion);
66
73
  if (v)
67
74
  return v;
68
- const r = readJsonIfExists(path.join(home, ".workbuddy", "app", "renderer-version.json"));
75
+ const r = readJsonIfExists(path.join(dir, "app", "renderer-version.json"));
69
76
  return r?.version ?? undefined;
70
77
  },
71
78
  },
@@ -75,13 +82,19 @@ export const appPackages = [
75
82
  binary: "dsh",
76
83
  latest: { kind: "npm", name: "@deepseek-ai/dsh" },
77
84
  installCmd: "npm install -g @deepseek-ai/dsh@latest",
85
+ windowsInstallCmd: "npm install -g @deepseek-ai/dsh@latest",
78
86
  },
79
87
  ];
88
+ function workbuddyDataDir() {
89
+ return process.env.WORKBUDDY_CONFIG_DIR?.trim() || process.env.CODEBUDDY_CONFIG_DIR?.trim() ||
90
+ (process.platform === "win32" ? appDataDir("workbuddy") : path.join(home, ".workbuddy"));
91
+ }
80
92
  const SEMVERISH = /\d+\.\d+(\.\d+)?([-.+][\w.-]+)?/;
81
93
  export function installedVersion(app) {
82
94
  if (app.binary) {
83
95
  try {
84
- const out = execFileSync(app.binary, app.versionArgs ?? ["--version"], {
96
+ const binary = executableName(app.binary);
97
+ const out = execFileSync(binary, app.versionArgs ?? ["--version"], {
85
98
  encoding: "utf8",
86
99
  timeout: 15000,
87
100
  stdio: ["ignore", "pipe", "pipe"],
@@ -126,7 +139,7 @@ export async function latestVersion(app) {
126
139
  case "brew": {
127
140
  // Local tap metadata first (works for taps; refreshed by `brew update`), then core API.
128
141
  try {
129
- const out = execSync(`brew info --json=v2 ${src.name}`, { encoding: "utf8", timeout: 30000 });
142
+ const out = execSync(`brew info --json=v2 ${src.name}`, { encoding: "utf8", timeout: 30000, stdio: ["pipe", "pipe", "pipe"] });
130
143
  const data = JSON.parse(out);
131
144
  const stable = data.formulae?.[0]?.versions?.stable;
132
145
  if (stable)
@@ -164,12 +177,38 @@ export function isNewer(installed, latest) {
164
177
  export function runShell(command) {
165
178
  execSync(command, { stdio: "inherit", env: process.env });
166
179
  }
180
+ /** Resolve the platform-specific command while keeping the package table readable. */
181
+ export function appCommand(app, action, platform = process.platform) {
182
+ if (platform === "win32")
183
+ return action === "install" ? app.windowsInstallCmd : app.windowsUpgradeCmd ?? app.windowsInstallCmd;
184
+ return action === "install" ? app.installCmd : app.upgradeCmd ?? app.installCmd;
185
+ }
186
+ function executableName(binary) {
187
+ if (process.platform !== "win32")
188
+ return binary;
189
+ for (const suffix of ["", ".cmd", ".exe", ".bat"]) {
190
+ const candidate = `${binary}${suffix}`;
191
+ if (binaryOnPath(candidate))
192
+ return candidate;
193
+ }
194
+ return binary;
195
+ }
167
196
  export function binaryOnPath(binary) {
168
197
  const dirs = (process.env.PATH ?? "").split(path.delimiter);
169
198
  return dirs.some((d) => {
170
199
  try {
171
- fs.accessSync(path.join(d, binary), fs.constants.X_OK);
172
- return true;
200
+ const names = process.platform === "win32" && !path.extname(binary)
201
+ ? [binary, ...((process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((s) => `${binary}${s.toLowerCase()}`))]
202
+ : [binary];
203
+ return names.some((name) => {
204
+ try {
205
+ fs.accessSync(path.join(d, name), process.platform === "win32" ? fs.constants.F_OK : fs.constants.X_OK);
206
+ return true;
207
+ }
208
+ catch {
209
+ return false;
210
+ }
211
+ });
173
212
  }
174
213
  catch {
175
214
  return false;
package/dist/commands.js CHANGED
@@ -4,10 +4,11 @@ import { loadStore, saveStore, getProvider, configFile } from "./store.js";
4
4
  import { scanCandidates, normalizeUrl } from "./import.js";
5
5
  import { enrichModels, loadCatalog, searchCatalog } from "./modelsdev.js";
6
6
  import { resolveTargets, supportsProtocol, targets } from "./targets/index.js";
7
- import { discoverProviderModels } from "./discover.js";
8
- import { appPackages, installedVersion, isNewer, latestVersion, runShell } from "./apps.js";
7
+ import { discoverProviderModels, probeProtocols } from "./discover.js";
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
12
  import { t } from "./i18n.js";
12
13
  function fail(message) {
13
14
  process.stderr.write(pc.red(`error: ${message}\n`));
@@ -57,6 +58,13 @@ function modelRows(models) {
57
58
  ]);
58
59
  }
59
60
  const MODEL_HEADER = ["MODEL", "CTX", "IN", "OUT", "REASONING", "$IN/$OUT per M"];
61
+ /** Print a dim hint when some models have no models.dev metadata. */
62
+ function printUncatalogedHint(models) {
63
+ const bare = models.filter((m) => m.contextWindow === undefined && !m.cost);
64
+ if (bare.length > 0) {
65
+ console.log(pc.dim(`${bare.length} model(s) have no models.dev metadata (shown as "-")`));
66
+ }
67
+ }
60
68
  /** Guess the models.dev provider whose API host matches the configured baseUrl. */
61
69
  function guessProviderHint(catalog, baseUrl) {
62
70
  if (!catalog)
@@ -178,7 +186,11 @@ export async function cmdAdd(opts) {
178
186
  const manualIds = (answers.models ?? "").split(",").map((s) => s.trim()).filter(Boolean);
179
187
  let modelIds = manualIds;
180
188
  const modelFilter = parseFilterOpts(opts);
181
- if (opts.discover) {
189
+ const shouldDiscover = opts.discover || manualIds.length === 0;
190
+ if (shouldDiscover) {
191
+ if (!opts.discover) {
192
+ process.stderr.write(pc.dim(t("add.autoDiscover") + "\n"));
193
+ }
182
194
  process.stderr.write(pc.dim(`${t("add.discovering", { url: baseUrl })}\n`));
183
195
  const discovered = await discoverProviderModels({ baseUrl, apiKey: answers.apiKey, protocol });
184
196
  console.log(t("add.providerLists", { count: discovered.length }));
@@ -222,8 +234,118 @@ export async function cmdAdd(opts) {
222
234
  console.log(pc.green(t("add.saved", { status: savedStatus, id: pc.bold(provider.id), protocol })));
223
235
  console.log(t("add.metadata", { matched, total: models.length }) + (hint ? t("add.providerHint", { hint }) : ""));
224
236
  console.log(table(modelRows(models), MODEL_HEADER));
237
+ printUncatalogedHint(models);
225
238
  console.log(pc.dim(`\n${t("add.next", { id: provider.id })}`));
226
239
  }
240
+ /** Shared logic to create and save a single provider from discovered models. */
241
+ async function createProvider(opts) {
242
+ const { store, id, name, protocol, openaiApi, baseUrl, apiKey, modelIds, modelFilter } = opts;
243
+ const catalog = await loadCatalog();
244
+ const hint = guessProviderHint(catalog, baseUrl);
245
+ const models = enrichModels(catalog, modelIds, hint);
246
+ const matched = models.filter((m) => m.contextWindow !== undefined).length;
247
+ const defaultModel = opts.defaultModel ?? modelIds[0];
248
+ if (!modelIds.includes(defaultModel))
249
+ fail(t("add.defaultMissing", { model: defaultModel }));
250
+ const provider = {
251
+ id,
252
+ name,
253
+ protocol,
254
+ ...(openaiApi ? { openaiApi } : {}),
255
+ baseUrl,
256
+ apiKey,
257
+ models,
258
+ defaultModel,
259
+ smallModel: opts.smallModel,
260
+ reasoningEffort: opts.reasoningEffort,
261
+ modelsDevId: hint,
262
+ modelFilter,
263
+ };
264
+ const existed = store.providers[provider.id] !== undefined;
265
+ store.providers[provider.id] = provider;
266
+ if (!store.active)
267
+ store.active = provider.id;
268
+ const savedStatus = t(existed ? "add.updated" : "add.added");
269
+ console.log(pc.green(t("add.saved", { status: savedStatus, id: pc.bold(provider.id), protocol })));
270
+ console.log(t("add.metadata", { matched, total: models.length }) + (hint ? t("add.providerHint", { hint }) : ""));
271
+ console.log(table(modelRows(models), MODEL_HEADER));
272
+ printUncatalogedHint(models);
273
+ return provider;
274
+ }
275
+ /**
276
+ * Quick add: only base URL + API key needed. Auto-detects protocol(s) by probing
277
+ * /v1/models with both openai and anthropic auth headers. When both succeed,
278
+ * creates two providers with -openai / -anthropic suffixes.
279
+ */
280
+ export async function cmdQuickAdd(opts) {
281
+ const store = loadStore();
282
+ const interactive = process.stdin.isTTY && !opts.yes;
283
+ let baseUrl;
284
+ let apiKey;
285
+ let baseId;
286
+ if (interactive) {
287
+ const answers = await prompts([
288
+ { type: "text", name: "baseUrl", message: t("add.baseUrl"), validate: (v) => (/^https?:\/\//.test(v) ? true : t("add.baseUrlInvalid")) },
289
+ { type: "password", name: "apiKey", message: t("add.apiKey") },
290
+ ], { onCancel: () => fail(t("add.cancelled")) });
291
+ baseUrl = answers.baseUrl.replace(/\/+$/, "");
292
+ apiKey = answers.apiKey;
293
+ baseId = opts.id ?? slugFromBaseUrl(baseUrl);
294
+ }
295
+ else {
296
+ if (!opts.baseUrl)
297
+ fail(t("add.fieldRequired", { field: "base-url" }));
298
+ if (!opts.apiKey)
299
+ fail(t("add.fieldRequired", { field: "api-key" }));
300
+ baseUrl = opts.baseUrl.replace(/\/+$/, "");
301
+ apiKey = opts.apiKey;
302
+ baseId = opts.id ?? slugFromBaseUrl(baseUrl);
303
+ }
304
+ process.stderr.write(pc.dim(t("quick.probing", { url: baseUrl }) + "\n"));
305
+ const protocols = await probeProtocols({ baseUrl, apiKey });
306
+ if (protocols.length === 0) {
307
+ fail(t("quick.noProtocol"));
308
+ }
309
+ const modelFilter = parseFilterOpts(opts);
310
+ const multi = protocols.length > 1;
311
+ const createdIds = [];
312
+ for (const protocol of protocols) {
313
+ const suffix = multi ? `-${protocol}` : "";
314
+ const id = `${baseId}${suffix}`;
315
+ const name = multi ? `${baseId} (${protocol})` : baseId;
316
+ process.stderr.write(pc.dim(`${t("add.discovering", { url: baseUrl })} [${protocol}]\n`));
317
+ const discovered = await discoverProviderModels({ baseUrl, apiKey, protocol });
318
+ console.log(t("add.providerLists", { count: discovered.length }));
319
+ let modelIds = discovered;
320
+ const pinned = [...(opts.defaultModel ? [opts.defaultModel] : [])];
321
+ const outcome = applyModelFilter(modelIds, modelFilter, pinned);
322
+ reportDropped(outcome.dropped);
323
+ modelIds = outcome.kept;
324
+ if (modelIds.length === 0) {
325
+ console.log(pc.yellow(t("quick.noModelsAfterFilter", { id })));
326
+ continue;
327
+ }
328
+ const provider = await createProvider({
329
+ store,
330
+ id,
331
+ name,
332
+ protocol,
333
+ baseUrl,
334
+ apiKey,
335
+ modelIds,
336
+ defaultModel: opts.defaultModel,
337
+ smallModel: opts.smallModel,
338
+ reasoningEffort: opts.reasoningEffort,
339
+ modelFilter,
340
+ });
341
+ createdIds.push(provider.id);
342
+ console.log(pc.dim(`\n${t("add.next", { id: provider.id })}`));
343
+ }
344
+ saveStore(store);
345
+ if (createdIds.length > 0) {
346
+ console.log(pc.green(t("quick.summary", { count: createdIds.length, ids: createdIds.join(", ") })));
347
+ }
348
+ }
227
349
  export function cmdList() {
228
350
  const store = loadStore();
229
351
  const ids = Object.keys(store.providers);
@@ -399,6 +521,7 @@ export async function cmdModels(query, opts) {
399
521
  const provider = getProvider(store, opts.provider);
400
522
  console.log(`${pc.bold(provider.id)} (${provider.protocol}) · ${provider.baseUrl}`);
401
523
  console.log(table(modelRows(provider.models), MODEL_HEADER));
524
+ printUncatalogedHint(provider.models);
402
525
  return;
403
526
  }
404
527
  const catalog = await loadCatalog({ refresh: opts.refresh });
@@ -463,6 +586,7 @@ export async function cmdDiscover(id, opts) {
463
586
  (added.length ? `\n new: ${added.join(", ")}` : "") +
464
587
  (gone.length ? `\n removed upstream: ${gone.join(", ")}` : ""));
465
588
  console.log(table(modelRows(provider.models), MODEL_HEADER));
589
+ printUncatalogedHint(provider.models);
466
590
  if (opts.sync) {
467
591
  console.log("");
468
592
  await cmdSync({ provider: id, apps: opts.apps });
@@ -477,13 +601,14 @@ async function collectAppRows() {
477
601
  Promise.resolve(installedVersion(app)),
478
602
  latestVersion(app),
479
603
  ]);
604
+ const knownInstalled = !!installed && installed !== "?";
480
605
  return {
481
606
  id: app.id,
482
607
  name: app.name,
483
608
  installed,
484
609
  latest,
485
- upgradable: !!installed && installed !== "?" && !!latest && isNewer(installed, latest),
486
- installable: !installed && !!app.installCmd,
610
+ upgradable: knownInstalled && !!latest && isNewer(installed, latest),
611
+ installable: !installed && !!appCommand(app, "install"),
487
612
  };
488
613
  }));
489
614
  }
@@ -496,11 +621,13 @@ export async function cmdApps() {
496
621
  r.latest ?? pc.dim("?"),
497
622
  r.upgradable
498
623
  ? pc.yellow("upgrade available")
499
- : r.installed
500
- ? pc.green("up to date")
501
- : r.installable
624
+ : !r.installed
625
+ ? r.installable
502
626
  ? pc.dim("installable")
503
- : pc.dim("-"),
627
+ : pc.dim("-")
628
+ : r.installed === "?" || !r.latest
629
+ ? pc.dim("unknown")
630
+ : pc.green("up to date"),
504
631
  ]), ["APP", "INSTALLED", "LATEST", "STATUS"]));
505
632
  const upgradable = rows.filter((r) => r.upgradable).map((r) => r.id);
506
633
  if (upgradable.length)
@@ -510,15 +637,16 @@ export async function cmdInstall(id) {
510
637
  const app = appPackages.find((a) => a.id === id);
511
638
  if (!app)
512
639
  fail(`unknown app "${id}" (supported: ${appPackages.map((a) => a.id).join(", ")})`);
513
- if (!app.installCmd)
514
- fail(`${app.name} is not CLI-installable (desktop app manages itself)`);
640
+ const installCmd = appCommand(app, "install");
641
+ if (!installCmd)
642
+ fail(`${app.name} is not installable on ${process.platform} (or is managed by its desktop app)`);
515
643
  const installed = installedVersion(app);
516
644
  if (installed) {
517
645
  console.log(`${app.name} already installed (${installed}); use \`agentsw upgrade ${id}\``);
518
646
  return;
519
647
  }
520
- console.log(`installing ${app.name}: ${pc.dim(app.installCmd)}`);
521
- runShell(app.installCmd);
648
+ console.log(`installing ${app.name}: ${pc.dim(installCmd)}`);
649
+ runShell(installCmd);
522
650
  console.log(pc.green(`${app.name} installed: ${installedVersion(app) ?? "version unknown"}`));
523
651
  }
524
652
  export async function cmdUpgrade(ids) {
@@ -538,7 +666,7 @@ export async function cmdUpgrade(ids) {
538
666
  selected = appPackages.filter((a) => upgradable.includes(a.id));
539
667
  }
540
668
  for (const app of selected) {
541
- const cmd = app.upgradeCmd ?? app.installCmd;
669
+ const cmd = appCommand(app, "upgrade");
542
670
  if (!cmd) {
543
671
  console.log(`${pc.yellow("skip")} ${app.id}: not CLI-upgradable`);
544
672
  continue;
package/dist/discover.js CHANGED
@@ -19,3 +19,33 @@ export async function discoverProviderModels(opts) {
19
19
  throw new Error(`${url} returned no models`);
20
20
  return [...new Set(ids)].sort();
21
21
  }
22
+ /**
23
+ * Probe which wire protocols an endpoint supports by hitting /v1/models
24
+ * with each protocol's auth headers. Returns the protocols that responded
25
+ * with a valid model list (at least one model id).
26
+ */
27
+ export async function probeProtocols(opts) {
28
+ const base = opts.baseUrl.replace(/\/+$/, "");
29
+ const url = /\/v\d+$/.test(base) ? `${base}/models` : `${base}/v1/models`;
30
+ const attempts = [
31
+ { protocol: "openai", headers: { authorization: `Bearer ${opts.apiKey}` } },
32
+ { protocol: "anthropic", headers: { "x-api-key": opts.apiKey, "anthropic-version": "2023-06-01" } },
33
+ ];
34
+ const supported = [];
35
+ for (const a of attempts) {
36
+ try {
37
+ const res = await fetch(url, { headers: a.headers, signal: AbortSignal.timeout(15000) });
38
+ if (!res.ok)
39
+ continue;
40
+ const body = (await res.json());
41
+ const rows = body.data ?? body.models ?? [];
42
+ const ids = rows.map((r) => r.id).filter((id) => typeof id === "string" && id.length > 0);
43
+ if (ids.length > 0)
44
+ supported.push(a.protocol);
45
+ }
46
+ catch {
47
+ /* network error or timeout — protocol not supported */
48
+ }
49
+ }
50
+ return supported;
51
+ }
package/dist/fsutil.js CHANGED
@@ -1,7 +1,25 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- export const home = os.homedir();
4
+ // AGENTSW_HOME is an explicit portable/test override. Native Windows otherwise
5
+ // uses USERPROFILE/os.homedir(), while Unix follows HOME as expected.
6
+ export const home = process.env.AGENTSW_HOME?.trim() ||
7
+ (process.platform === "win32" ? process.env.USERPROFILE?.trim() || os.homedir() : process.env.HOME?.trim() || os.homedir());
8
+ /** Windows roaming application data; Unix keeps the existing XDG-style layout. */
9
+ export function appDataDir(app) {
10
+ // AGENTSW_HOME is also a portable-layout override for tests and Git Bash.
11
+ if (process.platform === "win32" && !process.env.AGENTSW_HOME) {
12
+ return path.join(process.env.APPDATA?.trim() || path.join(home, "AppData", "Roaming"), app);
13
+ }
14
+ return path.join(home, ".config", app);
15
+ }
16
+ /** Windows local application data, used by agents whose native config lives there. */
17
+ export function localAppDataDir(app) {
18
+ if (process.platform === "win32" && !process.env.AGENTSW_HOME) {
19
+ return path.join(process.env.LOCALAPPDATA?.trim() || path.join(home, "AppData", "Local"), app);
20
+ }
21
+ return path.join(home, `.${app}`);
22
+ }
5
23
  export function expandHome(p) {
6
24
  return p.startsWith("~") ? path.join(home, p.slice(1)) : p;
7
25
  }
@@ -43,7 +61,7 @@ export function writeFileAtomic(file, content, mode) {
43
61
  fs.writeFileSync(tmp, content, mode !== undefined ? { mode } : undefined);
44
62
  fs.renameSync(tmp, file);
45
63
  }
46
- export const backupsDir = path.join(home, ".config", "agentsw", "backups");
64
+ export const backupsDir = path.join(appDataDir("agentsw"), "backups");
47
65
  /** Copy `file` into the backups dir with a timestamp suffix. No-op if file missing. */
48
66
  export function backupFile(file) {
49
67
  if (!fs.existsSync(file) || dryRun)
package/dist/i18n.js CHANGED
@@ -7,6 +7,7 @@ const messages = {
7
7
  "error.language": { en: "unsupported language \"{value}\"; use en or zh-CN", "zh-CN": "不支持语言 \"{value}\";请使用 en 或 zh-CN" },
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
+ "cmd.quick": { en: "quick add — auto-detect protocol(s) and models from just base URL + API key", "zh-CN": "快速添加 — 只需 base URL + API key,自动探测协议和模型" },
10
11
  "cmd.list": { en: "list configured providers", "zh-CN": "列出已配置供应商" },
11
12
  "cmd.remove": { en: "remove a provider", "zh-CN": "删除供应商" },
12
13
  "cmd.prune": { en: "remove a provider's entries from app configs (keeps it in agentsw)", "zh-CN": "从应用配置移除供应商条目(仍保留在 agentsw)" },
@@ -99,7 +100,7 @@ const messages = {
99
100
  "add.baseUrl": { en: "base URL", "zh-CN": "base URL" },
100
101
  "add.baseUrlInvalid": { en: "must start with http(s)://", "zh-CN": "必须以 http(s):// 开头" },
101
102
  "add.apiKey": { en: "API key", "zh-CN": "API key" },
102
- "add.models": { en: "model ids (comma separated)", "zh-CN": "模型 id(逗号分隔)" },
103
+ "add.models": { en: "model ids (comma separated, or leave blank to auto-discover)", "zh-CN": "模型 id(逗号分隔,留空则自动发现)" },
103
104
  "add.cancelled": { en: "cancelled", "zh-CN": "已取消" },
104
105
  "import.already": { en: "already configured as {id}", "zh-CN": "已配置为 {id}" },
105
106
  "import.noneNew": { en: "nothing new to import (every discovered provider is already configured)", "zh-CN": "没有可导入的新供应商(发现项均已配置)" },
@@ -137,6 +138,7 @@ const messages = {
137
138
  "add.discovering": { en: "discovering models from {url} ...", "zh-CN": "正在从 {url} 发现模型..." },
138
139
  "add.providerLists": { en: "provider lists {count} model(s) via /v1/models", "zh-CN": "供应商通过 /v1/models 返回 {count} 个模型" },
139
140
  "add.atLeastOne": { en: "at least one model id is required (or use --discover)", "zh-CN": "至少需要一个模型 id(或使用 --discover)" },
141
+ "add.autoDiscover": { en: "no models entered — discovering from /v1/models automatically", "zh-CN": "未输入模型 — 自动从 /v1/models 发现" },
140
142
  "add.defaultMissing": { en: "default model {model} is not in the model list", "zh-CN": "默认模型 {model} 不在模型列表中" },
141
143
  "add.smallMissing": { en: "small model {model} is not in the model list", "zh-CN": "小型模型 {model} 不在模型列表中" },
142
144
  "add.added": { en: "added", "zh-CN": "已添加" },
@@ -145,6 +147,11 @@ const messages = {
145
147
  "add.metadata": { en: "models.dev metadata: {matched}/{total} models matched", "zh-CN": "models.dev 元数据:匹配 {matched}/{total} 个模型" },
146
148
  "add.providerHint": { en: " (provider hint: {hint})", "zh-CN": " (供应商提示:{hint})" },
147
149
  "add.next": { en: "next: agentsw use {id}", "zh-CN": "下一步: agentsw use {id}" },
150
+ "quick.probing": { en: "probing protocols at {url} ...", "zh-CN": "正在探测 {url} 支持的协议..." },
151
+ "quick.noProtocol": { en: "no supported protocol found at this endpoint (tried openai and anthropic)", "zh-CN": "该端点未检测到支持的协议(已尝试 openai 和 anthropic)" },
152
+ "quick.noModelsAfterFilter": { en: "{id}: no models left after filtering, skipping", "zh-CN": "{id}: 过滤后无剩余模型,跳过" },
153
+ "quick.summary": { en: "created {count} provider(s): {ids}", "zh-CN": "创建了 {count} 个供应商: {ids}" },
154
+ "menu.quickAdd": { en: "quick add (auto-detect protocol + models)", "zh-CN": "快速添加(自动探测协议 + 模型)" },
148
155
  "import.skip": { en: "skip", "zh-CN": "跳过" },
149
156
  "import.modelsCount": { en: "{count} models", "zh-CN": "{count} 个模型" },
150
157
  "table.protocol": { en: "PROTOCOL", "zh-CN": "协议" },
package/dist/import.js CHANGED
@@ -4,7 +4,16 @@ import { targets } from "./targets/index.js";
4
4
  export function normalizeUrl(u) {
5
5
  return u.replace(/\/+$/, "");
6
6
  }
7
- const matchKey = (protocol, baseUrl) => `${protocol}|${normalizeUrl(baseUrl)}`;
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, "")}`;
8
17
  /** pure merge: dedupe candidates by protocol+baseUrl, union models, keep first resolved key */
9
18
  export function mergeCandidates(rows, existing) {
10
19
  const merged = new Map();
@@ -29,6 +38,10 @@ export function mergeCandidates(rows, existing) {
29
38
  cur.models.push(m);
30
39
  if (!cur.defaultModel && c.defaultModel)
31
40
  cur.defaultModel = c.defaultModel;
41
+ // keep the variant that names the API version: model discovery and most adapters
42
+ // need the full endpoint, and only Codex fills the segment in itself
43
+ if (!hasApiVersion(cur.baseUrl) && hasApiVersion(normalizeUrl(c.baseUrl)))
44
+ cur.baseUrl = normalizeUrl(c.baseUrl);
32
45
  // responses wins: an endpoint one app drives over /v1/responses serves it for all of them,
33
46
  // and codex speaks nothing else
34
47
  if (c.openaiApi === "responses" || !cur.openaiApi)
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
3
  import { Command, InvalidArgumentError } from "commander";
4
- import { cmdAdd, 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, cmdRemove, 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";
@@ -54,6 +54,20 @@ program
54
54
  .option("--models-dev <id>", t("opt.modelsDev"))
55
55
  .option("-y, --yes", t("opt.yes"))
56
56
  .action(cmdAdd);
57
+ program
58
+ .command("quick")
59
+ .description(t("cmd.quick"))
60
+ .option("--id <slug>", t("opt.id"))
61
+ .option("--base-url <url>", t("opt.baseUrl"))
62
+ .option("--api-key <key>", t("opt.apiKey"))
63
+ .option("--default-model <id>", t("opt.defaultModel"))
64
+ .option("--small-model <id>", t("opt.smallModel"))
65
+ .option("--reasoning-effort <level>", t("opt.reasoning"))
66
+ .option("--include <globs>", t("opt.include"))
67
+ .option("--exclude <globs>", t("opt.exclude"))
68
+ .option("--no-dedup", t("opt.noDedup"))
69
+ .option("-y, --yes", t("opt.yes"))
70
+ .action(cmdQuickAdd);
57
71
  program.command("list").alias("ls").description(t("cmd.list")).action(cmdList);
58
72
  program
59
73
  .command("remove <id>")
package/dist/menu.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import pc from "picocolors";
2
2
  import prompts from "prompts";
3
- import { cmdAdd, cmdApps, cmdDiscover, cmdImport, cmdList, cmdRemove, cmdStatus, cmdSync, cmdUpgrade, cmdUse, } from "./commands.js";
3
+ import { cmdAdd, cmdQuickAdd, cmdApps, cmdDiscover, cmdImport, cmdList, cmdRemove, cmdStatus, cmdSync, cmdUpgrade, cmdUse, } from "./commands.js";
4
4
  import { getLocale, setLocale, t } from "./i18n.js";
5
5
  import { loadStore, saveStore } from "./store.js";
6
6
  function bye() {
@@ -74,6 +74,7 @@ export async function cmdMenu() {
74
74
  hint: t("menu.selectInstructions"),
75
75
  choices: [
76
76
  { title: t("menu.add"), value: "add" },
77
+ { title: t("menu.quickAdd"), value: "quickAdd" },
77
78
  { title: t("menu.import"), value: "import" },
78
79
  { title: t("menu.use"), value: "use" },
79
80
  { title: t("menu.status"), value: "status" },
@@ -88,7 +89,10 @@ export async function cmdMenu() {
88
89
  }, cancel);
89
90
  if (action === undefined || action === "quit")
90
91
  return;
91
- if (action === "add") {
92
+ if (action === "quickAdd") {
93
+ await cmdQuickAdd({});
94
+ }
95
+ else if (action === "add") {
92
96
  const { src } = await prompts({
93
97
  type: "select",
94
98
  name: "src",
@@ -2,11 +2,13 @@ import fs from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import path from "node:path";
4
4
  import { parse as parseToml } from "smol-toml";
5
- import { home } from "../fsutil.js";
5
+ import { appDataDir, home } from "../fsutil.js";
6
6
  import { slugFromBaseUrl } from "../slug.js";
7
7
  import { classifyApi } from "../targets/wire.js";
8
8
  /** cc-switch (the Tauri desktop switcher) keeps every managed app's providers in one SQLite file. */
9
- const storeFile = path.join(home, ".cc-switch", "cc-switch.db");
9
+ const storeFile = process.platform === "win32" && !process.env.AGENTSW_HOME
10
+ ? path.join(appDataDir("cc-switch"), "cc-switch.db")
11
+ : path.join(home, ".cc-switch", "cc-switch.db");
10
12
  /** Source id shown in the `import` preview. */
11
13
  export const CC_SWITCH_SOURCE = "cc-switch";
12
14
  /** Claude Code / Claude Desktop rows: the env block, same shape agentsw's claude adapter writes. */
package/dist/store.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
- import { home, readJsonIfExists, writeFileAtomic } from "./fsutil.js";
3
- export const configDir = path.join(home, ".config", "agentsw");
2
+ import { appDataDir, readJsonIfExists, writeFileAtomic } from "./fsutil.js";
3
+ export const configDir = appDataDir("agentsw");
4
4
  export const configFile = path.join(configDir, "config.json");
5
5
  export function loadStore() {
6
6
  const store = readJsonIfExists(configFile);
@@ -1,12 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import YAML from "yaml";
4
- import { backupFile, expandHome, home, readTextIfExists, writeFileAtomic } from "../fsutil.js";
4
+ import { backupFile, expandHome, home, localAppDataDir, readTextIfExists, writeFileAtomic } from "../fsutil.js";
5
5
  import { apiValue, classifyApi, mergeModels } from "./wire.js";
6
6
  /** `$DSH_HOME`, or `~/.dsh` (packages/util/home-paths `resolveDshHome`). */
7
7
  function dshHome() {
8
8
  const env = process.env.DSH_HOME?.trim();
9
- return env ? expandHome(env) : path.join(home, ".dsh");
9
+ return env ? expandHome(env) : process.platform === "win32" ? localAppDataDir("dsh") : path.join(home, ".dsh");
10
10
  }
11
11
  /**
12
12
  * Settings document: `<harness home>/settings.yaml` by default; the extension
@@ -1,15 +1,15 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import YAML from "yaml";
4
- import { backupFile, readTextIfExists, writeFileAtomic } from "../fsutil.js";
4
+ import { backupFile, home, localAppDataDir, readTextIfExists, writeFileAtomic } from "../fsutil.js";
5
5
  function hermesHome() {
6
6
  const env = process.env.HERMES_HOME?.trim();
7
7
  if (env)
8
8
  return env;
9
- if (process.platform === "win32" && process.env.LOCALAPPDATA) {
10
- return path.join(process.env.LOCALAPPDATA, "hermes");
9
+ if (process.platform === "win32") {
10
+ return localAppDataDir("hermes");
11
11
  }
12
- return path.join(process.env.HOME ?? "", ".hermes");
12
+ return path.join(home, ".hermes");
13
13
  }
14
14
  /**
15
15
  * hermes (NousResearch): providers dict + model section in ~/.hermes/config.yaml,
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { backupFile, home, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
4
- const configFile = path.join(home, ".config", "opencode", "opencode.json");
3
+ import { appDataDir, backupFile, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
4
+ const configDir = process.env.OPENCODE_CONFIG_DIR?.trim() || appDataDir("opencode");
5
+ const configFile = path.join(configDir, "opencode.json");
5
6
  /**
6
7
  * opencode: custom providers in ~/.config/opencode/opencode.json "provider" map,
7
8
  * backed by @ai-sdk/openai-compatible or @ai-sdk/anthropic npm loaders.
@@ -1,9 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { backupFile, home, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
3
+ import { appDataDir, backupFile, home, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
4
4
  import { slugFromBaseUrl } from "../slug.js";
5
5
  function configDir() {
6
- return process.env.WORKBUDDY_CONFIG_DIR ?? process.env.CODEBUDDY_CONFIG_DIR ?? path.join(home, ".workbuddy");
6
+ return process.env.WORKBUDDY_CONFIG_DIR?.trim() ?? process.env.CODEBUDDY_CONFIG_DIR?.trim() ??
7
+ (process.platform === "win32" ? appDataDir("workbuddy") : path.join(home, ".workbuddy"));
7
8
  }
8
9
  /**
9
10
  * models.json accepts a raw array (desktop writes) or {models, availableModels} (CLI writes).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentsw",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "CLI provider switcher for coding agents: manage OpenAI/Anthropic-protocol providers in one place and sync them, with models.dev metadata, into Claude Code, Codex, omp, pi, prime-agent, opencode, Hermes, WorkBuddy and DeepSeek Harness",
5
5
  "type": "module",
6
6
  "repository": {