mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2026-01-08 08:04:23 +01:00
refactor: split request handle logic to controller
This commit is contained in:
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
32
src/server/core/claude-code/functions/parseMcpListOutput.ts
Normal file
32
src/server/core/claude-code/functions/parseMcpListOutput.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
) => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
41
src/server/core/claude-code/services/ClaudeCodeService.ts
Normal file
41
src/server/core/claude-code/services/ClaudeCodeService.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user