agentsw 0.10.1 → 0.11.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/dist/menu.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import pc from "picocolors";
2
2
  import prompts from "prompts";
3
- import { cmdAdd, cmdQuickAdd, cmdApps, cmdInstall, cmdDiscover, cmdImport, cmdRefreshMeta, cmdList, cmdStatus, cmdSync, cmdUpgrade, cmdUse, } from "./commands.js";
3
+ import { cmdAdd, cmdQuickAdd, cmdApps, cmdInstall, cmdImport, cmdRefreshMeta, cmdList, cmdStatus, cmdSync, cmdUpgrade, cmdUse, } from "./commands.js";
4
4
  import { getLocale, setLocale, t } from "./i18n.js";
5
5
  import { getMetadataMode } from "./metadata.js";
6
6
  import { loadStore, saveStore } from "./store.js";
@@ -66,6 +66,41 @@ async function pickProvider(message) {
66
66
  const p = store.providers[id];
67
67
  return { id, defaultModel: p.defaultModel, models: p.models.map((m) => m.id) };
68
68
  }
69
+ /**
70
+ * Multi-select providers for one sync. The first row takes every configured provider and maps to
71
+ * the CLI's `--all`; toggling everything off means "back", not "all", because a sync writes files.
72
+ */
73
+ async function pickProviders(message) {
74
+ const store = loadStore();
75
+ const ids = Object.keys(store.providers);
76
+ if (ids.length === 0) {
77
+ out(pc.yellow(t("menu.noProvidersHint")));
78
+ return undefined;
79
+ }
80
+ const { providers } = await prompts({
81
+ type: "multiselect",
82
+ name: "providers",
83
+ message,
84
+ hint: t("menu.appsHint"),
85
+ instructions: false,
86
+ choices: [
87
+ { title: t("menu.providersAll", { count: ids.length }), value: "__all__" },
88
+ ...ids.map((pid) => {
89
+ const p = store.providers[pid];
90
+ return {
91
+ title: `${pid} · ${p.protocol} · ${t("menu.defaultModel")} ${p.defaultModel}${store.active === pid ? ` (${t("menu.active")})` : ""}`,
92
+ value: pid,
93
+ selected: store.active === pid,
94
+ };
95
+ }),
96
+ ],
97
+ }, cancel);
98
+ const chosen = Array.isArray(providers) ? providers : [];
99
+ if (chosen.includes("__all__"))
100
+ return { all: true, ids };
101
+ const selected = chosen.filter((value) => value !== "__all__");
102
+ return selected.length ? { all: false, ids: selected } : undefined;
103
+ }
69
104
  /**
70
105
  * Multi-select target apps, remembering the previous choice. The first row selects
71
106
  * every detected app; picking it (or toggling everything off) returns undefined,
@@ -212,7 +247,6 @@ export async function cmdMenu() {
212
247
  { title: t("menu.status"), description: t("menu.statusHelp"), value: "status" },
213
248
  { title: t("menu.list"), description: t("menu.listHelp"), value: "list" },
214
249
  { title: t("menu.sync"), description: t("menu.syncHelp"), value: "sync" },
215
- { title: t("menu.discover"), description: t("menu.discoverHelp"), value: "discover" },
216
250
  { title: t("menu.metadata"), description: t("menu.metadataHelp"), value: "metadata" },
217
251
  { title: t("menu.rename"), description: t("menu.renameHelp"), value: "rename" },
218
252
  { title: t("menu.remove"), description: t("menu.removeHelp"), value: "remove" },
@@ -271,14 +305,11 @@ export async function cmdMenu() {
271
305
  cmdList();
272
306
  }
273
307
  else if (action === "sync") {
274
- await cmdSync({ apps: await pickApps(t("menu.pickApps")) });
275
- }
276
- else if (action === "discover") {
277
- const picked = await pickProvider(t("menu.discoverFor"));
308
+ const picked = await pickProviders(t("menu.syncFor"));
278
309
  if (!picked)
279
310
  continue;
280
- const sync = await askToggle(t("menu.pushRefresh"));
281
- await cmdDiscover(picked.id, { sync, apps: sync ? await pickApps(t("menu.pickApps")) : undefined });
311
+ const apps = await pickApps(t("menu.pickApps"));
312
+ await cmdSync(picked.all ? { all: true, apps } : { provider: picked.ids.join(","), apps });
282
313
  }
283
314
  else if (action === "metadata") {
284
315
  const picked = await pickProvider(t("menu.metadataProvider"));
@@ -14,6 +14,8 @@ export const claudecode = transactionalTarget({
14
14
  id: "claude",
15
15
  name: "Claude Code",
16
16
  protocols: ["anthropic"],
17
+ // The env block names one endpoint: a second provider replaces the first rather than joining it.
18
+ singleProvider: true,
17
19
  get configPaths() {
18
20
  return [claudeSettingsFile()];
19
21
  },
@@ -82,7 +82,11 @@ export const codex = transactionalTarget({
82
82
  wire_api: "responses",
83
83
  requires_openai_auth: true,
84
84
  };
85
- notes.push("codex requires an OpenAI Responses-compatible endpoint (/v1/responses); chat-completions-only endpoints will not work");
85
+ // Only worth saying when the endpoint has not declared itself Responses-compatible:
86
+ // a provider the user already marked `responses` needs no caveat.
87
+ if (provider.openaiApi !== "responses") {
88
+ notes.push("codex requires an OpenAI Responses-compatible endpoint (/v1/responses); chat-completions-only endpoints will not work");
89
+ }
86
90
  config.model_providers = providers;
87
91
  const configBackup = backupFile(configFile);
88
92
  if (configBackup)
@@ -5,7 +5,7 @@ import { dshCredentialsFile, dshDir, dshSettingsFile } from "../app-paths.js";
5
5
  import { isManagedCredentialRef, legacyManagedCredentialRef, managedCredentialRef } from "../provider-identity.js";
6
6
  import { transactionalTarget } from "../target-transaction.js";
7
7
  import { parseYamlMapping, serializeYamlMapping } from "../yaml.js";
8
- import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl } from "./wire.js";
8
+ import { apiValue, classifyApi, entryApi, hasModelMetadata, mergeModels, sdkBaseUrl } from "./wire.js";
9
9
  /** Layout version of `.credentials.yaml` this build reads and writes. */
10
10
  const CREDENTIALS_VERSION = 1;
11
11
  /** Thinking levels a `reasoningEfforts` entry may offer (llm-pi-ai THINKING_LEVEL_GATE). */
@@ -29,7 +29,8 @@ function modelEntry(m) {
29
29
  ...(m.name ? { name: m.name } : {}),
30
30
  ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
31
31
  ...(m.maxOutput ? { maxTokens: m.maxOutput } : {}),
32
- input: m.imageInput ? ["text", "image"] : ["text"],
32
+ // `input` is a guess for a spec that says nothing; see hasModelMetadata.
33
+ ...(hasModelMetadata(m) ? { input: m.imageInput ? ["text", "image"] : ["text"] } : {}),
33
34
  ...(m.reasoning === false
34
35
  ? { reasoningEfforts: false }
35
36
  : m.reasoning && m.reasoningEfforts?.length
@@ -5,9 +5,7 @@ import { ompAgentDir, ompModelsFiles } from "../app-paths.js";
5
5
  import { looksLikeEnvName } from "../slug.js";
6
6
  import { transactionalTarget } from "../target-transaction.js";
7
7
  import { parseYamlMapping, serializeYamlMapping } from "../yaml.js";
8
- import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl, stripConflictingOverrides } from "./wire.js";
9
- /** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
10
- const OWNED_MODEL_KEYS = ["id", "name", "reasoning", "input", "contextWindow", "maxTokens", "cost"];
8
+ import { apiValue, applyDeepseekCompat, classifyApi, entryApi, mergeModels, OWNED_MODEL_METADATA_KEYS, ownedModelMetadata, sdkBaseUrl, stripConflictingOverrides, } from "./wire.js";
11
9
  /** omp has no directory override; the file set is shared with remove/rename. */
12
10
  function modelFiles() {
13
11
  const [yml, yaml] = ompModelsFiles();
@@ -43,27 +41,12 @@ export const omp = transactionalTarget({
43
41
  const anthropic = provider.protocol === "anthropic";
44
42
  const api = apiValue(provider.protocol, provider.openaiApi, prev?.api ?? entryApi(prev ?? {}));
45
43
  const baseUrl = sdkBaseUrl(provider.protocol, provider.baseUrl);
46
- const models = mergeModels(prev?.models, provider.models.map((m) => ({
47
- id: m.id,
48
- ...(m.name ? { name: m.name } : {}),
49
- ...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}),
50
- input: m.imageInput ? ["text", "image"] : ["text"],
51
- ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
52
- ...(m.maxOutput ? { maxTokens: m.maxOutput } : {}),
53
- ...(m.cost
54
- ? {
55
- cost: {
56
- input: m.cost.input ?? 0,
57
- output: m.cost.output ?? 0,
58
- cacheRead: m.cost.cacheRead ?? 0,
59
- cacheWrite: m.cost.cacheWrite ?? 0,
60
- },
61
- }
62
- : {}),
63
- })), OWNED_MODEL_KEYS);
44
+ const models = mergeModels(prev?.models, provider.models.map(ownedModelMetadata), OWNED_MODEL_METADATA_KEYS);
64
45
  const conflicts = stripConflictingOverrides(models, api, baseUrl);
65
46
  if (conflicts.length)
66
47
  notes.push(`dropped model overrides pointing elsewhere: ${conflicts.join(", ")}`);
48
+ if (!anthropic)
49
+ applyDeepseekCompat(models, { requiresReasoningContentForToolCalls: true });
67
50
  const entry = {
68
51
  baseUrl,
69
52
  apiKey: provider.apiKey, // omp treats value as env-var name first, then literal
@@ -5,18 +5,9 @@ import { piDir, piModelsFile, piSettingsFile } from "../app-paths.js";
5
5
  import { editJsoncObject, isJsonObject, readJsoncObject } from "../jsonc.js";
6
6
  import { looksLikeEnvName } from "../slug.js";
7
7
  import { transactionalTarget } from "../target-transaction.js";
8
- import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl, stripConflictingOverrides } from "./wire.js";
9
- /** Per-model keys this adapter writes; one a metadata-bearing entry stops emitting is cleared, not inherited. */
10
- const OWNED_MODEL_KEYS = [
11
- "id",
12
- "name",
13
- "reasoning",
14
- "thinkingLevelMap",
15
- "input",
16
- "contextWindow",
17
- "maxTokens",
18
- "cost",
19
- ];
8
+ import { apiValue, applyDeepseekCompat, classifyApi, entryApi, mergeModels, OWNED_MODEL_METADATA_KEYS, ownedModelMetadata, sdkBaseUrl, stripConflictingOverrides, } from "./wire.js";
9
+ /** pi writes the shared metadata plus its own thinking-level map. */
10
+ const OWNED_MODEL_KEYS = [...OWNED_MODEL_METADATA_KEYS, "thinkingLevelMap"];
20
11
  function readModels(file) {
21
12
  const document = readJsoncObject(file);
22
13
  const providers = document.value.providers;
@@ -84,26 +75,17 @@ export function piStyleTarget(opts) {
84
75
  const api = apiValue(provider.protocol, provider.openaiApi, prev.api ?? entryApi(prev));
85
76
  const baseUrl = sdkBaseUrl(provider.protocol, provider.baseUrl);
86
77
  const models = mergeModels(prev.models, provider.models.map((m) => ({
87
- id: m.id,
88
- ...(m.name ? { name: m.name } : {}),
89
- ...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}),
78
+ ...ownedModelMetadata(m),
90
79
  ...(m.reasoning && m.reasoningEfforts?.length
91
80
  ? { thinkingLevelMap: thinkingLevelMap(m.reasoningEfforts) }
92
81
  : {}),
93
- input: m.imageInput ? ["text", "image"] : ["text"],
94
- ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
95
- ...(m.maxOutput ? { maxTokens: m.maxOutput } : {}),
96
- ...(m.cost
97
- ? {
98
- cost: {
99
- input: m.cost.input ?? 0,
100
- output: m.cost.output ?? 0,
101
- cacheRead: m.cost.cacheRead ?? 0,
102
- cacheWrite: m.cost.cacheWrite ?? 0,
103
- },
104
- }
105
- : {}),
106
82
  })), OWNED_MODEL_KEYS);
83
+ // pi/prime key their DeepSeek wire rules by provider name, so a gateway
84
+ // entry never picks them up; the flags below are what their own deepseek
85
+ // catalog entries carry. Anthropic-wire entries replay differently.
86
+ if (provider.protocol === "openai") {
87
+ applyDeepseekCompat(models, { requiresReasoningContentOnAssistantMessages: true, thinkingFormat: "deepseek" });
88
+ }
107
89
  const conflicts = stripConflictingOverrides(models, api, baseUrl);
108
90
  if (conflicts.length)
109
91
  notes.push(`dropped model overrides pointing elsewhere: ${conflicts.join(", ")}`);
@@ -99,6 +99,11 @@ derived = ["input"]) {
99
99
  const old = prev.get(m.id);
100
100
  if (!old)
101
101
  return m;
102
+ // An entry that carries nothing but its id says nothing about the model: it
103
+ // is no evidence that the config's metadata went stale, so clearing owned
104
+ // keys on its behalf would delete what a richer sync or the user put there.
105
+ if (Object.keys(m).length === 1)
106
+ return { ...m, ...old };
102
107
  const kept = { ...old };
103
108
  const known = informative.some((key) => key in m);
104
109
  if (known)
@@ -108,6 +113,66 @@ derived = ["input"]) {
108
113
  return { ...kept, ...m };
109
114
  });
110
115
  }
116
+ /** Per-model keys every pi-family adapter owns; see `ownedModelMetadata`. */
117
+ export const OWNED_MODEL_METADATA_KEYS = ["id", "name", "reasoning", "input", "contextWindow", "maxTokens", "cost"];
118
+ const DEEPSEEK_MODEL_ID = /deepseek/i;
119
+ /**
120
+ * omp/pi/prime key their DeepSeek wire rules by provider name ("deepseek",
121
+ * "opencode-go", ...), so a gateway or reseller entry matches none of them: its
122
+ * reasoning replay then breaks and DeepSeek answers 400. Match on the model id
123
+ * instead and write the adapter's own flags per model; an explicit user value wins.
124
+ */
125
+ export function applyDeepseekCompat(models, flags) {
126
+ for (const model of models) {
127
+ if (!DEEPSEEK_MODEL_ID.test(String(model.id)))
128
+ continue;
129
+ const compat = (model.compat ?? {});
130
+ model.compat = { ...flags, ...compat };
131
+ }
132
+ }
133
+ /**
134
+ * Whether a store entry describes the model at all. A bare id is ignorance,
135
+ * not a catalog statement, so adapters must not clear what the app config
136
+ * already holds on its behalf.
137
+ */
138
+ export function hasModelMetadata(m) {
139
+ return (m.name !== undefined ||
140
+ m.reasoning !== undefined ||
141
+ m.reasoningEfforts !== undefined ||
142
+ m.imageInput !== undefined ||
143
+ m.contextWindow !== undefined ||
144
+ m.maxInput !== undefined ||
145
+ m.maxOutput !== undefined ||
146
+ m.cost !== undefined);
147
+ }
148
+ /**
149
+ * The per-model metadata agentsw writes into a pi-family models config. omp and
150
+ * the pi/prime adapters share the shape; only the extras around it differ, so
151
+ * keeping one builder is what stops the two from drifting apart.
152
+ */
153
+ export function ownedModelMetadata(m) {
154
+ // `input` is a guess for a spec that says nothing; writing it would make the
155
+ // entry look authoritative to mergeModels and clear the config's own values.
156
+ const known = hasModelMetadata(m);
157
+ return {
158
+ id: m.id,
159
+ ...(m.name ? { name: m.name } : {}),
160
+ ...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}),
161
+ ...(known ? { input: m.imageInput ? ["text", "image"] : ["text"] } : {}),
162
+ ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
163
+ ...(m.maxOutput ? { maxTokens: m.maxOutput } : {}),
164
+ ...(m.cost
165
+ ? {
166
+ cost: {
167
+ input: m.cost.input ?? 0,
168
+ output: m.cost.output ?? 0,
169
+ cacheRead: m.cost.cacheRead ?? 0,
170
+ cacheWrite: m.cost.cacheWrite ?? 0,
171
+ },
172
+ }
173
+ : {}),
174
+ };
175
+ }
111
176
  /**
112
177
  * Drop per-model `api`/`baseUrl` overrides that contradict the route being
113
178
  * written. A model-level override wins over the provider entry in omp and pi,
@@ -6,6 +6,7 @@ import { workbuddyDir } from "../app-paths.js";
6
6
  import { localProviderId } from "../provider-identity.js";
7
7
  import { providerIdFromBaseUrl, providerNameFromBaseUrl } from "../slug.js";
8
8
  import { transactionalTarget } from "../target-transaction.js";
9
+ import { hasModelMetadata } from "./wire.js";
9
10
  function configDir() {
10
11
  return workbuddyDir();
11
12
  }
@@ -101,12 +102,15 @@ export const workbuddy = transactionalTarget({
101
102
  const previous = new Map(owned.map((row) => [row.id, row]));
102
103
  const ours = provider.models.map((model) => {
103
104
  const custom = { ...previous.get(model.id) };
104
- for (const key of OWNED_MODEL_KEYS)
105
- delete custom[key];
105
+ // A bare id says nothing about the model; the row keeps what it already has.
106
+ const known = hasModelMetadata(model);
107
+ if (known)
108
+ for (const key of OWNED_MODEL_KEYS)
109
+ delete custom[key];
106
110
  return {
107
111
  ...custom,
108
112
  id: model.id,
109
- name: model.name ?? model.id,
113
+ name: model.name ?? (known ? undefined : custom.name) ?? model.id,
110
114
  vendor: provider.name,
111
115
  url,
112
116
  apiKey: provider.apiKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentsw",
3
- "version": "0.10.1",
3
+ "version": "0.11.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": {