@rynfar/meridian 1.27.6 → 1.29.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
@@ -61,8 +61,70 @@ Meridian bridges that gap. It runs locally, accepts standard Anthropic API reque
61
61
  - **Auto token refresh** — expired OAuth tokens are refreshed automatically; requests continue without interruption
62
62
  - **Passthrough mode** — forward tool calls to the client instead of executing internally
63
63
  - **Multimodal** — images, documents, and file attachments pass through to Claude
64
+ - **Multi-profile** — switch between Claude accounts instantly, no restart needed
64
65
  - **Telemetry dashboard** — real-time performance metrics at `/telemetry`
65
66
 
67
+ ## Multi-Profile Support
68
+
69
+ Meridian can route requests to different Claude accounts. Each **profile** is a named auth context — a separate Claude login with its own OAuth tokens. Switch between personal and work accounts, or share a single Meridian instance across teams.
70
+
71
+ ### Adding profiles
72
+
73
+ ```bash
74
+ # Add your personal account
75
+ meridian profile add personal
76
+ # → Opens browser for Claude login
77
+
78
+ # Add your work account (sign out of claude.ai first, then sign into the work account)
79
+ meridian profile add work
80
+ ```
81
+
82
+ > **⚠ 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.
83
+
84
+ ### Switching profiles
85
+
86
+ ```bash
87
+ # CLI (while proxy is running)
88
+ meridian profile switch work
89
+
90
+ # Per-request header (any agent)
91
+ curl -H "x-meridian-profile: work" ...
92
+ ```
93
+
94
+ You can also switch profiles from the web UI at `http://127.0.0.1:3456/profiles` — a dropdown appears in the nav bar on all pages when profiles are configured.
95
+
96
+ ### Profile commands
97
+
98
+ | Command | Description |
99
+ |---------|-------------|
100
+ | `meridian profile add <name>` | Add a profile and authenticate via browser |
101
+ | `meridian profile list` | List profiles and auth status |
102
+ | `meridian profile switch <name>` | Switch the active profile (requires running proxy) |
103
+ | `meridian profile login <name>` | Re-authenticate an expired profile |
104
+ | `meridian profile remove <name>` | Remove a profile and its credentials |
105
+
106
+ ### How it works
107
+
108
+ 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:
109
+
110
+ 1. `x-meridian-profile` request header (per-request override)
111
+ 2. Active profile (set via `meridian profile switch` or the web UI)
112
+ 3. First configured profile
113
+
114
+ Session state is scoped per profile — switching accounts won't cross-contaminate conversation history.
115
+
116
+ ### Environment variable configuration
117
+
118
+ For advanced setups (CI, Docker), profiles can also be provided via environment variable:
119
+
120
+ ```bash
121
+ export MERIDIAN_PROFILES='[{"id":"personal","claudeConfigDir":"/path/to/config1"},{"id":"work","claudeConfigDir":"/path/to/config2"}]'
122
+ export MERIDIAN_DEFAULT_PROFILE=personal
123
+ meridian
124
+ ```
125
+
126
+ 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.
127
+
66
128
  ## Agent Setup
67
129
 
68
130
  ### OpenCode
@@ -282,8 +344,14 @@ src/proxy/
282
344
  │ ├── lineage.ts ← Per-message hashing, mutation classification (pure)
283
345
  │ ├── fingerprint.ts ← Conversation fingerprinting
284
346
  │ └── cache.ts ← LRU session caches
347
+ ├── profiles.ts ← Multi-profile: resolve, list, switch auth contexts
348
+ ├── profileCli.ts ← CLI commands for profile management
285
349
  ├── sessionStore.ts ← Cross-proxy file-based session persistence
286
350
  └── passthroughTools.ts ← Tool forwarding mode
351
+ telemetry/
352
+ ├── ...
353
+ ├── profileBar.ts ← Shared profile switcher bar
354
+ └── profilePage.ts ← Profile management page
287
355
  plugin/
288
356
  └── meridian.ts ← OpenCode plugin (session headers + agent mode)
289
357
  ```
@@ -333,6 +401,8 @@ Implement the `AgentAdapter` interface in `src/proxy/adapters/`. See [`adapters/
333
401
  | `MERIDIAN_NO_FILE_CHANGES` | `CLAUDE_PROXY_NO_FILE_CHANGES` | unset | Disable "Files changed" summary in responses |
334
402
  | `MERIDIAN_SONNET_MODEL` | `CLAUDE_PROXY_SONNET_MODEL` | `sonnet` | Sonnet context tier: `sonnet` (200k, default) or `sonnet[1m]` (1M, requires Extra Usage†) |
335
403
  | `MERIDIAN_DEFAULT_AGENT` | — | `opencode` | Default adapter for unrecognized agents: `opencode`, `pi`, `crush`, `droid`, `passthrough`. Requires restart. |
404
+ | `MERIDIAN_PROFILES` | — | unset | JSON array of profile configs (overrides disk discovery). See [Multi-Profile Support](#multi-profile-support). |
405
+ | `MERIDIAN_DEFAULT_PROFILE` | — | *(first profile)* | Default profile ID when no header is sent |
336
406
 
337
407
  †Sonnet 1M requires Extra Usage on all plans including Max ([docs](https://code.claude.com/docs/en/model-config#extended-context)). Opus 1M is included with Max/Team/Enterprise at no extra cost.
338
408
 
@@ -351,6 +421,9 @@ Implement the `AgentAdapter` interface in `src/proxy/adapters/`. See [`adapters/
351
421
  | `GET /telemetry/requests` | Recent request metrics (JSON) |
352
422
  | `GET /telemetry/summary` | Aggregate statistics (JSON) |
353
423
  | `GET /telemetry/logs` | Diagnostic logs (JSON) |
424
+ | `GET /profiles` | Profile management page |
425
+ | `GET /profiles/list` | List profiles with auth status (JSON) |
426
+ | `POST /profiles/active` | Switch the active profile |
354
427
 
355
428
  Health response example:
356
429
 
@@ -371,6 +444,11 @@ Health response example:
371
444
  |---------|-------------|
372
445
  | `meridian` | Start the proxy server |
373
446
  | `meridian setup` | Configure the OpenCode plugin in `~/.config/opencode/opencode.json` |
447
+ | `meridian profile add <name>` | Add a profile and authenticate via browser |
448
+ | `meridian profile list` | List all profiles and their auth status |
449
+ | `meridian profile switch <name>` | Switch the active profile (requires running proxy) |
450
+ | `meridian profile login <name>` | Re-authenticate an expired profile |
451
+ | `meridian profile remove <name>` | Remove a profile and its credentials |
374
452
  | `meridian refresh-token` | Manually refresh the Claude OAuth token (exits 0/1) |
375
453
 
376
454
  ## Programmatic API
@@ -0,0 +1,33 @@
1
+ // src/proxy/settings.ts
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { homedir } from "node:os";
5
+ var SETTINGS_FILE = join(homedir(), ".config", "meridian", "settings.json");
6
+ function loadSettings() {
7
+ try {
8
+ if (!existsSync(SETTINGS_FILE))
9
+ return {};
10
+ return JSON.parse(readFileSync(SETTINGS_FILE, "utf-8"));
11
+ } catch {
12
+ return {};
13
+ }
14
+ }
15
+ function saveSettings(updates) {
16
+ const current = loadSettings();
17
+ const merged = { ...current, ...updates };
18
+ try {
19
+ mkdirSync(dirname(SETTINGS_FILE), { recursive: true });
20
+ writeFileSync(SETTINGS_FILE, JSON.stringify(merged, null, 2) + `
21
+ `, { mode: 384 });
22
+ } catch (err) {
23
+ console.warn(`[meridian] Failed to write ${SETTINGS_FILE}: ${err instanceof Error ? err.message : err}`);
24
+ }
25
+ }
26
+ function getSetting(key) {
27
+ return loadSettings()[key];
28
+ }
29
+ function setSetting(key, value) {
30
+ saveSettings({ [key]: value });
31
+ }
32
+
33
+ export { getSetting, setSetting };
@@ -0,0 +1,113 @@
1
+ // src/telemetry/profileBar.ts
2
+ var profileBarCss = `
3
+ .meridian-profile-bar {
4
+ position: sticky; top: 0; z-index: 100;
5
+ display: none; align-items: center; gap: 12px;
6
+ padding: 8px 24px;
7
+ background: rgba(13, 17, 23, 0.92);
8
+ backdrop-filter: blur(12px);
9
+ border-bottom: 1px solid var(--border, #30363d);
10
+ font-size: 12px;
11
+ color: var(--muted, #8b949e);
12
+ }
13
+ .meridian-profile-bar.visible { display: flex; }
14
+ .meridian-profile-bar .profile-label {
15
+ font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px;
16
+ font-size: 10px; color: var(--muted, #8b949e);
17
+ }
18
+ .meridian-profile-bar select {
19
+ background: var(--surface, #161b22); color: var(--text, #e6edf3);
20
+ border: 1px solid var(--border, #30363d); border-radius: 6px;
21
+ padding: 4px 24px 4px 10px; font-size: 12px; cursor: pointer;
22
+ appearance: none;
23
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' fill='none' stroke='%238b949e' stroke-width='1.5'/%3E%3C/svg%3E");
24
+ background-repeat: no-repeat; background-position: right 6px center;
25
+ }
26
+ .meridian-profile-bar select:hover { border-color: var(--accent, #58a6ff); }
27
+ .meridian-profile-bar select:focus { outline: none; border-color: var(--accent, #58a6ff); box-shadow: 0 0 0 1px var(--accent, #58a6ff); }
28
+ .meridian-profile-bar .profile-status {
29
+ font-size: 11px; color: var(--green, #3fb950); opacity: 0;
30
+ transition: opacity 0.3s;
31
+ }
32
+ .meridian-profile-bar .profile-status.show { opacity: 1; }
33
+ .meridian-profile-bar .profile-type {
34
+ font-size: 10px; padding: 2px 8px; border-radius: 4px;
35
+ background: var(--surface, #161b22); border: 1px solid var(--border, #30363d);
36
+ }
37
+ .meridian-profile-bar .spacer { flex: 1; }
38
+ .meridian-profile-bar .profile-nav a {
39
+ color: var(--muted, #8b949e); text-decoration: none; font-size: 11px;
40
+ padding: 4px 8px; border-radius: 4px; transition: color 0.15s;
41
+ }
42
+ .meridian-profile-bar .profile-nav a:hover { color: var(--text, #e6edf3); }
43
+ .meridian-profile-bar .profile-nav a.active { color: var(--accent, #58a6ff); }
44
+ `;
45
+ var profileBarHtml = `
46
+ <div class="meridian-profile-bar" id="meridianProfileBar">
47
+ <span class="profile-label">Profile</span>
48
+ <select id="meridianProfileSelect"></select>
49
+ <span class="profile-type" id="meridianProfileType"></span>
50
+ <span class="profile-status" id="meridianProfileStatus">✓ Switched</span>
51
+ <div class="spacer"></div>
52
+ <div class="profile-nav">
53
+ <a href="/" id="nav-home">Home</a>
54
+ <a href="/profiles" id="nav-profiles">Profiles</a>
55
+ <a href="/telemetry" id="nav-telemetry">Telemetry</a>
56
+ </div>
57
+ </div>
58
+ `;
59
+ var profileBarJs = `
60
+ (function() {
61
+ var profileBar = document.getElementById('meridianProfileBar');
62
+ var profileSelect = document.getElementById('meridianProfileSelect');
63
+ var profileType = document.getElementById('meridianProfileType');
64
+ var profileStatus = document.getElementById('meridianProfileStatus');
65
+ var statusTimeout;
66
+
67
+ // Highlight active nav link
68
+ var path = location.pathname;
69
+ var navLinks = document.querySelectorAll('.profile-nav a');
70
+ navLinks.forEach(function(a) {
71
+ if (a.getAttribute('href') === path || (path === '/telemetry' && a.id === 'nav-telemetry') || (path === '/' && a.id === 'nav-home')) {
72
+ a.classList.add('active');
73
+ }
74
+ });
75
+
76
+ function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
77
+
78
+ function loadProfiles() {
79
+ fetch('/profiles/list').then(function(r) { return r.json(); }).then(function(data) {
80
+ if (!data.profiles || data.profiles.length === 0) {
81
+ profileBar.classList.remove('visible');
82
+ return;
83
+ }
84
+ profileBar.classList.add('visible');
85
+ var current = data.profiles.find(function(p) { return p.isActive; });
86
+ profileSelect.innerHTML = data.profiles.map(function(p) {
87
+ return '<option value="' + esc(p.id) + '"' + (p.isActive ? ' selected' : '') + '>' + esc(p.id) + '</option>';
88
+ }).join('');
89
+ if (current) profileType.textContent = current.type;
90
+ }).catch(function() {});
91
+ }
92
+
93
+ profileSelect.onchange = function() {
94
+ fetch('/profiles/active', {
95
+ method: 'POST',
96
+ headers: { 'Content-Type': 'application/json' },
97
+ body: JSON.stringify({ profile: profileSelect.value })
98
+ }).then(function(r) { return r.json(); }).then(function(data) {
99
+ if (data.success) {
100
+ profileStatus.classList.add('show');
101
+ clearTimeout(statusTimeout);
102
+ statusTimeout = setTimeout(function() { profileStatus.classList.remove('show'); }, 2000);
103
+ loadProfiles();
104
+ }
105
+ }).catch(function() {});
106
+ };
107
+
108
+ loadProfiles();
109
+ setInterval(loadProfiles, 10000);
110
+ })();
111
+ `;
112
+
113
+ export { profileBarCss, profileBarHtml, profileBarJs };