agentsw 0.8.0 → 0.9.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
@@ -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
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.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": {