@rynfar/meridian 1.41.0 → 1.42.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
@@ -239,6 +239,20 @@ meridian profile add work
239
239
 
240
240
  > **⚠ Important:** Claude's OAuth reuses your browser session. Before adding a second account, sign out of claude.ai and sign into the other account first.
241
241
 
242
+ #### Headless / CI: register an OAuth token
243
+
244
+ When a browser isn't available (containers, CI runners, remote shells), generate a long-lived OAuth token with `claude setup-token` and register it as a profile:
245
+
246
+ ```bash
247
+ # Prompt for the token (input is hidden — paste the value from `claude setup-token`)
248
+ meridian profile add ci --oauth-token
249
+
250
+ # Or pass it inline
251
+ meridian profile add ci --oauth-token sk-ant-oat01-...
252
+ ```
253
+
254
+ OAuth-token profiles store the token in `profiles.json` and feed it to the SDK via `CLAUDE_CODE_OAUTH_TOKEN` — no Keychain entry, no browser handshake. To prevent the SDK's 401-recovery from silently falling back to the host's `~/.claude` credentials, OAuth-token profiles also pin `CLAUDE_CONFIG_DIR` to an isolated per-profile directory under `~/.config/meridian/profiles/<name>/`. That directory holds only SDK state (sessions, settings) — never `.credentials.json`, since the token is delivered through the env.
255
+
242
256
  ### Switching profiles
243
257
 
244
258
  ```bash
@@ -256,14 +270,15 @@ You can also switch profiles from the web UI at `http://127.0.0.1:3456/profiles`
256
270
  | Command | Description |
257
271
  |---------|-------------|
258
272
  | `meridian profile add <name>` | Add a profile and authenticate via browser |
273
+ | `meridian profile add <name> --oauth-token [TOKEN]` | Add a headless profile from a `claude setup-token` value (prompts when `TOKEN` is omitted) |
259
274
  | `meridian profile list` | List profiles and auth status |
260
275
  | `meridian profile switch <name>` | Switch the active profile (requires running proxy) |
261
- | `meridian profile login <name>` | Re-authenticate an expired profile |
276
+ | `meridian profile login <name>` | Re-authenticate an expired profile (browser-login profiles only) |
262
277
  | `meridian profile remove <name>` | Remove a profile and its credentials |
263
278
 
264
279
  ### How it works
265
280
 
266
- Each profile stores its credentials in an isolated `CLAUDE_CONFIG_DIR` under `~/.config/meridian/profiles/<name>/`. When a request arrives, Meridian resolves the profile in priority order:
281
+ Each profile stores its credentials in an isolated `CLAUDE_CONFIG_DIR` under `~/.config/meridian/profiles/<name>/`. OAuth-token profiles use the same isolated directory layout — but the token itself lives in `~/.config/meridian/profiles.json` and is fed to the SDK via `CLAUDE_CODE_OAUTH_TOKEN`, so the per-profile dir holds only SDK state (sessions, settings) and never the credential. When a request arrives, Meridian resolves the profile in priority order:
267
282
 
268
283
  1. `x-meridian-profile` request header (per-request override)
269
284
  2. Active profile (set via `meridian profile switch` or the web UI)
@@ -276,11 +291,21 @@ Session state is scoped per profile — switching accounts won't cross-contamina
276
291
  For advanced setups (CI, Docker), profiles can also be provided via environment variable:
277
292
 
278
293
  ```bash
279
- export MERIDIAN_PROFILES='[{"id":"personal","claudeConfigDir":"/path/to/config1"},{"id":"work","claudeConfigDir":"/path/to/config2"}]'
294
+ export MERIDIAN_PROFILES='[
295
+ {"id":"personal","claudeConfigDir":"/path/to/config1"},
296
+ {"id":"work","claudeConfigDir":"/path/to/config2"},
297
+ {"id":"ci","oauthToken":"sk-ant-oat01-..."}
298
+ ]'
280
299
  export MERIDIAN_DEFAULT_PROFILE=personal
281
300
  meridian
282
301
  ```
283
302
 
303
+ Profile shapes:
304
+
305
+ - `claudeConfigDir` — points at a `~/.claude`-style directory; uses Claude Max OAuth from that dir
306
+ - `apiKey` (with optional `baseUrl`) — direct Anthropic API access; sets `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`
307
+ - `oauthToken` — long-lived token from `claude setup-token`; sets `CLAUDE_CODE_OAUTH_TOKEN`, no config dir needed
308
+
284
309
  When `MERIDIAN_PROFILES` is set, it takes precedence over disk-configured profiles. When unset, Meridian auto-discovers profiles from `~/.config/meridian/profiles.json` on each request.
285
310
 
286
311
  ## Agent Setup
@@ -710,9 +735,10 @@ export default {
710
735
  | `meridian` | Start the proxy server |
711
736
  | `meridian setup` | Configure the OpenCode plugin in `~/.config/opencode/opencode.json` |
712
737
  | `meridian profile add <name>` | Add a profile and authenticate via browser |
738
+ | `meridian profile add <name> --oauth-token [TOKEN]` | Add a headless profile from a `claude setup-token` value (prompts when `TOKEN` is omitted) |
713
739
  | `meridian profile list` | List all profiles and their auth status |
714
740
  | `meridian profile switch <name>` | Switch the active profile (requires running proxy) |
715
- | `meridian profile login <name>` | Re-authenticate an expired profile |
741
+ | `meridian profile login <name>` | Re-authenticate an expired profile (browser-login profiles only) |
716
742
  | `meridian profile remove <name>` | Remove a profile and its credentials |
717
743
  | `meridian refresh-token` | Manually refresh the Claude OAuth token (exits 0/1) |
718
744
 
@@ -767,6 +793,19 @@ docker run \
767
793
 
768
794
  Switch profiles at runtime via the `x-meridian-profile` header or `meridian profile switch` (see [Multi-Profile Support](#multi-profile-support)).
769
795
 
796
+ ### OAuth-token profiles in Docker (no volume mount)
797
+
798
+ If you'd rather not mount a credential directory, generate a long-lived OAuth token on the host with `claude setup-token` and pass it as a profile. There's nothing to mount — the token alone is the credential:
799
+
800
+ ```bash
801
+ docker run \
802
+ -e 'MERIDIAN_PROFILES=[{"id":"ci","oauthToken":"sk-ant-oat01-..."}]' \
803
+ -e MERIDIAN_DEFAULT_PROFILE=ci \
804
+ -p 3456:3456 meridian
805
+ ```
806
+
807
+ This is the recommended path for CI runners, ephemeral containers, and cross-host deployments where browser-based login isn't reachable. Treat the token like any other secret — inject it via your platform's secret store rather than committing it to your image or compose file.
808
+
770
809
  ## Testing
771
810
 
772
811
  ```bash
@@ -85,6 +85,9 @@ function configDirToKeychainService(claudeConfigDir) {
85
85
  function configDirToCredentialsFile(claudeConfigDir) {
86
86
  return join(resolve(claudeConfigDir), ".credentials.json");
87
87
  }
88
+ function serializeCredentials(credentials) {
89
+ return JSON.stringify(credentials);
90
+ }
88
91
  function parseKeychainValue(raw) {
89
92
  const trimmed = raw.trim();
90
93
  try {
@@ -113,7 +116,7 @@ function buildMacosStore(serviceName) {
113
116
  }
114
117
  },
115
118
  async write(credentials) {
116
- const json = JSON.stringify(credentials, null, 2);
119
+ const json = serializeCredentials(credentials);
117
120
  const wasHex = keychainWasHexByService.get(serviceName) ?? false;
118
121
  const value = wasHex ? Buffer.from(json).toString("hex") : json;
119
122
  try {
@@ -142,7 +145,7 @@ function buildFileStore(filePath) {
142
145
  async write(credentials) {
143
146
  try {
144
147
  mkdirSync(dirname(filePath), { recursive: true });
145
- writeFileSync(filePath, JSON.stringify(credentials, null, 2), "utf-8");
148
+ writeFileSync(filePath, serializeCredentials(credentials), "utf-8");
146
149
  return true;
147
150
  } catch (err) {
148
151
  claudeLog("token_refresh.file_write_failed", { path: filePath, error: String(err) });
@@ -226,8 +229,71 @@ async function doRefresh(store) {
226
229
  claudeLog("token_refresh.success", { expiresAt });
227
230
  return true;
228
231
  }
232
+ async function ensureFreshToken(store, bufferMs = 5 * 60 * 1000) {
233
+ const s = store ?? createPlatformCredentialStore();
234
+ const credentials = await s.read();
235
+ const expiresAt = credentials?.claudeAiOauth?.expiresAt;
236
+ if (!expiresAt)
237
+ return false;
238
+ if (expiresAt - Date.now() > bufferMs)
239
+ return true;
240
+ return refreshOAuthToken(s);
241
+ }
242
+ var scheduledRefreshTimer = null;
243
+ var scheduledRefreshActive = false;
244
+ var scheduledRefreshGeneration = 0;
245
+ function startBackgroundRefresh(store, bufferMs = 5 * 60 * 1000, failureRetryMs = 5 * 60 * 1000) {
246
+ if (scheduledRefreshActive)
247
+ return;
248
+ scheduledRefreshActive = true;
249
+ const gen = ++scheduledRefreshGeneration;
250
+ scheduleNext(store ?? createPlatformCredentialStore(), bufferMs, failureRetryMs, gen);
251
+ }
252
+ function stopBackgroundRefresh() {
253
+ scheduledRefreshActive = false;
254
+ scheduledRefreshGeneration++;
255
+ if (scheduledRefreshTimer)
256
+ clearTimeout(scheduledRefreshTimer);
257
+ scheduledRefreshTimer = null;
258
+ }
259
+ async function scheduleNext(store, bufferMs, failureRetryMs, gen) {
260
+ if (!scheduledRefreshActive || gen !== scheduledRefreshGeneration)
261
+ return;
262
+ const credentials = await store.read().catch(() => null);
263
+ if (!scheduledRefreshActive || gen !== scheduledRefreshGeneration)
264
+ return;
265
+ const expiresAt = credentials?.claudeAiOauth?.expiresAt;
266
+ if (!expiresAt) {
267
+ armTimer(failureRetryMs, store, bufferMs, failureRetryMs, gen);
268
+ return;
269
+ }
270
+ const dueIn = expiresAt - Date.now() - bufferMs;
271
+ if (dueIn <= 0) {
272
+ const ok = await refreshOAuthToken(store);
273
+ if (!scheduledRefreshActive || gen !== scheduledRefreshGeneration)
274
+ return;
275
+ claudeLog("token_refresh.scheduled", { ok, immediate: true });
276
+ console.error(`[token_refresh] scheduled refresh (immediate) ok=${ok}`);
277
+ armTimer(ok ? 0 : failureRetryMs, store, bufferMs, failureRetryMs, gen);
278
+ return;
279
+ }
280
+ armTimer(dueIn, store, bufferMs, failureRetryMs, gen);
281
+ }
282
+ function armTimer(delayMs, store, bufferMs, failureRetryMs, gen) {
283
+ scheduledRefreshTimer = setTimeout(async () => {
284
+ if (!scheduledRefreshActive || gen !== scheduledRefreshGeneration)
285
+ return;
286
+ scheduleNext(store, bufferMs, failureRetryMs, gen);
287
+ }, delayMs);
288
+ if (scheduledRefreshTimer && scheduledRefreshTimer.unref) {
289
+ scheduledRefreshTimer.unref();
290
+ }
291
+ }
292
+ function isBackgroundRefreshActive() {
293
+ return scheduledRefreshActive;
294
+ }
229
295
  function resetInflightRefresh() {
230
296
  inflightRefresh = null;
231
297
  }
232
298
 
233
- export { withClaudeLogContext, claudeLog, configDirToKeychainService, configDirToCredentialsFile, createPlatformCredentialStore, credentialsFilePathForProfile, refreshOAuthToken, resetInflightRefresh };
299
+ export { withClaudeLogContext, claudeLog, configDirToKeychainService, configDirToCredentialsFile, serializeCredentials, createPlatformCredentialStore, credentialsFilePathForProfile, refreshOAuthToken, ensureFreshToken, startBackgroundRefresh, stopBackgroundRefresh, isBackgroundRefreshActive, resetInflightRefresh };
@@ -86,6 +86,14 @@ function resolveProfile(profiles, defaultProfile, requestedId) {
86
86
  return buildResolvedProfile(profile);
87
87
  }
88
88
  function buildResolvedProfile(profile) {
89
+ if (profile.oauthToken || profile.type === "oauth-token") {
90
+ const env2 = {};
91
+ if (profile.oauthToken) {
92
+ env2.CLAUDE_CODE_OAUTH_TOKEN = profile.oauthToken;
93
+ env2.CLAUDE_CONFIG_DIR = join(homedir(), ".config", "meridian", "profiles", profile.id);
94
+ }
95
+ return { id: profile.id, type: "oauth-token", env: env2 };
96
+ }
89
97
  const type = profile.type ?? "claude-max";
90
98
  if (type === "api") {
91
99
  const env2 = {};