diff --git a/src/app/api/[[...route]]/route.ts b/src/app/api/[[...route]]/route.ts index db0eed5..83393a3 100644 --- a/src/app/api/[[...route]]/route.ts +++ b/src/app/api/[[...route]]/route.ts @@ -1,15 +1,25 @@ import { NodeContext } from "@effect/platform-node"; -import { Effect } from "effect"; +import { Effect, Layer } from "effect"; import { handle } from "hono/vercel"; +import { ClaudeCodeController } from "../../../server/core/claude-code/presentation/ClaudeCodeController"; +import { ClaudeCodePermissionController } from "../../../server/core/claude-code/presentation/ClaudeCodePermissionController"; +import { ClaudeCodeSessionProcessController } from "../../../server/core/claude-code/presentation/ClaudeCodeSessionProcessController"; import { ClaudeCodeLifeCycleService } from "../../../server/core/claude-code/services/ClaudeCodeLifeCycleService"; import { ClaudeCodePermissionService } from "../../../server/core/claude-code/services/ClaudeCodePermissionService"; +import { ClaudeCodeService } from "../../../server/core/claude-code/services/ClaudeCodeService"; import { ClaudeCodeSessionProcessService } from "../../../server/core/claude-code/services/ClaudeCodeSessionProcessService"; +import { SSEController } from "../../../server/core/events/presentation/SSEController"; import { EventBus } from "../../../server/core/events/services/EventBus"; import { FileWatcherService } from "../../../server/core/events/services/fileWatcher"; +import { FileSystemController } from "../../../server/core/file-system/presentation/FileSystemController"; +import { GitController } from "../../../server/core/git/presentation/GitController"; +import { HonoConfigService } from "../../../server/core/hono/services/HonoConfigService"; import { ProjectRepository } from "../../../server/core/project/infrastructure/ProjectRepository"; +import { ProjectController } from "../../../server/core/project/presentation/ProjectController"; import { ProjectMetaService } from "../../../server/core/project/services/ProjectMetaService"; import { SessionRepository } from "../../../server/core/session/infrastructure/SessionRepository"; import { VirtualConversationDatabase } from "../../../server/core/session/infrastructure/VirtualConversationDatabase"; +import { SessionController } from "../../../server/core/session/presentation/SessionController"; import { SessionMetaService } from "../../../server/core/session/services/SessionMetaService"; import { honoApp } from "../../../server/hono/app"; import { InitializeService } from "../../../server/hono/initialize"; @@ -17,10 +27,27 @@ import { routes } from "../../../server/hono/route"; const program = routes(honoApp); +/** Max count of pipe is 20, so merge some layers here */ +const storageLayer = Layer.mergeAll( + ProjectMetaService.Live, + SessionMetaService.Live, + VirtualConversationDatabase.Live, +); + await Effect.runPromise( program.pipe( // 依存の浅い順にコンテナに pipe する必要がある + /** Presentation */ + Effect.provide(ProjectController.Live), + Effect.provide(SessionController.Live), + Effect.provide(GitController.Live), + Effect.provide(ClaudeCodeController.Live), + Effect.provide(ClaudeCodeSessionProcessController.Live), + Effect.provide(ClaudeCodePermissionController.Live), + Effect.provide(FileSystemController.Live), + Effect.provide(SSEController.Live), + /** Application */ Effect.provide(InitializeService.Live), @@ -28,10 +55,12 @@ await Effect.runPromise( Effect.provide(ClaudeCodeLifeCycleService.Live), Effect.provide(ClaudeCodePermissionService.Live), Effect.provide(ClaudeCodeSessionProcessService.Live), + Effect.provide(ClaudeCodeService.Live), // Shared Services Effect.provide(FileWatcherService.Live), Effect.provide(EventBus.Live), + Effect.provide(HonoConfigService.Live), /** Infrastructure */ @@ -40,9 +69,7 @@ await Effect.runPromise( Effect.provide(SessionRepository.Live), // StorageService - Effect.provide(ProjectMetaService.Live), - Effect.provide(SessionMetaService.Live), - Effect.provide(VirtualConversationDatabase.Live), + Effect.provide(storageLayer), /** Platform */ Effect.provide(NodeContext.layer), diff --git a/src/lib/api/queries.ts b/src/lib/api/queries.ts index 4d5ef0c..9ea40de 100644 --- a/src/lib/api/queries.ts +++ b/src/lib/api/queries.ts @@ -1,5 +1,5 @@ -import type { DirectoryListingResult } from "../../server/core/directory-browser/functions/getDirectoryListing"; -import type { FileCompletionResult } from "../../server/core/file-completion/functions/getFileCompletion"; +import type { DirectoryListingResult } from "../../server/core/file-system/functions/getDirectoryListing"; +import type { FileCompletionResult } from "../../server/core/file-system/functions/getFileCompletion"; import { honoClient } from "./client"; export const projectListQuery = { @@ -21,7 +21,7 @@ export const directoryListingQuery = (currentPath?: string) => ({ queryKey: ["directory-listing", currentPath], queryFn: async (): Promise => { - const response = await honoClient.api["directory-browser"].$get({ + const response = await honoClient.api.fs["directory-browser"].$get({ query: currentPath ? { currentPath } : {}, }); @@ -182,11 +182,8 @@ export const fileCompletionQuery = (projectId: string, basePath: string) => ({ queryKey: ["file-completion", projectId, basePath], queryFn: async (): Promise => { - const response = await honoClient.api.projects[":projectId"][ - "file-completion" - ].$get({ - param: { projectId }, - query: { basePath }, + const response = await honoClient.api.fs["file-completion"].$get({ + query: { basePath, projectId }, }); if (!response.ok) { diff --git a/src/server/core/claude-code/functions/parseMcpListOutput.test.ts b/src/server/core/claude-code/functions/parseMcpListOutput.test.ts new file mode 100644 index 0000000..d2d6fba --- /dev/null +++ b/src/server/core/claude-code/functions/parseMcpListOutput.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { parseMcpListOutput } from "./parseMcpListOutput"; + +describe("parseMcpListOutput", () => { + it("should parse claude mcp list output correctly", async () => { + const output = `2.0.21 (Claude Code) +Checking MCP server health... + +context7: npx -y @upstash/context7-mcp@latest - ✓ Connected +`; + + const result = parseMcpListOutput(output); + + expect(result).toEqual([ + { + name: "context7", + command: "npx -y @upstash/context7-mcp@latest", + }, + ]); + }); + + it("should handle multiple MCP servers", async () => { + const output = `2.0.21 (Claude Code) +Checking MCP server health... + +context7: npx -y @upstash/context7-mcp@latest - ✓ Connected +filesystem: /usr/local/bin/mcp-server-fs - ✓ Connected +database: docker run db-mcp - ✗ Failed +`; + + const result = parseMcpListOutput(output); + + expect(result).toEqual([ + { + name: "context7", + command: "npx -y @upstash/context7-mcp@latest", + }, + { + name: "filesystem", + command: "/usr/local/bin/mcp-server-fs", + }, + { + name: "database", + command: "docker run db-mcp", + }, + ]); + }); + + it("should return empty array for output with no MCP servers", async () => { + const output = `2.0.21 (Claude Code) +Checking MCP server health... + +`; + + const result = parseMcpListOutput(output); + + expect(result).toEqual([]); + }); + + it("should skip malformed lines", async () => { + const output = `2.0.21 (Claude Code) +Checking MCP server health... + +context7: npx -y @upstash/context7-mcp@latest - ✓ Connected +invalid line without colon +: command without name +name without command: +`; + + const result = parseMcpListOutput(output); + + expect(result).toEqual([ + { + name: "context7", + command: "npx -y @upstash/context7-mcp@latest", + }, + ]); + }); +}); diff --git a/src/server/core/claude-code/functions/parseMcpListOutput.ts b/src/server/core/claude-code/functions/parseMcpListOutput.ts new file mode 100644 index 0000000..373a018 --- /dev/null +++ b/src/server/core/claude-code/functions/parseMcpListOutput.ts @@ -0,0 +1,32 @@ +export interface McpServer { + name: string; + command: string; +} + +export const parseMcpListOutput = (output: string) => { + const servers: McpServer[] = []; + const lines = output.trim().split("\n"); + + for (const line of lines) { + // Skip header lines and status indicators + if (line.includes("Checking MCP server health") || line.trim() === "") { + continue; + } + + // Parse lines like "context7: npx -y @upstash/context7-mcp@latest - ✓ Connected" + const colonIndex = line.indexOf(":"); + if (colonIndex > 0) { + const name = line.substring(0, colonIndex).trim(); + const rest = line.substring(colonIndex + 1).trim(); + + // Remove status indicators (✓ Connected, ✗ Failed, etc.) + const command = rest.replace(/\s*-\s*[✓✗].*$/, "").trim(); + + if (name && command) { + servers.push({ name, command }); + } + } + } + + return servers; +}; diff --git a/src/server/core/claude-code/models/ClaudeCode.ts b/src/server/core/claude-code/models/ClaudeCode.ts index 7344786..840261d 100644 --- a/src/server/core/claude-code/models/ClaudeCode.ts +++ b/src/server/core/claude-code/models/ClaudeCode.ts @@ -37,6 +37,22 @@ export const Config = Effect.gen(function* () { }; }); +export const getMcpListOutput = (projectCwd: string) => + Effect.gen(function* () { + const { claudeCodeExecutablePath } = yield* Config; + const output = yield* Command.string( + Command.make( + "cd", + projectCwd, + "&&", + claudeCodeExecutablePath, + "mcp", + "list", + ), + ); + return output; + }); + export const getAvailableFeatures = ( claudeCodeVersion: ClaudeCodeVersion.ClaudeCodeVersion | null, ) => ({ diff --git a/src/server/core/claude-code/presentation/ClaudeCodeController.ts b/src/server/core/claude-code/presentation/ClaudeCodeController.ts new file mode 100644 index 0000000..89e758d --- /dev/null +++ b/src/server/core/claude-code/presentation/ClaudeCodeController.ts @@ -0,0 +1,94 @@ +import { Context, Effect, Layer } from "effect"; +import { claudeCommandsDirPath } from "../../../lib/config/paths"; +import type { ControllerResponse } from "../../../lib/effect/toEffectResponse"; +import type { InferEffect } from "../../../lib/effect/types"; +import { ProjectRepository } from "../../project/infrastructure/ProjectRepository"; +import { ClaudeCodeService } from "../services/ClaudeCodeService"; +import { FileSystem, Path } from "@effect/platform"; + +const LayerImpl = Effect.gen(function* () { + const projectRepository = yield* ProjectRepository; + const claudeCodeService = yield* ClaudeCodeService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const getClaudeCommands = (options: { projectId: string }) => + Effect.gen(function* () { + const { projectId } = options; + + const { project } = yield* projectRepository.getProject(projectId); + + const globalCommands: string[] = yield* fs + .readDirectory(claudeCommandsDirPath) + .pipe( + Effect.map((items) => + items + .filter((item) => item.endsWith(".md")) + .map((item) => item.replace(/\.md$/, "")), + ), + ) + .pipe( + Effect.match({ + onSuccess: (items) => items, + onFailure: () => { + return []; + }, + }), + ); + + const projectCommands: string[] = + project.meta.projectPath === null + ? [] + : yield* fs + .readDirectory( + path.resolve(project.meta.projectPath, ".claude", "commands"), + ) + .pipe( + Effect.map((items) => + items + .filter((item) => item.endsWith(".md")) + .map((item) => item.replace(/\.md$/, "")), + ), + ) + .pipe( + Effect.match({ + onSuccess: (items) => items, + onFailure: () => { + return []; + }, + }), + ); + + return { + response: { + globalCommands: globalCommands, + projectCommands: projectCommands, + defaultCommands: ["init", "compact"], + }, + status: 200, + } as const satisfies ControllerResponse; + }); + + const getMcpListRoute = (options: { projectId: string }) => + Effect.gen(function* () { + const { projectId } = options; + const servers = yield* claudeCodeService.getMcpList(projectId); + return { + response: { servers }, + status: 200, + } as const satisfies ControllerResponse; + }); + + return { + getClaudeCommands, + getMcpListRoute, + }; +}); + +export type IClaudeCodeController = InferEffect; +export class ClaudeCodeController extends Context.Tag("ClaudeCodeController")< + ClaudeCodeController, + IClaudeCodeController +>() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/core/claude-code/presentation/ClaudeCodePermissionController.ts b/src/server/core/claude-code/presentation/ClaudeCodePermissionController.ts new file mode 100644 index 0000000..5efc78d --- /dev/null +++ b/src/server/core/claude-code/presentation/ClaudeCodePermissionController.ts @@ -0,0 +1,40 @@ +import { Context, Effect, Layer } from "effect"; +import type { PermissionResponse } from "../../../../types/permissions"; +import type { ControllerResponse } from "../../../lib/effect/toEffectResponse"; +import type { InferEffect } from "../../../lib/effect/types"; +import { ClaudeCodePermissionService } from "../services/ClaudeCodePermissionService"; + +const LayerImpl = Effect.gen(function* () { + const claudeCodePermissionService = yield* ClaudeCodePermissionService; + + const permissionResponse = (options: { + permissionResponse: PermissionResponse; + }) => + Effect.sync(() => { + const { permissionResponse } = options; + + Effect.runFork( + claudeCodePermissionService.respondToPermissionRequest( + permissionResponse, + ), + ); + + return { + status: 200, + response: { + message: "Permission response received", + }, + } as const satisfies ControllerResponse; + }); + + return { + permissionResponse, + }; +}); + +export type IClaudeCodePermissionController = InferEffect; +export class ClaudeCodePermissionController extends Context.Tag( + "ClaudeCodePermissionController", +)() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/core/claude-code/presentation/ClaudeCodeSessionProcessController.ts b/src/server/core/claude-code/presentation/ClaudeCodeSessionProcessController.ts new file mode 100644 index 0000000..d5e756d --- /dev/null +++ b/src/server/core/claude-code/presentation/ClaudeCodeSessionProcessController.ts @@ -0,0 +1,125 @@ +import { Context, Effect, Layer } from "effect"; +import type { PublicSessionProcess } from "../../../../types/session-process"; +import type { ControllerResponse } from "../../../lib/effect/toEffectResponse"; +import type { InferEffect } from "../../../lib/effect/types"; +import { HonoConfigService } from "../../hono/services/HonoConfigService"; +import { ProjectRepository } from "../../project/infrastructure/ProjectRepository"; +import { ClaudeCodeLifeCycleService } from "../services/ClaudeCodeLifeCycleService"; + +const LayerImpl = Effect.gen(function* () { + const projectRepository = yield* ProjectRepository; + const claudeCodeLifeCycleService = yield* ClaudeCodeLifeCycleService; + const honoConfigService = yield* HonoConfigService; + + const getSessionProcesses = () => + Effect.gen(function* () { + const publicSessionProcesses = + yield* claudeCodeLifeCycleService.getPublicSessionProcesses(); + + return { + response: { + processes: publicSessionProcesses.map( + (p): PublicSessionProcess => ({ + id: p.def.sessionProcessId, + projectId: p.def.projectId, + sessionId: p.sessionId, + status: p.type === "paused" ? "paused" : "running", + }), + ), + }, + status: 200, + } as const satisfies ControllerResponse; + }); + + const createSessionProcess = (options: { + projectId: string; + message: string; + baseSessionId?: string | undefined; + }) => + Effect.gen(function* () { + const { projectId, message, baseSessionId } = options; + + const { project } = yield* projectRepository.getProject(projectId); + const config = yield* honoConfigService.getConfig(); + + if (project.meta.projectPath === null) { + return { + response: { error: "Project path not found" }, + status: 400 as const, + } as const satisfies ControllerResponse; + } + + const result = yield* claudeCodeLifeCycleService.startTask({ + baseSession: { + cwd: project.meta.projectPath, + projectId, + sessionId: baseSessionId, + }, + config: config, + message, + }); + + const { sessionId } = yield* result.yieldSessionInitialized(); + + return { + status: 201 as const, + response: { + sessionProcess: { + id: result.sessionProcess.def.sessionProcessId, + projectId, + sessionId, + }, + }, + } as const satisfies ControllerResponse; + }); + + const continueSessionProcess = (options: { + projectId: string; + continueMessage: string; + baseSessionId: string; + sessionProcessId: string; + }) => + Effect.gen(function* () { + const { projectId, continueMessage, baseSessionId, sessionProcessId } = + options; + + const { project } = yield* projectRepository.getProject(projectId); + + if (project.meta.projectPath === null) { + return { + response: { error: "Project path not found" }, + status: 400, + } as const satisfies ControllerResponse; + } + + const result = yield* claudeCodeLifeCycleService.continueTask({ + sessionProcessId, + message: continueMessage, + baseSessionId, + }); + + return { + response: { + sessionProcess: { + id: result.sessionProcess.def.sessionProcessId, + projectId: result.sessionProcess.def.projectId, + sessionId: baseSessionId, + }, + }, + status: 200, + } as const satisfies ControllerResponse; + }); + + return { + getSessionProcesses, + createSessionProcess, + continueSessionProcess, + }; +}); + +export type IClaudeCodeSessionProcessController = InferEffect; +export class ClaudeCodeSessionProcessController extends Context.Tag( + "ClaudeCodeSessionProcessController", +)() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/core/claude-code/services/ClaudeCodeLifeCycleService.ts b/src/server/core/claude-code/services/ClaudeCodeLifeCycleService.ts index d52c58e..9684eca 100644 --- a/src/server/core/claude-code/services/ClaudeCodeLifeCycleService.ts +++ b/src/server/core/claude-code/services/ClaudeCodeLifeCycleService.ts @@ -342,6 +342,10 @@ const LayerImpl = Effect.gen(function* () { await sessionInitializedPromise.promise, awaitSessionFileCreated: async () => await sessionFileCreatedPromise.promise, + yieldSessionInitialized: () => + Effect.promise(() => sessionInitializedPromise.promise), + yieldSessionFileCreated: () => + Effect.promise(() => sessionFileCreatedPromise.promise), }; }); }; diff --git a/src/server/core/claude-code/services/ClaudeCodeService.ts b/src/server/core/claude-code/services/ClaudeCodeService.ts new file mode 100644 index 0000000..baedf4a --- /dev/null +++ b/src/server/core/claude-code/services/ClaudeCodeService.ts @@ -0,0 +1,41 @@ +import { Context, Data, Effect, Layer } from "effect"; +import type { InferEffect } from "../../../lib/effect/types"; +import { ProjectRepository } from "../../project/infrastructure/ProjectRepository"; +import { parseMcpListOutput } from "../functions/parseMcpListOutput"; +import * as ClaudeCode from "../models/ClaudeCode"; + +class ProjectPathNotFoundError extends Data.TaggedError( + "ProjectPathNotFoundError", +)<{ + projectId: string; +}> {} + +const LayerImpl = Effect.gen(function* () { + const projectRepository = yield* ProjectRepository; + + const getMcpList = (projectId: string) => + Effect.gen(function* () { + const { project } = yield* projectRepository.getProject(projectId); + if (project.meta.projectPath === null) { + return yield* Effect.fail(new ProjectPathNotFoundError({ projectId })); + } + + const output = yield* ClaudeCode.getMcpListOutput( + project.meta.projectPath, + ); + return parseMcpListOutput(output); + }); + + return { + getMcpList, + }; +}); + +export type IClaudeCodeService = InferEffect; + +export class ClaudeCodeService extends Context.Tag("ClaudeCodeService")< + ClaudeCodeService, + IClaudeCodeService +>() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/core/events/presentation/SSEController.ts b/src/server/core/events/presentation/SSEController.ts new file mode 100644 index 0000000..a08a7a3 --- /dev/null +++ b/src/server/core/events/presentation/SSEController.ts @@ -0,0 +1,108 @@ +import { Context, Effect, Layer } from "effect"; +import type { SSEStreamingApi } from "hono/streaming"; +import type { InferEffect } from "../../../lib/effect/types"; +import { adaptInternalEventToSSE } from "../functions/adaptInternalEventToSSE"; +import { TypeSafeSSE } from "../functions/typeSafeSSE"; +import { EventBus } from "../services/EventBus"; +import type { InternalEventDeclaration } from "../types/InternalEventDeclaration"; + +const LayerImpl = Effect.gen(function* () { + const eventBus = yield* EventBus; + + const handleSSE = (rawStream: SSEStreamingApi) => + Effect.gen(function* () { + const typeSafeSSE = yield* TypeSafeSSE; + + // Send connect event + yield* typeSafeSSE.writeSSE("connect", { + timestamp: new Date().toISOString(), + }); + + const onHeartbeat = () => { + Effect.runFork( + typeSafeSSE.writeSSE("heartbeat", { + timestamp: new Date().toISOString(), + }), + ); + }; + + const onSessionListChanged = ( + event: InternalEventDeclaration["sessionListChanged"], + ) => { + Effect.runFork( + typeSafeSSE.writeSSE("sessionListChanged", { + projectId: event.projectId, + }), + ); + }; + + const onSessionChanged = ( + event: InternalEventDeclaration["sessionChanged"], + ) => { + Effect.runFork( + typeSafeSSE.writeSSE("sessionChanged", { + projectId: event.projectId, + sessionId: event.sessionId, + }), + ); + }; + + const onSessionProcessChanged = ( + event: InternalEventDeclaration["sessionProcessChanged"], + ) => { + Effect.runFork( + typeSafeSSE.writeSSE("sessionProcessChanged", { + processes: event.processes, + }), + ); + }; + + const onPermissionRequested = ( + event: InternalEventDeclaration["permissionRequested"], + ) => { + Effect.runFork( + typeSafeSSE.writeSSE("permissionRequested", { + permissionRequest: event.permissionRequest, + }), + ); + }; + + yield* eventBus.on("sessionListChanged", onSessionListChanged); + yield* eventBus.on("sessionChanged", onSessionChanged); + yield* eventBus.on("sessionProcessChanged", onSessionProcessChanged); + yield* eventBus.on("heartbeat", onHeartbeat); + yield* eventBus.on("permissionRequested", onPermissionRequested); + + const { connectionPromise } = adaptInternalEventToSSE(rawStream, { + timeout: 5 /* min */ * 60 /* sec */ * 1000, + cleanUp: async () => { + await Effect.runPromise( + Effect.gen(function* () { + yield* eventBus.off("sessionListChanged", onSessionListChanged); + yield* eventBus.off("sessionChanged", onSessionChanged); + yield* eventBus.off( + "sessionProcessChanged", + onSessionProcessChanged, + ); + yield* eventBus.off("heartbeat", onHeartbeat); + yield* eventBus.off("permissionRequested", onPermissionRequested); + }), + ); + }, + }); + + yield* Effect.promise(() => connectionPromise); + }); + + return { + handleSSE, + }; +}); + +export type ISSEController = InferEffect; +export class SSEController extends Context.Tag("SSEController")< + SSEController, + ISSEController +>() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/core/directory-browser/functions/getDirectoryListing.test.ts b/src/server/core/file-system/functions/getDirectoryListing.test.ts similarity index 100% rename from src/server/core/directory-browser/functions/getDirectoryListing.test.ts rename to src/server/core/file-system/functions/getDirectoryListing.test.ts diff --git a/src/server/core/directory-browser/functions/getDirectoryListing.ts b/src/server/core/file-system/functions/getDirectoryListing.ts similarity index 100% rename from src/server/core/directory-browser/functions/getDirectoryListing.ts rename to src/server/core/file-system/functions/getDirectoryListing.ts diff --git a/src/server/core/file-completion/functions/getFileCompletion.ts b/src/server/core/file-system/functions/getFileCompletion.ts similarity index 100% rename from src/server/core/file-completion/functions/getFileCompletion.ts rename to src/server/core/file-system/functions/getFileCompletion.ts diff --git a/src/server/core/file-system/presentation/FileSystemController.ts b/src/server/core/file-system/presentation/FileSystemController.ts new file mode 100644 index 0000000..e49123e --- /dev/null +++ b/src/server/core/file-system/presentation/FileSystemController.ts @@ -0,0 +1,89 @@ +import { homedir } from "node:os"; +import { Context, Effect, Layer } from "effect"; +import type { ControllerResponse } from "../../../lib/effect/toEffectResponse"; +import type { InferEffect } from "../../../lib/effect/types"; +import { ProjectRepository } from "../../project/infrastructure/ProjectRepository"; +import { getDirectoryListing } from "../functions/getDirectoryListing"; +import { getFileCompletion } from "../functions/getFileCompletion"; + +const LayerImpl = Effect.gen(function* () { + const projectRepository = yield* ProjectRepository; + + const getFileCompletionRoute = (options: { + projectId: string; + basePath: string; + }) => + Effect.gen(function* () { + const { projectId, basePath } = options; + + const { project } = yield* projectRepository.getProject(projectId); + + if (project.meta.projectPath === null) { + return { + response: { error: "Project path not found" }, + status: 400, + } as const satisfies ControllerResponse; + } + + const projectPath = project.meta.projectPath; + + try { + const result = yield* Effect.promise(() => + getFileCompletion(projectPath, basePath), + ); + return { + response: result, + status: 200, + } as const satisfies ControllerResponse; + } catch (error) { + console.error("File completion error:", error); + return { + response: { error: "Failed to get file completion" }, + status: 500, + } as const satisfies ControllerResponse; + } + }); + + const getDirectoryListingRoute = (options: { + currentPath?: string | undefined; + }) => + Effect.promise(async () => { + const { currentPath } = options; + + const rootPath = "/"; + const defaultPath = homedir(); + + try { + const targetPath = currentPath ?? defaultPath; + const relativePath = targetPath.startsWith(rootPath) + ? targetPath.slice(rootPath.length) + : targetPath; + + const result = await getDirectoryListing(rootPath, relativePath); + + return { + response: result, + status: 200, + } as const satisfies ControllerResponse; + } catch (error) { + console.error("Directory listing error:", error); + return { + response: { error: "Failed to list directory" }, + status: 500, + } as const satisfies ControllerResponse; + } + }); + + return { + getFileCompletionRoute, + getDirectoryListingRoute, + }; +}); + +export type IFileSystemController = InferEffect; +export class FileSystemController extends Context.Tag("FileSystemController")< + FileSystemController, + IFileSystemController +>() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/core/git/presentation/GitController.ts b/src/server/core/git/presentation/GitController.ts new file mode 100644 index 0000000..ed62037 --- /dev/null +++ b/src/server/core/git/presentation/GitController.ts @@ -0,0 +1,139 @@ +import { Context, Effect, Layer } from "effect"; +import type { ControllerResponse } from "../../../lib/effect/toEffectResponse"; +import type { InferEffect } from "../../../lib/effect/types"; +import { ProjectRepository } from "../../project/infrastructure/ProjectRepository"; +import { getBranches } from "../functions/getBranches"; +import { getCommits } from "../functions/getCommits"; +import { getDiff } from "../functions/getDiff"; + +const LayerImpl = Effect.gen(function* () { + const projectRepository = yield* ProjectRepository; + + const getGitBranches = (options: { projectId: string }) => + Effect.gen(function* () { + const { projectId } = options; + + const { project } = yield* projectRepository.getProject(projectId); + + if (project.meta.projectPath === null) { + return { + response: { error: "Project path not found" }, + status: 400, + } as const satisfies ControllerResponse; + } + + const projectPath = project.meta.projectPath; + + try { + const result = yield* Effect.promise(() => getBranches(projectPath)); + return { + response: result, + status: 200, + } as const satisfies ControllerResponse; + } catch (error) { + console.error("Get branches error:", error); + if (error instanceof Error) { + return { + response: { error: error.message }, + status: 400, + } as const satisfies ControllerResponse; + } + return { + response: { error: "Failed to get branches" }, + status: 500, + } as const satisfies ControllerResponse; + } + }); + + const getGitCommits = (options: { projectId: string }) => + Effect.gen(function* () { + const { projectId } = options; + + const { project } = yield* projectRepository.getProject(projectId); + + if (project.meta.projectPath === null) { + return { + response: { error: "Project path not found" }, + status: 400, + } as const satisfies ControllerResponse; + } + + const projectPath = project.meta.projectPath; + + try { + const result = yield* Effect.promise(() => getCommits(projectPath)); + return { + response: result, + status: 200, + } as const satisfies ControllerResponse; + } catch (error) { + console.error("Get commits error:", error); + if (error instanceof Error) { + return { + response: { error: error.message }, + status: 400, + } as const satisfies ControllerResponse; + } + return { + response: { error: "Failed to get commits" }, + status: 500, + } as const satisfies ControllerResponse; + } + }); + + const getGitDiff = (options: { + projectId: string; + fromRef: string; + toRef: string; + }) => + Effect.gen(function* () { + const { projectId, fromRef, toRef } = options; + + const { project } = yield* projectRepository.getProject(projectId); + + try { + if (project.meta.projectPath === null) { + return { + response: { error: "Project path not found" }, + status: 400, + } as const satisfies ControllerResponse; + } + + const projectPath = project.meta.projectPath; + + const result = yield* Effect.promise(() => + getDiff(projectPath, fromRef, toRef), + ); + return { + response: result, + status: 200, + } as const satisfies ControllerResponse; + } catch (error) { + console.error("Get diff error:", error); + if (error instanceof Error) { + return { + response: { error: error.message }, + status: 400, + } as const satisfies ControllerResponse; + } + return { + response: { error: "Failed to get diff" }, + status: 500, + } as const satisfies ControllerResponse; + } + }); + + return { + getGitBranches, + getGitCommits, + getGitDiff, + }; +}); + +export type IGitController = InferEffect; +export class GitController extends Context.Tag("GitController")< + GitController, + IGitController +>() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/core/hono/services/HonoConfigService.ts b/src/server/core/hono/services/HonoConfigService.ts new file mode 100644 index 0000000..c776600 --- /dev/null +++ b/src/server/core/hono/services/HonoConfigService.ts @@ -0,0 +1,36 @@ +import { Context, Effect, Layer, Ref } from "effect"; +import type { Config } from "../../../lib/config/config"; +import type { InferEffect } from "../../../lib/effect/types"; + +const LayerImpl = Effect.gen(function* () { + const configRef = yield* Ref.make({ + hideNoUserMessageSession: true, + unifySameTitleSession: true, + enterKeyBehavior: "shift-enter-send", + permissionMode: "default", + }); + + const setConfig = (newConfig: Config) => + Effect.gen(function* () { + yield* Ref.update(configRef, () => newConfig); + }); + + const getConfig = () => + Effect.gen(function* () { + const config = yield* Ref.get(configRef); + return config; + }); + + return { + getConfig, + setConfig, + }; +}); + +export type IHonoConfigService = InferEffect; +export class HonoConfigService extends Context.Tag("HonoConfigService")< + HonoConfigService, + IHonoConfigService +>() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/core/mcp/functions/getMcpList.ts b/src/server/core/mcp/functions/getMcpList.ts deleted file mode 100644 index 79705f1..0000000 --- a/src/server/core/mcp/functions/getMcpList.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { execSync } from "node:child_process"; -import { decodeProjectId } from "../../project/functions/id"; - -export interface McpServer { - name: string; - command: string; -} - -export const getMcpList = async ( - projectId: string, -): Promise<{ servers: McpServer[] }> => { - try { - const output = execSync("claude mcp list", { - encoding: "utf8", - timeout: 10000, - cwd: decodeProjectId(projectId), - }); - - const servers: McpServer[] = []; - const lines = output.trim().split("\n"); - - for (const line of lines) { - // Skip header lines and status indicators - if (line.includes("Checking MCP server health") || line.trim() === "") { - continue; - } - - // Parse lines like "context7: npx -y @upstash/context7-mcp@latest - ✓ Connected" - const colonIndex = line.indexOf(":"); - if (colonIndex > 0) { - const name = line.substring(0, colonIndex).trim(); - const rest = line.substring(colonIndex + 1).trim(); - - // Remove status indicators (✓ Connected, ✗ Failed, etc.) - const command = rest.replace(/\s*-\s*[✓✗].*$/, "").trim(); - - if (name && command) { - servers.push({ name, command }); - } - } - } - - return { servers }; - } catch (error) { - console.error("Failed to get MCP list:", error); - // Return empty list if command fails - return { servers: [] }; - } -}; diff --git a/src/server/core/project/presentation/ProjectController.ts b/src/server/core/project/presentation/ProjectController.ts new file mode 100644 index 0000000..cb793ad --- /dev/null +++ b/src/server/core/project/presentation/ProjectController.ts @@ -0,0 +1,164 @@ +import { Context, Effect, Layer } from "effect"; +import type { ControllerResponse } from "../../../lib/effect/toEffectResponse"; +import type { InferEffect } from "../../../lib/effect/types"; +import { computeClaudeProjectFilePath } from "../../claude-code/functions/computeClaudeProjectFilePath"; +import { ClaudeCodeLifeCycleService } from "../../claude-code/services/ClaudeCodeLifeCycleService"; +import { HonoConfigService } from "../../hono/services/HonoConfigService"; +import { SessionRepository } from "../../session/infrastructure/SessionRepository"; +import { encodeProjectId } from "../functions/id"; +import { ProjectRepository } from "../infrastructure/ProjectRepository"; + +const LayerImpl = Effect.gen(function* () { + const projectRepository = yield* ProjectRepository; + const claudeCodeLifeCycleService = yield* ClaudeCodeLifeCycleService; + const honoConfigService = yield* HonoConfigService; + const sessionRepository = yield* SessionRepository; + + const getProjects = () => + Effect.gen(function* () { + const { projects } = yield* projectRepository.getProjects(); + return { + status: 200, + response: { projects }, + } as const satisfies ControllerResponse; + }); + + const getProject = (options: { projectId: string; cursor?: string }) => + Effect.gen(function* () { + const { projectId, cursor } = options; + + const config = yield* honoConfigService.getConfig(); + + const { project } = yield* projectRepository.getProject(projectId); + const { sessions } = yield* sessionRepository.getSessions(projectId, { + cursor, + }); + + let filteredSessions = sessions; + + // Filter sessions based on hideNoUserMessageSession setting + if (config.hideNoUserMessageSession) { + filteredSessions = filteredSessions.filter((session) => { + return session.meta.firstCommand !== null; + }); + } + + // Unify sessions with same title if unifySameTitleSession is enabled + if (config.unifySameTitleSession) { + const sessionMap = new Map(); + + for (const session of filteredSessions) { + // Generate title for comparison + const title = + session.meta.firstCommand !== null + ? (() => { + const cmd = session.meta.firstCommand; + switch (cmd.kind) { + case "command": + return cmd.commandArgs === undefined + ? cmd.commandName + : `${cmd.commandName} ${cmd.commandArgs}`; + case "local-command": + return cmd.stdout; + case "text": + return cmd.content; + default: + return session.id; + } + })() + : session.id; + + const existingSession = sessionMap.get(title); + if (existingSession) { + // Keep the session with the latest modification date + if (session.lastModifiedAt && existingSession.lastModifiedAt) { + if (session.lastModifiedAt > existingSession.lastModifiedAt) { + sessionMap.set(title, session); + } + } else if ( + session.lastModifiedAt && + !existingSession.lastModifiedAt + ) { + sessionMap.set(title, session); + } + // If no modification dates, keep the existing one + } else { + sessionMap.set(title, session); + } + } + + filteredSessions = Array.from(sessionMap.values()); + } + + const hasMore = sessions.length >= 20; + return { + status: 200, + response: { + project, + sessions: filteredSessions, + nextCursor: hasMore ? sessions.at(-1)?.id : undefined, + }, + } as const satisfies ControllerResponse; + }); + + const getProjectLatestSession = (options: { projectId: string }) => + Effect.gen(function* () { + const { projectId } = options; + const { sessions } = yield* sessionRepository.getSessions(projectId, { + maxCount: 1, + }); + + return { + status: 200, + response: { + latestSession: sessions[0] ?? null, + }, + } as const satisfies ControllerResponse; + }); + + const createProject = (options: { projectPath: string }) => + Effect.gen(function* () { + const { projectPath } = options; + + // No project validation needed - startTask will create a new project + // if it doesn't exist when running /init command + const claudeProjectFilePath = computeClaudeProjectFilePath(projectPath); + const projectId = encodeProjectId(claudeProjectFilePath); + const config = yield* honoConfigService.getConfig(); + + const result = yield* claudeCodeLifeCycleService.startTask({ + baseSession: { + cwd: projectPath, + projectId, + sessionId: undefined, + }, + config: config, + message: "/init", + }); + + const { sessionId } = yield* result.yieldSessionFileCreated(); + + return { + status: 201, + response: { + projectId, + sessionId, + }, + } as const satisfies ControllerResponse; + }); + + return { + getProjects, + getProject, + getProjectLatestSession, + createProject, + }; +}); + +export type IProjectController = InferEffect; +export class ProjectController extends Context.Tag("ProjectController")< + ProjectController, + IProjectController +>() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/core/session/presentation/SessionController.ts b/src/server/core/session/presentation/SessionController.ts new file mode 100644 index 0000000..69c3ad6 --- /dev/null +++ b/src/server/core/session/presentation/SessionController.ts @@ -0,0 +1,35 @@ +import { Context, Effect, Layer } from "effect"; +import type { ControllerResponse } from "../../../lib/effect/toEffectResponse"; +import type { InferEffect } from "../../../lib/effect/types"; +import { SessionRepository } from "../../session/infrastructure/SessionRepository"; + +const LayerImpl = Effect.gen(function* () { + const sessionRepository = yield* SessionRepository; + + const getSession = (options: { projectId: string; sessionId: string }) => + Effect.gen(function* () { + const { projectId, sessionId } = options; + + const { session } = yield* sessionRepository.getSession( + projectId, + sessionId, + ); + + return { + status: 200, + response: { session }, + } as const satisfies ControllerResponse; + }); + + return { + getSession, + }; +}); + +export type ISessionController = InferEffect; +export class SessionController extends Context.Tag("SessionController")< + SessionController, + ISessionController +>() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index 4139b0f..3354775 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -1,6 +1,3 @@ -import { readdir } from "node:fs/promises"; -import { homedir } from "node:os"; -import { resolve } from "node:path"; import type { CommandExecutor, FileSystem, Path } from "@effect/platform"; import { zValidator } from "@hono/zod-validator"; import { Effect, Runtime } from "effect"; @@ -8,28 +5,21 @@ import { setCookie } from "hono/cookie"; import { streamSSE } from "hono/streaming"; import prexit from "prexit"; import { z } from "zod"; -import type { PublicSessionProcess } from "../../types/session-process"; -import { computeClaudeProjectFilePath } from "../core/claude-code/functions/computeClaudeProjectFilePath"; +import { ClaudeCodeController } from "../core/claude-code/presentation/ClaudeCodeController"; +import { ClaudeCodePermissionController } from "../core/claude-code/presentation/ClaudeCodePermissionController"; +import { ClaudeCodeSessionProcessController } from "../core/claude-code/presentation/ClaudeCodeSessionProcessController"; import { ClaudeCodeLifeCycleService } from "../core/claude-code/services/ClaudeCodeLifeCycleService"; -import { ClaudeCodePermissionService } from "../core/claude-code/services/ClaudeCodePermissionService"; -import { getDirectoryListing } from "../core/directory-browser/functions/getDirectoryListing"; -import { adaptInternalEventToSSE } from "../core/events/functions/adaptInternalEventToSSE"; import { TypeSafeSSE } from "../core/events/functions/typeSafeSSE"; -import { EventBus } from "../core/events/services/EventBus"; -import type { InternalEventDeclaration } from "../core/events/types/InternalEventDeclaration"; -import { getFileCompletion } from "../core/file-completion/functions/getFileCompletion"; -import { getBranches } from "../core/git/functions/getBranches"; -import { getCommits } from "../core/git/functions/getCommits"; -import { getDiff } from "../core/git/functions/getDiff"; -import { getMcpList } from "../core/mcp/functions/getMcpList"; -import { encodeProjectId } from "../core/project/functions/id"; -import { ProjectRepository } from "../core/project/infrastructure/ProjectRepository"; -import type { ProjectMetaService } from "../core/project/services/ProjectMetaService"; -import { SessionRepository } from "../core/session/infrastructure/SessionRepository"; +import { SSEController } from "../core/events/presentation/SSEController"; +import { FileSystemController } from "../core/file-system/presentation/FileSystemController"; +import { GitController } from "../core/git/presentation/GitController"; +import { HonoConfigService } from "../core/hono/services/HonoConfigService"; +import { ProjectController } from "../core/project/presentation/ProjectController"; import type { VirtualConversationDatabase } from "../core/session/infrastructure/VirtualConversationDatabase"; +import { SessionController } from "../core/session/presentation/SessionController"; import type { SessionMetaService } from "../core/session/services/SessionMetaService"; import { configSchema } from "../lib/config/config"; -import { claudeCommandsDirPath } from "../lib/config/paths"; +import { effectToResponse } from "../lib/effect/toEffectResponse"; import { env } from "../lib/env"; import type { HonoAppType } from "./app"; import { InitializeService } from "./initialize"; @@ -37,15 +27,24 @@ import { configMiddleware } from "./middleware/config.middleware"; export const routes = (app: HonoAppType) => Effect.gen(function* () { - const sessionRepository = yield* SessionRepository; - const projectRepository = yield* ProjectRepository; + // controllers + const projectController = yield* ProjectController; + const sessionController = yield* SessionController; + const gitController = yield* GitController; + const claudeCodeSessionProcessController = + yield* ClaudeCodeSessionProcessController; + const claudeCodePermissionController = + yield* ClaudeCodePermissionController; + const sseController = yield* SSEController; + const fileSystemController = yield* FileSystemController; + const claudeCodeController = yield* ClaudeCodeController; + + // services + const honoConfigService = yield* HonoConfigService; const claudeCodeLifeCycleService = yield* ClaudeCodeLifeCycleService; - const claudeCodePermissionService = yield* ClaudeCodePermissionService; const initializeService = yield* InitializeService; - const eventBus = yield* EventBus; const runtime = yield* Effect.runtime< - | ProjectMetaService | SessionMetaService | VirtualConversationDatabase | FileSystem.FileSystem @@ -65,7 +64,13 @@ export const routes = (app: HonoAppType) => app // middleware .use(configMiddleware) - .use(async (_c, next) => { + .use(async (c, next) => { + await Effect.runPromise( + honoConfigService.setConfig({ + ...c.get("config"), + }), + ); + await next(); }) @@ -86,16 +91,35 @@ export const routes = (app: HonoAppType) => }); }) + /** + * ProjectController Routes + */ + .get("/projects", async (c) => { - const program = Effect.gen(function* () { - return yield* projectRepository.getProjects(); - }); - - const { projects } = await Runtime.runPromise(runtime)(program); - - return c.json({ projects }); + const response = await effectToResponse( + c, + projectController.getProjects(), + ); + return response; }) + .get( + "/projects/:projectId", + zValidator("query", z.object({ cursor: z.string().optional() })), + async (c) => { + const response = await effectToResponse( + c, + projectController + .getProject({ + ...c.req.param(), + ...c.req.valid("query"), + }) + .pipe(Effect.provide(runtime)), + ); + return response; + }, + ) + .post( "/projects", zValidator( @@ -105,369 +129,68 @@ export const routes = (app: HonoAppType) => }), ), async (c) => { - const { projectPath } = c.req.valid("json"); - - // No project validation needed - startTask will create a new project - // if it doesn't exist when running /init command - const claudeProjectFilePath = - computeClaudeProjectFilePath(projectPath); - const projectId = encodeProjectId(claudeProjectFilePath); - - const program = Effect.gen(function* () { - const result = yield* claudeCodeLifeCycleService.startTask({ - baseSession: { - cwd: projectPath, - projectId, - sessionId: undefined, - }, - config: c.get("config"), - message: "/init", - }); - - return { - result, - status: 200 as const, - }; - }); - - const result = await Runtime.runPromise(runtime)(program); - - if (result.status === 200) { - const { sessionId } = - await result.result.awaitSessionFileCreated(); - - return c.json({ - projectId: result.result.sessionProcess.def.projectId, - sessionId, - }); - } - - return c.json({ error: "Failed to create project" }, 500); - }, - ) - - .get( - "/directory-browser", - zValidator( - "query", - z.object({ - currentPath: z.string().optional(), - }), - ), - async (c) => { - const { currentPath } = c.req.valid("query"); - const rootPath = "/"; - const defaultPath = homedir(); - - try { - const targetPath = currentPath || defaultPath; - const relativePath = targetPath.startsWith(rootPath) - ? targetPath.slice(rootPath.length) - : targetPath; - - const result = await getDirectoryListing(rootPath, relativePath); - return c.json(result); - } catch (error) { - console.error("Directory listing error:", error); - if (error instanceof Error) { - return c.json({ error: error.message }, 400); - } - return c.json({ error: "Failed to list directory" }, 500); - } - }, - ) - - .get( - "/projects/:projectId", - zValidator("query", z.object({ cursor: z.string().optional() })), - async (c) => { - const { projectId } = c.req.param(); - const { cursor } = c.req.valid("query"); - const config = c.get("config"); - - const program = Effect.gen(function* () { - const { project } = - yield* projectRepository.getProject(projectId); - const { sessions } = yield* sessionRepository.getSessions( - projectId, - { cursor }, - ); - - let filteredSessions = sessions; - - // Filter sessions based on hideNoUserMessageSession setting - if (config.hideNoUserMessageSession) { - filteredSessions = filteredSessions.filter((session) => { - return session.meta.firstCommand !== null; - }); - } - - // Unify sessions with same title if unifySameTitleSession is enabled - if (config.unifySameTitleSession) { - const sessionMap = new Map< - string, - (typeof filteredSessions)[0] - >(); - - for (const session of filteredSessions) { - // Generate title for comparison - const title = - session.meta.firstCommand !== null - ? (() => { - const cmd = session.meta.firstCommand; - switch (cmd.kind) { - case "command": - return cmd.commandArgs === undefined - ? cmd.commandName - : `${cmd.commandName} ${cmd.commandArgs}`; - case "local-command": - return cmd.stdout; - case "text": - return cmd.content; - default: - return session.id; - } - })() - : session.id; - - const existingSession = sessionMap.get(title); - if (existingSession) { - // Keep the session with the latest modification date - if ( - session.lastModifiedAt && - existingSession.lastModifiedAt - ) { - if ( - session.lastModifiedAt > existingSession.lastModifiedAt - ) { - sessionMap.set(title, session); - } - } else if ( - session.lastModifiedAt && - !existingSession.lastModifiedAt - ) { - sessionMap.set(title, session); - } - // If no modification dates, keep the existing one - } else { - sessionMap.set(title, session); - } - } - - filteredSessions = Array.from(sessionMap.values()); - } - - const hasMore = sessions.length >= 20; - return { - project, - sessions: filteredSessions, - nextCursor: hasMore ? sessions.at(-1)?.id : undefined, - }; - }); - - const result = await Runtime.runPromise(runtime)(program); - return c.json(result); + const response = await effectToResponse( + c, + projectController.createProject({ + ...c.req.valid("json"), + }), + ); + return response; }, ) .get("/projects/:projectId/latest-session", async (c) => { - const { projectId } = c.req.param(); - - const program = Effect.gen(function* () { - const { sessions } = yield* sessionRepository.getSessions( - projectId, - { maxCount: 1 }, - ); - - return { - latestSession: sessions[0] ?? null, - }; - }); - - const result = await Runtime.runPromise(runtime)(program); - return c.json(result); + const response = await effectToResponse( + c, + projectController + .getProjectLatestSession({ + ...c.req.param(), + }) + .pipe(Effect.provide(runtime)), + ); + return response; }) + /** + * SessionController Routes + */ + .get("/projects/:projectId/sessions/:sessionId", async (c) => { - const { projectId, sessionId } = c.req.param(); - - const program = Effect.gen(function* () { - const { session } = yield* sessionRepository.getSession( - projectId, - sessionId, - ); - return { session }; - }); - - const result = await Runtime.runPromise(runtime)(program); - return c.json(result); + const response = await effectToResponse( + c, + sessionController + .getSession({ ...c.req.param() }) + .pipe(Effect.provide(runtime)), + ); + return response; }) - .get( - "/projects/:projectId/file-completion", - zValidator( - "query", - z.object({ - basePath: z.string().optional().default("/"), - }), - ), - async (c) => { - const { projectId } = c.req.param(); - const { basePath } = c.req.valid("query"); - - const program = Effect.gen(function* () { - const { project } = - yield* projectRepository.getProject(projectId); - - if (project.meta.projectPath === null) { - return { - error: "Project path not found", - status: 400 as const, - }; - } - - const projectPath = project.meta.projectPath; - - try { - const result = yield* Effect.promise(() => - getFileCompletion(projectPath, basePath), - ); - return { data: result, status: 200 as const }; - } catch (error) { - console.error("File completion error:", error); - return { - error: "Failed to get file completion", - status: 500 as const, - }; - } - }); - - const result = await Runtime.runPromise(runtime)(program); - - if (result.status === 200) { - return c.json(result.data); - } - return c.json({ error: result.error }, result.status); - }, - ) - - .get("/projects/:projectId/claude-commands", async (c) => { - const { projectId } = c.req.param(); - - const program = Effect.gen(function* () { - const { project } = yield* projectRepository.getProject(projectId); - - const [globalCommands, projectCommands] = yield* Effect.promise( - () => - Promise.allSettled([ - readdir(claudeCommandsDirPath, { - withFileTypes: true, - }).then((dirents) => - dirents - .filter((d) => d.isFile() && d.name.endsWith(".md")) - .map((d) => d.name.replace(/\.md$/, "")), - ), - project.meta.projectPath !== null - ? readdir( - resolve( - project.meta.projectPath, - ".claude", - "commands", - ), - { - withFileTypes: true, - }, - ).then((dirents) => - dirents - .filter((d) => d.isFile() && d.name.endsWith(".md")) - .map((d) => d.name.replace(/\.md$/, "")), - ) - : [], - ]), - ); - - return { - globalCommands: - globalCommands.status === "fulfilled" - ? globalCommands.value - : [], - projectCommands: - projectCommands.status === "fulfilled" - ? projectCommands.value - : [], - defaultCommands: ["init", "compact"], - }; - }); - - const result = await Runtime.runPromise(runtime)(program); - return c.json(result); - }) + /** + * GitController Routes + */ .get("/projects/:projectId/git/branches", async (c) => { - const { projectId } = c.req.param(); - - const program = Effect.gen(function* () { - const { project } = yield* projectRepository.getProject(projectId); - - if (project.meta.projectPath === null) { - return { error: "Project path not found", status: 400 as const }; - } - - const projectPath = project.meta.projectPath; - - try { - const result = yield* Effect.promise(() => - getBranches(projectPath), - ); - return { data: result, status: 200 as const }; - } catch (error) { - console.error("Get branches error:", error); - if (error instanceof Error) { - return { error: error.message, status: 400 as const }; - } - return { error: "Failed to get branches", status: 500 as const }; - } - }); - - const result = await Runtime.runPromise(runtime)(program); - if (result.status === 200) { - return c.json(result.data); - } - - return c.json({ error: result.error }, result.status); + const response = await effectToResponse( + c, + gitController + .getGitBranches({ + ...c.req.param(), + }) + .pipe(Effect.provide(runtime)), + ); + return response; }) .get("/projects/:projectId/git/commits", async (c) => { - const { projectId } = c.req.param(); - - const program = Effect.gen(function* () { - const { project } = yield* projectRepository.getProject(projectId); - - if (project.meta.projectPath === null) { - return { error: "Project path not found", status: 400 as const }; - } - - const projectPath = project.meta.projectPath; - - try { - const result = yield* Effect.promise(() => - getCommits(projectPath), - ); - return { data: result, status: 200 as const }; - } catch (error) { - console.error("Get commits error:", error); - if (error instanceof Error) { - return { error: error.message, status: 400 as const }; - } - return { error: "Failed to get commits", status: 500 as const }; - } - }); - - const result = await Runtime.runPromise(runtime)(program); - if (result.status === 200) { - return c.json(result.data); - } - return c.json({ error: result.error }, result.status); + const response = await effectToResponse( + c, + gitController + .getGitCommits({ + ...c.req.param(), + }) + .pipe(Effect.provide(runtime)), + ); + return response; }) .post( @@ -480,64 +203,55 @@ export const routes = (app: HonoAppType) => }), ), async (c) => { - const { projectId } = c.req.param(); - const { fromRef, toRef } = c.req.valid("json"); - - const program = Effect.gen(function* () { - const { project } = - yield* projectRepository.getProject(projectId); - - try { - if (project.meta.projectPath === null) { - return { - error: "Project path not found", - status: 400 as const, - }; - } - - const projectPath = project.meta.projectPath; - - const result = yield* Effect.promise(() => - getDiff(projectPath, fromRef, toRef), - ); - return { data: result, status: 200 as const }; - } catch (error) { - console.error("Get diff error:", error); - if (error instanceof Error) { - return { error: error.message, status: 400 as const }; - } - return { error: "Failed to get diff", status: 500 as const }; - } - }); - - const result = await Runtime.runPromise(runtime)(program); - if (result.status === 200) { - return c.json(result.data); - } - return c.json({ error: result.error }, result.status); + const response = await effectToResponse( + c, + gitController + .getGitDiff({ + ...c.req.param(), + ...c.req.valid("json"), + }) + .pipe(Effect.provide(runtime)), + ); + return response; }, ) - .get("/projects/:projectId/mcp/list", async (c) => { - const { projectId } = c.req.param(); - const { servers } = await getMcpList(projectId); - return c.json({ servers }); + /** + * ClaudeCodeController Routes + */ + + .get("/projects/:projectId/claude-commands", async (c) => { + const response = await effectToResponse( + c, + claudeCodeController.getClaudeCommands({ + ...c.req.param(), + }), + ); + return response; }) - .get("/cc/session-processes", async (c) => { - const publicProcesses = await Runtime.runPromise(runtime)( - claudeCodeLifeCycleService.getPublicSessionProcesses(), + .get("/projects/:projectId/mcp/list", async (c) => { + const response = await effectToResponse( + c, + claudeCodeController + .getMcpListRoute({ + ...c.req.param(), + }) + .pipe(Effect.provide(runtime)), ); - return c.json({ - processes: publicProcesses.map( - (process): PublicSessionProcess => ({ - id: process.def.sessionProcessId, - projectId: process.def.projectId, - sessionId: process.sessionId, - status: process.type === "paused" ? "paused" : "running", - }), - ), - }); + return response; + }) + + /** + * ClaudeCodeSessionProcessController Routes + */ + + .get("/cc/session-processes", async (c) => { + const response = await effectToResponse( + c, + claudeCodeSessionProcessController.getSessionProcesses(), + ); + return response; }) // new or resume @@ -552,51 +266,13 @@ export const routes = (app: HonoAppType) => }), ), async (c) => { - const { projectId, message, baseSessionId } = c.req.valid("json"); - - const program = Effect.gen(function* () { - const { project } = - yield* projectRepository.getProject(projectId); - - if (project.meta.projectPath === null) { - return { - error: "Project path not found", - status: 400 as const, - }; - } - - const result = yield* claudeCodeLifeCycleService.startTask({ - baseSession: { - cwd: project.meta.projectPath, - projectId, - sessionId: baseSessionId, - }, - config: c.get("config"), - message, - }); - - return { - result, - status: 200 as const, - }; - }); - - const result = await Runtime.runPromise(runtime)(program); - - if (result.status === 200) { - const { sessionId } = - await result.result.awaitSessionInitialized(); - - return c.json({ - sessionProcess: { - id: result.result.sessionProcess.def.sessionProcessId, - projectId: result.result.sessionProcess.def.projectId, - sessionId, - }, - }); - } - - return c.json({ error: result.error }, result.status); + const response = await effectToResponse( + c, + claudeCodeSessionProcessController.createSessionProcess( + c.req.valid("json"), + ), + ); + return response; }, ) @@ -612,45 +288,16 @@ export const routes = (app: HonoAppType) => }), ), async (c) => { - const { sessionProcessId } = c.req.param(); - const { projectId, continueMessage, baseSessionId } = - c.req.valid("json"); - - const program = Effect.gen(function* () { - const { project } = - yield* projectRepository.getProject(projectId); - - if (project.meta.projectPath === null) { - return { - error: "Project path not found", - status: 400 as const, - }; - } - - const result = yield* claudeCodeLifeCycleService.continueTask({ - sessionProcessId, - message: continueMessage, - baseSessionId, - }); - - return { - data: { - sessionProcess: { - id: result.sessionProcess.def.sessionProcessId, - projectId: result.sessionProcess.def.projectId, - sessionId: baseSessionId, - }, - }, - status: 200 as const, - }; - }); - - const result = await Runtime.runPromise(runtime)(program); - if (result.status === 200) { - return c.json(result.data); - } - - return c.json({ error: result.error }, result.status); + const response = await effectToResponse( + c, + claudeCodeSessionProcessController + .continueSessionProcess({ + ...c.req.param(), + ...c.req.valid("json"), + }) + .pipe(Effect.provide(runtime)), + ); + return response; }, ) @@ -666,6 +313,10 @@ export const routes = (app: HonoAppType) => }, ) + /** + * ClaudeCodePermissionController Routes + */ + .post( "/cc/permission-response", zValidator( @@ -676,135 +327,79 @@ export const routes = (app: HonoAppType) => }), ), async (c) => { - const permissionResponse = c.req.valid("json"); - Effect.runFork( - claudeCodePermissionService.respondToPermissionRequest( - permissionResponse, - ), + const response = await effectToResponse( + c, + claudeCodePermissionController.permissionResponse({ + permissionResponse: c.req.valid("json"), + }), ); - return c.json({ message: "Permission response received" }); + return response; }, ) + /** + * SSEController Routes + */ + .get("/sse", async (c) => { return streamSSE( c, async (rawStream) => { - const handleSSE = Effect.gen(function* () { - const typeSafeSSE = yield* TypeSafeSSE; - - // Send connect event - yield* typeSafeSSE.writeSSE("connect", { - timestamp: new Date().toISOString(), - }); - - const onHeartbeat = () => { - Effect.runFork( - typeSafeSSE.writeSSE("heartbeat", { - timestamp: new Date().toISOString(), - }), - ); - }; - - const onSessionListChanged = ( - event: InternalEventDeclaration["sessionListChanged"], - ) => { - Effect.runFork( - typeSafeSSE.writeSSE("sessionListChanged", { - projectId: event.projectId, - }), - ); - }; - - const onSessionChanged = ( - event: InternalEventDeclaration["sessionChanged"], - ) => { - Effect.runFork( - typeSafeSSE.writeSSE("sessionChanged", { - projectId: event.projectId, - sessionId: event.sessionId, - }), - ); - }; - - const onSessionProcessChanged = ( - event: InternalEventDeclaration["sessionProcessChanged"], - ) => { - Effect.runFork( - typeSafeSSE.writeSSE("sessionProcessChanged", { - processes: event.processes, - }), - ); - }; - - const onPermissionRequested = ( - event: InternalEventDeclaration["permissionRequested"], - ) => { - Effect.runFork( - typeSafeSSE.writeSSE("permissionRequested", { - permissionRequest: event.permissionRequest, - }), - ); - }; - - yield* eventBus.on("sessionListChanged", onSessionListChanged); - yield* eventBus.on("sessionChanged", onSessionChanged); - yield* eventBus.on( - "sessionProcessChanged", - onSessionProcessChanged, - ); - yield* eventBus.on("heartbeat", onHeartbeat); - yield* eventBus.on( - "permissionRequested", - onPermissionRequested, - ); - - const { connectionPromise } = adaptInternalEventToSSE( - rawStream, - { - timeout: 5 /* min */ * 60 /* sec */ * 1000, - cleanUp: async () => { - await Effect.runPromise( - Effect.gen(function* () { - yield* eventBus.off( - "sessionListChanged", - onSessionListChanged, - ); - yield* eventBus.off( - "sessionChanged", - onSessionChanged, - ); - yield* eventBus.off( - "sessionProcessChanged", - onSessionProcessChanged, - ); - yield* eventBus.off("heartbeat", onHeartbeat); - yield* eventBus.off( - "permissionRequested", - onPermissionRequested, - ); - }), - ); - }, - }, - ); - - return { - connectionPromise, - }; - }); - - const { connectionPromise } = await Runtime.runPromise(runtime)( - handleSSE.pipe(Effect.provide(TypeSafeSSE.make(rawStream))), + await Runtime.runPromise(runtime)( + sseController + .handleSSE(rawStream) + .pipe(Effect.provide(TypeSafeSSE.make(rawStream))), ); - - await connectionPromise; }, async (err) => { console.error("Streaming error:", err); }, ); }) + + /** + * FileSystemController Routes + */ + + .get( + "/fs/file-completion", + zValidator( + "query", + z.object({ + projectId: z.string(), + basePath: z.string().optional().default("/"), + }), + ), + async (c) => { + const response = await effectToResponse( + c, + fileSystemController.getFileCompletionRoute({ + ...c.req.valid("query"), + }), + ); + + return response; + }, + ) + + .get( + "/fs/directory-browser", + zValidator( + "query", + z.object({ + currentPath: z.string().optional(), + }), + ), + async (c) => { + const response = await effectToResponse( + c, + fileSystemController.getDirectoryListingRoute({ + ...c.req.valid("query"), + }), + ); + return response; + }, + ) ); }); diff --git a/src/server/lib/effect/toEffectResponse.ts b/src/server/lib/effect/toEffectResponse.ts new file mode 100644 index 0000000..1763456 --- /dev/null +++ b/src/server/lib/effect/toEffectResponse.ts @@ -0,0 +1,43 @@ +import { Effect } from "effect"; +import type { Context, Input } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import type { HonoContext } from "../../hono/app"; + +export type ControllerResponse = { + status: ContentfulStatusCode; + response: object; +}; + +declare const dummyCtx: Context; +const dummyJson = ( + s: S, + t: T, +) => dummyCtx.json(t, s); +type ResponseType< + S extends ContentfulStatusCode, + T extends object, +> = ReturnType>; + +export const effectToResponse = async < + const P extends string, + const I extends Input, + const CR extends ControllerResponse, + const E, + Ret = CR extends infer I + ? I extends { status: infer S; response: infer T } + ? S extends ContentfulStatusCode + ? T extends object + ? ResponseType + : never + : never + : never + : never, +>( + ctx: Context, + effect: Effect.Effect, +) => { + const result = await Effect.runPromise(effect); + const result2 = ctx.json(result.response, result.status); + + return result2 as Ret; +};