diff --git a/packages/codingcode/src/hooks/hooks.ts b/packages/codingcode/src/hooks/hooks.ts index aff7a636..5d736fa7 100644 --- a/packages/codingcode/src/hooks/hooks.ts +++ b/packages/codingcode/src/hooks/hooks.ts @@ -1,10 +1,26 @@ import { Layer, Effect } from 'effect'; -import { resolveHookConfigs } from './config.js'; +import { + resolveHookConfigs, + loadHookConfigs, + writeHookConfigs, + loadGlobalHookConfigs, + writeGlobalHookConfigs, + setGlobalHookEnabled, + setProjectHookEnabled, +} from './config.js'; import { executeHookCommand, executeDecisionHookCommand } from './executor.js'; import { createLogger } from '../infra/logger.js'; import { HookService } from './port.js'; import type { HookPoint, HookDecision, HookCommandOutput } from './types.js'; -import type { ObserverHandler, DecisionHandler, HandlerEntry, ProjectPath } from './types.js'; +import type { + ObserverHandler, + DecisionHandler, + HandlerEntry, + ProjectPath, + UserHookConfig, + HookConfigEntry, + HookConfigOutcome, +} from './types.js'; const logger = createLogger(); @@ -39,6 +55,21 @@ export const HookLayer = Layer.effect( return list.slice().sort((a, b) => a.priority - b.priority); } + // 配置读写:projectPath 省略 = 全局层 + function loadFor(projectPath?: string): UserHookConfig[] { + return projectPath === undefined ? loadGlobalHookConfigs() : loadHookConfigs(projectPath); + } + + function writeFor(projectPath: string | undefined, hooks: UserHookConfig[]): void { + if (projectPath === undefined) writeGlobalHookConfigs(hooks); + else writeHookConfigs(projectPath, hooks); + } + + /** 开关归一:缺省(undefined)等同启用 */ + function enabledOf(hook: UserHookConfig): UserHookConfig & { enabled: boolean } { + return { ...hook, enabled: hook.enabled !== false }; + } + return { emit: (point: HookPoint, payload: Record): Effect.Effect => { const projectPath = payload.projectPath as string | undefined; @@ -105,6 +136,85 @@ export const HookLayer = Layer.effect( } hooksByProject.set(projectPath, projectMap); }), + + listHooks: (projectPath?: string): Effect.Effect => + Effect.sync(() => { + const globalHooks = loadGlobalHookConfigs(); + // 全局窗口只列全局项,不带 hasProjectOverride + if (projectPath === undefined) { + return globalHooks.map((h) => ({ ...enabledOf(h), source: 'global' as const })); + } + const projectHooks = loadHookConfigs(projectPath); + const globalNames = new Set(globalHooks.map((h) => h.name)); + const projectNames = new Set(projectHooks.map((h) => h.name)); + return resolveHookConfigs(projectPath).map((h) => { + const isFromProject = projectNames.has(h.name); + const isFromGlobal = globalNames.has(h.name); + return { + ...enabledOf(h), + source: isFromProject ? ('project' as const) : ('global' as const), + hasProjectOverride: isFromProject && isFromGlobal, + }; + }); + }), + + createHook: ( + projectPath: string | undefined, + hook: UserHookConfig + ): Effect.Effect => + Effect.sync(() => { + const hooks = loadFor(projectPath); + if (hooks.some((h) => h.name === hook.name)) { + return { ok: false, reason: 'already-exists' } as const; + } + hooks.push(hook); + writeFor(projectPath, hooks); + return { ok: true } as const; + }), + + updateHook: ( + projectPath: string | undefined, + name: string, + hook: UserHookConfig + ): Effect.Effect => + Effect.sync(() => { + const hooks = loadFor(projectPath); + const idx = hooks.findIndex((h) => h.name === name); + if (idx === -1) return { ok: false, reason: 'not-found' } as const; + if (hook.name !== name && hooks.some((h) => h.name === hook.name)) { + return { ok: false, reason: 'already-exists' } as const; + } + hooks[idx] = hook; + writeFor(projectPath, hooks); + return { ok: true } as const; + }), + + deleteHook: ( + projectPath: string | undefined, + name: string + ): Effect.Effect => + Effect.sync(() => { + const hooks = loadFor(projectPath); + // 语义保持:项目层缺名报 not-found,全局层删除幂等 + if (projectPath !== undefined && !hooks.some((h) => h.name === name)) { + return { ok: false, reason: 'not-found' } as const; + } + writeFor( + projectPath, + hooks.filter((h) => h.name !== name) + ); + return { ok: true } as const; + }), + + setHookEnabled: ( + projectPath: string | undefined, + name: string, + enabled: boolean + ): Effect.Effect => + Effect.sync(() => { + if (projectPath === undefined) setGlobalHookEnabled(name, enabled); + else setProjectHookEnabled(projectPath, name, enabled); + }), }; }) ); diff --git a/packages/codingcode/src/hooks/port.ts b/packages/codingcode/src/hooks/port.ts index 011c3001..477dfef2 100644 --- a/packages/codingcode/src/hooks/port.ts +++ b/packages/codingcode/src/hooks/port.ts @@ -1,6 +1,12 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; -import type { HookPoint, HookDecision } from './types.js'; +import type { + HookPoint, + HookDecision, + HookConfigEntry, + HookConfigOutcome, + UserHookConfig, +} from './types.js'; export interface HookShape { emit(point: HookPoint, payload: Record): Effect.Effect; @@ -9,6 +15,24 @@ export interface HookShape { payload: Record ): Effect.Effect; reloadUserHooks(projectPath: string): Effect.Effect; + + /** projectPath 省略(undefined) = 全局层,模块内部经 getGlobalDir() 定位 */ + listHooks(projectPath?: string): Effect.Effect; + createHook( + projectPath: string | undefined, + hook: UserHookConfig + ): Effect.Effect; + updateHook( + projectPath: string | undefined, + name: string, + hook: UserHookConfig + ): Effect.Effect; + deleteHook(projectPath: string | undefined, name: string): Effect.Effect; + setHookEnabled( + projectPath: string | undefined, + name: string, + enabled: boolean + ): Effect.Effect; } export class HookService extends Context.Tag('HookService')() {} diff --git a/packages/codingcode/src/hooks/types.ts b/packages/codingcode/src/hooks/types.ts index f4b305b5..91442da3 100644 --- a/packages/codingcode/src/hooks/types.ts +++ b/packages/codingcode/src/hooks/types.ts @@ -37,6 +37,19 @@ export interface UserHookConfig { enabled?: boolean; } +/** 配置写操作的结果:只表达成败与原因,HTTP 语义由调用方翻译 */ +export type HookConfigOutcome = + | { readonly ok: true } + | { readonly ok: false; readonly reason: 'not-found' | 'already-exists' }; + +/** 列表项:配置 + 归一后的 enabled 与来源标注 */ +export interface HookConfigEntry extends UserHookConfig { + enabled: boolean; + source: 'global' | 'project'; + /** 仅项目层返回;全局层不带该键 */ + hasProjectOverride?: boolean; +} + /** 观察者:只被通知,返回值被忽略 */ export type ObserverHandler = (payload: Record) => Effect.Effect; diff --git a/packages/codingcode/src/mcp/mcp.ts b/packages/codingcode/src/mcp/mcp.ts index a85d4080..7b5eff43 100644 --- a/packages/codingcode/src/mcp/mcp.ts +++ b/packages/codingcode/src/mcp/mcp.ts @@ -1,8 +1,16 @@ import { Effect, Layer } from 'effect'; -import { resolveMcpConfig } from './config.js'; +import { + resolveMcpConfig, + loadMcpConfig, + writeMcpConfig, + loadGlobalMcpConfig, + writeGlobalMcpConfig, + setGlobalMcpServerEnabled, + setProjectMcpServerEnabled, +} from './config.js'; import { McpClient } from './client.js'; import { McpService } from './port.js'; -import type { McpServerConfig, McpToolSpec } from './types.js'; +import type { McpServerConfig, McpToolSpec, McpServerEntry, McpConfigOutcome } from './types.js'; import { createLogger } from '../infra/logger.js'; import { AgentError } from '../util/error.js'; @@ -95,6 +103,21 @@ export const McpLayer = Layer.effect( }); } + // 配置读写:projectPath 省略 = 全局层 + function loadFor(projectPath?: string): McpServerConfig[] { + return projectPath === undefined ? loadGlobalMcpConfig() : loadMcpConfig(projectPath); + } + + function writeFor(projectPath: string | undefined, servers: McpServerConfig[]): void { + if (projectPath === undefined) writeGlobalMcpConfig(servers); + else writeMcpConfig(projectPath, servers); + } + + /** 开关归一:缺省(undefined)等同启用 */ + function enabledOf(server: McpServerConfig): McpServerConfig & { enabled: boolean } { + return { ...server, enabled: server.enabled !== false }; + } + return { syncConnections: (projectPath: string): Effect.Effect => Effect.gen(function* () { @@ -137,6 +160,85 @@ export const McpLayer = Layer.effect( } return specs; }), + + listServers: (projectPath?: string): Effect.Effect => + Effect.sync(() => { + const globalServers = loadGlobalMcpConfig(); + // 全局窗口只列全局项,不带 hasProjectOverride + if (projectPath === undefined) { + return globalServers.map((s) => ({ ...enabledOf(s), source: 'global' as const })); + } + const projectServers = loadMcpConfig(projectPath); + const globalNames = new Set(globalServers.map((s) => s.name)); + const projectNames = new Set(projectServers.map((s) => s.name)); + return resolveMcpConfig(projectPath).map((s) => { + const isFromProject = projectNames.has(s.name); + const isFromGlobal = globalNames.has(s.name); + return { + ...enabledOf(s), + source: isFromProject ? ('project' as const) : ('global' as const), + hasProjectOverride: isFromProject && isFromGlobal, + }; + }); + }), + + createServer: ( + projectPath: string | undefined, + server: McpServerConfig + ): Effect.Effect => + Effect.sync(() => { + const servers = loadFor(projectPath); + if (servers.some((s) => s.name === server.name)) { + return { ok: false, reason: 'already-exists' } as const; + } + servers.push(server); + writeFor(projectPath, servers); + return { ok: true } as const; + }), + + updateServer: ( + projectPath: string | undefined, + name: string, + server: McpServerConfig + ): Effect.Effect => + Effect.sync(() => { + const servers = loadFor(projectPath); + const idx = servers.findIndex((s) => s.name === name); + if (idx === -1) return { ok: false, reason: 'not-found' } as const; + if (server.name !== name && servers.some((s) => s.name === server.name)) { + return { ok: false, reason: 'already-exists' } as const; + } + servers[idx] = server; + writeFor(projectPath, servers); + return { ok: true } as const; + }), + + deleteServer: ( + projectPath: string | undefined, + name: string + ): Effect.Effect => + Effect.sync(() => { + const servers = loadFor(projectPath); + // 语义保持:项目层缺名报 not-found,全局层删除幂等 + if (projectPath !== undefined && !servers.some((s) => s.name === name)) { + return { ok: false, reason: 'not-found' } as const; + } + writeFor( + projectPath, + servers.filter((s) => s.name !== name) + ); + return { ok: true } as const; + }), + + setServerEnabled: ( + projectPath: string | undefined, + name: string, + enabled: boolean + ): Effect.Effect => + Effect.sync(() => { + if (projectPath === undefined) setGlobalMcpServerEnabled(name, enabled); + else setProjectMcpServerEnabled(projectPath, name, enabled); + }), }; }) ); diff --git a/packages/codingcode/src/mcp/port.ts b/packages/codingcode/src/mcp/port.ts index 9820ce93..80097c73 100644 --- a/packages/codingcode/src/mcp/port.ts +++ b/packages/codingcode/src/mcp/port.ts @@ -1,10 +1,28 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; -import type { McpToolSpec } from './types.js'; +import type { McpToolSpec, McpServerConfig, McpServerEntry, McpConfigOutcome } from './types.js'; export interface McpShape { syncConnections(projectPath: string): Effect.Effect; listProjectMcpTools(projectPath: string): Effect.Effect; + + /** projectPath 省略(undefined) = 全局层,模块内部经 getGlobalDir() 定位 */ + listServers(projectPath?: string): Effect.Effect; + createServer( + projectPath: string | undefined, + server: McpServerConfig + ): Effect.Effect; + updateServer( + projectPath: string | undefined, + name: string, + server: McpServerConfig + ): Effect.Effect; + deleteServer(projectPath: string | undefined, name: string): Effect.Effect; + setServerEnabled( + projectPath: string | undefined, + name: string, + enabled: boolean + ): Effect.Effect; } export class McpService extends Context.Tag('Mcp')() {} diff --git a/packages/codingcode/src/mcp/types.ts b/packages/codingcode/src/mcp/types.ts index b591edd5..6331c302 100644 --- a/packages/codingcode/src/mcp/types.ts +++ b/packages/codingcode/src/mcp/types.ts @@ -21,6 +21,19 @@ export interface McpServerConfig { autoReconnect?: boolean; } +/** 配置写操作的结果:只表达成败与原因,HTTP 语义由调用方翻译 */ +export type McpConfigOutcome = + | { readonly ok: true } + | { readonly ok: false; readonly reason: 'not-found' | 'already-exists' }; + +/** 列表项:配置 + 归一后的 enabled 与来源标注 */ +export interface McpServerEntry extends McpServerConfig { + enabled: boolean; + source: 'global' | 'project'; + /** 仅项目层返回;全局层不带该键 */ + hasProjectOverride?: boolean; +} + /** MCP 远端工具的纯数据描述:zod schema 与 SDK client 等机制形状由实现层持有 */ export interface McpToolSpec { server: string; diff --git a/packages/codingcode/src/server/routes/hooks.ts b/packages/codingcode/src/server/routes/hooks.ts index 8d65368b..dd302eb2 100644 --- a/packages/codingcode/src/server/routes/hooks.ts +++ b/packages/codingcode/src/server/routes/hooks.ts @@ -1,19 +1,11 @@ import * as HttpRouter from '@effect/platform/HttpRouter'; import { Effect } from 'effect'; import { isGlobalCwd, resolveCwd } from '../cwd.js'; -import { AlreadyExistsError, NotFoundError, type AppError } from '../http-error.js'; +import { AlreadyExistsError, NotFoundError } from '../http-error.js'; import { AgentError } from '../../util/error.js'; import { json, pathParams, query, readJson, type Handler, type Router } from '../handler.js'; +import { HookService } from '../../hooks/port.js'; import type { UserHookConfig } from '../../hooks/types.js'; -import { - loadHookConfigs, - writeHookConfigs, - loadGlobalHookConfigs, - writeGlobalHookConfigs, - resolveHookConfigs, - setGlobalHookEnabled, - setProjectHookEnabled, -} from '../../hooks/config.js'; function toHookConfig(body: Partial): UserHookConfig | null { const { name, point, type, command } = body; @@ -23,73 +15,14 @@ function toHookConfig(body: Partial): UserHookConfig | null { const MISSING_FIELDS = 'Missing required fields: name, point, type, command'; -const hooksCreate = (cwd: string, hook: UserHookConfig): Effect.Effect => - Effect.gen(function* () { - const hooks = loadHookConfigs(cwd); - if (hooks.some((h) => h.name === hook.name)) { - return yield* Effect.fail(new AlreadyExistsError(`Hook '${hook.name}' already exists`)); - } - hooks.push(hook); - writeHookConfigs(cwd, hooks); - }); - -const hooksUpdate = ( - cwd: string, - name: string, - hook: UserHookConfig -): Effect.Effect => - Effect.gen(function* () { - const hooks = loadHookConfigs(cwd); - const idx = hooks.findIndex((h) => h.name === name); - if (idx === -1) return yield* Effect.fail(new NotFoundError(`Hook '${name}' not found`)); - if (hook.name !== name && hooks.some((h) => h.name === hook.name)) { - return yield* Effect.fail(new AlreadyExistsError(`Hook '${hook.name}' already exists`)); - } - hooks[idx] = hook; - writeHookConfigs(cwd, hooks); - }); - -const hooksDelete = (cwd: string, name: string): Effect.Effect => - Effect.gen(function* () { - const hooks = loadHookConfigs(cwd); - if (!hooks.some((h) => h.name === name)) { - return yield* Effect.fail(new NotFoundError(`Hook '${name}' not found in project config`)); - } - writeHookConfigs( - cwd, - hooks.filter((h) => h.name !== name) - ); - }); +/** `?cwd` 约定 → 模块入参:空 / global 即全局层(undefined) */ +const scopeOf = (rawCwd: string | undefined): string | undefined => + isGlobalCwd(rawCwd) ? undefined : resolveCwd(rawCwd); const listHooks: Handler = Effect.gen(function* () { const { cwd: rawCwd } = yield* query; - if (isGlobalCwd(rawCwd)) { - return json( - loadGlobalHookConfigs().map((h) => ({ - ...h, - enabled: h.enabled !== false, - source: 'global' as const, - })) - ); - } - - const cwd = resolveCwd(rawCwd); - const globalHooks = loadGlobalHookConfigs(); - const projectHooks = loadHookConfigs(cwd); - const globalNames = new Set(globalHooks.map((h) => h.name)); - const projectNames = new Set(projectHooks.map((h) => h.name)); - return json( - resolveHookConfigs(cwd).map((h) => { - const isFromProject = projectNames.has(h.name); - const isFromGlobal = globalNames.has(h.name); - return { - ...h, - enabled: h.enabled !== false, - source: isFromProject ? 'project' : 'global', - hasProjectOverride: isFromProject && isFromGlobal, - }; - }) - ); + const hooks = yield* HookService; + return json(yield* hooks.listHooks(scopeOf(rawCwd))); }); const createHook: Handler = Effect.gen(function* () { @@ -98,15 +31,10 @@ const createHook: Handler = Effect.gen(function* () { const hook = toHookConfig(body); if (!hook) return yield* Effect.fail(new AgentError('CONFIG_MISSING', MISSING_FIELDS)); - if (isGlobalCwd(rawCwd)) { - const hooks = loadGlobalHookConfigs(); - if (hooks.some((h) => h.name === hook.name)) { - return yield* Effect.fail(new AlreadyExistsError(`Hook '${hook.name}' already exists`)); - } - hooks.push(hook); - writeGlobalHookConfigs(hooks); - } else { - yield* hooksCreate(resolveCwd(rawCwd), hook); + const hooks = yield* HookService; + const outcome = yield* hooks.createHook(scopeOf(rawCwd), hook); + if (!outcome.ok) { + return yield* Effect.fail(new AlreadyExistsError(`Hook '${hook.name}' already exists`)); } return json({ ok: true }); }); @@ -119,17 +47,14 @@ const updateHook: Handler = Effect.gen(function* () { if (!hook) return yield* Effect.fail(new AgentError('CONFIG_MISSING', MISSING_FIELDS)); const target = name ?? ''; - if (isGlobalCwd(rawCwd)) { - const hooks = loadGlobalHookConfigs(); - const idx = hooks.findIndex((h) => h.name === target); - if (idx === -1) return yield* Effect.fail(new NotFoundError(`Hook '${target}' not found`)); - if (hook.name !== target && hooks.some((h) => h.name === hook.name)) { - return yield* Effect.fail(new AlreadyExistsError(`Hook '${hook.name}' already exists`)); - } - hooks[idx] = hook; - writeGlobalHookConfigs(hooks); - } else { - yield* hooksUpdate(resolveCwd(rawCwd), target, hook); + const hooks = yield* HookService; + const outcome = yield* hooks.updateHook(scopeOf(rawCwd), target, hook); + if (!outcome.ok) { + return yield* Effect.fail( + outcome.reason === 'already-exists' + ? new AlreadyExistsError(`Hook '${hook.name}' already exists`) + : new NotFoundError(`Hook '${target}' not found`) + ); } return json({ ok: true }); }); @@ -138,25 +63,22 @@ const deleteHook: Handler = Effect.gen(function* () { const { name } = yield* pathParams; const { cwd: rawCwd } = yield* query; const target = name ?? ''; - if (isGlobalCwd(rawCwd)) { - writeGlobalHookConfigs(loadGlobalHookConfigs().filter((h) => h.name !== target)); - } else { - yield* hooksDelete(resolveCwd(rawCwd), target); + + const hooks = yield* HookService; + const outcome = yield* hooks.deleteHook(scopeOf(rawCwd), target); + if (!outcome.ok) { + return yield* Effect.fail(new NotFoundError(`Hook '${target}' not found in project config`)); } return json({ ok: true }); }); -// 开关就是配置里的 enabled 字段:改开关 = 写回对应层的 hooks.yaml const setHookEnabled: Handler = Effect.gen(function* () { const { name } = yield* pathParams; const body = yield* readJson<{ enabled: boolean }>(); const { cwd: rawCwd } = yield* query; - const target = name ?? ''; - if (isGlobalCwd(rawCwd)) { - setGlobalHookEnabled(target, body.enabled); - } else { - setProjectHookEnabled(resolveCwd(rawCwd), target, body.enabled); - } + + const hooks = yield* HookService; + yield* hooks.setHookEnabled(scopeOf(rawCwd), name ?? '', body.enabled); return json({ ok: true }); }); diff --git a/packages/codingcode/src/server/routes/mcp.ts b/packages/codingcode/src/server/routes/mcp.ts index 0ed96c67..5d158f05 100644 --- a/packages/codingcode/src/server/routes/mcp.ts +++ b/packages/codingcode/src/server/routes/mcp.ts @@ -1,19 +1,11 @@ import * as HttpRouter from '@effect/platform/HttpRouter'; import { Effect } from 'effect'; import { isGlobalCwd, resolveCwd } from '../cwd.js'; -import { AlreadyExistsError, NotFoundError, type AppError } from '../http-error.js'; +import { AlreadyExistsError, NotFoundError } from '../http-error.js'; import { AgentError } from '../../util/error.js'; import { json, pathParams, query, readJson, type Handler, type Router } from '../handler.js'; +import { McpService } from '../../mcp/port.js'; import type { McpServerConfig } from '../../mcp/types.js'; -import { - loadMcpConfig, - writeMcpConfig, - loadGlobalMcpConfig, - writeGlobalMcpConfig, - resolveMcpConfig, - setGlobalMcpServerEnabled, - setProjectMcpServerEnabled, -} from '../../mcp/config.js'; /** 校验必填项后构造领域对象;缺 name 返回 null,由调用方落 400。不用 `as` 把未验证 body 断言成 McpServerConfig。 */ function toMcpServerConfig(body: Partial): McpServerConfig | null { @@ -24,73 +16,14 @@ function toMcpServerConfig(body: Partial): McpServerConfig | nu const MISSING_NAME = 'Missing required field: name'; -const mcpCreateServer = (cwd: string, server: McpServerConfig): Effect.Effect => - Effect.gen(function* () { - const servers = loadMcpConfig(cwd); - if (servers.some((s) => s.name === server.name)) { - return yield* Effect.fail(new AlreadyExistsError(`MCP server '${server.name}' already exists`)); - } - servers.push(server); - writeMcpConfig(cwd, servers); - }); - -const mcpUpdateServer = ( - cwd: string, - name: string, - server: McpServerConfig -): Effect.Effect => - Effect.gen(function* () { - const servers = loadMcpConfig(cwd); - const idx = servers.findIndex((s) => s.name === name); - if (idx === -1) return yield* Effect.fail(new NotFoundError(`MCP server '${name}' not found`)); - if (server.name !== name && servers.some((s) => s.name === server.name)) { - return yield* Effect.fail(new AlreadyExistsError(`MCP server '${server.name}' already exists`)); - } - servers[idx] = server; - writeMcpConfig(cwd, servers); - }); - -const mcpDeleteServer = (cwd: string, name: string): Effect.Effect => - Effect.gen(function* () { - const servers = loadMcpConfig(cwd); - if (!servers.some((s) => s.name === name)) { - return yield* Effect.fail(new NotFoundError(`MCP server '${name}' not found in project config`)); - } - writeMcpConfig( - cwd, - servers.filter((s) => s.name !== name) - ); - }); +/** `?cwd` 约定 → 模块入参:空 / global 即全局层(undefined) */ +const scopeOf = (rawCwd: string | undefined): string | undefined => + isGlobalCwd(rawCwd) ? undefined : resolveCwd(rawCwd); const listServers: Handler = Effect.gen(function* () { const { cwd: rawCwd } = yield* query; - if (isGlobalCwd(rawCwd)) { - return json( - loadGlobalMcpConfig().map((s) => ({ - ...s, - enabled: s.enabled !== false, - source: 'global' as const, - })) - ); - } - - const cwd = resolveCwd(rawCwd); - const globalServers = loadGlobalMcpConfig(); - const projectServers = loadMcpConfig(cwd); - const globalNames = new Set(globalServers.map((s) => s.name)); - const projectNames = new Set(projectServers.map((s) => s.name)); - return json( - resolveMcpConfig(cwd).map((s) => { - const isFromProject = projectNames.has(s.name); - const isFromGlobal = globalNames.has(s.name); - return { - ...s, - enabled: s.enabled !== false, - source: isFromProject ? 'project' : 'global', - hasProjectOverride: isFromProject && isFromGlobal, - }; - }) - ); + const mcp = yield* McpService; + return json(yield* mcp.listServers(scopeOf(rawCwd))); }); const createServer: Handler = Effect.gen(function* () { @@ -99,15 +32,10 @@ const createServer: Handler = Effect.gen(function* () { const server = toMcpServerConfig(body); if (!server) return yield* Effect.fail(new AgentError('CONFIG_MISSING', MISSING_NAME)); - if (isGlobalCwd(rawCwd)) { - const servers = loadGlobalMcpConfig(); - if (servers.some((s) => s.name === server.name)) { - return yield* Effect.fail(new AlreadyExistsError(`MCP server '${server.name}' already exists`)); - } - servers.push(server); - writeGlobalMcpConfig(servers); - } else { - yield* mcpCreateServer(resolveCwd(rawCwd), server); + const mcp = yield* McpService; + const outcome = yield* mcp.createServer(scopeOf(rawCwd), server); + if (!outcome.ok) { + return yield* Effect.fail(new AlreadyExistsError(`MCP server '${server.name}' already exists`)); } return json({ ok: true }); }); @@ -120,17 +48,14 @@ const updateServer: Handler = Effect.gen(function* () { if (!server) return yield* Effect.fail(new AgentError('CONFIG_MISSING', MISSING_NAME)); const target = name ?? ''; - if (isGlobalCwd(rawCwd)) { - const servers = loadGlobalMcpConfig(); - const idx = servers.findIndex((s) => s.name === target); - if (idx === -1) return yield* Effect.fail(new NotFoundError(`MCP server '${target}' not found`)); - if (server.name !== target && servers.some((s) => s.name === server.name)) { - return yield* Effect.fail(new AlreadyExistsError(`MCP server '${server.name}' already exists`)); - } - servers[idx] = server; - writeGlobalMcpConfig(servers); - } else { - yield* mcpUpdateServer(resolveCwd(rawCwd), target, server); + const mcp = yield* McpService; + const outcome = yield* mcp.updateServer(scopeOf(rawCwd), target, server); + if (!outcome.ok) { + return yield* Effect.fail( + outcome.reason === 'already-exists' + ? new AlreadyExistsError(`MCP server '${server.name}' already exists`) + : new NotFoundError(`MCP server '${target}' not found`) + ); } return json({ ok: true }); }); @@ -139,25 +64,24 @@ const deleteServer: Handler = Effect.gen(function* () { const { name } = yield* pathParams; const { cwd: rawCwd } = yield* query; const target = name ?? ''; - if (isGlobalCwd(rawCwd)) { - writeGlobalMcpConfig(loadGlobalMcpConfig().filter((s) => s.name !== target)); - } else { - yield* mcpDeleteServer(resolveCwd(rawCwd), target); + + const mcp = yield* McpService; + const outcome = yield* mcp.deleteServer(scopeOf(rawCwd), target); + if (!outcome.ok) { + return yield* Effect.fail( + new NotFoundError(`MCP server '${target}' not found in project config`) + ); } return json({ ok: true }); }); -// 开关就是配置里的 enabled 字段:改开关 = 写回对应层的 mcp.yaml const setServerEnabled: Handler = Effect.gen(function* () { const { name } = yield* pathParams; const { cwd: rawCwd } = yield* query; const body = yield* readJson<{ enabled: boolean }>(); - const target = name ?? ''; - if (isGlobalCwd(rawCwd)) { - setGlobalMcpServerEnabled(target, body.enabled); - } else { - setProjectMcpServerEnabled(resolveCwd(rawCwd), target, body.enabled); - } + + const mcp = yield* McpService; + yield* mcp.setServerEnabled(scopeOf(rawCwd), name ?? '', body.enabled); return json({ ok: true }); }); diff --git a/packages/codingcode/test/approval/pipeline.test.ts b/packages/codingcode/test/approval/pipeline.test.ts index 1b75f1cb..1dfc0b92 100644 --- a/packages/codingcode/test/approval/pipeline.test.ts +++ b/packages/codingcode/test/approval/pipeline.test.ts @@ -25,7 +25,7 @@ const mockEventSink = { emit: () => Effect.void, }; -const HookTestLayer = Layer.succeed(HookService, mockHookService); +const HookTestLayer = Layer.succeed(HookService, mockHookService as any); const WaitTestLayer = Layer.succeed(ApprovalWaitService, mockApprovalWaitService); const SinkTestLayer = Layer.succeed(EventSinkService, mockEventSink as any); const TestLayer = Layer.mergeAll(HookTestLayer, WaitTestLayer, SinkTestLayer); diff --git a/packages/codingcode/test/server/request-body-validation.test.ts b/packages/codingcode/test/server/request-body-validation.test.ts index 8bcb7760..f2fb40a4 100644 --- a/packages/codingcode/test/server/request-body-validation.test.ts +++ b/packages/codingcode/test/server/request-body-validation.test.ts @@ -11,19 +11,21 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { parse as parseYaml } from 'yaml'; import { createServer, type ServerApp } from '../../src/server/index.js'; +import { HookLayer } from '../../src/hooks/hooks.js'; +import { McpLayer } from '../../src/mcp/mcp.js'; import { useTempHome } from '../helpers/temp-home.js'; // 全局层落在临时 home,避免写坏开发机的 ~/.codingcode useTempHome('codingcode-test-body-'); let projectDir: string; -// 校验失败在任何 Effect 之前就返回,故运行时可以是空壳 —— 只有走到「校验通过」的 -// 分支才会去 context 里取 SchedulerService。 +// 校验失败在任何 Effect 之前就返回,故 400 用例不需要任何服务;跑通写路径只需要 +// hooks / mcp 两个无依赖的层。 let app: ServerApp; beforeEach(async () => { projectDir = mkdtempSync(join(tmpdir(), 'codingcode-test-body-project-')); - app = await createServer(ManagedRuntime.make(Layer.empty as any)); + app = await createServer(ManagedRuntime.make(Layer.mergeAll(HookLayer, McpLayer))); }); afterEach(() => {