@hasna/switcher 0.1.3 → 0.1.4

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
@@ -4,7 +4,7 @@ title: "Switcher"
4
4
  type: "package-documentation"
5
5
  owner: "codex-fixer"
6
6
  created_at: "2026-09-05T12:50:21.698672Z"
7
- updated_at: "2026-09-06T15:23:09.568156+00:00"
7
+ updated_at: "2026-09-07T06:18:32.384233+00:00"
8
8
  status: "active"
9
9
  source_task: "01a07181-ca8d-70c1-99a2-b276dc5770f3"
10
10
  ---
@@ -69,18 +69,27 @@ An interactive terminal can choose or search the catalog when `--model` is omitt
69
69
 
70
70
  When no remote API configuration is present, each CLI invocation starts an authenticated loopback API on an allocated port, stores SQLite data in `~/.hasna/switcher`, and closes its own listener on completion. Its random operator key remains in memory. Use `HASNA_SWITCHER_HOME` to choose another owner-only home, `HASNA_SWITCHER_SQLITE_PATH` for an explicit database, or `HASNA_SWITCHER_DATABASE_URL` for PostgreSQL. API and SDK data access remains HTTP.
71
71
 
72
- If either remote API variable is configured, both `HASNA_SWITCHER_API_URL` and `HASNA_SWITCHER_API_KEY` are required. An unreachable or misconfigured remote API fails; it never selects a local database instead. The SDK always requires an explicitly configured API.
72
+ Remote API configuration is resolved through Contracts, including canonical credential stores and the default gateway URL. Invalid, unavailable or unauthorized remote services fail without opening local SQLite.
73
73
 
74
74
  The registry contains DeepSeek, OpenRouter, Anthropic, OpenAI, xAI, Ollama, LM Studio, Groq, Cerebras, Mistral, Together AI, Fireworks, Moonshot/Kimi, DashScope, Z.AI, MiniMax, SiliconFlow, and generic protocol entries. `switcher providers presets ID` exposes documented routes, aliases and limitations; this is not a claim that every combination has passed live tests. Remaining adapters and acceptance gates are tracked in [TODOS.md](TODOS.md).
75
75
 
76
+ ## Canonical API configuration
77
+
78
+ The CLI, `clientFromEnv()` SDK helper and standalone MCP client resolve Switcher API URL/key through `@hasna/contracts/client`, following the same shared convention as Conversations. A key alone selects `https://api.hasna.com/switcher`; `HASNA_SWITCHER_API_URL` selects a self-hosted endpoint. The `SWITCHER_API_URL` and `SWITCHER_API_KEY` aliases are supported. Deliberate overrides/profile selections take precedence; otherwise the resolver reads macOS Keychain, canonical disk configuration and then environment. A live client rereads credentials before each request and refuses an authority change until rebuilt.
79
+
80
+ The canonical file is `~/.hasna/switcher/config/credentials` (owner-only mode 0600), using assignments named `HASNA_SWITCHER_API_URL` and `HASNA_SWITCHER_API_KEY`. Provision actual values through your approved secret manager. macOS Keychain uses service `hasna.credentials.switcher.api-key` and optional `api-url`, with account `HASNA_STATION` or the short hostname. Provider keys remain in the provider's vault or secure store; this file authenticates the Switcher API only.
81
+
82
+ `HASNA_HOME` replaces `~/.hasna`; `HASNA_CONFIG_HOME` places credentials at `<root>/switcher/credentials` and `<root>/secrets/credentials`. These shared overrides must be absolute and nonblank. `HASNA_PROFILE` selects `credentials-<profile>`; API authority stays in the common credentials file. `HASNA_SWITCHER_HOME` changes Switcher's local database, bindings and launch state only, and does not relocate shared credentials. `HOME` is respected by both paths.
83
+
84
+ Configured but missing, unsafe, conflicting or inaccessible remote credentials fail before local data is opened. Only complete absence of Switcher remote configuration retains the user-authorized automatic local API. The owned local API uses a random in-memory key. Server-side authentication remains configured separately through `HASNA_SWITCHER_API_KEY` on `switcher-serve`.
85
+
76
86
  ## Credential bindings
77
87
 
78
- Bind an existing vault key once, then launch without an external wrapper. Use your vault's URL and the installed `secrets` CLI. On macOS, `--vault-account` selects the operator's `hasna.credentials.secrets.api-key` Keychain item. Without that option, inject `HASNA_SECRETS_API_KEY` for each process.
88
+ Bind an existing vault key once, then launch without an external wrapper. New bindings use the installed `secrets` CLI and resolve its operator key and API URL through `@hasna/contracts/client`. A configured Keychain item or canonical `~/.hasna/secrets/config/credentials` supplies the operator without shell exports. The default Secrets API URL is `https://api.hasna.com/secrets`; `--vault-url` can select a custom vault but must agree with any configured Secrets authority.
79
89
 
80
90
  ```sh
81
91
  switcher credentials bind deepseek \
82
- --vault-key providers/deepseek/live/api_key --vault-url https://vault.example \
83
- --vault-account my-station
92
+ --vault-key providers/deepseek/live/api_key
84
93
  switcher credentials check deepseek
85
94
  switcher launch claude --provider deepseek --model deepseek-v4-pro
86
95
  ```
@@ -91,7 +100,7 @@ For provider keys already stored in macOS Keychain, use `--keychain-service SERV
91
100
 
92
101
  `credentials list` displays bindings; `credentials remove PRESET_OR_REFERENCE` removes only the locator. Replacement requires explicit removal. Custom credential references require `--origin URL` (repeatable); preset bindings authorize their documented origins by default. `credentials check` reports availability, length and hash, not successful provider authentication. Provider credentials needed by a remote API's catalog discovery must still be configured on that server independently.
93
102
 
94
- On Linux, use a vault binding without `--vault-account` and have your approved secret manager inject `HASNA_SECRETS_API_KEY` into each Switcher process. The binding records the vault URL and key locator; it stores no credential value. Alternatively, let the authenticated `secrets` CLI inject a provider credential for one command:
103
+ Explicit `--vault-account ACCOUNT` pins one macOS Keychain account and requires `--vault-url`; it never falls back when that account is missing or locked. `--vault-operator env` preserves the per-process `HASNA_SECRETS_API_KEY` mode and also requires `--vault-url`. Existing bindings keep their original operator mode. To adopt canonical resolution for an old binding, explicitly remove and rebind its same provider key reference. A Secrets operator cannot bootstrap itself through `HASNA_SECRETS_API_KEY_REF`; use a literal operator from its canonical store or explicit override. On Linux the shared resolver reads the owner-only canonical credentials file or process environment. The binding stores only the locator and optional vault authority. Alternatively, let the authenticated `secrets` CLI inject a provider credential for one command:
95
104
 
96
105
  ```sh
97
106
  secrets exec providers/deepseek/live/api_key --as DEEPSEEK_API_KEY -- \
package/dist/cli/index.js CHANGED
@@ -499,7 +499,7 @@ function canonicalPolicyJSON(value) {
499
499
  }
500
500
 
501
501
  // src/domain.ts
502
- var VERSION = "0.1.3";
502
+ var VERSION = "0.1.4";
503
503
  var harnessSchema = z3.enum(["claude", "codex", "grok", "opencode", "opencode2", "pi", "omp", "dsh", "cline", "hermes", "prime-agent", "gemini", "aider", "kilo"]);
504
504
  var protocolSchema = z3.enum(["anthropic-messages", "openai-responses", "openai-chat", "gemini-generate-content"]);
505
505
  var idSchema = z3.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/);
@@ -1033,7 +1033,7 @@ async function boundedJson(response, maxBytes = MAX_BYTES) {
1033
1033
  }
1034
1034
 
1035
1035
  // src/sdk.ts
1036
- import { resolveCredential } from "@hasna/contracts/client";
1036
+ import { createClientTransport } from "@hasna/contracts/client";
1037
1037
 
1038
1038
  // src/presets.ts
1039
1039
  var route = (protocol, baseUrl, options = {}) => ({
@@ -1206,6 +1206,7 @@ function apiError(status, data, apiKey) {
1206
1206
 
1207
1207
  class SwitcherClient {
1208
1208
  options;
1209
+ transport;
1209
1210
  baseUrl;
1210
1211
  constructor(options) {
1211
1212
  this.baseUrl = endpoint(options.baseUrl).replace(/\/v1$/, "");
@@ -1213,9 +1214,50 @@ class SwitcherClient {
1213
1214
  throw new Error("Switcher API key is required.");
1214
1215
  this.options = { ...options };
1215
1216
  }
1217
+ static fromEnvironment(env = process.env, options = {}) {
1218
+ const shared = createClientTransport("switcher", env, {
1219
+ credentials: options.credentials,
1220
+ timeoutMs: options.timeoutMs ?? 120000,
1221
+ retry: false,
1222
+ fetchImpl: async (input, init) => {
1223
+ const address = String(input).replace(/\/v1\/(health|ready|version)$/, "/$1");
1224
+ const key = new Headers(init?.headers).get("x-api-key") ?? "";
1225
+ let response;
1226
+ try {
1227
+ response = await (options.fetch ?? fetch)(address, init);
1228
+ } catch {
1229
+ throw new SwitcherError(0, "connection_failed", "Switcher API request failed; check endpoint and service availability.");
1230
+ }
1231
+ if (response.status === 401 || response.status === 403) {
1232
+ await response.body?.cancel().catch(() => {});
1233
+ throw new SwitcherError(response.status, "api_error", `Switcher API returned HTTP ${response.status}. Check the configured credential; no alternate identity was selected.`);
1234
+ }
1235
+ let data;
1236
+ try {
1237
+ data = await boundedJson(response);
1238
+ } catch {
1239
+ throw new SwitcherError(response.status, "invalid_response", "Switcher API returned invalid JSON.");
1240
+ }
1241
+ if (!response.ok)
1242
+ throw apiError(response.status, data, key);
1243
+ return Response.json(data);
1244
+ }
1245
+ });
1246
+ const client = new SwitcherClient({ baseUrl: shared.resolution.baseUrl, apiKey: () => {
1247
+ throw new Error("Shared credential transport was not invoked.");
1248
+ } });
1249
+ client.transport = shared.client;
1250
+ return client;
1251
+ }
1216
1252
  async request(method, path, body, options = {}) {
1217
1253
  if (!/^\/v1\/[a-zA-Z0-9/?&=._%+-]+$/.test(path) && !["/health", "/ready", "/version"].includes(path) || path.includes(".."))
1218
1254
  throw new Error("Invalid API path.");
1255
+ if (this.transport)
1256
+ return this.transport.request(method, path.startsWith("/v1/") ? path.slice(3) : path, body, {
1257
+ idempotencyKey: method === "GET" ? undefined : options.idempotencyKey ?? crypto.randomUUID(),
1258
+ headers: options.version === undefined ? undefined : { "if-match": String(options.version) },
1259
+ retry: false
1260
+ });
1219
1261
  const apiKey = typeof this.options.apiKey === "function" ? this.options.apiKey() : this.options.apiKey;
1220
1262
  if (!apiKey || /[\r\n]/.test(apiKey))
1221
1263
  throw new Error("Switcher API key is required.");
@@ -1312,11 +1354,8 @@ class SwitcherClient {
1312
1354
  return this.request("PATCH", `/v1/runs/${encodeURIComponent(id)}`, input, { version, idempotencyKey });
1313
1355
  }
1314
1356
  }
1315
- function clientFromEnv(env = process.env) {
1316
- const credential = () => resolveCredential("switcher", Object.fromEntries(Object.entries(env).filter(([name]) => name === "HASNA_SWITCHER_API_KEY")), { keychain: { enabled: false } })?.apiKey ?? "";
1317
- if (!env.HASNA_SWITCHER_API_URL || !credential())
1318
- throw new Error("Set HASNA_SWITCHER_API_URL and HASNA_SWITCHER_API_KEY; no local database fallback is available.");
1319
- return new SwitcherClient({ baseUrl: env.HASNA_SWITCHER_API_URL, apiKey: credential });
1357
+ function clientFromEnv(env = process.env, options = {}) {
1358
+ return SwitcherClient.fromEnvironment(env, options);
1320
1359
  }
1321
1360
 
1322
1361
  // src/opencode-model-policy.ts
@@ -1388,7 +1427,7 @@ function openCodeInvocationModel(args, policy, defaultAgent) {
1388
1427
 
1389
1428
  // src/gemini-config.ts
1390
1429
  import { readFile, writeFile as writeFile3, lstat as lstat2, readdir as readdir2, symlink, realpath as realpath2 } from "fs/promises";
1391
- import { join as join4, dirname as dirname3, resolve as resolve4, isAbsolute as isAbsolute3, relative as relative2, sep } from "path";
1430
+ import { join as join4, dirname as dirname3, resolve as resolve4, isAbsolute as isAbsolute4, relative as relative2, sep } from "path";
1392
1431
  import { homedir as homedir3 } from "os";
1393
1432
  import { parseTree as parseTree2, getNodeValue } from "jsonc-parser";
1394
1433
 
@@ -1396,7 +1435,7 @@ import { parseTree as parseTree2, getNodeValue } from "jsonc-parser";
1396
1435
  import { randomBytes } from "crypto";
1397
1436
  import { mkdir as mkdir4, lstat } from "fs/promises";
1398
1437
  import { homedir as homedir2 } from "os";
1399
- import { join as join3, resolve as resolve3 } from "path";
1438
+ import { isAbsolute as isAbsolute3, join as join3, resolve as resolve3 } from "path";
1400
1439
 
1401
1440
  // src/store.ts
1402
1441
  var {SQL } = globalThis.Bun;
@@ -1638,7 +1677,7 @@ async function fetchCatalogPage(url, headers, deadline) {
1638
1677
  await waitForCatalogRetry(delay ?? 100 * 2 ** retry, deadline);
1639
1678
  }
1640
1679
  }
1641
- async function discover(provider, env = process.env, resolveCredential2) {
1680
+ async function discover(provider, env = process.env, resolveCredential) {
1642
1681
  const refreshedAt = new Date().toISOString();
1643
1682
  if (provider.manualModels.length)
1644
1683
  return { models: provider.manualModels, source: "manual", refreshedAt };
@@ -1658,7 +1697,7 @@ async function discover(provider, env = process.env, resolveCredential2) {
1658
1697
  if (authStyle !== "none" && credentialEnv) {
1659
1698
  if (url.origin !== new URL(provider.baseUrl).origin && !provider.catalogCredentialEnv)
1660
1699
  throw new Fault(422, "catalog_credential_authority", "A different catalog origin requires an explicit catalog credential reference or catalogAuthStyle: none.");
1661
- const credential = resolveCredential2 ? await resolveCredential2({ ...provider, baseUrl: provider.catalogBaseUrl ?? provider.baseUrl, credentialEnv }) : env[credentialEnv];
1700
+ const credential = resolveCredential ? await resolveCredential({ ...provider, baseUrl: provider.catalogBaseUrl ?? provider.baseUrl, credentialEnv }) : env[credentialEnv];
1662
1701
  if (!credential)
1663
1702
  throw new Fault(422, "credential_missing", "Provider credential environment variable is not available on the server.");
1664
1703
  if (/[\r\n]/.test(credential))
@@ -1985,7 +2024,7 @@ var openapi_default = {
1985
2024
  openapi: "3.0.3",
1986
2025
  info: {
1987
2026
  title: "Switcher API",
1988
- version: "0.1.3",
2027
+ version: "0.1.4",
1989
2028
  description: "Authenticated provider/profile/catalog control plane. Launches run locally; the API never returns provider credentials."
1990
2029
  },
1991
2030
  security: [
@@ -5460,7 +5499,7 @@ var openapi_default = {
5460
5499
  // src/service.ts
5461
5500
  var snapshot = (profile, provider, catalog) => createHash3("sha256").update(JSON.stringify([profile, provider, { models: catalog.models, source: catalog.source }])).digest("hex");
5462
5501
  var hash = (s) => createHash3("sha256").update(s).digest();
5463
- function createHandler(store, apiKey, providerEnv = process.env, resolveCredential2) {
5502
+ function createHandler(store, apiKey, providerEnv = process.env, resolveCredential) {
5464
5503
  if (!apiKey || apiKey.length < 24)
5465
5504
  throw new Fault(500, "auth_config", "Set HASNA_SWITCHER_API_KEY to a random token of at least 24 characters.");
5466
5505
  const expected = hash(`Bearer ${apiKey}`);
@@ -5542,7 +5581,7 @@ function createHandler(store, apiKey, providerEnv = process.env, resolveCredenti
5542
5581
  if (resource === "providers" && id2 && parts[3] === "refresh" && parts.length === 4 && request.method === "POST") {
5543
5582
  parse2(z4.object({}).strict(), body);
5544
5583
  const provider = await store.get("providers", id2);
5545
- refreshed = { provider, catalog: await discover(provider, providerEnv, resolveCredential2) };
5584
+ refreshed = { provider, catalog: await discover(provider, providerEnv, resolveCredential) };
5546
5585
  }
5547
5586
  const result = await store.mutate(key, fingerprint, async (db) => {
5548
5587
  if ((resource === "providers" || resource === "profiles") && parts.length <= 3) {
@@ -5689,8 +5728,11 @@ async function startServer(options) {
5689
5728
  }
5690
5729
 
5691
5730
  // src/runtime.ts
5731
+ import { resolveCredential as resolveClientCredential, clientTransportEnvKeys, appConfigDiskValue, keychainConfigValue } from "@hasna/contracts/client";
5692
5732
  function switcherHome(env = process.env) {
5693
- return resolve3(env.HASNA_SWITCHER_HOME ?? join3(homedir2(), ".hasna", "switcher"));
5733
+ const override = env.HASNA_HOME?.trim();
5734
+ const root = override && isAbsolute3(override) ? override : join3(env.HOME?.trim() || homedir2(), ".hasna");
5735
+ return resolve3(env.HASNA_SWITCHER_HOME ?? join3(root, "switcher"));
5694
5736
  }
5695
5737
  async function privateDirectory(path) {
5696
5738
  await mkdir4(path, { recursive: true, mode: 448 });
@@ -5700,9 +5742,12 @@ async function privateDirectory(path) {
5700
5742
  if (process.platform !== "win32" && ((info.mode & 63) !== 0 || info.uid !== process.getuid?.()))
5701
5743
  throw new Fault(500, "home_permissions", "Switcher data directory must be owned by this user and accessible only to its owner (mode 0700).");
5702
5744
  }
5703
- async function openCliRuntime(env = process.env, resolveCredential2) {
5745
+ async function openCliRuntime(env = process.env, resolveCredential) {
5704
5746
  const providerEnv = Object.fromEntries(Object.entries(env).filter(([name]) => name.startsWith("SWITCHER_PROVIDER_")));
5705
- if (Object.hasOwn(env, "HASNA_SWITCHER_API_URL") || Object.hasOwn(env, "HASNA_SWITCHER_API_KEY")) {
5747
+ const keys = clientTransportEnvKeys("switcher");
5748
+ const credential = resolveClientCredential("switcher", env);
5749
+ const configured = credential || keys.apiUrlKeys.some((name) => env[name] !== undefined) || keychainConfigValue("switcher", env) || appConfigDiskValue("switcher", env, keys.apiUrlKeys);
5750
+ if (configured) {
5706
5751
  return { client: clientFromEnv(env), mode: "remote", providerEnv, close: async () => {} };
5707
5752
  }
5708
5753
  const home = switcherHome(env);
@@ -5714,7 +5759,7 @@ async function openCliRuntime(env = process.env, resolveCredential2) {
5714
5759
  databaseUrl: env.HASNA_SWITCHER_DATABASE_URL,
5715
5760
  sqlitePath: env.HASNA_SWITCHER_SQLITE_PATH ?? (env.HASNA_SWITCHER_DATABASE_URL ? undefined : join3(home, "switcher.db")),
5716
5761
  providerEnv,
5717
- resolveCredential: resolveCredential2
5762
+ resolveCredential
5718
5763
  });
5719
5764
  return { client: new SwitcherClient({ baseUrl: service.url, apiKey }), mode: "local", providerEnv, close: service.close };
5720
5765
  }
@@ -5860,7 +5905,7 @@ function originalPaths(value, home) {
5860
5905
  }
5861
5906
  async function validateGeminiConfiguration(cwd, env = process.env) {
5862
5907
  const home = env.GEMINI_CLI_HOME ?? env.HOME ?? homedir3();
5863
- if (!isAbsolute3(home))
5908
+ if (!isAbsolute4(home))
5864
5909
  throw new Error("Gemini requires an absolute native home.");
5865
5910
  const systemPath = env.GEMINI_CLI_SYSTEM_SETTINGS_PATH ?? (process.platform === "darwin" ? "/Library/Application Support/GeminiCli/settings.json" : process.platform === "win32" ? "C:\\ProgramData\\gemini-cli\\settings.json" : "/etc/gemini-cli/settings.json");
5866
5911
  const defaultsPath = env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH ?? join4(dirname3(systemPath), "system-defaults.json");
@@ -5924,7 +5969,7 @@ async function snapshotContext(sourceDir, destinationDir, names) {
5924
5969
  if (code.some(([a, b]) => start >= a && start < b))
5925
5970
  continue;
5926
5971
  const imported = resolve4(dirname3(source), match[2]), actual = await realpath2(imported).catch(() => imported), within = relative2(root, actual);
5927
- if (within === ".." || within.startsWith(".." + sep) || isAbsolute3(within))
5972
+ if (within === ".." || within.startsWith(".." + sep) || isAbsolute4(within))
5928
5973
  throw new Error("Gemini global context imports outside the original context directory require a supported explicit context location.");
5929
5974
  const target = files.get(imported) ?? join4(importsDir, `${files.size}.md`);
5930
5975
  await privateDirectory(importsDir);
@@ -6464,7 +6509,7 @@ function createInferenceGateway(input) {
6464
6509
 
6465
6510
  // src/codex-model-policy.ts
6466
6511
  import { mkdir as mkdir5, readFile as readFile2, realpath as realpath3, stat as stat3, writeFile as writeFile4 } from "fs/promises";
6467
- import { dirname as dirname4, isAbsolute as isAbsolute4, join as join5, resolve as resolve5 } from "path";
6512
+ import { dirname as dirname4, isAbsolute as isAbsolute5, join as join5, resolve as resolve5 } from "path";
6468
6513
  import { execFileSync as execFileSync2 } from "child_process";
6469
6514
  import { createHash as createHash6 } from "crypto";
6470
6515
  var dict = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
@@ -6484,7 +6529,7 @@ function codexAgentsFromEffectiveConfig(config, trustedProject = true, baseDir)
6484
6529
  const rawConfigFile = value.config_file;
6485
6530
  if (rawConfigFile !== undefined && typeof rawConfigFile !== "string")
6486
6531
  throw new Error(`Codex agents.${name}.config_file must be a string.`);
6487
- const configFile = rawConfigFile === undefined ? undefined : isAbsolute4(rawConfigFile) ? rawConfigFile : baseDir ? resolve5(baseDir, rawConfigFile) : undefined;
6532
+ const configFile = rawConfigFile === undefined ? undefined : isAbsolute5(rawConfigFile) ? rawConfigFile : baseDir ? resolve5(baseDir, rawConfigFile) : undefined;
6488
6533
  if (rawConfigFile !== undefined && !configFile)
6489
6534
  throw new Error(`Codex agents.${name}.config_file must be absolute after precedence resolution.`);
6490
6535
  const definition = { trusted: trustedProject, config_file: configFile };
@@ -6646,7 +6691,7 @@ async function prepareCodexModelPolicy(input) {
6646
6691
  const global = await readConfig(globalPath) ?? {};
6647
6692
  const projects = dict(global.projects) ? global.projects : {};
6648
6693
  const markers = global.project_root_markers ?? [".git"];
6649
- if (!Array.isArray(markers) || markers.some((m) => typeof m !== "string" || isAbsolute4(m) || m.includes("..")))
6694
+ if (!Array.isArray(markers) || markers.some((m) => typeof m !== "string" || isAbsolute5(m) || m.includes("..")))
6650
6695
  throw new Error("Unsupported Codex project root markers.");
6651
6696
  const ancestorDirs = [];
6652
6697
  for (let dir = cwd;; dir = dirname4(dir)) {
@@ -6715,7 +6760,7 @@ async function prepareCodexModelPolicy(input) {
6715
6760
  return { ...result, agentConfigPaths: await writeCodexAgentOverrides(result, input.stateDir), configPaths: loaded };
6716
6761
  }
6717
6762
  async function readCodexAgentConfig(path) {
6718
- if (!isAbsolute4(path))
6763
+ if (!isAbsolute5(path))
6719
6764
  throw new Error("Codex agent config_file must be absolute before launch.");
6720
6765
  const canonical = await realpath3(path);
6721
6766
  const info = await stat3(canonical);
@@ -6731,7 +6776,7 @@ async function readCodexAgentConfig(path) {
6731
6776
 
6732
6777
  // src/harnesses.ts
6733
6778
  import { access as access2, mkdir as mkdir10, readFile as readFile4, readdir as readdir4, writeFile as writeFile10, rm as rm2 } from "fs/promises";
6734
- import { basename as basename2, dirname as dirname7, join as join11, isAbsolute as isAbsolute9, resolve as resolve8 } from "path";
6779
+ import { basename as basename2, dirname as dirname7, join as join11, isAbsolute as isAbsolute10, resolve as resolve8 } from "path";
6735
6780
  import { homedir as homedir6, tmpdir } from "os";
6736
6781
  import { createHash as createHash8, randomUUID as randomUUID4, timingSafeEqual as timingSafeEqual5 } from "crypto";
6737
6782
  import { execFile } from "child_process";
@@ -6741,7 +6786,7 @@ import { createConnection } from "net";
6741
6786
  // src/aider-config.ts
6742
6787
  import { readFile as readFile3, writeFile as writeFile5, lstat as lstat3, copyFile, rename, realpath as realpath4, access } from "fs/promises";
6743
6788
  import { constants as constants2 } from "fs";
6744
- import { dirname as dirname5, basename, join as join6, resolve as resolve6, isAbsolute as isAbsolute5 } from "path";
6789
+ import { dirname as dirname5, basename, join as join6, resolve as resolve6, isAbsolute as isAbsolute6 } from "path";
6745
6790
  import { homedir as homedir4 } from "os";
6746
6791
  import { parseDocument as parseDocument2 } from "yaml";
6747
6792
  var KEY2 = "SWITCHER_HARNESS_API_KEY";
@@ -6800,7 +6845,7 @@ var startup = `yes-always gui browser upgrade install-main-branch just-check-upd
6800
6845
  var safeParameters = new Set(`max_tokens temperature top_p top_k min_p stop seed reasoning_effort thinking`.split(" "));
6801
6846
  var expandHome = (path, home) => path === "~" ? home : path.startsWith("~/") ? join6(home, path.slice(2)) : path;
6802
6847
  async function validateAiderConfiguration(cwd, args = [], home = process.env.HOME ?? homedir4()) {
6803
- if (!isAbsolute5(home))
6848
+ if (!isAbsolute6(home))
6804
6849
  throw new Error("Aider requires an absolute HOME to preserve native instruction paths.");
6805
6850
  const parsed = aiderArguments(args), cwdRoot = await gitRoot(cwd);
6806
6851
  const roots = new Set([resolve6(home), ...cwdRoot ? [cwdRoot] : [], resolve6(cwd)]);
@@ -7036,7 +7081,7 @@ function validateGrokResume(args) {
7036
7081
  // src/cline-backend.ts
7037
7082
  import { mkdir as mkdir6, writeFile as writeFile6 } from "fs/promises";
7038
7083
  import { randomUUID as randomUUID2 } from "crypto";
7039
- import { isAbsolute as isAbsolute6, join as join7 } from "path";
7084
+ import { isAbsolute as isAbsolute7, join as join7 } from "path";
7040
7085
  var ENV_BY_PROTOCOL = {
7041
7086
  "anthropic-messages": "ANTHROPIC_API_KEY",
7042
7087
  "openai-responses": "OPENAI_API_KEY",
@@ -7082,7 +7127,7 @@ async function prepareClineLaunch(input) {
7082
7127
  throw new Error(`Cline ${input.protocol} uses ${mapping.authStyle} authentication; use a provider with the native Cline auth contract.`);
7083
7128
  if (input.credential && /[\r\n]/.test(input.credential))
7084
7129
  throw new Error("Provider credential contains invalid header characters.");
7085
- if (!isAbsolute6(input.stateDir) || !isAbsolute6(input.cwd))
7130
+ if (!isAbsolute7(input.stateDir) || !isAbsolute7(input.cwd))
7086
7131
  throw new Error("Launch state and working directories must be absolute.");
7087
7132
  const dataDir = join7(input.stateDir, "cline-data");
7088
7133
  const sessionDir = input.sessionDir ?? join7(dataDir, "sessions");
@@ -7164,7 +7209,7 @@ async function prepareClineLaunch(input) {
7164
7209
  // src/opencode2-config.ts
7165
7210
  import { mkdir as mkdir7, open as open2, realpath as realpath5, readdir as readdir3, stat as stat4, writeFile as writeFile7 } from "fs/promises";
7166
7211
  import { constants as constants3 } from "fs";
7167
- import { dirname as dirname6, isAbsolute as isAbsolute7, join as join8, relative as relative4, resolve as resolve7 } from "path";
7212
+ import { dirname as dirname6, isAbsolute as isAbsolute8, join as join8, relative as relative4, resolve as resolve7 } from "path";
7168
7213
  import { homedir as homedir5 } from "os";
7169
7214
  import { randomUUID as randomUUID3 } from "crypto";
7170
7215
  import { Database as Database2 } from "bun:sqlite";
@@ -7180,7 +7225,7 @@ var maxTotalBytes2 = 8 * 1024 * 1024;
7180
7225
  var maxFiles2 = 256;
7181
7226
  var inside2 = (root, path) => {
7182
7227
  const rel = relative4(root, path);
7183
- return rel === "" || !isAbsolute7(rel) && rel !== ".." && !rel.startsWith("../");
7228
+ return rel === "" || !isAbsolute8(rel) && rel !== ".." && !rel.startsWith("../");
7184
7229
  };
7185
7230
  var action = (value) => ({ bash: "shell", task: "subagent", write: "edit", patch: "edit" })[value] ?? value;
7186
7231
  function invalid2(path, field) {
@@ -7301,7 +7346,7 @@ async function checkRemoteConfiguration(dataHome) {
7301
7346
  }
7302
7347
  async function isolateOpenCode2(cwd, stateDir, providerID, env = process.env) {
7303
7348
  for (const name of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "OPENCODE_CONFIG_DIR"])
7304
- if (env[name] && !isAbsolute7(env[name]))
7349
+ if (env[name] && !isAbsolute8(env[name]))
7305
7350
  throw new Error(`OpenCode 2 requires an absolute ${name} to preserve native configuration authority.`);
7306
7351
  const home = resolve7(env.HOME || homedir5());
7307
7352
  const configRoot = resolve7(env.OPENCODE_CONFIG_DIR || join8(env.XDG_CONFIG_HOME || join8(home, ".config"), "opencode"));
@@ -7723,7 +7768,7 @@ function compileHermesModelPolicy(currentModel, roles2, baseUrl, apiMode = "chat
7723
7768
  // src/hermes-backend.ts
7724
7769
  import { createHash as createHash7, timingSafeEqual as timingSafeEqual4 } from "crypto";
7725
7770
  import { mkdir as mkdir9, symlink as symlink2, writeFile as writeFile9 } from "fs/promises";
7726
- import { isAbsolute as isAbsolute8, join as join10 } from "path";
7771
+ import { isAbsolute as isAbsolute9, join as join10 } from "path";
7727
7772
  var hermesApiMode = {
7728
7773
  "anthropic-messages": "anthropic_messages",
7729
7774
  "openai-responses": "codex_responses",
@@ -7899,7 +7944,7 @@ function createHermesBridge(input) {
7899
7944
  };
7900
7945
  }
7901
7946
  async function prepareHermesLaunch(input) {
7902
- if (!isAbsolute8(input.stateDir) || !isAbsolute8(input.cwd))
7947
+ if (!isAbsolute9(input.stateDir) || !isAbsolute9(input.cwd))
7903
7948
  throw new Error("Launch state and working directories must be absolute.");
7904
7949
  if (input.protocol !== "anthropic-messages" && input.protocol !== "openai-responses" && input.protocol !== "openai-chat")
7905
7950
  throw new Error("Hermes does not support this provider protocol.");
@@ -8849,7 +8894,7 @@ async function prepareNativeLaunch(input, providerBaseUrl = input.providerBaseUr
8849
8894
  if (!compatible(input.harness, input.protocol))
8850
8895
  throw new Error("Harness and provider protocol are incompatible.");
8851
8896
  validateHarnessVersion(input.harness, input.version);
8852
- if (!isAbsolute9(input.stateDir) || !isAbsolute9(input.cwd))
8897
+ if (!isAbsolute10(input.stateDir) || !isAbsolute10(input.cwd))
8853
8898
  throw new Error("Launch state and working directories must be absolute.");
8854
8899
  if (!input.models.length || !input.models.some((m) => m.id === input.model))
8855
8900
  throw new Error("Selected model is missing from the launch catalog.");
@@ -8957,7 +9002,7 @@ async function prepareNativeLaunch(input, providerBaseUrl = input.providerBaseUr
8957
9002
  const api = { "anthropic-messages": "anthropic-messages", "openai-responses": "openai-responses", "openai-chat": "openai-completions" }[input.protocol];
8958
9003
  const home = join11(input.stateDir, "dsh-home");
8959
9004
  const sessionDir = input.sessionDir ?? join11(input.stateDir, "dsh-state");
8960
- if (!isAbsolute9(sessionDir))
9005
+ if (!isAbsolute10(sessionDir))
8961
9006
  throw new Error("DSH session directory must be absolute.");
8962
9007
  await mkdir10(home, { recursive: true, mode: 448 });
8963
9008
  await mkdir10(sessionDir, { recursive: true, mode: 448 });
@@ -9218,7 +9263,7 @@ async function prepareHarnessLaunch(input) {
9218
9263
  validateHarnessVersion(input.harness, input.version);
9219
9264
  if (!compatible(input.harness, input.protocol))
9220
9265
  throw new Error("Harness and provider protocol are incompatible.");
9221
- if (!isAbsolute9(input.stateDir) || !isAbsolute9(input.cwd))
9266
+ if (!isAbsolute10(input.stateDir) || !isAbsolute10(input.cwd))
9222
9267
  throw new Error("Launch state and working directories must be absolute.");
9223
9268
  const compiledPolicy = compileModelPolicy(input.model, input.models, input.modelPolicy);
9224
9269
  const nativePolicy = compileNativeModelPolicy({ harness: input.harness, mainModel: input.model, roles: compiledPolicy.roles, version: input.version });
@@ -9248,9 +9293,9 @@ async function prepareHarnessLaunch(input) {
9248
9293
  }
9249
9294
 
9250
9295
  // src/ori-model-policy.ts
9251
- import { isAbsolute as isAbsolute10 } from "path";
9296
+ import { isAbsolute as isAbsolute11 } from "path";
9252
9297
  function prepareOriModelPolicy(target, prepared) {
9253
- if (!isAbsolute10(prepared.executable) || /[\0\r\n]/.test(prepared.executable))
9298
+ if (!isAbsolute11(prepared.executable) || /[\0\r\n]/.test(prepared.executable))
9254
9299
  throw new Error("Ori requires an absolute verified native executable.");
9255
9300
  const entries = Object.entries(prepared.env);
9256
9301
  if (entries.some(([key]) => !/^[A-Z_][A-Z0-9_]*$/.test(key)))
@@ -10222,19 +10267,20 @@ async function ensureLaunchProfile(client, provider, harness, model3, modelPolic
10222
10267
  import { z as z6 } from "zod";
10223
10268
  import { constants as constants5 } from "fs";
10224
10269
  import { open as open3, readdir as readdir5, unlink, link, lstat as lstat4, access as access3, stat as stat5, realpath as realpath6, readlink } from "fs/promises";
10225
- import { join as join13, isAbsolute as isAbsolute11, dirname as dirname8, parse as pathParts, resolve as resolvePath, sep as sep2 } from "path";
10270
+ import { join as join13, isAbsolute as isAbsolute12, dirname as dirname8, parse as pathParts, resolve as resolvePath, sep as sep2 } from "path";
10226
10271
  import { createHash as createHash10, randomUUID as randomUUID5 } from "crypto";
10227
10272
  import { execFile as execFile3, spawn as spawn2 } from "child_process";
10228
10273
  import { promisify as promisify3 } from "util";
10229
- import { resolveCredential as resolveCredential2 } from "@hasna/contracts/client";
10274
+ import { resolveCredential, resolveClientTransport, toV1BaseUrl, clientTransportEnvKeys as clientTransportEnvKeys2, keychainConfigValue as keychainConfigValue2, appConfigDiskValue as appConfigDiskValue2 } from "@hasna/contracts/client";
10230
10275
  var execute2 = promisify3(execFile3);
10231
10276
  var reference = z6.string().regex(/^SWITCHER_PROVIDER_[A-Z0-9_]+$/).max(120);
10232
10277
  var item = z6.string().min(1).max(500).regex(/^[^\x00-\x1f\x7f]+$/);
10233
10278
  var vaultKey = z6.string().max(500).regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9][A-Za-z0-9_.-]*)*$/, "Use a vault key path, not an option or secret value");
10234
10279
  var keychain = z6.object({ kind: z6.literal("keychain"), service: item, account: item }).strict();
10235
10280
  var operator = z6.discriminatedUnion("kind", [
10281
+ z6.object({ kind: z6.literal("contracts") }).strict(),
10236
10282
  z6.object({ kind: z6.literal("env") }).strict(),
10237
- z6.object({ kind: z6.literal("keychain"), account: item }).strict()
10283
+ z6.object({ kind: z6.literal("keychain"), account: item.refine((value) => value.trim().length > 0 && value === value.trim(), "Vault account must be nonblank without surrounding whitespace") }).strict()
10238
10284
  ]);
10239
10285
  var origin = z6.string().transform((value) => new URL(endpoint(value)).origin);
10240
10286
  var credentialBindingSchema = z6.object({
@@ -10244,11 +10290,14 @@ var credentialBindingSchema = z6.object({
10244
10290
  source: z6.discriminatedUnion("kind", [keychain, z6.object({
10245
10291
  kind: z6.literal("vault"),
10246
10292
  key: vaultKey,
10247
- url: z6.string().max(2000).transform(endpoint),
10248
- executable: z6.string().max(4096).regex(/^[^\x00-\x1f\x7f]+$/).refine(isAbsolute11, "Secrets executable must be an absolute path"),
10293
+ url: z6.string().max(2000).transform(endpoint).optional(),
10294
+ executable: z6.string().max(4096).regex(/^[^\x00-\x1f\x7f]+$/).refine(isAbsolute12, "Secrets executable must be an absolute path"),
10249
10295
  operator
10250
10296
  }).strict()])
10251
- }).strict();
10297
+ }).strict().superRefine((binding, ctx) => {
10298
+ if (binding.source.kind === "vault" && binding.source.operator.kind !== "contracts" && !binding.source.url)
10299
+ ctx.addIssue({ code: "custom", path: ["source", "url"], message: "An explicit env or Keychain operator binding requires a vault URL." });
10300
+ });
10252
10301
  var fingerprint = (binding) => createHash10("sha256").update(JSON.stringify(binding)).digest("hex");
10253
10302
 
10254
10303
  class CredentialBindings {
@@ -10489,20 +10538,49 @@ class CredentialInterrupted extends CommandInterrupted {
10489
10538
  super(exitCode, "Credential lookup was interrupted; no harness was started.");
10490
10539
  }
10491
10540
  }
10492
- function vaultEnvironment(binding, env) {
10541
+ async function vaultEnvironment(binding, env, options = {}) {
10493
10542
  if (binding.source.kind !== "vault")
10494
10543
  throw new Fault(500, "credential_resolution", "Unexpected credential source.");
10495
- let credential;
10544
+ const source = binding.source;
10545
+ let credential, url = source.url;
10496
10546
  try {
10497
- credential = binding.source.operator.kind === "keychain" ? resolveCredential2("secrets", { HASNA_STATION: binding.source.operator.account }, { keychain: { enabled: true } }) : resolveCredential2("secrets", Object.fromEntries(Object.entries(env).filter(([name]) => name === "HASNA_SECRETS_API_KEY")), { keychain: { enabled: false } });
10498
- } catch {
10499
- throw new Fault(422, "vault_operator_unavailable", "The configured vault operator credential is inaccessible; no alternate account was selected.");
10547
+ if (source.operator.kind === "contracts") {
10548
+ const pair = () => {
10549
+ const keys = clientTransportEnvKeys2("secrets");
10550
+ const configuredUrl = keys.apiUrlKeys.map((name) => env[name]).find((value) => value !== undefined) ?? keychainConfigValue2("secrets", env, options.keychain)?.value ?? appConfigDiskValue2("secrets", env, keys.apiUrlKeys)?.value;
10551
+ const resolution = resolveClientTransport("secrets", env, { credentials: options });
10552
+ const key = resolveCredential("secrets", env, options);
10553
+ if (key?.tier === "pointer")
10554
+ throw new Fault(422, "vault_operator_pointer", "A Secrets operator cannot bootstrap itself through HASNA_SECRETS_API_KEY_REF. Configure its operator in Keychain, canonical config/credentials, or an explicit key override.");
10555
+ if (!key?.apiKey || key.source !== resolution.apiKeySource || key.tier !== resolution.apiKeyTier)
10556
+ throw new Fault(422, "vault_operator_changed", "The vault operator changed during resolution; no credential was sent. Retry after configuration is stable.");
10557
+ const boundUrl = source.url ? toV1BaseUrl(source.url) : resolution.baseUrl;
10558
+ if (resolution.apiUrlSource !== "default" && boundUrl !== resolution.baseUrl)
10559
+ throw new Fault(422, "vault_operator_authority", "The binding vault URL conflicts with the canonical Secrets API URL; no credential was sent.");
10560
+ if (configuredUrl !== undefined && toV1BaseUrl(configuredUrl) !== resolution.baseUrl)
10561
+ throw new Fault(422, "vault_operator_changed", "The vault authority changed during resolution; no credential was sent.");
10562
+ return { key, url: configuredUrl?.trim() ?? source.url ?? boundUrl.replace(/\/v1$/, "") };
10563
+ };
10564
+ const first = pair(), second = pair();
10565
+ if (first.key.apiKey !== second.key.apiKey || first.key.source !== second.key.source || first.key.tier !== second.key.tier || first.url !== second.url)
10566
+ throw new Fault(422, "vault_operator_changed", "The vault operator or authority changed during resolution; no credential was sent. Retry after configuration is stable.");
10567
+ credential = second.key;
10568
+ url = second.url;
10569
+ } else {
10570
+ credential = source.operator.kind === "keychain" ? resolveCredential("secrets", { HASNA_STATION: source.operator.account }, { keychain: { ...options.keychain, enabled: true } }) : resolveCredential("secrets", Object.fromEntries(Object.entries(env).filter(([name]) => name === "HASNA_SECRETS_API_KEY")), { keychain: { enabled: false } });
10571
+ }
10572
+ } catch (error) {
10573
+ if (error instanceof Fault)
10574
+ throw error;
10575
+ const kind = error instanceof Error ? error.name : "";
10576
+ const reason = kind === "CredentialFileUnsafeError" ? "unsafe canonical config/credentials file" : kind === "ClientTransportConfigurationError" ? "invalid or missing canonical API URL/key" : source.operator.kind === "keychain" ? "unavailable pinned Keychain account" : "credential selection refused by Contracts (Keychain, canonical file, profile, or environment)";
10577
+ throw new Fault(422, "vault_operator_unavailable", `Cannot resolve the Secrets vault operator: ${reason}. Check the selected source in this terminal session; no alternate account was selected.`);
10500
10578
  }
10501
10579
  if (!credential?.apiKey)
10502
- throw new Fault(422, "vault_operator_missing", "Configure the vault operator Keychain account or inject HASNA_SECRETS_API_KEY for this process.");
10503
- const allowed = /^(PATH|HOME|USER|LOGNAME|SHELL|LANG|LC_[A-Z_]+|TMPDIR|TEMP|TMP|HASNA_SECRETS_HOME|HASNA_STATION)$/;
10580
+ throw new Fault(422, "vault_operator_missing", "The selected vault operator has no credential. Use a canonical Contracts binding, restore its named Keychain account, or inject HASNA_SECRETS_API_KEY for an explicit env binding.");
10581
+ const allowed = /^(PATH|HOME|USER|LOGNAME|SHELL|LANG|LC_[A-Z_]+|TMPDIR|TEMP|TMP|HASNA_HOME|HASNA_CONFIG_HOME|HASNA_SECRETS_HOME|HASNA_STATION)$/;
10504
10582
  const next = Object.fromEntries(Object.entries(env).filter(([name]) => allowed.test(name)));
10505
- next.HASNA_SECRETS_API_URL = binding.source.url;
10583
+ next.HASNA_SECRETS_API_URL = url;
10506
10584
  next.HASNA_SECRETS_API_KEY = credential.apiKey;
10507
10585
  next.HASNA_SECRETS_API_KEY_OVERRIDE = credential.apiKey;
10508
10586
  if (binding.source.operator.kind === "keychain")
@@ -10515,7 +10593,7 @@ async function runVaultCommand(binding, args, env, delivery = {}, captureCheck =
10515
10593
  if (process.platform === "win32")
10516
10594
  throw new Fault(422, "vault_exec_unavailable", "Vault CLI bindings currently require POSIX process groups; use runtime environment injection on Windows.");
10517
10595
  const executable = await validateVaultExecutable(binding.source.executable);
10518
- const childEnv = { ...vaultEnvironment(binding, env), ...delivery };
10596
+ const childEnv = { ...await vaultEnvironment(binding, env), ...delivery };
10519
10597
  return new Promise((resolveResult, reject2) => {
10520
10598
  const child = spawn2(executable, args, { env: childEnv, stdio: ["ignore", captureCheck ? "pipe" : "ignore", "ignore"], detached: true, shell: false });
10521
10599
  let failure;
@@ -10646,8 +10724,8 @@ var HELP = `switcher \u2014 launch a coding harness with a provider and its mode
10646
10724
  [--ori-executable PATH] [--state-dir DIR]
10647
10725
  [--timeout SECONDS] -- [native harness arguments]
10648
10726
  switcher runs list|get [ID]
10649
- switcher credentials bind PRESET --vault-key KEY --vault-url URL
10650
- [--vault-cli PATH] [--vault-account ACCOUNT]
10727
+ switcher credentials bind PRESET --vault-key KEY [--vault-url URL]
10728
+ [--vault-cli PATH] [--vault-account ACCOUNT | --vault-operator env]
10651
10729
  switcher credentials bind PRESET --keychain-service SERVICE --keychain-account ACCOUNT
10652
10730
  switcher credentials list|check|remove [PRESET_OR_REFERENCE]
10653
10731
  switcher doctor
@@ -10656,12 +10734,14 @@ HARNESS: claude, codex, grok, opencode, opencode2, pi, omp, dsh, cline, hermes,
10656
10734
  PROTOCOL: anthropic-messages, openai-responses, openai-chat, gemini-generate-content
10657
10735
  Without remote API configuration, the CLI owns a local authenticated API and
10658
10736
  stores data in ~/.hasna/switcher (override HASNA_SWITCHER_HOME).
10659
- Set HASNA_SWITCHER_API_URL + HASNA_SWITCHER_API_KEY for a remote API.
10737
+ Remote API URL/key resolve through @hasna/contracts: overrides, Keychain,
10738
+ ~/.hasna/switcher/config/credentials, then environment. A key alone uses the gateway.
10660
10739
  A configured remote API never falls back to local data.
10661
10740
  Provider credential references must start SWITCHER_PROVIDER_.
10662
10741
  Credential bindings contain references only. Custom destinations require --origin URL.
10663
- Vault bindings use the installed secrets CLI; --vault-account reads its operator
10664
- from macOS Keychain, otherwise HASNA_SECRETS_API_KEY must be injected per process.
10742
+ Vault bindings use the installed secrets CLI and its canonical Contracts URL/key
10743
+ by default. --vault-account pins a Keychain account; --vault-operator env requires
10744
+ per-process HASNA_SECRETS_API_KEY. Explicit operators also require --vault-url.
10665
10745
  --file accepts a JSON object including id; raw credentials are never accepted.
10666
10746
  Fireworks discovery requires --catalog-account-id (or an explicit --catalog-url).
10667
10747
  --json outputs machine-readable records (also the default for data commands).
@@ -10739,6 +10819,7 @@ async function main(args = process.argv.slice(2)) {
10739
10819
  "vault-url": { type: "string" },
10740
10820
  "vault-cli": { type: "string" },
10741
10821
  "vault-account": { type: "string" },
10822
+ "vault-operator": { type: "string" },
10742
10823
  "keychain-service": { type: "string" },
10743
10824
  "keychain-account": { type: "string" },
10744
10825
  origin: { type: "string", multiple: true }
@@ -10753,7 +10834,7 @@ async function main(args = process.argv.slice(2)) {
10753
10834
  throw new Error("Unknown command. Run switcher --help.");
10754
10835
  const providerFlags = ["url", "protocol", "preset", "credential-env", "auth-style", "catalog-url", "catalog-format", "catalog-auth-style", "catalog-credential-env", "catalog-account-id", "models-path"];
10755
10836
  const provided = (names) => names.some((name) => values2[name] !== undefined);
10756
- const credentialFlags = ["vault-key", "vault-url", "vault-cli", "vault-account", "keychain-service", "keychain-account", "origin"];
10837
+ const credentialFlags = ["vault-key", "vault-url", "vault-cli", "vault-account", "vault-operator", "keychain-service", "keychain-account", "origin"];
10757
10838
  const credentials = new CredentialResolver;
10758
10839
  if (command === "credentials") {
10759
10840
  const bindingFlags = [...credentialFlags, "credential-env"];
@@ -10775,16 +10856,20 @@ async function main(args = process.argv.slice(2)) {
10775
10856
  }
10776
10857
  if (action2 !== "bind" || !id2)
10777
10858
  throw new Fault(400, "invalid_request", "Use credentials bind PRESET, list, check PRESET_OR_REFERENCE, or remove PRESET_OR_REFERENCE.");
10778
- const hasVault = provided(["vault-key", "vault-url", "vault-cli", "vault-account"]);
10859
+ const hasVault = provided(["vault-key", "vault-url", "vault-cli", "vault-account", "vault-operator"]);
10779
10860
  const hasKeychain = provided(["keychain-service", "keychain-account"]);
10780
10861
  if (hasVault === hasKeychain)
10781
10862
  throw new Fault(400, "conflicting_options", "Choose one credential source: vault or Keychain.");
10863
+ if (values2["vault-operator"] !== undefined && !["contracts", "env"].includes(values2["vault-operator"]))
10864
+ throw new Fault(400, "invalid_request", "Use --vault-operator contracts or env.");
10865
+ if (values2["vault-account"] !== undefined && values2["vault-operator"] !== undefined)
10866
+ throw new Fault(400, "conflicting_options", "Use either --vault-account or --vault-operator.");
10782
10867
  const source = hasVault ? {
10783
10868
  kind: "vault",
10784
10869
  key: values2["vault-key"],
10785
10870
  url: values2["vault-url"],
10786
10871
  executable: values2["vault-cli"] ?? Bun.which("secrets"),
10787
- operator: values2["vault-account"] ? { kind: "keychain", account: values2["vault-account"] } : { kind: "env" }
10872
+ operator: values2["vault-account"] !== undefined ? { kind: "keychain", account: values2["vault-account"] } : { kind: values2["vault-operator"] ?? "contracts" }
10788
10873
  } : { kind: "keychain", service: values2["keychain-service"], account: values2["keychain-account"] };
10789
10874
  output(await credentials.bindings.bind(parse2(credentialBindingSchema, { schema: 1, ...bindingTarget(id2, values2["credential-env"], values2.origin), source })));
10790
10875
  return;
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { type CredentialChainOptions } from "@hasna/contracts/client";
2
3
  import { CommandInterrupted, type ProviderInput } from "./domain";
3
4
  declare const keychain: z.ZodObject<{
4
5
  kind: z.ZodLiteral<"keychain">;
@@ -13,7 +14,7 @@ declare const keychain: z.ZodObject<{
13
14
  service: string;
14
15
  account: string;
15
16
  }>;
16
- export declare const credentialBindingSchema: z.ZodObject<{
17
+ export declare const credentialBindingSchema: z.ZodEffects<z.ZodObject<{
17
18
  schema: z.ZodLiteral<1>;
18
19
  credentialEnv: z.ZodString;
19
20
  origins: z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">;
@@ -32,9 +33,15 @@ export declare const credentialBindingSchema: z.ZodObject<{
32
33
  }>, z.ZodObject<{
33
34
  kind: z.ZodLiteral<"vault">;
34
35
  key: z.ZodString;
35
- url: z.ZodEffects<z.ZodString, string, string>;
36
+ url: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
36
37
  executable: z.ZodEffects<z.ZodString, string, string>;
37
38
  operator: z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
39
+ kind: z.ZodLiteral<"contracts">;
40
+ }, "strict", z.ZodTypeAny, {
41
+ kind: "contracts";
42
+ }, {
43
+ kind: "contracts";
44
+ }>, z.ZodObject<{
38
45
  kind: z.ZodLiteral<"env">;
39
46
  }, "strict", z.ZodTypeAny, {
40
47
  kind: "env";
@@ -42,7 +49,7 @@ export declare const credentialBindingSchema: z.ZodObject<{
42
49
  kind: "env";
43
50
  }>, z.ZodObject<{
44
51
  kind: z.ZodLiteral<"keychain">;
45
- account: z.ZodString;
52
+ account: z.ZodEffects<z.ZodString, string, string>;
46
53
  }, "strict", z.ZodTypeAny, {
47
54
  kind: "keychain";
48
55
  account: string;
@@ -51,27 +58,31 @@ export declare const credentialBindingSchema: z.ZodObject<{
51
58
  account: string;
52
59
  }>]>;
53
60
  }, "strict", z.ZodTypeAny, {
54
- url: string;
55
61
  key: string;
56
62
  executable: string;
57
63
  kind: "vault";
58
64
  operator: {
65
+ kind: "contracts";
66
+ } | {
59
67
  kind: "env";
60
68
  } | {
61
69
  kind: "keychain";
62
70
  account: string;
63
71
  };
72
+ url?: string | undefined;
64
73
  }, {
65
- url: string;
66
74
  key: string;
67
75
  executable: string;
68
76
  kind: "vault";
69
77
  operator: {
78
+ kind: "contracts";
79
+ } | {
70
80
  kind: "env";
71
81
  } | {
72
82
  kind: "keychain";
73
83
  account: string;
74
84
  };
85
+ url?: string | undefined;
75
86
  }>]>;
76
87
  }, "strict", z.ZodTypeAny, {
77
88
  credentialEnv: string;
@@ -80,16 +91,18 @@ export declare const credentialBindingSchema: z.ZodObject<{
80
91
  service: string;
81
92
  account: string;
82
93
  } | {
83
- url: string;
84
94
  key: string;
85
95
  executable: string;
86
96
  kind: "vault";
87
97
  operator: {
98
+ kind: "contracts";
99
+ } | {
88
100
  kind: "env";
89
101
  } | {
90
102
  kind: "keychain";
91
103
  account: string;
92
104
  };
105
+ url?: string | undefined;
93
106
  };
94
107
  schema: 1;
95
108
  origins: string[];
@@ -100,16 +113,62 @@ export declare const credentialBindingSchema: z.ZodObject<{
100
113
  service: string;
101
114
  account: string;
102
115
  } | {
103
- url: string;
104
116
  key: string;
105
117
  executable: string;
106
118
  kind: "vault";
107
119
  operator: {
120
+ kind: "contracts";
121
+ } | {
108
122
  kind: "env";
109
123
  } | {
110
124
  kind: "keychain";
111
125
  account: string;
112
126
  };
127
+ url?: string | undefined;
128
+ };
129
+ schema: 1;
130
+ origins: string[];
131
+ }>, {
132
+ credentialEnv: string;
133
+ source: {
134
+ kind: "keychain";
135
+ service: string;
136
+ account: string;
137
+ } | {
138
+ key: string;
139
+ executable: string;
140
+ kind: "vault";
141
+ operator: {
142
+ kind: "contracts";
143
+ } | {
144
+ kind: "env";
145
+ } | {
146
+ kind: "keychain";
147
+ account: string;
148
+ };
149
+ url?: string | undefined;
150
+ };
151
+ schema: 1;
152
+ origins: string[];
153
+ }, {
154
+ credentialEnv: string;
155
+ source: {
156
+ kind: "keychain";
157
+ service: string;
158
+ account: string;
159
+ } | {
160
+ key: string;
161
+ executable: string;
162
+ kind: "vault";
163
+ operator: {
164
+ kind: "contracts";
165
+ } | {
166
+ kind: "env";
167
+ } | {
168
+ kind: "keychain";
169
+ account: string;
170
+ };
171
+ url?: string | undefined;
113
172
  };
114
173
  schema: 1;
115
174
  origins: string[];
@@ -130,16 +189,18 @@ export declare class CredentialBindings {
130
189
  service: string;
131
190
  account: string;
132
191
  } | {
133
- url: string;
134
192
  key: string;
135
193
  executable: string;
136
194
  kind: "vault";
137
195
  operator: {
196
+ kind: "contracts";
197
+ } | {
138
198
  kind: "env";
139
199
  } | {
140
200
  kind: "keychain";
141
201
  account: string;
142
202
  };
203
+ url?: string | undefined;
143
204
  };
144
205
  schema: 1;
145
206
  origins: string[];
@@ -173,6 +234,8 @@ export declare class CredentialResolver {
173
234
  export declare class CredentialInterrupted extends CommandInterrupted {
174
235
  constructor(exitCode: number);
175
236
  }
237
+ /** Select an operator through the shared credential seam, then pin that choice. */
238
+ export declare function vaultEnvironment(binding: CredentialBinding, env: NodeJS.ProcessEnv, options?: Pick<CredentialChainOptions, "keychain">): Promise<NodeJS.ProcessEnv>;
176
239
  /** Internal child mode: the key travels only through an authenticated loopback request. */
177
240
  export declare function deliverVaultCredential(): Promise<void>;
178
241
  export {};
package/dist/domain.d.ts CHANGED
@@ -3,7 +3,7 @@ import { type RoutingEvent } from "./model-policy-schema";
3
3
  export { modelPolicySchema, routingEventSchema, routingEventsSchema } from "./model-policy-schema";
4
4
  export type { ModelPolicy, RoutingEvent } from "./model-policy-schema";
5
5
  export type { AuthStyle } from "./auth";
6
- export declare const VERSION = "0.1.3";
6
+ export declare const VERSION = "0.1.4";
7
7
  export declare const harnessSchema: z.ZodEnum<["claude", "codex", "grok", "opencode", "opencode2", "pi", "omp", "dsh", "cline", "hermes", "prime-agent", "gemini", "aider", "kilo"]>;
8
8
  export declare const protocolSchema: z.ZodEnum<["anthropic-messages", "openai-responses", "openai-chat", "gemini-generate-content"]>;
9
9
  export declare const idSchema: z.ZodString;
package/dist/index.js CHANGED
@@ -200,7 +200,7 @@ async function boundedJson(response, maxBytes = MAX_BYTES) {
200
200
  }
201
201
 
202
202
  // src/sdk.ts
203
- import { resolveCredential } from "@hasna/contracts/client";
203
+ import { createClientTransport } from "@hasna/contracts/client";
204
204
 
205
205
  // src/presets.ts
206
206
  var route = (protocol, baseUrl, options = {}) => ({
@@ -361,6 +361,7 @@ function apiError(status, data, apiKey) {
361
361
 
362
362
  class SwitcherClient {
363
363
  options;
364
+ transport;
364
365
  baseUrl;
365
366
  constructor(options) {
366
367
  this.baseUrl = endpoint(options.baseUrl).replace(/\/v1$/, "");
@@ -368,9 +369,50 @@ class SwitcherClient {
368
369
  throw new Error("Switcher API key is required.");
369
370
  this.options = { ...options };
370
371
  }
372
+ static fromEnvironment(env = process.env, options = {}) {
373
+ const shared = createClientTransport("switcher", env, {
374
+ credentials: options.credentials,
375
+ timeoutMs: options.timeoutMs ?? 120000,
376
+ retry: false,
377
+ fetchImpl: async (input, init) => {
378
+ const address = String(input).replace(/\/v1\/(health|ready|version)$/, "/$1");
379
+ const key = new Headers(init?.headers).get("x-api-key") ?? "";
380
+ let response;
381
+ try {
382
+ response = await (options.fetch ?? fetch)(address, init);
383
+ } catch {
384
+ throw new SwitcherError(0, "connection_failed", "Switcher API request failed; check endpoint and service availability.");
385
+ }
386
+ if (response.status === 401 || response.status === 403) {
387
+ await response.body?.cancel().catch(() => {});
388
+ throw new SwitcherError(response.status, "api_error", `Switcher API returned HTTP ${response.status}. Check the configured credential; no alternate identity was selected.`);
389
+ }
390
+ let data;
391
+ try {
392
+ data = await boundedJson(response);
393
+ } catch {
394
+ throw new SwitcherError(response.status, "invalid_response", "Switcher API returned invalid JSON.");
395
+ }
396
+ if (!response.ok)
397
+ throw apiError(response.status, data, key);
398
+ return Response.json(data);
399
+ }
400
+ });
401
+ const client = new SwitcherClient({ baseUrl: shared.resolution.baseUrl, apiKey: () => {
402
+ throw new Error("Shared credential transport was not invoked.");
403
+ } });
404
+ client.transport = shared.client;
405
+ return client;
406
+ }
371
407
  async request(method, path, body, options = {}) {
372
408
  if (!/^\/v1\/[a-zA-Z0-9/?&=._%+-]+$/.test(path) && !["/health", "/ready", "/version"].includes(path) || path.includes(".."))
373
409
  throw new Error("Invalid API path.");
410
+ if (this.transport)
411
+ return this.transport.request(method, path.startsWith("/v1/") ? path.slice(3) : path, body, {
412
+ idempotencyKey: method === "GET" ? undefined : options.idempotencyKey ?? crypto.randomUUID(),
413
+ headers: options.version === undefined ? undefined : { "if-match": String(options.version) },
414
+ retry: false
415
+ });
374
416
  const apiKey = typeof this.options.apiKey === "function" ? this.options.apiKey() : this.options.apiKey;
375
417
  if (!apiKey || /[\r\n]/.test(apiKey))
376
418
  throw new Error("Switcher API key is required.");
@@ -467,11 +509,8 @@ class SwitcherClient {
467
509
  return this.request("PATCH", `/v1/runs/${encodeURIComponent(id)}`, input, { version, idempotencyKey });
468
510
  }
469
511
  }
470
- function clientFromEnv(env = process.env) {
471
- const credential = () => resolveCredential("switcher", Object.fromEntries(Object.entries(env).filter(([name]) => name === "HASNA_SWITCHER_API_KEY")), { keychain: { enabled: false } })?.apiKey ?? "";
472
- if (!env.HASNA_SWITCHER_API_URL || !credential())
473
- throw new Error("Set HASNA_SWITCHER_API_URL and HASNA_SWITCHER_API_KEY; no local database fallback is available.");
474
- return new SwitcherClient({ baseUrl: env.HASNA_SWITCHER_API_URL, apiKey: credential });
512
+ function clientFromEnv(env = process.env, options = {}) {
513
+ return SwitcherClient.fromEnvironment(env, options);
475
514
  }
476
515
  export {
477
516
  providerFromPreset,
package/dist/mcp/index.js CHANGED
@@ -55,7 +55,7 @@ var routingEventSchema = z.object({
55
55
  var routingEventsSchema = z.array(routingEventSchema).max(1000);
56
56
 
57
57
  // src/domain.ts
58
- var VERSION = "0.1.3";
58
+ var VERSION = "0.1.4";
59
59
  var harnessSchema = z2.enum(["claude", "codex", "grok", "opencode", "opencode2", "pi", "omp", "dsh", "cline", "hermes", "prime-agent", "gemini", "aider", "kilo"]);
60
60
  var protocolSchema = z2.enum(["anthropic-messages", "openai-responses", "openai-chat", "gemini-generate-content"]);
61
61
  var idSchema = z2.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/);
@@ -199,7 +199,7 @@ async function boundedJson(response, maxBytes = MAX_BYTES) {
199
199
  }
200
200
 
201
201
  // src/sdk.ts
202
- import { resolveCredential } from "@hasna/contracts/client";
202
+ import { createClientTransport } from "@hasna/contracts/client";
203
203
 
204
204
  // src/presets.ts
205
205
  var route = (protocol, baseUrl, options = {}) => ({
@@ -321,6 +321,7 @@ function apiError(status, data, apiKey) {
321
321
 
322
322
  class SwitcherClient {
323
323
  options;
324
+ transport;
324
325
  baseUrl;
325
326
  constructor(options) {
326
327
  this.baseUrl = endpoint(options.baseUrl).replace(/\/v1$/, "");
@@ -328,9 +329,50 @@ class SwitcherClient {
328
329
  throw new Error("Switcher API key is required.");
329
330
  this.options = { ...options };
330
331
  }
332
+ static fromEnvironment(env = process.env, options = {}) {
333
+ const shared = createClientTransport("switcher", env, {
334
+ credentials: options.credentials,
335
+ timeoutMs: options.timeoutMs ?? 120000,
336
+ retry: false,
337
+ fetchImpl: async (input, init) => {
338
+ const address = String(input).replace(/\/v1\/(health|ready|version)$/, "/$1");
339
+ const key = new Headers(init?.headers).get("x-api-key") ?? "";
340
+ let response;
341
+ try {
342
+ response = await (options.fetch ?? fetch)(address, init);
343
+ } catch {
344
+ throw new SwitcherError(0, "connection_failed", "Switcher API request failed; check endpoint and service availability.");
345
+ }
346
+ if (response.status === 401 || response.status === 403) {
347
+ await response.body?.cancel().catch(() => {});
348
+ throw new SwitcherError(response.status, "api_error", `Switcher API returned HTTP ${response.status}. Check the configured credential; no alternate identity was selected.`);
349
+ }
350
+ let data;
351
+ try {
352
+ data = await boundedJson(response);
353
+ } catch {
354
+ throw new SwitcherError(response.status, "invalid_response", "Switcher API returned invalid JSON.");
355
+ }
356
+ if (!response.ok)
357
+ throw apiError(response.status, data, key);
358
+ return Response.json(data);
359
+ }
360
+ });
361
+ const client = new SwitcherClient({ baseUrl: shared.resolution.baseUrl, apiKey: () => {
362
+ throw new Error("Shared credential transport was not invoked.");
363
+ } });
364
+ client.transport = shared.client;
365
+ return client;
366
+ }
331
367
  async request(method, path, body, options = {}) {
332
368
  if (!/^\/v1\/[a-zA-Z0-9/?&=._%+-]+$/.test(path) && !["/health", "/ready", "/version"].includes(path) || path.includes(".."))
333
369
  throw new Error("Invalid API path.");
370
+ if (this.transport)
371
+ return this.transport.request(method, path.startsWith("/v1/") ? path.slice(3) : path, body, {
372
+ idempotencyKey: method === "GET" ? undefined : options.idempotencyKey ?? crypto.randomUUID(),
373
+ headers: options.version === undefined ? undefined : { "if-match": String(options.version) },
374
+ retry: false
375
+ });
334
376
  const apiKey = typeof this.options.apiKey === "function" ? this.options.apiKey() : this.options.apiKey;
335
377
  if (!apiKey || /[\r\n]/.test(apiKey))
336
378
  throw new Error("Switcher API key is required.");
@@ -427,11 +469,8 @@ class SwitcherClient {
427
469
  return this.request("PATCH", `/v1/runs/${encodeURIComponent(id)}`, input, { version, idempotencyKey });
428
470
  }
429
471
  }
430
- function clientFromEnv(env = process.env) {
431
- const credential = () => resolveCredential("switcher", Object.fromEntries(Object.entries(env).filter(([name]) => name === "HASNA_SWITCHER_API_KEY")), { keychain: { enabled: false } })?.apiKey ?? "";
432
- if (!env.HASNA_SWITCHER_API_URL || !credential())
433
- throw new Error("Set HASNA_SWITCHER_API_URL and HASNA_SWITCHER_API_KEY; no local database fallback is available.");
434
- return new SwitcherClient({ baseUrl: env.HASNA_SWITCHER_API_URL, apiKey: credential });
472
+ function clientFromEnv(env = process.env, options = {}) {
473
+ return SwitcherClient.fromEnvironment(env, options);
435
474
  }
436
475
 
437
476
  // src/mcp.ts
@@ -467,6 +506,6 @@ tool("runs_get", "Get launch metadata.", { id: z3.string() }, (p) => clientFromE
467
506
  if (process.argv.includes("--version"))
468
507
  console.log(VERSION);
469
508
  else if (process.argv.includes("--help"))
470
- console.log("switcher-mcp: authenticated Switcher API tools over MCP stdio. Requires HASNA_SWITCHER_API_URL and HASNA_SWITCHER_API_KEY.");
509
+ console.log("switcher-mcp: authenticated Switcher API tools over MCP stdio. Resolves API URL/key through @hasna/contracts (Keychain, canonical config/credentials, or environment).");
471
510
  else
472
511
  await server.connect(new StdioServerTransport);
package/dist/sdk.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { components } from "./generated/api";
2
+ import { type CredentialChainOptions } from "@hasna/contracts/client";
2
3
  export { providerFromPreset } from "./presets";
3
4
  export type ProviderPreset = components["schemas"]["ProviderPreset"];
4
5
  export type ProviderInput = components["schemas"]["ProviderInput"];
@@ -29,10 +30,18 @@ export type ClientOptions = {
29
30
  fetch?: typeof fetch;
30
31
  timeoutMs?: number;
31
32
  };
33
+ export type EnvironmentClientOptions = {
34
+ credentials?: CredentialChainOptions;
35
+ fetch?: typeof fetch;
36
+ timeoutMs?: number;
37
+ };
32
38
  export declare class SwitcherClient {
33
39
  private readonly options;
40
+ private transport?;
34
41
  readonly baseUrl: string;
35
42
  constructor(options: ClientOptions);
43
+ /** Use Contracts' paired authority/credential resolution on every request. */
44
+ static fromEnvironment(env?: Record<string, string | undefined>, options?: EnvironmentClientOptions): SwitcherClient;
36
45
  request<T>(method: string, path: string, body?: unknown, options?: {
37
46
  version?: number;
38
47
  idempotencyKey?: string;
@@ -617,4 +626,4 @@ export declare class SwitcherClient {
617
626
  routingEventsDropped?: number;
618
627
  }>;
619
628
  }
620
- export declare function clientFromEnv(env?: Record<string, string | undefined>): SwitcherClient;
629
+ export declare function clientFromEnv(env?: Record<string, string | undefined>, options?: EnvironmentClientOptions): SwitcherClient;
package/dist/sdk.js CHANGED
@@ -200,7 +200,7 @@ async function boundedJson(response, maxBytes = MAX_BYTES) {
200
200
  }
201
201
 
202
202
  // src/sdk.ts
203
- import { resolveCredential } from "@hasna/contracts/client";
203
+ import { createClientTransport } from "@hasna/contracts/client";
204
204
 
205
205
  // src/presets.ts
206
206
  var route = (protocol, baseUrl, options = {}) => ({
@@ -361,6 +361,7 @@ function apiError(status, data, apiKey) {
361
361
 
362
362
  class SwitcherClient {
363
363
  options;
364
+ transport;
364
365
  baseUrl;
365
366
  constructor(options) {
366
367
  this.baseUrl = endpoint(options.baseUrl).replace(/\/v1$/, "");
@@ -368,9 +369,50 @@ class SwitcherClient {
368
369
  throw new Error("Switcher API key is required.");
369
370
  this.options = { ...options };
370
371
  }
372
+ static fromEnvironment(env = process.env, options = {}) {
373
+ const shared = createClientTransport("switcher", env, {
374
+ credentials: options.credentials,
375
+ timeoutMs: options.timeoutMs ?? 120000,
376
+ retry: false,
377
+ fetchImpl: async (input, init) => {
378
+ const address = String(input).replace(/\/v1\/(health|ready|version)$/, "/$1");
379
+ const key = new Headers(init?.headers).get("x-api-key") ?? "";
380
+ let response;
381
+ try {
382
+ response = await (options.fetch ?? fetch)(address, init);
383
+ } catch {
384
+ throw new SwitcherError(0, "connection_failed", "Switcher API request failed; check endpoint and service availability.");
385
+ }
386
+ if (response.status === 401 || response.status === 403) {
387
+ await response.body?.cancel().catch(() => {});
388
+ throw new SwitcherError(response.status, "api_error", `Switcher API returned HTTP ${response.status}. Check the configured credential; no alternate identity was selected.`);
389
+ }
390
+ let data;
391
+ try {
392
+ data = await boundedJson(response);
393
+ } catch {
394
+ throw new SwitcherError(response.status, "invalid_response", "Switcher API returned invalid JSON.");
395
+ }
396
+ if (!response.ok)
397
+ throw apiError(response.status, data, key);
398
+ return Response.json(data);
399
+ }
400
+ });
401
+ const client = new SwitcherClient({ baseUrl: shared.resolution.baseUrl, apiKey: () => {
402
+ throw new Error("Shared credential transport was not invoked.");
403
+ } });
404
+ client.transport = shared.client;
405
+ return client;
406
+ }
371
407
  async request(method, path, body, options = {}) {
372
408
  if (!/^\/v1\/[a-zA-Z0-9/?&=._%+-]+$/.test(path) && !["/health", "/ready", "/version"].includes(path) || path.includes(".."))
373
409
  throw new Error("Invalid API path.");
410
+ if (this.transport)
411
+ return this.transport.request(method, path.startsWith("/v1/") ? path.slice(3) : path, body, {
412
+ idempotencyKey: method === "GET" ? undefined : options.idempotencyKey ?? crypto.randomUUID(),
413
+ headers: options.version === undefined ? undefined : { "if-match": String(options.version) },
414
+ retry: false
415
+ });
374
416
  const apiKey = typeof this.options.apiKey === "function" ? this.options.apiKey() : this.options.apiKey;
375
417
  if (!apiKey || /[\r\n]/.test(apiKey))
376
418
  throw new Error("Switcher API key is required.");
@@ -467,11 +509,8 @@ class SwitcherClient {
467
509
  return this.request("PATCH", `/v1/runs/${encodeURIComponent(id)}`, input, { version, idempotencyKey });
468
510
  }
469
511
  }
470
- function clientFromEnv(env = process.env) {
471
- const credential = () => resolveCredential("switcher", Object.fromEntries(Object.entries(env).filter(([name]) => name === "HASNA_SWITCHER_API_KEY")), { keychain: { enabled: false } })?.apiKey ?? "";
472
- if (!env.HASNA_SWITCHER_API_URL || !credential())
473
- throw new Error("Set HASNA_SWITCHER_API_URL and HASNA_SWITCHER_API_KEY; no local database fallback is available.");
474
- return new SwitcherClient({ baseUrl: env.HASNA_SWITCHER_API_URL, apiKey: credential });
512
+ function clientFromEnv(env = process.env, options = {}) {
513
+ return SwitcherClient.fromEnvironment(env, options);
475
514
  }
476
515
  export {
477
516
  providerFromPreset,
@@ -62,7 +62,7 @@ function canonicalPolicyJSON(value) {
62
62
  }
63
63
 
64
64
  // src/domain.ts
65
- var VERSION = "0.1.3";
65
+ var VERSION = "0.1.4";
66
66
  var harnessSchema = z2.enum(["claude", "codex", "grok", "opencode", "opencode2", "pi", "omp", "dsh", "cline", "hermes", "prime-agent", "gemini", "aider", "kilo"]);
67
67
  var protocolSchema = z2.enum(["anthropic-messages", "openai-responses", "openai-chat", "gemini-generate-content"]);
68
68
  var idSchema = z2.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/);
@@ -808,7 +808,7 @@ var openapi_default = {
808
808
  openapi: "3.0.3",
809
809
  info: {
810
810
  title: "Switcher API",
811
- version: "0.1.3",
811
+ version: "0.1.4",
812
812
  description: "Authenticated provider/profile/catalog control plane. Launches run locally; the API never returns provider credentials."
813
813
  },
814
814
  security: [
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.0.3",
3
3
  "info": {
4
4
  "title": "Switcher API",
5
- "version": "0.1.3",
5
+ "version": "0.1.4",
6
6
  "description": "Authenticated provider/profile/catalog control plane. Launches run locally; the API never returns provider credentials."
7
7
  },
8
8
  "security": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/switcher",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Universal provider and model switcher for coding harnesses with an API, SDK and self-hosted storage",
6
6
  "license": "Apache-2.0",