From da154568ac71aa1978b3a2b22f12b986aa4712c5 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 15 May 2026 13:19:37 +0000 Subject: [PATCH 1/4] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/ProverCoderAI/docker-git/issues/304 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 00000000..550a4ece --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-05-15T13:19:37.296Z for PR creation at branch issue-304-3b5ced26375c for issue https://github.com/ProverCoderAI/docker-git/issues/304 \ No newline at end of file From b6cb6bf0168d8c91578926b1a75aa4ccf02602d2 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 15 May 2026 14:43:32 +0000 Subject: [PATCH 2/4] feat(auth): add grok authentication support --- packages/api/src/api/contracts.ts | 18 +- packages/api/src/api/schema.ts | 19 +- packages/api/src/auth-terminal-runner.ts | 13 +- packages/api/src/http.ts | 7 +- packages/api/src/services/agents.ts | 3 + packages/api/src/services/auth-menu.ts | 70 +++-- .../src/services/auth-terminal-sessions.ts | 4 +- .../api/src/services/container-tasks-core.ts | 2 +- packages/api/src/services/federation.ts | 5 +- packages/api/src/services/project-auth.ts | 60 +++- packages/api/src/services/project-prompts.ts | 5 +- packages/api/src/services/project-skills.ts | 4 +- packages/api/src/services/projects.ts | 2 + packages/api/src/ui.ts | 3 +- packages/api/tests/agents.test.ts | 7 + packages/app/src/docker-git/api-auth-codec.ts | 29 +- .../app/src/docker-git/api-client-create.ts | 2 + packages/app/src/docker-git/api-client.ts | 2 +- .../app/src/docker-git/cli/parser-apply.ts | 2 + .../app/src/docker-git/cli/parser-auth.ts | 38 +++ .../app/src/docker-git/cli/parser-options.ts | 6 + .../frontend-lib/core/auth-domain.ts | 32 +++ .../frontend-lib/core/auto-agent-flags.ts | 4 +- .../core/command-builders-template.ts | 94 +++++++ .../frontend-lib/core/command-builders.ts | 113 ++------ .../frontend-lib/core/command-options.ts | 2 + .../docker-git/frontend-lib/core/domain.ts | 11 +- .../frontend-lib/core/template-defaults.ts | 4 + .../app/src/docker-git/menu-auth-effects.ts | 12 +- .../app/src/docker-git/menu-auth-shared.ts | 30 +- .../docker-git/menu-auth-snapshot-builder.ts | 7 +- packages/app/src/docker-git/menu-auth.ts | 2 +- .../docker-git/menu-project-auth-shared.ts | 10 +- .../app/src/docker-git/menu-project-auth.ts | 3 +- .../app/src/docker-git/menu-render-auth.ts | 52 +++- .../docker-git/menu-render-project-auth.ts | 8 +- packages/app/src/docker-git/menu-types.ts | 10 + .../app/src/docker-git/program-unsupported.ts | 15 + packages/app/src/lib/core/auth-domain.ts | 32 +++ packages/app/src/lib/core/auto-agent-flags.ts | 4 +- .../src/lib/core/command-builders-template.ts | 94 +++++++ packages/app/src/lib/core/command-builders.ts | 113 ++------ packages/app/src/lib/core/command-options.ts | 2 + packages/app/src/lib/core/domain.ts | 11 +- .../app/src/lib/core/template-defaults.ts | 4 + .../app/src/lib/core/templates-entrypoint.ts | 2 + .../lib/core/templates-entrypoint/agent.ts | 6 +- .../src/lib/core/templates-entrypoint/base.ts | 1 + .../src/lib/core/templates-entrypoint/grok.ts | 255 +++++++++++++++++ .../templates-entrypoint/project-rules.ts | 24 +- .../src/lib/core/templates-entrypoint/rtk.ts | 6 +- .../src/lib/core/templates/docker-compose.ts | 62 +++-- .../app/src/lib/core/templates/dockerfile.ts | 8 +- packages/app/src/lib/shell/config.ts | 7 + .../app/src/lib/shell/docker-daemon-access.ts | 12 +- .../app/src/lib/usecases/apply-overrides.ts | 8 + .../app/src/lib/usecases/auth-grok-helpers.ts | 259 ++++++++++++++++++ .../app/src/lib/usecases/auth-grok-logout.ts | 35 +++ .../app/src/lib/usecases/auth-grok-oauth.ts | 130 +++++++++ .../app/src/lib/usecases/auth-grok-status.ts | 30 ++ packages/app/src/lib/usecases/auth-grok.ts | 94 +++++++ .../app/src/lib/usecases/auth-sync-helpers.ts | 1 + packages/app/src/lib/usecases/auth.ts | 1 + packages/app/src/web/action-prompt.ts | 40 ++- packages/app/src/web/actions-auth.ts | 21 +- packages/app/src/web/api-auth-schema.ts | 47 ++++ packages/app/src/web/api-prompts-schema.ts | 3 +- packages/app/src/web/api-schema.ts | 45 +-- packages/app/src/web/api-skills-schema.ts | 3 +- packages/app/src/web/api-skills.ts | 3 +- packages/app/src/web/api-types.ts | 4 + packages/app/src/web/api.ts | 2 +- packages/app/src/web/panel-auth.tsx | 3 +- packages/app/src/web/panel-project-auth.tsx | 2 + .../app/src/web/panel-project-prompts.tsx | 5 +- packages/app/src/web/panel-project-skills.tsx | 4 +- .../docker-git/actions-github-oauth.test.ts | 2 + .../app/tests/docker-git/parser-auth.test.ts | 21 ++ packages/lib/src/core/auth-domain.ts | 32 +++ packages/lib/src/core/auto-agent-flags.ts | 4 +- .../lib/src/core/command-builders-template.ts | 92 +++++++ packages/lib/src/core/command-builders.ts | 113 ++------ packages/lib/src/core/command-options.ts | 2 + packages/lib/src/core/domain.ts | 11 +- packages/lib/src/core/template-defaults.ts | 4 + packages/lib/src/core/templates-entrypoint.ts | 2 + .../src/core/templates-entrypoint/agent.ts | 6 +- .../lib/src/core/templates-entrypoint/base.ts | 1 + .../lib/src/core/templates-entrypoint/grok.ts | 253 +++++++++++++++++ .../templates-entrypoint/project-rules.ts | 24 +- .../lib/src/core/templates-entrypoint/rtk.ts | 6 +- .../lib/src/core/templates/docker-compose.ts | 62 +++-- packages/lib/src/core/templates/dockerfile.ts | 8 +- packages/lib/src/shell/config.ts | 7 + .../lib/src/shell/docker-daemon-access.ts | 12 +- packages/lib/src/usecases/apply-overrides.ts | 8 + .../lib/src/usecases/auth-grok-helpers.ts | 257 +++++++++++++++++ packages/lib/src/usecases/auth-grok-logout.ts | 35 +++ packages/lib/src/usecases/auth-grok-oauth.ts | 130 +++++++++ packages/lib/src/usecases/auth-grok-status.ts | 30 ++ packages/lib/src/usecases/auth-grok.ts | 94 +++++++ .../lib/src/usecases/auth-sync-helpers.ts | 1 + packages/lib/src/usecases/auth.ts | 1 + packages/lib/tests/core/templates.test.ts | 30 +- .../tests/usecases/agent-auto-select.test.ts | 4 + packages/lib/tests/usecases/auth-grok.test.ts | 118 ++++++++ 106 files changed, 2982 insertions(+), 481 deletions(-) create mode 100644 packages/app/src/docker-git/frontend-lib/core/command-builders-template.ts create mode 100644 packages/app/src/lib/core/command-builders-template.ts create mode 100644 packages/app/src/lib/core/templates-entrypoint/grok.ts create mode 100644 packages/app/src/lib/usecases/auth-grok-helpers.ts create mode 100644 packages/app/src/lib/usecases/auth-grok-logout.ts create mode 100644 packages/app/src/lib/usecases/auth-grok-oauth.ts create mode 100644 packages/app/src/lib/usecases/auth-grok-status.ts create mode 100644 packages/app/src/lib/usecases/auth-grok.ts create mode 100644 packages/app/src/web/api-auth-schema.ts create mode 100644 packages/lib/src/core/command-builders-template.ts create mode 100644 packages/lib/src/core/templates-entrypoint/grok.ts create mode 100644 packages/lib/src/usecases/auth-grok-helpers.ts create mode 100644 packages/lib/src/usecases/auth-grok-logout.ts create mode 100644 packages/lib/src/usecases/auth-grok-oauth.ts create mode 100644 packages/lib/src/usecases/auth-grok-status.ts create mode 100644 packages/lib/src/usecases/auth-grok.ts create mode 100644 packages/lib/tests/usecases/auth-grok.test.ts diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index a5ae6620..b7ba9e15 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -1,6 +1,6 @@ export type ProjectStatus = "running" | "stopped" | "unknown" -export type AgentProvider = "codex" | "opencode" | "claude" | "custom" +export type AgentProvider = "codex" | "opencode" | "claude" | "grok" | "custom" export type AgentStatus = "starting" | "running" | "stopping" | "stopped" | "exited" | "failed" @@ -183,19 +183,23 @@ export type AuthMenuFlow = | "ClaudeLogout" | "GeminiApiKey" | "GeminiLogout" + | "GrokApiKey" + | "GrokLogout" -export type AuthTerminalFlow = "ClaudeOauth" | "GeminiOauth" +export type AuthTerminalFlow = "ClaudeOauth" | "GeminiOauth" | "GrokOauth" export type AuthSnapshot = { readonly globalEnvPath: string readonly claudeAuthPath: string readonly geminiAuthPath: string + readonly grokAuthPath: string readonly totalEntries: number readonly githubTokenEntries: number readonly gitTokenEntries: number readonly gitUserEntries: number readonly claudeAuthEntries: number readonly geminiAuthEntries: number + readonly grokAuthEntries: number } export type AuthMenuRequest = { @@ -249,6 +253,8 @@ export type ProjectAuthFlow = | "ProjectClaudeDisconnect" | "ProjectGeminiConnect" | "ProjectGeminiDisconnect" + | "ProjectGrokConnect" + | "ProjectGrokDisconnect" export type ProjectAuthSnapshot = { readonly projectDir: string @@ -257,14 +263,17 @@ export type ProjectAuthSnapshot = { readonly envProjectPath: string readonly claudeAuthPath: string readonly geminiAuthPath: string + readonly grokAuthPath: string readonly githubTokenEntries: number readonly gitTokenEntries: number readonly claudeAuthEntries: number readonly geminiAuthEntries: number + readonly grokAuthEntries: number readonly activeGithubLabel: string | null readonly activeGitLabel: string | null readonly activeClaudeLabel: string | null readonly activeGeminiLabel: string | null + readonly activeGrokLabel: string | null } export type ProjectAuthRequest = { @@ -272,7 +281,7 @@ export type ProjectAuthRequest = { readonly label?: string | null | undefined } -export type ProjectPromptKind = "claude" | "codex" | "gemini" +export type ProjectPromptKind = "claude" | "codex" | "gemini" | "grok" export type ProjectPromptFile = { readonly kind: ProjectPromptKind @@ -302,6 +311,7 @@ export type ProjectSkillScope = | "claude/skills" | "codex/skills" | "gemini/skills" + | "grok/skills" export type ProjectSkillFile = { readonly id: string @@ -394,6 +404,8 @@ export type CreateProjectRequest = { readonly skipGithubAuth?: boolean | undefined readonly codexTokenLabel?: string | undefined readonly claudeTokenLabel?: string | undefined + readonly geminiTokenLabel?: string | undefined + readonly grokTokenLabel?: string | undefined readonly agentAutoMode?: string | undefined readonly up?: boolean | undefined readonly openSsh?: boolean | undefined diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index c4cfaf41..243813e9 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -32,6 +32,8 @@ export const CreateProjectRequestSchema = Schema.Struct({ skipGithubAuth: OptionalBoolean, codexTokenLabel: OptionalString, claudeTokenLabel: OptionalString, + geminiTokenLabel: OptionalString, + grokTokenLabel: OptionalString, agentAutoMode: OptionalString, up: OptionalBoolean, openSsh: OptionalBoolean, @@ -58,10 +60,12 @@ export const AuthMenuFlowSchema = Schema.Literal( "GitRemove", "ClaudeLogout", "GeminiApiKey", - "GeminiLogout" + "GeminiLogout", + "GrokApiKey", + "GrokLogout" ) -export const AuthTerminalFlowSchema = Schema.Literal("ClaudeOauth", "GeminiOauth") +export const AuthTerminalFlowSchema = Schema.Literal("ClaudeOauth", "GeminiOauth", "GrokOauth") export const AuthMenuRequestSchema = Schema.Struct({ flow: AuthMenuFlowSchema, @@ -105,7 +109,9 @@ export const ProjectAuthFlowSchema = Schema.Literal( "ProjectClaudeConnect", "ProjectClaudeDisconnect", "ProjectGeminiConnect", - "ProjectGeminiDisconnect" + "ProjectGeminiDisconnect", + "ProjectGrokConnect", + "ProjectGrokDisconnect" ) export const ProjectAuthRequestSchema = Schema.Struct({ @@ -113,7 +119,7 @@ export const ProjectAuthRequestSchema = Schema.Struct({ label: OptionalNullableString }) -export const ProjectPromptKindSchema = Schema.Literal("claude", "codex", "gemini") +export const ProjectPromptKindSchema = Schema.Literal("claude", "codex", "gemini", "grok") export const ProjectPromptUpdateRequestSchema = Schema.Struct({ content: Schema.String @@ -125,7 +131,8 @@ export const ProjectSkillScopeSchema = Schema.Literal( "agents/.skills", "claude/skills", "codex/skills", - "gemini/skills" + "gemini/skills", + "grok/skills" ) export const ProjectSkillUpdateRequestSchema = Schema.Struct({ @@ -236,7 +243,7 @@ export const ProjectDatabaseForwardSchema = Schema.Struct({ targetPort: Schema.Number }) -export const AgentProviderSchema = Schema.Literal("codex", "opencode", "claude", "custom") +export const AgentProviderSchema = Schema.Literal("codex", "opencode", "claude", "grok", "custom") export const AgentEnvVarSchema = Schema.Struct({ key: Schema.String, diff --git a/packages/api/src/auth-terminal-runner.ts b/packages/api/src/auth-terminal-runner.ts index 2b624d6d..5706aa17 100644 --- a/packages/api/src/auth-terminal-runner.ts +++ b/packages/api/src/auth-terminal-runner.ts @@ -1,11 +1,11 @@ import { NodeContext, NodeRuntime } from "@effect/platform-node" -import { authClaudeLogin, authGeminiLoginOauth } from "@effect-template/lib" +import { authClaudeLogin, authGeminiLoginOauth, authGrokLoginOauth } from "@effect-template/lib" import { Effect, Match } from "effect" -type AuthTerminalRunnerFlow = "ClaudeOauth" | "GeminiOauth" +type AuthTerminalRunnerFlow = "ClaudeOauth" | "GeminiOauth" | "GrokOauth" const parseFlow = (value: string | undefined): AuthTerminalRunnerFlow => - value === "ClaudeOauth" || value === "GeminiOauth" ? value : "ClaudeOauth" + value === "ClaudeOauth" || value === "GeminiOauth" || value === "GrokOauth" ? value : "ClaudeOauth" const parseLabel = (value: string | undefined): string | null => { const trimmed = value?.trim() ?? "" @@ -29,6 +29,13 @@ const program = Match.value(flow).pipe( geminiAuthPath: ".docker-git/.orch/auth/gemini", isWeb: false })), + Match.when("GrokOauth", () => + authGrokLoginOauth({ + _tag: "AuthGrokLogin", + label, + grokAuthPath: ".docker-git/.orch/auth/grok", + isWeb: false + })), Match.exhaustive ) diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index bdd21bc5..6116ed4e 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -178,7 +178,7 @@ const ProjectDatabaseProfileParamsSchema = Schema.Struct({ const ProjectPromptParamsSchema = Schema.Struct({ projectId: Schema.String, - kind: Schema.Literal("claude", "codex", "gemini") + kind: Schema.Literal("claude", "codex", "gemini", "grok") }) const ProjectSkillParamsSchema = Schema.Struct({ @@ -435,6 +435,8 @@ const skillScopeFromId = (scopeId: string): ProjectSkillScope | null => { return "codex/skills" case "gemini-skills": return "gemini/skills" + case "grok-skills": + return "grok/skills" default: return null } @@ -454,6 +456,8 @@ export const skillScopeToId = (scope: ProjectSkillScope): string => { return "codex-skills" case "gemini/skills": return "gemini-skills" + case "grok/skills": + return "grok-skills" } } @@ -465,6 +469,7 @@ const skillScopeFromBody = (scope: string): ProjectSkillScope | null => { case "claude/skills": case "codex/skills": case "gemini/skills": + case "grok/skills": return scope as ProjectSkillScope default: return null diff --git a/packages/api/src/services/agents.ts b/packages/api/src/services/agents.ts index 1b5cbbcf..d4596881 100644 --- a/packages/api/src/services/agents.ts +++ b/packages/api/src/services/agents.ts @@ -67,6 +67,9 @@ const pickDefaultCommand = (provider: CreateAgentRequest["provider"]): string => if (provider === "claude") { return "MCP_PLAYWRIGHT_ISOLATED=1 claude" } + if (provider === "grok") { + return "MCP_PLAYWRIGHT_ISOLATED=1 grok --no-sandbox" + } return "" } diff --git a/packages/api/src/services/auth-menu.ts b/packages/api/src/services/auth-menu.ts index ed948d95..4a86ce3b 100644 --- a/packages/api/src/services/auth-menu.ts +++ b/packages/api/src/services/auth-menu.ts @@ -2,7 +2,7 @@ import * as FileSystem from "@effect/platform/FileSystem" import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import * as Path from "@effect/platform/Path" -import { authClaudeLogout, authGeminiLogin, authGeminiLogout } from "@effect-template/lib/usecases/auth" +import { authClaudeLogout, authGeminiLogin, authGeminiLogout, authGrokLogin, authGrokLogout } from "@effect-template/lib/usecases/auth" import { ensureEnvFile, parseEnvEntries, readEnvText, upsertEnvKey } from "@effect-template/lib/usecases/env-file" import { renderError, type AppError } from "@effect-template/lib/usecases/errors" import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers" @@ -16,6 +16,7 @@ type MenuAuthRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.Comma const claudeAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/claude` const geminiAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/gemini` +const grokAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/grok` const globalEnvPath = `${defaultProjectsRoot(process.cwd())}/.orch/env/global.env` const normalizeLabel = (value: string): string => { @@ -102,18 +103,21 @@ export const readAuthMenuSnapshot = (): Effect.Effect Effect.all({ claudeAuthEntries: countAuthAccountDirectories(fs, path, claudeAuthRoot), - geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthRoot) + geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthRoot), + grokAuthEntries: countAuthAccountDirectories(fs, path, grokAuthRoot) }).pipe( - Effect.map(({ claudeAuthEntries, geminiAuthEntries }) => ({ + Effect.map(({ claudeAuthEntries, geminiAuthEntries, grokAuthEntries }) => ({ globalEnvPath, claudeAuthPath: claudeAuthRoot, geminiAuthPath: geminiAuthRoot, + grokAuthPath: grokAuthRoot, totalEntries: parseEnvEntries(envText).filter((entry) => entry.value.trim().length > 0).length, githubTokenEntries: countKeyEntries(envText, "GITHUB_TOKEN"), gitTokenEntries: countKeyEntries(envText, "GIT_AUTH_TOKEN"), gitUserEntries: countKeyEntries(envText, "GIT_AUTH_USER"), claudeAuthEntries, - geminiAuthEntries + geminiAuthEntries, + grokAuthEntries })) ) ) @@ -135,7 +139,11 @@ const syncMessage = (request: AuthMenuRequest): string => ? `chore(state): auth claude logout ${canonicalLabel(request.label)}` : request.flow === "GeminiApiKey" ? `chore(state): auth gemini ${canonicalLabel(request.label)}` - : `chore(state): auth gemini logout ${canonicalLabel(request.label)}` + : request.flow === "GeminiLogout" + ? `chore(state): auth gemini logout ${canonicalLabel(request.label)}` + : request.flow === "GrokApiKey" + ? `chore(state): auth grok ${canonicalLabel(request.label)}` + : `chore(state): auth grok logout ${canonicalLabel(request.label)}` const writeEnvBackedAuthFlow = ( request: AuthMenuRequest @@ -213,15 +221,45 @@ export const runAuthMenuFlow = ( error instanceof ApiBadRequestError ? error : new ApiBadRequestError({ message: String(error) }) ) ) - : pipe( - authGeminiLogout({ - _tag: "AuthGeminiLogout", - label: request.label ?? null, - geminiAuthPath: geminiAuthRoot - }), - Effect.mapError(mapMenuAuthError), - Effect.zipRight(readAuthMenuSnapshot()), - Effect.mapError((error) => - error instanceof ApiBadRequestError ? error : new ApiBadRequestError({ message: String(error) }) + : request.flow === "GeminiLogout" + ? pipe( + authGeminiLogout({ + _tag: "AuthGeminiLogout", + label: request.label ?? null, + geminiAuthPath: geminiAuthRoot + }), + Effect.mapError(mapMenuAuthError), + Effect.zipRight(readAuthMenuSnapshot()), + Effect.mapError((error) => + error instanceof ApiBadRequestError ? error : new ApiBadRequestError({ message: String(error) }) + ) ) - ) + : request.flow === "GrokApiKey" + ? pipe( + authGrokLogin( + { + _tag: "AuthGrokLogin", + label: request.label ?? null, + grokAuthPath: grokAuthRoot, + isWeb: true + }, + request.apiKey ?? "" + ), + Effect.mapError(mapMenuAuthError), + Effect.zipRight(readAuthMenuSnapshot()), + Effect.mapError((error) => + error instanceof ApiBadRequestError ? error : new ApiBadRequestError({ message: String(error) }) + ) + ) + : pipe( + authGrokLogout({ + _tag: "AuthGrokLogout", + label: request.label ?? null, + grokAuthPath: grokAuthRoot + }), + Effect.mapError(mapMenuAuthError), + Effect.zipRight(readAuthMenuSnapshot()), + Effect.mapError((error) => + error instanceof ApiBadRequestError ? error : new ApiBadRequestError({ message: String(error) }) + ) + ) diff --git a/packages/api/src/services/auth-terminal-sessions.ts b/packages/api/src/services/auth-terminal-sessions.ts index 0ffc5781..73d7ddf4 100644 --- a/packages/api/src/services/auth-terminal-sessions.ts +++ b/packages/api/src/services/auth-terminal-sessions.ts @@ -71,7 +71,9 @@ const resolveCommandLabel = (request: AuthTerminalSessionRequest): string => { const suffix = label === undefined || label.length === 0 ? "" : ` [${label}]` return request.flow === "ClaudeOauth" ? `docker-git menu auth claude oauth${suffix}` - : `docker-git menu auth gemini oauth${suffix}` + : request.flow === "GeminiOauth" + ? `docker-git menu auth gemini oauth${suffix}` + : `docker-git menu auth grok oauth${suffix}` } const resolveRunnerArgs = (flow: AuthTerminalFlow, label: string | null | undefined): ReadonlyArray => { diff --git a/packages/api/src/services/container-tasks-core.ts b/packages/api/src/services/container-tasks-core.ts index 4aac9901..1b9850fd 100644 --- a/packages/api/src/services/container-tasks-core.ts +++ b/packages/api/src/services/container-tasks-core.ts @@ -17,7 +17,7 @@ export type ManagedAgentPid = { readonly pid: number } -const interactiveAgentPattern = /\b(codex|claude|opencode|gemini)\b/u +const interactiveAgentPattern = /\b(codex|claude|opencode|gemini|grok)\b/u const hasInteractiveTty = (process: RawContainerProcess): boolean => process.tty !== "?" && process.tty.trim().length > 0 diff --git a/packages/api/src/services/federation.ts b/packages/api/src/services/federation.ts index a43f7780..829cd77e 100644 --- a/packages/api/src/services/federation.ts +++ b/packages/api/src/services/federation.ts @@ -1695,7 +1695,7 @@ const resolveAgentProvider = ( subscription: FollowSubscription | undefined ): AgentProvider => { const raw = subscription?.agentProvider ?? process.env["DOCKER_GIT_EXCHANGE_AGENT_PROVIDER"] - return raw === "claude" || raw === "opencode" || raw === "custom" ? raw : "codex" + return raw === "claude" || raw === "opencode" || raw === "grok" || raw === "custom" ? raw : "codex" } const buildTaskPrompt = (issue: FederationIssueRecord): string => { @@ -1730,6 +1730,9 @@ const buildAgentCommand = ( if (provider === "opencode") { return `opencode run ${shellEscape(prompt)}` } + if (provider === "grok") { + return `MCP_PLAYWRIGHT_ISOLATED=1 grok --no-sandbox -p ${shellEscape(prompt)}` + } if (provider === "custom") { return `sh -lc ${shellEscape(`printf '%s\n' ${shellEscape(prompt)}`)}` } diff --git a/packages/api/src/services/project-auth.ts b/packages/api/src/services/project-auth.ts index f5a4e18f..f3b6b7a2 100644 --- a/packages/api/src/services/project-auth.ts +++ b/packages/api/src/services/project-auth.ts @@ -17,6 +17,7 @@ type ProjectAuthRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.Co const claudeAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/claude` const geminiAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/gemini` +const grokAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/grok` const globalEnvPath = `${defaultProjectsRoot(process.cwd())}/.orch/env/global.env` const githubTokenBaseKey = "GITHUB_TOKEN" @@ -26,6 +27,7 @@ const projectGithubLabelKey = "GITHUB_AUTH_LABEL" const projectGitLabelKey = "GIT_AUTH_LABEL" const projectClaudeLabelKey = "CLAUDE_AUTH_LABEL" const projectGeminiLabelKey = "GEMINI_AUTH_LABEL" +const projectGrokLabelKey = "GROK_AUTH_LABEL" const defaultGitUser = "x-access-token" const normalizeLabel = (value: string): string => { @@ -170,7 +172,8 @@ const hasClaudeAccountCredentials = ( const hasApiKeyInEnvFile = ( fs: FileSystem.FileSystem, - envFilePath: string + envFilePath: string, + key: string ): Effect.Effect => Effect.gen(function*(_) { const hasFile = yield* _(hasFileAtPath(fs, envFilePath)) @@ -179,12 +182,13 @@ const hasApiKeyInEnvFile = ( } const envContent = yield* _(fs.readFileString(envFilePath), Effect.orElseSucceed(() => "")) + const prefix = `${key}=` for (const line of envContent.split("\n")) { const trimmed = line.trim() - if (!trimmed.startsWith("GEMINI_API_KEY=")) { + if (!trimmed.startsWith(prefix)) { continue } - const value = trimmed.slice("GEMINI_API_KEY=".length).replaceAll(/^['"]|['"]$/g, "").trim() + const value = trimmed.slice(prefix.length).replaceAll(/^['"]|['"]$/g, "").trim() if (value.length > 0) { return true } @@ -217,7 +221,7 @@ const hasGeminiAccountCredentials = ( return Effect.succeed(true) } - return hasApiKeyInEnvFile(fs, `${accountPath}/.env`).pipe( + return hasApiKeyInEnvFile(fs, `${accountPath}/.env`, "GEMINI_API_KEY").pipe( Effect.flatMap((hasEnvApiKey) => { if (hasEnvApiKey) { return Effect.succeed(true) @@ -232,6 +236,27 @@ const hasGeminiAccountCredentials = ( }) ) +const hasGrokAccountCredentials = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + hasFileAtPath(fs, `${accountPath}/.api-key`).pipe( + Effect.flatMap((hasApiKey) => { + if (hasApiKey) { + return Effect.succeed(true) + } + + return hasApiKeyInEnvFile(fs, `${accountPath}/.env`, "GROK_API_KEY").pipe( + Effect.flatMap((hasEnvApiKey) => { + if (hasEnvApiKey) { + return Effect.succeed(true) + } + return hasNonEmptyOauthToken(fs, `${accountPath}/.grok/user-settings.json`) + }) + ) + }) + ) + const resolveAccountCandidates = (authPath: string, accountLabel: string): ReadonlyArray => accountLabel === "default" ? [`${authPath}/default`, authPath] : [`${authPath}/${accountLabel}`] @@ -298,23 +323,27 @@ export const readProjectAuthSnapshot = ( Effect.flatMap(({ fs, path, globalEnvText, projectEnvText }) => Effect.all({ claudeAuthEntries: countAuthAccountDirectories(fs, path, claudeAuthRoot), - geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthRoot) + geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthRoot), + grokAuthEntries: countAuthAccountDirectories(fs, path, grokAuthRoot) }).pipe( - Effect.map(({ claudeAuthEntries, geminiAuthEntries }) => ({ + Effect.map(({ claudeAuthEntries, geminiAuthEntries, grokAuthEntries }) => ({ projectDir: project.projectDir, projectName: project.displayName, envGlobalPath: globalEnvPath, envProjectPath: project.envProjectPath, claudeAuthPath: claudeAuthRoot, geminiAuthPath: geminiAuthRoot, + grokAuthPath: grokAuthRoot, githubTokenEntries: countKeyEntries(globalEnvText, githubTokenBaseKey), gitTokenEntries: countKeyEntries(globalEnvText, gitTokenBaseKey), claudeAuthEntries, geminiAuthEntries, + grokAuthEntries, activeGithubLabel: findEnvValue(projectEnvText, projectGithubLabelKey), activeGitLabel: findEnvValue(projectEnvText, projectGitLabelKey), activeClaudeLabel: findEnvValue(projectEnvText, projectClaudeLabelKey), - activeGeminiLabel: findEnvValue(projectEnvText, projectGeminiLabelKey) + activeGeminiLabel: findEnvValue(projectEnvText, projectGeminiLabelKey), + activeGrokLabel: findEnvValue(projectEnvText, projectGrokLabelKey) })) ) ) @@ -330,6 +359,8 @@ const resolveSyncMessage = (flow: ProjectAuthFlow, label: string, displayName: s Match.when("ProjectClaudeDisconnect", () => `chore(state): project auth claude logout ${displayName}`), Match.when("ProjectGeminiConnect", () => `chore(state): project auth gemini ${label} ${displayName}`), Match.when("ProjectGeminiDisconnect", () => `chore(state): project auth gemini logout ${displayName}`), + Match.when("ProjectGrokConnect", () => `chore(state): project auth grok ${label} ${displayName}`), + Match.when("ProjectGrokDisconnect", () => `chore(state): project auth grok logout ${displayName}`), Match.exhaustive ) @@ -405,6 +436,21 @@ const resolveProjectEnvUpdate = ( Match.when("ProjectGeminiDisconnect", () => Effect.succeed(upsertEnvKey(projectEnvText, projectGeminiLabelKey, "")) ), + Match.when("ProjectGrokConnect", () => + findFirstCredentialsMatch( + fs, + resolveAccountCandidates(grokAuthRoot, normalizeAccountLabel(request.label ?? null, "default")), + hasGrokAccountCredentials + ).pipe( + Effect.flatMap((matched) => + matched === null + ? Effect.fail(missingSecret("Grok CLI login", normalizedLabel, grokAuthRoot)) + : Effect.succeed(upsertEnvKey(projectEnvText, projectGrokLabelKey, normalizedLabel)) + ) + )), + Match.when("ProjectGrokDisconnect", () => + Effect.succeed(upsertEnvKey(projectEnvText, projectGrokLabelKey, "")) + ), Match.exhaustive ) }), diff --git a/packages/api/src/services/project-prompts.ts b/packages/api/src/services/project-prompts.ts index 5bbc84a6..19685271 100644 --- a/packages/api/src/services/project-prompts.ts +++ b/packages/api/src/services/project-prompts.ts @@ -6,7 +6,7 @@ import { Effect, pipe } from "effect" import type { ProjectDetails } from "../api/contracts.js" import { ApiBadRequestError } from "../api/errors.js" -export type ProjectPromptKind = "claude" | "codex" | "gemini" +export type ProjectPromptKind = "claude" | "codex" | "gemini" | "grok" export type ProjectPromptFile = { readonly kind: ProjectPromptKind @@ -28,7 +28,8 @@ export type ProjectPromptsSnapshot = { const promptDescriptors: ReadonlyArray<{ kind: ProjectPromptKind; fileName: string }> = [ { kind: "claude", fileName: "CLAUDE.md" }, { kind: "codex", fileName: "AGENTS.md" }, - { kind: "gemini", fileName: "GEMINI.md" } + { kind: "gemini", fileName: "GEMINI.md" }, + { kind: "grok", fileName: "GROK.md" } ] const maxPromptBytes = 1024 * 256 diff --git a/packages/api/src/services/project-skills.ts b/packages/api/src/services/project-skills.ts index 5e9f5982..db200e2c 100644 --- a/packages/api/src/services/project-skills.ts +++ b/packages/api/src/services/project-skills.ts @@ -13,6 +13,7 @@ export type ProjectSkillScope = | "claude/skills" | "codex/skills" | "gemini/skills" + | "grok/skills" export type ProjectSkillFile = { readonly id: string @@ -39,7 +40,8 @@ const skillScopes: ReadonlyArray<{ scope: ProjectSkillScope; relativeRoot: strin { scope: "agents/.skills", relativeRoot: ".agents/.skills" }, { scope: "claude/skills", relativeRoot: ".claude/skills" }, { scope: "codex/skills", relativeRoot: ".codex/skills" }, - { scope: "gemini/skills", relativeRoot: ".gemini/skills" } + { scope: "gemini/skills", relativeRoot: ".gemini/skills" }, + { scope: "grok/skills", relativeRoot: ".grok/skills" } ] const skillFileName = "SKILL.md" diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts index f92a1cb4..9a714519 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -435,6 +435,8 @@ const toCreateRawOptions = (request: CreateProjectRequest): RawOptions => ({ ...(request.skipGithubAuth === undefined ? {} : { skipGithubAuth: request.skipGithubAuth }), ...(request.codexTokenLabel === undefined ? {} : { codexTokenLabel: request.codexTokenLabel }), ...(request.claudeTokenLabel === undefined ? {} : { claudeTokenLabel: request.claudeTokenLabel }), + ...(request.geminiTokenLabel === undefined ? {} : { geminiTokenLabel: request.geminiTokenLabel }), + ...(request.grokTokenLabel === undefined ? {} : { grokTokenLabel: request.grokTokenLabel }), ...(request.agentAutoMode === undefined ? {} : { agentAutoMode: request.agentAutoMode }), ...(request.up === undefined ? {} : { up: request.up }), ...(request.openSsh === undefined ? {} : { openSsh: request.openSsh }), diff --git a/packages/api/src/ui.ts b/packages/api/src/ui.ts index 4e85a4b3..1752add8 100644 --- a/packages/api/src/ui.ts +++ b/packages/api/src/ui.ts @@ -461,6 +461,7 @@ export const uiHtml = ` + @@ -470,7 +471,7 @@ export const uiHtml = `
- +
diff --git a/packages/api/tests/agents.test.ts b/packages/api/tests/agents.test.ts index 35f6d9b7..438d4b87 100644 --- a/packages/api/tests/agents.test.ts +++ b/packages/api/tests/agents.test.ts @@ -17,6 +17,13 @@ describe("agent service", () => { ) }) + it("starts default Grok agents with isolated Playwright MCP and unrestricted sandbox", () => { + expect(buildCommand({ provider: "grok" })).toBe("MCP_PLAYWRIGHT_ISOLATED=1 grok --no-sandbox") + expect(buildCommand({ provider: "grok", args: ["-p", "hello world"] })).toBe( + "MCP_PLAYWRIGHT_ISOLATED=1 grok --no-sandbox '-p' 'hello world'" + ) + }) + it("starts default OpenCode agents without extra env assignments", () => { expect(buildCommand({ provider: "opencode" })).toBe("opencode") }) diff --git a/packages/app/src/docker-git/api-auth-codec.ts b/packages/app/src/docker-git/api-auth-codec.ts index c8ceeb86..b5eefe86 100644 --- a/packages/app/src/docker-git/api-auth-codec.ts +++ b/packages/app/src/docker-git/api-auth-codec.ts @@ -5,29 +5,36 @@ type RawAuthSnapshot = { readonly globalEnvPath: string | null readonly claudeAuthPath: string | null readonly geminiAuthPath: string | null + readonly grokAuthPath: string | null readonly totalEntries: number | null readonly githubTokenEntries: number | null readonly gitTokenEntries: number | null readonly gitUserEntries: number | null readonly claudeAuthEntries: number | null readonly geminiAuthEntries: number | null + readonly grokAuthEntries: number | null } type RawProjectAuthSnapshot = { + /* jscpd:ignore-start */ readonly projectDir: string | null readonly projectName: string | null readonly envGlobalPath: string | null readonly envProjectPath: string | null readonly claudeAuthPath: string | null readonly geminiAuthPath: string | null + readonly grokAuthPath: string | null readonly githubTokenEntries: number | null readonly gitTokenEntries: number | null readonly claudeAuthEntries: number | null readonly geminiAuthEntries: number | null + readonly grokAuthEntries: number | null readonly activeGithubLabel: string | null readonly activeGitLabel: string | null readonly activeClaudeLabel: string | null readonly activeGeminiLabel: string | null + readonly activeGrokLabel: string | null + /* jscpd:ignore-end */ } const readNumber = (value: JsonValue | undefined): number | null => typeof value === "number" ? value : null @@ -53,12 +60,14 @@ const readAuthSnapshot = ( globalEnvPath: asString(snapshot["globalEnvPath"]), claudeAuthPath: asString(snapshot["claudeAuthPath"]), geminiAuthPath: asString(snapshot["geminiAuthPath"]), + grokAuthPath: asString(snapshot["grokAuthPath"]), totalEntries: readNumber(snapshot["totalEntries"]), githubTokenEntries: readNumber(snapshot["githubTokenEntries"]), gitTokenEntries: readNumber(snapshot["gitTokenEntries"]), gitUserEntries: readNumber(snapshot["gitUserEntries"]), claudeAuthEntries: readNumber(snapshot["claudeAuthEntries"]), - geminiAuthEntries: readNumber(snapshot["geminiAuthEntries"]) + geminiAuthEntries: readNumber(snapshot["geminiAuthEntries"]), + grokAuthEntries: readNumber(snapshot["grokAuthEntries"]) } } @@ -71,12 +80,14 @@ const decodeRequiredAuthSnapshot = (snapshot: RawAuthSnapshot): AuthSnapshot | n globalEnvPath: stringOrEmpty(snapshot.globalEnvPath), claudeAuthPath: stringOrEmpty(snapshot.claudeAuthPath), geminiAuthPath: stringOrEmpty(snapshot.geminiAuthPath), + grokAuthPath: stringOrEmpty(snapshot.grokAuthPath), totalEntries: numberOrZero(snapshot.totalEntries), githubTokenEntries: numberOrZero(snapshot.githubTokenEntries), gitTokenEntries: numberOrZero(snapshot.gitTokenEntries), gitUserEntries: numberOrZero(snapshot.gitUserEntries), claudeAuthEntries: numberOrZero(snapshot.claudeAuthEntries), - geminiAuthEntries: numberOrZero(snapshot.geminiAuthEntries) + geminiAuthEntries: numberOrZero(snapshot.geminiAuthEntries), + grokAuthEntries: numberOrZero(snapshot.grokAuthEntries) } } @@ -94,14 +105,17 @@ const readProjectAuthSnapshot = ( envProjectPath: asString(snapshot["envProjectPath"]), claudeAuthPath: asString(snapshot["claudeAuthPath"]), geminiAuthPath: asString(snapshot["geminiAuthPath"]), + grokAuthPath: asString(snapshot["grokAuthPath"]), githubTokenEntries: readNumber(snapshot["githubTokenEntries"]), gitTokenEntries: readNumber(snapshot["gitTokenEntries"]), claudeAuthEntries: readNumber(snapshot["claudeAuthEntries"]), geminiAuthEntries: readNumber(snapshot["geminiAuthEntries"]), + grokAuthEntries: readNumber(snapshot["grokAuthEntries"]), activeGithubLabel: asString(snapshot["activeGithubLabel"]), activeGitLabel: asString(snapshot["activeGitLabel"]), activeClaudeLabel: asString(snapshot["activeClaudeLabel"]), - activeGeminiLabel: asString(snapshot["activeGeminiLabel"]) + activeGeminiLabel: asString(snapshot["activeGeminiLabel"]), + activeGrokLabel: asString(snapshot["activeGrokLabel"]) } } @@ -115,10 +129,12 @@ const decodeRequiredProjectAuthSnapshot = ( snapshot.envProjectPath, snapshot.claudeAuthPath, snapshot.geminiAuthPath, + snapshot.grokAuthPath, snapshot.githubTokenEntries, snapshot.gitTokenEntries, snapshot.claudeAuthEntries, - snapshot.geminiAuthEntries + snapshot.geminiAuthEntries, + snapshot.grokAuthEntries ] if (hasNullValue(requiredValues)) { @@ -132,14 +148,17 @@ const decodeRequiredProjectAuthSnapshot = ( envProjectPath: stringOrEmpty(snapshot.envProjectPath), claudeAuthPath: stringOrEmpty(snapshot.claudeAuthPath), geminiAuthPath: stringOrEmpty(snapshot.geminiAuthPath), + grokAuthPath: stringOrEmpty(snapshot.grokAuthPath), githubTokenEntries: numberOrZero(snapshot.githubTokenEntries), gitTokenEntries: numberOrZero(snapshot.gitTokenEntries), claudeAuthEntries: numberOrZero(snapshot.claudeAuthEntries), geminiAuthEntries: numberOrZero(snapshot.geminiAuthEntries), + grokAuthEntries: numberOrZero(snapshot.grokAuthEntries), activeGithubLabel: snapshot.activeGithubLabel, activeGitLabel: snapshot.activeGitLabel, activeClaudeLabel: snapshot.activeClaudeLabel, - activeGeminiLabel: snapshot.activeGeminiLabel + activeGeminiLabel: snapshot.activeGeminiLabel, + activeGrokLabel: snapshot.activeGrokLabel } } diff --git a/packages/app/src/docker-git/api-client-create.ts b/packages/app/src/docker-git/api-client-create.ts index a9048343..4541aab2 100644 --- a/packages/app/src/docker-git/api-client-create.ts +++ b/packages/app/src/docker-git/api-client-create.ts @@ -45,6 +45,8 @@ export const buildCreateProjectRequest = ( useManagedAuthorizedKeys: true, codexTokenLabel: config.codexAuthLabel, claudeTokenLabel: config.claudeAuthLabel, + geminiTokenLabel: config.geminiAuthLabel, + grokTokenLabel: config.grokAuthLabel, agentAutoMode: config.agentAuto ? (config.agentMode ?? "auto") : undefined, up: command.runUp, openSsh: false, diff --git a/packages/app/src/docker-git/api-client.ts b/packages/app/src/docker-git/api-client.ts index 4f5f1495..78a76084 100644 --- a/packages/app/src/docker-git/api-client.ts +++ b/packages/app/src/docker-git/api-client.ts @@ -233,7 +233,7 @@ export const createProjectTerminalSession = (projectId: string) => ) export const createAuthTerminalSession = ( - flow: "ClaudeOauth" | "GeminiOauth", + flow: "ClaudeOauth" | "GeminiOauth" | "GrokOauth", label: string | null ) => request("POST", "/auth/terminal-sessions", { flow, label: label ?? undefined }).pipe( diff --git a/packages/app/src/docker-git/cli/parser-apply.ts b/packages/app/src/docker-git/cli/parser-apply.ts index 86b1f231..3e6ac8b5 100644 --- a/packages/app/src/docker-git/cli/parser-apply.ts +++ b/packages/app/src/docker-git/cli/parser-apply.ts @@ -33,6 +33,8 @@ export const parseApply = ( gitTokenLabel: raw.gitTokenLabel, codexTokenLabel: raw.codexTokenLabel, claudeTokenLabel: raw.claudeTokenLabel, + geminiTokenLabel: raw.geminiTokenLabel, + grokTokenLabel: raw.grokTokenLabel, cpuLimit, ramLimit, playwrightCpuLimit, diff --git a/packages/app/src/docker-git/cli/parser-auth.ts b/packages/app/src/docker-git/cli/parser-auth.ts index 1babd3fd..623efc6d 100644 --- a/packages/app/src/docker-git/cli/parser-auth.ts +++ b/packages/app/src/docker-git/cli/parser-auth.ts @@ -11,6 +11,7 @@ type AuthOptions = { readonly codexAuthPath: string readonly claudeAuthPath: string readonly geminiAuthPath: string + readonly grokAuthPath: string readonly label: string | null readonly token: string | null readonly scopes: string | null @@ -43,12 +44,14 @@ const defaultEnvGlobalPath = ".docker-git/.orch/env/global.env" const defaultCodexAuthPath = ".docker-git/.orch/auth/codex" const defaultClaudeAuthPath = ".docker-git/.orch/auth/claude" const defaultGeminiAuthPath = ".docker-git/.orch/auth/gemini" +const defaultGrokAuthPath = ".docker-git/.orch/auth/grok" const resolveAuthOptions = (raw: RawOptions): AuthOptions => ({ envGlobalPath: raw.envGlobalPath ?? defaultEnvGlobalPath, codexAuthPath: raw.codexAuthPath ?? defaultCodexAuthPath, claudeAuthPath: defaultClaudeAuthPath, geminiAuthPath: defaultGeminiAuthPath, + grokAuthPath: defaultGrokAuthPath, label: normalizeOptionalText(raw.label), token: normalizeOptionalText(raw.token), scopes: normalizeOptionalText(raw.scopes), @@ -191,6 +194,40 @@ const buildGeminiCommand = (action: string, options: AuthOptions): Either.Either Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`))) ) +// CHANGE: add Grok CLI auth command parsing +// WHY: issue #304 requires docker-git auth grok login/status/logout support +// QUOTE(ТЗ): "Реализовать поддержку авторизации grok" +// REF: issue-304 +// SOURCE: https://www.npmjs.com/package/grok-dev +// FORMAT THEOREM: forall action: buildGrokCommand(action, opts) = AuthCommand | ParseError +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: grokAuthPath is always set from defaults +// COMPLEXITY: O(1) +const buildGrokCommand = (action: string, options: AuthOptions): Either.Either => + Match.value(action).pipe( + Match.when("login", () => + Either.right({ + _tag: "AuthGrokLogin", + label: options.label, + grokAuthPath: options.grokAuthPath, + isWeb: options.authWeb + })), + Match.when("status", () => + Either.right({ + _tag: "AuthGrokStatus", + label: options.label, + grokAuthPath: options.grokAuthPath + })), + Match.when("logout", () => + Either.right({ + _tag: "AuthGrokLogout", + label: options.label, + grokAuthPath: options.grokAuthPath + })), + Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`))) + ) + const buildAuthCommand = ( provider: string, action: string, @@ -204,6 +241,7 @@ const buildAuthCommand = ( Match.when("claude", () => buildClaudeCommand(action, options)), Match.when("cc", () => buildClaudeCommand(action, options)), Match.when("gemini", () => buildGeminiCommand(action, options)), + Match.when("grok", () => buildGrokCommand(action, options)), Match.orElse(() => Either.left(invalidArgument("auth provider", `unknown provider '${provider}'`))) ) diff --git a/packages/app/src/docker-git/cli/parser-options.ts b/packages/app/src/docker-git/cli/parser-options.ts index 2fa9ec85..bf69872d 100644 --- a/packages/app/src/docker-git/cli/parser-options.ts +++ b/packages/app/src/docker-git/cli/parser-options.ts @@ -33,6 +33,8 @@ interface ValueOptionSpec { | "gitTokenLabel" | "codexTokenLabel" | "claudeTokenLabel" + | "geminiTokenLabel" + | "grokTokenLabel" | "token" | "scopes" | "message" @@ -76,6 +78,8 @@ const valueOptionSpecs: ReadonlyArray = [ { flag: "--git-token", key: "gitTokenLabel" }, { flag: "--codex-token", key: "codexTokenLabel" }, { flag: "--claude-token", key: "claudeTokenLabel" }, + { flag: "--gemini-token", key: "geminiTokenLabel" }, + { flag: "--grok-token", key: "grokTokenLabel" }, { flag: "--token", key: "token" }, { flag: "--scopes", key: "scopes" }, { flag: "--message", key: "message" }, @@ -137,6 +141,8 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st gitTokenLabel: (raw, value) => ({ ...raw, gitTokenLabel: value }), codexTokenLabel: (raw, value) => ({ ...raw, codexTokenLabel: value }), claudeTokenLabel: (raw, value) => ({ ...raw, claudeTokenLabel: value }), + geminiTokenLabel: (raw, value) => ({ ...raw, geminiTokenLabel: value }), + grokTokenLabel: (raw, value) => ({ ...raw, grokTokenLabel: value }), token: (raw, value) => ({ ...raw, token: value }), scopes: (raw, value) => ({ ...raw, scopes: value }), message: (raw, value) => ({ ...raw, message: value }), diff --git a/packages/app/src/docker-git/frontend-lib/core/auth-domain.ts b/packages/app/src/docker-git/frontend-lib/core/auth-domain.ts index b6ae3f8e..4826933f 100644 --- a/packages/app/src/docker-git/frontend-lib/core/auth-domain.ts +++ b/packages/app/src/docker-git/frontend-lib/core/auth-domain.ts @@ -107,6 +107,35 @@ export interface AuthGeminiLogoutCommand { readonly geminiAuthPath: string } +// CHANGE: add Grok CLI auth commands +// WHY: issue #304 requires Grok login/status/logout profiles with isolated auth storage +// QUOTE(ТЗ): "Реализовать поддержку авторизации grok" +// REF: issue-304 +// SOURCE: https://www.npmjs.com/package/grok-dev +// FORMAT THEOREM: forall cmd ∈ AuthGrokCommand: cmd.grokAuthPath is valid path +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: authentication state is isolated by label +// COMPLEXITY: O(1) +export interface AuthGrokLoginCommand { + readonly _tag: "AuthGrokLogin" + readonly label: string | null + readonly grokAuthPath: string + readonly isWeb: boolean +} + +export interface AuthGrokStatusCommand { + readonly _tag: "AuthGrokStatus" + readonly label: string | null + readonly grokAuthPath: string +} + +export interface AuthGrokLogoutCommand { + readonly _tag: "AuthGrokLogout" + readonly label: string | null + readonly grokAuthPath: string +} + export type AuthCommand = | AuthGithubLoginCommand | AuthGithubStatusCommand @@ -124,4 +153,7 @@ export type AuthCommand = | AuthGeminiLoginCommand | AuthGeminiStatusCommand | AuthGeminiLogoutCommand + | AuthGrokLoginCommand + | AuthGrokStatusCommand + | AuthGrokLogoutCommand /* jscpd:ignore-end */ diff --git a/packages/app/src/docker-git/frontend-lib/core/auto-agent-flags.ts b/packages/app/src/docker-git/frontend-lib/core/auto-agent-flags.ts index c1683f36..c5472b66 100644 --- a/packages/app/src/docker-git/frontend-lib/core/auto-agent-flags.ts +++ b/packages/app/src/docker-git/frontend-lib/core/auto-agent-flags.ts @@ -14,13 +14,13 @@ export const resolveAutoAgentFlags = ( if (requested === "auto") { return Either.right({ agentMode: undefined, agentAuto: true }) } - if (requested === "claude" || requested === "codex") { + if (requested === "claude" || requested === "codex" || requested === "gemini" || requested === "grok") { return Either.right({ agentMode: requested, agentAuto: true }) } return Either.left({ _tag: "InvalidOption", option: "--auto", - reason: "expected one of: claude, codex" + reason: "expected one of: claude, codex, gemini, grok" }) } /* jscpd:ignore-end */ diff --git a/packages/app/src/docker-git/frontend-lib/core/command-builders-template.ts b/packages/app/src/docker-git/frontend-lib/core/command-builders-template.ts new file mode 100644 index 00000000..f54a214d --- /dev/null +++ b/packages/app/src/docker-git/frontend-lib/core/command-builders-template.ts @@ -0,0 +1,94 @@ +/* jscpd:ignore-start */ +import type { NameConfig, PathConfig, RepoBasics } from "./command-builders.js" +import { type AgentMode, type CreateCommand, defaultTemplateConfig } from "./domain.js" + +export type BuildTemplateConfigInput = { + readonly repo: RepoBasics + readonly names: NameConfig + readonly paths: PathConfig + readonly cpuLimit: string | undefined + readonly ramLimit: string | undefined + readonly playwrightCpuLimit: string | undefined + readonly playwrightRamLimit: string | undefined + readonly gpu: CreateCommand["config"]["gpu"] + readonly dockerNetworkMode: CreateCommand["config"]["dockerNetworkMode"] + readonly dockerSharedNetworkName: string + readonly gitTokenLabel: string | undefined + readonly skipGithubAuth: boolean + readonly codexAuthLabel: string | undefined + readonly claudeAuthLabel: string | undefined + readonly geminiAuthLabel: string | undefined + readonly grokAuthLabel: string | undefined + readonly enableMcpPlaywright: boolean + readonly agentMode: AgentMode | undefined + readonly agentAuto: boolean + readonly clonedOnHostname?: string | undefined +} + +const buildTemplateConfigBase = ( + input: Pick +): Pick< + CreateCommand["config"], + | "containerName" + | "serviceName" + | "sshUser" + | "sshPort" + | "repoUrl" + | "repoRef" + | "targetDir" + | "volumeName" + | "dockerGitPath" + | "authorizedKeysPath" + | "envGlobalPath" + | "envProjectPath" + | "codexAuthPath" + | "codexSharedAuthPath" + | "codexHome" + | "geminiAuthPath" + | "geminiHome" + | "grokAuthPath" + | "grokHome" +> => ({ + containerName: input.names.containerName, + serviceName: input.names.serviceName, + sshUser: input.repo.sshUser, + sshPort: input.repo.sshPort, + repoUrl: input.repo.repoUrl, + repoRef: input.repo.repoRef, + targetDir: input.repo.targetDir, + volumeName: input.names.volumeName, + dockerGitPath: input.paths.dockerGitPath, + authorizedKeysPath: input.paths.authorizedKeysPath, + envGlobalPath: input.paths.envGlobalPath, + envProjectPath: input.paths.envProjectPath, + codexAuthPath: input.paths.codexAuthPath, + codexSharedAuthPath: input.paths.codexSharedAuthPath, + codexHome: input.paths.codexHome, + geminiAuthPath: input.paths.geminiAuthPath, + geminiHome: input.paths.geminiHome, + grokAuthPath: input.paths.grokAuthPath, + grokHome: input.paths.grokHome +}) + +export const buildTemplateConfig = (input: BuildTemplateConfigInput): CreateCommand["config"] => ({ + ...buildTemplateConfigBase(input), + gitTokenLabel: input.gitTokenLabel, + skipGithubAuth: input.skipGithubAuth, + codexAuthLabel: input.codexAuthLabel, + claudeAuthLabel: input.claudeAuthLabel, + geminiAuthLabel: input.geminiAuthLabel, + grokAuthLabel: input.grokAuthLabel, + cpuLimit: input.cpuLimit, + ramLimit: input.ramLimit, + playwrightCpuLimit: input.playwrightCpuLimit, + playwrightRamLimit: input.playwrightRamLimit, + gpu: input.gpu, + dockerNetworkMode: input.dockerNetworkMode, + dockerSharedNetworkName: input.dockerSharedNetworkName, + enableMcpPlaywright: input.enableMcpPlaywright, + bunVersion: defaultTemplateConfig.bunVersion, + agentMode: input.agentMode, + agentAuto: input.agentAuto, + clonedOnHostname: input.clonedOnHostname +}) +/* jscpd:ignore-end */ diff --git a/packages/app/src/docker-git/frontend-lib/core/command-builders.ts b/packages/app/src/docker-git/frontend-lib/core/command-builders.ts index 44c0bd88..d6dd8730 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-builders.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-builders.ts @@ -10,9 +10,9 @@ import { parseSshPort, parseSshUser } from "./command-builders-shared.js" +import { buildTemplateConfig } from "./command-builders-template.js" import { type RawOptions } from "./command-options.js" import { - type AgentMode, type CreateCommand, defaultTemplateConfig, deriveRepoPathParts, @@ -28,7 +28,7 @@ export { nonEmpty } from "./command-builders-shared.js" const normalizeSecretsRoot = (value: string): string => trimRightChar(value, "/") -type RepoBasics = { +export type RepoBasics = { readonly repoUrl: string readonly repoSlug: string readonly projectSlug: string @@ -62,7 +62,7 @@ const resolveRepoBasics = (raw: RawOptions): Either.Either { @@ -122,7 +125,8 @@ const buildDefaultPathConfig = ( envGlobalPath: defaultTemplateConfig.envGlobalPath, envProjectPath: defaultTemplateConfig.envProjectPath, codexAuthPath: defaultTemplateConfig.codexAuthPath, - geminiAuthPath: defaultTemplateConfig.geminiAuthPath + geminiAuthPath: defaultTemplateConfig.geminiAuthPath, + grokAuthPath: defaultTemplateConfig.grokAuthPath } : { // NOTE: Keep docker-git root mount stable (projects root) so caches like @@ -132,7 +136,8 @@ const buildDefaultPathConfig = ( envGlobalPath: `${normalizedSecretsRoot}/global.env`, envProjectPath: defaultTemplateConfig.envProjectPath, codexAuthPath: `${normalizedSecretsRoot}/codex`, - geminiAuthPath: `${normalizedSecretsRoot}/gemini` + geminiAuthPath: `${normalizedSecretsRoot}/gemini`, + grokAuthPath: `${normalizedSecretsRoot}/grok` } const resolvePaths = ( @@ -157,6 +162,8 @@ const resolvePaths = ( const codexHome = yield* _(nonEmpty("--codex-home", raw.codexHome, defaultTemplateConfig.codexHome)) const geminiAuthPath = defaults.geminiAuthPath const geminiHome = defaultTemplateConfig.geminiHome + const grokAuthPath = defaults.grokAuthPath + const grokHome = defaultTemplateConfig.grokHome const outDir = yield* _(nonEmpty("--out-dir", raw.outDir, `.docker-git/${repoPath}`)) return { @@ -169,6 +176,8 @@ const resolvePaths = ( codexHome, geminiAuthPath, geminiHome, + grokAuthPath, + grokHome, outDir } }) @@ -191,86 +200,20 @@ const resolveCreateBehavior = (raw: RawOptions): CreateBehavior => ({ enableMcpPlaywright: raw.enableMcpPlaywright ?? false }) -type BuildTemplateConfigInput = { - readonly repo: RepoBasics - readonly names: NameConfig - readonly paths: PathConfig - readonly cpuLimit: string | undefined - readonly ramLimit: string | undefined - readonly playwrightCpuLimit: string | undefined - readonly playwrightRamLimit: string | undefined - readonly gpu: CreateCommand["config"]["gpu"] - readonly dockerNetworkMode: CreateCommand["config"]["dockerNetworkMode"] - readonly dockerSharedNetworkName: string +type TokenLabelConfig = { readonly gitTokenLabel: string | undefined - readonly skipGithubAuth: boolean readonly codexAuthLabel: string | undefined readonly claudeAuthLabel: string | undefined - readonly enableMcpPlaywright: boolean - readonly agentMode: AgentMode | undefined - readonly agentAuto: boolean - readonly clonedOnHostname?: string | undefined + readonly geminiAuthLabel: string | undefined + readonly grokAuthLabel: string | undefined } -const buildTemplateConfigBase = ( - input: Pick -): Pick< - CreateCommand["config"], - | "containerName" - | "serviceName" - | "sshUser" - | "sshPort" - | "repoUrl" - | "repoRef" - | "targetDir" - | "volumeName" - | "dockerGitPath" - | "authorizedKeysPath" - | "envGlobalPath" - | "envProjectPath" - | "codexAuthPath" - | "codexSharedAuthPath" - | "codexHome" - | "geminiAuthPath" - | "geminiHome" -> => ({ - containerName: input.names.containerName, - serviceName: input.names.serviceName, - sshUser: input.repo.sshUser, - sshPort: input.repo.sshPort, - repoUrl: input.repo.repoUrl, - repoRef: input.repo.repoRef, - targetDir: input.repo.targetDir, - volumeName: input.names.volumeName, - dockerGitPath: input.paths.dockerGitPath, - authorizedKeysPath: input.paths.authorizedKeysPath, - envGlobalPath: input.paths.envGlobalPath, - envProjectPath: input.paths.envProjectPath, - codexAuthPath: input.paths.codexAuthPath, - codexSharedAuthPath: input.paths.codexSharedAuthPath, - codexHome: input.paths.codexHome, - geminiAuthPath: input.paths.geminiAuthPath, - geminiHome: input.paths.geminiHome -}) - -const buildTemplateConfig = (input: BuildTemplateConfigInput): CreateCommand["config"] => ({ - ...buildTemplateConfigBase(input), - gitTokenLabel: input.gitTokenLabel, - skipGithubAuth: input.skipGithubAuth, - codexAuthLabel: input.codexAuthLabel, - claudeAuthLabel: input.claudeAuthLabel, - cpuLimit: input.cpuLimit, - ramLimit: input.ramLimit, - playwrightCpuLimit: input.playwrightCpuLimit, - playwrightRamLimit: input.playwrightRamLimit, - gpu: input.gpu, - dockerNetworkMode: input.dockerNetworkMode, - dockerSharedNetworkName: input.dockerSharedNetworkName, - enableMcpPlaywright: input.enableMcpPlaywright, - bunVersion: defaultTemplateConfig.bunVersion, - agentMode: input.agentMode, - agentAuto: input.agentAuto, - clonedOnHostname: input.clonedOnHostname +const resolveTokenLabels = (raw: RawOptions): TokenLabelConfig => ({ + gitTokenLabel: normalizeGitTokenLabel(raw.gitTokenLabel), + codexAuthLabel: normalizeAuthLabel(raw.codexTokenLabel), + claudeAuthLabel: normalizeAuthLabel(raw.claudeTokenLabel), + geminiAuthLabel: normalizeAuthLabel(raw.geminiTokenLabel), + grokAuthLabel: normalizeAuthLabel(raw.grokTokenLabel) }) // CHANGE: build a typed create command from raw options (CLI or API) @@ -291,9 +234,7 @@ export const buildCreateCommand = ( const names = yield* _(resolveNames(raw, repo.projectSlug)) const paths = yield* _(resolvePaths(raw, repo.repoPath)) const behavior = resolveCreateBehavior(raw) - const gitTokenLabel = normalizeGitTokenLabel(raw.gitTokenLabel) - const codexAuthLabel = normalizeAuthLabel(raw.codexTokenLabel) - const claudeAuthLabel = normalizeAuthLabel(raw.claudeTokenLabel) + const tokenLabels = resolveTokenLabels(raw) const limits = yield* _(resolveResourceLimitsIntent(raw)) const gpu = yield* _(parseGpuMode(raw.gpu)) const dockerNetworkMode = yield* _(parseDockerNetworkMode(raw.dockerNetworkMode)) @@ -321,10 +262,8 @@ export const buildCreateCommand = ( gpu, dockerNetworkMode, dockerSharedNetworkName, - gitTokenLabel, + ...tokenLabels, skipGithubAuth: behavior.skipGithubAuth, - codexAuthLabel, - claudeAuthLabel, enableMcpPlaywright: behavior.enableMcpPlaywright, agentMode, agentAuto diff --git a/packages/app/src/docker-git/frontend-lib/core/command-options.ts b/packages/app/src/docker-git/frontend-lib/core/command-options.ts index 12f15f15..5c4b02da 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-options.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-options.ts @@ -41,6 +41,8 @@ export interface RawOptions { readonly gitTokenLabel?: string readonly codexTokenLabel?: string readonly claudeTokenLabel?: string + readonly geminiTokenLabel?: string + readonly grokTokenLabel?: string readonly token?: string readonly scopes?: string readonly message?: string diff --git a/packages/app/src/docker-git/frontend-lib/core/domain.ts b/packages/app/src/docker-git/frontend-lib/core/domain.ts index a65fee05..2398984d 100644 --- a/packages/app/src/docker-git/frontend-lib/core/domain.ts +++ b/packages/app/src/docker-git/frontend-lib/core/domain.ts @@ -20,7 +20,10 @@ export type { AuthGithubStatusCommand, AuthGitlabLoginCommand, AuthGitlabLogoutCommand, - AuthGitlabStatusCommand + AuthGitlabStatusCommand, + AuthGrokLoginCommand, + AuthGrokLogoutCommand, + AuthGrokStatusCommand } from "./auth-domain.js" export type { MenuAction, ParseError } from "./menu.js" export { parseMenuSelection } from "./menu.js" @@ -53,7 +56,7 @@ export { dockerGitSharedCodexVolumeName } from "./template-defaults.js" -export type AgentMode = "claude" | "codex" | "gemini" +export type AgentMode = "claude" | "codex" | "gemini" | "grok" export type DockerNetworkMode = "shared" | "project" export type GpuMode = "none" | "all" @@ -97,6 +100,9 @@ export interface TemplateConfig { readonly geminiAuthLabel?: string | undefined readonly geminiAuthPath: string readonly geminiHome: string + readonly grokAuthLabel?: string | undefined + readonly grokAuthPath: string + readonly grokHome: string readonly cpuLimit?: string | undefined readonly ramLimit?: string | undefined readonly playwrightCpuLimit?: string | undefined @@ -192,6 +198,7 @@ export interface ApplyCommand { readonly codexTokenLabel?: string | undefined readonly claudeTokenLabel?: string | undefined readonly geminiTokenLabel?: string | undefined + readonly grokTokenLabel?: string | undefined readonly cpuLimit?: string | undefined readonly ramLimit?: string | undefined readonly playwrightCpuLimit?: string | undefined diff --git a/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts b/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts index da4d81eb..b3bc52c1 100644 --- a/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts +++ b/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts @@ -20,6 +20,8 @@ type DefaultTemplateConfig = Pick< | "codexHome" | "geminiAuthPath" | "geminiHome" + | "grokAuthPath" + | "grokHome" | "cpuLimit" | "ramLimit" | "playwrightCpuLimit" @@ -63,6 +65,8 @@ export const defaultTemplateConfig = { codexHome: "/home/dev/.codex", geminiAuthPath: "./.docker-git/.orch/auth/gemini", geminiHome: "/home/dev/.gemini", + grokAuthPath: "./.docker-git/.orch/auth/grok", + grokHome: "/home/dev/.grok", cpuLimit: defaultCpuLimit, ramLimit: defaultRamLimit, playwrightCpuLimit: defaultPlaywrightCpuLimit, diff --git a/packages/app/src/docker-git/menu-auth-effects.ts b/packages/app/src/docker-git/menu-auth-effects.ts index 50152b4c..7814e114 100644 --- a/packages/app/src/docker-git/menu-auth-effects.ts +++ b/packages/app/src/docker-git/menu-auth-effects.ts @@ -2,6 +2,7 @@ import { Effect, Match, pipe } from "effect" import { createAuthTerminalSession, githubLogin } from "./api-client.js" import { readAuthSnapshot, successMessage, writeAuthFlow } from "./menu-auth-data.js" +import { terminalAuthTitle } from "./menu-auth-shared.js" import type { MenuError } from "./menu-errors.js" import type { AuthSnapshot, MenuEnv, MenuRunner, MenuViewContext, ViewState } from "./menu-types.js" import { attachTerminalSession } from "./terminal-session-client.js" @@ -15,7 +16,9 @@ type AuthEffectContext = MenuViewContext & { readonly cwd: string } -const missingAuthTerminalSessionError = (provider: "ClaudeOauth" | "GeminiOauth"): MenuError => ({ +type TerminalAuthProvider = "ClaudeOauth" | "GeminiOauth" | "GrokOauth" + +const missingAuthTerminalSessionError = (provider: TerminalAuthProvider): MenuError => ({ _tag: "ApiRequestError", method: "POST", path: "/auth/terminal-sessions", @@ -28,7 +31,7 @@ const resolveLabelOption = (values: Readonly>): string | } const resolveTerminalAuthEffect = ( - provider: "ClaudeOauth" | "GeminiOauth", + provider: TerminalAuthProvider, labelOption: string | null ): Effect.Effect => createAuthTerminalSession(provider, labelOption).pipe( @@ -36,7 +39,7 @@ const resolveTerminalAuthEffect = ( session === null ? Effect.fail(missingAuthTerminalSessionError(provider)) : attachTerminalSession({ - header: provider === "ClaudeOauth" ? "Claude Code OAuth" : "Gemini CLI OAuth", + header: terminalAuthTitle(provider), session, websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws` }) @@ -63,6 +66,9 @@ export const resolveAuthPromptEffect = ( Match.when("GeminiOauth", () => resolveTerminalAuthEffect("GeminiOauth", labelOption)), Match.when("GeminiApiKey", (flow) => writeAuthFlow(cwd, flow, values)), Match.when("GeminiLogout", (flow) => writeAuthFlow(cwd, flow, values)), + Match.when("GrokOauth", () => resolveTerminalAuthEffect("GrokOauth", labelOption)), + Match.when("GrokApiKey", (flow) => writeAuthFlow(cwd, flow, values)), + Match.when("GrokLogout", (flow) => writeAuthFlow(cwd, flow, values)), Match.when("GithubRemove", (flow) => writeAuthFlow(cwd, flow, values)), Match.when("GitSet", (flow) => writeAuthFlow(cwd, flow, values)), Match.when("GitRemove", (flow) => writeAuthFlow(cwd, flow, values)), diff --git a/packages/app/src/docker-git/menu-auth-shared.ts b/packages/app/src/docker-git/menu-auth-shared.ts index 7e185224..9f42bfb5 100644 --- a/packages/app/src/docker-git/menu-auth-shared.ts +++ b/packages/app/src/docker-git/menu-auth-shared.ts @@ -4,7 +4,8 @@ import type { AuthFlow } from "./menu-types.js" export type AuthMenuAction = AuthFlow | "Refresh" | "Back" -export type AuthEnvFlow = Exclude +export type AuthEnvFlow = Exclude +export type TerminalAuthFlow = Extract export type AuthPromptStep = { readonly key: "label" | "token" | "user" | "apiKey" @@ -28,6 +29,9 @@ const authMenuItems: ReadonlyArray = [ { action: "GeminiOauth", label: "Gemini CLI: login via OAuth (Google account)" }, { action: "GeminiApiKey", label: "Gemini CLI: set API key" }, { action: "GeminiLogout", label: "Gemini CLI: logout (clear credentials)" }, + { action: "GrokOauth", label: "Grok CLI: login via OAuth (xAI account)" }, + { action: "GrokApiKey", label: "Grok CLI: set API key" }, + { action: "GrokLogout", label: "Grok CLI: logout (clear credentials)" }, { action: "Refresh", label: "Refresh snapshot" }, { action: "Back", label: "Back to main menu" } ] @@ -62,6 +66,16 @@ const flowSteps: Readonly>> = { ], GeminiLogout: [ { key: "label", label: "Label to logout (empty = default)", required: false, secret: false } + ], + GrokOauth: [ + { key: "label", label: "Label (empty = default)", required: false, secret: false } + ], + GrokApiKey: [ + { key: "label", label: "Label (empty = default)", required: false, secret: false }, + { key: "apiKey", label: "Grok API key (from x.ai)", required: true, secret: true } + ], + GrokLogout: [ + { key: "label", label: "Label to logout (empty = default)", required: false, secret: false } ] } @@ -76,6 +90,9 @@ export const successMessage = (flow: AuthFlow, label: string): string => Match.when("GeminiOauth", () => `Saved Gemini CLI OAuth login (${label}).`), Match.when("GeminiApiKey", () => `Saved Gemini API key (${label}).`), Match.when("GeminiLogout", () => `Logged out Gemini CLI (${label}).`), + Match.when("GrokOauth", () => `Saved Grok CLI OAuth login (${label}).`), + Match.when("GrokApiKey", () => `Saved Grok API key (${label}).`), + Match.when("GrokLogout", () => `Logged out Grok CLI (${label}).`), Match.exhaustive ) @@ -90,6 +107,17 @@ export const authViewTitle = (flow: AuthFlow): string => Match.when("GeminiOauth", () => "Gemini CLI OAuth"), Match.when("GeminiApiKey", () => "Gemini CLI API key"), Match.when("GeminiLogout", () => "Gemini CLI logout"), + Match.when("GrokOauth", () => "Grok CLI OAuth"), + Match.when("GrokApiKey", () => "Grok CLI API key"), + Match.when("GrokLogout", () => "Grok CLI logout"), + Match.exhaustive + ) + +export const terminalAuthTitle = (flow: TerminalAuthFlow): string => + Match.value(flow).pipe( + Match.when("ClaudeOauth", () => "Claude Code OAuth"), + Match.when("GeminiOauth", () => "Gemini CLI OAuth"), + Match.when("GrokOauth", () => "Grok CLI OAuth"), Match.exhaustive ) diff --git a/packages/app/src/docker-git/menu-auth-snapshot-builder.ts b/packages/app/src/docker-git/menu-auth-snapshot-builder.ts index 1f241b4c..86ef6d33 100644 --- a/packages/app/src/docker-git/menu-auth-snapshot-builder.ts +++ b/packages/app/src/docker-git/menu-auth-snapshot-builder.ts @@ -8,17 +8,20 @@ import { countAuthAccountDirectories } from "./menu-auth-helpers.js" export type AuthAccountCounts = { readonly claudeAuthEntries: number readonly geminiAuthEntries: number + readonly grokAuthEntries: number } export const countAuthAccountEntries = ( fs: FileSystem.FileSystem, path: Path.Path, claudeAuthPath: string, - geminiAuthPath: string + geminiAuthPath: string, + grokAuthPath: string ): Effect.Effect => pipe( Effect.all({ claudeAuthEntries: countAuthAccountDirectories(fs, path, claudeAuthPath), - geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthPath) + geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthPath), + grokAuthEntries: countAuthAccountDirectories(fs, path, grokAuthPath) }) ) diff --git a/packages/app/src/docker-git/menu-auth.ts b/packages/app/src/docker-git/menu-auth.ts index 314969c0..6d3eca3d 100644 --- a/packages/app/src/docker-git/menu-auth.ts +++ b/packages/app/src/docker-git/menu-auth.ts @@ -100,7 +100,7 @@ const submitAuthPrompt = (view: AuthPromptView, context: AuthInputContext) => { const effect = resolveAuthPromptEffect(view, context.state.cwd, nextValues) runAuthPromptEffect(effect, view, label, { ...context, cwd: context.state.cwd }, { suspendTui: view.flow === "GithubOauth" || view.flow === "ClaudeOauth" || view.flow === "ClaudeLogout" || - view.flow === "GeminiOauth" + view.flow === "GeminiOauth" || view.flow === "GrokOauth" }) } ) diff --git a/packages/app/src/docker-git/menu-project-auth-shared.ts b/packages/app/src/docker-git/menu-project-auth-shared.ts index 6250fe5f..062fefe7 100644 --- a/packages/app/src/docker-git/menu-project-auth-shared.ts +++ b/packages/app/src/docker-git/menu-project-auth-shared.ts @@ -25,6 +25,8 @@ const projectAuthMenuItems: ReadonlyArray = [ { action: "ProjectClaudeDisconnect", label: "Project: Claude disconnect" }, { action: "ProjectGeminiConnect", label: "Project: Gemini connect label" }, { action: "ProjectGeminiDisconnect", label: "Project: Gemini disconnect" }, + { action: "ProjectGrokConnect", label: "Project: Grok connect label" }, + { action: "ProjectGrokDisconnect", label: "Project: Grok disconnect" }, { action: "Refresh", label: "Refresh snapshot" }, { action: "Back", label: "Back to main menu" } ] @@ -45,7 +47,11 @@ const flowSteps: Readonly "Disconnected Claude from project."), Match.when("ProjectGeminiConnect", () => `Connected Gemini label (${label}) to project.`), Match.when("ProjectGeminiDisconnect", () => "Disconnected Gemini from project."), + Match.when("ProjectGrokConnect", () => `Connected Grok label (${label}) to project.`), + Match.when("ProjectGrokDisconnect", () => "Disconnected Grok from project."), Match.exhaustive ) diff --git a/packages/app/src/docker-git/menu-project-auth.ts b/packages/app/src/docker-git/menu-project-auth.ts index d355e64a..a946e4a9 100644 --- a/packages/app/src/docker-git/menu-project-auth.ts +++ b/packages/app/src/docker-git/menu-project-auth.ts @@ -133,7 +133,8 @@ const runProjectAuthAction = ( action === "ProjectGithubDisconnect" || action === "ProjectGitDisconnect" || action === "ProjectClaudeDisconnect" || - action === "ProjectGeminiDisconnect" + action === "ProjectGeminiDisconnect" || + action === "ProjectGrokDisconnect" ) { runProjectAuthEffect(view.project, action, {}, "default", context) return diff --git a/packages/app/src/docker-git/menu-render-auth.ts b/packages/app/src/docker-git/menu-render-auth.ts index 7d0f0d56..7b85c366 100644 --- a/packages/app/src/docker-git/menu-render-auth.ts +++ b/packages/app/src/docker-git/menu-render-auth.ts @@ -11,8 +11,39 @@ import { import { renderLayout } from "./menu-render-layout.js" import type { AuthSnapshot, ViewState } from "./menu-types.js" +type AuthPromptView = Extract +type AuthPromptFlow = AuthPromptView["flow"] + const renderCountLine = (title: string, count: number): string => `${title}: ${count}` +const oauthPromptFlows: ReadonlySet = new Set([ + "GithubOauth", + "ClaudeOauth", + "GeminiOauth", + "GrokOauth" +]) + +const claudePromptFlows: ReadonlySet = new Set(["ClaudeOauth", "ClaudeLogout"]) +const geminiPromptFlows: ReadonlySet = new Set(["GeminiOauth", "GeminiApiKey", "GeminiLogout"]) +const grokPromptFlows: ReadonlySet = new Set(["GrokOauth", "GrokApiKey", "GrokLogout"]) + +const authPromptHelpLine = (flow: AuthPromptFlow): string => { + if (oauthPromptFlows.has(flow)) { + return "Enter = start OAuth, Esc = cancel." + } + if (flow === "ClaudeLogout") { + return "Enter = logout, Esc = cancel." + } + return "Enter = next, Esc = cancel." +} + +const authPromptHeaderPaths = (view: AuthPromptView): ReadonlyArray => [ + `Global env: ${view.snapshot.globalEnvPath}`, + ...(claudePromptFlows.has(view.flow) ? [`Claude auth: ${view.snapshot.claudeAuthPath}`] : []), + ...(geminiPromptFlows.has(view.flow) ? [`Gemini auth: ${view.snapshot.geminiAuthPath}`] : []), + ...(grokPromptFlows.has(view.flow) ? [`Grok auth: ${view.snapshot.grokAuthPath}`] : []) +] + export const renderAuthMenu = ( snapshot: AuthSnapshot, selected: number, @@ -25,11 +56,15 @@ export const renderAuthMenu = ( [ el(Text, null, `Global env: ${snapshot.globalEnvPath}`), el(Text, null, `Claude auth: ${snapshot.claudeAuthPath}`), + el(Text, null, `Gemini auth: ${snapshot.geminiAuthPath}`), + el(Text, null, `Grok auth: ${snapshot.grokAuthPath}`), el(Text, { fg: "gray" }, renderCountLine("Entries", snapshot.totalEntries)), el(Text, { fg: "gray" }, renderCountLine("GitHub tokens", snapshot.githubTokenEntries)), el(Text, { fg: "gray" }, renderCountLine("Git tokens", snapshot.gitTokenEntries)), el(Text, { fg: "gray" }, renderCountLine("Git users", snapshot.gitUserEntries)), el(Text, { fg: "gray" }, renderCountLine("Claude logins", snapshot.claudeAuthEntries)), + el(Text, { fg: "gray" }, renderCountLine("Gemini logins", snapshot.geminiAuthEntries)), + el(Text, { fg: "gray" }, renderCountLine("Grok logins", snapshot.grokAuthEntries)), el(Box, { flexDirection: "column", marginTop: 1 }, ...list), renderMenuHelp("Use arrows + Enter, or type a number.") ], @@ -38,28 +73,17 @@ export const renderAuthMenu = ( } export const renderAuthPrompt = ( - view: Extract, + view: AuthPromptView, message: string | null ): React.ReactElement => { const el = React.createElement const { prompt, visibleBuffer } = resolvePromptState(authViewSteps(view.flow), view.step, view.buffer) - let helpLine = "Enter = next, Esc = cancel." - if (view.flow === "GithubOauth" || view.flow === "ClaudeOauth") { - helpLine = "Enter = start OAuth, Esc = cancel." - } else if (view.flow === "ClaudeLogout") { - helpLine = "Enter = logout, Esc = cancel." - } return renderPromptLayout({ title: `docker-git / Auth / ${authViewTitle(view.flow)}`, - header: [ - el(Text, { fg: "gray" }, `Global env: ${view.snapshot.globalEnvPath}`), - ...(view.flow === "ClaudeOauth" || view.flow === "ClaudeLogout" - ? [el(Text, { fg: "gray" }, `Claude auth: ${view.snapshot.claudeAuthPath}`)] - : []) - ], + header: authPromptHeaderPaths(view).map((line) => el(Text, { fg: "gray" }, line)), prompt, visibleBuffer, - helpLine, + helpLine: authPromptHelpLine(view.flow), message }) } diff --git a/packages/app/src/docker-git/menu-render-project-auth.ts b/packages/app/src/docker-git/menu-render-project-auth.ts index c151f9d4..8b16c62e 100644 --- a/packages/app/src/docker-git/menu-render-project-auth.ts +++ b/packages/app/src/docker-git/menu-render-project-auth.ts @@ -31,6 +31,8 @@ export const renderProjectAuthMenu = ( el(Text, { fg: "gray" }, `Project env: ${snapshot.envProjectPath}`), el(Text, { fg: "gray" }, `Global env: ${snapshot.envGlobalPath}`), el(Text, { fg: "gray" }, `Claude auth: ${snapshot.claudeAuthPath}`), + el(Text, { fg: "gray" }, `Gemini auth: ${snapshot.geminiAuthPath}`), + el(Text, { fg: "gray" }, `Grok auth: ${snapshot.grokAuthPath}`), el( Box, { marginTop: 1, flexDirection: "column" }, @@ -39,7 +41,11 @@ export const renderProjectAuthMenu = ( el(Text, { fg: "gray" }, `Git label: ${renderActiveLabel(snapshot.activeGitLabel)}`), el(Text, { fg: "gray" }, renderCountLine("Available Git tokens", snapshot.gitTokenEntries)), el(Text, { fg: "gray" }, `Claude label: ${renderActiveLabel(snapshot.activeClaudeLabel)}`), - el(Text, { fg: "gray" }, renderCountLine("Available Claude logins", snapshot.claudeAuthEntries)) + el(Text, { fg: "gray" }, renderCountLine("Available Claude logins", snapshot.claudeAuthEntries)), + el(Text, { fg: "gray" }, `Gemini label: ${renderActiveLabel(snapshot.activeGeminiLabel)}`), + el(Text, { fg: "gray" }, renderCountLine("Available Gemini logins", snapshot.geminiAuthEntries)), + el(Text, { fg: "gray" }, `Grok label: ${renderActiveLabel(snapshot.activeGrokLabel)}`), + el(Text, { fg: "gray" }, renderCountLine("Available Grok logins", snapshot.grokAuthEntries)) ), el(Box, { flexDirection: "column", marginTop: 1 }, ...list), renderMenuHelp("Use arrows + Enter, or type a number from the list.") diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index 7bd32792..44df2630 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -90,17 +90,22 @@ export type AuthFlow = | "GeminiOauth" | "GeminiApiKey" | "GeminiLogout" + | "GrokOauth" + | "GrokApiKey" + | "GrokLogout" export interface AuthSnapshot { readonly globalEnvPath: string readonly claudeAuthPath: string readonly geminiAuthPath: string + readonly grokAuthPath: string readonly totalEntries: number readonly githubTokenEntries: number readonly gitTokenEntries: number readonly gitUserEntries: number readonly claudeAuthEntries: number readonly geminiAuthEntries: number + readonly grokAuthEntries: number } export type ProjectAuthFlow = @@ -112,6 +117,8 @@ export type ProjectAuthFlow = | "ProjectClaudeDisconnect" | "ProjectGeminiConnect" | "ProjectGeminiDisconnect" + | "ProjectGrokConnect" + | "ProjectGrokDisconnect" export interface ProjectAuthSnapshot { readonly projectDir: string @@ -120,14 +127,17 @@ export interface ProjectAuthSnapshot { readonly envProjectPath: string readonly claudeAuthPath: string readonly geminiAuthPath: string + readonly grokAuthPath: string readonly githubTokenEntries: number readonly gitTokenEntries: number readonly claudeAuthEntries: number readonly geminiAuthEntries: number + readonly grokAuthEntries: number readonly activeGithubLabel: string | null readonly activeGitLabel: string | null readonly activeClaudeLabel: string | null readonly activeGeminiLabel: string | null + readonly activeGrokLabel: string | null } export type ViewState = diff --git a/packages/app/src/docker-git/program-unsupported.ts b/packages/app/src/docker-git/program-unsupported.ts index e4131b54..db5d545c 100644 --- a/packages/app/src/docker-git/program-unsupported.ts +++ b/packages/app/src/docker-git/program-unsupported.ts @@ -11,6 +11,9 @@ export type UnsupportedOperationalCommandTag = | "AuthGeminiLogin" | "AuthGeminiStatus" | "AuthGeminiLogout" + | "AuthGrokLogin" + | "AuthGrokStatus" + | "AuthGrokLogout" export const unsupportedOperationalCommands: Record< UnsupportedOperationalCommandTag, @@ -51,5 +54,17 @@ export const unsupportedOperationalCommands: Record< AuthGeminiLogout: { command: "auth gemini logout", message: "Only GitHub, GitLab, and Codex auth are routed through the controller in host API mode." + }, + AuthGrokLogin: { + command: "auth grok login", + message: "Only GitHub, GitLab, and Codex auth are routed through the controller in host API mode." + }, + AuthGrokStatus: { + command: "auth grok status", + message: "Only GitHub, GitLab, and Codex auth are routed through the controller in host API mode." + }, + AuthGrokLogout: { + command: "auth grok logout", + message: "Only GitHub, GitLab, and Codex auth are routed through the controller in host API mode." } } diff --git a/packages/app/src/lib/core/auth-domain.ts b/packages/app/src/lib/core/auth-domain.ts index b6ae3f8e..4826933f 100644 --- a/packages/app/src/lib/core/auth-domain.ts +++ b/packages/app/src/lib/core/auth-domain.ts @@ -107,6 +107,35 @@ export interface AuthGeminiLogoutCommand { readonly geminiAuthPath: string } +// CHANGE: add Grok CLI auth commands +// WHY: issue #304 requires Grok login/status/logout profiles with isolated auth storage +// QUOTE(ТЗ): "Реализовать поддержку авторизации grok" +// REF: issue-304 +// SOURCE: https://www.npmjs.com/package/grok-dev +// FORMAT THEOREM: forall cmd ∈ AuthGrokCommand: cmd.grokAuthPath is valid path +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: authentication state is isolated by label +// COMPLEXITY: O(1) +export interface AuthGrokLoginCommand { + readonly _tag: "AuthGrokLogin" + readonly label: string | null + readonly grokAuthPath: string + readonly isWeb: boolean +} + +export interface AuthGrokStatusCommand { + readonly _tag: "AuthGrokStatus" + readonly label: string | null + readonly grokAuthPath: string +} + +export interface AuthGrokLogoutCommand { + readonly _tag: "AuthGrokLogout" + readonly label: string | null + readonly grokAuthPath: string +} + export type AuthCommand = | AuthGithubLoginCommand | AuthGithubStatusCommand @@ -124,4 +153,7 @@ export type AuthCommand = | AuthGeminiLoginCommand | AuthGeminiStatusCommand | AuthGeminiLogoutCommand + | AuthGrokLoginCommand + | AuthGrokStatusCommand + | AuthGrokLogoutCommand /* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/auto-agent-flags.ts b/packages/app/src/lib/core/auto-agent-flags.ts index c1683f36..c5472b66 100644 --- a/packages/app/src/lib/core/auto-agent-flags.ts +++ b/packages/app/src/lib/core/auto-agent-flags.ts @@ -14,13 +14,13 @@ export const resolveAutoAgentFlags = ( if (requested === "auto") { return Either.right({ agentMode: undefined, agentAuto: true }) } - if (requested === "claude" || requested === "codex") { + if (requested === "claude" || requested === "codex" || requested === "gemini" || requested === "grok") { return Either.right({ agentMode: requested, agentAuto: true }) } return Either.left({ _tag: "InvalidOption", option: "--auto", - reason: "expected one of: claude, codex" + reason: "expected one of: claude, codex, gemini, grok" }) } /* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/command-builders-template.ts b/packages/app/src/lib/core/command-builders-template.ts new file mode 100644 index 00000000..f54a214d --- /dev/null +++ b/packages/app/src/lib/core/command-builders-template.ts @@ -0,0 +1,94 @@ +/* jscpd:ignore-start */ +import type { NameConfig, PathConfig, RepoBasics } from "./command-builders.js" +import { type AgentMode, type CreateCommand, defaultTemplateConfig } from "./domain.js" + +export type BuildTemplateConfigInput = { + readonly repo: RepoBasics + readonly names: NameConfig + readonly paths: PathConfig + readonly cpuLimit: string | undefined + readonly ramLimit: string | undefined + readonly playwrightCpuLimit: string | undefined + readonly playwrightRamLimit: string | undefined + readonly gpu: CreateCommand["config"]["gpu"] + readonly dockerNetworkMode: CreateCommand["config"]["dockerNetworkMode"] + readonly dockerSharedNetworkName: string + readonly gitTokenLabel: string | undefined + readonly skipGithubAuth: boolean + readonly codexAuthLabel: string | undefined + readonly claudeAuthLabel: string | undefined + readonly geminiAuthLabel: string | undefined + readonly grokAuthLabel: string | undefined + readonly enableMcpPlaywright: boolean + readonly agentMode: AgentMode | undefined + readonly agentAuto: boolean + readonly clonedOnHostname?: string | undefined +} + +const buildTemplateConfigBase = ( + input: Pick +): Pick< + CreateCommand["config"], + | "containerName" + | "serviceName" + | "sshUser" + | "sshPort" + | "repoUrl" + | "repoRef" + | "targetDir" + | "volumeName" + | "dockerGitPath" + | "authorizedKeysPath" + | "envGlobalPath" + | "envProjectPath" + | "codexAuthPath" + | "codexSharedAuthPath" + | "codexHome" + | "geminiAuthPath" + | "geminiHome" + | "grokAuthPath" + | "grokHome" +> => ({ + containerName: input.names.containerName, + serviceName: input.names.serviceName, + sshUser: input.repo.sshUser, + sshPort: input.repo.sshPort, + repoUrl: input.repo.repoUrl, + repoRef: input.repo.repoRef, + targetDir: input.repo.targetDir, + volumeName: input.names.volumeName, + dockerGitPath: input.paths.dockerGitPath, + authorizedKeysPath: input.paths.authorizedKeysPath, + envGlobalPath: input.paths.envGlobalPath, + envProjectPath: input.paths.envProjectPath, + codexAuthPath: input.paths.codexAuthPath, + codexSharedAuthPath: input.paths.codexSharedAuthPath, + codexHome: input.paths.codexHome, + geminiAuthPath: input.paths.geminiAuthPath, + geminiHome: input.paths.geminiHome, + grokAuthPath: input.paths.grokAuthPath, + grokHome: input.paths.grokHome +}) + +export const buildTemplateConfig = (input: BuildTemplateConfigInput): CreateCommand["config"] => ({ + ...buildTemplateConfigBase(input), + gitTokenLabel: input.gitTokenLabel, + skipGithubAuth: input.skipGithubAuth, + codexAuthLabel: input.codexAuthLabel, + claudeAuthLabel: input.claudeAuthLabel, + geminiAuthLabel: input.geminiAuthLabel, + grokAuthLabel: input.grokAuthLabel, + cpuLimit: input.cpuLimit, + ramLimit: input.ramLimit, + playwrightCpuLimit: input.playwrightCpuLimit, + playwrightRamLimit: input.playwrightRamLimit, + gpu: input.gpu, + dockerNetworkMode: input.dockerNetworkMode, + dockerSharedNetworkName: input.dockerSharedNetworkName, + enableMcpPlaywright: input.enableMcpPlaywright, + bunVersion: defaultTemplateConfig.bunVersion, + agentMode: input.agentMode, + agentAuto: input.agentAuto, + clonedOnHostname: input.clonedOnHostname +}) +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/command-builders.ts b/packages/app/src/lib/core/command-builders.ts index 44c0bd88..d6dd8730 100644 --- a/packages/app/src/lib/core/command-builders.ts +++ b/packages/app/src/lib/core/command-builders.ts @@ -10,9 +10,9 @@ import { parseSshPort, parseSshUser } from "./command-builders-shared.js" +import { buildTemplateConfig } from "./command-builders-template.js" import { type RawOptions } from "./command-options.js" import { - type AgentMode, type CreateCommand, defaultTemplateConfig, deriveRepoPathParts, @@ -28,7 +28,7 @@ export { nonEmpty } from "./command-builders-shared.js" const normalizeSecretsRoot = (value: string): string => trimRightChar(value, "/") -type RepoBasics = { +export type RepoBasics = { readonly repoUrl: string readonly repoSlug: string readonly projectSlug: string @@ -62,7 +62,7 @@ const resolveRepoBasics = (raw: RawOptions): Either.Either { @@ -122,7 +125,8 @@ const buildDefaultPathConfig = ( envGlobalPath: defaultTemplateConfig.envGlobalPath, envProjectPath: defaultTemplateConfig.envProjectPath, codexAuthPath: defaultTemplateConfig.codexAuthPath, - geminiAuthPath: defaultTemplateConfig.geminiAuthPath + geminiAuthPath: defaultTemplateConfig.geminiAuthPath, + grokAuthPath: defaultTemplateConfig.grokAuthPath } : { // NOTE: Keep docker-git root mount stable (projects root) so caches like @@ -132,7 +136,8 @@ const buildDefaultPathConfig = ( envGlobalPath: `${normalizedSecretsRoot}/global.env`, envProjectPath: defaultTemplateConfig.envProjectPath, codexAuthPath: `${normalizedSecretsRoot}/codex`, - geminiAuthPath: `${normalizedSecretsRoot}/gemini` + geminiAuthPath: `${normalizedSecretsRoot}/gemini`, + grokAuthPath: `${normalizedSecretsRoot}/grok` } const resolvePaths = ( @@ -157,6 +162,8 @@ const resolvePaths = ( const codexHome = yield* _(nonEmpty("--codex-home", raw.codexHome, defaultTemplateConfig.codexHome)) const geminiAuthPath = defaults.geminiAuthPath const geminiHome = defaultTemplateConfig.geminiHome + const grokAuthPath = defaults.grokAuthPath + const grokHome = defaultTemplateConfig.grokHome const outDir = yield* _(nonEmpty("--out-dir", raw.outDir, `.docker-git/${repoPath}`)) return { @@ -169,6 +176,8 @@ const resolvePaths = ( codexHome, geminiAuthPath, geminiHome, + grokAuthPath, + grokHome, outDir } }) @@ -191,86 +200,20 @@ const resolveCreateBehavior = (raw: RawOptions): CreateBehavior => ({ enableMcpPlaywright: raw.enableMcpPlaywright ?? false }) -type BuildTemplateConfigInput = { - readonly repo: RepoBasics - readonly names: NameConfig - readonly paths: PathConfig - readonly cpuLimit: string | undefined - readonly ramLimit: string | undefined - readonly playwrightCpuLimit: string | undefined - readonly playwrightRamLimit: string | undefined - readonly gpu: CreateCommand["config"]["gpu"] - readonly dockerNetworkMode: CreateCommand["config"]["dockerNetworkMode"] - readonly dockerSharedNetworkName: string +type TokenLabelConfig = { readonly gitTokenLabel: string | undefined - readonly skipGithubAuth: boolean readonly codexAuthLabel: string | undefined readonly claudeAuthLabel: string | undefined - readonly enableMcpPlaywright: boolean - readonly agentMode: AgentMode | undefined - readonly agentAuto: boolean - readonly clonedOnHostname?: string | undefined + readonly geminiAuthLabel: string | undefined + readonly grokAuthLabel: string | undefined } -const buildTemplateConfigBase = ( - input: Pick -): Pick< - CreateCommand["config"], - | "containerName" - | "serviceName" - | "sshUser" - | "sshPort" - | "repoUrl" - | "repoRef" - | "targetDir" - | "volumeName" - | "dockerGitPath" - | "authorizedKeysPath" - | "envGlobalPath" - | "envProjectPath" - | "codexAuthPath" - | "codexSharedAuthPath" - | "codexHome" - | "geminiAuthPath" - | "geminiHome" -> => ({ - containerName: input.names.containerName, - serviceName: input.names.serviceName, - sshUser: input.repo.sshUser, - sshPort: input.repo.sshPort, - repoUrl: input.repo.repoUrl, - repoRef: input.repo.repoRef, - targetDir: input.repo.targetDir, - volumeName: input.names.volumeName, - dockerGitPath: input.paths.dockerGitPath, - authorizedKeysPath: input.paths.authorizedKeysPath, - envGlobalPath: input.paths.envGlobalPath, - envProjectPath: input.paths.envProjectPath, - codexAuthPath: input.paths.codexAuthPath, - codexSharedAuthPath: input.paths.codexSharedAuthPath, - codexHome: input.paths.codexHome, - geminiAuthPath: input.paths.geminiAuthPath, - geminiHome: input.paths.geminiHome -}) - -const buildTemplateConfig = (input: BuildTemplateConfigInput): CreateCommand["config"] => ({ - ...buildTemplateConfigBase(input), - gitTokenLabel: input.gitTokenLabel, - skipGithubAuth: input.skipGithubAuth, - codexAuthLabel: input.codexAuthLabel, - claudeAuthLabel: input.claudeAuthLabel, - cpuLimit: input.cpuLimit, - ramLimit: input.ramLimit, - playwrightCpuLimit: input.playwrightCpuLimit, - playwrightRamLimit: input.playwrightRamLimit, - gpu: input.gpu, - dockerNetworkMode: input.dockerNetworkMode, - dockerSharedNetworkName: input.dockerSharedNetworkName, - enableMcpPlaywright: input.enableMcpPlaywright, - bunVersion: defaultTemplateConfig.bunVersion, - agentMode: input.agentMode, - agentAuto: input.agentAuto, - clonedOnHostname: input.clonedOnHostname +const resolveTokenLabels = (raw: RawOptions): TokenLabelConfig => ({ + gitTokenLabel: normalizeGitTokenLabel(raw.gitTokenLabel), + codexAuthLabel: normalizeAuthLabel(raw.codexTokenLabel), + claudeAuthLabel: normalizeAuthLabel(raw.claudeTokenLabel), + geminiAuthLabel: normalizeAuthLabel(raw.geminiTokenLabel), + grokAuthLabel: normalizeAuthLabel(raw.grokTokenLabel) }) // CHANGE: build a typed create command from raw options (CLI or API) @@ -291,9 +234,7 @@ export const buildCreateCommand = ( const names = yield* _(resolveNames(raw, repo.projectSlug)) const paths = yield* _(resolvePaths(raw, repo.repoPath)) const behavior = resolveCreateBehavior(raw) - const gitTokenLabel = normalizeGitTokenLabel(raw.gitTokenLabel) - const codexAuthLabel = normalizeAuthLabel(raw.codexTokenLabel) - const claudeAuthLabel = normalizeAuthLabel(raw.claudeTokenLabel) + const tokenLabels = resolveTokenLabels(raw) const limits = yield* _(resolveResourceLimitsIntent(raw)) const gpu = yield* _(parseGpuMode(raw.gpu)) const dockerNetworkMode = yield* _(parseDockerNetworkMode(raw.dockerNetworkMode)) @@ -321,10 +262,8 @@ export const buildCreateCommand = ( gpu, dockerNetworkMode, dockerSharedNetworkName, - gitTokenLabel, + ...tokenLabels, skipGithubAuth: behavior.skipGithubAuth, - codexAuthLabel, - claudeAuthLabel, enableMcpPlaywright: behavior.enableMcpPlaywright, agentMode, agentAuto diff --git a/packages/app/src/lib/core/command-options.ts b/packages/app/src/lib/core/command-options.ts index 12f15f15..5c4b02da 100644 --- a/packages/app/src/lib/core/command-options.ts +++ b/packages/app/src/lib/core/command-options.ts @@ -41,6 +41,8 @@ export interface RawOptions { readonly gitTokenLabel?: string readonly codexTokenLabel?: string readonly claudeTokenLabel?: string + readonly geminiTokenLabel?: string + readonly grokTokenLabel?: string readonly token?: string readonly scopes?: string readonly message?: string diff --git a/packages/app/src/lib/core/domain.ts b/packages/app/src/lib/core/domain.ts index 74bb7a64..f411e7a6 100644 --- a/packages/app/src/lib/core/domain.ts +++ b/packages/app/src/lib/core/domain.ts @@ -20,7 +20,10 @@ export type { AuthGithubStatusCommand, AuthGitlabLoginCommand, AuthGitlabLogoutCommand, - AuthGitlabStatusCommand + AuthGitlabStatusCommand, + AuthGrokLoginCommand, + AuthGrokLogoutCommand, + AuthGrokStatusCommand } from "./auth-domain.js" export type { MenuAction, ParseError } from "./menu.js" export { parseMenuSelection } from "./menu.js" @@ -53,7 +56,7 @@ export { dockerGitSharedCodexVolumeName } from "./template-defaults.js" -export type AgentMode = "claude" | "codex" | "gemini" +export type AgentMode = "claude" | "codex" | "gemini" | "grok" export type DockerNetworkMode = "shared" | "project" export type GpuMode = "none" | "all" @@ -97,6 +100,9 @@ export interface TemplateConfig { readonly geminiAuthLabel?: string | undefined readonly geminiAuthPath: string readonly geminiHome: string + readonly grokAuthLabel?: string | undefined + readonly grokAuthPath: string + readonly grokHome: string readonly cpuLimit?: string | undefined readonly ramLimit?: string | undefined readonly playwrightCpuLimit?: string | undefined @@ -188,6 +194,7 @@ export interface ApplyCommand { readonly codexTokenLabel?: string | undefined readonly claudeTokenLabel?: string | undefined readonly geminiTokenLabel?: string | undefined + readonly grokTokenLabel?: string | undefined readonly cpuLimit?: string | undefined readonly ramLimit?: string | undefined readonly playwrightCpuLimit?: string | undefined diff --git a/packages/app/src/lib/core/template-defaults.ts b/packages/app/src/lib/core/template-defaults.ts index da4d81eb..b3bc52c1 100644 --- a/packages/app/src/lib/core/template-defaults.ts +++ b/packages/app/src/lib/core/template-defaults.ts @@ -20,6 +20,8 @@ type DefaultTemplateConfig = Pick< | "codexHome" | "geminiAuthPath" | "geminiHome" + | "grokAuthPath" + | "grokHome" | "cpuLimit" | "ramLimit" | "playwrightCpuLimit" @@ -63,6 +65,8 @@ export const defaultTemplateConfig = { codexHome: "/home/dev/.codex", geminiAuthPath: "./.docker-git/.orch/auth/gemini", geminiHome: "/home/dev/.gemini", + grokAuthPath: "./.docker-git/.orch/auth/grok", + grokHome: "/home/dev/.grok", cpuLimit: defaultCpuLimit, ramLimit: defaultRamLimit, playwrightCpuLimit: defaultPlaywrightCpuLimit, diff --git a/packages/app/src/lib/core/templates-entrypoint.ts b/packages/app/src/lib/core/templates-entrypoint.ts index fce6deb9..43b16baa 100644 --- a/packages/app/src/lib/core/templates-entrypoint.ts +++ b/packages/app/src/lib/core/templates-entrypoint.ts @@ -24,6 +24,7 @@ import { import { renderEntrypointDnsRepair } from "./templates-entrypoint/dns-repair.js" import { renderEntrypointGeminiConfig } from "./templates-entrypoint/gemini.js" import { renderEntrypointGitConfig, renderEntrypointGitHooks } from "./templates-entrypoint/git.js" +import { renderEntrypointGrokConfig } from "./templates-entrypoint/grok.js" import { renderEntrypointDockerGitBootstrap } from "./templates-entrypoint/nested-docker-git.js" import { renderEntrypointOpenCodeConfig } from "./templates-entrypoint/opencode.js" import { renderEntrypointProjectAgentRules } from "./templates-entrypoint/project-rules.js" @@ -62,6 +63,7 @@ export const renderEntrypoint = (config: TemplateConfig): string => renderEntrypointGitConfig(config), renderEntrypointClaudeConfig(config), renderEntrypointGeminiConfig(config), + renderEntrypointGrokConfig(config), renderEntrypointRtkConfig(config), renderEntrypointGitHooks(), renderEntrypointBackgroundTasks(config), diff --git a/packages/app/src/lib/core/templates-entrypoint/agent.ts b/packages/app/src/lib/core/templates-entrypoint/agent.ts index d7517f3f..0db5871b 100644 --- a/packages/app/src/lib/core/templates-entrypoint/agent.ts +++ b/packages/app/src/lib/core/templates-entrypoint/agent.ts @@ -2,7 +2,7 @@ import { Match } from "effect" import type { TemplateConfig } from "../domain.js" -type AgentMode = "claude" | "codex" | "gemini" +type AgentMode = "claude" | "codex" | "gemini" | "grok" const indentBlock = (block: string, size = 2): string => { const prefix = " ".repeat(size) @@ -41,6 +41,7 @@ AGENT_ENV_FILE="/run/docker-git/agent-env.sh" [[ -f /etc/profile.d/gh-token.sh ]] && cat /etc/profile.d/gh-token.sh [[ -f /etc/profile.d/claude-config.sh ]] && cat /etc/profile.d/claude-config.sh [[ -f /etc/profile.d/gemini-config.sh ]] && cat /etc/profile.d/gemini-config.sh + [[ -f /etc/profile.d/grok-config.sh ]] && cat /etc/profile.d/grok-config.sh } > "$AGENT_ENV_FILE" 2>/dev/null || true chmod 644 "$AGENT_ENV_FILE"`, renderAgentPrompt(), @@ -61,6 +62,8 @@ const renderAgentPromptCommand = (mode: AgentMode): string => ), Match.when("codex", () => String.raw`MCP_PLAYWRIGHT_ISOLATED=1 codex exec \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), Match.when("gemini", () => String.raw`gemini --approval-mode=yolo \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), + Match.when("grok", () => + String.raw`MCP_PLAYWRIGHT_ISOLATED=1 grok --no-sandbox -p \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), Match.exhaustive ) @@ -99,6 +102,7 @@ const renderAgentModeCase = (config: TemplateConfig): string => indentBlock(renderAgentModeBlock(config, "claude")), indentBlock(renderAgentModeBlock(config, "codex")), indentBlock(renderAgentModeBlock(config, "gemini")), + indentBlock(renderAgentModeBlock(config, "grok")), indentBlock( String.raw`*) echo "[agent] unknown agent mode: $AGENT_MODE" diff --git a/packages/app/src/lib/core/templates-entrypoint/base.ts b/packages/app/src/lib/core/templates-entrypoint/base.ts index 659d38a2..1ba0bd21 100644 --- a/packages/app/src/lib/core/templates-entrypoint/base.ts +++ b/packages/app/src/lib/core/templates-entrypoint/base.ts @@ -24,6 +24,7 @@ fi CLAUDE_AUTH_LABEL="\${CLAUDE_AUTH_LABEL:-}" CODEX_AUTH_LABEL="\${CODEX_AUTH_LABEL:-}" GEMINI_AUTH_LABEL="\${GEMINI_AUTH_LABEL:-}" +GROK_AUTH_LABEL="\${GROK_AUTH_LABEL:-}" GIT_AUTH_USER="\${GIT_AUTH_USER:-\${GITHUB_USER:-}}" GITLAB_TOKEN="\${GITLAB_TOKEN:-}" GIT_AUTH_TOKEN="\${GIT_AUTH_TOKEN:-\${GITHUB_TOKEN:-\${GH_TOKEN:-}}}" diff --git a/packages/app/src/lib/core/templates-entrypoint/grok.ts b/packages/app/src/lib/core/templates-entrypoint/grok.ts new file mode 100644 index 00000000..ebfd4258 --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/grok.ts @@ -0,0 +1,255 @@ +/* jscpd:ignore-start */ +import type { TemplateConfig } from "../domain.js" + +// CHANGE: add Grok CLI entrypoint configuration +// WHY: issue #304 requires Grok auth, Playwright MCP and unrestricted agent permissions +// QUOTE(ТЗ): "Реализовать поддержку авторизации grok" +// REF: issue-304 +// SOURCE: https://www.npmjs.com/package/grok-dev +// FORMAT THEOREM: renderEntrypointGrokConfig(config) -> valid_bash_script +// PURITY: CORE +// INVARIANT: Grok credentials are isolated by GROK_AUTH_LABEL +// COMPLEXITY: O(1) + +const grokAuthRootContainerPath = (sshUser: string): string => `/home/${sshUser}/.docker-git/.orch/auth/grok` + +const grokAuthConfigTemplate = String + .raw`# Grok CLI: keep ~/.grok as a real home directory while sharing auth files from ~/.docker-git/.orch/auth/grok +GROK_LABEL_RAW="$GROK_AUTH_LABEL" +if [[ -z "$GROK_LABEL_RAW" ]]; then + GROK_LABEL_RAW="default" +fi + +GROK_LABEL_NORM="$(printf "%s" "$GROK_LABEL_RAW" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" +if [[ -z "$GROK_LABEL_NORM" ]]; then + GROK_LABEL_NORM="default" +fi + +GROK_AUTH_ROOT="__GROK_AUTH_ROOT__" +export GROK_CONFIG_DIR="$GROK_AUTH_ROOT/$GROK_LABEL_NORM" + +mkdir -p "$GROK_CONFIG_DIR" || true +GROK_HOME_DIR="__GROK_HOME_DIR__" +mkdir -p "$GROK_HOME_DIR" || true +GROK_SHARED_HOME_DIR="$GROK_CONFIG_DIR/.grok" +mkdir -p "$GROK_SHARED_HOME_DIR" || true + +docker_git_link_grok_file() { + local source_path="$1" + local link_path="$2" + + if [[ -e "$link_path" && ! -L "$link_path" ]]; then + if [[ -f "$link_path" && ! -e "$source_path" ]]; then + cp "$link_path" "$source_path" || true + chmod 0600 "$source_path" || true + fi + return 0 + fi + + ln -sfn "$source_path" "$link_path" || true +} + +docker_git_prepare_grok_home_dir() { + if [[ -L "$GROK_HOME_DIR" ]]; then + local previous_target + previous_target="$(readlink -f "$GROK_HOME_DIR" || true)" + rm -f "$GROK_HOME_DIR" || true + mkdir -p "$GROK_HOME_DIR" || true + if [[ -n "$previous_target" && -d "$previous_target" ]]; then + cp -a "$previous_target"/. "$GROK_HOME_DIR"/ 2>/dev/null || true + fi + return 0 + fi + + mkdir -p "$GROK_HOME_DIR" || true +} + +docker_git_prepare_grok_home_dir + +docker_git_link_grok_file "$GROK_CONFIG_DIR/.api-key" "$GROK_HOME_DIR/.api-key" +docker_git_link_grok_file "$GROK_CONFIG_DIR/.env" "$GROK_HOME_DIR/.env" +docker_git_link_grok_file "$GROK_SHARED_HOME_DIR/user-settings.json" "$GROK_HOME_DIR/user-settings.json" +docker_git_link_grok_file "$GROK_SHARED_HOME_DIR/settings.json" "$GROK_HOME_DIR/settings.json" + +GROK_REAL_BIN="$(command -v grok || echo "/usr/local/bin/grok")" +GROK_WRAPPER_BIN="/usr/local/bin/grok-wrapper" +if [[ -f "$GROK_REAL_BIN" && "$GROK_REAL_BIN" != "$GROK_WRAPPER_BIN" ]]; then + if [[ ! -f "$GROK_WRAPPER_BIN" ]]; then + cat <<'EOF' > "$GROK_WRAPPER_BIN" +#!/usr/bin/env bash +GROK_ORIGINAL_BIN="__GROK_REAL_BIN__" +for arg in "$@"; do + if [[ "$arg" == "--no-sandbox" ]]; then + exec "$GROK_ORIGINAL_BIN" "$@" + fi +done +exec "$GROK_ORIGINAL_BIN" --no-sandbox "$@" +EOF + sed -i "s#__GROK_REAL_BIN__#$GROK_REAL_BIN#g" "$GROK_WRAPPER_BIN" || true + chmod 0755 "$GROK_WRAPPER_BIN" || true + fi +fi + +docker_git_refresh_grok_env() { + if [[ -f "$GROK_HOME_DIR/.api-key" ]]; then + export GROK_API_KEY="$(cat "$GROK_HOME_DIR/.api-key" | tr -d '\r\n')" + elif [[ -f "$GROK_HOME_DIR/.env" ]]; then + API_KEY="$(grep "^GROK_API_KEY=" "$GROK_HOME_DIR/.env" | cut -d'=' -f2- | sed "s/^['\"]//;s/['\"]$//")" + if [[ -n "$API_KEY" ]]; then + export GROK_API_KEY="$API_KEY" + fi + fi + if [[ -n "\${GROK_API_KEY:-}" ]]; then + export XAI_API_KEY="$GROK_API_KEY" + fi +} + +docker_git_refresh_grok_env` + +const renderGrokAuthConfig = (config: TemplateConfig): string => + grokAuthConfigTemplate + .replaceAll("__GROK_AUTH_ROOT__", grokAuthRootContainerPath(config.sshUser)) + .replaceAll("__GROK_HOME_DIR__", config.grokHome) + +const grokSettingsJsonTemplate = `{ + "sandboxMode": "off", + "confirmBeforeToolUse": false, + "mcpServers": { + "playwright": { + "command": "docker-git-playwright-mcp", + "args": [], + "trust": true + } + } +}` + +const grokUserSettingsJsonTemplate = `{ + "sandboxMode": "off", + "confirmBeforeToolUse": false +}` + +const renderGrokPermissionSettingsConfig = (config: TemplateConfig): string => + String.raw`# Grok CLI: keep sandbox and MCP settings in sync with docker-git defaults +GROK_SETTINGS_DIR="${config.grokHome}" +GROK_CONFIG_SETTINGS_FILE="$GROK_SETTINGS_DIR/settings.json" +GROK_USER_SETTINGS_FILE="$GROK_SETTINGS_DIR/user-settings.json" + +mkdir -p "$GROK_SETTINGS_DIR" || true + +cat <<'EOF' > "$GROK_CONFIG_SETTINGS_FILE" +${grokSettingsJsonTemplate} +EOF + +if [[ ! -s "$GROK_USER_SETTINGS_FILE" ]]; then + cat <<'EOF' > "$GROK_USER_SETTINGS_FILE" +${grokUserSettingsJsonTemplate} +EOF +fi + +chown -R 1000:1000 "$GROK_SETTINGS_DIR" || true +chmod 0600 "$GROK_CONFIG_SETTINGS_FILE" "$GROK_USER_SETTINGS_FILE" 2>/dev/null || true` + +const renderGrokSudoConfig = (config: TemplateConfig): string => + String.raw`# Grok CLI: allow passwordless sudo for agent tasks +if [[ -d /etc/sudoers.d ]]; then + echo "${config.sshUser} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/grok-agent + chmod 0440 /etc/sudoers.d/grok-agent +fi` + +const renderGrokProfileSetup = (config: TemplateConfig): string => + String.raw`GROK_PROFILE="/etc/profile.d/grok-config.sh" +printf "export GROK_AUTH_LABEL=%q\n" "$GROK_AUTH_LABEL" > "$GROK_PROFILE" +printf "export GROK_HOME=%q\n" "${config.grokHome}" >> "$GROK_PROFILE" +printf "alias grok='/usr/local/bin/grok-wrapper'\n" >> "$GROK_PROFILE" +cat <<'EOF' >> "$GROK_PROFILE" +if [[ -f "$GROK_HOME/.api-key" ]]; then + export GROK_API_KEY="$(cat "$GROK_HOME/.api-key" | tr -d '\r\n')" + export XAI_API_KEY="$GROK_API_KEY" +fi +EOF +chmod 0644 "$GROK_PROFILE" || true + +docker_git_upsert_ssh_env "GROK_AUTH_LABEL" "$GROK_AUTH_LABEL" +docker_git_upsert_ssh_env "GROK_API_KEY" "\${GROK_API_KEY:-}" +docker_git_upsert_ssh_env "XAI_API_KEY" "\${XAI_API_KEY:-}"` + +const entrypointGrokNoticeTemplate = String.raw`# Ensure global GROK.md exists for container context +GROK_MD_PATH="__GROK_HOME__/GROK.md" +GROK_WORKSPACE_CONTEXT="Контекст workspace: repository" +if [[ "$REPO_REF" == issue-* ]]; then + ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" + ISSUE_URL="" + if [[ "$REPO_URL" == https://github.com/* ]]; then + ISSUE_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$ISSUE_REPO" ]]; then + ISSUE_URL="https://github.com/$ISSUE_REPO/issues/$ISSUE_ID" + fi + fi + if [[ -n "$ISSUE_URL" ]]; then + GROK_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID ($ISSUE_URL)" + else + GROK_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID" + fi +elif [[ "$REPO_REF" == refs/pull/*/head ]]; then + PR_ID="$(printf "%s" "$REPO_REF" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" + PR_URL="" + if [[ "$REPO_URL" == https://github.com/* && -n "$PR_ID" ]]; then + PR_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$PR_REPO" ]]; then + PR_URL="https://github.com/$PR_REPO/pull/$PR_ID" + fi + fi + if [[ -n "$PR_ID" && -n "$PR_URL" ]]; then + GROK_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID ($PR_URL)" + elif [[ -n "$PR_ID" ]]; then + GROK_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID" + else + GROK_WORKSPACE_CONTEXT="Контекст workspace: pull request ($REPO_REF)" + fi +fi + +GROK_SYSTEM_PROMPT_OVERRIDE_FILE="${"$"}{GROK_SYSTEM_PROMPT_OVERRIDE_FILE:-}" +GROK_SYSTEM_PROMPT_OVERRIDE="${"$"}{GROK_SYSTEM_PROMPT_OVERRIDE:-}" +GROK_DEFAULT_PROMPT_BODY="$(cat < "$GROK_MD_PATH" + +$GROK_PROMPT_BODY + +EOF +chown 1000:1000 "$GROK_MD_PATH" || true` + +const renderEntrypointGrokNotice = (config: TemplateConfig): string => + entrypointGrokNoticeTemplate + .replaceAll("__GROK_HOME__", config.grokHome) + .replaceAll("__TARGET_DIR__", config.targetDir) + +export const renderEntrypointGrokConfig = (config: TemplateConfig): string => + [ + renderGrokAuthConfig(config), + renderGrokPermissionSettingsConfig(config), + renderGrokSudoConfig(config), + renderGrokProfileSetup(config), + renderEntrypointGrokNotice(config) + ].join("\n\n") +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/project-rules.ts b/packages/app/src/lib/core/templates-entrypoint/project-rules.ts index 7e85cc1e..17a8e619 100644 --- a/packages/app/src/lib/core/templates-entrypoint/project-rules.ts +++ b/packages/app/src/lib/core/templates-entrypoint/project-rules.ts @@ -1,9 +1,9 @@ /* jscpd:ignore-start */ // CHANGE: separate project rule preparation by active agent mode -// WHY: Codex, Claude Code, and Gemini CLI each have different native project-level config models +// WHY: Codex, Claude Code, Gemini CLI, and Grok CLI each have different native project-level config models // REF: issue-207 // PURITY: CORE -// INVARIANT: Codex gets a bridge for skills that live outside CODEX_HOME; Claude/Gemini stay on native project-local discovery +// INVARIANT: Codex gets a bridge for skills that live outside CODEX_HOME; Claude/Gemini/Grok stay on native project-local discovery // COMPLEXITY: O(1) const entrypointProjectAgentRulesTemplate = String .raw`# Prepare project-local rules using each agent's native conventions. @@ -39,6 +39,22 @@ docker_git_detect_gemini_project_rules() { fi } +docker_git_detect_grok_project_rules() { + local project_dir="${"$"}{TARGET_DIR:-}" + + if [[ -z "$project_dir" || ! -d "$project_dir" ]]; then + return 0 + fi + + if [[ -f "$project_dir/GROK.md" \ + || -f "$project_dir/.grok/settings.json" \ + || -d "$project_dir/.grok/commands" \ + || -d "$project_dir/.grok/skills" \ + || -d "$project_dir/.agents/skills" ]]; then + echo "[grok] project-local Grok rules available in $project_dir" + fi +} + docker_git_prepare_active_agent_project_rules() { case "$AGENT_MODE" in "codex") @@ -50,10 +66,14 @@ docker_git_prepare_active_agent_project_rules() { "gemini") docker_git_detect_gemini_project_rules ;; + "grok") + docker_git_detect_grok_project_rules + ;; *) docker_git_sync_project_codex_skills docker_git_detect_claude_project_rules docker_git_detect_gemini_project_rules + docker_git_detect_grok_project_rules ;; esac }` diff --git a/packages/app/src/lib/core/templates-entrypoint/rtk.ts b/packages/app/src/lib/core/templates-entrypoint/rtk.ts index 4a2dc994..13bc72ff 100644 --- a/packages/app/src/lib/core/templates-entrypoint/rtk.ts +++ b/packages/app/src/lib/core/templates-entrypoint/rtk.ts @@ -6,7 +6,7 @@ import type { TemplateConfig } from "../domain.js" // QUOTE(TASK): "make it work out of the box for docker-git" // REF: issue-266 // SOURCE: https://github.com/rtk-ai/rtk/blob/develop/README.md -// FORMAT THEOREM: forall start: RTK_ENABLED(start) -> configured(codex, claude, gemini, opencode) +// FORMAT THEOREM: forall start: RTK_ENABLED(start) -> configured(codex, claude, gemini, grok, opencode) // PURITY: CORE (pure template renderer) // INVARIANT: RTK init runs as the non-root SSH user and never blocks container startup. // COMPLEXITY: O(1) @@ -28,8 +28,8 @@ docker_git_rtk_init_as_user() { return 0 fi - mkdir -p "$CLAUDE_CONFIG_DIR" "__CODEX_HOME__" "/home/__SSH_USER__/.config/opencode" "/home/__SSH_USER__/.gemini" || true - chown -R 1000:1000 "$CLAUDE_CONFIG_DIR" "__CODEX_HOME__" "/home/__SSH_USER__/.config" "/home/__SSH_USER__/.gemini" 2>/dev/null || true + mkdir -p "$CLAUDE_CONFIG_DIR" "__CODEX_HOME__" "/home/__SSH_USER__/.config/opencode" "/home/__SSH_USER__/.gemini" "/home/__SSH_USER__/.grok" || true + chown -R 1000:1000 "$CLAUDE_CONFIG_DIR" "__CODEX_HOME__" "/home/__SSH_USER__/.config" "/home/__SSH_USER__/.gemini" "/home/__SSH_USER__/.grok" 2>/dev/null || true if su - __SSH_USER__ -s /bin/bash -c "$command" +type AuthEnvFragments = Pick< + ComposeFragments, + | "maybeGitTokenLabelEnv" + | "maybeCodexAuthLabelEnv" + | "maybeClaudeAuthLabelEnv" + | "maybeGeminiAuthLabelEnv" + | "maybeGrokAuthLabelEnv" +> + +type AgentEnvFragments = Pick + export type ComposeResourceLimits = { readonly main: ResolvedComposeResourceLimits | undefined readonly playwright: ResolvedComposeResourceLimits | undefined @@ -60,6 +73,16 @@ const renderClaudeAuthLabelEnv = (claudeAuthLabel: string): string => ? ` CLAUDE_AUTH_LABEL: "${claudeAuthLabel}"\n` : "" +const renderGeminiAuthLabelEnv = (geminiAuthLabel: string): string => + geminiAuthLabel.length > 0 + ? ` GEMINI_AUTH_LABEL: "${geminiAuthLabel}"\n` + : "" + +const renderGrokAuthLabelEnv = (grokAuthLabel: string): string => + grokAuthLabel.length > 0 + ? ` GROK_AUTH_LABEL: "${grokAuthLabel}"\n` + : "" + const renderAgentModeEnv = (agentMode: string | undefined): string => agentMode !== undefined && agentMode.length > 0 ? ` AGENT_MODE: "${agentMode}"\n` @@ -85,6 +108,21 @@ const renderBootstrapMounts = (): string => ` - ${bootstrapVolumeKey}:/opt/ const renderEnvFiles = (config: TemplateConfig): string => ` env_file:\n - ${config.envGlobalPath}\n - ${config.envProjectPath}\n` +const optionalTrimmed = (value: string | undefined): string => value?.trim() ?? "" + +const buildAuthEnvFragments = (config: TemplateConfig): AuthEnvFragments => ({ + maybeGitTokenLabelEnv: renderGitTokenLabelEnv(optionalTrimmed(config.gitTokenLabel)), + maybeCodexAuthLabelEnv: renderCodexAuthLabelEnv(optionalTrimmed(config.codexAuthLabel)), + maybeClaudeAuthLabelEnv: renderClaudeAuthLabelEnv(optionalTrimmed(config.claudeAuthLabel)), + maybeGeminiAuthLabelEnv: renderGeminiAuthLabelEnv(optionalTrimmed(config.geminiAuthLabel)), + maybeGrokAuthLabelEnv: renderGrokAuthLabelEnv(optionalTrimmed(config.grokAuthLabel)) +}) + +const buildAgentEnvFragments = (config: TemplateConfig): AgentEnvFragments => ({ + maybeAgentModeEnv: renderAgentModeEnv(config.agentMode), + maybeAgentAutoEnv: renderAgentAutoEnv(config.agentAuto) +}) + const buildPlaywrightFragments = ( config: TemplateConfig, networkName: string, @@ -141,33 +179,23 @@ const buildComposeFragments = ( ): ComposeFragments => { const networkMode = config.dockerNetworkMode const networkName = resolveComposeNetworkName(config) - const forkRepoUrl = config.forkRepoUrl ?? "" const maybeGithubAuthSkipEnv = renderGithubAuthSkipEnv(config.skipGithubAuth) - const gitTokenLabel = config.gitTokenLabel?.trim() ?? "" - const codexAuthLabel = config.codexAuthLabel?.trim() ?? "" - const claudeAuthLabel = config.claudeAuthLabel?.trim() ?? "" - const maybeGitTokenLabelEnv = renderGitTokenLabelEnv(gitTokenLabel) - const maybeCodexAuthLabelEnv = renderCodexAuthLabelEnv(codexAuthLabel) - const maybeClaudeAuthLabelEnv = renderClaudeAuthLabelEnv(claudeAuthLabel) - const maybeAgentModeEnv = renderAgentModeEnv(config.agentMode) - const maybeAgentAutoEnv = renderAgentAutoEnv(config.agentAuto) + const authEnv = buildAuthEnvFragments(config) + const agentEnv = buildAgentEnvFragments(config) const playwright = buildPlaywrightFragments(config, networkName, resourceLimits.playwright) return { networkMode, networkName, maybeGithubAuthSkipEnv, - maybeGitTokenLabelEnv, - maybeCodexAuthLabelEnv, - maybeClaudeAuthLabelEnv, - maybeAgentModeEnv, - maybeAgentAutoEnv, + ...authEnv, + ...agentEnv, maybeDependsOn: playwright.maybeDependsOn, maybePlaywrightEnv: playwright.maybePlaywrightEnv, maybeBrowserService: playwright.maybeBrowserService, maybeBrowserVolume: playwright.maybeBrowserVolume, maybeBootstrapMounts: renderBootstrapMounts(), - forkRepoUrl + forkRepoUrl: config.forkRepoUrl ?? "" } } @@ -191,7 +219,9 @@ ${renderGpu(config.gpu)}${ ${fragments.maybeGithubAuthSkipEnv} # Optional anonymous public GitHub clone override ${fragments.maybeGitTokenLabelEnv} # Optional token label selector (maps to GITHUB_TOKEN__