diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index e6efd92d..db05e89e 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -299,6 +299,14 @@ export type CodexAuthStatus = { readonly account: string | null } +export type ClaudeAuthStatus = { + readonly label: string + readonly message: string + readonly connected: boolean + readonly authPath: string + readonly method: "none" | "oauth-token" | "claude-ai-session" +} + export type GrokAuthStatus = { readonly label: string readonly message: string diff --git a/packages/api/src/api/openapi.ts b/packages/api/src/api/openapi.ts index 306eae30..17e5a480 100644 --- a/packages/api/src/api/openapi.ts +++ b/packages/api/src/api/openapi.ts @@ -251,6 +251,19 @@ export const CodexStatusResponseSchema = Schema.Struct({ status: CodexAuthStatusSchema }) +export const ClaudeAuthStatusSchema = Schema.Struct({ + authPath: Schema.String, + connected: Schema.Boolean, + label: Schema.String, + message: Schema.String, + method: Schema.Literal("none", "oauth-token", "claude-ai-session") +}) + +export const ClaudeStatusResponseSchema = Schema.Struct({ + ok: OptionalOkSchema, + status: ClaudeAuthStatusSchema +}) + export const GrokAuthStatusSchema = Schema.Struct({ authPath: Schema.String, connected: Schema.Boolean, @@ -601,6 +614,11 @@ const AuthGroup = HttpApiGroup.make("auth") .setUrlParams(QueryLabelSchema) .addSuccess(CodexStatusResponseSchema) ) + .add( + endpoint.get("claudeStatus", "/auth/claude/status") + .setUrlParams(QueryLabelSchema) + .addSuccess(ClaudeStatusResponseSchema) + ) .add( endpoint.post("githubLogin", "/auth/github/login") .setPayload(GithubAuthLoginRequestSchema) diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index aeb9497a..73cb1dee 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -59,6 +59,7 @@ import { logoutGitAuth, logoutGitlabAuth, logoutGithubAuth, + readClaudeAuthStatus, readCodexAuthStatus, readGrokAuthStatus, readGitAuthStatus, @@ -1138,6 +1139,15 @@ export const makeRouter = () => { return yield* _(jsonResponse({ status }, 200)) }).pipe(Effect.catchAll(errorResponse)) ), + HttpRouter.get( + "/auth/claude/status", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const label = new URL(request.url, "http://localhost").searchParams.get("label") + const status = yield* _(readClaudeAuthStatus(label)) + return yield* _(jsonResponse({ status }, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), HttpRouter.get( "/auth/menu", Effect.gen(function*(_) { diff --git a/packages/api/src/services/auth.ts b/packages/api/src/services/auth.ts index 593e4a15..105d1f37 100644 --- a/packages/api/src/services/auth.ts +++ b/packages/api/src/services/auth.ts @@ -6,6 +6,11 @@ import { defaultTemplateConfig } from "@effect-template/lib/core/template-defaul import { parseGithubRepoUrl, parseGitlabRepoUrl } from "@effect-template/lib/core/repo" import { CommandFailedError } from "@effect-template/lib/shell/errors" import { authCodexLogin as runCodexLogin } from "@effect-template/lib/usecases/auth-codex" +import { + claudeAuthRoot, + resolveClaudeAccountPath, + resolveClaudeAuthMethod +} from "@effect-template/lib/usecases/auth-claude" import { authGrokLogout as runGrokLogout } from "@effect-template/lib/usecases/auth-grok-logout" import { authGitLogin as runGitLogin, authGitLogout as runGitLogout, listGitConnections } from "@effect-template/lib/usecases/auth-git" import { authGitlabLogin as runGitlabLogin, authGitlabLogout as runGitlabLogout, listGitlabTokens } from "@effect-template/lib/usecases/auth-gitlab" @@ -37,6 +42,7 @@ import type { CodexAuthLoginRequest, CodexAuthLogoutRequest, CodexAuthStatus, + ClaudeAuthStatus, GrokAuthLogoutRequest, GrokAuthStatus, GitAuthLoginRequest, @@ -65,6 +71,7 @@ export const gitlabAuthRequiredMessage = [ ].join("\n") export const githubAuthEnvGlobalPath = defaultTemplateConfig.envGlobalPath export const codexAuthPath = defaultTemplateConfig.codexAuthPath +export const claudeAuthPath = claudeAuthRoot export const grokAuthPath = defaultTemplateConfig.grokAuthPath const githubTokenKey = "GITHUB_TOKEN" @@ -503,6 +510,20 @@ const codexAuthStatus = ( account }) +const claudeAuthStatus = ( + label: string, + authPath: string, + method: ClaudeAuthStatus["method"] +): ClaudeAuthStatus => ({ + label, + message: method === "none" + ? `Claude not connected (${label}).` + : `Claude connected (${label}, ${method}).`, + connected: method !== "none", + authPath, + method +}) + const grokAuthStatus = ( label: string, authPath: string, @@ -517,6 +538,18 @@ const grokAuthStatus = ( method }) +export const readClaudeAuthStatus = ( + label?: string | null | undefined +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const rootPath = resolvePathFromCwd(path, process.cwd(), claudeAuthPath) + const { accountLabel, accountPath } = resolveClaudeAccountPath(path, rootPath, label ?? null) + const method = yield* _(resolveClaudeAuthMethod(fs, path, accountPath)) + return claudeAuthStatus(accountLabel, accountPath, method) + }) + export const readGrokAuthStatus = ( label?: string | null | undefined ): Effect.Effect => diff --git a/packages/api/tests/openapi.test.ts b/packages/api/tests/openapi.test.ts index dad37446..1b9e96d5 100644 --- a/packages/api/tests/openapi.test.ts +++ b/packages/api/tests/openapi.test.ts @@ -32,11 +32,12 @@ describe("openapi contract", () => { expect(paths["/auth/git/status"]).toBeDefined() expect(paths["/auth/gitlab/status"]).toBeDefined() expect(paths["/auth/codex/status"]).toBeDefined() + expect(paths["/auth/claude/status"]).toBeDefined() expect(paths["/auth/grok/status"]).toBeDefined() expect(paths["/auth/codex/login"]).toBeUndefined() expect(paths["/projects/{projectId}/auth/menu"]).toBeDefined() expect(paths["/projects/{projectId}/auth"]).toBeUndefined() - expect(Object.keys(paths)).toHaveLength(54) + expect(Object.keys(paths)).toHaveLength(55) })) it.effect("documents real HTTP success status codes for create and async endpoints", () => diff --git a/packages/app/src/docker-git/api-client-auth.ts b/packages/app/src/docker-git/api-client-auth.ts index ec371b80..b768cbca 100644 --- a/packages/app/src/docker-git/api-client-auth.ts +++ b/packages/app/src/docker-git/api-client-auth.ts @@ -22,6 +22,7 @@ import type { AuthCodexLoginCommand, AuthCodexLogoutCommand, AuthCodexStatusCommand, + AuthClaudeStatusCommand, AuthGithubLoginCommand, AuthGithubLogoutCommand, AuthGithubStatusCommand, @@ -203,6 +204,11 @@ export const codexStatus = (command: AuthCodexStatusCommand) => { return request("GET", `/auth/codex/status${query}`) } +export const claudeStatus = (command: AuthClaudeStatusCommand) => { + const query = command.label === null ? "" : `?label=${encodeURIComponent(command.label)}` + return request("GET", `/auth/claude/status${query}`) +} + export const grokStatus = (command: AuthGrokStatusCommand) => { const query = command.label === null ? "" : `?label=${encodeURIComponent(command.label)}` return request("GET", `/auth/grok/status${query}`) diff --git a/packages/app/src/docker-git/api-client.ts b/packages/app/src/docker-git/api-client.ts index 785969b0..c023b5e1 100644 --- a/packages/app/src/docker-git/api-client.ts +++ b/packages/app/src/docker-git/api-client.ts @@ -28,6 +28,7 @@ export { codexLogin, codexLogout, codexStatus, + claudeStatus, githubLogin, githubLogout, githubStatus, diff --git a/packages/app/src/docker-git/program-auth.ts b/packages/app/src/docker-git/program-auth.ts index 0dc64901..92fff899 100644 --- a/packages/app/src/docker-git/program-auth.ts +++ b/packages/app/src/docker-git/program-auth.ts @@ -6,6 +6,7 @@ import { codexLogin, codexLogout, codexStatus, + claudeStatus, createAuthTerminalSession, githubLogin, githubLogout, @@ -52,6 +53,7 @@ export type RoutedAuthCommand = Extract< | "AuthCodexImport" | "AuthCodexStatus" | "AuthCodexLogout" + | "AuthClaudeStatus" } > @@ -77,6 +79,7 @@ const routedAuthTags: Readonly> = { AuthGithubLogout: true, AuthGithubStatus: true, AuthClaudeLogin: true, + AuthClaudeStatus: true, AuthGeminiLogin: true, AuthGrokLogin: true, AuthGrokLogout: true, @@ -156,6 +159,10 @@ const handleClaudeLoginCommand = ( ) ) +const handleClaudeStatusCommand = ( + command: Extract +) => withControllerReady(pipe(claudeStatus(command), Effect.flatMap((payload) => renderAuthPayload(payload)))) + const handleGeminiLoginCommand = ( command: Extract ) => @@ -214,6 +221,7 @@ export const dispatchRoutedAuthCommand = ( Match.when({ _tag: "AuthGitStatus" }, handleGitStatusCommand), Match.when({ _tag: "AuthGitLogout" }, handleGitLogoutCommand), Match.when({ _tag: "AuthClaudeLogin" }, handleClaudeLoginCommand), + Match.when({ _tag: "AuthClaudeStatus" }, handleClaudeStatusCommand), Match.when({ _tag: "AuthGeminiLogin" }, handleGeminiLoginCommand), Match.when({ _tag: "AuthGrokLogin" }, handleGrokLoginCommand), Match.when({ _tag: "AuthGrokStatus" }, handleGrokStatusCommand), diff --git a/packages/app/src/docker-git/program-unsupported.ts b/packages/app/src/docker-git/program-unsupported.ts index bb33082f..c250e58b 100644 --- a/packages/app/src/docker-git/program-unsupported.ts +++ b/packages/app/src/docker-git/program-unsupported.ts @@ -5,7 +5,6 @@ export type UnsupportedOperationalCommandTag = | "ScrapImport" | "McpPlaywrightUp" | "Apply" - | "AuthClaudeStatus" | "AuthClaudeLogout" | "AuthGeminiStatus" | "AuthGeminiLogout" @@ -26,10 +25,6 @@ export const unsupportedOperationalCommands: Record< command: "Apply", message: "Command Apply is not available in API-only host mode." }, - AuthClaudeStatus: { - command: "auth claude status", - message: "Claude status is not routed through the controller in host API mode." - }, AuthClaudeLogout: { command: "auth claude logout", message: "Claude logout is not routed through the controller in host API mode." diff --git a/packages/app/tests/docker-git/program.test.ts b/packages/app/tests/docker-git/program.test.ts index db7f51ea..1c425363 100644 --- a/packages/app/tests/docker-git/program.test.ts +++ b/packages/app/tests/docker-git/program.test.ts @@ -12,6 +12,7 @@ const runBrowserFrontendCommandMock = vi.hoisted( const runMenuCallMock = vi.hoisted(() => vi.fn(() => {})) const readCommandMock = vi.hoisted(() => vi.fn<() => Command>()) const codexLoginMock = vi.hoisted(() => vi.fn(() => Effect.void)) +const claudeStatusMock = vi.hoisted(() => vi.fn(() => Effect.succeed({ status: { connected: false } }))) const createAuthTerminalSessionMock = vi.hoisted(() => vi.fn()) const attachTerminalSessionMock = vi.hoisted(() => vi.fn(() => Effect.void)) const gitlabLoginMock = vi.hoisted(() => vi.fn(() => Effect.succeed({ ok: true }))) @@ -43,6 +44,11 @@ const claudeLoginCommand: Extract label: "work", claudeAuthPath: ".docker-git/.orch/auth/claude" } +const claudeStatusCommand: Extract = { + _tag: "AuthClaudeStatus", + label: "work", + claudeAuthPath: ".docker-git/.orch/auth/claude" +} const geminiLoginCommand: Extract = { _tag: "AuthGeminiLogin", label: null, @@ -71,6 +77,7 @@ vi.mock("../../src/docker-git/api-client.js", () => ({ codexImport: vi.fn(() => Effect.succeed({ ok: true })), codexLogout: vi.fn(() => Effect.void), codexStatus: vi.fn(() => Effect.succeed({ ok: true })), + claudeStatus: claudeStatusMock, createAuthTerminalSession: createAuthTerminalSessionMock, createProject: vi.fn(() => Effect.succeed(null)), downAllProjects: vi.fn(() => Effect.void), @@ -122,6 +129,8 @@ describe("program menu dispatch", () => { readCommandMock.mockReturnValue(menuCommand) codexLoginMock.mockReset() codexLoginMock.mockImplementation(() => Effect.void) + claudeStatusMock.mockReset() + claudeStatusMock.mockImplementation(() => Effect.succeed({ status: { connected: false } })) createAuthTerminalSessionMock.mockReset() createAuthTerminalSessionMock.mockImplementation(() => Effect.succeed({ @@ -212,6 +221,16 @@ describe("program menu dispatch", () => { expect(process.exitCode ?? 0).toBe(0) })) + it.effect("routes claude status through the controller API", () => + Effect.gen(function*(_) { + readCommandMock.mockReturnValue(claudeStatusCommand) + yield* _(runProgram()) + + expect(ensureControllerReadyMock).toHaveBeenCalledTimes(1) + expect(claudeStatusMock).toHaveBeenCalledWith(claudeStatusCommand) + expect(process.exitCode ?? 0).toBe(0) + })) + it.effect("routes gemini login through controller auth terminal sessions", () => Effect.gen(function*(_) { readCommandMock.mockReturnValue(geminiLoginCommand) diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index a9907c0e..b34a1459 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -19,7 +19,7 @@ import { autoSyncState } from "./state-repo.js" import { readFileStringIfPresent, writeFileStringEnsuringParent } from "./volatile-files.js" type ClaudeRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor -type ClaudeAuthMethod = "none" | "oauth-token" | "claude-ai-session" +export type ClaudeAuthMethod = "none" | "oauth-token" | "claude-ai-session" type ClaudeAccountContext = { readonly accountLabel: string @@ -113,7 +113,7 @@ const readOauthToken = ( return token.length > 0 ? token : null }) -const resolveClaudeAuthMethod = ( +export const resolveClaudeAuthMethod = ( fs: FileSystem.FileSystem, path: Path.Path, accountPath: string @@ -166,7 +166,7 @@ RUN npm install -g @anthropic-ai/claude-code@latest ENTRYPOINT ["claude"] ` -const resolveClaudeAccountPath = (path: Path.Path, rootPath: string, label: string | null): { +export const resolveClaudeAccountPath = (path: Path.Path, rootPath: string, label: string | null): { readonly accountLabel: string readonly accountPath: string } => { diff --git a/packages/openapi/openapi.json b/packages/openapi/openapi.json index 1e797e52..301ada4d 100644 --- a/packages/openapi/openapi.json +++ b/packages/openapi/openapi.json @@ -10455,6 +10455,296 @@ } } }, + "/auth/claude/status": { + "get": { + "tags": [ + "auth" + ], + "operationId": "auth.claudeStatus", + "parameters": [ + { + "name": "label", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "object", + "required": [ + "authPath", + "connected", + "label", + "message", + "method" + ], + "properties": { + "authPath": { + "type": "string" + }, + "connected": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "message": { + "type": "string" + }, + "method": { + "type": "string", + "enum": [ + "none", + "oauth-token", + "claude-ai-session" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + }, + "400": { + "description": "The request did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/HttpApiDecodeError" + }, + { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "object", + "required": [ + "message", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "details": { + "$id": "/schemas/unknown", + "title": "unknown" + }, + "message": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } + } + }, + "401": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "object", + "required": [ + "message", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "details": { + "$id": "/schemas/unknown", + "title": "unknown" + }, + "message": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + }, + "404": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "object", + "required": [ + "message", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "details": { + "$id": "/schemas/unknown", + "title": "unknown" + }, + "message": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + }, + "409": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "object", + "required": [ + "message", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "details": { + "$id": "/schemas/unknown", + "title": "unknown" + }, + "message": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + }, + "500": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "object", + "required": [ + "message", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "details": { + "$id": "/schemas/unknown", + "title": "unknown" + }, + "message": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + } + } + } + }, "/auth/github/login": { "post": { "tags": [ diff --git a/packages/openapi/src/openapi-paths.ts b/packages/openapi/src/openapi-paths.ts index 21e1bdd6..7b0ba9ef 100644 --- a/packages/openapi/src/openapi-paths.ts +++ b/packages/openapi/src/openapi-paths.ts @@ -452,6 +452,22 @@ export interface paths { patch?: never; trace?: never; }; + "/auth/claude/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["auth.claudeStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/auth/github/login": { parameters: { query?: never; @@ -5144,6 +5160,128 @@ export interface operations { }; }; }; + "auth.claudeStatus": { + parameters: { + query?: { + label?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + status: { + authPath: string; + connected: boolean; + label: string; + message: string; + /** @enum {string} */ + method: "none" | "oauth-token" | "claude-ai-session"; + }; + }; + }; + }; + /** @description The request did not match the expected schema */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HttpApiDecodeError"] | { + error: { + command?: string; + /** unknown */ + details?: unknown; + message: string; + provider?: string; + type: string; + }; + }; + }; + }; + /** @description Error */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: { + command?: string; + /** unknown */ + details?: unknown; + message: string; + provider?: string; + type: string; + }; + }; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: { + command?: string; + /** unknown */ + details?: unknown; + message: string; + provider?: string; + type: string; + }; + }; + }; + }; + /** @description Error */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: { + command?: string; + /** unknown */ + details?: unknown; + message: string; + provider?: string; + type: string; + }; + }; + }; + }; + /** @description Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: { + command?: string; + /** unknown */ + details?: unknown; + message: string; + provider?: string; + type: string; + }; + }; + }; + }; + }; + }; "auth.githubLogin": { parameters: { query?: never;