agentsw 0.8.0 → 0.9.1

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
@@ -18,14 +18,14 @@ without touching the settings you hand-tuned.
18
18
  </div>
19
19
 
20
20
  ```console
21
- $ asw use vfing -m glm-5.3-flash
22
- switching to vfing (openai) · default model glm-5.3-flash
21
+ $ asw use myproxy -m glm-5.3-flash
22
+ switching to myproxy (openai) · default model glm-5.3-flash
23
23
 
24
24
  skip claude Claude Code does not support openai-protocol providers
25
25
  ok codex ~/.codex/config.toml, ~/.codex/auth.json
26
26
  backup: ~/.config/agentsw/backups/transaction-<unique>
27
27
  ok omp ~/.omp/agent/models.yml
28
- select in omp with: omp --model vfing/glm-5.3-flash
28
+ select in omp with: omp --model myproxy/glm-5.3-flash
29
29
  skip pi pi not detected (pass --apps pi to force)
30
30
  ok opencode ~/.config/opencode/opencode.json
31
31
  ok dsh ~/.dsh/settings.yaml, ~/.dsh/.credentials.yaml
@@ -73,35 +73,35 @@ when a portable home directory is desired.
73
73
  ## Quick start
74
74
 
75
75
  ```bash
76
- agentsw # no args: interactive menu (add, import, use, status, apps)
76
+ npx agentsw # no install needed — opens the interactive menu
77
+ agentsw # once installed globally; the short alias `asw` is identical
77
78
  ```
78
79
 
79
- The first run on an empty store offers to import the providers already configured in your
80
- agents, and asks for English or 简体中文. Prefer flags? The same flow, unattended:
80
+ **Run it with no arguments and you get a menu — that is the whole tool.** Every action lives
81
+ there: add a provider (auto-detect or manual setup), import the providers already configured in
82
+ your agents, switch provider and default model, re-sync, update a model list, configure model
83
+ metadata, rename or delete, install/upgrade an agent, change language. On the first run it asks
84
+ for English or 简体中文 and offers to adopt what your agents are already using.
81
85
 
82
- ```bash
83
- # add a provider and let it discover its own model list
84
- asw add -y --id vfing --protocol openai --openai-api responses \
85
- --base-url https://api.example.com/v1 --api-key sk-... --discover
86
+ Each menu action has a command equivalent for scripts, CI and headless machines — see
87
+ [Commands](#commands). Examples below are written as `asw`; without a global install, run them
88
+ as `npx agentsw <command>`.
86
89
 
87
- asw use vfing # point every detected agent at it
88
- asw use vfing --dry-run # ...or just show the diffs
89
- asw status # what each agent points at right now
90
- ```
90
+ One command that needs no menu — `asw status` prints what each agent currently points at:
91
91
 
92
92
  ```console
93
93
  $ asw status
94
94
  config: ~/.config/agentsw/config.json
95
- active provider: vfing
96
-
97
- APP FOUND PROTOCOLS CURRENT CONFIG
98
- --------- ----- ---------------- ------------------------------------ ---------------------------
99
- claude yes anthropic - ~/.claude/settings.json
100
- codex yes openai vfing · glm-5.3-flash ~/.codex/config.toml
101
- omp yes openai+anthropic providers: sub, sub-anthropic, vfing ~/.omp/agent/models.yml
102
- pi no openai+anthropic - ~/.pi/agent/models.json
103
- opencode yes openai+anthropic vfing/glm-5.3-flash ~/.config/opencode/opencode.json
104
- dsh yes openai+anthropic vfing · glm-5.3-flash ~/.dsh/settings.yaml
95
+ active provider: myproxy
96
+
97
+ APP FOUND PROTOCOLS CURRENT CONFIG
98
+ -------- ----- ---------------- -------------------------------------- --------------------------------
99
+ claude yes anthropic - ~/.claude/settings.json
100
+ codex yes openai myproxy · glm-5.3-flash ~/.codex/config.toml
101
+ omp yes openai+anthropic providers: sub, sub-anthropic, myproxy ~/.omp/agent/models.yml
102
+ pi no openai+anthropic - ~/.pi/agent/models.json
103
+ opencode yes openai+anthropic myproxy/glm-5.3-flash ~/.config/opencode/opencode.json
104
+ dsh yes openai+anthropic myproxy · glm-5.3-flash ~/.dsh/settings.yaml
105
105
  ```
106
106
 
107
107
  ## Supported apps
@@ -121,6 +121,12 @@ dsh yes openai+anthropic vfing · glm-5.3-flash ~/.ds
121
121
  An agent that is not installed is skipped, not guessed at. `--apps codex,omp` narrows a run;
122
122
  `--apps` with an uninstalled agent forces it.
123
123
 
124
+ The interactive menu asks the same question before writing: a multi-select where the first row
125
+ is **all detected apps**, detected agents are listed first and undetected ones are marked. The
126
+ last selection is remembered, so a menu `use`/`sync` keeps rewriting only the agents you picked.
127
+ An error in any config file (invalid JSON/YAML/TOML) is reported with the file and position, never
128
+ with the offending line, and dry-run keeps that detail instead of hiding it.
129
+
124
130
  ## Import what you already configured
125
131
 
126
132
  `import` reads every agent's config **and cc-switch's SQLite store** (read-only), resolves
@@ -129,13 +135,14 @@ lists and source apps are unioned. Different protocols on the same host stay sep
129
135
 
130
136
  ```console
131
137
  $ asw import --all
138
+
132
139
  ID PROTOCOL BASE URL MODELS FROM KEY
133
140
  ------------- --------- -------------------------------------- ------ --------- ---
134
- any anthropic https://a-ocnfniawgw.cn-shanghai... 1 cc-switch yes
135
- sub openai https://new.vfing.de/v1 18 omp yes
136
- sub-anthropic anthropic https://new.vfing.de 18 omp yes
141
+ any anthropic https://a-long-reseller.example.com... 1 cc-switch yes
142
+ sub openai https://api.example.com/v1 18 omp yes
143
+ sub-anthropic anthropic https://api.example.com 18 omp yes
137
144
  zhipu-glm anthropic https://open.bigmodel.cn/api/anthropic 1 cc-switch yes
138
- imported sub · openai · https://new.vfing.de/v1 · 18 models [from omp]
145
+ imported sub · openai · https://api.example.com/v1 · 18 models [from omp]
139
146
  imported zhipu-glm · anthropic · https://open.bigmodel.cn/api/anthropic · 1 models [from cc-switch]
140
147
  ```
141
148
 
@@ -245,7 +252,7 @@ Import still skips already configured accounts.
245
252
  | `use <id>` | switch every detected agent; `-a codex,omp`, `-m <model>`, `--dry-run` |
246
253
  | `sync` | re-apply the active provider (after an agent update, say) |
247
254
  | `discover <id> [--sync]` | refresh the model list + metadata from `/v1/models` |
248
- | `models [query]` | search the models.dev catalog |
255
+ | `models [query]` | search the models.dev catalog (`-r` refresh the cache, `-l <n>` limit rows) |
249
256
  | `refresh [--provider <id>]` | refresh saved metadata and optionally configure Gateway; keep the model list |
250
257
  | `models --provider <id> --metadata` | inspect field sources, conflicts and reference pricing as JSON |
251
258
  | `prune <id>` / `remove <id> [--prune]` | remove from app configs / from the store |
@@ -253,7 +260,11 @@ Import still skips already configured accounts.
253
260
  | `apps` / `install <app>` / `upgrade` | agent version manager |
254
261
 
255
262
  ```bash
256
- asw use myproxy -a codex,omp -m glm-5.2
263
+ # unattended add: discover the provider's model list, then write it everywhere
264
+ asw add -y --id myproxy --protocol openai --openai-api responses \
265
+ --base-url https://api.example.com/v1 --api-key sk-... --discover
266
+ asw use myproxy # every detected agent
267
+ asw use myproxy -a codex,omp -m glm-5.2 # ...or only these, with another default model
257
268
  asw discover myproxy --sync
258
269
  asw remove myproxy --prune
259
270
  ```
@@ -262,6 +273,13 @@ New automatic IDs include the full hostname and protocol, e.g. `api-example-com-
262
273
  An explicit `--id` is retained, and syncing never renames an existing provider. Import
263
274
  deduplication compares endpoint, protocol, and credentials; different accounts remain separate.
264
275
 
276
+ A bare IP works the same way — every dot and colon becomes a dash, and the **port is not part
277
+ of the ID**, so `http://152.53.108.231:3456` becomes `152-53-108-231-openai`. Two ports on that
278
+ host therefore share a base ID and the second provider gets `-2` appended
279
+ (`152-53-108-231-openai-2`); pass `--id` to name them yourself. IPv6 hosts lose their brackets
280
+ and dashes (`https://[2001:db8::1]:8080` → `2001-db8-1-openai`), an IDN host uses its punycode
281
+ form, and a base URL that cannot be parsed falls back to `imported`.
282
+
265
283
  ```bash
266
284
  asw rename myproxy api-example-com-openai --dry-run
267
285
  asw rename myproxy api-example-com-openai
package/README.zh-CN.md CHANGED
@@ -18,14 +18,14 @@
18
18
  </div>
19
19
 
20
20
  ```console
21
- $ asw use vfing -m glm-5.3-flash
22
- switching to vfing (openai) · default model glm-5.3-flash
21
+ $ asw use myproxy -m glm-5.3-flash
22
+ switching to myproxy (openai) · default model glm-5.3-flash
23
23
 
24
24
  skip claude Claude Code does not support openai-protocol providers
25
25
  ok codex ~/.codex/config.toml, ~/.codex/auth.json
26
26
  backup: ~/.config/agentsw/backups/transaction-<unique>
27
27
  ok omp ~/.omp/agent/models.yml
28
- select in omp with: omp --model vfing/glm-5.3-flash
28
+ select in omp with: omp --model myproxy/glm-5.3-flash
29
29
  skip pi pi not detected (pass --apps pi to force)
30
30
  ok opencode ~/.config/opencode/opencode.json
31
31
  ok dsh ~/.dsh/settings.yaml, ~/.dsh/.credentials.yaml
@@ -71,35 +71,32 @@ npm install -g agentsw # 需要 Node >= 22.13,内置 SQLite 无需额外启
71
71
  ## 快速开始
72
72
 
73
73
  ```bash
74
- agentsw # 不带参数:交互菜单(添加、导入、切换、状态、应用管理)
74
+ npx agentsw # 免安装,直接打开交互菜单
75
+ agentsw # 全局安装后可用;短名 `asw` 完全等价
75
76
  ```
76
77
 
77
- 存储为空时,首次运行会询问是否导入各智能体里已配置的供应商,并让你选择
78
- English 或简体中文。想用参数跑?同样的流程,全自动:
78
+ **不带参数运行就是一个菜单——整个工具都在里面。** 添加供应商(自动识别或手动设置)、导入各智能体
79
+ 已有的供应商、切换供应商和默认模型、重新同步、更新模型列表、设置模型参数补充源、重命名或删除、
80
+ 安装/升级智能体、切换语言。首次运行会先问你要 English 还是简体中文,并提议接管你各智能体已经在用的配置。
79
81
 
80
- ```bash
81
- # 添加供应商,并让它自己发现模型列表
82
- asw add -y --id vfing --protocol openai --openai-api responses \
83
- --base-url https://api.example.com/v1 --api-key sk-... --discover
82
+ 菜单里的每项操作都有对应的命令,方便脚本、CI 和无头机器——见 [命令](#命令)。
83
+ 下文示例统一写作 `asw`;没有全局安装时,请改成 `npx agentsw <命令>`。
84
84
 
85
- asw use vfing # 把所有检测到的智能体指向它
86
- asw use vfing --dry-run # ……或者只看 diff
87
- asw status # 各智能体当前指向什么
88
- ```
85
+ 不用菜单也能看一眼——`asw status` 打印各智能体当前指向哪个供应商:
89
86
 
90
87
  ```console
91
88
  $ asw status
92
89
  config: ~/.config/agentsw/config.json
93
- active provider: vfing
94
-
95
- APP FOUND PROTOCOLS CURRENT CONFIG
96
- --------- ----- ---------------- ------------------------------------ ---------------------------
97
- claude yes anthropic - ~/.claude/settings.json
98
- codex yes openai vfing · glm-5.3-flash ~/.codex/config.toml
99
- omp yes openai+anthropic providers: sub, sub-anthropic, vfing ~/.omp/agent/models.yml
100
- pi no openai+anthropic - ~/.pi/agent/models.json
101
- opencode yes openai+anthropic vfing/glm-5.3-flash ~/.config/opencode/opencode.json
102
- dsh yes openai+anthropic vfing · glm-5.3-flash ~/.dsh/settings.yaml
90
+ active provider: myproxy
91
+
92
+ APP FOUND PROTOCOLS CURRENT CONFIG
93
+ -------- ----- ---------------- -------------------------------------- --------------------------------
94
+ claude yes anthropic - ~/.claude/settings.json
95
+ codex yes openai myproxy · glm-5.3-flash ~/.codex/config.toml
96
+ omp yes openai+anthropic providers: sub, sub-anthropic, myproxy ~/.omp/agent/models.yml
97
+ pi no openai+anthropic - ~/.pi/agent/models.json
98
+ opencode yes openai+anthropic myproxy/glm-5.3-flash ~/.config/opencode/opencode.json
99
+ dsh yes openai+anthropic myproxy · glm-5.3-flash ~/.dsh/settings.yaml
103
100
  ```
104
101
 
105
102
  ## 支持的应用
@@ -119,6 +116,11 @@ dsh yes openai+anthropic vfing · glm-5.3-flash ~/.ds
119
116
  未安装的应用会被跳过,而不是瞎猜。`--apps codex,omp` 只跑指定应用;
120
117
  对未检测到的应用显式传 `--apps` 可强制写入。
121
118
 
119
+ 交互菜单写配置前会问同一个问题:多选框第一行是**全部检测到的应用**,已检测到的排在前面,
120
+ 未检测到的会标注出来。上次的选择会被记住,所以菜单里的 `use`/`sync` 只会继续写你勾选的那几个。
121
+ 任何配置文件解析失败(JSON/YAML/TOML 非法)都会报出文件与位置,绝不回显出错那一行;
122
+ `--dry-run` 同样保留这些细节,而不是含糊带过。
123
+
122
124
  ## 导入你已有的配置
123
125
 
124
126
  `import` 读取每个智能体的配置**以及 cc-switch 的 SQLite 存储**(只读),解析内联或环境变量
@@ -127,13 +129,14 @@ dsh yes openai+anthropic vfing · glm-5.3-flash ~/.ds
127
129
 
128
130
  ```console
129
131
  $ asw import --all
132
+
130
133
  ID PROTOCOL BASE URL MODELS FROM KEY
131
134
  ------------- --------- -------------------------------------- ------ --------- ---
132
- any anthropic https://a-ocnfniawgw.cn-shanghai... 1 cc-switch yes
133
- sub openai https://new.vfing.de/v1 18 omp yes
134
- sub-anthropic anthropic https://new.vfing.de 18 omp yes
135
+ any anthropic https://a-long-reseller.example.com... 1 cc-switch yes
136
+ sub openai https://api.example.com/v1 18 omp yes
137
+ sub-anthropic anthropic https://api.example.com 18 omp yes
135
138
  zhipu-glm anthropic https://open.bigmodel.cn/api/anthropic 1 cc-switch yes
136
- imported sub · openai · https://new.vfing.de/v1 · 18 models [from omp]
139
+ imported sub · openai · https://api.example.com/v1 · 18 models [from omp]
137
140
  imported zhipu-glm · anthropic · https://open.bigmodel.cn/api/anthropic · 1 models [from cc-switch]
138
141
  ```
139
142
 
@@ -234,7 +237,7 @@ asw sync --provider myproxy
234
237
  | `use <id>` | 切换所有检测到的智能体;`-a codex,omp`、`-m <model>`、`--dry-run` |
235
238
  | `sync` | 重新应用当前供应商(比如某个智能体升级之后) |
236
239
  | `discover <id> [--sync]` | 从 `/v1/models` 刷新模型列表与元数据 |
237
- | `models [query]` | 搜索 models.dev 目录 |
240
+ | `models [query]` | 搜索 models.dev 目录(`-r` 强制刷新缓存,`-l <n>` 限制条数) |
238
241
  | `refresh [--provider <id>]` | 刷新已有模型参数,可设置 Gateway 补充源,不改变模型列表 |
239
242
  | `models --provider <id> --metadata` | 以 JSON 查看字段来源、冲突和参考价格 |
240
243
  | `prune <id>` / `remove <id> [--prune]` | 从各应用配置中清除 / 从存储中删除 |
@@ -242,7 +245,11 @@ asw sync --provider myproxy
242
245
  | `apps` / `install <app>` / `upgrade` | 智能体版本管理 |
243
246
 
244
247
  ```bash
245
- asw use myproxy -a codex,omp -m glm-5.2
248
+ # 无人值守:添加供应商并自动发现模型列表,然后写入所有智能体
249
+ asw add -y --id myproxy --protocol openai --openai-api responses \
250
+ --base-url https://api.example.com/v1 --api-key sk-... --discover
251
+ asw use myproxy # 所有检测到的智能体
252
+ asw use myproxy -a codex,omp -m glm-5.2 # ……或只写这几个,并换一个默认模型
246
253
  asw discover myproxy --sync
247
254
  asw remove myproxy --prune
248
255
  ```
@@ -251,6 +258,13 @@ asw remove myproxy --prune
251
258
  显式指定的 `--id` 会保留,同步不会自动重命名。导入去重会同时比较端点、协议和凭据,
252
259
  不同账号保持独立;同一账号的自定义名称优先于自动生成名称。
253
260
 
261
+ 纯 IP 的填写方式完全一样——域名里的每个点和冒号都变成短横线,**端口不属于 ID**:
262
+ `http://152.53.108.231:3456` 生成的 ID 是 `152-53-108-231-openai`。因此同一 IP 上的两个端口
263
+ 共用一个基础 ID,第二个供应商会自动带上 `-2`(`152-53-108-231-openai-2`);
264
+ 想区分请自己传 `--id`。IPv6 会去掉方括号并把冒号变短横线
265
+ (`https://[2001:db8::1]:8080` → `2001-db8-1-openai`),国际化域名使用 punycode,
266
+ 无法解析的 base URL 退回 `imported`。
267
+
254
268
  ```bash
255
269
  asw rename myproxy api-example-com-openai --dry-run
256
270
  asw rename myproxy api-example-com-openai
package/dist/commands.js CHANGED
@@ -13,7 +13,7 @@ import { loadGatewayCatalog } from "./gateway.js";
13
13
  import { resolveTargets, supportsProtocol, targets } from "./targets/index.js";
14
14
  import { discoverProviderModels, probeProtocols } from "./discover.js";
15
15
  import { appCommand, appPackages, installedVersion, isNewer, latestVersion, normalizeAppVersion, runShell } from "./apps.js";
16
- import { drainPendingWrites, readTextIfExists, setDryRun } from "./fsutil.js";
16
+ import { SafeConfigError, drainPendingWrites, readTextIfExists, setDryRun } from "./fsutil.js";
17
17
  import { applyModelFilter } from "./filter.js";
18
18
  import { availableProviderId, providerIdFromBaseUrl, providerNameFromBaseUrl } from "./slug.js";
19
19
  import { t } from "./i18n.js";
@@ -443,7 +443,8 @@ async function runTargets(op, provider, appsFilter, redactErrors = false) {
443
443
  results.push(await target[op](provider));
444
444
  }
445
445
  catch (err) {
446
- results.push({ app: target.id, changed: [], notes: [], skipped: pc.red(redactErrors ? "failed: configuration could not be previewed safely" : `failed: ${err.message}`) });
446
+ const safe = redactErrors && !(err instanceof SafeConfigError);
447
+ results.push({ app: target.id, changed: [], notes: [], skipped: pc.red(`failed: ${safe ? "configuration could not be previewed safely" : err.message}`) });
447
448
  process.exitCode = 1;
448
449
  }
449
450
  }
package/dist/fsutil.js CHANGED
@@ -3,6 +3,13 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { AsyncLocalStorage } from "node:async_hooks";
5
5
  import { randomUUID } from "node:crypto";
6
+ /**
7
+ * An error whose message is guaranteed to carry no file content. Dry-run redacts
8
+ * unknown errors wholesale (parser messages can embed credentials), so config
9
+ * parsers must throw this to stay actionable there.
10
+ */
11
+ export class SafeConfigError extends Error {
12
+ }
6
13
  // AGENTSW_HOME is an explicit portable/test override. Native Windows otherwise
7
14
  // uses USERPROFILE/os.homedir(), while Unix follows HOME as expected.
8
15
  export const home = process.env.AGENTSW_HOME?.trim() ||
@@ -88,7 +95,13 @@ export function readJsonIfExists(file) {
88
95
  const text = readTextIfExists(file);
89
96
  if (text === undefined)
90
97
  return undefined;
91
- return JSON.parse(text);
98
+ try {
99
+ return JSON.parse(text);
100
+ }
101
+ catch {
102
+ // Node quotes surrounding input in the message, which may hold credentials.
103
+ throw new SafeConfigError(`${file}: invalid JSON configuration`);
104
+ }
92
105
  }
93
106
  /** Compatibility preview switch. Each target captures its value when its scoped work starts. */
94
107
  let dryRun = false;
package/dist/i18n.js CHANGED
@@ -123,6 +123,10 @@ const messages = {
123
123
  "menu.defaultModel": { en: "default model", "zh-CN": "默认模型" },
124
124
  "menu.keepDefault": { en: "keep current default ({model})", "zh-CN": "保持当前默认模型({model})" },
125
125
  "menu.active": { en: "current in agentsw", "zh-CN": "agentsw 当前" },
126
+ "menu.pickApps": { en: "which agent configs should this write to?", "zh-CN": "写入哪些智能体配置?" },
127
+ "menu.appsAll": { en: "all {count} detected apps", "zh-CN": "全部 {count} 个检测到的应用" },
128
+ "menu.appNotDetected": { en: "(not detected)", "zh-CN": "(未检测到)" },
129
+ "menu.appsHint": { en: "↑/↓ move · Space toggle · Enter confirm", "zh-CN": "↑/↓ 移动 · 空格切换 · Enter 确认" },
126
130
  "menu.discoverFor": { en: "which provider's model list should be updated?", "zh-CN": "选择要更新模型列表的供应商" },
127
131
  "menu.pushRefresh": { en: "also sync this provider to agent configs after updating its model list?", "zh-CN": "更新模型列表后,是否同时将该供应商同步到智能体配置?" },
128
132
  "menu.removeProvider": { en: "which provider configuration should be deleted?", "zh-CN": "选择要删除配置的供应商" },
package/dist/jsonc.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readTextIfExists } from "./fsutil.js";
1
+ import { SafeConfigError, readTextIfExists } from "./fsutil.js";
2
2
  import { applyEdits, createScanner, getNodeValue, modify, parseTree, printParseErrorCode, } from "jsonc-parser";
3
3
  export function isJsonObject(value) {
4
4
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -9,11 +9,10 @@ function parseJsoncObject(file, text) {
9
9
  const root = parseTree(source, errors, { allowTrailingComma: true });
10
10
  const error = errors[0];
11
11
  if (error) {
12
- // Do not quote source text: config files can contain credentials.
13
- throw new Error(`${file}: invalid JSONC (${printParseErrorCode(error.error)} at offset ${error.offset})`);
12
+ throw new SafeConfigError(`${file}: invalid JSONC (${printParseErrorCode(error.error)} at offset ${error.offset})`);
14
13
  }
15
14
  if (root?.type !== "object")
16
- throw new Error(`${file}: expected a JSON object at the document root`);
15
+ throw new SafeConfigError(`${file}: expected a JSON object at the document root`);
17
16
  // Parsing resolves duplicate keys last-wins, but modify edits the first occurrence.
18
17
  // Reject that ambiguity at any depth rather than syncing a value the agent will not read.
19
18
  const checkDuplicates = (node) => {
@@ -22,7 +21,7 @@ function parseJsoncObject(file, text) {
22
21
  if (names) {
23
22
  const name = child.children[0].value;
24
23
  if (names.has(name)) {
25
- throw new Error(`${file}: invalid JSONC (duplicate object property at offset ${child.offset})`);
24
+ throw new SafeConfigError(`${file}: invalid JSONC (duplicate object property at offset ${child.offset})`);
26
25
  }
27
26
  names.add(name);
28
27
  }
package/dist/menu.js CHANGED
@@ -62,6 +62,42 @@ async function pickProvider(message) {
62
62
  const p = store.providers[id];
63
63
  return { id, defaultModel: p.defaultModel, models: p.models.map((m) => m.id) };
64
64
  }
65
+ /**
66
+ * Multi-select target apps, remembering the previous choice. The first row selects
67
+ * every detected app; picking it (or toggling everything off) returns undefined,
68
+ * which keeps the CLI default of "all detected apps". A narrower selection becomes
69
+ * an explicit --apps filter, which force-writes even undetected apps — so only
70
+ * offer real choices.
71
+ */
72
+ async function pickApps(message) {
73
+ const remembered = (loadStore().syncTargets ?? []).filter((id) => targets.some((target) => target.id === id));
74
+ const detected = targets.filter((target) => target.detect());
75
+ const { apps } = await prompts({
76
+ type: "multiselect",
77
+ name: "apps",
78
+ message,
79
+ hint: t("menu.appsHint"),
80
+ instructions: false,
81
+ choices: [
82
+ { title: t("menu.appsAll", { count: detected.length }), value: "all", selected: remembered.length === 0 },
83
+ // Detected apps first, and say so: choosing an undetected one force-creates its config.
84
+ ...[...targets].sort((a, b) => Number(b.detect()) - Number(a.detect())).map((target) => ({
85
+ title: `${target.id} · ${target.name}${target.detect() ? "" : ` ${pc.dim(t("menu.appNotDetected"))}`}`,
86
+ value: target.id,
87
+ selected: remembered.includes(target.id),
88
+ })),
89
+ ],
90
+ }, cancel);
91
+ const picked = (Array.isArray(apps) ? apps : []).filter((value) => value !== "all");
92
+ const all = picked.length === 0 || apps.includes("all") || picked.length === targets.length;
93
+ const store = loadStore();
94
+ if (all)
95
+ delete store.syncTargets;
96
+ else
97
+ store.syncTargets = picked;
98
+ saveStore(store);
99
+ return all ? undefined : picked.join(",");
100
+ }
65
101
  async function renameFromMenu() {
66
102
  const picked = await pickProvider(t("menu.renameProvider"));
67
103
  if (!picked)
@@ -201,6 +237,7 @@ export async function cmdMenu() {
201
237
  const picked = await pickProvider(t("menu.pickProvider"));
202
238
  if (!picked)
203
239
  continue;
240
+ const apps = await pickApps(t("menu.pickApps"));
204
241
  const { model } = await prompts({
205
242
  type: picked.models.length > 1 ? "select" : null,
206
243
  name: "model",
@@ -211,7 +248,7 @@ export async function cmdMenu() {
211
248
  ...picked.models.filter((m) => m !== picked.defaultModel).map((m) => ({ title: m, value: m })),
212
249
  ],
213
250
  }, cancel);
214
- await cmdUse(picked.id, { model: model || undefined });
251
+ await cmdUse(picked.id, { model: model || undefined, apps });
215
252
  }
216
253
  else if (action === "status") {
217
254
  cmdStatus();
@@ -220,14 +257,14 @@ export async function cmdMenu() {
220
257
  cmdList();
221
258
  }
222
259
  else if (action === "sync") {
223
- await cmdSync({});
260
+ await cmdSync({ apps: await pickApps(t("menu.pickApps")) });
224
261
  }
225
262
  else if (action === "discover") {
226
263
  const picked = await pickProvider(t("menu.discoverFor"));
227
264
  if (!picked)
228
265
  continue;
229
266
  const sync = await askToggle(t("menu.pushRefresh"));
230
- await cmdDiscover(picked.id, { sync });
267
+ await cmdDiscover(picked.id, { sync, apps: sync ? await pickApps(t("menu.pickApps")) : undefined });
231
268
  }
232
269
  else if (action === "metadata") {
233
270
  const picked = await pickProvider(t("menu.metadataProvider"));
@@ -1,12 +1,23 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
4
- import { backupFile, home, readJsonIfExists, readTextIfExists, writeFileAtomic } from "../fsutil.js";
4
+ import { SafeConfigError, backupFile, home, readJsonIfExists, readTextIfExists, writeFileAtomic } from "../fsutil.js";
5
5
  import { isJsonObject } from "../jsonc.js";
6
6
  import { transactionalTarget } from "../target-transaction.js";
7
7
  const dir = path.join(home, ".codex");
8
8
  const configFile = path.join(dir, "config.toml");
9
9
  const authFile = path.join(dir, "auth.json");
10
+ /** smol-toml quotes the offending source line, which may hold a key: report only the position. */
11
+ function parseConfigToml(file, text) {
12
+ try {
13
+ return parseToml(text);
14
+ }
15
+ catch (err) {
16
+ const { line, column } = err;
17
+ const position = line === undefined ? "" : ` (line ${line}${column === undefined ? "" : `, col ${column}`})`;
18
+ throw new SafeConfigError(`${file}: invalid TOML configuration${position}`);
19
+ }
20
+ }
10
21
  /**
11
22
  * Codex CLI: [model_providers.<id>] in ~/.codex/config.toml selects a custom
12
23
  * OpenAI-protocol endpoint; the key ships via ~/.codex/auth.json OPENAI_API_KEY.
@@ -21,7 +32,7 @@ export const codex = transactionalTarget({
21
32
  async apply(provider) {
22
33
  const notes = [];
23
34
  const text = readTextIfExists(configFile);
24
- const config = (text ? parseToml(text) : {});
35
+ const config = text ? parseConfigToml(configFile, text) : {};
25
36
  const authValue = readJsonIfExists(authFile);
26
37
  const auth = authValue === undefined ? {} : authValue;
27
38
  if (!isJsonObject(auth))
@@ -84,7 +95,7 @@ export const codex = transactionalTarget({
84
95
  const text = readTextIfExists(configFile);
85
96
  if (!text)
86
97
  return { app: this.id, changed: [], notes: [], skipped: "no config.toml" };
87
- const config = parseToml(text);
98
+ const config = parseConfigToml(configFile, text);
88
99
  if (config.model_providers !== undefined && !isJsonObject(config.model_providers)) {
89
100
  throw new Error(`${configFile}: expected model_providers to be a table`);
90
101
  }
@@ -114,7 +125,7 @@ export const codex = transactionalTarget({
114
125
  if (!text)
115
126
  return undefined;
116
127
  try {
117
- const config = parseToml(text);
128
+ const config = parseConfigToml(configFile, text);
118
129
  if (!config.model_provider)
119
130
  return undefined;
120
131
  return `${String(config.model_provider)} · ${String(config.model ?? "?")}`;
package/dist/yaml.js CHANGED
@@ -1,12 +1,33 @@
1
1
  import YAML from "yaml";
2
+ import { SafeConfigError } from "./fsutil.js";
3
+ /** YAML errors embed the offending source line, which may hold credentials: keep only the code and position. */
4
+ function describeYamlErrors(file, errors) {
5
+ const details = [...new Set(errors.map((error) => {
6
+ const position = error.linePos?.[0];
7
+ return [
8
+ error.code,
9
+ position ? `line ${position.line}` : undefined,
10
+ position ? `col ${position.col}` : undefined,
11
+ ].filter(Boolean).join(" ");
12
+ }))].slice(0, 3);
13
+ return details.length
14
+ ? `${file}: invalid YAML configuration (${details.join("; ")})`
15
+ : `${file}: invalid YAML configuration`;
16
+ }
2
17
  /** Resolve aliases in document context before edits can mutate or remove anchors. */
3
18
  export function parseYamlMapping(file, text) {
19
+ let doc;
20
+ try {
21
+ doc = text ? YAML.parseDocument(text) : new YAML.Document({});
22
+ }
23
+ catch {
24
+ throw new SafeConfigError(`${file}: invalid YAML configuration`);
25
+ }
26
+ if (doc.errors.length)
27
+ throw new SafeConfigError(describeYamlErrors(file, doc.errors));
28
+ if (doc.contents == null)
29
+ doc.contents = doc.createNode({});
4
30
  try {
5
- const doc = text ? YAML.parseDocument(text) : new YAML.Document({});
6
- if (doc.errors.length)
7
- throw new Error("parse error");
8
- if (doc.contents == null)
9
- doc.contents = doc.createNode({});
10
31
  // YAML permits cycles; application configuration does not. The document
11
32
  // conversion also enforces YAML's alias expansion limit before we detach.
12
33
  JSON.stringify(doc.toJS());
@@ -27,8 +48,8 @@ export function parseYamlMapping(file, text) {
27
48
  return doc;
28
49
  }
29
50
  catch {
30
- // Parser messages include source lines, which may contain credentials.
31
- throw new Error(`${file}: invalid YAML configuration (expected an acyclic mapping with valid aliases)`);
51
+ // Parser and conversion messages include source lines, which may contain credentials.
52
+ throw new SafeConfigError(`${file}: invalid YAML configuration (expected an acyclic mapping with valid aliases)`);
32
53
  }
33
54
  }
34
55
  /** Validate the emitted configuration before any backup or write. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentsw",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
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": {