From 8938c47f88264434b154d364300f06af658d4174 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sun, 15 Feb 2026 23:12:06 +0800 Subject: [PATCH] feat: add experimental support for AST parsing and static graph indexing - Introduced CLI options for using AST grep parsers and enabling static graph relationships during indexing. - Updated configuration management to load new settings for AST parsing and static graph types. - Enhanced AST grep processor to handle imports with aliases and improve relationship tracking. - Modified TreeSitter parsers to support synthetic module scopes for better static graph persistence. - Implemented global relationship updates in the incremental indexer for static graph expansion. - Added new ArtifactTag and FloatingFileBrowser components to the frontend for improved terminal dashboard functionality. - Created utility functions for detecting CCW artifacts in terminal output with associated tests. --- .claude/commands/ccw.md | 252 ++++---- .../dashboard/widgets/WorkflowTaskWidget.tsx | 2 +- .../src/components/mcp/CcwToolsMcpCard.tsx | 109 +++- .../settings/PlatformConfigCards.tsx | 364 +++++++++++- .../settings/RemoteNotificationSection.tsx | 158 +++-- .../terminal-dashboard/ArtifactTag.tsx | 78 +++ .../FloatingFileBrowser.tsx | 191 ++++++ .../terminal-dashboard/TerminalInstance.tsx | 122 +++- .../terminal-dashboard/TerminalPane.tsx | 53 +- ccw/frontend/src/hooks/useMcpServers.ts | 5 + ccw/frontend/src/lib/api.ts | 104 +++- ccw/frontend/src/lib/ccw-artifacts.test.ts | 73 +++ ccw/frontend/src/lib/ccw-artifacts.ts | 91 +++ ccw/frontend/src/locales/en/mcp-manager.json | 17 + ccw/frontend/src/locales/en/settings.json | 31 + .../src/locales/en/terminal-dashboard.json | 42 ++ ccw/frontend/src/locales/zh/mcp-manager.json | 17 + ccw/frontend/src/locales/zh/settings.json | 31 + .../src/locales/zh/terminal-dashboard.json | 42 ++ ccw/frontend/src/pages/McpManagerPage.tsx | 78 ++- ccw/frontend/src/types/remote-notification.ts | 78 ++- ccw/frontend/vite.config.ts | 6 + ccw/src/core/a2ui/A2UITypes.ts | 2 + ccw/src/core/routes/hooks-routes.ts | 15 + ccw/src/core/routes/notification-routes.ts | 222 ++++++- .../services/remote-notification-service.ts | 554 +++++++++++++++++- ccw/src/tools/ask-question.ts | 98 +++- ccw/src/types/remote-notification.ts | 84 ++- ccw/tsconfig.tsbuildinfo | 1 + codex-lens/docs/CONFIGURATION.md | 33 ++ codex-lens/pyproject.toml | 4 +- codex-lens/src/codexlens/cli/commands.py | 58 +- codex-lens/src/codexlens/config.py | 37 ++ .../codexlens/parsers/astgrep_processor.py | 86 ++- codex-lens/src/codexlens/parsers/factory.py | 12 +- .../codexlens/parsers/treesitter_parser.py | 8 +- .../src/codexlens/storage/index_tree.py | 2 + .../codexlens/watcher/incremental_indexer.py | 56 +- codex-lens/tests/test_parsers.py | 37 ++ 39 files changed, 2956 insertions(+), 297 deletions(-) create mode 100644 ccw/frontend/src/components/terminal-dashboard/ArtifactTag.tsx create mode 100644 ccw/frontend/src/components/terminal-dashboard/FloatingFileBrowser.tsx create mode 100644 ccw/frontend/src/lib/ccw-artifacts.test.ts create mode 100644 ccw/frontend/src/lib/ccw-artifacts.ts create mode 100644 ccw/tsconfig.tsbuildinfo diff --git a/.claude/commands/ccw.md b/.claude/commands/ccw.md index 735dccab..aba61a81 100644 --- a/.claude/commands/ccw.md +++ b/.claude/commands/ccw.md @@ -11,42 +11,39 @@ Main process orchestrator: intent analysis → workflow selection → command ch ## Skill 映射 -命令链中的 workflow 操作通过 `Skill()` 调用。 +命令链中的 workflow 操作通过 `Skill()` 调用。每个 Skill 是自包含的执行单元,内部处理完整流水线。 -| Skill | 包含操作 | -|-------|---------| -| `workflow-lite-plan` | lite-plan, lite-execute | -| `workflow-plan` | plan, plan-verify, replan | -| `workflow-execute` | execute | -| `workflow-multi-cli-plan` | multi-cli-plan | -| `workflow-test-fix` | test-fix-gen, test-cycle-execute | -| `workflow-tdd` | tdd-plan, tdd-verify | -| `review-cycle` | review-session-cycle, review-module-cycle, review-cycle-fix | -| `brainstorm` | auto-parallel, artifacts, role-analysis, synthesis | +| Skill | 内部流水线 | +|-------|-----------| +| `workflow-lite-plan` | explore → plan → confirm → execute | +| `workflow-plan` | session → context → convention → gen → verify/replan | +| `workflow-execute` | session discovery → task processing → commit | +| `workflow-tdd` | 6-phase TDD plan → verify | +| `workflow-test-fix` | session → context → analysis → gen → cycle | +| `workflow-multi-cli-plan` | ACE context → CLI discussion → plan → execute | +| `review-cycle` | session/module review → fix orchestration | +| `brainstorm` | auto/single-role → artifacts → analysis → synthesis | -独立命令:workflow:brainstorm-with-file, workflow:debug-with-file, workflow:analyze-with-file, issue:* +独立命令(仍使用 colon 格式):workflow:brainstorm-with-file, workflow:debug-with-file, workflow:analyze-with-file, workflow:ui-design:*, issue:*, workflow:session:* -## Core Concept: Minimum Execution Units (最小执行单元) +## Core Concept: Self-Contained Skills (自包含 Skill) -**Definition**: A set of commands that must execute together as an atomic group to achieve a meaningful workflow milestone. +**Definition**: 每个 Skill 内部处理完整流水线,是天然的最小执行单元。单次 Skill 调用即完成一个有意义的工作里程碑。 **Why This Matters**: -- **Prevents Incomplete States**: Avoid stopping after task generation without execution +- **Prevents Incomplete States**: 每个 Skill 内部保证端到端完整性 - **User Experience**: User gets complete results, not intermediate artifacts requiring manual follow-up -- **Workflow Integrity**: Maintains logical coherence of multi-step operations +- **Simplified Orchestration**: 命令链只需组合独立 Skill,无需关注内部步骤 **Key Units in CCW**: -| Unit Type | Pattern | Example | -|-----------|---------|---------| -| **Planning + Execution** | plan-cmd → execute-cmd | lite-plan → lite-execute | -| **Testing** | test-gen-cmd → test-exec-cmd | test-fix-gen → test-cycle-execute | -| **Review** | review-cmd → fix-cmd | review-session-cycle → review-cycle-fix | - -**Atomic Rules**: -1. CCW automatically groups commands into minimum units - never splits them -2. Pipeline visualization shows units with `【 】` markers -3. Error handling preserves unit boundaries (retry/skip affects whole unit) +| 单元类型 | Skill | 说明 | +|---------|-------|------| +| 轻量 Plan+Execute | `workflow-lite-plan` | 内部完成 plan→execute | +| 标准 Planning | `workflow-plan` → `workflow-execute` | plan 和 execute 是独立 Skill | +| TDD Planning | `workflow-tdd` → `workflow-execute` | tdd-plan 和 execute 是独立 Skill | +| 测试流水线 | `workflow-test-fix` | 内部完成 gen→cycle | +| 代码审查 | `review-cycle` | 内部完成 review→fix | ## Execution Model @@ -157,153 +154,119 @@ function selectWorkflow(analysis) { return buildCommandChain(selected, analysis); } -// Build command chain (port-based matching with Minimum Execution Units) +// Build command chain (Skill-based composition) function buildCommandChain(workflow, analysis) { const chains = { // Level 2 - Lightweight 'rapid': [ - // Unit: Quick Implementation【lite-plan → lite-execute】 - { cmd: '/workflow:lite-plan', args: `"${analysis.goal}"`, unit: 'quick-impl' }, - { cmd: '/workflow:lite-execute', args: '--in-memory', unit: 'quick-impl' }, - // Unit: Test Validation【test-fix-gen → test-cycle-execute】 + { cmd: 'workflow-lite-plan', args: `"${analysis.goal}"` }, ...(analysis.constraints?.includes('skip-tests') ? [] : [ - { cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' }, - { cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' } + { cmd: 'workflow-test-fix', args: '' } ]) ], // Level 2 Bridge - Lightweight to Issue Workflow 'rapid-to-issue': [ - // Unit: Quick Implementation【lite-plan → convert-to-plan】 - { cmd: '/workflow:lite-plan', args: `"${analysis.goal}"`, unit: 'quick-impl-to-issue' }, - { cmd: '/issue:convert-to-plan', args: '--latest-lite-plan -y', unit: 'quick-impl-to-issue' }, - // Auto-continue to issue workflow - { cmd: '/issue:queue', args: '' }, - { cmd: '/issue:execute', args: '--queue auto' } + { cmd: 'workflow-lite-plan', args: `"${analysis.goal}" --plan-only` }, + { cmd: 'issue:convert-to-plan', args: '--latest-lite-plan -y' }, + { cmd: 'issue:queue', args: '' }, + { cmd: 'issue:execute', args: '--queue auto' } ], 'bugfix.standard': [ - // Unit: Bug Fix【lite-plan → lite-execute】 - { cmd: '/workflow:lite-plan', args: `--bugfix "${analysis.goal}"`, unit: 'bug-fix' }, - { cmd: '/workflow:lite-execute', args: '--in-memory', unit: 'bug-fix' }, - // Unit: Test Validation【test-fix-gen → test-cycle-execute】 + { cmd: 'workflow-lite-plan', args: `--bugfix "${analysis.goal}"` }, ...(analysis.constraints?.includes('skip-tests') ? [] : [ - { cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' }, - { cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' } + { cmd: 'workflow-test-fix', args: '' } ]) ], 'bugfix.hotfix': [ - { cmd: '/workflow:lite-plan', args: `--hotfix "${analysis.goal}"` } + { cmd: 'workflow-lite-plan', args: `--hotfix "${analysis.goal}"` } ], 'multi-cli-plan': [ - // Unit: Multi-CLI Planning【multi-cli-plan → lite-execute】 - { cmd: '/workflow:multi-cli-plan', args: `"${analysis.goal}"`, unit: 'multi-cli' }, - { cmd: '/workflow:lite-execute', args: '--in-memory', unit: 'multi-cli' }, - // Unit: Test Validation【test-fix-gen → test-cycle-execute】 + { cmd: 'workflow-multi-cli-plan', args: `"${analysis.goal}"` }, ...(analysis.constraints?.includes('skip-tests') ? [] : [ - { cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' }, - { cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' } + { cmd: 'workflow-test-fix', args: '' } ]) ], 'docs': [ - // Unit: Quick Implementation【lite-plan → lite-execute】 - { cmd: '/workflow:lite-plan', args: `"${analysis.goal}"`, unit: 'quick-impl' }, - { cmd: '/workflow:lite-execute', args: '--in-memory', unit: 'quick-impl' } + { cmd: 'workflow-lite-plan', args: `"${analysis.goal}"` } ], // With-File workflows (documented exploration with multi-CLI collaboration) 'brainstorm-with-file': [ - { cmd: '/workflow:brainstorm-with-file', args: `"${analysis.goal}"` } + { cmd: 'workflow:brainstorm-with-file', args: `"${analysis.goal}"` } // Note: Has built-in post-completion options (create plan, create issue, deep analysis) ], // Brainstorm-to-Issue workflow (bridge from brainstorm to issue execution) 'brainstorm-to-issue': [ // Note: Assumes brainstorm session already exists, or run brainstorm first - { cmd: '/issue:from-brainstorm', args: `SESSION="${extractBrainstormSession(analysis)}" --auto` }, - { cmd: '/issue:queue', args: '' }, - { cmd: '/issue:execute', args: '--queue auto' } + { cmd: 'issue:from-brainstorm', args: `SESSION="${extractBrainstormSession(analysis)}" --auto` }, + { cmd: 'issue:queue', args: '' }, + { cmd: 'issue:execute', args: '--queue auto' } ], 'debug-with-file': [ - { cmd: '/workflow:debug-with-file', args: `"${analysis.goal}"` } + { cmd: 'workflow:debug-with-file', args: `"${analysis.goal}"` } // Note: Self-contained with hypothesis-driven iteration and Gemini validation ], 'analyze-with-file': [ - { cmd: '/workflow:analyze-with-file', args: `"${analysis.goal}"` } + { cmd: 'workflow:analyze-with-file', args: `"${analysis.goal}"` } // Note: Self-contained with multi-round discussion and CLI exploration ], // Level 3 - Standard 'coupled': [ - // Unit: Verified Planning【plan → plan-verify】 - { cmd: '/workflow:plan', args: `"${analysis.goal}"`, unit: 'verified-planning' }, - { cmd: '/workflow:plan-verify', args: '', unit: 'verified-planning' }, - // Execution - { cmd: '/workflow:execute', args: '' }, - // Unit: Code Review【review-session-cycle → review-cycle-fix】 - { cmd: '/workflow:review-session-cycle', args: '', unit: 'code-review' }, - { cmd: '/workflow:review-cycle-fix', args: '', unit: 'code-review' }, - // Unit: Test Validation【test-fix-gen → test-cycle-execute】 + { cmd: 'workflow-plan', args: `"${analysis.goal}"` }, + { cmd: 'workflow-execute', args: '' }, + { cmd: 'review-cycle', args: '' }, ...(analysis.constraints?.includes('skip-tests') ? [] : [ - { cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' }, - { cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' } + { cmd: 'workflow-test-fix', args: '' } ]) ], 'tdd': [ - // Unit: TDD Planning + Execution【tdd-plan → execute】 - { cmd: '/workflow:tdd-plan', args: `"${analysis.goal}"`, unit: 'tdd-planning' }, - { cmd: '/workflow:execute', args: '', unit: 'tdd-planning' }, - // TDD Verification - { cmd: '/workflow:tdd-verify', args: '' } + { cmd: 'workflow-tdd', args: `"${analysis.goal}"` }, + { cmd: 'workflow-execute', args: '' } ], 'test-fix-gen': [ - // Unit: Test Validation【test-fix-gen → test-cycle-execute】 - { cmd: '/workflow:test-fix-gen', args: `"${analysis.goal}"`, unit: 'test-validation' }, - { cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' } + { cmd: 'workflow-test-fix', args: `"${analysis.goal}"` } ], 'review-cycle-fix': [ - // Unit: Code Review【review-session-cycle → review-cycle-fix】 - { cmd: '/workflow:review-session-cycle', args: '', unit: 'code-review' }, - { cmd: '/workflow:review-cycle-fix', args: '', unit: 'code-review' }, - // Unit: Test Validation【test-fix-gen → test-cycle-execute】 - { cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' }, - { cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' } + { cmd: 'review-cycle', args: '' }, + ...(analysis.constraints?.includes('skip-tests') ? [] : [ + { cmd: 'workflow-test-fix', args: '' } + ]) ], 'ui': [ - { cmd: '/workflow:ui-design:explore-auto', args: `"${analysis.goal}"` }, - // Unit: Planning + Execution【plan → execute】 - { cmd: '/workflow:plan', args: '', unit: 'plan-execute' }, - { cmd: '/workflow:execute', args: '', unit: 'plan-execute' } + { cmd: 'workflow:ui-design:explore-auto', args: `"${analysis.goal}"` }, + { cmd: 'workflow-plan', args: '' }, + { cmd: 'workflow-execute', args: '' } ], - // Level 4 - Brainstorm + // Level 4 - Full 'full': [ - { cmd: '/brainstorm', args: `"${analysis.goal}"` }, - // Unit: Verified Planning【plan → plan-verify】 - { cmd: '/workflow:plan', args: '', unit: 'verified-planning' }, - { cmd: '/workflow:plan-verify', args: '', unit: 'verified-planning' }, - // Execution - { cmd: '/workflow:execute', args: '' }, - // Unit: Test Validation【test-fix-gen → test-cycle-execute】 - { cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' }, - { cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' } + { cmd: 'brainstorm', args: `"${analysis.goal}"` }, + { cmd: 'workflow-plan', args: '' }, + { cmd: 'workflow-execute', args: '' }, + ...(analysis.constraints?.includes('skip-tests') ? [] : [ + { cmd: 'workflow-test-fix', args: '' } + ]) ], // Issue Workflow 'issue': [ - { cmd: '/issue:discover', args: '' }, - { cmd: '/issue:plan', args: '--all-pending' }, - { cmd: '/issue:queue', args: '' }, - { cmd: '/issue:execute', args: '' } + { cmd: 'issue:discover', args: '' }, + { cmd: 'issue:plan', args: '--all-pending' }, + { cmd: 'issue:queue', args: '' }, + { cmd: 'issue:execute', args: '' } ] }; @@ -311,7 +274,7 @@ function buildCommandChain(workflow, analysis) { } ``` -**Output**: `Level [X] - [flow] | Pipeline: [...] | Commands: [1. /cmd1 2. /cmd2 ...]` +**Output**: `Level [X] - [flow] | Pipeline: [...] | Commands: [1. cmd1 2. cmd2 ...]` --- @@ -377,7 +340,7 @@ function setupTodoTracking(chain, workflow, analysis) { ``` **Output**: -- TODO: `-> CCW:rapid: [1/3] /workflow:lite-plan | CCW:rapid: [2/3] /workflow:lite-execute | ...` +- TODO: `-> CCW:rapid: [1/2] workflow-lite-plan | CCW:rapid: [2/2] workflow-test-fix | ...` - Status File: `.workflow/.ccw/{session_id}/status.json` --- @@ -397,8 +360,8 @@ async function executeCommandChain(chain, workflow, trackingState) { state.updated_at = new Date().toISOString(); Write(`${stateDir}/status.json`, JSON.stringify(state, null, 2)); - const fullCommand = assembleCommand(chain[i], previousResult); - const result = await Skill({ skill: fullCommand }); + const assembled = assembleCommand(chain[i], previousResult); + const result = await Skill(assembled); previousResult = { ...result, success: true }; @@ -442,15 +405,13 @@ async function executeCommandChain(chain, workflow, trackingState) { return { success: true, completed: chain.length, sessionId }; } -// Assemble full command with session/plan parameters +// Assemble Skill call with session/plan parameters function assembleCommand(step, previousResult) { - let command = step.cmd; - if (step.args) { - command += ` ${step.args}`; - } else if (previousResult?.session_id) { - command += ` --session="${previousResult.session_id}"`; + let args = step.args || ''; + if (!args && previousResult?.session_id) { + args = `--session="${previousResult.session_id}"`; } - return command; + return { skill: step.cmd, args }; } // Update TODO: mark current as complete, next as in-progress @@ -498,7 +459,7 @@ Phase 1: Analyze Intent Phase 2: Select Workflow & Build Chain |-- Map task_type -> Level (1/2/3/4/Issue) |-- Select flow based on complexity - +-- Build command chain (port-based) + +-- Build command chain (Skill-based) | Phase 3: User Confirmation (optional) |-- Show pipeline visualization @@ -511,7 +472,7 @@ Phase 4: Setup TODO Tracking & Status File Phase 5: Execute Command Chain |-- For each command: | |-- Update status.json (current=running) - | |-- Assemble full command + | |-- Assemble Skill call | |-- Execute via Skill | |-- Update status.json (current=completed, next=running) | |-- Update TODO status @@ -521,22 +482,20 @@ Phase 5: Execute Command Chain --- -## Pipeline Examples (with Minimum Execution Units) +## Pipeline Examples -**Note**: `【 】` marks Minimum Execution Units - commands execute together as atomic groups. - -| Input | Type | Level | Pipeline (with Units) | -|-------|------|-------|-----------------------| -| "Add API endpoint" | feature (low) | 2 |【lite-plan → lite-execute】→【test-fix-gen → test-cycle-execute】| -| "Fix login timeout" | bugfix | 2 |【lite-plan → lite-execute】→【test-fix-gen → test-cycle-execute】| -| "Use issue workflow" | issue-transition | 2.5 |【lite-plan → convert-to-plan】→ queue → execute | -| "头脑风暴: 通知系统重构" | brainstorm | 4 | brainstorm-with-file → (built-in post-completion) | -| "从头脑风暴创建 issue" | brainstorm-to-issue | 4 | from-brainstorm → queue → execute | -| "深度调试 WebSocket 连接断开" | debug-file | 3 | debug-with-file → (hypothesis iteration) | -| "协作分析: 认证架构优化" | analyze-file | 3 | analyze-with-file → (multi-round discussion) | -| "OAuth2 system" | feature (high) | 3 |【plan → plan-verify】→ execute →【review-session-cycle → review-cycle-fix】→【test-fix-gen → test-cycle-execute】| -| "Implement with TDD" | tdd | 3 |【tdd-plan → execute】→ tdd-verify | -| "Uncertain: real-time arch" | exploration | 4 | brainstorm →【plan → plan-verify】→ execute →【test-fix-gen → test-cycle-execute】| +| Input | Type | Level | Pipeline | +|-------|------|-------|----------| +| "Add API endpoint" | feature (low) | 2 | workflow-lite-plan → workflow-test-fix | +| "Fix login timeout" | bugfix | 2 | workflow-lite-plan → workflow-test-fix | +| "Use issue workflow" | issue-transition | 2.5 | workflow-lite-plan(plan-only) → convert-to-plan → queue → execute | +| "头脑风暴: 通知系统重构" | brainstorm | 4 | workflow:brainstorm-with-file | +| "从头脑风暴创建 issue" | brainstorm-to-issue | 4 | issue:from-brainstorm → issue:queue → issue:execute | +| "深度调试 WebSocket" | debug-file | 3 | workflow:debug-with-file | +| "协作分析: 认证架构优化" | analyze-file | 3 | workflow:analyze-with-file | +| "OAuth2 system" | feature (high) | 3 | workflow-plan → workflow-execute → review-cycle → workflow-test-fix | +| "Implement with TDD" | tdd | 3 | workflow-tdd → workflow-execute | +| "Uncertain: real-time" | exploration | 4 | brainstorm → workflow-plan → workflow-execute → workflow-test-fix | --- @@ -544,11 +503,11 @@ Phase 5: Execute Command Chain 1. **Main Process Execution** - Use Skill in main process, no external CLI 2. **Intent-Driven** - Auto-select workflow based on task intent -3. **Port-Based Chaining** - Build command chain using port matching -4. **Minimum Execution Units** - Commands grouped into atomic units, never split (e.g., lite-plan → lite-execute) +3. **Skill-Based Chaining** - Build command chain by composing independent Skills +4. **Self-Contained Skills** - 每个 Skill 内部处理完整流水线,是天然的最小执行单元 5. **Progressive Clarification** - Low clarity triggers clarification phase 6. **TODO Tracking** - Use CCW prefix to isolate workflow todos -7. **Unit-Aware Error Handling** - Retry/skip/abort affects whole unit, not individual commands +7. **Error Handling** - Retry/skip/abort at Skill level 8. **User Control** - Optional user confirmation at each phase --- @@ -560,18 +519,16 @@ Phase 5: Execute Command Chain **1. TodoWrite-Based Tracking** (UI Display): All execution state tracked via TodoWrite with `CCW:` prefix. ```javascript -// Initial state +// Initial state (rapid workflow: 2 steps) todos = [ - { content: "CCW:rapid: [1/3] /workflow:lite-plan", status: "in_progress" }, - { content: "CCW:rapid: [2/3] /workflow:lite-execute", status: "pending" }, - { content: "CCW:rapid: [3/3] /workflow:test-cycle-execute", status: "pending" } + { content: "CCW:rapid: [1/2] workflow-lite-plan", status: "in_progress" }, + { content: "CCW:rapid: [2/2] workflow-test-fix", status: "pending" } ]; -// After command 1 completes +// After step 1 completes todos = [ - { content: "CCW:rapid: [1/3] /workflow:lite-plan", status: "completed" }, - { content: "CCW:rapid: [2/3] /workflow:lite-execute", status: "in_progress" }, - { content: "CCW:rapid: [3/3] /workflow:test-cycle-execute", status: "pending" } + { content: "CCW:rapid: [1/2] workflow-lite-plan", status: "completed" }, + { content: "CCW:rapid: [2/2] workflow-test-fix", status: "in_progress" } ]; ``` @@ -597,18 +554,13 @@ todos = [ "command_chain": [ { "index": 0, - "command": "/workflow:lite-plan", + "command": "workflow-lite-plan", "status": "completed" }, { "index": 1, - "command": "/workflow:lite-execute", + "command": "workflow-test-fix", "status": "running" - }, - { - "index": 2, - "command": "/workflow:test-cycle-execute", - "status": "pending" } ], "current_index": 1 diff --git a/ccw/frontend/src/components/dashboard/widgets/WorkflowTaskWidget.tsx b/ccw/frontend/src/components/dashboard/widgets/WorkflowTaskWidget.tsx index b3131a45..dfac1f32 100644 --- a/ccw/frontend/src/components/dashboard/widgets/WorkflowTaskWidget.tsx +++ b/ccw/frontend/src/components/dashboard/widgets/WorkflowTaskWidget.tsx @@ -704,7 +704,7 @@ function WorkflowTaskWidgetComponent({ className }: WorkflowTaskWidgetProps) { const isLastOdd = currentSession.tasks!.length % 2 === 1 && index === currentSession.tasks!.length - 1; return (
void; /** Installation target: Claude or Codex */ target?: 'claude' | 'codex'; + /** Scopes where CCW MCP is currently installed */ + installedScopes?: ('global' | 'project')[]; + /** Callback to uninstall from a specific scope */ + onUninstallScope?: (scope: 'global' | 'project') => void; + /** Callback to install to an additional scope */ + onInstallToScope?: (scope: 'global' | 'project') => void; } // ========== Constants ========== @@ -115,6 +122,9 @@ export function CcwToolsMcpCard({ onUpdateConfig, onInstall, target = 'claude', + installedScopes = [], + onUninstallScope, + onInstallToScope, }: CcwToolsMcpCardProps) { const { formatMessage } = useIntl(); const queryClient = useQueryClient(); @@ -242,9 +252,26 @@ export function CcwToolsMcpCard({ {formatMessage({ id: 'mcp.ccw.title' })} - - {isInstalled ? formatMessage({ id: 'mcp.ccw.status.installed' }) : formatMessage({ id: 'mcp.ccw.status.notInstalled' })} - + {isInstalled && installedScopes.length > 0 ? ( + <> + {installedScopes.map((s) => ( + + {s === 'global' ? : } + {formatMessage({ id: `mcp.ccw.scope.${s}` })} + + ))} + {installedScopes.length >= 2 && ( + + + {formatMessage({ id: 'mcp.conflict.badge' })} + + )} + + ) : ( + + {isInstalled ? formatMessage({ id: 'mcp.ccw.status.installed' }) : formatMessage({ id: 'mcp.ccw.status.notInstalled' })} + + )} {isCodex && ( Codex @@ -425,7 +452,7 @@ export function CcwToolsMcpCard({ {/* Install/Uninstall Button */}
- {/* Scope Selection - Claude only (Codex is always global) */} + {/* Scope Selection - Claude only, only when not installed */} {!isInstalled && !isCodex && (

@@ -465,6 +492,20 @@ export function CcwToolsMcpCard({ {formatMessage({ id: 'mcp.ccw.codexNote' })}

)} + + {/* Dual-scope conflict warning */} + {isInstalled && !isCodex && installedScopes.length >= 2 && ( +
+
+ + {formatMessage({ id: 'mcp.conflict.title' })} +
+

+ {formatMessage({ id: 'mcp.conflict.description' }, { scope: formatMessage({ id: 'mcp.scope.global' }) })} +

+
+ )} + {!isInstalled ? ( + )} + + {/* Per-scope uninstall buttons */} + {onUninstallScope && installedScopes.map((s) => ( + + ))} + + {/* Fallback: full uninstall if no scope info */} + {(!onUninstallScope || installedScopes.length === 0) && ( + + )} +
)}
diff --git a/ccw/frontend/src/components/settings/PlatformConfigCards.tsx b/ccw/frontend/src/components/settings/PlatformConfigCards.tsx index 5291c534..1a1bb317 100644 --- a/ccw/frontend/src/components/settings/PlatformConfigCards.tsx +++ b/ccw/frontend/src/components/settings/PlatformConfigCards.tsx @@ -16,6 +16,10 @@ import { TestTube, Eye, EyeOff, + MessageSquare, + Bell, + Users, + Mail, } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; @@ -28,6 +32,10 @@ import type { DiscordConfig, TelegramConfig, WebhookConfig, + FeishuConfig, + DingTalkConfig, + WeComConfig, + EmailConfig, } from '@/types/remote-notification'; import { PLATFORM_INFO } from '@/types/remote-notification'; @@ -38,11 +46,11 @@ interface PlatformConfigCardsProps { onToggleExpand: (platform: NotificationPlatform | null) => void; onUpdateConfig: ( platform: NotificationPlatform, - updates: Partial + updates: Partial ) => void; onTest: ( platform: NotificationPlatform, - config: DiscordConfig | TelegramConfig | WebhookConfig + config: DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig ) => void; onSave: () => void; saving: boolean; @@ -60,7 +68,7 @@ export function PlatformConfigCards({ }: PlatformConfigCardsProps) { const { formatMessage } = useIntl(); - const platforms: NotificationPlatform[] = ['discord', 'telegram', 'webhook']; + const platforms: NotificationPlatform[] = ['discord', 'telegram', 'feishu', 'dingtalk', 'wecom', 'email', 'webhook']; const getPlatformIcon = (platform: NotificationPlatform) => { switch (platform) { @@ -68,6 +76,14 @@ export function PlatformConfigCards({ return ; case 'telegram': return ; + case 'feishu': + return ; + case 'dingtalk': + return ; + case 'wecom': + return ; + case 'email': + return ; case 'webhook': return ; } @@ -75,12 +91,20 @@ export function PlatformConfigCards({ const getPlatformConfig = ( platform: NotificationPlatform - ): DiscordConfig | TelegramConfig | WebhookConfig => { + ): DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig => { switch (platform) { case 'discord': return config.platforms.discord || { enabled: false, webhookUrl: '' }; case 'telegram': return config.platforms.telegram || { enabled: false, botToken: '', chatId: '' }; + case 'feishu': + return config.platforms.feishu || { enabled: false, webhookUrl: '' }; + case 'dingtalk': + return config.platforms.dingtalk || { enabled: false, webhookUrl: '' }; + case 'wecom': + return config.platforms.wecom || { enabled: false, webhookUrl: '' }; + case 'email': + return config.platforms.email || { enabled: false, host: '', port: 587, username: '', password: '', from: '', to: [] }; case 'webhook': return config.platforms.webhook || { enabled: false, url: '', method: 'POST' }; } @@ -93,6 +117,15 @@ export function PlatformConfigCards({ return !!(platformConfig as DiscordConfig).webhookUrl; case 'telegram': return !!(platformConfig as TelegramConfig).botToken && !!(platformConfig as TelegramConfig).chatId; + case 'feishu': + return !!(platformConfig as FeishuConfig).webhookUrl; + case 'dingtalk': + return !!(platformConfig as DingTalkConfig).webhookUrl; + case 'wecom': + return !!(platformConfig as WeComConfig).webhookUrl; + case 'email': + const emailConfig = platformConfig as EmailConfig; + return !!(emailConfig.host && emailConfig.username && emailConfig.password && emailConfig.from && emailConfig.to?.length > 0); case 'webhook': return !!(platformConfig as WebhookConfig).url; } @@ -176,6 +209,30 @@ export function PlatformConfigCards({ onUpdate={(updates) => onUpdateConfig('telegram', updates)} /> )} + {platform === 'feishu' && ( + onUpdateConfig('feishu', updates)} + /> + )} + {platform === 'dingtalk' && ( + onUpdateConfig('dingtalk', updates)} + /> + )} + {platform === 'wecom' && ( + onUpdateConfig('wecom', updates)} + /> + )} + {platform === 'email' && ( + onUpdateConfig('email', updates)} + /> + )} {platform === 'webhook' && ( ) => void; +}) { + const { formatMessage } = useIntl(); + const [showUrl, setShowUrl] = useState(false); + + return ( +
+
+ +
+ onUpdate({ webhookUrl: e.target.value })} + placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..." + className="flex-1" + /> + +
+

+ {formatMessage({ id: 'settings.remoteNotifications.feishu.webhookUrlHint' })} +

+
+
+ onUpdate({ useCard: e.target.checked })} + className="rounded border-border" + /> + +
+

+ {formatMessage({ id: 'settings.remoteNotifications.feishu.useCardHint' })} +

+
+ + onUpdate({ title: e.target.value })} + placeholder="CCW Notification" + className="mt-1" + /> +
+
+ ); +} + +// ========== DingTalk Config Form ========== + +function DingTalkConfigForm({ + config, + onUpdate, +}: { + config: DingTalkConfig; + onUpdate: (updates: Partial) => void; +}) { + const { formatMessage } = useIntl(); + const [showUrl, setShowUrl] = useState(false); + + return ( +
+
+ +
+ onUpdate({ webhookUrl: e.target.value })} + placeholder="https://oapi.dingtalk.com/robot/send?access_token=..." + className="flex-1" + /> + +
+

+ {formatMessage({ id: 'settings.remoteNotifications.dingtalk.webhookUrlHint' })} +

+
+
+ + onUpdate({ keywords: e.target.value.split(',').map(k => k.trim()).filter(Boolean) })} + placeholder="keyword1, keyword2" + className="mt-1" + /> +

+ {formatMessage({ id: 'settings.remoteNotifications.dingtalk.keywordsHint' })} +

+
+
+ ); +} + +// ========== WeCom Config Form ========== + +function WeComConfigForm({ + config, + onUpdate, +}: { + config: WeComConfig; + onUpdate: (updates: Partial) => void; +}) { + const { formatMessage } = useIntl(); + const [showUrl, setShowUrl] = useState(false); + + return ( +
+
+ +
+ onUpdate({ webhookUrl: e.target.value })} + placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." + className="flex-1" + /> + +
+

+ {formatMessage({ id: 'settings.remoteNotifications.wecom.webhookUrlHint' })} +

+
+
+ + onUpdate({ mentionedList: e.target.value.split(',').map(m => m.trim()).filter(Boolean) })} + placeholder="userid1, userid2, @all" + className="mt-1" + /> +

+ {formatMessage({ id: 'settings.remoteNotifications.wecom.mentionedListHint' })} +

+
+
+ ); +} + +// ========== Email Config Form ========== + +function EmailConfigForm({ + config, + onUpdate, +}: { + config: EmailConfig; + onUpdate: (updates: Partial) => void; +}) { + const { formatMessage } = useIntl(); + const [showPassword, setShowPassword] = useState(false); + + return ( +
+
+
+ + onUpdate({ host: e.target.value })} + placeholder="smtp.gmail.com" + className="mt-1" + /> +

+ {formatMessage({ id: 'settings.remoteNotifications.email.hostHint' })} +

+
+
+ + onUpdate({ port: parseInt(e.target.value, 10) || 587 })} + placeholder="587" + className="mt-1" + /> +
+
+
+ onUpdate({ secure: e.target.checked })} + className="rounded border-border" + /> + +
+
+ + onUpdate({ username: e.target.value })} + placeholder="your-email@gmail.com" + className="mt-1" + /> +
+
+ +
+ onUpdate({ password: e.target.value })} + placeholder="********" + className="flex-1" + /> + +
+
+
+ + onUpdate({ from: e.target.value })} + placeholder="noreply@example.com" + className="mt-1" + /> +
+
+ + onUpdate({ to: e.target.value.split(',').map(t => t.trim()).filter(Boolean) })} + placeholder="user1@example.com, user2@example.com" + className="mt-1" + /> +

+ {formatMessage({ id: 'settings.remoteNotifications.email.toHint' })} +

+
+
+ ); +} + export default PlatformConfigCards; diff --git a/ccw/frontend/src/components/settings/RemoteNotificationSection.tsx b/ccw/frontend/src/components/settings/RemoteNotificationSection.tsx index ce966afa..27b5782d 100644 --- a/ccw/frontend/src/components/settings/RemoteNotificationSection.tsx +++ b/ccw/frontend/src/components/settings/RemoteNotificationSection.tsx @@ -11,15 +11,13 @@ import { RefreshCw, Check, X, + Save, ChevronDown, ChevronUp, - TestTube, - Save, - AlertTriangle, + Plus, } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; import { Badge } from '@/components/ui/Badge'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; @@ -30,6 +28,10 @@ import type { DiscordConfig, TelegramConfig, WebhookConfig, + FeishuConfig, + DingTalkConfig, + WeComConfig, + EmailConfig, } from '@/types/remote-notification'; import { PLATFORM_INFO, EVENT_INFO, getDefaultConfig } from '@/types/remote-notification'; import { PlatformConfigCards } from './PlatformConfigCards'; @@ -45,6 +47,7 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(null); const [expandedPlatform, setExpandedPlatform] = useState(null); + const [expandedEvent, setExpandedEvent] = useState(null); // Load configuration const loadConfig = useCallback(async () => { @@ -97,7 +100,7 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti // Test platform const testPlatform = useCallback(async ( platform: NotificationPlatform, - platformConfig: DiscordConfig | TelegramConfig | WebhookConfig + platformConfig: DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig ) => { setTesting(platform); try { @@ -136,7 +139,7 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti // Update platform config const updatePlatformConfig = ( platform: NotificationPlatform, - updates: Partial + updates: Partial ) => { if (!config) return; const newConfig = { @@ -160,6 +163,19 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti setConfig({ ...config, events: newEvents }); }; + // Toggle platform for event + const toggleEventPlatform = (eventIndex: number, platform: NotificationPlatform) => { + if (!config) return; + const eventConfig = config.events[eventIndex]; + const platforms = eventConfig.platforms.includes(platform) + ? eventConfig.platforms.filter((p) => p !== platform) + : [...eventConfig.platforms, platform]; + updateEventConfig(eventIndex, { platforms }); + }; + + // All available platforms + const allPlatforms: NotificationPlatform[] = ['discord', 'telegram', 'feishu', 'dingtalk', 'wecom', 'email', 'webhook']; + // Reset to defaults const resetConfig = async () => { if (!confirm(formatMessage({ id: 'settings.remoteNotifications.resetConfirm' }))) { @@ -266,51 +282,107 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
{config.events.map((eventConfig, index) => { const info = EVENT_INFO[eventConfig.event]; + const isExpanded = expandedEvent === index; return (
-
-
- {info.icon} + {/* Event Header */} +
setExpandedEvent(isExpanded ? null : index)} + > +
+
+ {info.icon} +
+
+

{info.name}

+

{info.description}

+
-
-

{info.name}

-

{info.description}

-
-
-
- {/* Platform badges */} -
- {eventConfig.platforms.map((platform) => ( - - {PLATFORM_INFO[platform].name} - - ))} - {eventConfig.platforms.length === 0 && ( - - {formatMessage({ id: 'settings.remoteNotifications.noPlatforms' })} - - )} -
- {/* Toggle */} - + {/* Expand icon */} + {isExpanded ? ( + ) : ( - + )} - +
+ + {/* Expanded Content - Platform Selection */} + {isExpanded && ( +
+

+ {formatMessage({ id: 'settings.remoteNotifications.selectPlatforms' })} +

+
+ {allPlatforms.map((platform) => { + const isSelected = eventConfig.platforms.includes(platform); + const platformInfo = PLATFORM_INFO[platform]; + const platformConfig = config.platforms[platform]; + const isConfigured = platformConfig?.enabled; + return ( + + ); + })} +
+
+ )}
); })} diff --git a/ccw/frontend/src/components/terminal-dashboard/ArtifactTag.tsx b/ccw/frontend/src/components/terminal-dashboard/ArtifactTag.tsx new file mode 100644 index 00000000..c43e6dd1 --- /dev/null +++ b/ccw/frontend/src/components/terminal-dashboard/ArtifactTag.tsx @@ -0,0 +1,78 @@ +// ======================================== +// ArtifactTag Component +// ======================================== +// Colored, clickable tag for detected CCW artifacts in terminal output. + +import { useIntl } from 'react-intl'; +import { cn } from '@/lib/utils'; +import { badgeVariants } from '@/components/ui/Badge'; +import type { ArtifactType } from '@/lib/ccw-artifacts'; + +export interface ArtifactTagProps { + type: ArtifactType; + path: string; + onClick?: (path: string) => void; + className?: string; +} + +function getVariant(type: ArtifactType) { + switch (type) { + case 'workflow-session': + return 'info'; + case 'lite-session': + return 'success'; + case 'claude-md': + return 'review'; + case 'ccw-config': + return 'warning'; + case 'issue': + return 'destructive'; + default: + return 'secondary'; + } +} + +function getLabelId(type: ArtifactType): string { + switch (type) { + case 'workflow-session': + return 'terminalDashboard.artifacts.types.workflowSession'; + case 'lite-session': + return 'terminalDashboard.artifacts.types.liteSession'; + case 'claude-md': + return 'terminalDashboard.artifacts.types.claudeMd'; + case 'ccw-config': + return 'terminalDashboard.artifacts.types.ccwConfig'; + case 'issue': + return 'terminalDashboard.artifacts.types.issue'; + } +} + +function basename(p: string): string { + const parts = p.split(/[\\/]/g); + return parts[parts.length - 1] || p; +} + +export function ArtifactTag({ type, path, onClick, className }: ArtifactTagProps) { + const { formatMessage } = useIntl(); + const label = formatMessage({ id: getLabelId(type) }); + const display = basename(path); + + return ( + + ); +} + +export default ArtifactTag; diff --git a/ccw/frontend/src/components/terminal-dashboard/FloatingFileBrowser.tsx b/ccw/frontend/src/components/terminal-dashboard/FloatingFileBrowser.tsx new file mode 100644 index 00000000..58158ff1 --- /dev/null +++ b/ccw/frontend/src/components/terminal-dashboard/FloatingFileBrowser.tsx @@ -0,0 +1,191 @@ +// ======================================== +// FloatingFileBrowser Component +// ======================================== +// Floating file browser panel for Terminal Dashboard. + +import * as React from 'react'; +import { useIntl } from 'react-intl'; +import { Copy, ArrowRightToLine, Loader2, RefreshCw } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { FloatingPanel } from './FloatingPanel'; +import { Button } from '@/components/ui/Button'; +import { TreeView } from '@/components/shared/TreeView'; +import { FilePreview } from '@/components/shared/FilePreview'; +import { useFileExplorer, useFileContent } from '@/hooks/useFileExplorer'; +import type { FileSystemNode } from '@/types/file-explorer'; + +export interface FloatingFileBrowserProps { + isOpen: boolean; + onClose: () => void; + rootPath: string; + onInsertPath?: (path: string) => void; + initialSelectedPath?: string | null; +} + +export function FloatingFileBrowser({ + isOpen, + onClose, + rootPath, + onInsertPath, + initialSelectedPath = null, +}: FloatingFileBrowserProps) { + const { formatMessage } = useIntl(); + + const { + state, + rootNodes, + isLoading, + isFetching, + error, + refetch, + setSelectedFile, + toggleExpanded, + } = useFileExplorer({ + rootPath, + maxDepth: 6, + enabled: isOpen, + }); + + const selectedPath = state.selectedFile; + const { content, isLoading: isContentLoading, error: contentError } = useFileContent(selectedPath, { + enabled: isOpen && !!selectedPath, + }); + + const [copied, setCopied] = React.useState(false); + + React.useEffect(() => { + if (!isOpen) return; + if (initialSelectedPath) { + setSelectedFile(initialSelectedPath); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, initialSelectedPath]); + + const handleNodeClick = (node: FileSystemNode) => { + if (node.type === 'file') { + setSelectedFile(node.path); + } + }; + + const handleCopyPath = async () => { + if (!selectedPath) return; + try { + await navigator.clipboard.writeText(selectedPath); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + } catch (err) { + console.error('[FloatingFileBrowser] copy path failed:', err); + } + }; + + const handleInsert = () => { + if (!selectedPath) return; + onInsertPath?.(selectedPath); + }; + + return ( + +
+ {/* Toolbar */} +
+
+
+ {selectedPath + ? formatMessage({ id: 'terminalDashboard.fileBrowser.selected' }) + : formatMessage({ id: 'terminalDashboard.fileBrowser.noSelection' })} +
+
+ {selectedPath ?? rootPath} +
+
+
+ + + + + +
+
+ + {/* Body */} +
+ {/* Tree */} +
+ {isLoading ? ( +
+ + + {formatMessage({ id: 'terminalDashboard.fileBrowser.loading' })} + +
+ ) : error ? ( +
+ {formatMessage({ id: 'terminalDashboard.fileBrowser.loadFailed' })} +
+ ) : ( + + )} +
+ + {/* Preview */} +
+ +
+
+
+
+ ); +} + +export default FloatingFileBrowser; diff --git a/ccw/frontend/src/components/terminal-dashboard/TerminalInstance.tsx b/ccw/frontend/src/components/terminal-dashboard/TerminalInstance.tsx index 11d1cc85..7f1c4bc5 100644 --- a/ccw/frontend/src/components/terminal-dashboard/TerminalInstance.tsx +++ b/ccw/frontend/src/components/terminal-dashboard/TerminalInstance.tsx @@ -6,7 +6,7 @@ // XTerm instance in ref, FitAddon, ResizeObserver, batched PTY input (30ms), // output chunk streaming from cliSessionStore. -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef, useCallback, useState } from 'react'; import { Terminal as XTerm } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; import { useCliSessionStore } from '@/stores/cliSessionStore'; @@ -18,6 +18,8 @@ import { resizeCliSession, } from '@/lib/api'; import { cn } from '@/lib/utils'; +import { detectCcArtifacts, type CcArtifact } from '@/lib/ccw-artifacts'; +import { ArtifactTag } from './ArtifactTag'; // ========== Types ========== @@ -26,11 +28,54 @@ interface TerminalInstanceProps { sessionId: string; /** Additional CSS classes */ className?: string; + /** Optional callback to reveal a detected artifact path (e.g. open file browser) */ + onRevealPath?: (path: string) => void; } // ========== Component ========== -export function TerminalInstance({ sessionId, className }: TerminalInstanceProps) { +const ARTIFACT_DEBOUNCE_MS = 250; +const MAX_ARTIFACT_TAGS = 12; + +function isAbsolutePath(p: string): boolean { + if (!p) return false; + if (p.startsWith('/') || p.startsWith('\\')) return true; + if (/^[A-Za-z]:[\\/]/.test(p)) return true; + if (p.startsWith('~')) return true; + return false; +} + +function joinPath(base: string, relative: string): string { + const sep = base.includes('\\') ? '\\' : '/'; + const b = base.replace(/[\\/]+$/, ''); + const r = relative.replace(/^[\\/]+/, ''); + return `${b}${sep}${r}`; +} + +function resolveArtifactPath(path: string, projectPath: string | null): string { + if (!path) return path; + if (isAbsolutePath(path)) return path; + if (!projectPath) return path; + return joinPath(projectPath, path); +} + +function mergeArtifacts(prev: CcArtifact[], next: CcArtifact[]): CcArtifact[] { + if (next.length === 0) return prev; + const map = new Map(); + for (const a of prev) map.set(`${a.type}:${a.path}`, a); + let changed = false; + for (const a of next) { + const key = `${a.type}:${a.path}`; + if (map.has(key)) continue; + map.set(key, a); + changed = true; + } + if (!changed) return prev; + const merged = Array.from(map.values()); + return merged.length > MAX_ARTIFACT_TAGS ? merged.slice(merged.length - MAX_ARTIFACT_TAGS) : merged; +} + +export function TerminalInstance({ sessionId, className, onRevealPath }: TerminalInstanceProps) { const projectPath = useWorkflowStore(selectProjectPath); // cliSessionStore selectors @@ -38,6 +83,8 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps const setBuffer = useCliSessionStore((s) => s.setBuffer); const clearOutput = useCliSessionStore((s) => s.clearOutput); + const [artifacts, setArtifacts] = useState([]); + // ========== xterm Refs ========== const terminalHostRef = useRef(null); @@ -45,6 +92,10 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps const fitAddonRef = useRef(null); const lastChunkIndexRef = useRef(0); + // Debounced artifact detection + const pendingArtifactTextRef = useRef(''); + const artifactTimerRef = useRef(null); + // PTY input batching (30ms, matching TerminalMainArea) const pendingInputRef = useRef(''); const flushTimerRef = useRef(null); @@ -56,6 +107,37 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps const projectPathRef = useRef(projectPath); projectPathRef.current = projectPath; + const handleArtifactClick = useCallback((path: string) => { + const resolved = resolveArtifactPath(path, projectPathRef.current); + navigator.clipboard.writeText(resolved).catch((err) => { + console.error('[TerminalInstance] copy artifact path failed:', err); + }); + onRevealPath?.(resolved); + }, [onRevealPath]); + + const scheduleArtifactParse = useCallback((text: string) => { + if (!text) return; + pendingArtifactTextRef.current += text; + if (artifactTimerRef.current !== null) return; + artifactTimerRef.current = window.setTimeout(() => { + artifactTimerRef.current = null; + const pending = pendingArtifactTextRef.current; + pendingArtifactTextRef.current = ''; + const detected = detectCcArtifacts(pending); + if (detected.length === 0) return; + setArtifacts((prev) => mergeArtifacts(prev, detected)); + }, ARTIFACT_DEBOUNCE_MS); + }, []); + + useEffect(() => { + return () => { + if (artifactTimerRef.current !== null) { + window.clearTimeout(artifactTimerRef.current); + artifactTimerRef.current = null; + } + }; + }, []); + // ========== PTY Input Batching ========== const flushInput = useCallback(async () => { @@ -139,6 +221,14 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps term.reset(); term.clear(); + // Reset artifact detection state per session + setArtifacts([]); + pendingArtifactTextRef.current = ''; + if (artifactTimerRef.current !== null) { + window.clearTimeout(artifactTimerRef.current); + artifactTimerRef.current = null; + } + if (!sessionId) return; clearOutput(sessionId); @@ -164,12 +254,18 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps if (start >= chunks.length) return; const { feedMonitor } = useSessionManagerStore.getState(); + const newTextParts: string[] = []; for (let i = start; i < chunks.length; i++) { term.write(chunks[i].data); feedMonitor(sessionId, chunks[i].data); + newTextParts.push(chunks[i].data); } lastChunkIndexRef.current = chunks.length; - }, [outputChunks, sessionId]); + + if (newTextParts.length > 0) { + scheduleArtifactParse(newTextParts.join('')); + } + }, [outputChunks, sessionId, scheduleArtifactParse]); // ResizeObserver -> fit + resize backend useEffect(() => { @@ -203,9 +299,21 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps // ========== Render ========== return ( -
+
+ {artifacts.length > 0 && ( +
+ {artifacts.map((a) => ( + + ))} +
+ )} +
+
); } diff --git a/ccw/frontend/src/components/terminal-dashboard/TerminalPane.tsx b/ccw/frontend/src/components/terminal-dashboard/TerminalPane.tsx index 7cd867d8..83927307 100644 --- a/ccw/frontend/src/components/terminal-dashboard/TerminalPane.tsx +++ b/ccw/frontend/src/components/terminal-dashboard/TerminalPane.tsx @@ -9,6 +9,7 @@ import { useIntl } from 'react-intl'; import { SplitSquareHorizontal, SplitSquareVertical, + FolderOpen, Eraser, AlertTriangle, X, @@ -21,6 +22,7 @@ import { } from 'lucide-react'; import { cn } from '@/lib/utils'; import { TerminalInstance } from './TerminalInstance'; +import { FloatingFileBrowser } from './FloatingFileBrowser'; import { useTerminalGridStore, selectTerminalGridPanes, @@ -37,6 +39,8 @@ import { } from '@/stores/issueQueueIntegrationStore'; import { useCliSessionStore } from '@/stores/cliSessionStore'; import { getAllPaneIds } from '@/lib/layout-utils'; +import { sendCliSessionText } from '@/lib/api'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; import type { PaneId } from '@/stores/viewerStore'; import type { TerminalStatus } from '@/types/terminal-dashboard'; @@ -75,6 +79,10 @@ export function TerminalPane({ paneId }: TerminalPaneProps) { const isFocused = focusedPaneId === paneId; const canClose = getAllPaneIds(layout).length > 1; + const projectPath = useWorkflowStore(selectProjectPath); + const [isFileBrowserOpen, setIsFileBrowserOpen] = useState(false); + const [initialFileBrowserPath, setInitialFileBrowserPath] = useState(null); + // Session data const groups = useSessionManagerStore(selectGroups); const terminalMetas = useSessionManagerStore(selectTerminalMetas); @@ -146,6 +154,25 @@ export function TerminalPane({ paneId }: TerminalPaneProps) { } }, [paneId, sessionId, assignSession]); + const handleOpenFileBrowser = useCallback(() => { + setInitialFileBrowserPath(null); + setIsFileBrowserOpen(true); + }, []); + + const handleRevealPath = useCallback((path: string) => { + setInitialFileBrowserPath(path); + setIsFileBrowserOpen(true); + }, []); + + const handleInsertPath = useCallback((path: string) => { + if (!sessionId) return; + sendCliSessionText( + sessionId, + { text: path, appendNewline: false }, + projectPath ?? undefined + ).catch((err) => console.error('[TerminalPane] insert path failed:', err)); + }, [sessionId, projectPath]); + const handleRestart = useCallback(async () => { if (!sessionId || isRestarting) return; setIsRestarting(true); @@ -291,6 +318,19 @@ export function TerminalPane({ paneId }: TerminalPaneProps) { )} + {alertCount > 0 && ( @@ -314,7 +354,7 @@ export function TerminalPane({ paneId }: TerminalPaneProps) { {/* Terminal content */} {sessionId ? (
- +
) : (
@@ -329,6 +369,17 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
)} + + { + setIsFileBrowserOpen(false); + setInitialFileBrowserPath(null); + }} + rootPath={projectPath ?? '/'} + onInsertPath={sessionId ? handleInsertPath : undefined} + initialSelectedPath={initialFileBrowserPath} + />
); } diff --git a/ccw/frontend/src/hooks/useMcpServers.ts b/ccw/frontend/src/hooks/useMcpServers.ts index a4e905f2..b8e8826b 100644 --- a/ccw/frontend/src/hooks/useMcpServers.ts +++ b/ccw/frontend/src/hooks/useMcpServers.ts @@ -21,6 +21,7 @@ import { crossCliCopy, type McpServer, type McpServersResponse, + type McpServerConflict, type McpProjectConfigType, type McpTemplate, type McpTemplateInstallRequest, @@ -66,6 +67,7 @@ export interface UseMcpServersReturn { servers: McpServer[]; projectServers: McpServer[]; globalServers: McpServer[]; + conflicts: McpServerConflict[]; totalCount: number; enabledCount: number; isLoading: boolean; @@ -95,6 +97,7 @@ export function useMcpServers(options: UseMcpServersOptions = {}): UseMcpServers const projectServers = query.data?.project ?? []; const globalServers = query.data?.global ?? []; + const conflicts = query.data?.conflicts ?? []; const allServers = scope === 'project' ? projectServers : scope === 'global' ? globalServers : [...projectServers, ...globalServers]; const enabledServers = allServers.filter((s) => s.enabled); @@ -111,6 +114,7 @@ export function useMcpServers(options: UseMcpServersOptions = {}): UseMcpServers servers: allServers, projectServers, globalServers, + conflicts, totalCount: allServers.length, enabledCount: enabledServers.length, isLoading: query.isLoading, @@ -224,6 +228,7 @@ export function useToggleMcpServer(): UseToggleMcpServerReturn { return { project: updateServer(old.project), global: updateServer(old.global), + conflicts: old.conflicts ?? [], }; }); diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts index 98d4cda4..35fdff57 100644 --- a/ccw/frontend/src/lib/api.ts +++ b/ccw/frontend/src/lib/api.ts @@ -2507,9 +2507,18 @@ export interface McpServer { scope: 'project' | 'global'; } +export interface McpServerConflict { + name: string; + projectServer: McpServer; + globalServer: McpServer; + /** Runtime effective scope */ + effectiveScope: 'global' | 'project'; +} + export interface McpServersResponse { project: McpServer[]; global: McpServer[]; + conflicts: McpServerConflict[]; } /** @@ -2618,7 +2627,6 @@ export async function fetchMcpServers(projectPath?: string): Promise !(name in userServers) && !(name in enterpriseServers)) .map(([name, raw]) => { const normalized = normalizeServerConfig(raw); return { name, ...normalized, enabled: !disabledSet.has(name), - scope: 'project', + scope: 'project' as const, }; }); + // Detect conflicts: same name exists in both project and global + const conflicts: McpServerConflict[] = []; + for (const ps of project) { + const gs = global.find(g => g.name === ps.name); + if (gs) { + conflicts.push({ + name: ps.name, + projectServer: ps, + globalServer: gs, + effectiveScope: 'global', + }); + } + } + return { project, global, + conflicts, }; } @@ -3549,6 +3570,7 @@ export interface CcwMcpConfig { projectRoot?: string; allowedDirs?: string; enableSandbox?: boolean; + installedScopes: ('global' | 'project')[]; } /** @@ -3605,22 +3627,24 @@ export async function fetchCcwMcpConfig(): Promise { try { const config = await fetchMcpConfig(); - // Check if ccw-tools server exists in any config + const installedScopes: ('global' | 'project')[] = []; let ccwServer: any = null; - // Check global servers + // Check global/user servers if (config.globalServers?.['ccw-tools']) { + installedScopes.push('global'); ccwServer = config.globalServers['ccw-tools']; - } - // Check user servers - if (!ccwServer && config.userServers?.['ccw-tools']) { + } else if (config.userServers?.['ccw-tools']) { + installedScopes.push('global'); ccwServer = config.userServers['ccw-tools']; } + // Check project servers - if (!ccwServer && config.projects) { + if (config.projects) { for (const proj of Object.values(config.projects)) { if (proj.mcpServers?.['ccw-tools']) { - ccwServer = proj.mcpServers['ccw-tools']; + installedScopes.push('project'); + if (!ccwServer) ccwServer = proj.mcpServers['ccw-tools']; break; } } @@ -3630,6 +3654,7 @@ export async function fetchCcwMcpConfig(): Promise { return { isInstalled: false, enabledTools: [], + installedScopes: [], }; } @@ -3646,11 +3671,13 @@ export async function fetchCcwMcpConfig(): Promise { projectRoot: env.CCW_PROJECT_ROOT, allowedDirs: env.CCW_ALLOWED_DIRS, enableSandbox: env.CCW_ENABLE_SANDBOX === '1', + installedScopes, }; } catch { return { isInstalled: false, enabledTools: [], + installedScopes: [], }; } } @@ -3742,6 +3769,27 @@ export async function uninstallCcwMcp(): Promise { } } +/** + * Uninstall CCW Tools MCP server from a specific scope + */ +export async function uninstallCcwMcpFromScope( + scope: 'global' | 'project', + projectPath?: string +): Promise { + if (scope === 'global') { + await fetchApi('/api/mcp-remove-global-server', { + method: 'POST', + body: JSON.stringify({ serverName: 'ccw-tools' }), + }); + } else { + if (!projectPath) throw new Error('projectPath required for project scope uninstall'); + await fetchApi('/api/mcp-remove-server', { + method: 'POST', + body: JSON.stringify({ projectPath, serverName: 'ccw-tools' }), + }); + } +} + // ========== CCW Tools MCP - Codex API ========== /** @@ -3753,7 +3801,7 @@ export async function fetchCcwMcpConfigForCodex(): Promise { const ccwServer = servers.find((s) => s.name === 'ccw-tools'); if (!ccwServer) { - return { isInstalled: false, enabledTools: [] }; + return { isInstalled: false, enabledTools: [], installedScopes: [] }; } const env = ccwServer.env || {}; @@ -3768,9 +3816,10 @@ export async function fetchCcwMcpConfigForCodex(): Promise { projectRoot: env.CCW_PROJECT_ROOT, allowedDirs: env.CCW_ALLOWED_DIRS, enableSandbox: env.CCW_ENABLE_SANDBOX === '1', + installedScopes: ['global'], }; } catch { - return { isInstalled: false, enabledTools: [] }; + return { isInstalled: false, enabledTools: [], installedScopes: [] }; } } @@ -3856,18 +3905,39 @@ export async function updateCcwConfigForCodex(config: { * @param projectPath - Optional project path to filter data by workspace */ export async function fetchIndexStatus(projectPath?: string): Promise { - const url = projectPath ? `/api/index/status?path=${encodeURIComponent(projectPath)}` : '/api/index/status'; - return fetchApi(url); + const url = projectPath + ? `/api/codexlens/workspace-status?path=${encodeURIComponent(projectPath)}` + : '/api/codexlens/workspace-status'; + const resp = await fetchApi<{ + success: boolean; + hasIndex: boolean; + fts?: { indexedFiles: number; totalFiles: number }; + }>(url); + return { + totalFiles: resp.fts?.totalFiles ?? 0, + lastUpdated: new Date().toISOString(), + buildTime: 0, + status: resp.hasIndex ? 'completed' : 'idle', + }; } /** * Rebuild index */ export async function rebuildIndex(request: IndexRebuildRequest = {}): Promise { - return fetchApi('/api/index/rebuild', { + await fetchApi<{ success: boolean }>('/api/codexlens/init', { method: 'POST', - body: JSON.stringify(request), + body: JSON.stringify({ + path: request.paths?.[0], + indexType: 'vector', + }), }); + return { + totalFiles: 0, + lastUpdated: new Date().toISOString(), + buildTime: 0, + status: 'building', + }; } // ========== Prompt History API ========== diff --git a/ccw/frontend/src/lib/ccw-artifacts.test.ts b/ccw/frontend/src/lib/ccw-artifacts.test.ts new file mode 100644 index 00000000..05c0cfeb --- /dev/null +++ b/ccw/frontend/src/lib/ccw-artifacts.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { detectCcArtifacts } from './ccw-artifacts'; + +describe('ccw-artifacts', () => { + it('returns empty array for empty input', () => { + expect(detectCcArtifacts('')).toEqual([]); + }); + + it('detects workflow session artifacts', () => { + const text = 'Created: (.workflow/active/WFS-demo/workflow-session.json)'; + expect(detectCcArtifacts(text)).toEqual([ + { type: 'workflow-session', path: '.workflow/active/WFS-demo/workflow-session.json' }, + ]); + }); + + it('detects lite session artifacts', () => { + const text = 'Plan: .workflow/.lite-plan/terminal-dashboard-enhancement-2026-02-15/plan.json'; + expect(detectCcArtifacts(text)).toEqual([ + { type: 'lite-session', path: '.workflow/.lite-plan/terminal-dashboard-enhancement-2026-02-15/plan.json' }, + ]); + }); + + it('detects CLAUDE.md artifacts (case-insensitive)', () => { + const text = 'Updated: /repo/docs/claude.md and also CLAUDE.md'; + const res = detectCcArtifacts(text); + expect(res).toEqual([ + { type: 'claude-md', path: '/repo/docs/claude.md' }, + { type: 'claude-md', path: 'CLAUDE.md' }, + ]); + }); + + it('detects CCW config artifacts', () => { + const text = 'Config: .ccw/config.toml and ccw.config.yaml'; + expect(detectCcArtifacts(text)).toEqual([ + { type: 'ccw-config', path: '.ccw/config.toml' }, + { type: 'ccw-config', path: 'ccw.config.yaml' }, + ]); + }); + + it('detects issue artifacts', () => { + const text = 'Queue: .workflow/issues/queues/index.json'; + expect(detectCcArtifacts(text)).toEqual([ + { type: 'issue', path: '.workflow/issues/queues/index.json' }, + ]); + }); + + it('deduplicates repeated artifacts', () => { + const text = '.workflow/issues/issues.jsonl ... .workflow/issues/issues.jsonl'; + expect(detectCcArtifacts(text)).toEqual([ + { type: 'issue', path: '.workflow/issues/issues.jsonl' }, + ]); + }); + + it('preserves discovery order across types', () => { + const text = [ + 'Issue: .workflow/issues/issues.jsonl', + 'Then plan: .workflow/.lite-plan/abc/plan.json', + 'Then session: .workflow/active/WFS-x/workflow-session.json', + 'Then config: .ccw/config.toml', + 'Then CLAUDE: CLAUDE.md', + ].join(' | '); + + const res = detectCcArtifacts(text); + expect(res.map((a) => a.type)).toEqual([ + 'issue', + 'lite-session', + 'workflow-session', + 'ccw-config', + 'claude-md', + ]); + }); +}); + diff --git a/ccw/frontend/src/lib/ccw-artifacts.ts b/ccw/frontend/src/lib/ccw-artifacts.ts new file mode 100644 index 00000000..7b12ddd9 --- /dev/null +++ b/ccw/frontend/src/lib/ccw-artifacts.ts @@ -0,0 +1,91 @@ +// ======================================== +// CCW Artifacts - Types & Detection +// ======================================== + +export type ArtifactType = + | 'workflow-session' + | 'lite-session' + | 'claude-md' + | 'ccw-config' + | 'issue'; + +export interface CcArtifact { + type: ArtifactType; + path: string; +} + +const TRAILING_PUNCTUATION = /[)\]}>,.;:!?]+$/g; +const WRAP_QUOTES = /^['"`]+|['"`]+$/g; + +function normalizePath(raw: string): string { + return raw.trim().replace(WRAP_QUOTES, '').replace(TRAILING_PUNCTUATION, ''); +} + +/** + * Patterns for detecting CCW-related artifacts in terminal output. + * + * Notes: + * - Prefer relative paths (e.g., `.workflow/...`) so callers can resolve against a project root. + * - Keep patterns conservative to reduce false positives in generic logs. + */ +export const ARTIFACT_PATTERNS: Record = { + 'workflow-session': [ + /(?:^|[^\w.])(\.workflow[\\/](?:active|archives)[\\/][^\s"'`]+[\\/]workflow-session\.json)\b/g, + ], + 'lite-session': [ + /(?:^|[^\w.])(\.workflow[\\/]\.lite-plan[\\/][^\s"'`]+)\b/g, + ], + 'claude-md': [ + /([^\s"'`]*CLAUDE\.md)\b/gi, + ], + 'ccw-config': [ + /(?:^|[^\w.])(\.ccw[\\/][^\s"'`]+)\b/g, + /(?:^|[^\w.])(ccw\.config\.(?:json|ya?ml|toml))\b/gi, + ], + issue: [ + /(?:^|[^\w.])(\.workflow[\\/]issues[\\/][^\s"'`]+)\b/g, + ], +}; + +/** + * Detect CCW artifacts from an arbitrary text blob. + * + * Returns a de-duplicated list of `{ type, path }` in discovery order. + */ +export function detectCcArtifacts(text: string): CcArtifact[] { + if (!text) return []; + + const candidates: Array = []; + + for (const type of Object.keys(ARTIFACT_PATTERNS) as ArtifactType[]) { + for (const pattern of ARTIFACT_PATTERNS[type]) { + pattern.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + const raw = match[1] ?? match[0]; + const path = normalizePath(raw); + if (!path) continue; + + const full = match[0] ?? ''; + const group = match[1] ?? raw; + const rel = full.indexOf(group); + const index = (match.index ?? 0) + (rel >= 0 ? rel : 0); + + candidates.push({ type, path, index }); + } + } + } + + candidates.sort((a, b) => a.index - b.index); + + const results: CcArtifact[] = []; + const seen = new Set(); + for (const c of candidates) { + const key = `${c.type}:${c.path}`; + if (seen.has(key)) continue; + seen.add(key); + results.push({ type: c.type, path: c.path }); + } + + return results; +} diff --git a/ccw/frontend/src/locales/en/mcp-manager.json b/ccw/frontend/src/locales/en/mcp-manager.json index 70f6d53c..4fdcb715 100644 --- a/ccw/frontend/src/locales/en/mcp-manager.json +++ b/ccw/frontend/src/locales/en/mcp-manager.json @@ -164,11 +164,28 @@ "uninstall": "Uninstall", "uninstalling": "Uninstalling...", "uninstallConfirm": "Are you sure you want to uninstall CCW MCP?", + "uninstallScopeConfirm": "Are you sure you want to remove CCW MCP from {scope}?", "saveConfig": "Save Configuration", "saving": "Saving..." }, + "scope": { + "global": "Global", + "project": "Project", + "addScope": "Install to other scope", + "installToProject": "Also install to project", + "installToGlobal": "Also install to global", + "uninstallFrom": "Uninstall by scope", + "uninstallGlobal": "Remove from global", + "uninstallProject": "Remove from project" + }, "codexNote": "Requires: npm install -g claude-code-workflow" }, + "conflict": { + "badge": "Conflict", + "title": "Scope Conflict", + "description": "This server exists in both project and global scopes. The {scope} version is used at runtime.", + "resolution": "Consider removing this server from one of the scopes." + }, "recommended": { "title": "Recommended Servers", "description": "Quickly install popular MCP servers with one click", diff --git a/ccw/frontend/src/locales/en/settings.json b/ccw/frontend/src/locales/en/settings.json index 1eb4fafe..23755ff9 100644 --- a/ccw/frontend/src/locales/en/settings.json +++ b/ccw/frontend/src/locales/en/settings.json @@ -121,6 +121,7 @@ "disabled": "Disabled", "platforms": "Platform Configuration", "events": "Event Triggers", + "selectPlatforms": "Select which platforms to notify for this event:", "noPlatforms": "No platforms", "configured": "Configured", "save": "Save", @@ -151,6 +152,36 @@ "method": "HTTP Method", "headers": "Custom Headers (JSON)", "headersHint": "Optional JSON object with custom headers" + }, + "feishu": { + "webhookUrl": "Webhook URL", + "webhookUrlHint": "Get from Feishu robot settings", + "useCard": "Use Card Format", + "useCardHint": "Send as rich interactive card", + "title": "Card Title (optional)" + }, + "dingtalk": { + "webhookUrl": "Webhook URL", + "webhookUrlHint": "Get from DingTalk robot settings", + "keywords": "Security Keywords", + "keywordsHint": "Comma-separated keywords for security check" + }, + "wecom": { + "webhookUrl": "Webhook URL", + "webhookUrlHint": "Get from WeCom robot settings", + "mentionedList": "Mention Users", + "mentionedListHint": "User IDs to mention, use '@all' for everyone" + }, + "email": { + "host": "SMTP Host", + "hostHint": "e.g., smtp.gmail.com", + "port": "Port", + "secure": "Use TLS", + "username": "Username", + "password": "Password", + "from": "Sender Email", + "to": "Recipients", + "toHint": "Comma-separated email addresses" } }, "versionCheck": { diff --git a/ccw/frontend/src/locales/en/terminal-dashboard.json b/ccw/frontend/src/locales/en/terminal-dashboard.json index 03b00ce1..7748d37e 100644 --- a/ccw/frontend/src/locales/en/terminal-dashboard.json +++ b/ccw/frontend/src/locales/en/terminal-dashboard.json @@ -78,9 +78,51 @@ "layoutSplitV": "Split Vertical", "layoutGrid": "Grid 2x2", "launchCli": "Launch CLI", + "tool": "Tool", + "mode": "Mode", + "modeDefault": "Default", + "modeYolo": "Yolo", "quickCreate": "Quick Create", "configure": "Configure..." }, + "cliConfig": { + "title": "Create CLI Session", + "description": "Configure tool, model, mode, shell, and working directory.", + "tool": "Tool", + "model": "Model", + "modelAuto": "Auto", + "mode": "Mode", + "modeDefault": "Default", + "modeYolo": "Yolo", + "shell": "Shell", + "workingDir": "Working Directory", + "workingDirPlaceholder": "e.g. /path/to/project", + "browse": "Browse", + "errors": { + "workingDirRequired": "Working directory is required.", + "createFailed": "Failed to create session." + } + }, + "fileBrowser": { + "title": "File Browser", + "open": "Open file browser", + "selected": "Selected file", + "noSelection": "No file selected", + "copyPath": "Copy path", + "copied": "Copied", + "insertPath": "Insert into terminal", + "loading": "Loading...", + "loadFailed": "Failed to load file tree" + }, + "artifacts": { + "types": { + "workflowSession": "Workflow", + "liteSession": "Lite", + "claudeMd": "CLAUDE.md", + "ccwConfig": "CCW Config", + "issue": "Issue" + } + }, "pane": { "selectSession": "Select a session", "selectSessionHint": "Choose a terminal session from the dropdown", diff --git a/ccw/frontend/src/locales/zh/mcp-manager.json b/ccw/frontend/src/locales/zh/mcp-manager.json index c0a0d953..a0a168c8 100644 --- a/ccw/frontend/src/locales/zh/mcp-manager.json +++ b/ccw/frontend/src/locales/zh/mcp-manager.json @@ -164,11 +164,28 @@ "uninstall": "卸载", "uninstalling": "卸载中...", "uninstallConfirm": "确定要卸载 CCW MCP 吗?", + "uninstallScopeConfirm": "确定要从{scope}移除 CCW MCP 吗?", "saveConfig": "保存配置", "saving": "保存中..." }, + "scope": { + "global": "全局", + "project": "项目", + "addScope": "安装到其他作用域", + "installToProject": "同时安装到项目", + "installToGlobal": "同时安装到全局", + "uninstallFrom": "按作用域卸载", + "uninstallGlobal": "从全局移除", + "uninstallProject": "从项目移除" + }, "codexNote": "需要全局安装:npm install -g claude-code-workflow" }, + "conflict": { + "badge": "冲突", + "title": "作用域冲突", + "description": "此服务器同时存在于项目和全局作用域。运行时使用{scope}版本。", + "resolution": "建议从其中一个作用域移除该服务器。" + }, "recommended": { "title": "推荐服务器", "description": "一键快速安装热门 MCP 服务器", diff --git a/ccw/frontend/src/locales/zh/settings.json b/ccw/frontend/src/locales/zh/settings.json index 89cee92d..a4fb86e8 100644 --- a/ccw/frontend/src/locales/zh/settings.json +++ b/ccw/frontend/src/locales/zh/settings.json @@ -121,6 +121,7 @@ "disabled": "已禁用", "platforms": "平台配置", "events": "事件触发器", + "selectPlatforms": "选择此事件要通知的平台:", "noPlatforms": "无平台", "configured": "已配置", "save": "保存", @@ -151,6 +152,36 @@ "method": "HTTP 方法", "headers": "自定义请求头(JSON)", "headersHint": "可选的 JSON 对象,包含自定义请求头" + }, + "feishu": { + "webhookUrl": "Webhook URL", + "webhookUrlHint": "从飞书机器人设置中获取", + "useCard": "使用卡片格式", + "useCardHint": "以富交互卡片形式发送", + "title": "卡片标题(可选)" + }, + "dingtalk": { + "webhookUrl": "Webhook URL", + "webhookUrlHint": "从钉钉机器人设置中获取", + "keywords": "安全关键词", + "keywordsHint": "逗号分隔的关键词,用于安全校验" + }, + "wecom": { + "webhookUrl": "Webhook URL", + "webhookUrlHint": "从企业微信机器人设置中获取", + "mentionedList": "提醒用户", + "mentionedListHint": "要提醒的用户 ID,使用 '@all' 提醒所有人" + }, + "email": { + "host": "SMTP 服务器", + "hostHint": "例如:smtp.gmail.com", + "port": "端口", + "secure": "使用 TLS", + "username": "用户名", + "password": "密码", + "from": "发件人邮箱", + "to": "收件人", + "toHint": "逗号分隔的邮箱地址" } }, "versionCheck": { diff --git a/ccw/frontend/src/locales/zh/terminal-dashboard.json b/ccw/frontend/src/locales/zh/terminal-dashboard.json index 5157b444..c7fb2bdf 100644 --- a/ccw/frontend/src/locales/zh/terminal-dashboard.json +++ b/ccw/frontend/src/locales/zh/terminal-dashboard.json @@ -78,9 +78,51 @@ "layoutSplitV": "上下分割", "layoutGrid": "2x2 网格", "launchCli": "启动 CLI", + "tool": "工具", + "mode": "模式", + "modeDefault": "默认", + "modeYolo": "Yolo", "quickCreate": "快速创建", "configure": "配置..." }, + "cliConfig": { + "title": "创建 CLI 会话", + "description": "配置工具、模型、模式、Shell 与工作目录。", + "tool": "工具", + "model": "模型", + "modelAuto": "自动", + "mode": "模式", + "modeDefault": "默认", + "modeYolo": "Yolo", + "shell": "Shell", + "workingDir": "工作目录", + "workingDirPlaceholder": "例如:/path/to/project", + "browse": "浏览", + "errors": { + "workingDirRequired": "工作目录不能为空。", + "createFailed": "创建会话失败。" + } + }, + "fileBrowser": { + "title": "文件浏览器", + "open": "打开文件浏览器", + "selected": "已选文件", + "noSelection": "未选择文件", + "copyPath": "复制路径", + "copied": "已复制", + "insertPath": "插入到终端", + "loading": "加载中...", + "loadFailed": "加载文件树失败" + }, + "artifacts": { + "types": { + "workflowSession": "工作流", + "liteSession": "Lite", + "claudeMd": "CLAUDE.md", + "ccwConfig": "配置", + "issue": "问题" + } + }, "pane": { "selectSession": "选择会话", "selectSessionHint": "从下拉菜单中选择终端会话", diff --git a/ccw/frontend/src/pages/McpManagerPage.tsx b/ccw/frontend/src/pages/McpManagerPage.tsx index d9918b3a..521e67e1 100644 --- a/ccw/frontend/src/pages/McpManagerPage.tsx +++ b/ccw/frontend/src/pages/McpManagerPage.tsx @@ -4,7 +4,7 @@ // Manage MCP servers (Model Context Protocol) with tabbed interface // Supports Templates, Servers, and Cross-CLI tabs -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useIntl } from 'react-intl'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { @@ -21,6 +21,7 @@ import { ChevronDown, ChevronUp, BookmarkPlus, + AlertTriangle, } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; @@ -38,16 +39,20 @@ import { AllProjectsTable } from '@/components/mcp/AllProjectsTable'; import { OtherProjectsSection } from '@/components/mcp/OtherProjectsSection'; import { TabsNavigation } from '@/components/ui/TabsNavigation'; import { useMcpServers, useMcpServerMutations, useNotifications } from '@/hooks'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; import { fetchCodexMcpServers, fetchCcwMcpConfig, fetchCcwMcpConfigForCodex, updateCcwConfig, updateCcwConfigForCodex, + installCcwMcp, + uninstallCcwMcpFromScope, codexRemoveServer, codexToggleServer, saveMcpTemplate, type McpServer, + type McpServerConflict, type CcwMcpConfig, } from '@/lib/api'; import { cn } from '@/lib/utils'; @@ -62,9 +67,10 @@ interface McpServerCardProps { onEdit: (server: McpServer) => void; onDelete: (server: McpServer) => void; onSaveAsTemplate: (server: McpServer) => void; + conflictInfo?: McpServerConflict; } -function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, onDelete, onSaveAsTemplate }: McpServerCardProps) { +function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, onDelete, onSaveAsTemplate, conflictInfo }: McpServerCardProps) { const { formatMessage } = useIntl(); return ( @@ -97,6 +103,12 @@ function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, o <>{formatMessage({ id: 'mcp.scope.project' })} )} + {conflictInfo && ( + + + {formatMessage({ id: 'mcp.conflict.badge' })} + + )} {server.enabled && ( {formatMessage({ id: 'mcp.status.enabled' })} @@ -205,6 +217,22 @@ function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, o
)} + + {/* Conflict warning panel */} + {conflictInfo && ( +
+
+ + {formatMessage({ id: 'mcp.conflict.title' })} +
+

+ {formatMessage({ id: 'mcp.conflict.description' }, { scope: formatMessage({ id: `mcp.scope.${conflictInfo.effectiveScope}` }) })} +

+

+ {formatMessage({ id: 'mcp.conflict.resolution' })} +

+
+ )} )} @@ -233,6 +261,7 @@ export function McpManagerPage() { servers, projectServers, globalServers, + conflicts, totalCount, enabledCount, isLoading, @@ -350,6 +379,7 @@ export function McpManagerPage() { projectRoot: undefined, allowedDirs: undefined, enableSandbox: undefined, + installedScopes: [] as ('global' | 'project')[], }; const handleToggleCcwTool = async (tool: string, enabled: boolean) => { @@ -401,13 +431,43 @@ export function McpManagerPage() { ccwMcpQuery.refetch(); }; + const projectPath = useWorkflowStore(selectProjectPath); + + // Build conflict map for quick lookup + const conflictMap = useMemo(() => { + const map = new Map(); + for (const c of conflicts) map.set(c.name, c); + return map; + }, [conflicts]); + + // CCW scope-specific handlers + const handleCcwInstallToScope = async (scope: 'global' | 'project') => { + try { + await installCcwMcp(scope, scope === 'project' ? projectPath ?? undefined : undefined); + ccwMcpQuery.refetch(); + } catch (error) { + console.error('Failed to install CCW MCP to scope:', error); + } + }; + + const handleCcwUninstallFromScope = async (scope: 'global' | 'project') => { + try { + await uninstallCcwMcpFromScope(scope, scope === 'project' ? projectPath ?? undefined : undefined); + ccwMcpQuery.refetch(); + queryClient.invalidateQueries({ queryKey: ['mcpServers'] }); + } catch (error) { + console.error('Failed to uninstall CCW MCP from scope:', error); + } + }; + // CCW MCP handlers for Codex mode const ccwCodexConfig = ccwMcpCodexQuery.data ?? { isInstalled: false, - enabledTools: [], + enabledTools: [] as string[], projectRoot: undefined, allowedDirs: undefined, enableSandbox: undefined, + installedScopes: [] as ('global' | 'project')[], }; const handleToggleCcwToolCodex = async (tool: string, enabled: boolean) => { @@ -725,6 +785,9 @@ export function McpManagerPage() { onToggleTool={handleToggleCcwTool} onUpdateConfig={handleUpdateCcwConfig} onInstall={handleCcwInstall} + installedScopes={ccwConfig.installedScopes} + onInstallToScope={handleCcwInstallToScope} + onUninstallScope={handleCcwUninstallFromScope} /> )} {cliMode === 'codex' && ( @@ -761,7 +824,7 @@ export function McpManagerPage() { {currentServers.map((server) => ( cliMode === 'codex' ? ( ) : ( currentToggleExpand(server.name)} + isExpanded={currentExpanded.has(`${server.name}-${server.scope}`)} + onToggleExpand={() => currentToggleExpand(`${server.name}-${server.scope}`)} onToggle={handleToggle} onEdit={handleEdit} onDelete={handleDelete} onSaveAsTemplate={handleSaveServerAsTemplate} + conflictInfo={conflictMap.get(server.name)} /> ) ))} diff --git a/ccw/frontend/src/types/remote-notification.ts b/ccw/frontend/src/types/remote-notification.ts index 2bb66d18..504c2de3 100644 --- a/ccw/frontend/src/types/remote-notification.ts +++ b/ccw/frontend/src/types/remote-notification.ts @@ -7,7 +7,7 @@ /** * Supported notification platforms */ -export type NotificationPlatform = 'discord' | 'telegram' | 'webhook'; +export type NotificationPlatform = 'discord' | 'telegram' | 'feishu' | 'dingtalk' | 'wecom' | 'email' | 'webhook'; /** * Event types that can trigger notifications @@ -39,6 +39,48 @@ export interface TelegramConfig { parseMode?: 'HTML' | 'Markdown' | 'MarkdownV2'; } +/** + * Feishu (Lark) platform configuration + */ +export interface FeishuConfig { + enabled: boolean; + webhookUrl: string; + useCard?: boolean; + title?: string; +} + +/** + * DingTalk platform configuration + */ +export interface DingTalkConfig { + enabled: boolean; + webhookUrl: string; + keywords?: string[]; +} + +/** + * WeCom (WeChat Work) platform configuration + */ +export interface WeComConfig { + enabled: boolean; + webhookUrl: string; + mentionedList?: string[]; +} + +/** + * Email SMTP platform configuration + */ +export interface EmailConfig { + enabled: boolean; + host: string; + port: number; + secure?: boolean; + username: string; + password: string; + from: string; + to: string[]; +} + /** * Generic Webhook platform configuration */ @@ -67,6 +109,10 @@ export interface RemoteNotificationConfig { platforms: { discord?: DiscordConfig; telegram?: TelegramConfig; + feishu?: FeishuConfig; + dingtalk?: DingTalkConfig; + wecom?: WeComConfig; + email?: EmailConfig; webhook?: WebhookConfig; }; events: EventConfig[]; @@ -78,7 +124,7 @@ export interface RemoteNotificationConfig { */ export interface TestNotificationRequest { platform: NotificationPlatform; - config: DiscordConfig | TelegramConfig | WebhookConfig; + config: DiscordConfig | TelegramConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig | WebhookConfig; } /** @@ -129,6 +175,34 @@ export const PLATFORM_INFO: Record = { description: 'Send notifications to Telegram chats via bot', requiredFields: ['botToken', 'chatId'], }, + feishu: { + id: 'feishu', + name: 'Feishu', + icon: 'message-square', + description: 'Send notifications to Feishu (Lark) via webhook with rich card support', + requiredFields: ['webhookUrl'], + }, + dingtalk: { + id: 'dingtalk', + name: 'DingTalk', + icon: 'bell', + description: 'Send notifications to DingTalk via webhook', + requiredFields: ['webhookUrl'], + }, + wecom: { + id: 'wecom', + name: 'WeCom', + icon: 'users', + description: 'Send notifications to WeCom (WeChat Work) via webhook', + requiredFields: ['webhookUrl'], + }, + email: { + id: 'email', + name: 'Email', + icon: 'mail', + description: 'Send notifications via SMTP email', + requiredFields: ['host', 'username', 'password', 'from', 'to'], + }, webhook: { id: 'webhook', name: 'Custom Webhook', diff --git a/ccw/frontend/vite.config.ts b/ccw/frontend/vite.config.ts index f31ebd38..73f557e1 100644 --- a/ccw/frontend/vite.config.ts +++ b/ccw/frontend/vite.config.ts @@ -26,6 +26,12 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, './src'), + // Ensure a single React instance in Vitest (avoid invalid hook call from nested node_modules) + react: path.resolve(__dirname, '../../node_modules/react'), + 'react-dom': path.resolve(__dirname, '../../node_modules/react-dom'), + 'react/jsx-runtime': path.resolve(__dirname, '../../node_modules/react/jsx-runtime.js'), + 'react/jsx-dev-runtime': path.resolve(__dirname, '../../node_modules/react/jsx-dev-runtime.js'), + 'react-dom/client': path.resolve(__dirname, '../../node_modules/react-dom/client.js'), }, extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'], }, diff --git a/ccw/src/core/a2ui/A2UITypes.ts b/ccw/src/core/a2ui/A2UITypes.ts index 2eff7a30..7df484e0 100644 --- a/ccw/src/core/a2ui/A2UITypes.ts +++ b/ccw/src/core/a2ui/A2UITypes.ts @@ -71,6 +71,7 @@ export type QuestionAnswer = z.infer; export const SimpleOptionSchema = z.object({ label: z.string(), description: z.string().optional(), + isDefault: z.boolean().optional(), }); export type SimpleOption = z.infer; @@ -114,6 +115,7 @@ export const AskQuestionResultSchema = z.object({ answers: z.array(QuestionAnswerSchema), timestamp: z.string(), error: z.string().optional(), + autoSelected: z.boolean().optional(), }); export type AskQuestionResult = z.infer; diff --git a/ccw/src/core/routes/hooks-routes.ts b/ccw/src/core/routes/hooks-routes.ts index e1feae6d..e7e9a406 100644 --- a/ccw/src/core/routes/hooks-routes.ts +++ b/ccw/src/core/routes/hooks-routes.ts @@ -8,6 +8,7 @@ import { homedir } from 'os'; import { spawn } from 'child_process'; import type { RouteContext } from './types.js'; +import { a2uiWebSocketHandler } from '../a2ui/A2UIWebSocketHandler.js'; interface HooksRouteContext extends RouteContext { extractSessionIdFromPath: (filePath: string) => string | null; @@ -313,6 +314,20 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise; + const questionId = initState.questionId as string | undefined; + const questionType = initState.questionType as string | undefined; + if (questionId && questionType === 'select') { + a2uiWebSocketHandler.initSingleSelect(questionId); + } else if (questionId && questionType === 'multi-select') { + a2uiWebSocketHandler.initMultiSelect(questionId); + } + } + broadcastToClients(notification); return { success: true, notification }; diff --git a/ccw/src/core/routes/notification-routes.ts b/ccw/src/core/routes/notification-routes.ts index 00d16b8e..8aa1cdcb 100644 --- a/ccw/src/core/routes/notification-routes.ts +++ b/ccw/src/core/routes/notification-routes.ts @@ -12,7 +12,7 @@ import { } from '../../config/remote-notification-config.js'; import { remoteNotificationService, -} from '../../services/remote-notification-service.js'; +} from '../services/remote-notification-service.js'; import { maskSensitiveConfig, type RemoteNotificationConfig, @@ -21,6 +21,10 @@ import { type DiscordConfig, type TelegramConfig, type WebhookConfig, + type FeishuConfig, + type DingTalkConfig, + type WeComConfig, + type EmailConfig, } from '../../types/remote-notification.js'; import { deepMerge } from '../../types/util.js'; @@ -110,13 +114,72 @@ function isValidHeaders(headers: unknown): { valid: boolean; error?: string } { return { valid: true }; } +/** + * Validate Feishu webhook URL format + */ +function isValidFeishuWebhookUrl(url: string): boolean { + if (!isValidUrl(url)) return false; + try { + const parsed = new URL(url); + // Feishu webhooks are typically: open.feishu.cn/open-apis/bot/v2/hook/{token} + // or: open.larksuite.com/open-apis/bot/v2/hook/{token} + const validHosts = ['open.feishu.cn', 'open.larksuite.com']; + return validHosts.includes(parsed.hostname) && parsed.pathname.includes('/bot/'); + } catch { + return false; + } +} + +/** + * Validate DingTalk webhook URL format + */ +function isValidDingTalkWebhookUrl(url: string): boolean { + if (!isValidUrl(url)) return false; + try { + const parsed = new URL(url); + // DingTalk webhooks are typically: oapi.dingtalk.com/robot/send?access_token=xxx + return parsed.hostname.includes('dingtalk.com') && parsed.pathname.includes('robot'); + } catch { + return false; + } +} + +/** + * Validate WeCom webhook URL format + */ +function isValidWeComWebhookUrl(url: string): boolean { + if (!isValidUrl(url)) return false; + try { + const parsed = new URL(url); + // WeCom webhooks are typically: qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx + return parsed.hostname.includes('qyapi.weixin.qq.com') && parsed.pathname.includes('webhook'); + } catch { + return false; + } +} + +/** + * Validate email address format + */ +function isValidEmail(email: string): boolean { + // Basic email validation regex + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +/** + * Validate SMTP port number + */ +function isValidSmtpPort(port: number): boolean { + return Number.isInteger(port) && port > 0 && port <= 65535; +} + /** * Validate configuration updates */ function validateConfigUpdates(updates: Partial): { valid: boolean; error?: string } { // Validate platforms if present if (updates.platforms) { - const { discord, telegram, webhook } = updates.platforms; + const { discord, telegram, webhook, feishu, dingtalk, wecom, email } = updates.platforms; // Validate Discord config if (discord) { @@ -165,6 +228,99 @@ function validateConfigUpdates(updates: Partial): { va return { valid: false, error: 'Webhook timeout must be between 1000ms and 60000ms' }; } } + + // Validate Feishu config + if (feishu) { + if (feishu.webhookUrl !== undefined && feishu.webhookUrl !== '') { + if (!isValidUrl(feishu.webhookUrl)) { + return { valid: false, error: 'Invalid Feishu webhook URL format' }; + } + if (!isValidFeishuWebhookUrl(feishu.webhookUrl)) { + console.warn('[RemoteNotification] Webhook URL does not match Feishu format'); + } + } + if (feishu.title !== undefined && feishu.title.length > 100) { + return { valid: false, error: 'Feishu title too long (max 100 chars)' }; + } + } + + // Validate DingTalk config + if (dingtalk) { + if (dingtalk.webhookUrl !== undefined && dingtalk.webhookUrl !== '') { + if (!isValidUrl(dingtalk.webhookUrl)) { + return { valid: false, error: 'Invalid DingTalk webhook URL format' }; + } + if (!isValidDingTalkWebhookUrl(dingtalk.webhookUrl)) { + console.warn('[RemoteNotification] Webhook URL does not match DingTalk format'); + } + } + if (dingtalk.keywords !== undefined) { + if (!Array.isArray(dingtalk.keywords)) { + return { valid: false, error: 'DingTalk keywords must be an array' }; + } + if (dingtalk.keywords.length > 10) { + return { valid: false, error: 'Too many DingTalk keywords (max 10)' }; + } + } + } + + // Validate WeCom config + if (wecom) { + if (wecom.webhookUrl !== undefined && wecom.webhookUrl !== '') { + if (!isValidUrl(wecom.webhookUrl)) { + return { valid: false, error: 'Invalid WeCom webhook URL format' }; + } + if (!isValidWeComWebhookUrl(wecom.webhookUrl)) { + console.warn('[RemoteNotification] Webhook URL does not match WeCom format'); + } + } + if (wecom.mentionedList !== undefined) { + if (!Array.isArray(wecom.mentionedList)) { + return { valid: false, error: 'WeCom mentionedList must be an array' }; + } + if (wecom.mentionedList.length > 100) { + return { valid: false, error: 'Too many mentioned users (max 100)' }; + } + } + } + + // Validate Email config + if (email) { + if (email.host !== undefined && email.host !== '') { + if (email.host.length > 255) { + return { valid: false, error: 'Email host too long (max 255 chars)' }; + } + } + if (email.port !== undefined) { + if (!isValidSmtpPort(email.port)) { + return { valid: false, error: 'Invalid SMTP port (must be 1-65535)' }; + } + } + if (email.username !== undefined && email.username.length > 255) { + return { valid: false, error: 'Email username too long (max 255 chars)' }; + } + if (email.from !== undefined && email.from !== '') { + if (!isValidEmail(email.from)) { + return { valid: false, error: 'Invalid sender email address' }; + } + } + if (email.to !== undefined) { + if (!Array.isArray(email.to)) { + return { valid: false, error: 'Email recipients must be an array' }; + } + if (email.to.length === 0) { + return { valid: false, error: 'At least one email recipient is required' }; + } + if (email.to.length > 50) { + return { valid: false, error: 'Too many email recipients (max 50)' }; + } + for (const addr of email.to) { + if (!isValidEmail(addr)) { + return { valid: false, error: `Invalid email address: ${addr}` }; + } + } + } + } } // Validate timeout @@ -183,7 +339,7 @@ function validateTestRequest(request: TestNotificationRequest): { valid: boolean return { valid: false, error: 'Missing platform' }; } - const validPlatforms: NotificationPlatform[] = ['discord', 'telegram', 'webhook']; + const validPlatforms: NotificationPlatform[] = ['discord', 'telegram', 'webhook', 'feishu', 'dingtalk', 'wecom', 'email']; if (!validPlatforms.includes(request.platform as NotificationPlatform)) { return { valid: false, error: `Invalid platform: ${request.platform}` }; } @@ -236,6 +392,66 @@ function validateTestRequest(request: TestNotificationRequest): { valid: boolean } break; } + case 'feishu': { + const config = request.config as Partial; + if (!config.webhookUrl) { + return { valid: false, error: 'Feishu webhook URL is required' }; + } + if (!isValidUrl(config.webhookUrl)) { + return { valid: false, error: 'Invalid Feishu webhook URL format' }; + } + break; + } + case 'dingtalk': { + const config = request.config as Partial; + if (!config.webhookUrl) { + return { valid: false, error: 'DingTalk webhook URL is required' }; + } + if (!isValidUrl(config.webhookUrl)) { + return { valid: false, error: 'Invalid DingTalk webhook URL format' }; + } + break; + } + case 'wecom': { + const config = request.config as Partial; + if (!config.webhookUrl) { + return { valid: false, error: 'WeCom webhook URL is required' }; + } + if (!isValidUrl(config.webhookUrl)) { + return { valid: false, error: 'Invalid WeCom webhook URL format' }; + } + break; + } + case 'email': { + const config = request.config as Partial; + if (!config.host) { + return { valid: false, error: 'SMTP host is required' }; + } + if (!config.username) { + return { valid: false, error: 'SMTP username is required' }; + } + if (!config.password) { + return { valid: false, error: 'SMTP password is required' }; + } + if (!config.from) { + return { valid: false, error: 'Sender email address is required' }; + } + if (!isValidEmail(config.from)) { + return { valid: false, error: 'Invalid sender email address' }; + } + if (!config.to || config.to.length === 0) { + return { valid: false, error: 'At least one recipient email is required' }; + } + for (const addr of config.to) { + if (!isValidEmail(addr)) { + return { valid: false, error: `Invalid recipient email: ${addr}` }; + } + } + if (config.port !== undefined && !isValidSmtpPort(config.port)) { + return { valid: false, error: 'Invalid SMTP port' }; + } + break; + } } return { valid: true }; diff --git a/ccw/src/core/services/remote-notification-service.ts b/ccw/src/core/services/remote-notification-service.ts index 4d6bdf08..12c32074 100644 --- a/ccw/src/core/services/remote-notification-service.ts +++ b/ccw/src/core/services/remote-notification-service.ts @@ -16,6 +16,10 @@ import type { DiscordConfig, TelegramConfig, WebhookConfig, + FeishuConfig, + DingTalkConfig, + WeComConfig, + EmailConfig, } from '../../types/remote-notification.js'; import { loadConfig, @@ -170,6 +174,14 @@ class RemoteNotificationService { return await this.sendTelegram(context, config.platforms.telegram!, config.timeout); case 'webhook': return await this.sendWebhook(context, config.platforms.webhook!, config.timeout); + case 'feishu': + return await this.sendFeishu(context, config.platforms.feishu!, config.timeout); + case 'dingtalk': + return await this.sendDingTalk(context, config.platforms.dingtalk!, config.timeout); + case 'wecom': + return await this.sendWeCom(context, config.platforms.wecom!, config.timeout); + case 'email': + return await this.sendEmail(context, config.platforms.email!, config.timeout); default: return { platform, @@ -408,6 +420,538 @@ class RemoteNotificationService { } } + /** + * Send Feishu notification via webhook + * Supports both rich card format and simple text format + */ + private async sendFeishu( + context: NotificationContext, + config: FeishuConfig, + timeout: number + ): Promise { + const startTime = Date.now(); + + if (!config.webhookUrl) { + return { platform: 'feishu', success: false, error: 'Webhook URL not configured' }; + } + + const useCard = config.useCard !== false; // Default to true + + try { + let body: unknown; + + if (useCard) { + // Rich card format + const card = this.buildFeishuCard(context, config); + body = { + msg_type: 'interactive', + card, + }; + } else { + // Simple text format + const text = this.buildFeishuText(context); + body = { + msg_type: 'post', + content: { + post: { + zh_cn: { + title: config.title || 'CCW Notification', + content: [[{ tag: 'text', text }]], + }, + }, + }, + }; + } + + await this.httpRequest(config.webhookUrl, body, timeout); + return { + platform: 'feishu', + success: true, + responseTime: Date.now() - startTime, + }; + } catch (error) { + return { + platform: 'feishu', + success: false, + error: error instanceof Error ? error.message : String(error), + responseTime: Date.now() - startTime, + }; + } + } + + /** + * Build Feishu interactive card from context + */ + private buildFeishuCard(context: NotificationContext, config: FeishuConfig): Record { + const elements: Array> = []; + + // Add event type as header + elements.push({ + tag: 'markdown', + content: `**${this.formatEventName(context.eventType)}**`, + text_align: 'left' as const, + text_size: 'normal_v2' as const, + }); + + // Add session info + if (context.sessionId) { + elements.push({ + tag: 'markdown', + content: `**Session:** ${context.sessionId.slice(0, 16)}...`, + text_align: 'left' as const, + text_size: 'normal_v2' as const, + }); + } + + // Add question text + if (context.questionText) { + const truncated = context.questionText.length > 300 + ? context.questionText.slice(0, 300) + '...' + : context.questionText; + elements.push({ + tag: 'markdown', + content: `**Question:** ${this.escapeFeishuMarkdown(truncated)}`, + text_align: 'left' as const, + text_size: 'normal_v2' as const, + }); + } + + // Add task description + if (context.taskDescription) { + const truncated = context.taskDescription.length > 300 + ? context.taskDescription.slice(0, 300) + '...' + : context.taskDescription; + elements.push({ + tag: 'markdown', + content: `**Task:** ${this.escapeFeishuMarkdown(truncated)}`, + text_align: 'left' as const, + text_size: 'normal_v2' as const, + }); + } + + // Add error message + if (context.errorMessage) { + const truncated = context.errorMessage.length > 300 + ? context.errorMessage.slice(0, 300) + '...' + : context.errorMessage; + elements.push({ + tag: 'markdown', + content: `**Error:** ${this.escapeFeishuMarkdown(truncated)}`, + text_align: 'left' as const, + text_size: 'normal_v2' as const, + }); + } + + // Add timestamp + elements.push({ + tag: 'markdown', + content: `**Time:** ${new Date(context.timestamp).toLocaleString()}`, + text_align: 'left' as const, + text_size: 'normal_v2' as const, + }); + + return { + schema: '2.0', + config: { + update_multi: true, + style: { + text_size: { + normal_v2: { + default: 'normal', + pc: 'normal', + mobile: 'heading', + }, + }, + }, + }, + header: { + title: { + tag: 'plain_text', + content: config.title || 'CCW Notification', + }, + template: 'wathet', + padding: '12px 12px 12px 12px', + }, + body: { + direction: 'vertical', + horizontal_spacing: '8px', + vertical_spacing: '8px', + horizontal_align: 'left', + vertical_align: 'top', + padding: '12px 12px 12px 12px', + elements, + }, + }; + } + + /** + * Build Feishu simple text message + */ + private buildFeishuText(context: NotificationContext): string { + const lines: string[] = []; + lines.push(`Event: ${this.formatEventName(context.eventType)}`); + + if (context.sessionId) { + lines.push(`Session: ${context.sessionId.slice(0, 16)}...`); + } + if (context.questionText) { + const truncated = context.questionText.length > 200 + ? context.questionText.slice(0, 200) + '...' + : context.questionText; + lines.push(`Question: ${truncated}`); + } + if (context.taskDescription) { + const truncated = context.taskDescription.length > 200 + ? context.taskDescription.slice(0, 200) + '...' + : context.taskDescription; + lines.push(`Task: ${truncated}`); + } + if (context.errorMessage) { + const truncated = context.errorMessage.length > 200 + ? context.errorMessage.slice(0, 200) + '...' + : context.errorMessage; + lines.push(`Error: ${truncated}`); + } + lines.push(`Time: ${new Date(context.timestamp).toLocaleString()}`); + + return lines.join('\n'); + } + + /** + * Escape special characters for Feishu markdown + */ + private escapeFeishuMarkdown(text: string): string { + return text + .replace(//g, '>'); + } + + /** + * Send DingTalk notification via webhook + */ + private async sendDingTalk( + context: NotificationContext, + config: DingTalkConfig, + timeout: number + ): Promise { + const startTime = Date.now(); + + if (!config.webhookUrl) { + return { platform: 'dingtalk', success: false, error: 'Webhook URL not configured' }; + } + + const text = this.buildDingTalkText(context, config.keywords); + + const body = { + msgtype: 'text', + text: { + content: text, + }, + }; + + try { + await this.httpRequest(config.webhookUrl, body, timeout); + return { + platform: 'dingtalk', + success: true, + responseTime: Date.now() - startTime, + }; + } catch (error) { + return { + platform: 'dingtalk', + success: false, + error: error instanceof Error ? error.message : String(error), + responseTime: Date.now() - startTime, + }; + } + } + + /** + * Build DingTalk text message + */ + private buildDingTalkText(context: NotificationContext, keywords?: string[]): string { + const lines: string[] = []; + + // Add keywords at the beginning if configured (for security check) + if (keywords && keywords.length > 0) { + lines.push(`[${keywords[0]}]`); + } + + lines.push(`Event: ${this.formatEventName(context.eventType)}`); + + if (context.sessionId) { + lines.push(`Session: ${context.sessionId.slice(0, 16)}...`); + } + if (context.questionText) { + const truncated = context.questionText.length > 200 + ? context.questionText.slice(0, 200) + '...' + : context.questionText; + lines.push(`Question: ${truncated}`); + } + if (context.taskDescription) { + const truncated = context.taskDescription.length > 200 + ? context.taskDescription.slice(0, 200) + '...' + : context.taskDescription; + lines.push(`Task: ${truncated}`); + } + if (context.errorMessage) { + const truncated = context.errorMessage.length > 200 + ? context.errorMessage.slice(0, 200) + '...' + : context.errorMessage; + lines.push(`Error: ${truncated}`); + } + lines.push(`Time: ${new Date(context.timestamp).toLocaleString()}`); + + return lines.join('\n'); + } + + /** + * Send WeCom (WeChat Work) notification via webhook + */ + private async sendWeCom( + context: NotificationContext, + config: WeComConfig, + timeout: number + ): Promise { + const startTime = Date.now(); + + if (!config.webhookUrl) { + return { platform: 'wecom', success: false, error: 'Webhook URL not configured' }; + } + + const markdown = this.buildWeComMarkdown(context); + + const body: Record = { + msgtype: 'markdown', + markdown: { + content: markdown, + }, + }; + + // Add mentioned list if configured + if (config.mentionedList && config.mentionedList.length > 0) { + body.text = { + content: markdown, + mentioned_list: config.mentionedList, + }; + } + + try { + await this.httpRequest(config.webhookUrl, body, timeout); + return { + platform: 'wecom', + success: true, + responseTime: Date.now() - startTime, + }; + } catch (error) { + return { + platform: 'wecom', + success: false, + error: error instanceof Error ? error.message : String(error), + responseTime: Date.now() - startTime, + }; + } + } + + /** + * Build WeCom markdown message + */ + private buildWeComMarkdown(context: NotificationContext): string { + const lines: string[] = []; + lines.push(`### ${this.formatEventName(context.eventType)}`); + lines.push(''); + + if (context.sessionId) { + lines.push(`> Session: \`${context.sessionId.slice(0, 16)}...\``); + } + if (context.questionText) { + const truncated = context.questionText.length > 200 + ? context.questionText.slice(0, 200) + '...' + : context.questionText; + lines.push(`**Question:** ${truncated}`); + } + if (context.taskDescription) { + const truncated = context.taskDescription.length > 200 + ? context.taskDescription.slice(0, 200) + '...' + : context.taskDescription; + lines.push(`**Task:** ${truncated}`); + } + if (context.errorMessage) { + const truncated = context.errorMessage.length > 200 + ? context.errorMessage.slice(0, 200) + '...' + : context.errorMessage; + lines.push(`**Error:** ${truncated}`); + } + lines.push(''); + lines.push(`Time: ${new Date(context.timestamp).toLocaleString()}`); + + return lines.join('\n'); + } + + /** + * Send Email notification via SMTP + */ + private async sendEmail( + context: NotificationContext, + config: EmailConfig, + timeout: number + ): Promise { + const startTime = Date.now(); + + if (!config.host || !config.username || !config.password || !config.from || !config.to || config.to.length === 0) { + return { platform: 'email', success: false, error: 'Email configuration incomplete (host, username, password, from, to required)' }; + } + + try { + // Dynamic import for nodemailer (optional dependency) + const nodemailer = await this.loadNodemailer(); + + const transporter = nodemailer.createTransport({ + host: config.host, + port: config.port || 465, + secure: config.secure !== false, // Default to true for port 465 + auth: { + user: config.username, + pass: config.password, + }, + }); + + const { subject, html } = this.buildEmailContent(context); + + // Set timeout for email sending + await Promise.race([ + transporter.sendMail({ + from: config.from, + to: config.to.join(', '), + subject, + html, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Email send timeout')), timeout) + ), + ]); + + return { + platform: 'email', + success: true, + responseTime: Date.now() - startTime, + }; + } catch (error) { + return { + platform: 'email', + success: false, + error: error instanceof Error ? error.message : String(error), + responseTime: Date.now() - startTime, + }; + } + } + + /** + * Load nodemailer module (optional dependency) + */ + private async loadNodemailer(): Promise<{ + createTransport: (options: Record) => { + sendMail: (mailOptions: Record) => Promise; + }; + }> { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('nodemailer'); + } catch { + throw new Error('nodemailer not installed. Run: npm install nodemailer'); + } + } + + /** + * Build email subject and HTML content + */ + private buildEmailContent(context: NotificationContext): { subject: string; html: string } { + const subject = `[CCW] ${this.formatEventName(context.eventType)}`; + + const htmlParts: string[] = []; + htmlParts.push(''); + htmlParts.push(''); + htmlParts.push(''); + htmlParts.push(''); + htmlParts.push(''); + htmlParts.push(''); + htmlParts.push(''); + htmlParts.push('
'); + + // Header + htmlParts.push('
'); + htmlParts.push(`

${this.formatEventName(context.eventType)}

`); + htmlParts.push('
'); + + // Content + htmlParts.push('
'); + + if (context.sessionId) { + htmlParts.push('
'); + htmlParts.push('
Session
'); + htmlParts.push(`
${context.sessionId}
`); + htmlParts.push('
'); + } + + if (context.questionText) { + const truncated = context.questionText.length > 500 + ? context.questionText.slice(0, 500) + '...' + : context.questionText; + htmlParts.push('
'); + htmlParts.push('
Question
'); + htmlParts.push(`
${this.escapeHtml(truncated).replace(/\n/g, '
')}
`); + htmlParts.push('
'); + } + + if (context.taskDescription) { + const truncated = context.taskDescription.length > 500 + ? context.taskDescription.slice(0, 500) + '...' + : context.taskDescription; + htmlParts.push('
'); + htmlParts.push('
Task
'); + htmlParts.push(`
${this.escapeHtml(truncated).replace(/\n/g, '
')}
`); + htmlParts.push('
'); + } + + if (context.errorMessage) { + const truncated = context.errorMessage.length > 500 + ? context.errorMessage.slice(0, 500) + '...' + : context.errorMessage; + htmlParts.push('
'); + htmlParts.push('
Error
'); + htmlParts.push(`
${this.escapeHtml(truncated).replace(/\n/g, '
')}
`); + htmlParts.push('
'); + } + + htmlParts.push('
'); + htmlParts.push('
Timestamp
'); + htmlParts.push(`
${new Date(context.timestamp).toLocaleString()}
`); + htmlParts.push('
'); + + htmlParts.push('
'); // content + + // Footer + htmlParts.push(''); + + htmlParts.push('
'); // container + htmlParts.push(''); + htmlParts.push(''); + + return { subject, html: htmlParts.join('\n') }; + } + /** * Check if a URL is safe from SSRF attacks * Blocks private IP ranges, loopback, and link-local addresses @@ -556,7 +1100,7 @@ class RemoteNotificationService { */ async testPlatform( platform: NotificationPlatform, - config: DiscordConfig | TelegramConfig | WebhookConfig + config: DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig ): Promise<{ success: boolean; error?: string; responseTime?: number }> { const testContext: NotificationContext = { eventType: 'task-completed', @@ -575,6 +1119,14 @@ class RemoteNotificationService { return await this.sendTelegram(testContext, config as TelegramConfig, 10000); case 'webhook': return await this.sendWebhook(testContext, config as WebhookConfig, 10000); + case 'feishu': + return await this.sendFeishu(testContext, config as FeishuConfig, 10000); + case 'dingtalk': + return await this.sendDingTalk(testContext, config as DingTalkConfig, 10000); + case 'wecom': + return await this.sendWeCom(testContext, config as WeComConfig, 10000); + case 'email': + return await this.sendEmail(testContext, config as EmailConfig, 30000); // Longer timeout for email default: return { success: false, error: `Unknown platform: ${platform}` }; } diff --git a/ccw/src/tools/ask-question.ts b/ccw/src/tools/ask-question.ts index 86430cf4..669f253f 100644 --- a/ccw/src/tools/ask-question.ts +++ b/ccw/src/tools/ask-question.ts @@ -163,17 +163,22 @@ function normalizeSimpleQuestion(simple: SimpleQuestion): Question { type = 'input'; } - const options: QuestionOption[] | undefined = simple.options?.map((opt) => ({ - value: opt.label, - label: opt.label, - description: opt.description, - })); + let defaultValue: string | undefined; + const options: QuestionOption[] | undefined = simple.options?.map((opt) => { + const isDefault = opt.isDefault === true + || /\(Recommended\)/i.test(opt.label); + if (isDefault && !defaultValue) { + defaultValue = opt.label; + } + return { value: opt.label, label: opt.label, description: opt.description }; + }); return { id: simple.header, type, title: simple.question, options, + ...(defaultValue !== undefined && { defaultValue }), } as Question; } @@ -192,7 +197,7 @@ function isSimpleFormat(params: Record): params is { questions: * @param surfaceId - Surface ID for the question * @returns A2UI surface update object */ -function generateQuestionSurface(question: Question, surfaceId: string): { +function generateQuestionSurface(question: Question, surfaceId: string, timeoutMs: number): { surfaceUpdate: { surfaceId: string; components: unknown[]; @@ -274,6 +279,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): { label: { literalString: opt.label }, value: opt.value, description: opt.description ? { literalString: opt.description } : undefined, + isDefault: question.defaultValue !== undefined && opt.value === String(question.defaultValue), })) || []; // Add "Other" option for custom input @@ -281,6 +287,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): { label: { literalString: 'Other' }, value: '__other__', description: { literalString: 'Provide a custom answer' }, + isDefault: false, }); // Use RadioGroup for direct selection display (not dropdown) @@ -411,6 +418,8 @@ function generateQuestionSurface(question: Question, surfaceId: string): { questionType: question.type, options: question.options, required: question.required, + timeoutAt: new Date(Date.now() + timeoutMs).toISOString(), + ...(question.defaultValue !== undefined && { defaultValue: question.defaultValue }), }, /** Display mode: 'popup' for centered dialog (interactive questions) */ displayMode: 'popup' as const, @@ -451,20 +460,31 @@ export async function execute(params: AskQuestionParams): Promise { if (pendingQuestions.has(question.id)) { pendingQuestions.delete(question.id); - resolve({ - success: false, - surfaceId, - cancelled: false, - answers: [], - timestamp: new Date().toISOString(), - error: 'Question timed out', - }); + if (question.defaultValue !== undefined) { + resolve({ + success: true, + surfaceId, + cancelled: false, + answers: [{ questionId: question.id, value: question.defaultValue as string | string[] | boolean, cancelled: false }], + timestamp: new Date().toISOString(), + autoSelected: true, + }); + } else { + resolve({ + success: false, + surfaceId, + cancelled: false, + answers: [], + timestamp: new Date().toISOString(), + error: 'Question timed out', + }); + } } }, params.timeout || DEFAULT_TIMEOUT_MS); }); // Send A2UI surface via WebSocket to frontend - const a2uiSurface = generateQuestionSurface(question, surfaceId); + const a2uiSurface = generateQuestionSurface(question, surfaceId, params.timeout || DEFAULT_TIMEOUT_MS); const sentCount = a2uiWebSocketHandler.sendSurface(a2uiSurface.surfaceUpdate); // Trigger remote notification for ask-user-question event (if enabled) @@ -594,9 +614,17 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v if (isComposite && Array.isArray(parsed.answers)) { const ok = handleMultiAnswer(questionId, parsed.answers as QuestionAnswer[]); console.error(`[A2UI-Poll] handleMultiAnswer result: ${ok}`); + if (!ok && pendingQuestions.has(questionId)) { + // Answer consumed but delivery failed; keep polling for a new answer + setTimeout(poll, POLL_INTERVAL_MS); + } } else if (!isComposite && parsed.answer) { const ok = handleAnswer(parsed.answer as QuestionAnswer); console.error(`[A2UI-Poll] handleAnswer result: ${ok}`); + if (!ok && pendingQuestions.has(questionId)) { + // Answer consumed but validation/delivery failed; keep polling for a new answer + setTimeout(poll, POLL_INTERVAL_MS); + } } else { console.error(`[A2UI-Poll] Unexpected response shape, keep polling`); setTimeout(poll, POLL_INTERVAL_MS); @@ -873,6 +901,7 @@ function generateMultiQuestionSurface( label: { literalString: opt.label }, value: opt.value, description: opt.description ? { literalString: opt.description } : undefined, + isDefault: question.defaultValue !== undefined && opt.value === String(question.defaultValue), })) || []; // Add "Other" option for custom input @@ -880,6 +909,7 @@ function generateMultiQuestionSurface( label: { literalString: 'Other' }, value: '__other__', description: { literalString: 'Provide a custom answer' }, + isDefault: false, }); components.push({ @@ -997,7 +1027,8 @@ async function executeSimpleFormat( return result; } - if (result.result.cancelled) { + // Propagate inner failures (e.g. timeout) — don't mask them as success + if (result.result.cancelled || !result.result.success) { return result; } @@ -1058,14 +1089,33 @@ async function executeSimpleFormat( setTimeout(() => { if (pendingQuestions.has(compositeId)) { pendingQuestions.delete(compositeId); - resolve({ - success: false, - surfaceId, - cancelled: false, - answers: [], - timestamp: new Date().toISOString(), - error: 'Question timed out', - }); + // Collect default values from each sub-question + const defaultAnswers: QuestionAnswer[] = []; + for (const simpleQ of questions) { + const q = normalizeSimpleQuestion(simpleQ); + if (q.defaultValue !== undefined) { + defaultAnswers.push({ questionId: q.id, value: q.defaultValue as string | string[] | boolean, cancelled: false }); + } + } + if (defaultAnswers.length > 0) { + resolve({ + success: true, + surfaceId, + cancelled: false, + answers: defaultAnswers, + timestamp: new Date().toISOString(), + autoSelected: true, + }); + } else { + resolve({ + success: false, + surfaceId, + cancelled: false, + answers: [], + timestamp: new Date().toISOString(), + error: 'Question timed out', + }); + } } }, timeout ?? DEFAULT_TIMEOUT_MS); }); diff --git a/ccw/src/types/remote-notification.ts b/ccw/src/types/remote-notification.ts index c7968131..cec678f7 100644 --- a/ccw/src/types/remote-notification.ts +++ b/ccw/src/types/remote-notification.ts @@ -2,12 +2,12 @@ // Remote Notification Types // ======================================== // Type definitions for remote notification system -// Supports Discord, Telegram, and Generic Webhook platforms +// Supports Discord, Telegram, Feishu, DingTalk, WeCom, Email, and Generic Webhook platforms /** * Supported notification platforms */ -export type NotificationPlatform = 'discord' | 'telegram' | 'webhook'; +export type NotificationPlatform = 'discord' | 'telegram' | 'feishu' | 'dingtalk' | 'wecom' | 'email' | 'webhook'; /** * Event types that can trigger notifications @@ -47,6 +47,66 @@ export interface TelegramConfig { parseMode?: 'HTML' | 'Markdown' | 'MarkdownV2'; } +/** + * Feishu (Lark) platform configuration + */ +export interface FeishuConfig { + /** Whether Feishu notifications are enabled */ + enabled: boolean; + /** Feishu webhook URL */ + webhookUrl: string; + /** Use rich card format (default: true) */ + useCard?: boolean; + /** Custom title for notifications */ + title?: string; +} + +/** + * DingTalk platform configuration + */ +export interface DingTalkConfig { + /** Whether DingTalk notifications are enabled */ + enabled: boolean; + /** DingTalk webhook URL */ + webhookUrl: string; + /** Optional keywords for security check */ + keywords?: string[]; +} + +/** + * WeCom (WeChat Work) platform configuration + */ +export interface WeComConfig { + /** Whether WeCom notifications are enabled */ + enabled: boolean; + /** WeCom webhook URL */ + webhookUrl: string; + /** Mentioned user IDs (@all for all members) */ + mentionedList?: string[]; +} + +/** + * Email SMTP platform configuration + */ +export interface EmailConfig { + /** Whether Email notifications are enabled */ + enabled: boolean; + /** SMTP server host */ + host: string; + /** SMTP server port */ + port: number; + /** Use secure connection (TLS) */ + secure?: boolean; + /** SMTP username */ + username: string; + /** SMTP password */ + password: string; + /** Sender email address */ + from: string; + /** Recipient email addresses */ + to: string[]; +} + /** * Generic Webhook platform configuration */ @@ -85,6 +145,10 @@ export interface RemoteNotificationConfig { platforms: { discord?: DiscordConfig; telegram?: TelegramConfig; + feishu?: FeishuConfig; + dingtalk?: DingTalkConfig; + wecom?: WeComConfig; + email?: EmailConfig; webhook?: WebhookConfig; }; /** Event-to-platform mappings */ @@ -192,6 +256,22 @@ export function maskSensitiveConfig(config: RemoteNotificationConfig): RemoteNot ...config.platforms.telegram, botToken: maskToken(config.platforms.telegram.botToken), } : undefined, + feishu: config.platforms.feishu ? { + ...config.platforms.feishu, + webhookUrl: maskWebhookUrl(config.platforms.feishu.webhookUrl), + } : undefined, + dingtalk: config.platforms.dingtalk ? { + ...config.platforms.dingtalk, + webhookUrl: maskWebhookUrl(config.platforms.dingtalk.webhookUrl), + } : undefined, + wecom: config.platforms.wecom ? { + ...config.platforms.wecom, + webhookUrl: maskWebhookUrl(config.platforms.wecom.webhookUrl), + } : undefined, + email: config.platforms.email ? { + ...config.platforms.email, + password: maskToken(config.platforms.email.password), + } : undefined, webhook: config.platforms.webhook ? { ...config.platforms.webhook, // Don't mask webhook URL as it's needed for display diff --git a/ccw/tsconfig.tsbuildinfo b/ccw/tsconfig.tsbuildinfo new file mode 100644 index 00000000..cb4f03a5 --- /dev/null +++ b/ccw/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/cli.ts","./src/index.ts","./src/commands/cli.ts","./src/commands/core-memory.ts","./src/commands/hook.ts","./src/commands/install.ts","./src/commands/issue.ts","./src/commands/list.ts","./src/commands/loop.ts","./src/commands/memory.ts","./src/commands/serve.ts","./src/commands/session-path-resolver.ts","./src/commands/session.ts","./src/commands/stop.ts","./src/commands/team.ts","./src/commands/tool.ts","./src/commands/uninstall.ts","./src/commands/upgrade.ts","./src/commands/view.ts","./src/commands/workflow.ts","./src/config/cli-settings-manager.ts","./src/config/litellm-api-config-manager.ts","./src/config/litellm-provider-models.ts","./src/config/provider-models.ts","./src/config/remote-notification-config.ts","./src/config/storage-paths.ts","./src/core/cache-manager.ts","./src/core/claude-freshness.ts","./src/core/core-memory-store.ts","./src/core/cors.ts","./src/core/data-aggregator.ts","./src/core/history-importer.ts","./src/core/lite-scanner-complete.ts","./src/core/lite-scanner.ts","./src/core/manifest.ts","./src/core/memory-consolidation-pipeline.ts","./src/core/memory-consolidation-prompts.ts","./src/core/memory-embedder-bridge.ts","./src/core/memory-extraction-pipeline.ts","./src/core/memory-extraction-prompts.ts","./src/core/memory-job-scheduler.ts","./src/core/memory-store.ts","./src/core/memory-v2-config.ts","./src/core/pattern-detector.ts","./src/core/server.ts","./src/core/session-clustering-service.ts","./src/core/session-scanner.ts","./src/core/unified-context-builder.ts","./src/core/unified-memory-service.ts","./src/core/unified-vector-index.ts","./src/core/websocket.ts","./src/core/a2ui/a2uitypes.ts","./src/core/a2ui/a2uiwebsockethandler.ts","./src/core/a2ui/index.ts","./src/core/auth/csrf-manager.ts","./src/core/auth/csrf-middleware.ts","./src/core/auth/middleware.ts","./src/core/auth/token-manager.ts","./src/core/routes/audit-routes.ts","./src/core/routes/auth-routes.ts","./src/core/routes/ccw-routes.ts","./src/core/routes/claude-routes.ts","./src/core/routes/cli-routes.ts","./src/core/routes/cli-sessions-routes.ts","./src/core/routes/cli-settings-routes.ts","./src/core/routes/codexlens-routes.ts","./src/core/routes/commands-routes.ts","./src/core/routes/config-routes.ts","./src/core/routes/core-memory-routes.ts","./src/core/routes/dashboard-routes.ts","./src/core/routes/discovery-routes.ts","./src/core/routes/files-routes.ts","./src/core/routes/graph-routes.ts","./src/core/routes/help-routes.ts","./src/core/routes/hooks-routes.ts","./src/core/routes/issue-routes.ts","./src/core/routes/litellm-api-routes.ts","./src/core/routes/litellm-routes.ts","./src/core/routes/loop-routes.ts","./src/core/routes/loop-v2-routes.ts","./src/core/routes/mcp-routes.ts","./src/core/routes/mcp-templates-db.ts","./src/core/routes/memory-routes.ts","./src/core/routes/nav-status-routes.ts","./src/core/routes/notification-routes.ts","./src/core/routes/orchestrator-routes.ts","./src/core/routes/provider-routes.ts","./src/core/routes/rules-routes.ts","./src/core/routes/session-routes.ts","./src/core/routes/skills-routes.ts","./src/core/routes/status-routes.ts","./src/core/routes/system-routes.ts","./src/core/routes/task-routes.ts","./src/core/routes/team-routes.ts","./src/core/routes/test-loop-routes.ts","./src/core/routes/types.ts","./src/core/routes/unified-memory-routes.ts","./src/core/routes/unsplash-routes.ts","./src/core/routes/codexlens/config-handlers.ts","./src/core/routes/codexlens/index-handlers.ts","./src/core/routes/codexlens/semantic-handlers.ts","./src/core/routes/codexlens/utils.ts","./src/core/routes/codexlens/watcher-handlers.ts","./src/core/services/api-key-tester.ts","./src/core/services/cli-session-audit.ts","./src/core/services/cli-session-command-builder.ts","./src/core/services/cli-session-manager.ts","./src/core/services/cli-session-mux.ts","./src/core/services/cli-session-policy.ts","./src/core/services/cli-session-share.ts","./src/core/services/config-backup.ts","./src/core/services/config-sync.ts","./src/core/services/flow-executor.ts","./src/core/services/health-check-service.ts","./src/core/services/rate-limiter.ts","./src/core/services/remote-notification-service.ts","./src/core/services/version-checker.ts","./src/mcp-server/index.ts","./src/tools/ask-question.ts","./src/tools/classify-folders.ts","./src/tools/claude-cli-tools.ts","./src/tools/cli-config-manager.ts","./src/tools/cli-executor-core.ts","./src/tools/cli-executor-state.ts","./src/tools/cli-executor-utils.ts","./src/tools/cli-executor.ts","./src/tools/cli-history-store.ts","./src/tools/cli-output-converter.ts","./src/tools/cli-prompt-builder.ts","./src/tools/codex-lens-lsp.ts","./src/tools/codex-lens.ts","./src/tools/command-registry.ts","./src/tools/context-cache-store.ts","./src/tools/context-cache.ts","./src/tools/convert-tokens-to-css.ts","./src/tools/core-memory.ts","./src/tools/detect-changed-modules.ts","./src/tools/discover-design-files.ts","./src/tools/edit-file.ts","./src/tools/generate-module-docs.ts","./src/tools/get-modules-by-depth.ts","./src/tools/index.ts","./src/tools/litellm-client.ts","./src/tools/litellm-executor.ts","./src/tools/loop-manager.ts","./src/tools/loop-state-manager.ts","./src/tools/loop-task-manager.ts","./src/tools/memory-update-queue.js","./src/tools/native-session-discovery.ts","./src/tools/notifier.ts","./src/tools/pattern-parser.ts","./src/tools/read-file.ts","./src/tools/read-many-files.ts","./src/tools/read-outline.ts","./src/tools/resume-strategy.ts","./src/tools/session-content-parser.ts","./src/tools/session-manager.ts","./src/tools/skill-context-loader.ts","./src/tools/smart-context.ts","./src/tools/smart-search.ts","./src/tools/storage-manager.ts","./src/tools/team-msg.ts","./src/tools/template-discovery.ts","./src/tools/ui-generate-preview.js","./src/tools/ui-instantiate-prototypes.js","./src/tools/update-module-claude.js","./src/tools/vscode-lsp.ts","./src/tools/write-file.ts","./src/types/cli-settings.ts","./src/types/config.ts","./src/types/index.ts","./src/types/litellm-api-config.ts","./src/types/loop.ts","./src/types/remote-notification.ts","./src/types/session.ts","./src/types/skill-types.ts","./src/types/tool.ts","./src/types/util.ts","./src/utils/browser-launcher.ts","./src/utils/codexlens-path.ts","./src/utils/db-loader.ts","./src/utils/exec-constants.ts","./src/utils/file-reader.ts","./src/utils/file-utils.ts","./src/utils/outline-parser.ts","./src/utils/outline-queries.ts","./src/utils/path-resolver.ts","./src/utils/path-validator.ts","./src/utils/project-root.ts","./src/utils/python-utils.ts","./src/utils/react-frontend.ts","./src/utils/secret-redactor.ts","./src/utils/security-validation.ts","./src/utils/shell-escape.ts","./src/utils/ui.ts","./src/utils/update-checker.ts","./src/utils/uv-manager.ts"],"errors":true,"version":"5.9.3"} \ No newline at end of file diff --git a/codex-lens/docs/CONFIGURATION.md b/codex-lens/docs/CONFIGURATION.md index 082ca933..f155c088 100644 --- a/codex-lens/docs/CONFIGURATION.md +++ b/codex-lens/docs/CONFIGURATION.md @@ -125,6 +125,13 @@ CODEXLENS_DEBUG=false "tool": "gemini", "timeout_ms": 300000, "batch_size": 5 + }, + "parsing": { + "use_astgrep": false + }, + "indexing": { + "static_graph_enabled": false, + "static_graph_relationship_types": ["imports", "inherits"] } } ``` @@ -167,6 +174,32 @@ CODEXLENS_DEBUG=false | `timeout_ms` | int | 超时时间 (毫秒) | | `batch_size` | int | 批处理大小 | +### Parsing 设置 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `use_astgrep` | bool | 优先使用 ast-grep 解析关系(实验性;当前主要用于 Python relationships) | + +### Indexing 设置(静态图) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `static_graph_enabled` | bool | 索引时将 relationships 写入全局 `global_relationships`,用于搜索阶段静态图扩展 | +| `static_graph_relationship_types` | array | 允许持久化的关系类型:`imports` / `inherits` / `calls` | + +**CLI 覆盖(单次运行,不写入 settings.json)**: + +```bash +# 索引时启用静态图 relationships + 使用 ast-grep(如果可用) +codexlens index init --use-astgrep --static-graph --static-graph-types imports,inherits,calls +``` + +**Search staged 静态图扩展(高级)**: + +```bash +codexlens search --cascade-strategy staged --staged-stage2-mode static_global_graph +``` + ## FastEmbed 模型配置文件 使用 `fastembed` 后端时的预定义模型: diff --git a/codex-lens/pyproject.toml b/codex-lens/pyproject.toml index 2349f43e..c5c85533 100644 --- a/codex-lens/pyproject.toml +++ b/codex-lens/pyproject.toml @@ -23,8 +23,8 @@ dependencies = [ "pathspec>=0.11", "watchdog>=3.0", # ast-grep for pattern-based AST matching (PyO3 bindings) - # Note: May have compatibility issues with Python 3.13 - "ast-grep-py>=0.3.0; python_version < '3.13'", + # ast-grep-py 0.40+ supports Python 3.13 + "ast-grep-py>=0.40.0", ] [project.optional-dependencies] diff --git a/codex-lens/src/codexlens/cli/commands.py b/codex-lens/src/codexlens/cli/commands.py index c1ed64cf..8435c299 100644 --- a/codex-lens/src/codexlens/cli/commands.py +++ b/codex-lens/src/codexlens/cli/commands.py @@ -126,6 +126,21 @@ def index_init( no_embeddings: bool = typer.Option(False, "--no-embeddings", help="Skip automatic embedding generation (if semantic deps installed)."), backend: Optional[str] = typer.Option(None, "--backend", "-b", help="Embedding backend: fastembed (local) or litellm (remote API). Defaults to settings.json config."), model: Optional[str] = typer.Option(None, "--model", "-m", help="Embedding model: profile name for fastembed or model name for litellm. Defaults to settings.json config."), + use_astgrep: Optional[bool] = typer.Option( + None, + "--use-astgrep/--no-use-astgrep", + help="Prefer ast-grep parsers when available (experimental). Overrides settings.json config.", + ), + static_graph: Optional[bool] = typer.Option( + None, + "--static-graph/--no-static-graph", + help="Persist global relationships during indexing for static graph expansion. Overrides settings.json config.", + ), + static_graph_types: Optional[str] = typer.Option( + None, + "--static-graph-types", + help="Comma-separated relationship types to persist: imports,inherits,calls. Overrides settings.json config.", + ), max_workers: int = typer.Option(1, "--max-workers", min=1, help="Max concurrent API calls for embedding generation. Recommended: 4-8 for litellm backend."), json_mode: bool = typer.Option(False, "--json", help="Output JSON response."), verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."), @@ -154,6 +169,33 @@ def index_init( # Fallback to settings.json config if CLI params not provided config.load_settings() # Ensure settings are loaded + + # Apply CLI overrides for parsing/indexing behavior + if use_astgrep is not None: + config.use_astgrep = bool(use_astgrep) + if static_graph is not None: + config.static_graph_enabled = bool(static_graph) + if static_graph_types is not None: + allowed = {"imports", "inherits", "calls"} + parsed = [ + t.strip().lower() + for t in static_graph_types.split(",") + if t.strip() + ] + invalid = [t for t in parsed if t not in allowed] + if invalid: + msg = ( + "Invalid --static-graph-types. Must be a comma-separated list of: " + f"{', '.join(sorted(allowed))}. Got: {invalid}" + ) + if json_mode: + print_json(success=False, error=msg) + else: + console.print(f"[red]Error:[/red] {msg}") + raise typer.Exit(code=1) + if parsed: + config.static_graph_relationship_types = parsed + actual_backend = backend or config.embedding_backend actual_model = model or config.embedding_model @@ -412,8 +454,10 @@ def watch( manager: WatcherManager | None = None try: + watch_config = Config.load() manager = WatcherManager( root_path=base_path, + config=watch_config, watcher_config=watcher_config, on_indexed=on_indexed, ) @@ -459,7 +503,7 @@ def search( None, "--staged-stage2-mode", hidden=True, - help="[Advanced] Stage 2 expansion mode for cascade strategy 'staged': precomputed | realtime.", + help="[Advanced] Stage 2 expansion mode for cascade strategy 'staged': precomputed | realtime | static_global_graph.", ), # Hidden deprecated parameter for backward compatibility mode: Optional[str] = typer.Option(None, "--mode", hidden=True, help="[DEPRECATED] Use --method instead."), @@ -615,8 +659,8 @@ def search( # Optional staged cascade overrides (only meaningful for cascade strategy 'staged') if staged_stage2_mode is not None: stage2 = staged_stage2_mode.strip().lower() - if stage2 not in {"precomputed", "realtime"}: - msg = "Invalid --staged-stage2-mode. Must be: precomputed | realtime." + if stage2 not in {"precomputed", "realtime", "static_global_graph"}: + msg = "Invalid --staged-stage2-mode. Must be: precomputed | realtime | static_global_graph." if json_mode: print_json(success=False, error=msg) else: @@ -810,7 +854,7 @@ def inspect( ) -> None: """Analyze a single file and display symbols.""" _configure_logging(verbose, json_mode) - config = Config() + config = Config.load() factory = ParserFactory(config) file_path = file.expanduser().resolve() @@ -3145,8 +3189,10 @@ def watch( console.print("[dim]Press Ctrl+C to stop[/dim]\n") # Create and start watcher manager + watch_config = Config.load() manager = WatcherManager( root_path=watch_path, + config=watch_config, watcher_config=watcher_config, on_indexed=lambda result: _display_index_result(result), ) @@ -3681,7 +3727,7 @@ def index_update( registry = RegistryStore() registry.initialize() mapper = PathMapper() - config = Config() + config = Config.load() resolved_path = file_path.resolve() @@ -3776,7 +3822,7 @@ def index_all( from codexlens.config import Config from codexlens.storage.index_tree import IndexTreeBuilder - config = Config() + config = Config.load() languages = _parse_languages(language) registry = RegistryStore() registry.initialize() diff --git a/codex-lens/src/codexlens/config.py b/codex-lens/src/codexlens/config.py index 84577184..77b5b055 100644 --- a/codex-lens/src/codexlens/config.py +++ b/codex-lens/src/codexlens/config.py @@ -294,6 +294,15 @@ class Config: "timeout_ms": self.llm_timeout_ms, "batch_size": self.llm_batch_size, }, + "parsing": { + # Prefer ast-grep processors when available (experimental). + "use_astgrep": self.use_astgrep, + }, + "indexing": { + # Persist global relationship edges during index build for static graph expansion. + "static_graph_enabled": self.static_graph_enabled, + "static_graph_relationship_types": self.static_graph_relationship_types, + }, "reranker": { "enabled": self.enable_cross_encoder_rerank, "backend": self.reranker_backend, @@ -413,6 +422,34 @@ class Config: if "fine_k" in cascade: self.cascade_fine_k = cascade["fine_k"] + # Load parsing settings + parsing = settings.get("parsing", {}) + if isinstance(parsing, dict) and "use_astgrep" in parsing: + self.use_astgrep = bool(parsing["use_astgrep"]) + + # Load indexing settings + indexing = settings.get("indexing", {}) + if isinstance(indexing, dict): + if "static_graph_enabled" in indexing: + self.static_graph_enabled = bool(indexing["static_graph_enabled"]) + if "static_graph_relationship_types" in indexing: + raw_types = indexing["static_graph_relationship_types"] + if isinstance(raw_types, list): + allowed = {"imports", "inherits", "calls"} + cleaned = [] + for item in raw_types: + val = str(item).strip().lower() + if val and val in allowed: + cleaned.append(val) + if cleaned: + self.static_graph_relationship_types = cleaned + else: + log.warning( + "Invalid indexing.static_graph_relationship_types in %s: %r (expected list)", + self.settings_path, + raw_types, + ) + # Load API settings api = settings.get("api", {}) if "max_workers" in api: diff --git a/codex-lens/src/codexlens/parsers/astgrep_processor.py b/codex-lens/src/codexlens/parsers/astgrep_processor.py index 2fac1cbb..e0358743 100644 --- a/codex-lens/src/codexlens/parsers/astgrep_processor.py +++ b/codex-lens/src/codexlens/parsers/astgrep_processor.py @@ -299,12 +299,25 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor): if func_name: all_matches.append((start_line, end_line, "func_def", func_name, node)) - # Get import matches + # Get import matches (process import_with_alias first to avoid duplicates) + import_alias_positions: set = set() + + # Process import with alias: import X as Y + import_alias_matches = self.run_ast_grep(source_code, get_pattern("import_with_alias")) + for node in import_alias_matches: + module = self._get_match(node, "MODULE") + alias = self._get_match(node, "ALIAS") + start_line, end_line = self._get_line_range(node) + if module and alias: + import_alias_positions.add(start_line) + all_matches.append((start_line, end_line, "import_alias", f"{module}:{alias}", node)) + + # Process simple imports: import X (skip lines with aliases) import_matches = self.run_ast_grep(source_code, get_pattern("import_stmt")) for node in import_matches: module = self._get_match(node, "MODULE") start_line, end_line = self._get_line_range(node) - if module: + if module and start_line not in import_alias_positions: all_matches.append((start_line, end_line, "import", module, node)) from_matches = self.run_ast_grep(source_code, get_pattern("import_from")) @@ -429,7 +442,7 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor): )) elif match_type == "import": - # Process import statement + # Process simple import statement module = symbol # Simple import: add base name to alias map base_name = module.split(".", 1)[0] @@ -443,6 +456,22 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor): source_line=start_line, )) + elif match_type == "import_alias": + # Process import with alias: import X as Y + parts = symbol.split(":", 1) + module = parts[0] + alias = parts[1] if len(parts) > 1 else "" + if alias: + update_aliases({alias: module}) + relationships.append(CodeRelationship( + source_symbol=get_current_scope(), + target_symbol=module, + relationship_type=RelationshipType.IMPORTS, + source_file=source_file, + target_file=None, + source_line=start_line, + )) + elif match_type == "from_import": # Process from-import statement parts = symbol.split(":", 1) @@ -647,6 +676,22 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor): return match.group(1).strip() return "" + def _extract_import_names_from_text(self, import_text: str) -> str: + """Extract imported names from from-import statement. + + Args: + import_text: Full text of import statement (e.g., "from typing import List, Dict") + + Returns: + Names text (e.g., "List, Dict") or empty string + """ + import re + # Match "from MODULE import NAMES" - extract NAMES + match = re.search(r'from\s+[\w.]+\s+import\s+(.+)$', import_text, re.MULTILINE) + if match: + return match.group(1).strip() + return "" + def extract_calls( self, source_code: str, @@ -736,16 +781,19 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor): relationships: List[CodeRelationship] = [] alias_map: Dict[str, str] = {} - # Process simple imports: import X - import_matches = self.run_ast_grep(source_code, get_pattern("import_stmt")) - for node in import_matches: + # Track processed lines to avoid duplicates + processed_lines: set = set() + + # Process import with alias FIRST: import X as Y + alias_matches = self.run_ast_grep(source_code, get_pattern("import_with_alias")) + for node in alias_matches: module = self._get_match(node, "MODULE") + alias = self._get_match(node, "ALIAS") line = self._get_line_number(node) - if module: - # Add to alias map: first part of module - base_name = module.split(".", 1)[0] - alias_map[base_name] = module + if module and alias: + alias_map[alias] = module + processed_lines.add(line) relationships.append(CodeRelationship( source_symbol=source_symbol, @@ -756,15 +804,16 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor): source_line=line, )) - # Process import with alias: import X as Y - alias_matches = self.run_ast_grep(source_code, get_pattern("import_with_alias")) - for node in alias_matches: + # Process simple imports: import X (skip lines already processed) + import_matches = self.run_ast_grep(source_code, get_pattern("import_stmt")) + for node in import_matches: module = self._get_match(node, "MODULE") - alias = self._get_match(node, "ALIAS") line = self._get_line_number(node) - if module and alias: - alias_map[alias] = module + if module and line not in processed_lines: + # Add to alias map: first part of module + base_name = module.split(".", 1)[0] + alias_map[base_name] = module relationships.append(CodeRelationship( source_symbol=source_symbol, @@ -779,7 +828,6 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor): from_matches = self.run_ast_grep(source_code, get_pattern("import_from")) for node in from_matches: module = self._get_match(node, "MODULE") - names = self._get_match(node, "NAMES") line = self._get_line_number(node) if module: @@ -793,6 +841,10 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor): source_line=line, )) + # Parse names from node text (ast-grep-py 0.40+ doesn't capture $$$ multi-match) + node_text = self._binding._get_node_text(node) if self._binding else "" + names = self._extract_import_names_from_text(node_text) + # Add aliases for imported names if names and names != "*": for name in names.split(","): diff --git a/codex-lens/src/codexlens/parsers/factory.py b/codex-lens/src/codexlens/parsers/factory.py index 0f8f4f14..5b07a4bc 100644 --- a/codex-lens/src/codexlens/parsers/factory.py +++ b/codex-lens/src/codexlens/parsers/factory.py @@ -24,11 +24,16 @@ class Parser(Protocol): @dataclass class SimpleRegexParser: language_id: str + config: Optional[Config] = None def parse(self, text: str, path: Path) -> IndexedFile: # Try tree-sitter first for supported languages if self.language_id in {"python", "javascript", "typescript"}: - ts_parser = TreeSitterSymbolParser(self.language_id, path) + ts_parser = TreeSitterSymbolParser( + self.language_id, + path, + config=self.config, + ) if ts_parser.is_available(): indexed = ts_parser.parse(text, path) if indexed is not None: @@ -73,7 +78,10 @@ class ParserFactory: def get_parser(self, language_id: str) -> Parser: if language_id not in self._parsers: - self._parsers[language_id] = SimpleRegexParser(language_id) + self._parsers[language_id] = SimpleRegexParser( + language_id, + config=self.config, + ) return self._parsers[language_id] diff --git a/codex-lens/src/codexlens/parsers/treesitter_parser.py b/codex-lens/src/codexlens/parsers/treesitter_parser.py index 34ef180f..019b6189 100644 --- a/codex-lens/src/codexlens/parsers/treesitter_parser.py +++ b/codex-lens/src/codexlens/parsers/treesitter_parser.py @@ -291,7 +291,9 @@ class TreeSitterSymbolParser: source_file = str(path.resolve()) relationships: List[CodeRelationship] = [] - scope_stack: List[str] = [] + # Use a synthetic module scope so module-level imports/calls can be recorded + # (useful for static global graph persistence). + scope_stack: List[str] = [""] alias_stack: List[Dict[str, str]] = [{}] def record_import(target_symbol: str, source_line: int) -> None: @@ -398,7 +400,9 @@ class TreeSitterSymbolParser: source_file = str(path.resolve()) relationships: List[CodeRelationship] = [] - scope_stack: List[str] = [] + # Use a synthetic module scope so module-level imports/calls can be recorded + # (useful for static global graph persistence). + scope_stack: List[str] = [""] alias_stack: List[Dict[str, str]] = [{}] def record_import(target_symbol: str, source_line: int) -> None: diff --git a/codex-lens/src/codexlens/storage/index_tree.py b/codex-lens/src/codexlens/storage/index_tree.py index 8f61eb74..696ec573 100644 --- a/codex-lens/src/codexlens/storage/index_tree.py +++ b/codex-lens/src/codexlens/storage/index_tree.py @@ -519,6 +519,7 @@ class IndexTreeBuilder: "global_symbol_index_enabled": self.config.global_symbol_index_enabled, "static_graph_enabled": self.config.static_graph_enabled, "static_graph_relationship_types": self.config.static_graph_relationship_types, + "use_astgrep": getattr(self.config, "use_astgrep", False), } worker_args = [ @@ -984,6 +985,7 @@ def _build_dir_worker(args: tuple) -> DirBuildResult: global_symbol_index_enabled=bool(config_dict.get("global_symbol_index_enabled", True)), static_graph_enabled=bool(config_dict.get("static_graph_enabled", False)), static_graph_relationship_types=list(config_dict.get("static_graph_relationship_types", ["imports", "inherits"])), + use_astgrep=bool(config_dict.get("use_astgrep", False)), ) parser_factory = ParserFactory(config) diff --git a/codex-lens/src/codexlens/watcher/incremental_indexer.py b/codex-lens/src/codexlens/watcher/incremental_indexer.py index 9991c5fc..39888115 100644 --- a/codex-lens/src/codexlens/watcher/incremental_indexer.py +++ b/codex-lens/src/codexlens/watcher/incremental_indexer.py @@ -89,7 +89,18 @@ class IncrementalIndexer: project_info = self.registry.get_project(source_root) if project_info: project_id = project_info.id - self._global_index = GlobalSymbolIndex(global_db_path, project_id=project_id) + try: + self._global_index = GlobalSymbolIndex(global_db_path, project_id=project_id) + # Ensure schema exists (best-effort). The DB should already be initialized + # by `codexlens index init`, but watcher/index-update should be robust. + self._global_index.initialize() + except Exception as exc: + logger.debug( + "Failed to initialize global symbol index at %s: %s", + global_db_path, + exc, + ) + self._global_index = None return self._global_index @@ -262,6 +273,34 @@ class IncrementalIndexer: # Update merkle root store.update_merkle_root() + # Update global relationships for static graph expansion (best-effort). + if getattr(self.config, "static_graph_enabled", False): + try: + source_root = self.mapper.get_project_root(path) or dir_path + index_root = self.mapper.source_to_index_dir(source_root) + global_index = self._get_global_index(index_root, source_root=source_root) + if global_index is not None: + allowed_types = set( + getattr( + self.config, + "static_graph_relationship_types", + ["imports", "inherits"], + ) + or [] + ) + filtered_rels = [ + r + for r in (indexed_file.relationships or []) + if r.relationship_type.value in allowed_types + ] + global_index.update_file_relationships(path, filtered_rels) + except Exception as exc: + logger.debug( + "Failed to update global relationships for %s: %s", + path, + exc, + ) + logger.debug("Indexed file: %s (%d symbols)", path, len(indexed_file.symbols)) return FileIndexResult( @@ -329,6 +368,21 @@ class IncrementalIndexer: try: store.remove_file(str(path)) store.update_merkle_root() + + # Best-effort cleanup of static graph relationships (keeps global DB consistent). + if getattr(self.config, "static_graph_enabled", False): + try: + source_root = self.mapper.get_project_root(path) or dir_path + index_root = self.mapper.source_to_index_dir(source_root) + global_index = self._get_global_index(index_root, source_root=source_root) + if global_index is not None: + global_index.delete_file_relationships(path) + except Exception as exc: + logger.debug( + "Failed to delete global relationships for %s: %s", + path, + exc, + ) logger.debug("Removed file from index: %s", path) return True diff --git a/codex-lens/tests/test_parsers.py b/codex-lens/tests/test_parsers.py index a8fc3e04..9651fddc 100644 --- a/codex-lens/tests/test_parsers.py +++ b/codex-lens/tests/test_parsers.py @@ -377,6 +377,43 @@ class TestParserFactory: finally: del os.environ["CODEXLENS_DATA_DIR"] + def test_factory_passes_config_to_treesitter(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure ParserFactory config is forwarded into TreeSitterSymbolParser.""" + from codexlens.entities import IndexedFile + + captured: dict = {} + + class FakeTreeSitterSymbolParser: + def __init__(self, language_id, path=None, config=None) -> None: + captured["config"] = config + self.language_id = language_id + + def is_available(self) -> bool: + return True + + def parse(self, text: str, path: Path) -> IndexedFile: + return IndexedFile( + path=str(path.resolve()), + language=self.language_id, + symbols=[], + chunks=[], + relationships=[], + ) + + monkeypatch.setattr( + "codexlens.parsers.factory.TreeSitterSymbolParser", + FakeTreeSitterSymbolParser, + ) + + config = Config() + config.use_astgrep = True + + factory = ParserFactory(config) + parser = factory.get_parser("python") + parser.parse("def hello():\n pass\n", Path("test.py")) + + assert captured.get("config") is config + class TestParserEdgeCases: """Edge case tests for parsers."""