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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 112 additions & 2 deletions packages/codingcode/src/hooks/hooks.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -29,7 +45,7 @@

export const HookLayer = Layer.effect(
HookService,
Effect.gen(function* () {

Check warning on line 48 in packages/codingcode/src/hooks/hooks.ts

View workflow job for this annotation

GitHub Actions / lint

This generator function does not have 'yield'
const hooksByProject = new Map<ProjectPath, Map<HookPoint, HandlerEntry[]>>();

/** 某个项目在某个 hook 点上生效的 handler,按 priority 升序 */
Expand All @@ -39,6 +55,21 @@
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<string, unknown>): Effect.Effect<void> => {
const projectPath = payload.projectPath as string | undefined;
Expand Down Expand Up @@ -105,6 +136,85 @@
}
hooksByProject.set(projectPath, projectMap);
}),

listHooks: (projectPath?: string): Effect.Effect<HookConfigEntry[]> =>
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<HookConfigOutcome> =>
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<HookConfigOutcome> =>
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<HookConfigOutcome> =>
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<void> =>
Effect.sync(() => {
if (projectPath === undefined) setGlobalHookEnabled(name, enabled);
else setProjectHookEnabled(projectPath, name, enabled);
}),
};
})
);
26 changes: 25 additions & 1 deletion packages/codingcode/src/hooks/port.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Effect.Effect<void>;
Expand All @@ -9,6 +15,24 @@ export interface HookShape {
payload: Record<string, unknown>
): Effect.Effect<HookDecision | null>;
reloadUserHooks(projectPath: string): Effect.Effect<void>;

/** projectPath 省略(undefined) = 全局层,模块内部经 getGlobalDir() 定位 */
listHooks(projectPath?: string): Effect.Effect<HookConfigEntry[]>;
createHook(
projectPath: string | undefined,
hook: UserHookConfig
): Effect.Effect<HookConfigOutcome>;
updateHook(
projectPath: string | undefined,
name: string,
hook: UserHookConfig
): Effect.Effect<HookConfigOutcome>;
deleteHook(projectPath: string | undefined, name: string): Effect.Effect<HookConfigOutcome>;
setHookEnabled(
projectPath: string | undefined,
name: string,
enabled: boolean
): Effect.Effect<void>;
}

export class HookService extends Context.Tag('HookService')<HookService, HookShape>() {}
13 changes: 13 additions & 0 deletions packages/codingcode/src/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => Effect.Effect<void, never, any>;

Expand Down
106 changes: 104 additions & 2 deletions packages/codingcode/src/mcp/mcp.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<void> =>
Effect.gen(function* () {
Expand Down Expand Up @@ -137,6 +160,85 @@ export const McpLayer = Layer.effect(
}
return specs;
}),

listServers: (projectPath?: string): Effect.Effect<McpServerEntry[]> =>
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<McpConfigOutcome> =>
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<McpConfigOutcome> =>
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<McpConfigOutcome> =>
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<void> =>
Effect.sync(() => {
if (projectPath === undefined) setGlobalMcpServerEnabled(name, enabled);
else setProjectMcpServerEnabled(projectPath, name, enabled);
}),
};
})
);
Expand Down
20 changes: 19 additions & 1 deletion packages/codingcode/src/mcp/port.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
listProjectMcpTools(projectPath: string): Effect.Effect<McpToolSpec[]>;

/** projectPath 省略(undefined) = 全局层,模块内部经 getGlobalDir() 定位 */
listServers(projectPath?: string): Effect.Effect<McpServerEntry[]>;
createServer(
projectPath: string | undefined,
server: McpServerConfig
): Effect.Effect<McpConfigOutcome>;
updateServer(
projectPath: string | undefined,
name: string,
server: McpServerConfig
): Effect.Effect<McpConfigOutcome>;
deleteServer(projectPath: string | undefined, name: string): Effect.Effect<McpConfigOutcome>;
setServerEnabled(
projectPath: string | undefined,
name: string,
enabled: boolean
): Effect.Effect<void>;
}

export class McpService extends Context.Tag('Mcp')<McpService, McpShape>() {}
13 changes: 13 additions & 0 deletions packages/codingcode/src/mcp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading