Skip to content

Commit dff74dc

Browse files
konardclaude
andcommitted
feat(auth): add Gemini CLI authentication support
Implement Gemini CLI authentication management for docker-git: - Add auth commands: login (API key), status, logout - Add CLI parsing for `docker-git auth gemini <action>` - Add menu-based API key entry flow - Add container entrypoint configuration for GEMINI_API_KEY - Add project-level auth binding (connect/disconnect) - Update template config with geminiAuthPath and geminiHome The Gemini CLI uses API key authentication stored at: `.docker-git/.orch/auth/gemini/<label>/.api-key` Closes #146 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b56f070 commit dff74dc

23 files changed

Lines changed: 1005 additions & 308 deletions

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ type AuthOptions = {
99
readonly envGlobalPath: string
1010
readonly codexAuthPath: string
1111
readonly claudeAuthPath: string
12+
readonly geminiAuthPath: string
1213
readonly label: string | null
1314
readonly token: string | null
1415
readonly scopes: string | null
@@ -34,11 +35,13 @@ const normalizeLabel = (value: string | undefined): string | null => {
3435
const defaultEnvGlobalPath = ".docker-git/.orch/env/global.env"
3536
const defaultCodexAuthPath = ".docker-git/.orch/auth/codex"
3637
const defaultClaudeAuthPath = ".docker-git/.orch/auth/claude"
38+
const defaultGeminiAuthPath = ".docker-git/.orch/auth/gemini"
3739

3840
const resolveAuthOptions = (raw: RawOptions): AuthOptions => ({
3941
envGlobalPath: raw.envGlobalPath ?? defaultEnvGlobalPath,
4042
codexAuthPath: raw.codexAuthPath ?? defaultCodexAuthPath,
4143
claudeAuthPath: defaultClaudeAuthPath,
44+
geminiAuthPath: defaultGeminiAuthPath,
4245
label: normalizeLabel(raw.label),
4346
token: normalizeLabel(raw.token),
4447
scopes: normalizeLabel(raw.scopes),
@@ -117,6 +120,39 @@ const buildClaudeCommand = (action: string, options: AuthOptions): Either.Either
117120
Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`)))
118121
)
119122

123+
// CHANGE: add Gemini CLI auth command parsing
124+
// WHY: enable Gemini CLI authentication management via docker-git CLI
125+
// QUOTE(ТЗ): "Добавь поддержку gemini CLI"
126+
// REF: issue-146
127+
// SOURCE: https://geminicli.com/docs/get-started/authentication/
128+
// FORMAT THEOREM: forall action: buildGeminiCommand(action, opts) = AuthCommand | ParseError
129+
// PURITY: CORE
130+
// EFFECT: n/a
131+
// INVARIANT: geminiAuthPath is always set from defaults or options
132+
// COMPLEXITY: O(1)
133+
const buildGeminiCommand = (action: string, options: AuthOptions): Either.Either<AuthCommand, ParseError> =>
134+
Match.value(action).pipe(
135+
Match.when("login", () =>
136+
Either.right<AuthCommand>({
137+
_tag: "AuthGeminiLogin",
138+
label: options.label,
139+
geminiAuthPath: options.geminiAuthPath
140+
})),
141+
Match.when("status", () =>
142+
Either.right<AuthCommand>({
143+
_tag: "AuthGeminiStatus",
144+
label: options.label,
145+
geminiAuthPath: options.geminiAuthPath
146+
})),
147+
Match.when("logout", () =>
148+
Either.right<AuthCommand>({
149+
_tag: "AuthGeminiLogout",
150+
label: options.label,
151+
geminiAuthPath: options.geminiAuthPath
152+
})),
153+
Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`)))
154+
)
155+
120156
const buildAuthCommand = (
121157
provider: string,
122158
action: string,
@@ -128,6 +164,7 @@ const buildAuthCommand = (
128164
Match.when("codex", () => buildCodexCommand(action, options)),
129165
Match.when("claude", () => buildClaudeCommand(action, options)),
130166
Match.when("cc", () => buildClaudeCommand(action, options)),
167+
Match.when("gemini", () => buildGeminiCommand(action, options)),
131168
Match.orElse(() => Either.left(invalidArgument("auth provider", `unknown provider '${provider}'`)))
132169
)
133170

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

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +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"
10+
import { countAuthAccountEntries } from "./menu-auth-snapshot-builder.js"
1111
import { buildLabeledEnvKey, countKeyEntries, normalizeLabel } from "./menu-labeled-env.js"
1212
import type { AuthFlow, AuthSnapshot, MenuEnv } from "./menu-types.js"
1313

@@ -21,7 +21,7 @@ type AuthMenuItem = {
2121
export type AuthEnvFlow = Extract<AuthFlow, "GithubRemove" | "GitSet" | "GitRemove">
2222

2323
export type AuthPromptStep = {
24-
readonly key: "label" | "token" | "user"
24+
readonly key: "label" | "token" | "user" | "apiKey"
2525
readonly label: string
2626
readonly required: boolean
2727
readonly secret: boolean
@@ -34,6 +34,8 @@ const authMenuItems: ReadonlyArray<AuthMenuItem> = [
3434
{ action: "GitRemove", label: "Git: remove credentials" },
3535
{ action: "ClaudeOauth", label: "Claude Code: login via OAuth (web)" },
3636
{ action: "ClaudeLogout", label: "Claude Code: logout (clear cache)" },
37+
{ action: "GeminiApiKey", label: "Gemini CLI: set API key" },
38+
{ action: "GeminiLogout", label: "Gemini CLI: logout (clear API key)" },
3739
{ action: "Refresh", label: "Refresh snapshot" },
3840
{ action: "Back", label: "Back to main menu" }
3941
]
@@ -58,6 +60,13 @@ const flowSteps: Readonly<Record<AuthFlow, ReadonlyArray<AuthPromptStep>>> = {
5860
],
5961
ClaudeLogout: [
6062
{ key: "label", label: "Label to logout (empty = default)", required: false, secret: false }
63+
],
64+
GeminiApiKey: [
65+
{ key: "label", label: "Label (empty = default)", required: false, secret: false },
66+
{ key: "apiKey", label: "Gemini API key (from ai.google.dev)", required: true, secret: true }
67+
],
68+
GeminiLogout: [
69+
{ key: "label", label: "Label to logout (empty = default)", required: false, secret: false }
6170
]
6271
}
6372

@@ -69,6 +78,8 @@ const flowTitle = (flow: AuthFlow): string =>
6978
Match.when("GitRemove", () => "Git remove"),
7079
Match.when("ClaudeOauth", () => "Claude Code OAuth"),
7180
Match.when("ClaudeLogout", () => "Claude Code logout"),
81+
Match.when("GeminiApiKey", () => "Gemini CLI API key"),
82+
Match.when("GeminiLogout", () => "Gemini CLI logout"),
7283
Match.exhaustive
7384
)
7485

@@ -80,17 +91,21 @@ export const successMessage = (flow: AuthFlow, label: string): string =>
8091
Match.when("GitRemove", () => `Removed Git credentials (${label}).`),
8192
Match.when("ClaudeOauth", () => `Saved Claude Code login (${label}).`),
8293
Match.when("ClaudeLogout", () => `Logged out Claude Code (${label}).`),
94+
Match.when("GeminiApiKey", () => `Saved Gemini API key (${label}).`),
95+
Match.when("GeminiLogout", () => `Logged out Gemini CLI (${label}).`),
8396
Match.exhaustive
8497
)
8598

8699
const buildGlobalEnvPath = (cwd: string): string => `${defaultProjectsRoot(cwd)}/.orch/env/global.env`
87100
const buildClaudeAuthPath = (cwd: string): string => `${defaultProjectsRoot(cwd)}/.orch/auth/claude`
101+
const buildGeminiAuthPath = (cwd: string): string => `${defaultProjectsRoot(cwd)}/.orch/auth/gemini`
88102

89103
type AuthEnvText = {
90104
readonly fs: FileSystem.FileSystem
91105
readonly path: Path.Path
92106
readonly globalEnvPath: string
93107
readonly claudeAuthPath: string
108+
readonly geminiAuthPath: string
94109
readonly envText: string
95110
}
96111

@@ -102,27 +117,29 @@ const loadAuthEnvText = (
102117
const path = yield* _(Path.Path)
103118
const globalEnvPath = buildGlobalEnvPath(cwd)
104119
const claudeAuthPath = buildClaudeAuthPath(cwd)
120+
const geminiAuthPath = buildGeminiAuthPath(cwd)
105121
yield* _(ensureEnvFile(fs, path, globalEnvPath))
106122
const envText = yield* _(readEnvText(fs, globalEnvPath))
107-
return { fs, path, globalEnvPath, claudeAuthPath, envText }
123+
return { fs, path, globalEnvPath, claudeAuthPath, geminiAuthPath, envText }
108124
})
109125

110126
export const readAuthSnapshot = (
111127
cwd: string
112128
): Effect.Effect<AuthSnapshot, AppError, MenuEnv> =>
113129
pipe(
114130
loadAuthEnvText(cwd),
115-
Effect.flatMap(({ claudeAuthPath, envText, fs, globalEnvPath, path }) =>
116-
pipe(
117-
countAuthAccountDirectories(fs, path, claudeAuthPath),
118-
Effect.map((claudeAuthEntries) => ({
131+
Effect.flatMap(({ claudeAuthPath, envText, fs, geminiAuthPath, globalEnvPath, path }) =>
132+
countAuthAccountEntries(fs, path, claudeAuthPath, geminiAuthPath).pipe(
133+
Effect.map(({ claudeAuthEntries, geminiAuthEntries }) => ({
119134
globalEnvPath,
120135
claudeAuthPath,
136+
geminiAuthPath,
121137
totalEntries: parseEnvEntries(envText).filter((entry) => entry.value.trim().length > 0).length,
122138
githubTokenEntries: countKeyEntries(envText, "GITHUB_TOKEN"),
123139
gitTokenEntries: countKeyEntries(envText, "GIT_AUTH_TOKEN"),
124140
gitUserEntries: countKeyEntries(envText, "GIT_AUTH_USER"),
125-
claudeAuthEntries
141+
claudeAuthEntries,
142+
geminiAuthEntries
126143
}))
127144
)
128145
)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { Effect, Match, pipe } from "effect"
2+
3+
import {
4+
authClaudeLogin,
5+
authClaudeLogout,
6+
authGeminiLogin,
7+
authGeminiLogout,
8+
authGithubLogin,
9+
claudeAuthRoot,
10+
geminiAuthRoot
11+
} from "@effect-template/lib/usecases/auth"
12+
import type { AppError } from "@effect-template/lib/usecases/errors"
13+
import { renderError } from "@effect-template/lib/usecases/errors"
14+
15+
import { readAuthSnapshot, successMessage, writeAuthFlow } from "./menu-auth-data.js"
16+
import { pauseOnError, resumeSshWithSkipInputs, withSuspendedTui } from "./menu-shared.js"
17+
import type { AuthSnapshot, MenuEnv, MenuViewContext, ViewState } from "./menu-types.js"
18+
19+
type AuthPromptView = Extract<ViewState, { readonly _tag: "AuthPrompt" }>
20+
21+
type AuthEffectContext = MenuViewContext & {
22+
readonly runner: { readonly runEffect: (effect: Effect.Effect<void, AppError, MenuEnv>) => void }
23+
readonly setSshActive: (active: boolean) => void
24+
readonly setSkipInputs: (update: (value: number) => number) => void
25+
readonly cwd: string
26+
}
27+
28+
const resolveLabelOption = (values: Readonly<Record<string, string>>): string | null => {
29+
const labelValue = (values["label"] ?? "").trim()
30+
return labelValue.length > 0 ? labelValue : null
31+
}
32+
33+
export const resolveAuthPromptEffect = (
34+
view: AuthPromptView,
35+
cwd: string,
36+
values: Readonly<Record<string, string>>
37+
): Effect.Effect<void, AppError, MenuEnv> => {
38+
const labelOption = resolveLabelOption(values)
39+
return Match.value(view.flow).pipe(
40+
Match.when("GithubOauth", () =>
41+
authGithubLogin({
42+
_tag: "AuthGithubLogin",
43+
label: labelOption,
44+
token: null,
45+
scopes: null,
46+
envGlobalPath: view.snapshot.globalEnvPath
47+
})),
48+
Match.when("ClaudeOauth", () =>
49+
authClaudeLogin({
50+
_tag: "AuthClaudeLogin",
51+
label: labelOption,
52+
claudeAuthPath: claudeAuthRoot
53+
})),
54+
Match.when("ClaudeLogout", () =>
55+
authClaudeLogout({
56+
_tag: "AuthClaudeLogout",
57+
label: labelOption,
58+
claudeAuthPath: claudeAuthRoot
59+
})),
60+
Match.when("GeminiApiKey", () => {
61+
const apiKey = (values["apiKey"] ?? "").trim()
62+
return authGeminiLogin({
63+
_tag: "AuthGeminiLogin",
64+
label: labelOption,
65+
geminiAuthPath: geminiAuthRoot
66+
}, apiKey)
67+
}),
68+
Match.when("GeminiLogout", () =>
69+
authGeminiLogout({
70+
_tag: "AuthGeminiLogout",
71+
label: labelOption,
72+
geminiAuthPath: geminiAuthRoot
73+
})),
74+
Match.when("GithubRemove", (flow) => writeAuthFlow(cwd, flow, values)),
75+
Match.when("GitSet", (flow) => writeAuthFlow(cwd, flow, values)),
76+
Match.when("GitRemove", (flow) => writeAuthFlow(cwd, flow, values)),
77+
Match.exhaustive
78+
)
79+
}
80+
81+
export const startAuthMenuWithSnapshot = (
82+
snapshot: AuthSnapshot,
83+
context: Pick<MenuViewContext, "setView" | "setMessage">
84+
): void => {
85+
context.setView({ _tag: "AuthMenu", selected: 0, snapshot })
86+
context.setMessage(null)
87+
}
88+
89+
export const runAuthPromptEffect = (
90+
effect: Effect.Effect<void, AppError, MenuEnv>,
91+
view: AuthPromptView,
92+
label: string,
93+
context: AuthEffectContext,
94+
options: { readonly suspendTui: boolean }
95+
): void => {
96+
const withOptionalSuspension = options.suspendTui
97+
? withSuspendedTui(effect, {
98+
onError: pauseOnError(renderError),
99+
onResume: resumeSshWithSkipInputs(context)
100+
})
101+
: effect
102+
103+
context.setSshActive(options.suspendTui)
104+
context.runner.runEffect(
105+
pipe(
106+
withOptionalSuspension,
107+
Effect.zipRight(readAuthSnapshot(context.cwd)),
108+
Effect.tap((snapshot) =>
109+
Effect.sync(() => {
110+
startAuthMenuWithSnapshot(snapshot, context)
111+
context.setMessage(successMessage(view.flow, label))
112+
})
113+
),
114+
Effect.asVoid
115+
)
116+
)
117+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type * as FileSystem from "@effect/platform/FileSystem"
2+
import type * as Path from "@effect/platform/Path"
3+
import { Effect, pipe } from "effect"
4+
5+
import type { AppError } from "@effect-template/lib/usecases/errors"
6+
import { countAuthAccountDirectories } from "./menu-auth-helpers.js"
7+
8+
export type AuthAccountCounts = {
9+
readonly claudeAuthEntries: number
10+
readonly geminiAuthEntries: number
11+
}
12+
13+
export const countAuthAccountEntries = (
14+
fs: FileSystem.FileSystem,
15+
path: Path.Path,
16+
claudeAuthPath: string,
17+
geminiAuthPath: string
18+
): Effect.Effect<AuthAccountCounts, AppError> =>
19+
pipe(
20+
Effect.all({
21+
claudeAuthEntries: countAuthAccountDirectories(fs, path, claudeAuthPath),
22+
geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthPath)
23+
})
24+
)

0 commit comments

Comments
 (0)