Skip to content

Commit 34c03a5

Browse files
authored
Merge pull request #147 from konard/issue-146-ab357c6183a2
feat(auth): add Gemini CLI authentication support with OAuth
2 parents 2c018c5 + a6afb7f commit 34c03a5

26 files changed

Lines changed: 1544 additions & 380 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: 31 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,9 @@ 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: "GeminiOauth", label: "Gemini CLI: login via OAuth (Google account)" },
38+
{ action: "GeminiApiKey", label: "Gemini CLI: set API key" },
39+
{ action: "GeminiLogout", label: "Gemini CLI: logout (clear credentials)" },
3740
{ action: "Refresh", label: "Refresh snapshot" },
3841
{ action: "Back", label: "Back to main menu" }
3942
]
@@ -58,6 +61,16 @@ const flowSteps: Readonly<Record<AuthFlow, ReadonlyArray<AuthPromptStep>>> = {
5861
],
5962
ClaudeLogout: [
6063
{ key: "label", label: "Label to logout (empty = default)", required: false, secret: false }
64+
],
65+
GeminiOauth: [
66+
{ key: "label", label: "Label (empty = default)", required: false, secret: false }
67+
],
68+
GeminiApiKey: [
69+
{ key: "label", label: "Label (empty = default)", required: false, secret: false },
70+
{ key: "apiKey", label: "Gemini API key (from ai.google.dev)", required: true, secret: true }
71+
],
72+
GeminiLogout: [
73+
{ key: "label", label: "Label to logout (empty = default)", required: false, secret: false }
6174
]
6275
}
6376

@@ -69,6 +82,9 @@ const flowTitle = (flow: AuthFlow): string =>
6982
Match.when("GitRemove", () => "Git remove"),
7083
Match.when("ClaudeOauth", () => "Claude Code OAuth"),
7184
Match.when("ClaudeLogout", () => "Claude Code logout"),
85+
Match.when("GeminiOauth", () => "Gemini CLI OAuth"),
86+
Match.when("GeminiApiKey", () => "Gemini CLI API key"),
87+
Match.when("GeminiLogout", () => "Gemini CLI logout"),
7288
Match.exhaustive
7389
)
7490

@@ -80,17 +96,22 @@ export const successMessage = (flow: AuthFlow, label: string): string =>
8096
Match.when("GitRemove", () => `Removed Git credentials (${label}).`),
8197
Match.when("ClaudeOauth", () => `Saved Claude Code login (${label}).`),
8298
Match.when("ClaudeLogout", () => `Logged out Claude Code (${label}).`),
99+
Match.when("GeminiOauth", () => `Saved Gemini CLI OAuth login (${label}).`),
100+
Match.when("GeminiApiKey", () => `Saved Gemini API key (${label}).`),
101+
Match.when("GeminiLogout", () => `Logged out Gemini CLI (${label}).`),
83102
Match.exhaustive
84103
)
85104

86105
const buildGlobalEnvPath = (cwd: string): string => `${defaultProjectsRoot(cwd)}/.orch/env/global.env`
87106
const buildClaudeAuthPath = (cwd: string): string => `${defaultProjectsRoot(cwd)}/.orch/auth/claude`
107+
const buildGeminiAuthPath = (cwd: string): string => `${defaultProjectsRoot(cwd)}/.orch/auth/gemini`
88108

89109
type AuthEnvText = {
90110
readonly fs: FileSystem.FileSystem
91111
readonly path: Path.Path
92112
readonly globalEnvPath: string
93113
readonly claudeAuthPath: string
114+
readonly geminiAuthPath: string
94115
readonly envText: string
95116
}
96117

@@ -102,27 +123,29 @@ const loadAuthEnvText = (
102123
const path = yield* _(Path.Path)
103124
const globalEnvPath = buildGlobalEnvPath(cwd)
104125
const claudeAuthPath = buildClaudeAuthPath(cwd)
126+
const geminiAuthPath = buildGeminiAuthPath(cwd)
105127
yield* _(ensureEnvFile(fs, path, globalEnvPath))
106128
const envText = yield* _(readEnvText(fs, globalEnvPath))
107-
return { fs, path, globalEnvPath, claudeAuthPath, envText }
129+
return { fs, path, globalEnvPath, claudeAuthPath, geminiAuthPath, envText }
108130
})
109131

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