Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,20 @@ meridian profile add work

> **⚠ 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.

#### Headless / SSH: complete Claude OAuth with a pasted code

When you still want a normal Claude Max browser-login profile but the Meridian host cannot open a browser (SSH, WSL, containers, remote servers), use `--headless`. Meridian prints a Claude OAuth URL, prompts for the returned code, exchanges it with PKCE, and saves the resulting credentials into the profile's isolated `CLAUDE_CONFIG_DIR`:

```bash
meridian profile add work --headless
```

Open the printed URL in a browser, sign in to the target Claude account, then paste the returned code at Meridian's `Paste code:` prompt. For an existing browser-login profile:

```bash
meridian profile login work --headless
```

#### Headless / CI: register an OAuth token

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:
Expand Down Expand Up @@ -269,11 +283,11 @@ You can also switch profiles from the web UI at `http://127.0.0.1:3456/profiles`

| Command | Description |
|---------|-------------|
| `meridian profile add <name>` | Add a profile and authenticate via browser |
| `meridian profile add <name> [--headless]` | Add a profile and authenticate via Claude OAuth; `--headless` prints a URL, prompts for the returned code, and stores the exchanged credentials |
| `meridian profile add <name> --oauth-token [TOKEN]` | Add a headless profile from a `claude setup-token` value (prompts when `TOKEN` is omitted) |
| `meridian profile list` | List profiles and auth status |
| `meridian profile switch <name>` | Switch the active profile (requires running proxy) |
| `meridian profile login <name>` | Re-authenticate an expired profile (browser-login profiles only) |
| `meridian profile login <name> [--headless]` | Re-authenticate an expired profile (browser-login profiles only); `--headless` uses the URL/code flow |
| `meridian profile remove <name>` | Remove a profile and its credentials |

### How it works
Expand Down Expand Up @@ -734,11 +748,11 @@ export default {
|---------|-------------|
| `meridian` | Start the proxy server |
| `meridian setup` | Configure the OpenCode plugin in `~/.config/opencode/opencode.json` |
| `meridian profile add <name>` | Add a profile and authenticate via browser |
| `meridian profile add <name> [--headless]` | Add a profile and authenticate via Claude OAuth; `--headless` prints a URL, prompts for the returned code, and stores the exchanged credentials |
| `meridian profile add <name> --oauth-token [TOKEN]` | Add a headless profile from a `claude setup-token` value (prompts when `TOKEN` is omitted) |
| `meridian profile list` | List all profiles and their auth status |
| `meridian profile switch <name>` | Switch the active profile (requires running proxy) |
| `meridian profile login <name>` | Re-authenticate an expired profile (browser-login profiles only) |
| `meridian profile login <name> [--headless]` | Re-authenticate an expired profile (browser-login profiles only); `--headless` uses the URL/code flow |
| `meridian profile remove <name>` | Remove a profile and its credentials |
| `meridian refresh-token` | Manually refresh the Claude OAuth token (exits 0/1) |

Expand Down
7 changes: 4 additions & 3 deletions bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,21 @@ if (args[0] === "profile") {
const { profileAdd, profileAddOauthToken, profileList, profileRemove, profileSwitch, profileLogin, profileHelp } = await import("../src/proxy/profileCli")
const subcommand = args[1]
const profileId = args[2]
const headless = args.includes("--headless")

if (subcommand === "add" && profileId) {
const oauthFlagIdx = args.indexOf("--oauth-token", 3)
if (oauthFlagIdx >= 0) {
const tokenArg = args[oauthFlagIdx + 1]
await profileAddOauthToken(profileId, tokenArg)
await profileAddOauthToken(profileId, tokenArg?.startsWith("--") ? undefined : tokenArg)
} else {
profileAdd(profileId)
await profileAdd(profileId, { headless })
}
}
else if (subcommand === "list" || subcommand === "ls") profileList()
else if (subcommand === "remove" && profileId) profileRemove(profileId)
else if (subcommand === "switch" && profileId) await profileSwitch(profileId)
else if (subcommand === "login" && profileId) profileLogin(profileId)
else if (subcommand === "login" && profileId) await profileLogin(profileId, { headless })
else profileHelp()
process.exit(0)
}
Expand Down
34 changes: 34 additions & 0 deletions src/__tests__/profiles-unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,37 @@ describe("dirsToRemoveOnProfileRemove", () => {
expect(dirs).toEqual([])
})
})

describe("profile auth login env", () => {
test("sets CLAUDE_CONFIG_DIR without forcing browser behavior by default", async () => {
const { buildAuthLoginEnv } = await import("../proxy/profileCli")
const env = buildAuthLoginEnv("/tmp/profile", {}, { PATH: "/usr/bin", BROWSER: "open" })

expect(env.CLAUDE_CONFIG_DIR).toBe("/tmp/profile")
expect(env.BROWSER).toBe("open")
})

test("headless OAuth login builds a manual PKCE authorization URL", async () => {
const { createManualOAuthSession } = await import("../proxy/profileCli")
const session = createManualOAuthSession()
const url = new URL(session.authorizeUrl)

expect(url.origin).toBe("https://claude.com")
expect(url.pathname).toBe("/cai/oauth/authorize")
expect(url.searchParams.get("code")).toBe("true")
expect(url.searchParams.get("client_id")).toBe("9d1c250a-e61b-44d9-88ed-5944d1962f5e")
expect(url.searchParams.get("redirect_uri")).toBe("https://platform.claude.com/oauth/code/callback")
expect(url.searchParams.get("code_challenge_method")).toBe("S256")
expect(url.searchParams.get("code_challenge")).toBeTruthy()
expect(session.codeVerifier).toBeTruthy()
expect(session.state).toBeTruthy()
})

test("parses pasted authorization code values", async () => {
const { parseAuthorizationCodeInput } = await import("../proxy/profileCli")

expect(parseAuthorizationCodeInput("abc123")).toEqual({ code: "abc123" })
expect(parseAuthorizationCodeInput("abc123#state456")).toEqual({ code: "abc123", state: "state456" })
expect(parseAuthorizationCodeInput("https://platform.claude.com/oauth/code/callback?code=abc123&state=state456")).toEqual({ code: "abc123", state: "state456" })
})
})
Loading