Skip to content

Commit 0810859

Browse files
committed
feat(auth): claude code oauth profiles
1 parent 2c83f67 commit 0810859

19 files changed

Lines changed: 555 additions & 97 deletions

packages/app/src/docker-git/cli/parser-auth.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { parseRawOptions } from "./parser-options.js"
88
type AuthOptions = {
99
readonly envGlobalPath: string
1010
readonly codexAuthPath: string
11+
readonly claudeAuthPath: string
1112
readonly label: string | null
1213
readonly token: string | null
1314
readonly scopes: string | null
@@ -32,10 +33,12 @@ const normalizeLabel = (value: string | undefined): string | null => {
3233

3334
const defaultEnvGlobalPath = ".docker-git/.orch/env/global.env"
3435
const defaultCodexAuthPath = ".docker-git/.orch/auth/codex"
36+
const defaultClaudeAuthPath = ".docker-git/.orch/auth/claude"
3537

3638
const resolveAuthOptions = (raw: RawOptions): AuthOptions => ({
3739
envGlobalPath: raw.envGlobalPath ?? defaultEnvGlobalPath,
3840
codexAuthPath: raw.codexAuthPath ?? defaultCodexAuthPath,
41+
claudeAuthPath: defaultClaudeAuthPath,
3942
label: normalizeLabel(raw.label),
4043
token: normalizeLabel(raw.token),
4144
scopes: normalizeLabel(raw.scopes),
@@ -91,6 +94,29 @@ const buildCodexCommand = (action: string, options: AuthOptions): Either.Either<
9194
Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`)))
9295
)
9396

97+
const buildClaudeCommand = (action: string, options: AuthOptions): Either.Either<AuthCommand, ParseError> =>
98+
Match.value(action).pipe(
99+
Match.when("login", () =>
100+
Either.right<AuthCommand>({
101+
_tag: "AuthClaudeLogin",
102+
label: options.label,
103+
claudeAuthPath: options.claudeAuthPath
104+
})),
105+
Match.when("status", () =>
106+
Either.right<AuthCommand>({
107+
_tag: "AuthClaudeStatus",
108+
label: options.label,
109+
claudeAuthPath: options.claudeAuthPath
110+
})),
111+
Match.when("logout", () =>
112+
Either.right<AuthCommand>({
113+
_tag: "AuthClaudeLogout",
114+
label: options.label,
115+
claudeAuthPath: options.claudeAuthPath
116+
})),
117+
Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`)))
118+
)
119+
94120
const buildAuthCommand = (
95121
provider: string,
96122
action: string,
@@ -100,6 +126,8 @@ const buildAuthCommand = (
100126
Match.when("github", () => buildGithubCommand(action, options)),
101127
Match.when("gh", () => buildGithubCommand(action, options)),
102128
Match.when("codex", () => buildCodexCommand(action, options)),
129+
Match.when("claude", () => buildClaudeCommand(action, options)),
130+
Match.when("cc", () => buildClaudeCommand(action, options)),
103131
Match.orElse(() => Either.left(invalidArgument("auth provider", `unknown provider '${provider}'`)))
104132
)
105133

packages/app/src/docker-git/cli/usage.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Commands:
2828
sessions List/kill/log container terminal processes
2929
ps, status Show docker compose status for all docker-git projects
3030
down-all Stop all docker-git containers (docker compose down)
31-
auth Manage GitHub/Codex auth for docker-git
31+
auth Manage GitHub/Codex/Claude Code auth for docker-git
3232
state Manage docker-git state directory via git (sync across machines)
3333
3434
Options:
@@ -71,6 +71,7 @@ Container runtime env (set via .orch/env/project.env):
7171
Auth providers:
7272
github, gh GitHub CLI auth (tokens saved to env file)
7373
codex Codex CLI auth (stored under .orch/auth/codex)
74+
claude, cc Claude Code CLI auth (OAuth cache stored under .orch/auth/claude)
7475
7576
Auth actions:
7677
login Run login flow and store credentials

packages/app/src/docker-git/menu-auth-data.ts

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { type AppError } from "@effect-template/lib/usecases/errors"
77
import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers"
88
import { autoSyncState } from "@effect-template/lib/usecases/state-repo"
99

10+
import { countAuthAccountDirectories } from "./menu-auth-helpers.js"
1011
import { buildLabeledEnvKey, countKeyEntries, normalizeLabel } from "./menu-labeled-env.js"
1112
import type { AuthFlow, AuthSnapshot, MenuEnv } from "./menu-types.js"
1213

@@ -17,8 +18,10 @@ type AuthMenuItem = {
1718
readonly label: string
1819
}
1920

21+
export type AuthEnvFlow = Extract<AuthFlow, "GithubRemove" | "GitSet" | "GitRemove">
22+
2023
export type AuthPromptStep = {
21-
readonly key: "label" | "token" | "user" | "apiKey"
24+
readonly key: "label" | "token" | "user"
2225
readonly label: string
2326
readonly required: boolean
2427
readonly secret: boolean
@@ -29,8 +32,8 @@ const authMenuItems: ReadonlyArray<AuthMenuItem> = [
2932
{ action: "GithubRemove", label: "GitHub: remove token" },
3033
{ action: "GitSet", label: "Git: add/update credentials" },
3134
{ action: "GitRemove", label: "Git: remove credentials" },
32-
{ action: "ClaudeSet", label: "Claude: add/update API key" },
33-
{ action: "ClaudeRemove", label: "Claude: remove API key" },
35+
{ action: "ClaudeOauth", label: "Claude Code: login via OAuth (web)" },
36+
{ action: "ClaudeLogout", label: "Claude Code: logout (clear cache)" },
3437
{ action: "Refresh", label: "Refresh snapshot" },
3538
{ action: "Back", label: "Back to main menu" }
3639
]
@@ -50,12 +53,11 @@ const flowSteps: Readonly<Record<AuthFlow, ReadonlyArray<AuthPromptStep>>> = {
5053
GitRemove: [
5154
{ key: "label", label: "Label to remove (empty = default)", required: false, secret: false }
5255
],
53-
ClaudeSet: [
54-
{ key: "label", label: "Label (empty = default)", required: false, secret: false },
55-
{ key: "apiKey", label: "Claude API key", required: true, secret: true }
56+
ClaudeOauth: [
57+
{ key: "label", label: "Label (empty = default)", required: false, secret: false }
5658
],
57-
ClaudeRemove: [
58-
{ key: "label", label: "Label to remove (empty = default)", required: false, secret: false }
59+
ClaudeLogout: [
60+
{ key: "label", label: "Label to logout (empty = default)", required: false, secret: false }
5961
]
6062
}
6163

@@ -65,8 +67,8 @@ const flowTitle = (flow: AuthFlow): string =>
6567
Match.when("GithubRemove", () => "GitHub remove"),
6668
Match.when("GitSet", () => "Git credentials"),
6769
Match.when("GitRemove", () => "Git remove"),
68-
Match.when("ClaudeSet", () => "Claude API key"),
69-
Match.when("ClaudeRemove", () => "Claude remove"),
70+
Match.when("ClaudeOauth", () => "Claude Code OAuth"),
71+
Match.when("ClaudeLogout", () => "Claude Code logout"),
7072
Match.exhaustive
7173
)
7274

@@ -76,16 +78,19 @@ export const successMessage = (flow: AuthFlow, label: string): string =>
7678
Match.when("GithubRemove", () => `Removed GitHub token (${label}).`),
7779
Match.when("GitSet", () => `Saved Git credentials (${label}).`),
7880
Match.when("GitRemove", () => `Removed Git credentials (${label}).`),
79-
Match.when("ClaudeSet", () => `Saved Claude key (${label}).`),
80-
Match.when("ClaudeRemove", () => `Removed Claude key (${label}).`),
81+
Match.when("ClaudeOauth", () => `Saved Claude Code login (${label}).`),
82+
Match.when("ClaudeLogout", () => `Logged out Claude Code (${label}).`),
8183
Match.exhaustive
8284
)
8385

8486
const buildGlobalEnvPath = (cwd: string): string => `${defaultProjectsRoot(cwd)}/.orch/env/global.env`
87+
const buildClaudeAuthPath = (cwd: string): string => `${defaultProjectsRoot(cwd)}/.orch/auth/claude`
8588

8689
type AuthEnvText = {
8790
readonly fs: FileSystem.FileSystem
91+
readonly path: Path.Path
8892
readonly globalEnvPath: string
93+
readonly claudeAuthPath: string
8994
readonly envText: string
9095
}
9196

@@ -96,29 +101,36 @@ const loadAuthEnvText = (
96101
const fs = yield* _(FileSystem.FileSystem)
97102
const path = yield* _(Path.Path)
98103
const globalEnvPath = buildGlobalEnvPath(cwd)
104+
const claudeAuthPath = buildClaudeAuthPath(cwd)
99105
yield* _(ensureEnvFile(fs, path, globalEnvPath))
100106
const envText = yield* _(readEnvText(fs, globalEnvPath))
101-
return { fs, globalEnvPath, envText }
107+
return { fs, path, globalEnvPath, claudeAuthPath, envText }
102108
})
103109

104110
export const readAuthSnapshot = (
105111
cwd: string
106112
): Effect.Effect<AuthSnapshot, AppError, MenuEnv> =>
107113
pipe(
108114
loadAuthEnvText(cwd),
109-
Effect.map(({ envText, globalEnvPath }) => ({
110-
globalEnvPath,
111-
totalEntries: parseEnvEntries(envText).filter((entry) => entry.value.trim().length > 0).length,
112-
githubTokenEntries: countKeyEntries(envText, "GITHUB_TOKEN"),
113-
gitTokenEntries: countKeyEntries(envText, "GIT_AUTH_TOKEN"),
114-
gitUserEntries: countKeyEntries(envText, "GIT_AUTH_USER"),
115-
claudeKeyEntries: countKeyEntries(envText, "ANTHROPIC_API_KEY")
116-
}))
115+
Effect.flatMap(({ claudeAuthPath, envText, fs, globalEnvPath, path }) =>
116+
pipe(
117+
countAuthAccountDirectories(fs, path, claudeAuthPath),
118+
Effect.map((claudeAuthEntries) => ({
119+
globalEnvPath,
120+
claudeAuthPath,
121+
totalEntries: parseEnvEntries(envText).filter((entry) => entry.value.trim().length > 0).length,
122+
githubTokenEntries: countKeyEntries(envText, "GITHUB_TOKEN"),
123+
gitTokenEntries: countKeyEntries(envText, "GIT_AUTH_TOKEN"),
124+
gitUserEntries: countKeyEntries(envText, "GIT_AUTH_USER"),
125+
claudeAuthEntries
126+
}))
127+
)
128+
)
117129
)
118130

119131
export const writeAuthFlow = (
120132
cwd: string,
121-
flow: AuthFlow,
133+
flow: AuthEnvFlow,
122134
values: Readonly<Record<string, string>>
123135
): Effect.Effect<void, AppError, MenuEnv> =>
124136
pipe(
@@ -131,9 +143,7 @@ export const writeAuthFlow = (
131143
})()
132144
const token = (values["token"] ?? "").trim()
133145
const user = (values["user"] ?? "").trim()
134-
const apiKey = (values["apiKey"] ?? "").trim()
135146
const nextText = Match.value(flow).pipe(
136-
Match.when("GithubOauth", () => envText),
137147
Match.when("GithubRemove", () => upsertEnvKey(envText, buildLabeledEnvKey("GITHUB_TOKEN", label), "")),
138148
Match.when("GitSet", () => {
139149
const withToken = upsertEnvKey(envText, buildLabeledEnvKey("GIT_AUTH_TOKEN", label), token)
@@ -144,17 +154,12 @@ export const writeAuthFlow = (
144154
const withoutToken = upsertEnvKey(envText, buildLabeledEnvKey("GIT_AUTH_TOKEN", label), "")
145155
return upsertEnvKey(withoutToken, buildLabeledEnvKey("GIT_AUTH_USER", label), "")
146156
}),
147-
Match.when("ClaudeSet", () => upsertEnvKey(envText, buildLabeledEnvKey("ANTHROPIC_API_KEY", label), apiKey)),
148-
Match.when("ClaudeRemove", () => upsertEnvKey(envText, buildLabeledEnvKey("ANTHROPIC_API_KEY", label), "")),
149157
Match.exhaustive
150158
)
151159
const syncMessage = Match.value(flow).pipe(
152-
Match.when("GithubOauth", () => `chore(state): auth gh ${canonicalLabel}`),
153160
Match.when("GithubRemove", () => `chore(state): auth gh logout ${canonicalLabel}`),
154161
Match.when("GitSet", () => `chore(state): auth git ${canonicalLabel}`),
155162
Match.when("GitRemove", () => `chore(state): auth git logout ${canonicalLabel}`),
156-
Match.when("ClaudeSet", () => `chore(state): auth claude ${canonicalLabel}`),
157-
Match.when("ClaudeRemove", () => `chore(state): auth claude logout ${canonicalLabel}`),
158163
Match.exhaustive
159164
)
160165
return pipe(
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type * as FileSystem from "@effect/platform/FileSystem"
2+
import type * as Path from "@effect/platform/Path"
3+
import { Effect } from "effect"
4+
5+
import type { AppError } from "@effect-template/lib/usecases/errors"
6+
7+
export const countAuthAccountDirectories = (
8+
fs: FileSystem.FileSystem,
9+
path: Path.Path,
10+
root: string
11+
): Effect.Effect<number, AppError> =>
12+
Effect.gen(function*(_) {
13+
const exists = yield* _(fs.exists(root))
14+
if (!exists) {
15+
return 0
16+
}
17+
const entries = yield* _(fs.readDirectory(root))
18+
let count = 0
19+
for (const entry of entries) {
20+
if (entry === ".image") {
21+
continue
22+
}
23+
const fullPath = path.join(root, entry)
24+
const info = yield* _(fs.stat(fullPath))
25+
if (info.type === "Directory") {
26+
count += 1
27+
}
28+
}
29+
return count
30+
})

packages/app/src/docker-git/menu-auth.ts

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { Effect, pipe } from "effect"
1+
import { Effect, Match, pipe } from "effect"
22

3-
import { authGithubLogin } from "@effect-template/lib/usecases/auth"
3+
import { authClaudeLogin, authClaudeLogout, authGithubLogin, claudeAuthRoot } from "@effect-template/lib/usecases/auth"
44
import type { AppError } from "@effect-template/lib/usecases/errors"
55
import { renderError } from "@effect-template/lib/usecases/errors"
66

@@ -68,23 +68,43 @@ const startAuthPrompt = (
6868
context.setMessage(null)
6969
}
7070

71+
const resolveLabelOption = (values: Readonly<Record<string, string>>): string | null => {
72+
const labelValue = (values["label"] ?? "").trim()
73+
return labelValue.length > 0 ? labelValue : null
74+
}
75+
7176
const resolveAuthPromptEffect = (
7277
view: AuthPromptView,
7378
cwd: string,
7479
values: Readonly<Record<string, string>>
7580
): Effect.Effect<void, AppError, MenuEnv> => {
76-
if (view.flow === "GithubOauth") {
77-
const labelValue = (values["label"] ?? "").trim()
78-
const labelOption = labelValue.length > 0 ? labelValue : null
79-
return authGithubLogin({
80-
_tag: "AuthGithubLogin",
81-
label: labelOption,
82-
token: null,
83-
scopes: null,
84-
envGlobalPath: view.snapshot.globalEnvPath
85-
})
86-
}
87-
return writeAuthFlow(cwd, view.flow, values)
81+
const labelOption = resolveLabelOption(values)
82+
return Match.value(view.flow).pipe(
83+
Match.when("GithubOauth", () =>
84+
authGithubLogin({
85+
_tag: "AuthGithubLogin",
86+
label: labelOption,
87+
token: null,
88+
scopes: null,
89+
envGlobalPath: view.snapshot.globalEnvPath
90+
})),
91+
Match.when("ClaudeOauth", () =>
92+
authClaudeLogin({
93+
_tag: "AuthClaudeLogin",
94+
label: labelOption,
95+
claudeAuthPath: claudeAuthRoot
96+
})),
97+
Match.when("ClaudeLogout", () =>
98+
authClaudeLogout({
99+
_tag: "AuthClaudeLogout",
100+
label: labelOption,
101+
claudeAuthPath: claudeAuthRoot
102+
})),
103+
Match.when("GithubRemove", (flow) => writeAuthFlow(cwd, flow, values)),
104+
Match.when("GitSet", (flow) => writeAuthFlow(cwd, flow, values)),
105+
Match.when("GitRemove", (flow) => writeAuthFlow(cwd, flow, values)),
106+
Match.exhaustive
107+
)
88108
}
89109

90110
const runAuthPromptEffect = (
@@ -162,7 +182,9 @@ const submitAuthPrompt = (
162182
(nextValues) => {
163183
const label = defaultLabel(nextValues["label"] ?? "")
164184
const effect = resolveAuthPromptEffect(view, context.state.cwd, nextValues)
165-
runAuthPromptEffect(effect, view, label, context, { suspendTui: view.flow === "GithubOauth" })
185+
runAuthPromptEffect(effect, view, label, context, {
186+
suspendTui: view.flow === "GithubOauth" || view.flow === "ClaudeOauth" || view.flow === "ClaudeLogout"
187+
})
166188
}
167189
)
168190
}

0 commit comments

Comments
 (0)