refactor: split request handle logic to controller

This commit is contained in:
d-kimsuon
2025-10-17 23:46:38 +09:00
parent c745824dbe
commit 1bd122daa0
22 changed files with 1308 additions and 693 deletions

View File

@@ -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",
},
]);
});
});

View File

@@ -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;
};

View File

@@ -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,
) => ({

View File

@@ -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<typeof LayerImpl>;
export class ClaudeCodeController extends Context.Tag("ClaudeCodeController")<
ClaudeCodeController,
IClaudeCodeController
>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -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<typeof LayerImpl>;
export class ClaudeCodePermissionController extends Context.Tag(
"ClaudeCodePermissionController",
)<ClaudeCodePermissionController, IClaudeCodePermissionController>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -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<typeof LayerImpl>;
export class ClaudeCodeSessionProcessController extends Context.Tag(
"ClaudeCodeSessionProcessController",
)<ClaudeCodeSessionProcessController, IClaudeCodeSessionProcessController>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -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),
};
});
};

View File

@@ -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<typeof LayerImpl>;
export class ClaudeCodeService extends Context.Tag("ClaudeCodeService")<
ClaudeCodeService,
IClaudeCodeService
>() {
static Live = Layer.effect(this, LayerImpl);
}