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
11 changes: 3 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# === 飞书应用配置 ===
FEISHU_APP_ID=cli_xxxxxxxxxx
FEISHU_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx
# === 飞书配置 ===
# 飞书应用凭证 (appId/appSecret) 已迁移到 config/agents.json 的 agent.feishu 字段
# 运行 npm run onboard 自动配置,或手动编辑 config/agents.json

# 事件接收模式 (默认 websocket,推荐):
# websocket - 长连接,无需公网/域名/HTTPS,SDK 主动连接飞书
Expand Down Expand Up @@ -112,11 +112,6 @@ FEISHU_TOOLS_CALENDAR=true
# 无配置文件时自动使用内置 dev agent 兜底
# AGENT_CONFIG_PATH=./config/agents.json

# === 多 Bot / 多 Agent 配置 ===
# 多 bot 账号配置 (JSON 格式,每个 bot 需要独立的 app_id/app_secret)
# BOT_ACCOUNTS=[{"id":"dev-bot","appId":"cli_xxx","appSecret":"xxx"},{"id":"pm-bot","appId":"cli_yyy","appSecret":"yyy"}]
# Agent 绑定规则 (JSON 格式,将 agent 角色绑定到特定 bot/chat)
# AGENT_BINDINGS=[{"agentId":"dev","botId":"dev-bot"},{"agentId":"pm","botId":"pm-bot"}]

# === 服务配置 ===
PORT=3000
Expand Down
10 changes: 10 additions & 0 deletions config/agents.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
{
"id": "pm",
"displayName": "<onboarding 中设定>",
"description": "Chat Bot — 日常对话、问答、讨论。只读模式,不修改代码,直接回复消息(不创建话题)。适合群聊中的轻量交互。",
"feishu": {
"appId": "<飞书应用 App ID>",
"appSecret": "<飞书应用 App Secret>"
},
"model": "claude-opus-4-6",
"replyMode": "direct",
"toolPolicy": {
Expand All @@ -27,6 +32,11 @@
{
"id": "dev",
"displayName": "<onboarding 中设定>",
"description": "Dev Bot — 代码开发、修 bug、创建 PR。完整读写权限,每条消息创建独立话题,非 Owner 需要审批。适合实际编码任务。",
"feishu": {
"appId": "<同上,共享同一个飞书应用>",
"appSecret": "<同上>"
},
"model": "claude-opus-4-6",
"toolPolicy": "all",
"replyMode": "thread",
Expand Down
Empty file removed config/knowledge/.gitkeep
Empty file.
12 changes: 6 additions & 6 deletions config/knowledge/team.example.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@

## Organization

Your Company / Your Team — brief team description.
(在 onboarding 中设定 — 填写你的公司/团队名称和简介)

## Key People

| Name | Role | Notes |
|------|------|-------|
| Alice | Team lead | Full-stack, project owner |
| Bob | Backend | API and infrastructure |
| Carol | Frontend | UI/UX implementation |
| (姓名) | (角色) | (备注) |

## Repositories

| Repo | Description |
|------|-------------|
| [org/main-app](https://github.com/org/main-app) | Main application |
| [org/shared-lib](https://github.com/org/shared-lib) | Shared library |
| (仓库地址) | (项目简介) |

---
此文件是模板。运行 `npm run onboard` 后会根据你的团队信息生成正式的 team.md。
Empty file removed config/personas/.gitkeep
Empty file.
18 changes: 0 additions & 18 deletions config/personas/pm.example.md

This file was deleted.

43 changes: 1 addition & 42 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,36 +12,7 @@ describe('config', () => {
}

describe('validateConfig', () => {
it('should return errors when FEISHU_APP_ID is missing', async () => {
vi.stubEnv('FEISHU_APP_ID', '');
vi.stubEnv('FEISHU_APP_SECRET', 'secret');
vi.stubEnv('BOT_ACCOUNTS', '');
const { validateConfig } = await loadConfig();
const errors = validateConfig();
expect(errors.some(e => e.includes('FEISHU_APP_ID is required'))).toBe(true);
});

it('should return errors when FEISHU_APP_SECRET is missing', async () => {
vi.stubEnv('FEISHU_APP_ID', 'id');
vi.stubEnv('FEISHU_APP_SECRET', '');
vi.stubEnv('BOT_ACCOUNTS', '');
const { validateConfig } = await loadConfig();
const errors = validateConfig();
expect(errors.some(e => e.includes('FEISHU_APP_SECRET is required'))).toBe(true);
});

it('should return errors for both missing fields', async () => {
vi.stubEnv('FEISHU_APP_ID', '');
vi.stubEnv('FEISHU_APP_SECRET', '');
vi.stubEnv('BOT_ACCOUNTS', '');
const { validateConfig } = await loadConfig();
const errors = validateConfig();
expect(errors).toHaveLength(2);
});

it('should return empty array when all required fields are set', async () => {
vi.stubEnv('FEISHU_APP_ID', 'myid');
vi.stubEnv('FEISHU_APP_SECRET', 'mysecret');
it('should return empty array (feishu validation moved to agents.json)', async () => {
const { validateConfig } = await loadConfig();
const errors = validateConfig();
expect(errors).toHaveLength(0);
Expand All @@ -50,32 +21,24 @@ describe('config', () => {

describe('environment variable parsing', () => {
it('should parse comma-separated ALLOWED_USER_IDS', async () => {
vi.stubEnv('FEISHU_APP_ID', 'id');
vi.stubEnv('FEISHU_APP_SECRET', 'secret');
vi.stubEnv('ALLOWED_USER_IDS', 'user1, user2, user3');
const { config } = await loadConfig();
expect(config.security.allowedUserIds).toEqual(['user1', 'user2', 'user3']);
});

it('should handle empty ALLOWED_USER_IDS', async () => {
vi.stubEnv('FEISHU_APP_ID', 'id');
vi.stubEnv('FEISHU_APP_SECRET', 'secret');
vi.stubEnv('ALLOWED_USER_IDS', '');
const { config } = await loadConfig();
expect(config.security.allowedUserIds).toEqual([]);
});

it('should parse PORT as integer', async () => {
vi.stubEnv('FEISHU_APP_ID', 'id');
vi.stubEnv('FEISHU_APP_SECRET', 'secret');
vi.stubEnv('PORT', '8080');
const { config } = await loadConfig();
expect(config.server.port).toBe(8080);
});

it('should parse CLAUDE_TIMEOUT as integer', async () => {
vi.stubEnv('FEISHU_APP_ID', 'id');
vi.stubEnv('FEISHU_APP_SECRET', 'secret');
vi.stubEnv('CLAUDE_TIMEOUT', '600');
const { config } = await loadConfig();
expect(config.claude.timeoutSeconds).toBe(600);
Expand All @@ -84,16 +47,12 @@ describe('config', () => {

describe('defaults', () => {
it('should default eventMode to websocket', async () => {
vi.stubEnv('FEISHU_APP_ID', 'id');
vi.stubEnv('FEISHU_APP_SECRET', 'secret');
delete process.env.FEISHU_EVENT_MODE;
const { config } = await loadConfig();
expect(config.feishu.eventMode).toBe('websocket');
});

it('should default work dir to parent of process.cwd()', async () => {
vi.stubEnv('FEISHU_APP_ID', 'id');
vi.stubEnv('FEISHU_APP_SECRET', 'secret');
vi.stubEnv('DEFAULT_WORK_DIR', '');
const { config } = await loadConfig();
const { dirname } = await import('node:path');
Expand Down
80 changes: 79 additions & 1 deletion src/agent/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { logger } from '../utils/logger.js';
import { agentRegistry } from './registry.js';
import { AgentConfigFileSchema } from './config-schema.js';
import type { AgentDefaults, AgentConfigInput, ToolPolicyValue } from './config-schema.js';
import type { AgentConfig, ToolPolicy } from './types.js';
import type { AgentConfig, ToolPolicy, BotAccountConfig, AgentBinding } from './types.js';

// ─── 内置默认值(无配置文件时的 fallback) ───────────────────

Expand All @@ -37,6 +37,12 @@ let configFilePath: string | undefined;
let configFileDir: string | undefined;
/** 知识文件根目录(resolved absolute path) */
let knowledgeDirPath: string | undefined;
/** 配置文件中的显式 bindings */
let explicitBindings: AgentBinding[] = [];
/** 缓存的 bot 账号列表(agents 加载/重载时更新) */
let cachedBotAccounts: BotAccountConfig[] = [];
/** 缓存的推导 bindings(agents 加载/重载时更新) */
let cachedDerivedBindings: AgentBinding[] = [];
/** 文件监听是否活跃 */
let watcherActive = false;
/** 重载防抖定时器 */
Expand Down Expand Up @@ -81,6 +87,7 @@ function mergeAgentConfig(input: AgentConfigInput, defaults: AgentDefaults): Age
return {
id: input.id,
displayName: input.displayName ?? input.id,
description: input.description,
model: input.model ?? defaults.model ?? BUILTIN_DEFAULTS.model!,
toolPolicy,
readOnly,
Expand All @@ -95,6 +102,7 @@ function mergeAgentConfig(input: AgentConfigInput, defaults: AgentDefaults): Age
toolDeny,
bashAllowPatterns: input.bashAllowPatterns,
editablePathPatterns: input.editablePathPatterns ?? defaults.editablePathPatterns,
feishu: input.feishu,
};
}

Expand Down Expand Up @@ -180,12 +188,22 @@ export function reloadAgentConfig(): LoadResult {
knowledgeDirPath = undefined;
}

// 保存显式 bindings
explicitBindings = (configFile.bindings ?? []).map(b => ({
agentId: b.agentId,
match: b.match,
}));

// 合并每个 agent
const agents = configFile.agents.map(input => mergeAgentConfig(input, defaults));

// 应用到 registry
agentRegistry.replaceAll(agents);

// 刷新缓存(bot 账号 + 推导 bindings)
cachedBotAccounts = computeBotAccounts(agents);
cachedDerivedBindings = computeDerivedBindings(agents);

logger.info(
{ path: configFilePath, agentCount: agents.length, agentIds: agents.map(a => a.id) },
'Agent config loaded',
Expand Down Expand Up @@ -282,6 +300,66 @@ export function loadKnowledgeContent(agentId: string): string | undefined {
return parts.length > 0 ? parts.join('\n\n') : undefined;
}

// ─── 飞书凭证推导 ─────────────────────────────────────────

/** 内部:从 agents 列表计算 bot 账号(reload 时调用) */
function computeBotAccounts(agents: AgentConfig[]): BotAccountConfig[] {
const seen = new Map<string, BotAccountConfig>();
for (const agent of agents) {
if (!agent.feishu) continue;
const { appId, appSecret } = agent.feishu;
if (seen.has(appId)) continue;
seen.set(appId, {
accountId: agent.id,
appId,
appSecret,
botName: agent.displayName,
});
}
return [...seen.values()];
}

/** 内部:从 agents 列表推导 bindings(reload 时调用) */
function computeDerivedBindings(agents: AgentConfig[]): AgentBinding[] {
const appIdToAccountId = new Map<string, string>();
for (const agent of agents) {
if (!agent.feishu) continue;
if (!appIdToAccountId.has(agent.feishu.appId)) {
appIdToAccountId.set(agent.feishu.appId, agent.id);
}
}
if (appIdToAccountId.size <= 1) return [];
const bindings: AgentBinding[] = [];
for (const agent of agents) {
if (!agent.feishu) continue;
const accountId = appIdToAccountId.get(agent.feishu.appId);
if (!accountId) continue;
bindings.push({ agentId: agent.id, match: { accountId } });
}
return bindings;
}

/**
* 获取缓存的 bot 账号列表(agents 加载/重载时自动更新)。
* 按 appId 去重,返回空数组表示没有配置飞书凭证。
*/
export function deriveBotAccounts(): BotAccountConfig[] {
return cachedBotAccounts;
}

/**
* 获取缓存的推导 bindings(agents 加载/重载时自动更新)。
* 仅多 bot 模式(多个不同 appId)时非空。
*/
export function deriveBindings(): AgentBinding[] {
return cachedDerivedBindings;
}

/** 获取配置文件中用户显式配置的 bindings */
export function getExplicitBindings(): AgentBinding[] {
return explicitBindings;
}

// ─── 热重载 Watcher ────────────────────────────────────────

/**
Expand Down
32 changes: 32 additions & 0 deletions src/agent/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,41 @@ const ToolPolicyDetailedSchema = z.object({
/** toolPolicy 支持两种格式 */
export const ToolPolicySchema = z.union([ToolPolicySimpleSchema, ToolPolicyDetailedSchema]);

// ─── 飞书账号 ────────────────────────────────────────────────

/** 飞书应用凭证(内嵌在 agent 配置中) */
const FeishuAccountSchema = z.object({
appId: z.string().min(1),
appSecret: z.string().min(1),
});

// ─── Binding 路由 ────────────────────────────────────────────

/** Binding 匹配条件 */
const BindingMatchSchema = z.object({
accountId: z.string().optional(),
peer: z.object({
kind: z.enum(['group', 'direct']),
id: z.string(),
}).optional(),
userId: z.string().optional(),
});

/** Agent Binding — 消息路由规则 */
const AgentBindingSchema = z.object({
agentId: z.string(),
match: BindingMatchSchema,
});

// ─── Agent 配置输入(用户填写,除 id 外全部 optional) ─────

export const AgentConfigInputSchema = z.object({
/** Agent 标识(必填) */
id: z.string().min(1),
/** 显示名称 */
displayName: z.string().optional(),
/** Agent 角色描述(说明该 agent 的定位和特点,便于理解配置意图) */
description: z.string().optional(),
/** 模型名称 */
model: z.string().optional(),
/** 工具策略 */
Expand All @@ -53,6 +81,8 @@ export const AgentConfigInputSchema = z.object({
bashAllowPatterns: z.array(z.string()).optional(),
/** 即使 readOnly 也允许 Edit/Write 的路径 glob 列表(相对于 cwd,如 "config/personas/*") */
editablePathPatterns: z.array(z.string()).optional(),
/** 飞书应用凭证(appId + appSecret) */
feishu: FeishuAccountSchema.optional(),
});

// ─── Defaults(全部 optional) ──────────────────────────────
Expand All @@ -77,6 +107,8 @@ export const AgentConfigFileSchema = z.object({
knowledgeDir: z.string().optional(),
defaults: AgentDefaultsSchema.optional(),
agents: z.array(AgentConfigInputSchema).min(1),
/** 消息路由规则(可选,覆盖 feishu 字段自动推导的路由) */
bindings: z.array(AgentBindingSchema).optional(),
});

// ─── 导出类型 ──────────────────────────────────────────────
Expand Down
5 changes: 5 additions & 0 deletions src/agent/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ class AgentRegistry {
return [...this.agents.keys()];
}

/** 所有已注册的 agent 配置 */
list(): AgentConfig[] {
return [...this.agents.values()];
}

/** 默认 agent(兜底) */
get defaultAgentId(): AgentId {
return 'dev';
Expand Down
4 changes: 4 additions & 0 deletions src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export interface AgentConfig {
id: AgentId;
/** 显示名称(用于日志和审批卡片) */
displayName: string;
/** 角色描述(说明该 agent 的定位和特点) */
description?: string;
/** 默认模型 */
model: string;
/** 工具策略 */
Expand Down Expand Up @@ -65,6 +67,8 @@ export interface AgentConfig {
bashAllowPatterns?: string[];
/** 即使 readOnly 也允许 Edit/Write 的路径 glob 列表(相对于 cwd,如 "config/personas/*") */
editablePathPatterns?: string[];
/** 飞书应用凭证(从 agents.json 加载) */
feishu?: { appId: string; appSecret: string };
}

// ─── Binding 路由 ────────────────────────────────────────
Expand Down
Loading
Loading