Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/api/src/api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions packages/api/src/api/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions packages/api/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
logoutGitAuth,
logoutGitlabAuth,
logoutGithubAuth,
readClaudeAuthStatus,
readCodexAuthStatus,
readGrokAuthStatus,
readGitAuthStatus,
Expand Down Expand Up @@ -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*(_) {
Expand Down
33 changes: 33 additions & 0 deletions packages/api/src/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -37,6 +42,7 @@ import type {
CodexAuthLoginRequest,
CodexAuthLogoutRequest,
CodexAuthStatus,
ClaudeAuthStatus,
GrokAuthLogoutRequest,
GrokAuthStatus,
GitAuthLoginRequest,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -517,6 +538,18 @@ const grokAuthStatus = (
method
})

export const readClaudeAuthStatus = (
label?: string | null | undefined
): Effect.Effect<ClaudeAuthStatus, PlatformError, FileSystem.FileSystem | Path.Path> =>
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<GrokAuthStatus, PlatformError, FileSystem.FileSystem | Path.Path> =>
Expand Down
3 changes: 2 additions & 1 deletion packages/api/tests/openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () =>
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/docker-git/api-client-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
AuthCodexLoginCommand,
AuthCodexLogoutCommand,
AuthCodexStatusCommand,
AuthClaudeStatusCommand,
AuthGithubLoginCommand,
AuthGithubLogoutCommand,
AuthGithubStatusCommand,
Expand Down Expand Up @@ -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}`)
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/docker-git/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export {
codexLogin,
codexLogout,
codexStatus,
claudeStatus,
githubLogin,
githubLogout,
githubStatus,
Expand Down
8 changes: 8 additions & 0 deletions packages/app/src/docker-git/program-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
codexLogin,
codexLogout,
codexStatus,
claudeStatus,
createAuthTerminalSession,
githubLogin,
githubLogout,
Expand Down Expand Up @@ -52,6 +53,7 @@ export type RoutedAuthCommand = Extract<
| "AuthCodexImport"
| "AuthCodexStatus"
| "AuthCodexLogout"
| "AuthClaudeStatus"
}
>

Expand All @@ -77,6 +79,7 @@ const routedAuthTags: Readonly<Record<string, true>> = {
AuthGithubLogout: true,
AuthGithubStatus: true,
AuthClaudeLogin: true,
AuthClaudeStatus: true,
AuthGeminiLogin: true,
AuthGrokLogin: true,
AuthGrokLogout: true,
Expand Down Expand Up @@ -156,6 +159,10 @@ const handleClaudeLoginCommand = (
)
)

const handleClaudeStatusCommand = (
command: Extract<OperationalCommand, { readonly _tag: "AuthClaudeStatus" }>
) => withControllerReady(pipe(claudeStatus(command), Effect.flatMap((payload) => renderAuthPayload(payload))))

const handleGeminiLoginCommand = (
command: Extract<OperationalCommand, { readonly _tag: "AuthGeminiLogin" }>
) =>
Expand Down Expand Up @@ -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),
Expand Down
5 changes: 0 additions & 5 deletions packages/app/src/docker-git/program-unsupported.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ export type UnsupportedOperationalCommandTag =
| "ScrapImport"
| "McpPlaywrightUp"
| "Apply"
| "AuthClaudeStatus"
| "AuthClaudeLogout"
| "AuthGeminiStatus"
| "AuthGeminiLogout"
Expand All @@ -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."
Expand Down
19 changes: 19 additions & 0 deletions packages/app/tests/docker-git/program.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })))
Expand Down Expand Up @@ -43,6 +44,11 @@ const claudeLoginCommand: Extract<Command, { readonly _tag: "AuthClaudeLogin" }>
label: "work",
claudeAuthPath: ".docker-git/.orch/auth/claude"
}
const claudeStatusCommand: Extract<Command, { readonly _tag: "AuthClaudeStatus" }> = {
_tag: "AuthClaudeStatus",
label: "work",
claudeAuthPath: ".docker-git/.orch/auth/claude"
}
const geminiLoginCommand: Extract<Command, { readonly _tag: "AuthGeminiLogin" }> = {
_tag: "AuthGeminiLogin",
label: null,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions packages/lib/src/usecases/auth-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
} => {
Expand Down
Loading
Loading