From 5764032a1d2c930ef014417d0a4a242a02e3ea21 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 14:55:36 +0800 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20editablePathPatterns=20=E2=80=94?= =?UTF-8?q?=20readonly=20agent=20=E4=B9=9F=E8=83=BD=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 editablePathPatterns 配置项,允许 readonly agent 编辑指定路径的文件。 PM agent 默认可编辑 config/personas/* 和 config/knowledge/*, 用户可以直接让 chatbot 修改自己的人格和知识库配置。 Co-Authored-By: Claude Opus 4.6 (1M context) --- config/agents.example.json | 3 +- src/agent/__tests__/config-loader.test.ts | 74 ++++++++++++++ src/agent/config-loader.ts | 1 + src/agent/config-schema.ts | 3 + src/agent/types.ts | 2 + .../__tests__/editable-path-patterns.test.ts | 98 +++++++++++++++++++ src/claude/executor.ts | 44 ++++++++- src/feishu/event-handler.ts | 3 + 8 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 src/claude/__tests__/editable-path-patterns.test.ts diff --git a/config/agents.example.json b/config/agents.example.json index f62103c9..18e19579 100644 --- a/config/agents.example.json +++ b/config/agents.example.json @@ -7,7 +7,8 @@ "maxBudgetUsd": 5, "maxTurns": 100, "requiresApproval": false, - "settingSources": ["user", "project"] + "settingSources": ["user", "project"], + "editablePathPatterns": ["config/personas/*", "config/knowledge/*"] }, "agents": [ { diff --git a/src/agent/__tests__/config-loader.test.ts b/src/agent/__tests__/config-loader.test.ts index 4f90d674..01e8b00a 100644 --- a/src/agent/__tests__/config-loader.test.ts +++ b/src/agent/__tests__/config-loader.test.ts @@ -266,3 +266,77 @@ describe('getAgentConfigInfo', () => { expect(info).toBeUndefined(); }); }); + +describe('editablePathPatterns', () => { + beforeEach(() => { + vi.resetModules(); + mkdirSync(KNOWLEDGE_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + async function setup(configObj: Record) { + writeFileSync(CONFIG_FILE, JSON.stringify(configObj)); + vi.stubEnv('AGENT_CONFIG_PATH', CONFIG_FILE); + vi.doMock('../../config.js', () => ({ + config: { + agent: { configPath: CONFIG_FILE }, + claude: { model: 'claude-sonnet-4-6', maxBudgetUsd: 5, maxTurns: 100 }, + }, + })); + const loader = await import('../config-loader.js'); + const result = loader.loadAgentConfig(); + expect(result.loaded).toBe(true); + return loader; + } + + it('should pass agent-level editablePathPatterns through to registry', async () => { + await setup({ + agents: [ + { id: 'pm', editablePathPatterns: ['config/personas/*', 'config/knowledge/*'] }, + ], + }); + + const { agentRegistry } = await import('../registry.js'); + const cfg = agentRegistry.get('pm'); + expect(cfg).toBeDefined(); + expect(cfg!.editablePathPatterns).toEqual(['config/personas/*', 'config/knowledge/*']); + }); + + it('should inherit editablePathPatterns from defaults', async () => { + await setup({ + defaults: { editablePathPatterns: ['config/**'] }, + agents: [{ id: 'dev' }], + }); + + const { agentRegistry } = await import('../registry.js'); + const cfg = agentRegistry.get('dev'); + expect(cfg).toBeDefined(); + expect(cfg!.editablePathPatterns).toEqual(['config/**']); + }); + + it('should let agent-level editablePathPatterns override defaults', async () => { + await setup({ + defaults: { editablePathPatterns: ['config/**'] }, + agents: [ + { id: 'pm', editablePathPatterns: ['config/personas/*'] }, + ], + }); + + const { agentRegistry } = await import('../registry.js'); + const cfg = agentRegistry.get('pm'); + expect(cfg!.editablePathPatterns).toEqual(['config/personas/*']); + }); + + it('should be undefined when not configured', async () => { + await setup({ + agents: [{ id: 'dev' }], + }); + + const { agentRegistry } = await import('../registry.js'); + const cfg = agentRegistry.get('dev'); + expect(cfg!.editablePathPatterns).toBeUndefined(); + }); +}); diff --git a/src/agent/config-loader.ts b/src/agent/config-loader.ts index 4b713542..6782f08e 100644 --- a/src/agent/config-loader.ts +++ b/src/agent/config-loader.ts @@ -94,6 +94,7 @@ function mergeAgentConfig(input: AgentConfigInput, defaults: AgentDefaults): Age toolAllow, toolDeny, bashAllowPatterns: input.bashAllowPatterns, + editablePathPatterns: input.editablePathPatterns ?? defaults.editablePathPatterns, }; } diff --git a/src/agent/config-schema.ts b/src/agent/config-schema.ts index 84bcd6ed..5f1495e8 100644 --- a/src/agent/config-schema.ts +++ b/src/agent/config-schema.ts @@ -51,6 +51,8 @@ export const AgentConfigInputSchema = z.object({ knowledge: z.array(z.string()).optional(), /** Bash 命令白名单正则(readOnly + toolAllow 含 Bash 时生效,仅匹配的命令被放行) */ bashAllowPatterns: z.array(z.string()).optional(), + /** 即使 readOnly 也允许 Edit/Write 的路径 glob 列表(相对于 cwd,如 "config/personas/*") */ + editablePathPatterns: z.array(z.string()).optional(), }); // ─── Defaults(全部 optional) ────────────────────────────── @@ -65,6 +67,7 @@ export const AgentDefaultsSchema = z.object({ replyMode: z.enum(['direct', 'thread']).optional(), persona: z.string().optional(), knowledge: z.array(z.string()).optional(), + editablePathPatterns: z.array(z.string()).optional(), }); // ─── 顶层配置文件 ────────────────────────────────────────── diff --git a/src/agent/types.ts b/src/agent/types.ts index eac4a641..1f11bf57 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -63,6 +63,8 @@ export interface AgentConfig { toolDeny?: string[]; /** Bash 命令白名单正则(readOnly + toolAllow 含 Bash 时生效,仅匹配的命令被放行) */ bashAllowPatterns?: string[]; + /** 即使 readOnly 也允许 Edit/Write 的路径 glob 列表(相对于 cwd,如 "config/personas/*") */ + editablePathPatterns?: string[]; } // ─── Binding 路由 ──────────────────────────────────────── diff --git a/src/claude/__tests__/editable-path-patterns.test.ts b/src/claude/__tests__/editable-path-patterns.test.ts new file mode 100644 index 00000000..044b462b --- /dev/null +++ b/src/claude/__tests__/editable-path-patterns.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { matchesEditablePattern } from '../executor.js'; + +describe('matchesEditablePattern', () => { + const cwd = '/root/dev/anywhere-code'; + + describe('single-level wildcard (config/personas/*)', () => { + const patterns = ['config/personas/*']; + + it('should match files directly in the pattern directory', () => { + expect(matchesEditablePattern('config/personas/pm.md', patterns, cwd)).toBe(true); + expect(matchesEditablePattern('config/personas/dev.md', patterns, cwd)).toBe(true); + }); + + it('should match absolute paths', () => { + expect(matchesEditablePattern('/root/dev/anywhere-code/config/personas/pm.md', patterns, cwd)).toBe(true); + }); + + it('should NOT match files in subdirectories', () => { + expect(matchesEditablePattern('config/personas/sub/file.md', patterns, cwd)).toBe(false); + }); + + it('should NOT match files in parent directory', () => { + expect(matchesEditablePattern('config/pm.md', patterns, cwd)).toBe(false); + }); + + it('should NOT match unrelated paths', () => { + expect(matchesEditablePattern('src/config/personas/pm.md', patterns, cwd)).toBe(false); + expect(matchesEditablePattern('config/knowledge/team.md', patterns, cwd)).toBe(false); + }); + }); + + describe('recursive wildcard (config/**)', () => { + const patterns = ['config/**']; + + it('should match files at any depth', () => { + expect(matchesEditablePattern('config/agents.json', patterns, cwd)).toBe(true); + expect(matchesEditablePattern('config/personas/pm.md', patterns, cwd)).toBe(true); + expect(matchesEditablePattern('config/knowledge/sub/deep.md', patterns, cwd)).toBe(true); + }); + + it('should NOT match files outside the pattern directory', () => { + expect(matchesEditablePattern('src/config.ts', patterns, cwd)).toBe(false); + }); + }); + + describe('exact match (config/agents.json)', () => { + const patterns = ['config/agents.json']; + + it('should match the exact file', () => { + expect(matchesEditablePattern('config/agents.json', patterns, cwd)).toBe(true); + }); + + it('should match with absolute path', () => { + expect(matchesEditablePattern('/root/dev/anywhere-code/config/agents.json', patterns, cwd)).toBe(true); + }); + + it('should NOT match other files', () => { + expect(matchesEditablePattern('config/agents.example.json', patterns, cwd)).toBe(false); + }); + }); + + describe('multiple patterns', () => { + const patterns = ['config/personas/*', 'config/knowledge/*']; + + it('should match files in any pattern', () => { + expect(matchesEditablePattern('config/personas/pm.md', patterns, cwd)).toBe(true); + expect(matchesEditablePattern('config/knowledge/team.md', patterns, cwd)).toBe(true); + }); + + it('should NOT match files outside all patterns', () => { + expect(matchesEditablePattern('config/agents.json', patterns, cwd)).toBe(false); + expect(matchesEditablePattern('src/index.ts', patterns, cwd)).toBe(false); + }); + }); + + describe('absolute patterns', () => { + const patterns = ['/etc/config/*']; + + it('should match absolute pattern directly', () => { + expect(matchesEditablePattern('/etc/config/file.conf', patterns, cwd)).toBe(true); + }); + + it('should NOT match relative path that would resolve differently', () => { + expect(matchesEditablePattern('etc/config/file.conf', patterns, cwd)).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should return false for empty patterns', () => { + expect(matchesEditablePattern('config/personas/pm.md', [], cwd)).toBe(false); + }); + + it('should return false for empty file path', () => { + expect(matchesEditablePattern('', ['config/*'], cwd)).toBe(false); + }); + }); +}); diff --git a/src/claude/executor.ts b/src/claude/executor.ts index 645b1595..d7aed170 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -1,7 +1,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk'; import type { Query, SDKMessage } from '@anthropic-ai/claude-agent-sdk'; import { mkdirSync, existsSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { execFile as execFileCb } from 'node:child_process'; import { createHash } from 'node:crypto'; import { config } from '../config.js'; @@ -142,6 +142,38 @@ function checkSourceRepoProtection( return null; } +/** + * 检查文件路径是否匹配 editablePathPatterns 中的某个模式。 + * 模式相对于 cwd 解析为绝对路径,支持末尾 `*` 通配符: + * "config/personas/*" → 匹配 /cwd/config/personas/ 下的所有文件 + * "config/agents.json" → 精确匹配 + */ +export function matchesEditablePattern(filePath: string, patterns: string[], cwd: string): boolean { + const absFile = filePath.startsWith('/') ? filePath : resolve(cwd, filePath); + for (const pattern of patterns) { + const absPattern = pattern.startsWith('/') ? pattern : resolve(cwd, pattern); + if (absPattern.endsWith('/*')) { + // 目录通配:匹配目录前缀 + const dir = absPattern.slice(0, -2); // remove /* + if (absFile.startsWith(dir + '/') && !absFile.slice(dir.length + 1).includes('/')) { + return true; + } + } else if (absPattern.endsWith('/**')) { + // 递归通配:匹配目录及所有子目录 + const dir = absPattern.slice(0, -3); + if (absFile.startsWith(dir + '/')) { + return true; + } + } else { + // 精确匹配 + if (absFile === absPattern) { + return true; + } + } + } + return false; +} + /** 工具名匹配(支持 glob 前缀:'mcp__*' 匹配所有 MCP 工具) */ function matchToolPattern(toolName: string, pattern: string): boolean { if (pattern.endsWith('*')) { @@ -183,6 +215,8 @@ export interface ExecuteInput extends ExecuteOptions { toolDeny?: string[]; /** Bash 命令白名单正则(readOnly + toolAllow 含 Bash 时生效,仅匹配的命令被放行) */ bashAllowPatterns?: string[]; + /** 即使 readOnly 也允许 Edit/Write 的路径 glob 列表(相对于 cwd) */ + editablePathPatterns?: string[]; /** 知识文件内容(注入到 system prompt 最前层,优先于 persona/workspace prompt) */ knowledgeContent?: string; /** 上次保存的 system prompt hash(用于自动失效检测,hash 不匹配时跳过 resume) */ @@ -805,6 +839,14 @@ export class ClaudeExecutor { return { behavior: 'allow' as const, updatedInput: inputObj }; } } + // readOnly 模式下,editablePathPatterns 匹配的路径允许 Edit/Write + if (readOnly && (toolName === 'Edit' || toolName === 'Write') && input.editablePathPatterns?.length) { + const filePath = String(inputObj.file_path || ''); + if (filePath && matchesEditablePattern(filePath, input.editablePathPatterns, workingDir ?? process.cwd())) { + logger.info({ toolName, filePath: filePath.slice(0, 200) }, 'canUseTool allowed — readOnly but path matches editablePathPatterns'); + return { behavior: 'allow' as const, updatedInput: inputObj }; + } + } // 只读模式:拦截写入类工具 if (readOnly && WRITE_TOOLS.has(toolName)) { logger.info({ toolName, readOnly }, 'canUseTool denied — read-only mode'); diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index bf8b56e3..56800b04 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -2060,6 +2060,7 @@ export async function executeClaudeTask( toolAllow: agentCfg?.toolAllow, toolDeny: agentCfg?.toolDeny, bashAllowPatterns: agentCfg?.bashAllowPatterns, + editablePathPatterns: agentCfg?.editablePathPatterns, resumeSessionId: canResume ? activeConversationId : undefined, storedSystemPromptHash: activePromptHash, botIdentityContext, @@ -2154,6 +2155,7 @@ export async function executeClaudeTask( settingSources: agentCfg?.settingSources, toolAllow: agentCfg?.toolAllow, toolDeny: agentCfg?.toolDeny, + editablePathPatterns: agentCfg?.editablePathPatterns, onProgress, onTurn, historySummaries, @@ -2542,6 +2544,7 @@ export async function executeDirectTask( maxBudgetUsd: agentCfg.maxBudgetUsd, toolAllow: agentCfg.toolAllow, toolDeny: agentCfg.toolDeny, + editablePathPatterns: agentCfg.editablePathPatterns, settingSources: agentCfg.settingSources, knowledgeContent: loadKnowledgeContent(agentId), memoryContext, From ff4bf89ef0c3ab2613a644f82a0cb79783d14ca8 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 14:55:47 +0800 Subject: [PATCH 02/15] =?UTF-8?q?refactor:=20=E7=B2=BE=E7=AE=80=20setup.sh?= =?UTF-8?q?=EF=BC=8C=E9=85=8D=E7=BD=AE=E4=BA=A4=E7=BB=99=20CLI=20onboardin?= =?UTF-8?q?g=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup.sh 从 428 行精简到 ~160 行,仅保留: - 系统依赖检查 (Node.js, python3/make/gcc) - npm install + native 模块验证 - ANTHROPIC_API_KEY 配置 - 编译验证 飞书配置、团队信息、Bot 人格等交由 npm run onboard 完成。 Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/setup.sh | 352 ++++++++++------------------------------------- 1 file changed, 69 insertions(+), 283 deletions(-) diff --git a/scripts/setup.sh b/scripts/setup.sh index 531c8bba..b8328464 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash # ============================================================ -# Anycode 开发环境初始化脚本 +# Anycode 开发环境初始化脚本 (精简版) +# +# 只做最小必要配置: 环境检查 + 依赖安装 + API Key + 编译 +# 其余配置 (飞书、人格、团队信息等) 由 CLI onboarding agent 完成 +# # 用法: bash scripts/setup.sh # ============================================================ @@ -15,71 +19,25 @@ CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' # No Color -info() { echo -e "${BLUE}ℹ${NC} $*"; } -ok() { echo -e "${GREEN}✅${NC} $*"; } -warn() { echo -e "${YELLOW}⚠️${NC} $*"; } -err() { echo -e "${RED}❌${NC} $*"; } -header(){ echo -e "\n${BOLD}${CYAN}── $* ──${NC}\n"; } +info() { echo -e "${BLUE}i${NC} $*"; } +ok() { echo -e "${GREEN}OK${NC} $*"; } +warn() { echo -e "${YELLOW}!!${NC} $*"; } +err() { echo -e "${RED}ERR${NC} $*"; } +header(){ echo -e "\n${BOLD}${CYAN}-- $* --${NC}\n"; } # 项目根目录 (脚本所在的上一级) ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" -# 自动将 .example.md 复制为正式文件(仅当正式文件不存在时) -init_config_files() { - for src in "$ROOT"/config/personas/*.example.md "$ROOT"/config/knowledge/*.example.md; do - [[ -f "$src" ]] || continue - dst="${src/.example.md/.md}" - if [[ ! -f "$dst" ]]; then - cp "$src" "$dst" - ok "已创建 $(basename "$dst") ← 可编辑自定义" - fi - done -} - -# ============================================================ -# Step 1: 环境检测 -# ============================================================ -header "Step 1/6: 环境检测" - -MISSING=0 - -check_file() { - local label="$1" path="$2" - if [[ -e "$path" ]]; then - ok "$label" - return 0 - else - err "$label — 缺失" - return 1 - fi -} - -check_file "node_modules/" "$ROOT/node_modules" || MISSING=$((MISSING+1)) -check_file ".env" "$ROOT/.env" || MISSING=$((MISSING+1)) -check_file "config/agents.json" "$ROOT/config/agents.json" || MISSING=$((MISSING+1)) -echo -e "${BLUE}ℹ${NC} data/ — 首次启动时自动创建" - -if [[ $MISSING -eq 0 ]]; then - echo "" - ok "所有配置已就绪!" - read -rp "$(echo -e "${YELLOW}要重新配置吗?(y/N): ${NC}")" REDO - if [[ "${REDO,,}" != "y" ]]; then - info "退出,未做任何修改。" - exit 0 - fi -fi - # ============================================================ -# Step 2: 系统依赖检查 +# Step 1: 系统依赖检查 # ============================================================ -header "Step 2/6: 系统依赖检查" +header "Step 1/4: 系统依赖检查" # Node.js if command -v node &>/dev/null; then NODE_VER="$(node -v)" ok "Node.js $NODE_VER" - # 检查版本 >= 18 MAJOR="${NODE_VER#v}" MAJOR="${MAJOR%%.*}" if [[ "$MAJOR" -lt 18 ]]; then @@ -87,7 +45,7 @@ if command -v node &>/dev/null; then exit 1 fi else - err "未找到 Node.js(需要 >= 18)" + err "未找到 Node.js (需要 >= 18)" info "安装: https://nodejs.org/ 或使用 nvm" exit 1 fi @@ -98,16 +56,15 @@ for tool in python3 make gcc; do if command -v "$tool" &>/dev/null; then ok "$tool" else - warn "$tool 未找到 — native 模块 (better-sqlite3, sqlite-vec) 可能编译失败" + warn "$tool 未找到 -- native 模块 (better-sqlite3, sqlite-vec) 可能编译失败" BUILD_TOOLS_OK=false fi done if [[ "$BUILD_TOOLS_OK" == false ]]; then echo "" - warn "缺少构建工具(native 模块编译需要)" + warn "缺少构建工具 (native 模块编译需要)" - # 检测包管理器并构造安装命令 INSTALL_CMD="" if [[ -f /etc/debian_version ]]; then INSTALL_CMD="apt-get install -y python3 make gcc g++" @@ -118,7 +75,6 @@ if [[ "$BUILD_TOOLS_OK" == false ]]; then fi if [[ -n "$INSTALL_CMD" ]]; then - # root 用户不需要 sudo if [[ "$(id -u)" -eq 0 ]]; then SUDO="" else @@ -130,18 +86,17 @@ if [[ "$BUILD_TOOLS_OK" == false ]]; then ${SUDO}$INSTALL_CMD ok "构建工具安装完成" else - warn "跳过构建工具安装 — npm install 阶段 native 模块可能编译失败" + warn "跳过构建工具安装 -- npm install 阶段 native 模块可能编译失败" fi else warn "无法识别包管理器,请手动安装 python3 make gcc" - warn "继续执行,但 native 模块可能编译失败" fi fi # ============================================================ -# Step 3: 安装 npm 依赖 +# Step 2: 安装依赖 # ============================================================ -header "Step 3/6: 安装依赖" +header "Step 2/4: 安装依赖" if [[ -d "$ROOT/node_modules" ]]; then ok "node_modules/ 已存在,跳过 npm install" @@ -163,248 +118,68 @@ fi if node -e "require('sqlite-vec')" 2>/dev/null; then ok "sqlite-vec 编译正常" else - warn "sqlite-vec 编译失败 — 记忆系统将降级为 BM25 文本搜索(不影响核心功能)" + warn "sqlite-vec 编译失败 -- 记忆系统将降级为 BM25 文本搜索 (不影响核心功能)" fi # ============================================================ -# Step 4: 配置 .env +# Step 3: 最小 .env 配置 # ============================================================ -header "Step 4/6: 配置 .env" +header "Step 3/4: 最小配置" -# 转义 sed 替换字符串中的特殊字符 (|, &, \) +# 转义 sed 替换字符串中的特殊字符 sed_escape() { printf '%s\n' "$1" | sed 's/[&|\\]/\\&/g'; } -configure_env() { - # 用 .env.example 作为基底 +if [[ -f "$ROOT/.env" ]]; then + ok ".env 已存在" + # 检查必填项 + source "$ROOT/.env" 2>/dev/null || true + MISSING_KEYS=0 + if [[ -z "${ANTHROPIC_API_KEY:-}" || "$ANTHROPIC_API_KEY" == "sk-ant-xxxxxxxx" ]]; then + MISSING_KEYS=1 + fi + if [[ $MISSING_KEYS -eq 1 ]]; then + info "检测到 ANTHROPIC_API_KEY 未配置" + info "Anthropic API Key -- 在 console.anthropic.com/settings/keys 获取" + read -rp " ANTHROPIC_API_KEY: " NEW_KEY + if [[ -n "$NEW_KEY" ]]; then + sed -i "s|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=$(sed_escape "$NEW_KEY")|" "$ROOT/.env" + ok "已更新 ANTHROPIC_API_KEY" + fi + else + ok "ANTHROPIC_API_KEY 已配置" + fi +else + # 从 .env.example 创建 if [[ ! -f "$ROOT/.env.example" ]]; then err ".env.example 不存在,无法生成 .env" - return 1 + exit 1 fi - cp "$ROOT/.env.example" "$ROOT/.env" - echo -e "${BOLD}必填配置${NC}(回车跳过,稍后手动编辑 .env)\n" + echo -e "${BOLD}Anycode 需要一个 Anthropic API Key 才能运行。${NC}" + echo -e "其余配置 (飞书、团队信息、Bot 人格等) 稍后由 AI 助手引导完成。\n" - # FEISHU_APP_ID - info "飞书 App ID — 在 open.feishu.cn 创建应用后获取" - read -rp " FEISHU_APP_ID: " FEISHU_APP_ID - if [[ -n "$FEISHU_APP_ID" ]]; then - sed -i "s|^FEISHU_APP_ID=.*|FEISHU_APP_ID=$(sed_escape "$FEISHU_APP_ID")|" "$ROOT/.env" - fi - - # FEISHU_APP_SECRET - info "飞书 App Secret — 与 App ID 同一页面获取" - read -rp " FEISHU_APP_SECRET: " FEISHU_APP_SECRET - if [[ -n "$FEISHU_APP_SECRET" ]]; then - sed -i "s|^FEISHU_APP_SECRET=.*|FEISHU_APP_SECRET=$(sed_escape "$FEISHU_APP_SECRET")|" "$ROOT/.env" - fi - - # 飞书权限提示 - echo "" - echo -e "${BOLD}${YELLOW}📋 飞书应用权限配置${NC}" - echo "" - echo " 请在飞书开放平台 (open.feishu.cn) 为应用开通以下权限:" - echo "" - echo -e " ${BOLD}必须开通:${NC}" - echo " • im:message — 发送和接收消息" - echo " • im:message:send_as_bot — 以 Bot 身份发消息" - echo " • im:chat:readonly — 读取群信息" - echo " • contact:contact.base:readonly — 读取用户基本信息" - echo "" - echo -e " ${BOLD}推荐开通:${NC}" - echo " • im:resource — 读取图片/文件资源" - echo " • im:chat — 群管理(Bot 入群等)" - echo "" - echo -e " ${BOLD}事件订阅:${NC}" - echo " • im.message.receive_v1 — 接收消息事件" - echo " • card.action.trigger — 卡片按钮交互回调(AskUser 等)" - echo " • p2p_chat_create — 用户首次私聊 Bot" - echo " • im.chat.member.bot.added_v1 — Bot 被拉入群" - echo "" - echo -e " 路径: 开发者后台 → 权限管理 → 搜索上述权限名 → 开通" - echo -e " 事件: 开发者后台 → 事件与回调 → 添加事件" - echo "" - read -rp "$(echo -e "${YELLOW}确认已配置好权限后回车继续...${NC}")" _DUMMY - - # ANTHROPIC_API_KEY - info "Anthropic API Key — 在 console.anthropic.com/settings/keys 获取" + # ANTHROPIC_API_KEY (必填) + info "Anthropic API Key -- 在 console.anthropic.com/settings/keys 获取" read -rp " ANTHROPIC_API_KEY: " ANTHROPIC_API_KEY if [[ -n "$ANTHROPIC_API_KEY" ]]; then sed -i "s|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=$(sed_escape "$ANTHROPIC_API_KEY")|" "$ROOT/.env" fi - # ANTHROPIC_BASE_URL - info "Anthropic API Base URL — 使用代理或第三方兼容端点时填写,直连官方 API 回车跳过" + # ANTHROPIC_BASE_URL (可选) + info "API Base URL -- 使用代理或第三方兼容端点时填写,直连官方回车跳过" read -rp " ANTHROPIC_BASE_URL (回车=官方地址): " ANTHROPIC_BASE_URL if [[ -n "$ANTHROPIC_BASE_URL" ]]; then - sed -i "s|^# ANTHROPIC_BASE_URL=.*|ANTHROPIC_BASE_URL=$(sed_escape "$ANTHROPIC_BASE_URL")|" "$ROOT/.env" sed -i "s|^# *ANTHROPIC_BASE_URL=.*|ANTHROPIC_BASE_URL=$(sed_escape "$ANTHROPIC_BASE_URL")|" "$ROOT/.env" fi - # 可选功能 - echo "" - echo -e "${BOLD}可选功能${NC}(输入 y 启用,回车跳过)\n" - - # 记忆系统 - read -rp "$(echo -e " 启用${CYAN}记忆系统${NC}?需要 DashScope API Key (y/N): ")" ENABLE_MEMORY - if [[ "${ENABLE_MEMORY,,}" == "y" ]]; then - sed -i "s|^# MEMORY_ENABLED=.*|MEMORY_ENABLED=true|" "$ROOT/.env" - sed -i "s|^# *MEMORY_ENABLED=.*|MEMORY_ENABLED=true|" "$ROOT/.env" - info "DashScope API Key — 在 dashscope.console.aliyun.com 获取" - read -rp " DASHSCOPE_API_KEY: " DASHSCOPE_API_KEY - if [[ -n "$DASHSCOPE_API_KEY" ]]; then - sed -i "s|^# DASHSCOPE_API_KEY=.*|DASHSCOPE_API_KEY=$(sed_escape "$DASHSCOPE_API_KEY")|" "$ROOT/.env" - sed -i "s|^# *DASHSCOPE_API_KEY=.*|DASHSCOPE_API_KEY=$(sed_escape "$DASHSCOPE_API_KEY")|" "$ROOT/.env" - fi - fi - - # 定时任务 - read -rp "$(echo -e " 启用${CYAN}定时任务${NC}?(y/N): ")" ENABLE_CRON - if [[ "${ENABLE_CRON,,}" == "y" ]]; then - sed -i "s|^# CRON_ENABLED=.*|CRON_ENABLED=true|" "$ROOT/.env" - sed -i "s|^# *CRON_ENABLED=.*|CRON_ENABLED=true|" "$ROOT/.env" - fi - - # 飞书工具 - read -rp "$(echo -e " 启用${CYAN}飞书文档工具${NC}?需要飞书应用有文档权限 (y/N): ")" ENABLE_TOOLS - if [[ "${ENABLE_TOOLS,,}" == "y" ]]; then - sed -i "s|^FEISHU_TOOLS_ENABLED=.*|FEISHU_TOOLS_ENABLED=true|" "$ROOT/.env" - fi - - # 快速确认 - read -rp "$(echo -e " 启用${CYAN}快速确认${NC}?Direct 模式下先发一条自然短回复掩盖延迟,需要 DashScope API Key (y/N): ")" ENABLE_ACK - if [[ "${ENABLE_ACK,,}" == "y" ]]; then - sed -i "s|^# QUICK_ACK_ENABLED=.*|QUICK_ACK_ENABLED=true|" "$ROOT/.env" - sed -i "s|^# *QUICK_ACK_ENABLED=.*|QUICK_ACK_ENABLED=true|" "$ROOT/.env" - # 如果前面还没配 DASHSCOPE_API_KEY,提示一下 - if [[ "${ENABLE_MEMORY,,}" != "y" ]]; then - info "快速确认依赖 DASHSCOPE_API_KEY,请确保已在记忆系统步骤中配置" - fi - fi - - # 访问控制 - read -rp "$(echo -e " 配置${CYAN}用户访问控制${NC}?限制哪些用户可以使用 Bot (y/N): ")" ENABLE_ACL - if [[ "${ENABLE_ACL,,}" == "y" ]]; then - read -rp " ALLOWED_USER_IDS (逗号分隔,回车=允许所有): " ALLOWED_IDS - if [[ -n "$ALLOWED_IDS" ]]; then - sed -i "s|^ALLOWED_USER_IDS=.*|ALLOWED_USER_IDS=$(sed_escape "$ALLOWED_IDS")|" "$ROOT/.env" - fi - fi - - # OWNER 提示 - echo "" - info "管理员 (OWNER_USER_ID) 无需手动配置" - info "首次向 Bot 发消息的用户将自动成为管理员,并回写到 .env" - - ok ".env 已生成" -} - -if [[ -f "$ROOT/.env" ]]; then - ok ".env 已存在" - echo "" - echo " 1) 跳过 — 保留现有配置" - echo " 2) 重新配置 — 备份后重新生成" - echo " 3) 补充缺失项 — 只填充空值" - echo "" - read -rp "$(echo -e "${YELLOW}选择 (1/2/3) [1]: ${NC}")" ENV_CHOICE - case "${ENV_CHOICE:-1}" in - 2) - cp "$ROOT/.env" "$ROOT/.env.bak" - ok "已备份为 .env.bak" - configure_env - ;; - 3) - info "检查缺失的必填项..." - source "$ROOT/.env" 2>/dev/null || true - PATCHED=0 - if [[ -z "${FEISHU_APP_ID:-}" || "$FEISHU_APP_ID" == "cli_xxxxxxxxxx" ]]; then - info "飞书 App ID — 在 open.feishu.cn 创建应用后获取" - read -rp " FEISHU_APP_ID: " NEW_VAL - if [[ -n "$NEW_VAL" ]]; then - sed -i "s|^FEISHU_APP_ID=.*|FEISHU_APP_ID=$(sed_escape "$NEW_VAL")|" "$ROOT/.env" - PATCHED=$((PATCHED+1)) - fi - fi - if [[ -z "${FEISHU_APP_SECRET:-}" || "$FEISHU_APP_SECRET" == "xxxxxxxxxxxxxxxxxxxxxxxx" ]]; then - info "飞书 App Secret" - read -rp " FEISHU_APP_SECRET: " NEW_VAL - if [[ -n "$NEW_VAL" ]]; then - sed -i "s|^FEISHU_APP_SECRET=.*|FEISHU_APP_SECRET=$(sed_escape "$NEW_VAL")|" "$ROOT/.env" - PATCHED=$((PATCHED+1)) - fi - fi - if [[ -z "${ANTHROPIC_API_KEY:-}" || "$ANTHROPIC_API_KEY" == "sk-ant-xxxxxxxx" ]]; then - info "Anthropic API Key — 在 console.anthropic.com/settings/keys 获取" - read -rp " ANTHROPIC_API_KEY: " NEW_VAL - if [[ -n "$NEW_VAL" ]]; then - sed -i "s|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=$(sed_escape "$NEW_VAL")|" "$ROOT/.env" - PATCHED=$((PATCHED+1)) - fi - fi - if [[ $PATCHED -gt 0 ]]; then - ok "补充了 $PATCHED 个配置项" - else - ok "所有必填项已配置" - fi - ;; - *) - ok "跳过 .env 配置" - ;; - esac -else - configure_env + ok ".env 已生成 (最小配置)" fi # ============================================================ -# Step 5: Agent 配置 +# Step 4: 编译验证 # ============================================================ -header "Step 5/6: Agent 配置" - -if [[ -f "$ROOT/config/agents.json" ]]; then - ok "config/agents.json 已存在" - echo "" - echo " 1) 跳过 — 保留现有配置" - echo " 2) 重新生成 — 从示例文件复制(备份现有)" - echo "" - read -rp "$(echo -e "${YELLOW}选择 (1/2) [1]: ${NC}")" AGENT_CHOICE - case "${AGENT_CHOICE:-1}" in - 2) - cp "$ROOT/config/agents.json" "$ROOT/config/agents.json.bak" - ok "已备份为 agents.json.bak" - cp "$ROOT/config/agents.example.json" "$ROOT/config/agents.json" - ok "已从示例文件重新生成 config/agents.json" - init_config_files - ;; - *) - ok "跳过 Agent 配置" - ;; - esac -else - echo " 1) 使用示例配置(推荐)— PM + Dev 双 agent" - echo " 2) 跳过 — 使用内置 dev agent(无需配置文件)" - echo "" - read -rp "$(echo -e "${YELLOW}选择 (1/2) [1]: ${NC}")" AGENT_CHOICE - case "${AGENT_CHOICE:-1}" in - 2) - info "将使用内置 dev agent,后续可运行: cp config/agents.example.json config/agents.json" - ;; - *) - cp "$ROOT/config/agents.example.json" "$ROOT/config/agents.json" - ok "已创建 config/agents.json(PM + Dev 双 agent)" - init_config_files - echo "" - info "可编辑以下文件自定义 agent 人设和知识库(支持热加载,改完即生效):" - info " config/personas/pm.md — PM agent 人设" - info " config/knowledge/team.md — 团队信息" - info "也可以直接在飞书群里让 bot 帮你修改这些文件" - ;; - esac -fi - -# ============================================================ -# Step 6: 编译验证 -# ============================================================ -header "Step 6/6: 编译验证" +header "Step 4/4: 编译验证" info "运行 npm run build ..." if npm run build 2>&1; then @@ -418,10 +193,21 @@ fi # 完成 # ============================================================ echo "" -echo -e "${BOLD}${GREEN}🎉 Anycode 环境初始化完成!${NC}" +echo -e "${BOLD}${GREEN}Anycode 基础环境初始化完成!${NC}" echo "" -echo -e "${BOLD}下一步:${NC}" -echo " 1. npm run dev # 启动开发服务器" -echo " 2. 在飞书中 @Bot 发消息测试" -echo " 3. 自定义 config/ 下的 persona 和 knowledge 文件" +echo -e "${BOLD}下一步:${NC}" +echo " 1. npm run onboard # AI 助手引导完成剩余配置 (飞书、团队、Bot 人格)" +echo " 2. npm run dev # 启动开发服务器" echo "" +echo -e " ${CYAN}也可以跳过 onboard 直接手动配置:${NC}" +echo " - 编辑 .env 填写飞书 App ID/Secret" +echo " - cp config/agents.example.json config/agents.json" +echo " - 编辑 config/personas/ 和 config/knowledge/ 下的文件" +echo "" + +# 提示运行 onboard +read -rp "$(echo -e "${YELLOW}现在运行 AI 配置助手?(Y/n): ${NC}")" RUN_ONBOARD +if [[ "${RUN_ONBOARD,,}" != "n" ]]; then + echo "" + exec npm run onboard +fi From f8b21ea73aa979bd14c7575dcba95506e676946b Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 14:55:59 +0800 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20CLI=20onboarding=20agent=20?= =?UTF-8?q?=E2=80=94=20=E5=AF=B9=E8=AF=9D=E5=BC=8F=E9=A6=96=E6=AC=A1?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 npm run onboard 命令,通过 Agent SDK 在终端启动多轮对话, 引导用户完成飞书应用配置、团队信息、Bot 人格和可选功能设定。 - scripts/onboard.ts: CLI 多轮对话入口 - src/onboarding/bootstrap.ts: prompt 生成 + 完成状态检测 - .env.example: 添加 ONBOARDING_COMPLETED 文档 Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 4 + package.json | 1 + scripts/onboard.ts | 145 +++++++++++++++++++ src/onboarding/__tests__/bootstrap.test.ts | 144 +++++++++++++++++++ src/onboarding/bootstrap.ts | 160 +++++++++++++++++++++ 5 files changed, 454 insertions(+) create mode 100644 scripts/onboard.ts create mode 100644 src/onboarding/__tests__/bootstrap.test.ts create mode 100644 src/onboarding/bootstrap.ts diff --git a/.env.example b/.env.example index b1aa3dfc..cff98ca6 100644 --- a/.env.example +++ b/.env.example @@ -102,6 +102,10 @@ FEISHU_TOOLS_CALENDAR=true # 注入记忆最大 token 数 (默认 4000,约 20-30 条记忆) # MEMORY_MAX_INJECT_TOKENS=4000 +# === Onboarding === +# 首次配置完成标记 (npm run onboard 完成后自动写入,无需手动配置) +# ONBOARDING_COMPLETED=true + # === Agent 配置 === # Agent 配置文件路径 (默认 ./config/agents.json) # 首次使用: cp config/agents.example.json config/agents.json 然后自定义 diff --git a/package.json b/package.json index 4a6413fa..d32f22c4 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "main": "dist/index.js", "scripts": { "dev": "tsx watch src/index.ts", + "onboard": "tsx scripts/onboard.ts", "build": "tsc", "start": "node dist/index.js", "lint": "eslint src/", diff --git a/scripts/onboard.ts b/scripts/onboard.ts new file mode 100644 index 00000000..43dde9c1 --- /dev/null +++ b/scripts/onboard.ts @@ -0,0 +1,145 @@ +#!/usr/bin/env tsx +/** + * CLI Onboarding Agent — 交互式配置助手 + * + * 通过 Agent SDK 在终端中启动多轮对话,引导用户完成 Anycode 首次配置: + * - 飞书应用配置 (App ID/Secret, 权限, 事件) + * - 团队信息 (写入 config/knowledge/team.md) + * - Bot 人格设定 (写入 config/personas/pm.md) + * - 可选功能 (记忆系统、定时任务等) + * + * 用法: npm run onboard 或 npx tsx scripts/onboard.ts + */ +import { query } from '@anthropic-ai/claude-agent-sdk'; +import * as readline from 'node:readline/promises'; +import { getBootstrapPrompt, isOnboardingCompleted } from '../src/onboarding/bootstrap.js'; + +// 颜色 +const CYAN = '\x1b[36m'; +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; +const RESET = '\x1b[0m'; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +// 检查是否已完成 +if (isOnboardingCompleted()) { + console.log(`\n${GREEN}Onboarding 已完成。${RESET}`); + const answer = await rl.question(`${YELLOW}要重新配置吗?(y/N): ${RESET}`); + if (answer.trim().toLowerCase() !== 'y') { + console.log('退出。运行 npm run dev 启动服务。'); + rl.close(); + process.exit(0); + } +} + +console.log(`\n${BOLD}${CYAN}-- Anycode 配置助手 --${RESET}\n`); +console.log(`${DIM}AI 助手将引导你完成飞书配置、团队信息和 Bot 人格设定。${RESET}`); +console.log(`${DIM}输入 "跳过" 跳过当前步骤,输入 "退出" 结束配置。${RESET}\n`); + +const systemPrompt = getBootstrapPrompt(); +let sessionId: string | undefined; +let userMessage = '开始配置。请先帮我配置飞书应用。'; +let round = 0; +const MAX_ROUNDS = 30; + +while (round < MAX_ROUNDS) { + round++; + + try { + const q = query({ + prompt: userMessage, + options: { + cwd: process.cwd(), + systemPrompt: systemPrompt, + permissionMode: 'acceptEdits', + model: process.env.CLAUDE_MODEL || 'claude-sonnet-4-6', + maxTurns: 50, + maxBudgetUsd: 5, + ...(sessionId ? { resume: sessionId } : {}), + canUseTool: async (_toolName: string, inputObj: Record) => { + return { behavior: 'allow' as const, updatedInput: inputObj }; + }, + }, + }); + + let hasOutput = false; + + for await (const msg of q) { + switch (msg.type) { + case 'system': + if (msg.subtype === 'init') { + sessionId = msg.session_id; + } + break; + + case 'assistant': + if (msg.message?.content) { + for (const block of msg.message.content) { + if ('text' in block && block.text) { + // 过滤 thinking 标签 + const text = block.text + .replace(/[\s\S]*?<\/thinking>\s*/g, '') + .replace(/[\s\S]*/g, ''); + if (text.trim()) { + process.stdout.write(`\n${CYAN}Bot:${RESET} ${text}\n`); + hasOutput = true; + } + } + } + } + break; + + case 'result': + sessionId = msg.session_id ?? sessionId; + break; + } + } + + // 检查是否完成 + if (isOnboardingCompleted()) { + console.log(`\n${BOLD}${GREEN}配置完成!${RESET}`); + console.log(`\n运行 ${BOLD}npm run dev${RESET} 启动 Anycode 服务。\n`); + break; + } + + if (!hasOutput) { + console.log(`${DIM}(agent 正在处理文件操作...)${RESET}`); + } + + // 读取用户输入 + const input = await rl.question(`\n${YELLOW}你:${RESET} `); + if (input.trim().toLowerCase() === '退出' || input.trim().toLowerCase() === 'exit') { + console.log(`\n${DIM}配置中断。已完成的配置已保存。${RESET}`); + console.log(`运行 ${BOLD}npm run onboard${RESET} 继续配置。\n`); + break; + } + userMessage = input; + + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`\n${YELLOW}出错: ${msg}${RESET}`); + + if (msg.includes('ANTHROPIC_API_KEY') || msg.includes('authentication')) { + console.error(`请确认 .env 中的 ANTHROPIC_API_KEY 已正确配置。`); + break; + } + + // 非致命错误,尝试继续 + const retry = await rl.question(`${YELLOW}要继续吗?(Y/n): ${RESET}`); + if (retry.trim().toLowerCase() === 'n') break; + userMessage = '继续之前的配置流程。'; + } +} + +if (round >= MAX_ROUNDS) { + console.log(`\n${YELLOW}已达到最大轮次,配置中止。${RESET}`); + console.log(`运行 ${BOLD}npm run onboard${RESET} 继续。\n`); +} + +rl.close(); diff --git a/src/onboarding/__tests__/bootstrap.test.ts b/src/onboarding/__tests__/bootstrap.test.ts new file mode 100644 index 00000000..f54ff27a --- /dev/null +++ b/src/onboarding/__tests__/bootstrap.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + isOnboardingCompleted, + markOnboardingCompleted, + clearOnboardingCompleted, + getBootstrapPrompt, +} from '../bootstrap.js'; + +const TEST_DIR = resolve('/tmp/onboard-test-' + process.pid); + +describe('isOnboardingCompleted', () => { + const origCwd = process.cwd; + + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + process.cwd = () => TEST_DIR; + }); + + afterEach(() => { + process.cwd = origCwd; + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + it('should return false when .env does not exist', () => { + expect(isOnboardingCompleted()).toBe(false); + }); + + it('should return false when ONBOARDING_COMPLETED is not set', () => { + writeFileSync(resolve(TEST_DIR, '.env'), 'ANTHROPIC_API_KEY=sk-test\n'); + expect(isOnboardingCompleted()).toBe(false); + }); + + it('should return false when ONBOARDING_COMPLETED is commented out', () => { + writeFileSync(resolve(TEST_DIR, '.env'), '# ONBOARDING_COMPLETED=true\n'); + expect(isOnboardingCompleted()).toBe(false); + }); + + it('should return true when ONBOARDING_COMPLETED=true is set', () => { + writeFileSync(resolve(TEST_DIR, '.env'), 'ONBOARDING_COMPLETED=true\nOTHER=value\n'); + expect(isOnboardingCompleted()).toBe(true); + }); + + it('should return false when ONBOARDING_COMPLETED=false', () => { + writeFileSync(resolve(TEST_DIR, '.env'), 'ONBOARDING_COMPLETED=false\n'); + expect(isOnboardingCompleted()).toBe(false); + }); +}); + +describe('markOnboardingCompleted', () => { + const origCwd = process.cwd; + + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + process.cwd = () => TEST_DIR; + }); + + afterEach(() => { + process.cwd = origCwd; + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + it('should append ONBOARDING_COMPLETED=true when not present', () => { + writeFileSync(resolve(TEST_DIR, '.env'), 'KEY=value\n'); + markOnboardingCompleted(); + const content = readFileSync(resolve(TEST_DIR, '.env'), 'utf-8'); + expect(content).toContain('ONBOARDING_COMPLETED=true'); + }); + + it('should replace commented-out ONBOARDING_COMPLETED', () => { + writeFileSync(resolve(TEST_DIR, '.env'), '# ONBOARDING_COMPLETED=\nKEY=value\n'); + markOnboardingCompleted(); + const content = readFileSync(resolve(TEST_DIR, '.env'), 'utf-8'); + expect(content).toContain('ONBOARDING_COMPLETED=true'); + expect(content).not.toContain('# ONBOARDING_COMPLETED'); + }); + + it('should replace existing ONBOARDING_COMPLETED=false', () => { + writeFileSync(resolve(TEST_DIR, '.env'), 'ONBOARDING_COMPLETED=false\n'); + markOnboardingCompleted(); + const content = readFileSync(resolve(TEST_DIR, '.env'), 'utf-8'); + expect(content).toContain('ONBOARDING_COMPLETED=true'); + expect(content).not.toContain('false'); + }); +}); + +describe('clearOnboardingCompleted', () => { + const origCwd = process.cwd; + + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + process.cwd = () => TEST_DIR; + }); + + afterEach(() => { + process.cwd = origCwd; + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + it('should comment out ONBOARDING_COMPLETED', () => { + writeFileSync(resolve(TEST_DIR, '.env'), 'ONBOARDING_COMPLETED=true\n'); + clearOnboardingCompleted(); + const content = readFileSync(resolve(TEST_DIR, '.env'), 'utf-8'); + expect(content).toContain('# ONBOARDING_COMPLETED='); + expect(content).not.toMatch(/^ONBOARDING_COMPLETED=true/m); + }); + + it('should be idempotent', () => { + writeFileSync(resolve(TEST_DIR, '.env'), 'KEY=value\n'); + clearOnboardingCompleted(); // should not crash + const content = readFileSync(resolve(TEST_DIR, '.env'), 'utf-8'); + expect(content).toBe('KEY=value\n'); + }); +}); + +describe('getBootstrapPrompt', () => { + it('should contain key file paths', () => { + const prompt = getBootstrapPrompt(); + expect(prompt).toContain('.env'); + expect(prompt).toContain('config/agents.example.json'); + expect(prompt).toContain('config/personas/pm.example.md'); + expect(prompt).toContain('config/knowledge/team.example.md'); + }); + + it('should contain configuration phases', () => { + const prompt = getBootstrapPrompt(); + expect(prompt).toContain('Phase 1'); + expect(prompt).toContain('飞书'); + expect(prompt).toContain('Phase 2'); + expect(prompt).toContain('团队'); + expect(prompt).toContain('Phase 3'); + expect(prompt).toContain('人格'); + expect(prompt).toContain('Phase 5'); + expect(prompt).toContain('可选'); + expect(prompt).toContain('ONBOARDING_COMPLETED=true'); + }); + + it('should contain important rules', () => { + const prompt = getBootstrapPrompt(); + expect(prompt).toContain('Alice/Bob/Carol'); + expect(prompt).toContain('跳过'); + }); +}); diff --git a/src/onboarding/bootstrap.ts b/src/onboarding/bootstrap.ts new file mode 100644 index 00000000..c8d1d637 --- /dev/null +++ b/src/onboarding/bootstrap.ts @@ -0,0 +1,160 @@ +/** + * Onboarding Bootstrap — prompt 生成 + 完成状态检测 + * + * CLI onboarding agent 的核心模块: + * - 生成引导 system prompt(指导 agent 通过对话收集配置信息) + * - 检测 onboarding 是否已完成(.env 中的 ONBOARDING_COMPLETED 标记) + */ +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +/** 项目根目录(延迟求值,支持测试中 mock process.cwd) */ +function root(): string { + return process.cwd(); +} + +/** + * 检查 onboarding 是否已完成。 + * 读取 .env 文件中的 ONBOARDING_COMPLETED 标记。 + */ +export function isOnboardingCompleted(): boolean { + try { + const envPath = resolve(root(), '.env'); + if (!existsSync(envPath)) return false; + const content = readFileSync(envPath, 'utf-8'); + return /^ONBOARDING_COMPLETED=true/m.test(content); + } catch { + return false; + } +} + +/** + * 标记 onboarding 完成(写入 .env)。 + * 由 CLI onboarding agent 或 agent 自行调用。 + */ +export function markOnboardingCompleted(): void { + const envPath = resolve(root(), '.env'); + try { + let content = readFileSync(envPath, 'utf-8'); + const line = 'ONBOARDING_COMPLETED=true'; + if (/^#?\s*ONBOARDING_COMPLETED=/m.test(content)) { + content = content.replace(/^#?\s*ONBOARDING_COMPLETED=.*/m, line); + } else { + content = content.trimEnd() + '\n' + line + '\n'; + } + writeFileSync(envPath, content, 'utf-8'); + } catch { + // best-effort + } +} + +/** + * 清除 onboarding 完成标记(/setup 重配时使用)。 + */ +export function clearOnboardingCompleted(): void { + const envPath = resolve(root(), '.env'); + try { + let content = readFileSync(envPath, 'utf-8'); + content = content.replace(/^ONBOARDING_COMPLETED=.*/m, '# ONBOARDING_COMPLETED='); + writeFileSync(envPath, content, 'utf-8'); + } catch { + // best-effort + } +} + +/** + * 生成 onboarding bootstrap system prompt。 + * 注入实际的文件路径,指导 agent 通过自然对话收集配置信息并写入文件。 + */ +export function getBootstrapPrompt(): string { + const envPath = resolve(root(), '.env'); + const envExamplePath = resolve(root(), '.env.example'); + const agentsExamplePath = resolve(root(), 'config/agents.example.json'); + const agentsPath = resolve(root(), 'config/agents.json'); + const personaExamplePath = resolve(root(), 'config/personas/pm.example.md'); + const personaPath = resolve(root(), 'config/personas/pm.md'); + const knowledgeExamplePath = resolve(root(), 'config/knowledge/team.example.md'); + const knowledgePath = resolve(root(), 'config/knowledge/team.md'); + + return `你是 Anycode 配置助手。用户刚部署了 Anycode(一个基于飞书的 AI 多 Agent 开发系统),你在终端中和他对话,帮他完成首次配置。 + +## 文件路径 + +- .env 配置文件: ${envPath} +- .env 模板文件: ${envExamplePath} +- Agent 配置模板: ${agentsExamplePath} +- Agent 配置文件: ${agentsPath} +- PM 人设模板: ${personaExamplePath} +- PM 人设文件: ${personaPath} +- 团队信息模板: ${knowledgeExamplePath} +- 团队信息文件: ${knowledgePath} + +## 配置流程 + +按以下顺序引导用户,每次只问 1-2 个问题,保持对话自然。 + +### Phase 1: 飞书应用配置 + +引导用户在 open.feishu.cn 创建企业自建应用(或使用已有应用): +1. 收集 FEISHU_APP_ID 和 FEISHU_APP_SECRET +2. 告知需要开通的权限: + - 必须:im:message, im:message:send_as_bot, im:chat:readonly, contact:contact.base:readonly + - 推荐:im:resource, im:chat +3. 告知需要订阅的事件: + - im.message.receive_v1 — 接收消息 + - card.action.trigger — 卡片按钮交互 + - p2p_chat_create — 用户首次私聊 Bot + - im.chat.member.bot.added_v1 — Bot 被拉入群 +4. 使用 Edit 工具将 FEISHU_APP_ID 和 FEISHU_APP_SECRET 写入 .env + +### Phase 2: 团队信息 + +1. 询问团队/公司名称 +2. 了解核心团队成员(姓名、角色) +3. 了解主要项目和仓库 +4. 读取 ${knowledgeExamplePath} 了解格式 +5. 将收集的信息写入 ${knowledgePath}(不要照搬模板中的 Alice/Bob/Carol) + +### Phase 3: Bot 人格设定 + +1. 询问希望 Bot 用什么名字/称呼 +2. 沟通风格偏好(正式/随意/技术流/幽默) +3. 特别的行为偏好 +4. 读取 ${personaExamplePath} 了解格式 +5. 将人格设定写入 ${personaPath} + +### Phase 4: Agent 配置文件 + +1. 如果 ${agentsPath} 不存在,从 ${agentsExamplePath} 复制 +2. 如果用户给了 Bot 名字,更新 agents.json 中的 displayName +3. 同时初始化 persona 和 knowledge 的 .example.md → .md 文件(仅当正式文件不存在时复制) + +### Phase 5: 可选功能 + +简要介绍并询问是否启用: +- 记忆系统(长期记住对话内容,需要 DASHSCOPE_API_KEY) +- 定时任务(定时执行任务) +- 飞书文档工具(读写飞书文档/表格) +- 快速确认(Direct 模式下先发一条自然短回复掩盖延迟,需要 DASHSCOPE_API_KEY) +- 用户访问控制(限制哪些飞书用户可以使用 Bot) +用户选择启用的功能,在 .env 中取消对应行的注释并配置 + +### Phase 6: 完成 + +1. 读取 .env 确认关键配置已就绪(FEISHU_APP_ID, FEISHU_APP_SECRET, ANTHROPIC_API_KEY 非空非占位符) +2. 在 .env 中写入 ONBOARDING_COMPLETED=true +3. 告知用户配置完成,可以运行 \`npm run dev\` 启动服务 +4. 提醒:配置文件支持热加载,后续可以随时通过飞书对话让 Bot 修改自己的人设和知识库 + +## 重要规则 + +- 保持对话简洁自然,像同事聊天而非填表 +- 每次只问 1-2 个问题,不要一次性列出所有问题 +- 用户说"跳过"时跳过当前步骤,继续下一步 +- 敏感信息(API Keys)用户粘贴后不要在回复中回显 +- 所有配置通过 Read/Edit/Write 工具直接写入文件 +- 写入 .env 时注意保留已有配置,只修改需要的行 +- 读取 .example 文件了解格式结构,但不要照搬模板占位内容 +- 如果用户问其他问题(不相关的),简短回答后引导回配置流程 +`; +} From f4cf0faaf151cf806826fdba752b11e792debf06 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 15:21:43 +0800 Subject: [PATCH 04/15] =?UTF-8?q?fix:=20onboard=20=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=20dotenv=20=E5=B9=B6=E4=BC=A0=E9=80=92=20env?= =?UTF-8?q?=20=E7=BB=99=20SDK=20=E5=AD=90=E8=BF=9B=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 `npm run onboard` 报 "Not logged in" 的问题: - 添加 `import 'dotenv/config'` 加载 .env 中的 ANTHROPIC_API_KEY - query options 传递 `env: process.env` 确保子进程继承 API Key 和 Base URL Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/onboard.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/onboard.ts b/scripts/onboard.ts index 43dde9c1..a4394947 100644 --- a/scripts/onboard.ts +++ b/scripts/onboard.ts @@ -10,6 +10,7 @@ * * 用法: npm run onboard 或 npx tsx scripts/onboard.ts */ +import 'dotenv/config'; import { query } from '@anthropic-ai/claude-agent-sdk'; import * as readline from 'node:readline/promises'; import { getBootstrapPrompt, isOnboardingCompleted } from '../src/onboarding/bootstrap.js'; @@ -61,6 +62,8 @@ while (round < MAX_ROUNDS) { model: process.env.CLAUDE_MODEL || 'claude-sonnet-4-6', maxTurns: 50, maxBudgetUsd: 5, + // 传递环境变量给 Claude Code 子进程(ANTHROPIC_API_KEY、ANTHROPIC_BASE_URL 等) + env: process.env as Record, ...(sessionId ? { resume: sessionId } : {}), canUseTool: async (_toolName: string, inputObj: Record) => { return { behavior: 'allow' as const, updatedInput: inputObj }; From 61833c0267452122327d543f7ef82e2080c10f61 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 15:25:42 +0800 Subject: [PATCH 05/15] =?UTF-8?q?fix:=20onboard=20=E6=B7=BB=E5=8A=A0=20std?= =?UTF-8?q?err=20=E5=92=8C=E8=B0=83=E8=AF=95=E8=BE=93=E5=87=BA=EF=BC=8C?= =?UTF-8?q?=E6=8E=92=E6=9F=A5=E5=AD=90=E8=BF=9B=E7=A8=8B=E6=97=A0=E5=93=8D?= =?UTF-8?q?=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/onboard.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/onboard.ts b/scripts/onboard.ts index a4394947..85ede388 100644 --- a/scripts/onboard.ts +++ b/scripts/onboard.ts @@ -53,6 +53,7 @@ while (round < MAX_ROUNDS) { round++; try { + console.log(`${DIM}[启动 Agent SDK query, round ${round}...]${RESET}`); const q = query({ prompt: userMessage, options: { @@ -64,6 +65,11 @@ while (round < MAX_ROUNDS) { maxBudgetUsd: 5, // 传递环境变量给 Claude Code 子进程(ANTHROPIC_API_KEY、ANTHROPIC_BASE_URL 等) env: process.env as Record, + // 捕获子进程 stderr 输出(调试用) + stderr: (data: string) => { + const trimmed = data.trim(); + if (trimmed) console.error(`${DIM}[stderr] ${trimmed}${RESET}`); + }, ...(sessionId ? { resume: sessionId } : {}), canUseTool: async (_toolName: string, inputObj: Record) => { return { behavior: 'allow' as const, updatedInput: inputObj }; From 2caec3ecc9d958e40f6d04372538d80dd8e34ec3 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 15:32:37 +0800 Subject: [PATCH 06/15] =?UTF-8?q?fix:=20setup.sh=20=E6=A0=A1=E9=AA=8C=20AP?= =?UTF-8?q?I=20Key=20=E6=A0=BC=E5=BC=8F=20+=20onboard=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=20env=20=E4=BC=A0=E9=80=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup.sh: 检查 ANTHROPIC_API_KEY 是否以 sk- 开头,防止误填飞书 ID - onboard.ts: 清理传给子进程的 env(去掉 CLAUDECODE 嵌套检测变量) Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/onboard.ts | 14 ++++++++++++-- scripts/setup.sh | 20 ++++++++++++++++---- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/scripts/onboard.ts b/scripts/onboard.ts index 85ede388..34d90e16 100644 --- a/scripts/onboard.ts +++ b/scripts/onboard.ts @@ -63,8 +63,18 @@ while (round < MAX_ROUNDS) { model: process.env.CLAUDE_MODEL || 'claude-sonnet-4-6', maxTurns: 50, maxBudgetUsd: 5, - // 传递环境变量给 Claude Code 子进程(ANTHROPIC_API_KEY、ANTHROPIC_BASE_URL 等) - env: process.env as Record, + // 只传递必要的环境变量给 Claude Code 子进程 + // 避免 .env 中的其他变量(如 OWNER_USER_ID)干扰 SDK 认证 + env: (() => { + const e: Record = {}; + // 继承系统 PATH 等基础环境 + for (const [k, v] of Object.entries(process.env)) { + if (v != null) e[k] = v; + } + // 确保清除可能干扰 Claude CLI 的嵌套检测变量 + delete e.CLAUDECODE; + return e; + })(), // 捕获子进程 stderr 输出(调试用) stderr: (data: string) => { const trimmed = data.trim(); diff --git a/scripts/setup.sh b/scripts/setup.sh index b8328464..5b3a1828 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -161,10 +161,22 @@ else # ANTHROPIC_API_KEY (必填) info "Anthropic API Key -- 在 console.anthropic.com/settings/keys 获取" - read -rp " ANTHROPIC_API_KEY: " ANTHROPIC_API_KEY - if [[ -n "$ANTHROPIC_API_KEY" ]]; then - sed -i "s|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=$(sed_escape "$ANTHROPIC_API_KEY")|" "$ROOT/.env" - fi + info "格式: sk-ant-... 或 sk-..." + while true; do + read -rp " ANTHROPIC_API_KEY: " ANTHROPIC_API_KEY + if [[ -z "$ANTHROPIC_API_KEY" ]]; then + warn "API Key 不能为空,请重新输入" + continue + fi + if [[ ! "$ANTHROPIC_API_KEY" =~ ^sk- ]]; then + warn "API Key 应以 sk- 开头,你输入的看起来不像 Anthropic API Key" + read -rp "$(echo -e "${YELLOW}确认使用?(y/N): ${NC}")" CONFIRM + [[ "${CONFIRM,,}" == "y" ]] && break + continue + fi + break + done + sed -i "s|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=$(sed_escape "$ANTHROPIC_API_KEY")|" "$ROOT/.env" # ANTHROPIC_BASE_URL (可选) info "API Base URL -- 使用代理或第三方兼容端点时填写,直连官方回车跳过" From 2dc79867aed740a56624a2a9bbce806f425daa8a Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 15:34:36 +0800 Subject: [PATCH 07/15] =?UTF-8?q?fix:=20=E5=8E=BB=E6=8E=89=20API=20Key=20?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E6=A0=A1=E9=AA=8C=20+=20=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=97=B6=E7=BB=99=E5=87=BA=E6=B8=85=E6=99=B0?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8C=87=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup.sh: 只校验非空,不限制 key 格式(兼容第三方代理) - onboard.ts: 认证 401 错误时明确提示检查 ANTHROPIC_API_KEY 和 BASE_URL - onboard.ts: 首次连接时显示等待提示,避免用户以为卡死 Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/onboard.ts | 14 +++++++++++--- scripts/setup.sh | 17 ++++------------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/scripts/onboard.ts b/scripts/onboard.ts index 34d90e16..5c2c5075 100644 --- a/scripts/onboard.ts +++ b/scripts/onboard.ts @@ -53,7 +53,9 @@ while (round < MAX_ROUNDS) { round++; try { - console.log(`${DIM}[启动 Agent SDK query, round ${round}...]${RESET}`); + if (round === 1) { + console.log(`${DIM}正在连接 AI 服务... (首次可能需要 10-20 秒)${RESET}`); + } const q = query({ prompt: userMessage, options: { @@ -144,8 +146,14 @@ while (round < MAX_ROUNDS) { const msg = err instanceof Error ? err.message : String(err); console.error(`\n${YELLOW}出错: ${msg}${RESET}`); - if (msg.includes('ANTHROPIC_API_KEY') || msg.includes('authentication')) { - console.error(`请确认 .env 中的 ANTHROPIC_API_KEY 已正确配置。`); + // 认证相关错误:给出清晰的修复指引 + if (msg.includes('401') || msg.includes('auth') || msg.includes('authenticate') || msg.includes('API Key') || msg.includes('Unauthorized')) { + console.error(`\n${BOLD}API 认证失败。请检查:${RESET}`); + console.error(` 1. .env 中的 ANTHROPIC_API_KEY 是否正确`); + console.error(` 2. 如果使用代理,ANTHROPIC_BASE_URL 是否正确`); + console.error(` 3. API Key 是否有效且未过期\n`); + console.error(` 当前 ANTHROPIC_BASE_URL: ${process.env.ANTHROPIC_BASE_URL || '(官方地址)'}`); + console.error(` 编辑 .env 修正后重新运行: ${BOLD}npm run onboard${RESET}\n`); break; } diff --git a/scripts/setup.sh b/scripts/setup.sh index 5b3a1828..95449a3d 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -161,20 +161,11 @@ else # ANTHROPIC_API_KEY (必填) info "Anthropic API Key -- 在 console.anthropic.com/settings/keys 获取" - info "格式: sk-ant-... 或 sk-..." - while true; do + info "使用第三方代理时填写对应平台的 API Key" + read -rp " ANTHROPIC_API_KEY: " ANTHROPIC_API_KEY + while [[ -z "$ANTHROPIC_API_KEY" ]]; do + warn "API Key 不能为空" read -rp " ANTHROPIC_API_KEY: " ANTHROPIC_API_KEY - if [[ -z "$ANTHROPIC_API_KEY" ]]; then - warn "API Key 不能为空,请重新输入" - continue - fi - if [[ ! "$ANTHROPIC_API_KEY" =~ ^sk- ]]; then - warn "API Key 应以 sk- 开头,你输入的看起来不像 Anthropic API Key" - read -rp "$(echo -e "${YELLOW}确认使用?(y/N): ${NC}")" CONFIRM - [[ "${CONFIRM,,}" == "y" ]] && break - continue - fi - break done sed -i "s|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=$(sed_escape "$ANTHROPIC_API_KEY")|" "$ROOT/.env" From 5ea022ab9dc5f938315fe9a002aeb1fc78c6799b Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 15:44:13 +0800 Subject: [PATCH 08/15] =?UTF-8?q?fix:=20onboarding=20prompt=20=E4=B8=A5?= =?UTF-8?q?=E6=A0=BC=E4=B8=80=E6=AC=A1=E5=8F=AA=E9=97=AE=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 1 拆分为先问 App ID 再问 App Secret,不同时要两个值 - 重要规则强调「每次只问一个问题」 - 终端不支持 markdown 链接,改为直接写 URL Co-Authored-By: Claude Opus 4.6 (1M context) --- src/onboarding/bootstrap.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/onboarding/bootstrap.ts b/src/onboarding/bootstrap.ts index c8d1d637..d271a06a 100644 --- a/src/onboarding/bootstrap.ts +++ b/src/onboarding/bootstrap.ts @@ -91,21 +91,21 @@ export function getBootstrapPrompt(): string { ## 配置流程 -按以下顺序引导用户,每次只问 1-2 个问题,保持对话自然。 +按以下顺序引导用户,**严格遵守每次只问一个问题**,等用户回答后再问下一个。 ### Phase 1: 飞书应用配置 引导用户在 open.feishu.cn 创建企业自建应用(或使用已有应用): -1. 收集 FEISHU_APP_ID 和 FEISHU_APP_SECRET -2. 告知需要开通的权限: +1. 先问 FEISHU_APP_ID(告知在哪里找),用户给出后写入 .env +2. 再问 FEISHU_APP_SECRET,用户给出后写入 .env +3. 告知需要开通的权限(列出清单,让用户确认已开通,不需要用户回传内容): - 必须:im:message, im:message:send_as_bot, im:chat:readonly, contact:contact.base:readonly - 推荐:im:resource, im:chat -3. 告知需要订阅的事件: +4. 告知需要订阅的事件(同上,列出让用户确认): - im.message.receive_v1 — 接收消息 - card.action.trigger — 卡片按钮交互 - p2p_chat_create — 用户首次私聊 Bot - im.chat.member.bot.added_v1 — Bot 被拉入群 -4. 使用 Edit 工具将 FEISHU_APP_ID 和 FEISHU_APP_SECRET 写入 .env ### Phase 2: 团队信息 @@ -148,13 +148,14 @@ export function getBootstrapPrompt(): string { ## 重要规则 +- **每次只问一个问题**,等用户回答后再问下一个。绝不同时问两个值 - 保持对话简洁自然,像同事聊天而非填表 -- 每次只问 1-2 个问题,不要一次性列出所有问题 - 用户说"跳过"时跳过当前步骤,继续下一步 -- 敏感信息(API Keys)用户粘贴后不要在回复中回显 +- 敏感信息(API Keys、Secrets)用户粘贴后不要在回复中回显完整值 - 所有配置通过 Read/Edit/Write 工具直接写入文件 - 写入 .env 时注意保留已有配置,只修改需要的行 - 读取 .example 文件了解格式结构,但不要照搬模板占位内容 - 如果用户问其他问题(不相关的),简短回答后引导回配置流程 +- 终端对话没有富文本,不要用 markdown 链接语法 [text](url),直接写 URL `; } From d8975ac003e42c66f0c905845673baa6ad68572b Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 15:47:58 +0800 Subject: [PATCH 09/15] =?UTF-8?q?feat:=20onboard=20=E7=AD=89=E5=BE=85=20ag?= =?UTF-8?q?ent=20=E5=9B=9E=E5=A4=8D=E6=97=B6=E6=98=BE=E7=A4=BA=20spinner?= =?UTF-8?q?=20=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/onboard.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/scripts/onboard.ts b/scripts/onboard.ts index 5c2c5075..3a304f4d 100644 --- a/scripts/onboard.ts +++ b/scripts/onboard.ts @@ -23,6 +23,24 @@ const BOLD = '\x1b[1m'; const DIM = '\x1b[2m'; const RESET = '\x1b[0m'; +// Spinner — 等待 agent 回复时显示 +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +let spinnerTimer: ReturnType | undefined; +function startSpinner(label = '思考中') { + let i = 0; + spinnerTimer = setInterval(() => { + process.stdout.write(`\r${DIM}${SPINNER_FRAMES[i % SPINNER_FRAMES.length]} ${label}...${RESET} `); + i++; + }, 80); +} +function stopSpinner() { + if (spinnerTimer) { + clearInterval(spinnerTimer); + spinnerTimer = undefined; + process.stdout.write('\r\x1b[K'); // 清除 spinner 行 + } +} + const rl = readline.createInterface({ input: process.stdin, output: process.stdout, @@ -53,9 +71,7 @@ while (round < MAX_ROUNDS) { round++; try { - if (round === 1) { - console.log(`${DIM}正在连接 AI 服务... (首次可能需要 10-20 秒)${RESET}`); - } + startSpinner(round === 1 ? '正在连接 AI 服务' : '思考中'); const q = query({ prompt: userMessage, options: { @@ -100,6 +116,7 @@ while (round < MAX_ROUNDS) { break; case 'assistant': + stopSpinner(); if (msg.message?.content) { for (const block of msg.message.content) { if ('text' in block && block.text) { @@ -143,6 +160,7 @@ while (round < MAX_ROUNDS) { userMessage = input; } catch (err) { + stopSpinner(); const msg = err instanceof Error ? err.message : String(err); console.error(`\n${YELLOW}出错: ${msg}${RESET}`); From a2832f6bdfc10b664bdd414d512f67ef308ee7ee Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 15:49:07 +0800 Subject: [PATCH 10/15] =?UTF-8?q?fix:=20onboarding=20prompt=20=E6=9D=83?= =?UTF-8?q?=E9=99=90=E5=92=8C=E4=BA=8B=E4=BB=B6=E8=AE=A2=E9=98=85=E5=88=86?= =?UTF-8?q?=E5=BC=80=E8=AE=B2=EF=BC=8C=E5=BC=BA=E8=B0=83=20card.action.tri?= =?UTF-8?q?gger=20=E5=BF=85=E9=A1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 权限(权限管理)和事件订阅(事件与回调)是飞书后台不同页面,分两步引导 - card.action.trigger 标注为必须,否则卡片按钮不生效 - 提醒用户配置后需要发布版本 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/onboarding/bootstrap.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/onboarding/bootstrap.ts b/src/onboarding/bootstrap.ts index d271a06a..bce57148 100644 --- a/src/onboarding/bootstrap.ts +++ b/src/onboarding/bootstrap.ts @@ -98,14 +98,15 @@ export function getBootstrapPrompt(): string { 引导用户在 open.feishu.cn 创建企业自建应用(或使用已有应用): 1. 先问 FEISHU_APP_ID(告知在哪里找),用户给出后写入 .env 2. 再问 FEISHU_APP_SECRET,用户给出后写入 .env -3. 告知需要开通的权限(列出清单,让用户确认已开通,不需要用户回传内容): +3. 告知需要开通的**权限**(开发者后台 → 权限管理),列出清单让用户确认: - 必须:im:message, im:message:send_as_bot, im:chat:readonly, contact:contact.base:readonly - 推荐:im:resource, im:chat -4. 告知需要订阅的事件(同上,列出让用户确认): - - im.message.receive_v1 — 接收消息 - - card.action.trigger — 卡片按钮交互 +4. 告知需要添加的**事件订阅**(开发者后台 → 事件与回调 → 添加事件),这是单独的一步: + - im.message.receive_v1 — 接收消息(必须) + - card.action.trigger — 卡片按钮交互回调(必须,否则 AskUser 等卡片按钮不生效) - p2p_chat_create — 用户首次私聊 Bot - im.chat.member.bot.added_v1 — Bot 被拉入群 +5. 提醒用户:配置完权限和事件后需要在「版本管理与发布」创建版本并发布,权限才生效 ### Phase 2: 团队信息 From 370c801cb47a8e20711b6ae9559e756a55e169f6 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 16:02:46 +0800 Subject: [PATCH 11/15] =?UTF-8?q?fix:=20onboarding=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=AE=BF=E9=97=AE=E6=8E=A7=E5=88=B6=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=8CautoDetectOwner=20=E8=87=AA=E5=8A=A8=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户在 onboarding 阶段还未启动 bot,不知道自己的飞书 open_id, 不应该问 ALLOWED_USER_IDS。系统有 autoDetectOwner 机制自动将 第一个发消息的用户设为管理员。 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/onboarding/bootstrap.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/onboarding/bootstrap.ts b/src/onboarding/bootstrap.ts index bce57148..9090cb46 100644 --- a/src/onboarding/bootstrap.ts +++ b/src/onboarding/bootstrap.ts @@ -132,13 +132,14 @@ export function getBootstrapPrompt(): string { ### Phase 5: 可选功能 -简要介绍并询问是否启用: +简要介绍并询问是否启用(逐个问,不要一次列出全部): - 记忆系统(长期记住对话内容,需要 DASHSCOPE_API_KEY) - 定时任务(定时执行任务) - 飞书文档工具(读写飞书文档/表格) - 快速确认(Direct 模式下先发一条自然短回复掩盖延迟,需要 DASHSCOPE_API_KEY) -- 用户访问控制(限制哪些飞书用户可以使用 Bot) -用户选择启用的功能,在 .env 中取消对应行的注释并配置 +用户选择启用的功能,在 .env 中取消对应行的注释并配置。 +不要在 onboarding 中配置用户访问控制(ALLOWED_USER_IDS / OWNER_USER_ID), +因为用户此时还不知道自己的飞书 open_id,系统有 autoDetectOwner 机制会自动处理。 ### Phase 6: 完成 From 985e4637fbdb88138266ab5f09416e2056caa0f2 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 16:04:19 +0800 Subject: [PATCH 12/15] =?UTF-8?q?fix:=20=E9=85=8D=E7=BD=AE=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E5=90=8E=E5=90=8C=E6=97=B6=E6=8F=90=E7=A4=BA=20npm=20?= =?UTF-8?q?run=20dev=20=E5=92=8C=20pm2=20=E5=90=AF=E5=8A=A8=E6=96=B9?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/onboard.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/onboard.ts b/scripts/onboard.ts index 3a304f4d..f1209945 100644 --- a/scripts/onboard.ts +++ b/scripts/onboard.ts @@ -142,7 +142,9 @@ while (round < MAX_ROUNDS) { // 检查是否完成 if (isOnboardingCompleted()) { console.log(`\n${BOLD}${GREEN}配置完成!${RESET}`); - console.log(`\n运行 ${BOLD}npm run dev${RESET} 启动 Anycode 服务。\n`); + console.log(`\n${BOLD}启动服务:${RESET}`); + console.log(` npm run dev ${DIM}# 开发模式 (带 auto-reload)${RESET}`); + console.log(` pm2 start npm --name anycode -- start ${DIM}# 生产模式 (后台运行)${RESET}\n`); break; } From ec4165d483e95f2604c658f2bfc5facb6b94d6cd Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 16:13:11 +0800 Subject: [PATCH 13/15] =?UTF-8?q?feat:=20=E5=8D=95=20bot=20=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E6=B3=A8=E5=85=A5=E8=BA=AB=E4=BB=BD=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=20+=20/config=20=E8=AF=8A=E6=96=AD=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复单 bot 模式下 bot 不知道自己名字的问题: - client.ts: fetchBotInfo 存储 app_name - event-handler.ts: buildBotIdentityContext 单 bot 模式也构建身份上下文 (从飞书 API 的 botName + agent config 的 displayName 取名字) 新增 /config 命令(Owner only): - 显示 agent 配置(模型、工具策略、回复模式等) - 显示关联文件路径(persona、knowledge) - 预览人设和知识内容 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/feishu/client.ts | 9 +++- src/feishu/event-handler.ts | 86 +++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/feishu/client.ts b/src/feishu/client.ts index 1ce9abf5..4831f56c 100644 --- a/src/feishu/client.ts +++ b/src/feishu/client.ts @@ -23,12 +23,18 @@ export function serializeCard(card: Record): string { export class FeishuClient { private client: lark.Client; private _botOpenId: string | undefined; + private _botName: string | undefined; /** Bot's own open_id, fetched at startup */ get botOpenId(): string | undefined { return this._botOpenId; } + /** Bot's display name from Feishu, fetched at startup */ + get botName(): string | undefined { + return this._botName; + } + constructor(appId?: string, appSecret?: string) { this.client = new lark.Client({ appId: appId ?? config.feishu.appId, @@ -54,7 +60,8 @@ export class FeishuClient { if (resp.code === 0 && resp.bot?.open_id) { this._botOpenId = resp.bot.open_id; - logger.info({ botOpenId: this._botOpenId, appName: resp.bot.app_name }, 'Bot info fetched'); + this._botName = resp.bot.app_name; + logger.info({ botOpenId: this._botOpenId, appName: this._botName }, 'Bot info fetched'); } else { logger.warn({ code: resp.code, msg: resp.msg }, 'Failed to fetch bot info, @mention detection may be inaccurate'); } diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index 56800b04..acbc5f32 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -1095,6 +1095,68 @@ async function handleSlashCommand( return true; } + // /config - 查看当前 agent 配置 + if (trimmed === '/config') { + if (!isOwner(userId)) { + const reply = '⚠️ 只有管理员可以使用 /config 命令'; + if (threadReplyMsgId) { + await feishuClient.replyTextInThread(threadReplyMsgId, reply); + } else { + await feishuClient.replyText(messageId, reply); + } + return true; + } + + const agentCfg = agentRegistry.get(agentId); + const cfgInfo = getAgentConfigInfo(agentId); + const personaContent = readPersonaFile(agentId); + const knowledgeContent = loadKnowledgeContent(agentId); + + const lines: string[] = [ + `🔧 **Agent 配置 — ${agentId}**`, + '', + `**显示名称**: ${agentCfg?.displayName ?? '(未配置)'}`, + `**飞书 Bot 名**: ${feishuClient.botName ?? '(未获取)'}`, + `**模型**: ${agentCfg?.model ?? '(默认)'}`, + `**工具策略**: ${agentCfg?.toolPolicy ?? '(默认)'}`, + `**回复模式**: ${agentCfg?.replyMode ?? '(默认)'}`, + `**需要审批**: ${agentCfg?.requiresApproval ? '是' : '否'}`, + `**预算**: $${agentCfg?.maxBudgetUsd ?? '?'} / ${agentCfg?.maxTurns ?? '?'} turns`, + ]; + + if (agentCfg?.editablePathPatterns?.length) { + lines.push(`**可编辑路径**: ${agentCfg.editablePathPatterns.join(', ')}`); + } + + lines.push(''); + if (cfgInfo) { + lines.push(`**配置文件**: ${cfgInfo.configFile}`); + if (cfgInfo.personaFile) lines.push(`**人设文件**: ${cfgInfo.personaFile}`); + if (cfgInfo.knowledgeDir) lines.push(`**知识目录**: ${cfgInfo.knowledgeDir}`); + if (cfgInfo.knowledgeFiles.length > 0) { + lines.push(`**知识文件**: ${cfgInfo.knowledgeFiles.join(', ')}`); + } + } + + if (personaContent) { + const preview = personaContent.length > 200 ? personaContent.slice(0, 200) + '...' : personaContent; + lines.push('', '**── 人设内容 ──**', preview); + } + + if (knowledgeContent) { + const preview = knowledgeContent.length > 200 ? knowledgeContent.slice(0, 200) + '...' : knowledgeContent; + lines.push('', '**── 知识内容 ──**', preview); + } + + const reply = lines.join('\n'); + if (threadReplyMsgId) { + await feishuClient.replyTextInThread(threadReplyMsgId, reply); + } else { + await feishuClient.replyText(messageId, reply); + } + return true; + } + // /help - 帮助 if (trimmed === '/help') { const helpLines: string[] = [ @@ -1106,6 +1168,7 @@ async function handleSlashCommand( '`/status` — 查看当前会话状态(工作目录、队列)', '`/reset` — 重置会话,清除对话历史', '`/stop` — 中断当前正在执行的任务', + '`/config` — 查看当前 Agent 配置和人设 🔒', '`/help` — 显示此帮助', '', '**── 工作区 ──**', @@ -1704,8 +1767,23 @@ async function injectQuotedMessage( * 2. 群内其他 bot 的名称(可通过 @名称 与其交互) * 3. 与其他 bot 交互的正确方式(在回复中 @,而非 send_to_chat) */ -export function buildBotIdentityContext(chatId: string): string | undefined { - if (!isMultiBotMode()) return undefined; +export function buildBotIdentityContext(chatId: string, agentId?: string): string | undefined { + if (!isMultiBotMode()) { + // 单 bot 模式:从飞书 API 获取的 botName + agent displayName 构建身份 + const botName = feishuClient.botName; + const agentCfg = agentId ? agentRegistry.get(agentId) : undefined; + const displayName = agentCfg?.displayName; + // 至少有一个名字才注入 + const name = displayName || botName; + if (!name) return undefined; + const lines = [`## 你的身份`]; + if (displayName && botName && displayName !== botName) { + lines.push(`你的名字是"${displayName}",在飞书中显示为"${botName}"。`); + } else { + lines.push(`你的名字是"${name}"。`); + } + return lines.join('\n'); + } const accountId = feishuClientContext.getStore(); if (!accountId) return undefined; @@ -2043,7 +2121,7 @@ export async function executeClaudeTask( : ''; // Bot 身份上下文(多 bot 模式下告诉 agent 自己是谁、群内有哪些其他 bot) - const botIdentityContext = buildBotIdentityContext(chatId); + const botIdentityContext = buildBotIdentityContext(chatId, agentId); // AskUserQuestion 回调:将 Claude 的提问渲染为飞书交互卡片 const onAskUser = createAskUserHandler(chatId, () => threadReplyMsgId); @@ -2529,7 +2607,7 @@ export async function executeDirectTask( : ''; // Bot 身份上下文(多 bot 模式下告诉 agent 自己是谁、群内有哪些其他 bot) - const botIdentityContext = buildBotIdentityContext(chatId); + const botIdentityContext = buildBotIdentityContext(chatId, agentId); // AskUserQuestion 回调(与 executeClaudeTask 共享逻辑) const onAskUserDirect = createAskUserHandler(chatId, () => threadReplyMsgId); From 8032ee1d06964954f2259260c9f441b0fbd5c557 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 17:05:49 +0800 Subject: [PATCH 14/15] =?UTF-8?q?refactor:=20persona=20=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E5=8E=BB=20PM=20=E8=80=A6=E5=90=88=20+=20agents.example=20?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E7=AC=A6=E8=AF=AD=E4=B9=89=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config/personas/pm.example.md → assistant.example.md(通用名,不绑定角色) - agents.example.json displayName 改为 "" 明确是占位符 - agents.example.json persona 路径改为 assistant.md - bootstrap prompt 动态配置所有 agent 的 displayName 和 persona, 不再硬编码写入 pm.md Co-Authored-By: Claude Opus 4.6 (1M context) --- config/agents.example.json | 6 +++--- config/personas/assistant.example.md | 22 ++++++++++++++++++++++ src/onboarding/__tests__/bootstrap.test.ts | 2 +- src/onboarding/bootstrap.ts | 18 ++++++++++-------- 4 files changed, 36 insertions(+), 12 deletions(-) create mode 100644 config/personas/assistant.example.md diff --git a/config/agents.example.json b/config/agents.example.json index 18e19579..fefdb55e 100644 --- a/config/agents.example.json +++ b/config/agents.example.json @@ -13,7 +13,7 @@ "agents": [ { "id": "pm", - "displayName": "PM Bot", + "displayName": "", "model": "claude-opus-4-6", "replyMode": "direct", "toolPolicy": { @@ -21,12 +21,12 @@ "allow": ["Skill", "Bash"] }, "bashAllowPatterns": ["^(ls|cat|head|tail|wc|find|grep|git (log|status|diff|show|branch))"], - "persona": "./personas/pm.md", + "persona": "./personas/assistant.md", "knowledge": ["team.md"] }, { "id": "dev", - "displayName": "DevBot", + "displayName": "", "model": "claude-opus-4-6", "toolPolicy": "all", "replyMode": "thread", diff --git a/config/personas/assistant.example.md b/config/personas/assistant.example.md new file mode 100644 index 00000000..46e3bedb --- /dev/null +++ b/config/personas/assistant.example.md @@ -0,0 +1,22 @@ +你是一个 AI 助手,通过飞书与团队成员交互。 + +## 身份 + +- **名字**:(在 onboarding 中设定) +- **角色**:(在 onboarding 中设定,如:全栈开发助手、产品经理、技术顾问等) +- **性格**:(在 onboarding 中设定,如:简洁高效、幽默随和、技术范等) + +## 能力 + +- 理解业务逻辑和用户场景 +- 阅读和分析代码 +- 协助梳理任务、回答技术问题 + +## 沟通风格 + +- 简洁直接,适合群聊节奏 +- 给建议时明确表达判断 +- 需求不清晰时主动追问 + +--- +此文件是模板。运行 `npm run onboard` 后会根据你的设定生成正式的人设文件。 diff --git a/src/onboarding/__tests__/bootstrap.test.ts b/src/onboarding/__tests__/bootstrap.test.ts index f54ff27a..5a784c81 100644 --- a/src/onboarding/__tests__/bootstrap.test.ts +++ b/src/onboarding/__tests__/bootstrap.test.ts @@ -119,7 +119,7 @@ describe('getBootstrapPrompt', () => { const prompt = getBootstrapPrompt(); expect(prompt).toContain('.env'); expect(prompt).toContain('config/agents.example.json'); - expect(prompt).toContain('config/personas/pm.example.md'); + expect(prompt).toContain('config/personas/assistant.example.md'); expect(prompt).toContain('config/knowledge/team.example.md'); }); diff --git a/src/onboarding/bootstrap.ts b/src/onboarding/bootstrap.ts index 9090cb46..5fd326c8 100644 --- a/src/onboarding/bootstrap.ts +++ b/src/onboarding/bootstrap.ts @@ -71,8 +71,8 @@ export function getBootstrapPrompt(): string { const envExamplePath = resolve(root(), '.env.example'); const agentsExamplePath = resolve(root(), 'config/agents.example.json'); const agentsPath = resolve(root(), 'config/agents.json'); - const personaExamplePath = resolve(root(), 'config/personas/pm.example.md'); - const personaPath = resolve(root(), 'config/personas/pm.md'); + const personaExamplePath = resolve(root(), 'config/personas/assistant.example.md'); + const personaDir = resolve(root(), 'config/personas'); const knowledgeExamplePath = resolve(root(), 'config/knowledge/team.example.md'); const knowledgePath = resolve(root(), 'config/knowledge/team.md'); @@ -84,8 +84,8 @@ export function getBootstrapPrompt(): string { - .env 模板文件: ${envExamplePath} - Agent 配置模板: ${agentsExamplePath} - Agent 配置文件: ${agentsPath} -- PM 人设模板: ${personaExamplePath} -- PM 人设文件: ${personaPath} +- 人设模板: ${personaExamplePath} +- 人设目录: ${personaDir} - 团队信息模板: ${knowledgeExamplePath} - 团队信息文件: ${knowledgePath} @@ -121,14 +121,16 @@ export function getBootstrapPrompt(): string { 1. 询问希望 Bot 用什么名字/称呼 2. 沟通风格偏好(正式/随意/技术流/幽默) 3. 特别的行为偏好 -4. 读取 ${personaExamplePath} 了解格式 -5. 将人格设定写入 ${personaPath} +4. 读取 ${personaExamplePath} 了解人设文件的格式 +5. 将人格设定写入人设文件(文件名不要叫 pm.md,用 assistant.md 或与 Bot 名字相关的名字) ### Phase 4: Agent 配置文件 1. 如果 ${agentsPath} 不存在,从 ${agentsExamplePath} 复制 -2. 如果用户给了 Bot 名字,更新 agents.json 中的 displayName -3. 同时初始化 persona 和 knowledge 的 .example.md → .md 文件(仅当正式文件不存在时复制) +2. 读取 agents.json,找到所有 agent,将每个 agent 的 displayName 更新为用户设定的 Bot 名字 +3. 将所有引用了 persona 字段的 agent 指向 Phase 3 中创建的人设文件 +4. 没有 persona 字段的 agent(如 dev),也加上 persona 指向同一文件 +5. 确保 knowledge 的 .example.md 已复制为 .md(仅当正式文件不存在时) ### Phase 5: 可选功能 From e6568a770570cb56a4c6ff8baecac11964ed5b43 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 17:51:19 +0800 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20spinner=20=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E5=90=8E=E5=85=9C=E5=BA=95=20stopSpinner=20+=20GIF=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=B6=85=E6=97=B6=E5=8A=A0=E5=88=B0=2015s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onboard.ts: for-await 循环后调用 stopSpinner(),防止无 assistant 消息时 spinner 残留导致终端输出错乱 (review feedback) - image-compress.test.ts: GIF 压缩测试在 CI 上偶发超时,15s 更安全 Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/onboard.ts | 2 ++ src/utils/__tests__/image-compress.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/onboard.ts b/scripts/onboard.ts index f1209945..edc6a6ba 100644 --- a/scripts/onboard.ts +++ b/scripts/onboard.ts @@ -139,6 +139,8 @@ while (round < MAX_ROUNDS) { } } + stopSpinner(); + // 检查是否完成 if (isOnboardingCompleted()) { console.log(`\n${BOLD}${GREEN}配置完成!${RESET}`); diff --git a/src/utils/__tests__/image-compress.test.ts b/src/utils/__tests__/image-compress.test.ts index 7099c013..0fb2a649 100644 --- a/src/utils/__tests__/image-compress.test.ts +++ b/src/utils/__tests__/image-compress.test.ts @@ -70,7 +70,7 @@ describe('compressImage', () => { expect(result.mediaType).toBe('image/jpeg'); }); - it('should extract first frame from GIF (output JPEG)', async () => { + it('should extract first frame from GIF (output JPEG)', { timeout: 15000 }, async () => { const raw = randomBytes(800 * 800 * 4); const gif = await sharp(raw, { raw: { width: 800, height: 800, channels: 4 } }) .gif()