Compare commits

..

8 Commits

Author SHA1 Message Date
catlog22
6305f19bbb chore: bump version to 6.3.26
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:33:24 +08:00
catlog22
275d2cb0af feat: Add environment file support for CLI tools
- Introduced a new input group for environment file configuration in the dashboard CSS.
- Updated hook manager to queue CLAUDE.md updates with configurable threshold and timeout.
- Enhanced CLI manager view to include environment file input for built-in tools (gemini, qwen).
- Implemented environment file loading mechanism in cli-executor-core, allowing custom environment variables.
- Added unit tests for environment file parsing and loading functionalities.
- Updated memory update queue to support dynamic configuration of threshold and timeout settings.
2026-01-13 21:31:46 +08:00
catlog22
d5f57d29ed feat: add issue discovery by prompt command with Gemini planning
- Introduced `/issue:discover-by-prompt` command for user-driven issue discovery.
- Implemented multi-agent exploration with iterative feedback loops.
- Added ACE semantic search for context gathering and cross-module comparison capabilities.
- Enhanced user experience with natural language input and adaptive exploration strategies.

feat: implement memory update queue tool for batching updates

- Created `memory-update-queue.js` for managing CLAUDE.md updates.
- Added functionality for queuing paths, deduplication, and auto-flushing based on thresholds and timeouts.
- Implemented methods for queue status retrieval, flushing, and timeout checks.
- Configured to store queue data persistently in `~/.claude/.memory-queue.json`.
2026-01-13 21:04:45 +08:00
catlog22
7d8b13f34f feat(mcp): add cross-platform MCP config support with Windows cmd /c auto-fix
- Add buildCrossPlatformMcpConfig() helper for automatic Windows cmd /c wrapping
- Add checkWindowsMcpCompatibility() to detect configs needing Windows fixes
- Add autoFixWindowsMcpConfig() to automatically fix incompatible configs
- Add showWindowsMcpCompatibilityWarning() dialog for user confirmation
- Simplify recommended MCP configs (ace-tool, chrome-devtools, exa) using helper
- Auto-detect and prompt when adding MCP servers with npx/npm/node/python commands
- Add i18n translations for Windows compatibility warnings (en/zh)

Supported commands for auto-detection: npx, npm, node, python, python3, pip, pip3, pnpm, yarn, bun

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 19:07:11 +08:00
catlog22
340137d347 fix: resolve GitHub issues #63, #66, #67, #68, #69, #70
- #70: Fix API Key Tester URL handling - normalize trailing slashes before
  version suffix detection to prevent double-slash URLs like //models
- #69: Fix memory embedder ignoring CodexLens config - add error handling
  for CodexLensConfig.load() with fallback to defaults
- #68: Fix ccw cli using wrong Python environment - add getCodexLensVenvPython()
  to resolve correct venv path on Windows/Unix
- #67: Fix LiteLLM API Provider test endpoint - actually test API key connection
  instead of just checking ccw-litellm installation
- #66: Fix help-routes.ts path configuration - use correct 'ccw-help' directory
  name and refactor getIndexDir to pure function
- #63: Fix CodexLens install state refresh - add cache invalidation after
  config save in codexlens-manager.js

Also includes targeted unit tests for the URL normalization logic.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:20:54 +08:00
catlog22
61cef8019a chore: bump version to 6.3.25
- Add review-code skill with multi-dimensional code review
- Externalized rules configuration (specs/rules/*.json)
- Centralized state management module

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 17:14:07 +08:00
catlog22
08308aa9ea feat: 增加对 HTML 标签的支持,扩展 BBCode 转换规则 2026-01-13 16:58:24 +08:00
catlog22
94ae9e264c Add output preview phase and callout types specifications
- Implemented Phase 4: Output & Preview for text formatter, including saving formatted content, generating statistics, and providing HTML preview.
- Created callout types documentation with detection patterns and conversion rules for BBCode and HTML.
- Added element mapping specifications detailing detection patterns and conversion matrices for various Markdown elements.
- Established format conversion rules for BBCode and Markdown, emphasizing pixel-based sizing and supported tags.
- Developed BBCode template with structured document and callout templates for consistent formatting.
2026-01-13 16:53:25 +08:00
37 changed files with 4936 additions and 161 deletions

View File

@@ -0,0 +1,764 @@
---
name: issue:discover-by-prompt
description: Discover issues from user prompt with Gemini-planned iterative multi-agent exploration. Uses ACE semantic search for context gathering and supports cross-module comparison (e.g., frontend vs backend API contracts).
argument-hint: "<prompt> [--scope=src/**] [--depth=standard|deep] [--max-iterations=5]"
allowed-tools: SlashCommand(*), TodoWrite(*), Read(*), Bash(*), Task(*), AskUserQuestion(*), Glob(*), Grep(*), mcp__ace-tool__search_context(*), mcp__exa__search(*)
---
# Issue Discovery by Prompt
## Quick Start
```bash
# Discover issues based on user description
/issue:discover-by-prompt "Check if frontend API calls match backend implementations"
# Compare specific modules
/issue:discover-by-prompt "Verify auth flow consistency between mobile and web clients" --scope=src/auth/**,src/mobile/**
# Deep exploration with more iterations
/issue:discover-by-prompt "Find all places where error handling is inconsistent" --depth=deep --max-iterations=8
# Focused backend-frontend contract check
/issue:discover-by-prompt "Compare REST API definitions with frontend fetch calls"
```
**Core Difference from `/issue:discover`**:
- `discover`: Pre-defined perspectives (bug, security, etc.), parallel execution
- `discover-by-prompt`: User-driven prompt, Gemini-planned strategy, iterative exploration
## What & Why
### Core Concept
Prompt-driven issue discovery with intelligent planning. Instead of fixed perspectives, this command:
1. **Analyzes user intent** via Gemini to understand what to find
2. **Plans exploration strategy** dynamically based on codebase structure
3. **Executes iterative multi-agent exploration** with feedback loops
4. **Performs cross-module comparison** when detecting comparison intent
### Value Proposition
1. **Natural Language Input**: Describe what you want to find, not how to find it
2. **Intelligent Planning**: Gemini designs optimal exploration strategy
3. **Iterative Refinement**: Each round builds on previous discoveries
4. **Cross-Module Analysis**: Compare frontend/backend, mobile/web, old/new implementations
5. **Adaptive Exploration**: Adjusts direction based on findings
### Use Cases
| Scenario | Example Prompt |
|----------|----------------|
| API Contract | "Check if frontend calls match backend endpoints" |
| Error Handling | "Find inconsistent error handling patterns" |
| Migration Gap | "Compare old auth with new auth implementation" |
| Feature Parity | "Verify mobile has all web features" |
| Schema Drift | "Check if TypeScript types match API responses" |
| Integration | "Find mismatches between service A and service B" |
## How It Works
### Execution Flow
```
Phase 1: Prompt Analysis & Initialization
├─ Parse user prompt and flags
├─ Detect exploration intent (comparison/search/verification)
└─ Initialize discovery session
Phase 1.5: ACE Context Gathering
├─ Use ACE semantic search to understand codebase structure
├─ Identify relevant modules based on prompt keywords
├─ Collect architecture context for Gemini planning
└─ Build initial context package
Phase 2: Gemini Strategy Planning
├─ Feed ACE context + prompt to Gemini CLI
├─ Gemini analyzes and generates exploration strategy
├─ Create exploration dimensions with search targets
├─ Define comparison matrix (if comparison intent)
└─ Set success criteria and iteration limits
Phase 3: Iterative Agent Exploration (with ACE)
├─ Iteration 1: Initial exploration by assigned agents
│ ├─ Agent A: ACE search + explore dimension 1
│ ├─ Agent B: ACE search + explore dimension 2
│ └─ Collect findings, update shared context
├─ Iteration 2-N: Refined exploration
│ ├─ Analyze previous findings
│ ├─ ACE search for related code paths
│ ├─ Execute targeted exploration
│ └─ Update cumulative findings
└─ Termination: Max iterations or convergence
Phase 4: Cross-Analysis & Synthesis
├─ Compare findings across dimensions
├─ Identify discrepancies and issues
├─ Calculate confidence scores
└─ Generate issue candidates
Phase 5: Issue Generation & Summary
├─ Convert findings to issue format
├─ Write discovery outputs
└─ Prompt user for next action
```
### Exploration Dimensions
Dimensions are **dynamically generated by Gemini** based on the user prompt. Not limited to predefined categories.
**Examples**:
| Prompt | Generated Dimensions |
|--------|---------------------|
| "Check API contracts" | frontend-calls, backend-handlers |
| "Find auth issues" | auth-module (single dimension) |
| "Compare old/new implementations" | legacy-code, new-code |
| "Audit payment flow" | payment-service, validation, logging |
| "Find error handling gaps" | error-handlers, error-types, recovery-logic |
Gemini analyzes the prompt + ACE context to determine:
- How many dimensions are needed (1 to N)
- What each dimension should focus on
- Whether comparison is needed between dimensions
### Iteration Strategy
```
┌─────────────────────────────────────────────────────────────┐
│ Iteration Loop │
├─────────────────────────────────────────────────────────────┤
│ 1. Plan: What to explore this iteration │
│ └─ Based on: previous findings + unexplored areas │
│ │
│ 2. Execute: Launch agents for this iteration │
│ └─ Each agent: explore → collect → return summary │
│ │
│ 3. Analyze: Process iteration results │
│ └─ New findings? Gaps? Contradictions? │
│ │
│ 4. Decide: Continue or terminate │
│ └─ Terminate if: max iterations OR convergence OR │
│ high confidence on all questions │
└─────────────────────────────────────────────────────────────┘
```
## Core Responsibilities
### Phase 1: Prompt Analysis & Initialization
```javascript
// Step 1: Parse arguments
const { prompt, scope, depth, maxIterations } = parseArgs(args);
// Step 2: Generate discovery ID
const discoveryId = `DBP-${formatDate(new Date(), 'YYYYMMDD-HHmmss')}`;
// Step 3: Create output directory
const outputDir = `.workflow/issues/discoveries/${discoveryId}`;
await mkdir(outputDir, { recursive: true });
await mkdir(`${outputDir}/iterations`, { recursive: true });
// Step 4: Detect intent type from prompt
const intentType = detectIntent(prompt);
// Returns: 'comparison' | 'search' | 'verification' | 'audit'
// Step 5: Initialize discovery state
await writeJson(`${outputDir}/discovery-state.json`, {
discovery_id: discoveryId,
type: 'prompt-driven',
prompt: prompt,
intent_type: intentType,
scope: scope || '**/*',
depth: depth || 'standard',
max_iterations: maxIterations || 5,
phase: 'initialization',
created_at: new Date().toISOString(),
iterations: [],
cumulative_findings: [],
comparison_matrix: null // filled for comparison intent
});
```
### Phase 1.5: ACE Context Gathering
**Purpose**: Use ACE semantic search to gather codebase context before Gemini planning.
```javascript
// Step 1: Extract keywords from prompt for semantic search
const keywords = extractKeywords(prompt);
// e.g., "frontend API calls match backend" → ["frontend", "API", "backend", "endpoints"]
// Step 2: Use ACE to understand codebase structure
const aceQueries = [
`Project architecture and module structure for ${keywords.join(', ')}`,
`Where are ${keywords[0]} implementations located?`,
`How does ${keywords.slice(0, 2).join(' ')} work in this codebase?`
];
const aceResults = [];
for (const query of aceQueries) {
const result = await mcp__ace-tool__search_context({
project_root_path: process.cwd(),
query: query
});
aceResults.push({ query, result });
}
// Step 3: Build context package for Gemini (kept in memory)
const aceContext = {
prompt_keywords: keywords,
codebase_structure: aceResults[0].result,
relevant_modules: aceResults.slice(1).map(r => r.result),
detected_patterns: extractPatterns(aceResults)
};
// Step 4: Update state (no separate file)
await updateDiscoveryState(outputDir, {
phase: 'context-gathered',
ace_context: {
queries_executed: aceQueries.length,
modules_identified: aceContext.relevant_modules.length
}
});
// aceContext passed to Phase 2 in memory
```
**ACE Query Strategy by Intent Type**:
| Intent | ACE Queries |
|--------|-------------|
| **comparison** | "frontend API calls", "backend API handlers", "API contract definitions" |
| **search** | "{keyword} implementations", "{keyword} usage patterns" |
| **verification** | "expected behavior for {feature}", "test coverage for {feature}" |
| **audit** | "all {category} patterns", "{category} security concerns" |
### Phase 2: Gemini Strategy Planning
**Purpose**: Gemini analyzes user prompt + ACE context to design optimal exploration strategy.
```javascript
// Step 1: Load ACE context gathered in Phase 1.5
const aceContext = await readJson(`${outputDir}/ace-context.json`);
// Step 2: Build Gemini planning prompt with ACE context
const planningPrompt = `
PURPOSE: Analyze discovery prompt and create exploration strategy based on codebase context
TASK:
• Parse user intent from prompt: "${prompt}"
• Use codebase context to identify specific modules and files to explore
• Create exploration dimensions with precise search targets
• Define comparison matrix structure (if comparison intent)
• Set success criteria and iteration strategy
MODE: analysis
CONTEXT: @${scope || '**/*'} | Discovery type: ${intentType}
## Codebase Context (from ACE semantic search)
${JSON.stringify(aceContext, null, 2)}
EXPECTED: JSON exploration plan following exploration-plan-schema.json:
{
"intent_analysis": { "type": "${intentType}", "primary_question": "...", "sub_questions": [...] },
"dimensions": [{ "name": "...", "description": "...", "search_targets": [...], "focus_areas": [...], "agent_prompt": "..." }],
"comparison_matrix": { "dimension_a": "...", "dimension_b": "...", "comparison_points": [...] },
"success_criteria": [...],
"estimated_iterations": N,
"termination_conditions": [...]
}
RULES: $(cat ~/.claude/workflows/cli-templates/protocols/analysis-protocol.md) | Use ACE context to inform targets | Focus on actionable plan
`;
// Step 3: Execute Gemini planning
Bash({
command: `ccw cli -p "${planningPrompt}" --tool gemini --mode analysis`,
run_in_background: true,
timeout: 300000
});
// Step 4: Parse Gemini output and validate against schema
const explorationPlan = await parseGeminiPlanOutput(geminiResult);
validateAgainstSchema(explorationPlan, 'exploration-plan-schema.json');
// Step 5: Enhance plan with ACE-discovered file paths
explorationPlan.dimensions = explorationPlan.dimensions.map(dim => ({
...dim,
ace_suggested_files: aceContext.relevant_modules
.filter(m => m.relevance_to === dim.name)
.map(m => m.file_path)
}));
// Step 6: Update state (plan kept in memory, not persisted)
await updateDiscoveryState(outputDir, {
phase: 'planned',
exploration_plan: {
dimensions_count: explorationPlan.dimensions.length,
has_comparison_matrix: !!explorationPlan.comparison_matrix,
estimated_iterations: explorationPlan.estimated_iterations
}
});
// explorationPlan passed to Phase 3 in memory
```
**Gemini Planning Responsibilities**:
| Responsibility | Input | Output |
|----------------|-------|--------|
| Intent Analysis | User prompt | type, primary_question, sub_questions |
| Dimension Design | ACE context + prompt | dimensions with search_targets |
| Comparison Matrix | Intent type + modules | comparison_points (if applicable) |
| Iteration Strategy | Depth setting | estimated_iterations, termination_conditions |
**Gemini Planning Output Schema**:
```json
{
"intent_analysis": {
"type": "comparison|search|verification|audit",
"primary_question": "string",
"sub_questions": ["string"]
},
"dimensions": [
{
"name": "frontend",
"description": "Client-side API calls and error handling",
"search_targets": ["src/api/**", "src/hooks/**"],
"focus_areas": ["fetch calls", "error boundaries", "response parsing"],
"agent_prompt": "Explore frontend API consumption patterns..."
},
{
"name": "backend",
"description": "Server-side API implementations",
"search_targets": ["src/server/**", "src/routes/**"],
"focus_areas": ["endpoint handlers", "response schemas", "error responses"],
"agent_prompt": "Explore backend API implementations..."
}
],
"comparison_matrix": {
"dimension_a": "frontend",
"dimension_b": "backend",
"comparison_points": [
{"aspect": "endpoints", "frontend_check": "fetch URLs", "backend_check": "route paths"},
{"aspect": "methods", "frontend_check": "HTTP methods used", "backend_check": "methods accepted"},
{"aspect": "payloads", "frontend_check": "request body structure", "backend_check": "expected schema"},
{"aspect": "responses", "frontend_check": "response parsing", "backend_check": "response format"},
{"aspect": "errors", "frontend_check": "error handling", "backend_check": "error responses"}
]
},
"success_criteria": [
"All API endpoints mapped between frontend and backend",
"Discrepancies identified with file:line references",
"Each finding includes remediation suggestion"
],
"estimated_iterations": 3,
"termination_conditions": [
"All comparison points verified",
"No new findings in last iteration",
"Confidence > 0.8 on primary question"
]
}
```
### Phase 3: Iterative Agent Exploration (with ACE)
**Purpose**: Multi-agent iterative exploration using ACE for semantic search within each iteration.
```javascript
let iteration = 0;
let cumulativeFindings = [];
let sharedContext = { aceDiscoveries: [], crossReferences: [] };
let shouldContinue = true;
while (shouldContinue && iteration < maxIterations) {
iteration++;
const iterationDir = `${outputDir}/iterations/${iteration}`;
await mkdir(iterationDir, { recursive: true });
// Step 1: ACE-assisted iteration planning
// Use previous findings to guide ACE queries for this iteration
const iterationAceQueries = iteration === 1
? explorationPlan.dimensions.map(d => d.focus_areas[0]) // Initial queries from plan
: deriveQueriesFromFindings(cumulativeFindings); // Follow-up queries from findings
// Execute ACE searches to find related code
const iterationAceResults = [];
for (const query of iterationAceQueries) {
const result = await mcp__ace-tool__search_context({
project_root_path: process.cwd(),
query: `${query} in ${explorationPlan.scope}`
});
iterationAceResults.push({ query, result });
}
// Update shared context with ACE discoveries
sharedContext.aceDiscoveries.push(...iterationAceResults);
// Step 2: Plan this iteration based on ACE results
const iterationPlan = planIteration(iteration, explorationPlan, cumulativeFindings, iterationAceResults);
// Step 3: Launch dimension agents with ACE context
const agentPromises = iterationPlan.dimensions.map(dimension =>
Task({
subagent_type: "cli-explore-agent",
run_in_background: false,
description: `Explore ${dimension.name} (iteration ${iteration})`,
prompt: buildDimensionPromptWithACE(dimension, iteration, cumulativeFindings, iterationAceResults, iterationDir)
})
);
// Wait for iteration agents
const iterationResults = await Promise.all(agentPromises);
// Step 4: Collect and analyze iteration findings
const iterationFindings = await collectIterationFindings(iterationDir, iterationPlan.dimensions);
// Step 5: Cross-reference findings between dimensions
if (iterationPlan.dimensions.length > 1) {
const crossRefs = findCrossReferences(iterationFindings, iterationPlan.dimensions);
sharedContext.crossReferences.push(...crossRefs);
}
cumulativeFindings.push(...iterationFindings);
// Step 6: Decide whether to continue
const convergenceCheck = checkConvergence(iterationFindings, cumulativeFindings, explorationPlan);
shouldContinue = !convergenceCheck.converged;
// Step 7: Update state (iteration summary embedded in state)
await updateDiscoveryState(outputDir, {
iterations: [...state.iterations, {
number: iteration,
findings_count: iterationFindings.length,
ace_queries: iterationAceQueries.length,
cross_references: sharedContext.crossReferences.length,
new_discoveries: convergenceCheck.newDiscoveries,
confidence: convergenceCheck.confidence,
continued: shouldContinue
}],
cumulative_findings: cumulativeFindings
});
}
```
**ACE in Iteration Loop**:
```
Iteration N
├─→ ACE Search (based on previous findings)
│ └─ Query: "related code paths for {finding.category}"
│ └─ Result: Additional files to explore
├─→ Agent Exploration (with ACE context)
│ └─ Agent receives: dimension targets + ACE suggestions
│ └─ Agent can call ACE for deeper search
├─→ Cross-Reference Analysis
│ └─ Compare findings between dimensions
│ └─ Identify discrepancies
└─→ Convergence Check
└─ New findings? Continue
└─ No new findings? Terminate
```
**Dimension Agent Prompt Template (with ACE)**:
```javascript
function buildDimensionPromptWithACE(dimension, iteration, previousFindings, aceResults, outputDir) {
// Filter ACE results relevant to this dimension
const relevantAceResults = aceResults.filter(r =>
r.query.includes(dimension.name) || dimension.focus_areas.some(fa => r.query.includes(fa))
);
return `
## Task Objective
Explore ${dimension.name} dimension for issue discovery (Iteration ${iteration})
## Context
- Dimension: ${dimension.name}
- Description: ${dimension.description}
- Search Targets: ${dimension.search_targets.join(', ')}
- Focus Areas: ${dimension.focus_areas.join(', ')}
## ACE Semantic Search Results (Pre-gathered)
The following files/code sections were identified by ACE as relevant to this dimension:
${JSON.stringify(relevantAceResults.map(r => ({ query: r.query, files: r.result.slice(0, 5) })), null, 2)}
**Use ACE for deeper exploration**: You have access to mcp__ace-tool__search_context.
When you find something interesting, use ACE to find related code:
- mcp__ace-tool__search_context({ project_root_path: ".", query: "related to {finding}" })
${iteration > 1 ? `
## Previous Findings to Build Upon
${summarizePreviousFindings(previousFindings, dimension.name)}
## This Iteration Focus
- Explore areas not yet covered (check ACE results for new files)
- Verify/deepen previous findings
- Follow leads from previous discoveries
- Use ACE to find cross-references between dimensions
` : ''}
## MANDATORY FIRST STEPS
1. Read exploration plan: ${outputDir}/../exploration-plan.json
2. Read schema: ~/.claude/workflows/cli-templates/schemas/discovery-finding-schema.json
3. Review ACE results above for starting points
4. Explore files identified by ACE
## Exploration Instructions
${dimension.agent_prompt}
## ACE Usage Guidelines
- Use ACE when you need to find:
- Where a function/class is used
- Related implementations in other modules
- Cross-module dependencies
- Similar patterns elsewhere in codebase
- Query format: Natural language, be specific
- Example: "Where is UserService.authenticate called from?"
## Output Requirements
**1. Write JSON file**: ${outputDir}/${dimension.name}.json
Follow discovery-finding-schema.json:
- findings: [{id, title, category, description, file, line, snippet, confidence, related_dimension}]
- coverage: {files_explored, areas_covered, areas_remaining}
- leads: [{description, suggested_search}] // for next iteration
- ace_queries_used: [{query, result_count}] // track ACE usage
**2. Return summary**:
- Total findings this iteration
- Key discoveries
- ACE queries that revealed important code
- Recommended next exploration areas
## Success Criteria
- [ ] JSON written to ${outputDir}/${dimension.name}.json
- [ ] Each finding has file:line reference
- [ ] ACE used for cross-references where applicable
- [ ] Coverage report included
- [ ] Leads for next iteration identified
`;
}
```
### Phase 4: Cross-Analysis & Synthesis
```javascript
// For comparison intent, perform cross-analysis
if (intentType === 'comparison' && explorationPlan.comparison_matrix) {
const comparisonResults = [];
for (const point of explorationPlan.comparison_matrix.comparison_points) {
const dimensionAFindings = cumulativeFindings.filter(f =>
f.related_dimension === explorationPlan.comparison_matrix.dimension_a &&
f.category.includes(point.aspect)
);
const dimensionBFindings = cumulativeFindings.filter(f =>
f.related_dimension === explorationPlan.comparison_matrix.dimension_b &&
f.category.includes(point.aspect)
);
// Compare and find discrepancies
const discrepancies = findDiscrepancies(dimensionAFindings, dimensionBFindings, point);
comparisonResults.push({
aspect: point.aspect,
dimension_a_count: dimensionAFindings.length,
dimension_b_count: dimensionBFindings.length,
discrepancies: discrepancies,
match_rate: calculateMatchRate(dimensionAFindings, dimensionBFindings)
});
}
// Write comparison analysis
await writeJson(`${outputDir}/comparison-analysis.json`, {
matrix: explorationPlan.comparison_matrix,
results: comparisonResults,
summary: {
total_discrepancies: comparisonResults.reduce((sum, r) => sum + r.discrepancies.length, 0),
overall_match_rate: average(comparisonResults.map(r => r.match_rate)),
critical_mismatches: comparisonResults.filter(r => r.match_rate < 0.5)
}
});
}
// Prioritize all findings
const prioritizedFindings = prioritizeFindings(cumulativeFindings, explorationPlan);
```
### Phase 5: Issue Generation & Summary
```javascript
// Convert high-confidence findings to issues
const issueWorthy = prioritizedFindings.filter(f =>
f.confidence >= 0.7 || f.priority === 'critical' || f.priority === 'high'
);
const issues = issueWorthy.map(finding => ({
id: `ISS-${discoveryId}-${finding.id}`,
title: finding.title,
description: finding.description,
source: {
discovery_id: discoveryId,
finding_id: finding.id,
dimension: finding.related_dimension
},
file: finding.file,
line: finding.line,
priority: finding.priority,
category: finding.category,
suggested_fix: finding.suggested_fix,
confidence: finding.confidence,
status: 'discovered',
created_at: new Date().toISOString()
}));
// Write issues
await writeJsonl(`${outputDir}/discovery-issues.jsonl`, issues);
// Update final state (summary embedded in state, no separate file)
await updateDiscoveryState(outputDir, {
phase: 'complete',
updated_at: new Date().toISOString(),
results: {
total_iterations: iteration,
total_findings: cumulativeFindings.length,
issues_generated: issues.length,
comparison_match_rate: comparisonResults
? average(comparisonResults.map(r => r.match_rate))
: null
}
});
// Prompt user for next action
await AskUserQuestion({
questions: [{
question: `Discovery complete: ${issues.length} issues from ${cumulativeFindings.length} findings across ${iteration} iterations. What next?`,
header: "Next Step",
multiSelect: false,
options: [
{ label: "Export to Issues (Recommended)", description: `Export ${issues.length} issues for planning` },
{ label: "Review Details", description: "View comparison analysis and iteration details" },
{ label: "Run Deeper", description: "Continue with more iterations" },
{ label: "Skip", description: "Complete without exporting" }
]
}]
});
```
## Output File Structure
```
.workflow/issues/discoveries/
└── {DBP-YYYYMMDD-HHmmss}/
├── discovery-state.json # Session state with iteration tracking
├── iterations/
│ ├── 1/
│ │ └── {dimension}.json # Dimension findings
│ ├── 2/
│ │ └── {dimension}.json
│ └── ...
├── comparison-analysis.json # Cross-dimension comparison (if applicable)
└── discovery-issues.jsonl # Generated issue candidates
```
**Simplified Design**:
- ACE context and Gemini plan kept in memory, not persisted
- Iteration summaries embedded in state
- No separate summary.md (state.json contains all needed info)
## Schema References
| Schema | Path | Used By |
|--------|------|---------|
| **Discovery State** | `discovery-state-schema.json` | Orchestrator (state tracking) |
| **Discovery Finding** | `discovery-finding-schema.json` | Dimension agents (output) |
| **Exploration Plan** | `exploration-plan-schema.json` | Gemini output validation (memory only) |
## Configuration Options
| Flag | Default | Description |
|------|---------|-------------|
| `--scope` | `**/*` | File pattern to explore |
| `--depth` | `standard` | `standard` (3 iterations) or `deep` (5+ iterations) |
| `--max-iterations` | 5 | Maximum exploration iterations |
| `--tool` | `gemini` | Planning tool (gemini/qwen) |
| `--plan-only` | `false` | Stop after Phase 2 (Gemini planning), show plan for user review |
## Examples
### Example 1: Single Module Deep Dive
```bash
/issue:discover-by-prompt "Find all potential issues in the auth module" --scope=src/auth/**
```
**Gemini plans** (single dimension):
- Dimension: auth-module
- Focus: security vulnerabilities, edge cases, error handling, test gaps
**Iterations**: 2-3 (until no new findings)
### Example 2: API Contract Comparison
```bash
/issue:discover-by-prompt "Check if API calls match implementations" --scope=src/**
```
**Gemini plans** (comparison):
- Dimension 1: api-consumers (fetch calls, hooks, services)
- Dimension 2: api-providers (handlers, routes, controllers)
- Comparison matrix: endpoints, methods, payloads, responses
### Example 3: Multi-Module Audit
```bash
/issue:discover-by-prompt "Audit the payment flow for issues" --scope=src/payment/**
```
**Gemini plans** (multi-dimension):
- Dimension 1: payment-logic (calculations, state transitions)
- Dimension 2: validation (input checks, business rules)
- Dimension 3: error-handling (failure modes, recovery)
### Example 4: Plan Only Mode
```bash
/issue:discover-by-prompt "Find inconsistent patterns" --plan-only
```
Stops after Gemini planning, outputs:
```
Gemini Plan:
- Intent: search
- Dimensions: 2 (pattern-definitions, pattern-usages)
- Estimated iterations: 3
Continue with exploration? [Y/n]
```
## Related Commands
```bash
# After discovery, plan solutions
/issue:plan DBP-001-01,DBP-001-02
# View all discoveries
/issue:manage
# Standard perspective-based discovery
/issue:discover src/auth/** --perspectives=security,bug
```
## Best Practices
1. **Be Specific in Prompts**: More specific prompts lead to better Gemini planning
2. **Scope Appropriately**: Narrow scope for focused comparison, wider for audits
3. **Review Exploration Plan**: Check `exploration-plan.json` before long explorations
4. **Use Standard Depth First**: Start with standard, go deep only if needed
5. **Combine with `/issue:discover`**: Use prompt-based for comparisons, perspective-based for audits

View File

@@ -0,0 +1,196 @@
---
name: text-formatter
description: Transform and optimize text content with intelligent formatting. Output BBCode + Markdown hybrid format optimized for forums. Triggers on "format text", "text formatter", "排版", "格式化文本", "BBCode".
allowed-tools: Task, AskUserQuestion, Read, Write, Bash, Glob
---
# Text Formatter
Transform and optimize text content with intelligent structure analysis. Output format: **BBCode + Markdown hybrid** optimized for forum publishing.
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ Text Formatter Architecture (BBCode + MD Mode) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Phase 1: Input Collection → 接收文本/文件 │
│ ↓ │
│ Phase 2: Content Analysis → 分析结构、识别 Callout/Admonition │
│ ↓ │
│ Phase 3: Format Transform → 转换为 BBCode+MD 格式 │
│ ↓ │
│ Phase 4: Output & Preview → 保存文件 + 预览 │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## Key Design Principles
1. **Single Format Output**: BBCode + Markdown hybrid (forum optimized)
2. **Pixel-Based Sizing**: size=150/120/100/80 (not 1-7 levels)
3. **Forum Compatibility**: Only use widely-supported BBCode tags
4. **Markdown Separators**: Use `---` for horizontal rules (not `[hr]`)
5. **No Alignment Tags**: `[align]` not supported, avoid usage
---
## Format Specification
### Supported BBCode Tags
| Tag | Usage | Example |
|-----|-------|---------|
| `[size=N]` | Font size (pixels) | `[size=120]Title[/size]` |
| `[color=X]` | Text color (hex/name) | `[color=#2196F3]Blue[/color]``[color=blue]` |
| `[b]` | Bold | `[b]Bold text[/b]` |
| `[i]` | Italic | `[i]Italic[/i]` |
| `[s]` | Strikethrough | `[s]deleted[/s]` |
| `[u]` | Underline | `[u]underlined[/u]` |
| `[quote]` | Quote block | `[quote]Content[/quote]` |
| `[code]` | Code block | `[code]code[/code]` |
| `[img]` | Image | `[img]url[/img]` |
| `[url]` | Link | `[url=link]text[/url]` |
| `[list]` | List container | `[list][*]item[/list]` |
| `[spoiler]` | Collapsible content | `[spoiler=标题]隐藏内容[/spoiler]` |
### HTML to BBCode Conversion
| HTML Input | BBCode Output |
|------------|---------------|
| `<mark>高亮</mark>` | `[color=yellow]高亮[/color]` |
| `<u>下划线</u>` | `[u]下划线[/u]` |
| `<details><summary>标题</summary>内容</details>` | `[spoiler=标题]内容[/spoiler]` |
### Unsupported Tags (Avoid!)
| Tag | Reason | Alternative |
|-----|--------|-------------|
| `[align]` | Not rendered | Remove or use default left |
| `[hr]` | Shows as text | Use Markdown `---` |
| `<div>` | HTML not supported | Use BBCode only |
| `[table]` | Limited support | Use list or code block |
### Size Hierarchy (Pixels)
| Element | Size | Color | Usage |
|---------|------|-------|-------|
| **Main Title** | 150 | #2196F3 | Document title |
| **Section Title** | 120 | #2196F3 | Major sections (## H2) |
| **Subsection** | 100 | #333 | Sub-sections (### H3) |
| **Normal Text** | (default) | - | Body content |
| **Notes/Gray** | 80 | gray | Footnotes, metadata |
### Color Palette
| Color | Hex | Semantic Usage |
|-------|-----|----------------|
| **Blue** | #2196F3 | Titles, links, info |
| **Green** | #4CAF50 | Success, tips, features |
| **Orange** | #FF9800 | Warnings, caution |
| **Red** | #F44336 | Errors, danger, important |
| **Purple** | #9C27B0 | Examples, code |
| **Gray** | gray | Notes, metadata |
---
## Mandatory Prerequisites
> Read before execution:
| Document | Purpose | Priority |
|----------|---------|----------|
| [specs/format-rules.md](specs/format-rules.md) | Format conversion rules | **P0** |
| [specs/element-mapping.md](specs/element-mapping.md) | Element type mappings | P1 |
| [specs/callout-types.md](specs/callout-types.md) | Callout/Admonition types | P1 |
---
## Execution Flow
```
┌────────────────────────────────────────────────────────────────┐
│ Phase 1: Input Collection │
│ - Ask: paste text OR file path │
│ - Output: input-config.json │
├────────────────────────────────────────────────────────────────┤
│ Phase 2: Content Analysis │
│ - Detect structure: headings, lists, code blocks, tables │
│ - Identify Callouts/Admonitions (>[!type]) │
│ - Output: analysis.json │
├────────────────────────────────────────────────────────────────┤
│ Phase 3: Format Transform │
│ - Apply BBCode + MD rules from specs/format-rules.md │
│ - Convert elements with pixel-based sizes │
│ - Use Markdown --- for separators │
│ - Output: formatted content │
├────────────────────────────────────────────────────────────────┤
│ Phase 4: Output & Preview │
│ - Save to .bbcode.txt file │
│ - Display preview │
│ - Output: final file │
└────────────────────────────────────────────────────────────────┘
```
## Callout/Admonition Support
支持 Obsidian 风格的 Callout 语法,转换为 BBCode quote
```markdown
> [!NOTE]
> 这是一个提示信息
> [!WARNING]
> 这是一个警告信息
```
转换结果:
```bbcode
[quote]
[size=100][color=#2196F3][b]📝 注意[/b][/color][/size]
这是一个提示信息
[/quote]
```
| Type | Color | Icon |
|------|-------|------|
| NOTE/INFO | #2196F3 | 📝 |
| TIP/HINT | #4CAF50 | 💡 |
| SUCCESS | #4CAF50 | ✅ |
| WARNING/CAUTION | #FF9800 | ⚠️ |
| DANGER/ERROR | #F44336 | ❌ |
| EXAMPLE | #9C27B0 | 📋 |
## Directory Setup
```javascript
const timestamp = new Date().toISOString().slice(0,10).replace(/-/g, '');
const workDir = `.workflow/.scratchpad/text-formatter-${timestamp}`;
Bash(`mkdir -p "${workDir}"`);
```
## Output Structure
```
.workflow/.scratchpad/text-formatter-{date}/
├── input-config.json # 输入配置
├── analysis.json # 内容分析结果
└── output.bbcode.txt # BBCode+MD 输出
```
## Reference Documents
| Document | Purpose |
|----------|---------|
| [phases/01-input-collection.md](phases/01-input-collection.md) | 收集输入内容 |
| [phases/02-content-analysis.md](phases/02-content-analysis.md) | 分析内容结构 |
| [phases/03-format-transform.md](phases/03-format-transform.md) | 格式转换 |
| [phases/04-output-preview.md](phases/04-output-preview.md) | 输出和预览 |
| [specs/format-rules.md](specs/format-rules.md) | 格式转换规则 |
| [specs/element-mapping.md](specs/element-mapping.md) | 元素映射表 |
| [specs/callout-types.md](specs/callout-types.md) | Callout 类型定义 |
| [templates/bbcode-template.md](templates/bbcode-template.md) | BBCode 模板 |

View File

@@ -0,0 +1,111 @@
# Phase 1: Input Collection
收集用户输入的文本内容。
## Objective
- 获取用户输入内容(直接粘贴或文件路径)
- 生成输入配置文件
**注意**: 输出格式固定为 BBCode + Markdown 混合格式(论坛优化),无需选择。
## Input
- 来源: 用户交互
- 配置: 无前置依赖
## Execution Steps
### Step 1: 询问输入方式
```javascript
const inputMethod = await AskUserQuestion({
questions: [
{
question: "请选择输入方式",
header: "输入方式",
multiSelect: false,
options: [
{ label: "直接粘贴文本", description: "在对话中粘贴要格式化的内容" },
{ label: "指定文件路径", description: "读取指定文件的内容" }
]
}
]
});
```
### Step 2: 获取内容
```javascript
let content = '';
if (inputMethod["输入方式"] === "直接粘贴文本") {
// 提示用户粘贴内容
const textInput = await AskUserQuestion({
questions: [
{
question: "请粘贴要格式化的文本内容(粘贴后选择确认)",
header: "文本内容",
multiSelect: false,
options: [
{ label: "已粘贴完成", description: "确认已在上方粘贴内容" }
]
}
]
});
// 从用户消息中提取文本内容
content = extractUserText();
} else {
// 询问文件路径
const filePath = await AskUserQuestion({
questions: [
{
question: "请输入文件路径",
header: "文件路径",
multiSelect: false,
options: [
{ label: "已输入路径", description: "确认路径已在上方输入" }
]
}
]
});
content = Read(extractedFilePath);
}
```
### Step 3: 保存配置
```javascript
const config = {
input_method: inputMethod["输入方式"],
target_format: "BBCode+MD", // 固定格式
original_content: content,
timestamp: new Date().toISOString()
};
Write(`${workDir}/input-config.json`, JSON.stringify(config, null, 2));
```
## Output
- **File**: `input-config.json`
- **Format**: JSON
```json
{
"input_method": "直接粘贴文本",
"target_format": "BBCode+MD",
"original_content": "...",
"timestamp": "2026-01-13T..."
}
```
## Quality Checklist
- [ ] 成功获取用户输入内容
- [ ] 内容非空且有效
- [ ] 配置文件已保存
## Next Phase
→ [Phase 2: Content Analysis](02-content-analysis.md)

View File

@@ -0,0 +1,248 @@
# Phase 2: Content Analysis
分析输入内容的结构和语义元素。
## Objective
- 识别内容结构(标题、段落、列表等)
- 检测特殊元素(代码块、表格、链接等)
- 生成结构化分析结果
## Input
- 依赖: `input-config.json`
- 配置: `{workDir}/input-config.json`
## Execution Steps
### Step 1: 加载输入
```javascript
const config = JSON.parse(Read(`${workDir}/input-config.json`));
const content = config.original_content;
```
### Step 2: 结构分析
```javascript
function analyzeStructure(text) {
const analysis = {
elements: [],
stats: {
headings: 0,
paragraphs: 0,
lists: 0,
code_blocks: 0,
tables: 0,
links: 0,
images: 0,
quotes: 0,
callouts: 0
}
};
// Callout 检测正则 (Obsidian 风格)
const CALLOUT_PATTERN = /^>\s*\[!(\w+)\](?:\s+(.+))?$/;
const lines = text.split('\n');
let currentElement = null;
let inCodeBlock = false;
let inList = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 检测代码块
if (line.match(/^```/)) {
inCodeBlock = !inCodeBlock;
if (inCodeBlock) {
analysis.elements.push({
type: 'code_block',
start: i,
language: line.replace(/^```/, '').trim()
});
analysis.stats.code_blocks++;
}
continue;
}
if (inCodeBlock) continue;
// 检测标题 (Markdown 或纯文本模式)
if (line.match(/^#{1,6}\s/)) {
const level = line.match(/^(#+)/)[1].length;
analysis.elements.push({
type: 'heading',
level: level,
content: line.replace(/^#+\s*/, ''),
line: i
});
analysis.stats.headings++;
continue;
}
// 检测列表
if (line.match(/^[\s]*[-*+]\s/) || line.match(/^[\s]*\d+\.\s/)) {
if (!inList) {
analysis.elements.push({
type: 'list',
start: i,
ordered: line.match(/^\d+\./) !== null
});
analysis.stats.lists++;
inList = true;
}
continue;
} else {
inList = false;
}
// 检测 Callout (Obsidian 风格) - 优先于普通引用
const calloutMatch = line.match(CALLOUT_PATTERN);
if (calloutMatch) {
const calloutType = calloutMatch[1].toLowerCase();
const calloutTitle = calloutMatch[2] || null;
// 收集 Callout 内容行
const calloutContent = [];
let j = i + 1;
while (j < lines.length && lines[j].startsWith('>')) {
calloutContent.push(lines[j].replace(/^>\s*/, ''));
j++;
}
analysis.elements.push({
type: 'callout',
calloutType: calloutType,
title: calloutTitle,
content: calloutContent.join('\n'),
start: i,
end: j - 1
});
analysis.stats.callouts++;
i = j - 1; // 跳过已处理的行
continue;
}
// 检测普通引用
if (line.match(/^>\s/)) {
analysis.elements.push({
type: 'quote',
content: line.replace(/^>\s*/, ''),
line: i
});
analysis.stats.quotes++;
continue;
}
// 检测表格
if (line.match(/^\|.*\|$/)) {
analysis.elements.push({
type: 'table_row',
line: i
});
if (!analysis.elements.find(e => e.type === 'table')) {
analysis.stats.tables++;
}
continue;
}
// 检测链接
const links = line.match(/\[([^\]]+)\]\(([^)]+)\)/g);
if (links) {
analysis.stats.links += links.length;
}
// 检测图片
const images = line.match(/!\[([^\]]*)\]\(([^)]+)\)/g);
if (images) {
analysis.stats.images += images.length;
}
// 普通段落
if (line.trim() && !line.match(/^[-=]{3,}$/)) {
analysis.elements.push({
type: 'paragraph',
line: i,
preview: line.substring(0, 50)
});
analysis.stats.paragraphs++;
}
}
return analysis;
}
const analysis = analyzeStructure(content);
```
### Step 3: 语义增强
```javascript
// 识别特殊语义
function enhanceSemantics(text, analysis) {
const enhanced = { ...analysis };
// 检测关键词强调
const boldPatterns = text.match(/\*\*[^*]+\*\*/g) || [];
const italicPatterns = text.match(/\*[^*]+\*/g) || [];
enhanced.semantics = {
emphasis: {
bold: boldPatterns.length,
italic: italicPatterns.length
},
estimated_reading_time: Math.ceil(text.split(/\s+/).length / 200) // 200 words/min
};
return enhanced;
}
const enhancedAnalysis = enhanceSemantics(content, analysis);
```
### Step 4: 保存分析结果
```javascript
Write(`${workDir}/analysis.json`, JSON.stringify(enhancedAnalysis, null, 2));
```
## Output
- **File**: `analysis.json`
- **Format**: JSON
```json
{
"elements": [
{ "type": "heading", "level": 1, "content": "Title", "line": 0 },
{ "type": "paragraph", "line": 2, "preview": "..." },
{ "type": "callout", "calloutType": "warning", "title": "注意事项", "content": "...", "start": 4, "end": 6 },
{ "type": "code_block", "start": 8, "language": "javascript" }
],
"stats": {
"headings": 3,
"paragraphs": 10,
"lists": 2,
"code_blocks": 1,
"tables": 0,
"links": 5,
"images": 0,
"quotes": 1,
"callouts": 2
},
"semantics": {
"emphasis": { "bold": 5, "italic": 3 },
"estimated_reading_time": 2
}
}
```
## Quality Checklist
- [ ] 所有结构元素已识别
- [ ] 统计信息准确
- [ ] 语义增强完成
- [ ] 分析文件已保存
## Next Phase
→ [Phase 3: Format Transform](03-format-transform.md)

View File

@@ -0,0 +1,245 @@
# Phase 3: Format Transform
将内容转换为 BBCode + Markdown 混合格式(论坛优化)。
## Objective
- 根据分析结果转换内容
- 应用像素级字号规则
- 处理 Callout/标注语法
- 生成论坛兼容的输出
## Input
- 依赖: `input-config.json`, `analysis.json`
- 规范: `specs/format-rules.md`, `specs/element-mapping.md`
## Format Specification
### Size Hierarchy (Pixels)
| Element | Size | Color | Usage |
|---------|------|-------|-------|
| **H1** | 150 | #2196F3 | 文档主标题 |
| **H2** | 120 | #2196F3 | 章节标题 |
| **H3** | 100 | #333 | 子标题 |
| **H4+** | (默认) | - | 仅加粗 |
| **Notes** | 80 | gray | 备注/元数据 |
### Unsupported Tags (禁止使用)
| Tag | Reason | Alternative |
|-----|--------|-------------|
| `[align]` | 不渲染 | 删除,使用默认左对齐 |
| `[hr]` | 显示为文本 | 使用 Markdown `---` |
| `[table]` | 支持有限 | 转为列表或代码块 |
| HTML tags | 不支持 | 仅使用 BBCode |
## Execution Steps
### Step 1: 加载配置和分析
```javascript
const config = JSON.parse(Read(`${workDir}/input-config.json`));
const analysis = JSON.parse(Read(`${workDir}/analysis.json`));
const content = config.original_content;
```
### Step 2: Callout 配置
```javascript
// Callout 类型映射(像素级字号)
const CALLOUT_CONFIG = {
// 信息类
note: { icon: '📝', color: '#2196F3', label: '注意' },
info: { icon: '', color: '#2196F3', label: '信息' },
abstract: { icon: '📄', color: '#2196F3', label: '摘要' },
summary: { icon: '📄', color: '#2196F3', label: '摘要' },
tldr: { icon: '📄', color: '#2196F3', label: '摘要' },
// 成功/提示类
tip: { icon: '💡', color: '#4CAF50', label: '提示' },
hint: { icon: '💡', color: '#4CAF50', label: '提示' },
success: { icon: '✅', color: '#4CAF50', label: '成功' },
check: { icon: '✅', color: '#4CAF50', label: '完成' },
done: { icon: '✅', color: '#4CAF50', label: '完成' },
// 警告类
warning: { icon: '⚠️', color: '#FF9800', label: '警告' },
caution: { icon: '⚠️', color: '#FF9800', label: '注意' },
attention: { icon: '⚠️', color: '#FF9800', label: '注意' },
question: { icon: '❓', color: '#FF9800', label: '问题' },
help: { icon: '❓', color: '#FF9800', label: '帮助' },
faq: { icon: '❓', color: '#FF9800', label: 'FAQ' },
todo: { icon: '📋', color: '#FF9800', label: '待办' },
// 错误/危险类
danger: { icon: '❌', color: '#F44336', label: '危险' },
error: { icon: '❌', color: '#F44336', label: '错误' },
bug: { icon: '🐛', color: '#F44336', label: 'Bug' },
important: { icon: '⭐', color: '#F44336', label: '重要' },
// 其他
example: { icon: '📋', color: '#9C27B0', label: '示例' },
quote: { icon: '💬', color: 'gray', label: '引用' },
cite: { icon: '💬', color: 'gray', label: '引用' }
};
// Callout 检测正则 (支持 +/- 折叠标记)
const CALLOUT_PATTERN = /^>\s*\[!(\w+)\][+-]?(?:\s+(.+))?$/;
```
### Step 3: Callout 解析器
```javascript
function parseCallouts(text) {
const lines = text.split('\n');
const result = [];
let i = 0;
while (i < lines.length) {
const match = lines[i].match(CALLOUT_PATTERN);
if (match) {
const type = match[1].toLowerCase();
const title = match[2] || null;
const content = [];
i++;
// 收集 Callout 内容行
while (i < lines.length && lines[i].startsWith('>')) {
content.push(lines[i].replace(/^>\s*/, ''));
i++;
}
result.push({
isCallout: true,
type,
title,
content: content.join('\n')
});
} else {
result.push({ isCallout: false, line: lines[i] });
i++;
}
}
return result;
}
```
### Step 4: BBCode+MD 转换器
```javascript
function formatBBCodeMD(text) {
let result = text;
// ===== 标题转换 (像素级字号) =====
result = result.replace(/^######\s*(.+)$/gm, '[b]$1[/b]');
result = result.replace(/^#####\s*(.+)$/gm, '[b]$1[/b]');
result = result.replace(/^####\s*(.+)$/gm, '[b]$1[/b]');
result = result.replace(/^###\s*(.+)$/gm, '[size=100][color=#333][b]$1[/b][/color][/size]');
result = result.replace(/^##\s*(.+)$/gm, '[size=120][color=#2196F3][b]$1[/b][/color][/size]');
result = result.replace(/^#\s*(.+)$/gm, '[size=150][color=#2196F3][b]$1[/b][/color][/size]');
// ===== 文本样式 =====
result = result.replace(/\*\*\*(.+?)\*\*\*/g, '[b][i]$1[/i][/b]');
result = result.replace(/\*\*(.+?)\*\*/g, '[b]$1[/b]');
result = result.replace(/__(.+?)__/g, '[b]$1[/b]');
result = result.replace(/\*(.+?)\*/g, '[i]$1[/i]');
result = result.replace(/_(.+?)_/g, '[i]$1[/i]');
result = result.replace(/~~(.+?)~~/g, '[s]$1[/s]');
result = result.replace(/==(.+?)==/g, '[color=yellow]$1[/color]');
// ===== HTML 转 BBCode =====
result = result.replace(/<mark>(.+?)<\/mark>/g, '[color=yellow]$1[/color]');
result = result.replace(/<u>(.+?)<\/u>/g, '[u]$1[/u]');
result = result.replace(/<details>\s*<summary>(.+?)<\/summary>\s*([\s\S]*?)<\/details>/g,
'[spoiler=$1]$2[/spoiler]');
// ===== 代码 =====
result = result.replace(/```(\w*)\n([\s\S]*?)```/g, '[code]$2[/code]');
// 行内代码保持原样 (部分论坛不支持 font=monospace)
// ===== 链接和图片 =====
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[url=$2]$1[/url]');
result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[img]$2[/img]');
// ===== 引用 (非 Callout) =====
result = result.replace(/^>\s+(.+)$/gm, '[quote]$1[/quote]');
// ===== 列表 (使用 • 符号) =====
result = result.replace(/^[-*+]\s+(.+)$/gm, '• $1');
// ===== 分隔线 (保持 Markdown 语法) =====
// `---` 在混合格式中通常可用,不转换为 [hr]
return result.trim();
}
```
### Step 5: Callout 转换
```javascript
function convertCallouts(text) {
const parsed = parseCallouts(text);
return parsed.map(item => {
if (item.isCallout) {
const cfg = CALLOUT_CONFIG[item.type] || CALLOUT_CONFIG.note;
const displayTitle = item.title || cfg.label;
// 使用 [quote] 包裹,标题使用 size=100
return `[quote]
[size=100][color=${cfg.color}][b]${cfg.icon} ${displayTitle}[/b][/color][/size]
${item.content}
[/quote]`;
}
return item.line;
}).join('\n');
}
```
### Step 6: 执行转换
```javascript
// 1. 先处理 Callouts
let formattedContent = convertCallouts(content);
// 2. 再进行通用 BBCode+MD 转换
formattedContent = formatBBCodeMD(formattedContent);
// 3. 清理多余空行
formattedContent = formattedContent.replace(/\n{3,}/g, '\n\n');
```
### Step 7: 保存转换结果
```javascript
const outputFile = 'output.bbcode.txt';
Write(`${workDir}/${outputFile}`, formattedContent);
// 更新配置
config.output_file = outputFile;
config.formatted_content = formattedContent;
Write(`${workDir}/input-config.json`, JSON.stringify(config, null, 2));
```
## Output
- **File**: `output.bbcode.txt`
- **Format**: BBCode + Markdown 混合格式
## Quality Checklist
- [ ] 标题使用像素值 (150/120/100)
- [ ] 未使用 `[align]` 标签
- [ ] 未使用 `[hr]` 标签
- [ ] 分隔线使用 `---`
- [ ] Callout 正确转换为 [quote]
- [ ] 颜色值使用 hex 格式
- [ ] 内容完整无丢失
## Next Phase
→ [Phase 4: Output & Preview](04-output-preview.md)

View File

@@ -0,0 +1,183 @@
# Phase 4: Output & Preview
输出最终结果并提供预览。
## Objective
- 保存格式化后的内容到文件
- 提供预览功能
- 显示转换统计信息
## Input
- 依赖: `input-config.json`, `output.*`
- 配置: `{workDir}/input-config.json`
## Execution Steps
### Step 1: 加载结果
```javascript
const config = JSON.parse(Read(`${workDir}/input-config.json`));
const analysis = JSON.parse(Read(`${workDir}/analysis.json`));
const outputFile = `${workDir}/${config.output_file}`;
const formattedContent = Read(outputFile);
```
### Step 2: 生成统计摘要
```javascript
const summary = {
input: {
method: config.input_method,
original_length: config.original_content.length,
word_count: config.original_content.split(/\s+/).length
},
output: {
format: config.target_format,
file: outputFile,
length: formattedContent.length
},
elements: analysis.stats,
reading_time: analysis.semantics?.estimated_reading_time || 1
};
console.log(`
╔════════════════════════════════════════════════════════════════╗
║ Text Formatter Summary ║
╠════════════════════════════════════════════════════════════════╣
║ Input: ${summary.input.word_count} words (${summary.input.original_length} chars)
║ Output: ${summary.output.format}${summary.output.file}
║ Elements Converted:
║ • Headings: ${summary.elements.headings}
║ • Paragraphs: ${summary.elements.paragraphs}
║ • Lists: ${summary.elements.lists}
║ • Code Blocks: ${summary.elements.code_blocks}
║ • Links: ${summary.elements.links}
║ Estimated Reading Time: ${summary.reading_time} min
╚════════════════════════════════════════════════════════════════╝
`);
```
### Step 3: HTML 预览(如适用)
```javascript
if (config.target_format === 'HTML') {
// 生成完整 HTML 文件用于预览
const previewHtml = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Text Formatter Preview</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background: #f5f5f5;
}
.content {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1, h2, h3, h4, h5, h6 { color: #333; margin-top: 1.5em; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
pre { background: #282c34; color: #abb2bf; padding: 1rem; border-radius: 6px; overflow-x: auto; }
pre code { background: none; padding: 0; }
blockquote { border-left: 4px solid #ddd; margin: 0; padding-left: 1rem; color: #666; }
a { color: #0066cc; }
img { max-width: 100%; }
hr { border: none; border-top: 1px solid #ddd; margin: 2rem 0; }
</style>
</head>
<body>
<div class="content">
${formattedContent}
</div>
</body>
</html>`;
Write(`${workDir}/preview.html`, previewHtml);
// 可选:在浏览器中打开预览
// Bash(`start "${workDir}/preview.html"`); // Windows
// Bash(`open "${workDir}/preview.html"`); // macOS
}
```
### Step 4: 显示输出内容
```javascript
// 显示格式化后的内容
console.log('\n=== Formatted Content ===\n');
console.log(formattedContent);
console.log('\n=========================\n');
// 提示用户
console.log(`
📁 Output saved to: ${outputFile}
${config.target_format === 'HTML' ? '🌐 Preview available: ' + workDir + '/preview.html' : ''}
💡 Tips:
- Copy the content above for immediate use
- Or access the saved file at the path shown
`);
```
### Step 5: 询问后续操作
```javascript
const nextAction = await AskUserQuestion({
questions: [
{
question: "需要执行什么操作?",
header: "后续操作",
multiSelect: false,
options: [
{ label: "完成", description: "结束格式化流程" },
{ label: "转换为其他格式", description: "选择另一种输出格式" },
{ label: "重新编辑", description: "修改原始内容后重新格式化" }
]
}
]
});
if (nextAction["后续操作"] === "转换为其他格式") {
// 返回 Phase 1 选择新格式
console.log('请重新运行 /text-formatter 选择其他格式');
}
```
## Output
- **File**: `output.{ext}` (最终输出)
- **File**: `preview.html` (HTML 预览,仅 HTML 格式)
- **Console**: 统计摘要和格式化内容
## Final Output Structure
```
{workDir}/
├── input-config.json # 配置信息
├── analysis.json # 分析结果
├── output.md # Markdown 输出(如选择)
├── output.bbcode.txt # BBCode 输出(如选择)
├── output.html # HTML 输出(如选择)
└── preview.html # HTML 预览页面
```
## Quality Checklist
- [ ] 输出文件已保存
- [ ] 统计信息正确显示
- [ ] 预览功能可用HTML
- [ ] 用户可访问输出内容
## Completion
此为最终阶段,格式化流程完成。

View File

@@ -0,0 +1,293 @@
# Callout Types
Obsidian 风格的 Callout/Admonition 类型定义和转换规则。
## When to Use
| Phase | Usage | Section |
|-------|-------|---------|
| Phase 2 | 检测 Callout | Detection patterns |
| Phase 3 | 格式转换 | Conversion rules |
---
## Callout 语法
### Obsidian 原生语法
```markdown
> [!TYPE] 可选标题
> 内容行1
> 内容行2
```
### 支持的类型
| Type | Alias | Icon | Color | 用途 |
|------|-------|------|-------|------|
| `note` | - | 📝 | blue | 普通提示 |
| `info` | - | | blue | 信息说明 |
| `tip` | `hint` | 💡 | green | 技巧提示 |
| `success` | `check`, `done` | ✅ | green | 成功状态 |
| `warning` | `caution`, `attention` | ⚠️ | orange | 警告信息 |
| `danger` | `error` | ❌ | red | 危险/错误 |
| `bug` | - | 🐛 | red | Bug 说明 |
| `example` | - | 📋 | purple | 示例内容 |
| `quote` | `cite` | 💬 | gray | 引用内容 |
| `abstract` | `summary`, `tldr` | 📄 | cyan | 摘要 |
| `question` | `help`, `faq` | ❓ | yellow | 问题/FAQ |
| `todo` | - | 📌 | orange | 待办事项 |
---
## 检测 Pattern
```javascript
// Callout 检测正则
const CALLOUT_PATTERN = /^>\s*\[!(\w+)\](?:\s+(.+))?$/;
// 检测函数
function detectCallout(line) {
const match = line.match(CALLOUT_PATTERN);
if (match) {
return {
type: match[1].toLowerCase(),
title: match[2] || null
};
}
return null;
}
// 解析完整 Callout 块
function parseCalloutBlock(lines, startIndex) {
const firstLine = lines[startIndex];
const calloutInfo = detectCallout(firstLine);
if (!calloutInfo) return null;
const content = [];
let i = startIndex + 1;
while (i < lines.length && lines[i].startsWith('>')) {
content.push(lines[i].replace(/^>\s*/, ''));
i++;
}
return {
...calloutInfo,
content: content.join('\n'),
endIndex: i - 1
};
}
```
---
## 转换规则
### BBCode 转换
```javascript
const CALLOUT_BBCODE = {
note: {
icon: '📝',
color: '#2196F3',
label: '注意'
},
info: {
icon: '',
color: '#2196F3',
label: '信息'
},
tip: {
icon: '💡',
color: '#4CAF50',
label: '提示'
},
success: {
icon: '✅',
color: '#4CAF50',
label: '成功'
},
warning: {
icon: '⚠️',
color: '#FF9800',
label: '警告'
},
danger: {
icon: '❌',
color: '#F44336',
label: '危险'
},
bug: {
icon: '🐛',
color: '#F44336',
label: 'Bug'
},
example: {
icon: '📋',
color: '#9C27B0',
label: '示例'
},
quote: {
icon: '💬',
color: '#9E9E9E',
label: '引用'
},
question: {
icon: '❓',
color: '#FFEB3B',
label: '问题'
}
};
function calloutToBBCode(type, title, content, style = 'forum') {
const config = CALLOUT_BBCODE[type] || CALLOUT_BBCODE.note;
const displayTitle = title || config.label;
if (style === 'compact') {
return `[quote][b]${config.icon} ${displayTitle}[/b]
${content}[/quote]`;
}
// Forum style - more visual
return `[quote]
[color=${config.color}][size=4][b]${config.icon} ${displayTitle}[/b][/size][/color]
${content}
[/quote]`;
}
```
### HTML 转换
```javascript
function calloutToHTML(type, title, content) {
const config = CALLOUT_BBCODE[type] || CALLOUT_BBCODE.note;
const displayTitle = title || config.label;
return `<div class="callout callout-${type}">
<div class="callout-title">
<span class="callout-icon">${config.icon}</span>
<span class="callout-title-text">${displayTitle}</span>
</div>
<div class="callout-content">
${content}
</div>
</div>`;
}
```
### Hybrid 转换
```javascript
function calloutToHybrid(type, title, content) {
const config = CALLOUT_BBCODE[type] || CALLOUT_BBCODE.note;
const displayTitle = title || config.label;
// HTML container + BBCode styling + MD content
return `<div class="callout ${type}">
[color=${config.color}][b]${config.icon} ${displayTitle}[/b][/color]
${content}
</div>`;
}
```
---
## Callout CSS 样式
```css
/* Base callout styles */
.callout {
padding: 1rem;
margin: 1rem 0;
border-left: 4px solid;
border-radius: 4px;
background: #f8f9fa;
}
.callout-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.callout-icon {
font-size: 1.2em;
}
/* Type-specific colors */
.callout-note, .callout-info {
border-color: #2196F3;
background: #E3F2FD;
}
.callout-tip, .callout-success {
border-color: #4CAF50;
background: #E8F5E9;
}
.callout-warning {
border-color: #FF9800;
background: #FFF3E0;
}
.callout-danger, .callout-bug {
border-color: #F44336;
background: #FFEBEE;
}
.callout-example {
border-color: #9C27B0;
background: #F3E5F5;
}
.callout-quote {
border-color: #9E9E9E;
background: #FAFAFA;
}
.callout-question {
border-color: #FFC107;
background: #FFFDE7;
}
```
---
## 折叠 Callout
支持可折叠的 Callout 语法:
```markdown
> [!NOTE]+ 默认展开
> 内容
> [!NOTE]- 默认折叠
> 内容
```
### BBCode 折叠
```bbcode
[collapse=📝 注意]
内容
[/collapse]
```
### HTML 折叠
```html
<details class="callout callout-note">
<summary>📝 注意</summary>
<div class="callout-content">
内容
</div>
</details>
```

View File

@@ -0,0 +1,226 @@
# Element Mapping
内容元素到 BBCode + Markdown 混合格式的映射表。
## When to Use
| Phase | Usage | Section |
|-------|-------|---------|
| Phase 2 | 元素识别 | Detection patterns |
| Phase 3 | 格式转换 | Conversion rules |
---
## Element Detection Patterns
### 标题检测
| 类型 | Pattern | 示例 |
|------|---------|------|
| ATX 标题 | `/^#{1,6}\s+(.+)$/` | `# Title`, `## Subtitle` |
| Setext H1 | `/^(.+)\n={3,}$/` | `Title\n====` |
| Setext H2 | `/^(.+)\n-{3,}$/` | `Subtitle\n----` |
### 列表检测
| 类型 | Pattern | 示例 |
|------|---------|------|
| 无序列表 | `/^[\s]*[-*+]\s+(.+)$/` | `- item`, `* item` |
| 有序列表 | `/^[\s]*\d+\.\s+(.+)$/` | `1. item`, `2. item` |
| 任务列表 | `/^[\s]*[-*]\s+\[([ x])\]\s+(.+)$/` | `- [ ] todo`, `- [x] done` |
### Callout 检测
| 类型 | Pattern | 示例 |
|------|---------|------|
| Callout 开始 | `/^>\s*\[!(\w+)\](?:\s+(.+))?$/` | `> [!NOTE] 标题` |
| Callout 内容 | `/^>\s*(.*)$/` | `> 内容行` |
| 可折叠展开 | `/^>\s*\[!(\w+)\]\+/` | `> [!NOTE]+` |
| 可折叠收起 | `/^>\s*\[!(\w+)\]-/` | `> [!NOTE]-` |
### 代码检测
| 类型 | Pattern | 示例 |
|------|---------|------|
| 代码块开始 | `/^```(\w*)$/` | ` ```js ` |
| 代码块结束 | `/^```$/` | ` ``` ` |
| 行内代码 | `/`([^`]+)`/` | `` `code` `` |
### 其他元素
| 类型 | Pattern | 示例 |
|------|---------|------|
| 链接 | `/\[([^\]]+)\]\(([^)]+)\)/` | `[text](url)` |
| 图片 | `/!\[([^\]]*)\]\(([^)]+)\)/` | `![alt](url)` |
| 普通引用 | `/^>\s+(.+)$/` | `> quote` |
| 分隔线 | `/^[-*_]{3,}$/` | `---`, `***` |
| 高亮 | `/==(.+?)==/` | `==highlight==` |
| 粗体 | `/\*\*(.+?)\*\*/` | `**bold**` |
| 斜体 | `/\*(.+?)\*/` | `*italic*` |
| 删除线 | `/~~(.+?)~~/` | `~~strike~~` |
### HTML 元素检测
| 类型 | Pattern | 示例 |
|------|---------|------|
| 高亮 | `/<mark>(.+?)<\/mark>/` | `<mark>高亮</mark>` |
| 折叠块 | `/<details>\s*<summary>(.+?)<\/summary>([\s\S]*?)<\/details>/` | `<details><summary>标题</summary>内容</details>` |
| 下划线 | `/<u>(.+?)<\/u>/` | `<u>下划线</u>` |
---
## Element Conversion Matrix
### 标题映射 (Pixel-Based)
| Element | Markdown | BBCode Output |
|---------|----------|---------------|
| **H1** | `# text` | `[size=150][color=#2196F3][b]text[/b][/color][/size]` |
| **H2** | `## text` | `[size=120][color=#2196F3][b]text[/b][/color][/size]` |
| **H3** | `### text` | `[size=100][color=#333][b]text[/b][/color][/size]` |
| **H4** | `#### text` | `[b]text[/b]` |
| **H5** | `##### text` | `[b]text[/b]` |
| **H6** | `###### text` | `[b]text[/b]` |
### 文本样式映射
| Element | Markdown/HTML | BBCode |
|---------|---------------|--------|
| **Bold** | `**text**` 或 `__text__` | `[b]text[/b]` |
| **Italic** | `*text*` 或 `_text_` | `[i]text[/i]` |
| **Bold+Italic** | `***text***` | `[b][i]text[/i][/b]` |
| **Strike** | `~~text~~` | `[s]text[/s]` |
| **Underline** | `<u>text</u>` | `[u]text[/u]` |
| **Highlight** | `==text==` 或 `<mark>text</mark>` | `[color=yellow]text[/color]` |
| **Code (inline)** | `` `text` `` | 保持原样 |
### HTML 转 BBCode 映射
| HTML | BBCode |
|------|--------|
| `<mark>text</mark>` | `[color=yellow]text[/color]` |
| `<u>text</u>` | `[u]text[/u]` |
| `<details><summary>标题</summary>内容</details>` | `[spoiler=标题]内容[/spoiler]` |
### 块级元素映射
| Element | Markdown | BBCode |
|---------|----------|--------|
| **Code Block** | ` ```lang\ncode\n``` ` | `[code]code[/code]` |
| **Quote** | `> text` | `[quote]text[/quote]` |
| **HR** | `---` | `---` (保持 Markdown) |
| **List Item** | `- text` | `• text` |
| **Paragraph** | `text\n\ntext` | `text\n\ntext` |
### 链接和媒体映射
| Element | Markdown | BBCode |
|---------|----------|--------|
| **Link** | `[text](url)` | `[url=url]text[/url]` |
| **Image** | `![alt](url)` | `[img]url[/img]` |
---
## Callout Mapping
### 类型到样式映射
| Callout Type | Color | Icon | Label |
|--------------|-------|------|-------|
| note | #2196F3 | 📝 | 注意 |
| info | #2196F3 | | 信息 |
| tip | #4CAF50 | 💡 | 提示 |
| hint | #4CAF50 | 💡 | 提示 |
| success | #4CAF50 | ✅ | 成功 |
| check | #4CAF50 | ✅ | 完成 |
| done | #4CAF50 | ✅ | 完成 |
| warning | #FF9800 | ⚠️ | 警告 |
| caution | #FF9800 | ⚠️ | 注意 |
| attention | #FF9800 | ⚠️ | 注意 |
| danger | #F44336 | ❌ | 危险 |
| error | #F44336 | ❌ | 错误 |
| bug | #F44336 | 🐛 | Bug |
| example | #9C27B0 | 📋 | 示例 |
| question | #FF9800 | ❓ | 问题 |
| help | #FF9800 | ❓ | 帮助 |
| faq | #FF9800 | ❓ | FAQ |
| quote | gray | 💬 | 引用 |
| cite | gray | 💬 | 引用 |
| abstract | #2196F3 | 📄 | 摘要 |
| summary | #2196F3 | 📄 | 摘要 |
| tldr | #2196F3 | 📄 | 摘要 |
| todo | #FF9800 | 📋 | 待办 |
| important | #F44336 | ⭐ | 重要 |
### Callout 输出模板
```bbcode
[quote]
[size=100][color={color}][b]{icon} {title}[/b][/color][/size]
{content}
[/quote]
```
---
## Unsupported Elements
### 不支持转换的元素
| 元素 | 原因 | 降级方案 |
|------|------|----------|
| 表格 | BBCode 表格支持有限 | 转为代码块或列表 |
| 脚注 | 不支持 | 转为括号注释 `(注: ...)` |
| 数学公式 | 不支持 | 保留原始文本 |
| 嵌入内容 | 不支持 | 转为链接 |
| 任务列表 | 复选框不支持 | 转为普通列表 `☐`/`☑` |
### 降级示例
**表格**:
```
| A | B |
|---|---|
| 1 | 2 |
→ 降级为:
A: 1
B: 2
```
**脚注**:
```
文本[^1]
[^1]: 脚注内容
→ 降级为:
文本 (注: 脚注内容)
```
**任务列表**:
```
- [ ] 待办
- [x] 已完成
→ 降级为:
☐ 待办
☑ 已完成
```
---
## Validation Rules
### 转换验证
- [ ] 所有 H1-H3 使用像素值 size (150/120/100)
- [ ] 未使用 `[align]` 标签
- [ ] 未使用 `[hr]` 标签
- [ ] 分隔线保持 `---`
- [ ] Callout 正确识别并转换
- [ ] 颜色值使用 hex 格式

View File

@@ -0,0 +1,273 @@
# Format Conversion Rules
BBCode + Markdown 混合格式转换规则(论坛优化)。
## When to Use
| Phase | Usage | Section |
|-------|-------|---------|
| Phase 3 | 格式转换 | All sections |
---
## Core Principles
### 1. Pixel-Based Sizing
**重要**: 使用像素值而非 1-7 级别
| 元素 | Size (px) | 说明 |
|------|-----------|------|
| 主标题 (H1) | 150 | 文档标题 |
| 章节标题 (H2) | 120 | 主要章节 |
| 子标题 (H3) | 100 | 子章节 |
| 正文 | (默认) | 不指定 size |
| 备注/灰色 | 80 | 脚注、元数据 |
### 2. Supported Tags Only
**支持的 BBCode 标签**:
- `[size=N]` - 字号(像素值)
- `[color=X]` - 颜色hex 或名称,如 `[color=blue]``[color=#2196F3]`
- `[b]`, `[i]`, `[s]`, `[u]` - 粗体、斜体、删除线、下划线
- `[quote]` - 引用块
- `[code]` - 代码块
- `[url]`, `[img]` - 链接、图片
- `[list]`, `[*]` - 列表
- `[spoiler]``[spoiler=标题]` - 折叠/隐藏内容
**禁止使用的标签**:
- `[align]` - 不渲染,显示为文本
- `[hr]` - 不渲染,使用 Markdown `---`
- `[table]` - 支持有限,避免使用
**HTML 标签转换** (输入时支持,转换为 BBCode):
- `<mark>text</mark>``[color=yellow]text[/color]`
- `<details><summary>标题</summary>内容</details>``[spoiler=标题]内容[/spoiler]`
- 其他 HTML 标签 (`<div>`, `<span>`) - 删除
### 3. Markdown as Separator
分隔线使用 Markdown 语法:`---`
---
## Element Conversion Rules
### 标题转换
| Markdown | BBCode Output |
|----------|---------------|
| `# H1` | `[size=150][color=#2196F3][b]H1[/b][/color][/size]` |
| `## H2` | `[size=120][color=#2196F3][b]H2[/b][/color][/size]` |
| `### H3` | `[size=100][color=#333][b]H3[/b][/color][/size]` |
| `#### H4+` | `[b]H4[/b]` (不加 size) |
### 文本样式
| Markdown/HTML | BBCode |
|---------------|--------|
| `**bold**``__bold__` | `[b]bold[/b]` |
| `*italic*``_italic_` | `[i]italic[/i]` |
| `***both***` | `[b][i]both[/i][/b]` |
| `~~strike~~` | `[s]strike[/s]` |
| `==highlight==``<mark>text</mark>` | `[color=yellow]highlight[/color]` |
| (无 MD 语法) | `[u]underline[/u]` |
### 折叠内容
| HTML | BBCode |
|------|--------|
| `<details><summary>标题</summary>内容</details>` | `[spoiler=标题]内容[/spoiler]` |
| (无 HTML) | `[spoiler]隐藏内容[/spoiler]` |
### 代码
| Markdown | BBCode |
|----------|--------|
| `` `inline` `` | 保持原样或 `[color=#9C27B0]inline[/color]` |
| ` ```code``` ` | `[code]code[/code]` |
### 链接和图片
| Markdown | BBCode |
|----------|--------|
| `[text](url)` | `[url=url]text[/url]` |
| `![alt](url)` | `[img]url[/img]` |
### 列表
```
Markdown:
- item 1
- item 2
- nested
BBCode:
• item 1
• item 2
• nested
```
注意:使用 `•` 符号而非 `[list][*]`,因为部分论坛渲染有问题。
### 引用
```
Markdown:
> quote text
BBCode:
[quote]
quote text
[/quote]
```
---
## Callout (标注) 转换
### Obsidian Callout 语法
```markdown
> [!TYPE] 可选标题
> 内容行 1
> 内容行 2
```
### 支持的 Callout 类型
| Type | Color | Icon | 中文标签 |
|------|-------|------|----------|
| note, info | #2196F3 | 📝 | 注意 / 信息 |
| tip, hint | #4CAF50 | 💡 | 提示 |
| success, check, done | #4CAF50 | ✅ | 成功 |
| warning, caution, attention | #FF9800 | ⚠️ | 警告 |
| danger, error, bug | #F44336 | ❌ | 危险 / 错误 |
| example | #9C27B0 | 📋 | 示例 |
| question, help, faq | #FF9800 | ❓ | 问题 |
| quote, cite | gray | 💬 | 引用 |
| abstract, summary, tldr | #2196F3 | 📄 | 摘要 |
### Callout 转换模板
```bbcode
[quote]
[size=100][color={color}][b]{icon} {title}[/b][/color][/size]
{content}
[/quote]
```
**示例**:
```markdown
> [!WARNING] 注意事项
> 这是警告内容
```
转换为:
```bbcode
[quote]
[size=100][color=#FF9800][b]⚠️ 注意事项[/b][/color][/size]
这是警告内容
[/quote]
```
### 可折叠 Callout
Obsidian 支持 `> [!NOTE]+` (展开) 和 `> [!NOTE]-` (折叠)。
由于 BBCode 不支持折叠,统一转换为普通 quote。
---
## Color Palette
### 语义颜色
| 语义 | Hex | 使用场景 |
|------|-----|----------|
| Primary | #2196F3 | 标题、链接、信息 |
| Success | #4CAF50 | 成功、提示、特性 |
| Warning | #FF9800 | 警告、注意 |
| Error | #F44336 | 错误、危险 |
| Purple | #9C27B0 | 示例、代码 |
| Gray | gray | 备注、元数据 |
| Dark | #333 | 子标题 |
### 颜色使用规则
1. **标题颜色**: H1/H2 使用 #2196F3H3 使用 #333
2. **Callout 颜色**: 根据类型使用语义颜色
3. **备注颜色**: 使用 gray
4. **强调颜色**: 根据语义选择(成功用绿色,警告用橙色)
---
## Spacing Rules
### 空行控制
| 元素 | 前空行 | 后空行 |
|------|--------|--------|
| 标题 | 1 | 1 |
| 段落 | 0 | 1 |
| 列表 | 0 | 1 |
| 代码块 | 1 | 1 |
| Callout | 1 | 1 |
| 分隔线 `---` | 1 | 1 |
### 示例输出结构
```bbcode
[size=150][color=#2196F3][b]文档标题[/b][/color][/size]
[size=80][color=gray]作者 | 日期[/color][/size]
---
[size=120][color=#2196F3][b]第一章节[/b][/color][/size]
正文内容...
[quote]
[size=100][color=#4CAF50][b]💡 提示[/b][/color][/size]
提示内容
[/quote]
---
[size=120][color=#2196F3][b]第二章节[/b][/color][/size]
更多内容...
---
[size=80][color=gray]— 全文完 —[/color][/size]
```
---
## Quality Checklist
### 转换完整性
- [ ] 所有标题使用像素值 size
- [ ] 未使用 `[align]``[hr]`
- [ ] 分隔线使用 `---`
- [ ] Callout 正确转换为 quote
- [ ] 颜色符合语义规范
- [ ] 空行控制正确
### 常见错误
| 错误 | 正确做法 |
|------|----------|
| `[size=5]` | `[size=120]` |
| `[align=center]` | 删除,默认左对齐 |
| `[hr]` | 使用 `---` |
| `<div class="...">` | 删除 HTML 标签 |

View File

@@ -0,0 +1,350 @@
# BBCode Template
论坛优化的 BBCode + Markdown 混合模板(像素级字号)。
## 核心规则
### 字号体系 (Pixels)
| 元素 | Size | 说明 |
|------|------|------|
| 主标题 | 150 | 文档标题 |
| 章节标题 | 120 | H2 级别 |
| 子标题 | 100 | H3 级别 |
| 正文 | (默认) | 不指定 |
| 备注 | 80 | 灰色小字 |
### 禁止使用
- `[align]` - 不渲染
- `[hr]` - 不渲染,用 `---`
- `[table]` - 支持有限
- HTML 标签
---
## 文档模板
### 基础文档结构
```bbcode
[size=150][color=#2196F3][b]{{title}}[/b][/color][/size]
[size=80][color=gray]{{metadata}}[/color][/size]
---
{{introduction}}
---
[size=120][color=#2196F3][b]{{section1_title}}[/b][/color][/size]
{{section1_content}}
---
[size=120][color=#2196F3][b]{{section2_title}}[/b][/color][/size]
{{section2_content}}
---
[size=80][color=gray]— 全文完 —[/color][/size]
```
### 带目录的文档
```bbcode
[size=150][color=#2196F3][b]{{title}}[/b][/color][/size]
[size=80][color=gray]{{author}} | {{date}}[/color][/size]
---
[size=100][b]📋 目录[/b][/size]
• {{section1_title}}
• {{section2_title}}
• {{section3_title}}
---
[size=120][color=#2196F3][b]{{section1_title}}[/b][/color][/size]
{{section1_content}}
---
[size=120][color=#2196F3][b]{{section2_title}}[/b][/color][/size]
{{section2_content}}
---
[size=120][color=#2196F3][b]{{section3_title}}[/b][/color][/size]
{{section3_content}}
---
[size=80][color=gray]— 全文完 —[/color][/size]
```
---
## Callout 模板
### 提示 (Note/Info)
```bbcode
[quote]
[size=100][color=#2196F3][b]📝 {{title}}[/b][/color][/size]
{{content}}
[/quote]
```
### 技巧 (Tip/Hint)
```bbcode
[quote]
[size=100][color=#4CAF50][b]💡 {{title}}[/b][/color][/size]
{{content}}
[/quote]
```
### 成功 (Success)
```bbcode
[quote]
[size=100][color=#4CAF50][b]✅ {{title}}[/b][/color][/size]
{{content}}
[/quote]
```
### 警告 (Warning/Caution)
```bbcode
[quote]
[size=100][color=#FF9800][b]⚠️ {{title}}[/b][/color][/size]
{{content}}
[/quote]
```
### 危险/错误 (Danger/Error)
```bbcode
[quote]
[size=100][color=#F44336][b]❌ {{title}}[/b][/color][/size]
{{content}}
[/quote]
```
### 示例 (Example)
```bbcode
[quote]
[size=100][color=#9C27B0][b]📋 {{title}}[/b][/color][/size]
{{content}}
[/quote]
```
### 问题 (Question/FAQ)
```bbcode
[quote]
[size=100][color=#FF9800][b]❓ {{title}}[/b][/color][/size]
{{content}}
[/quote]
```
### 重要 (Important)
```bbcode
[quote]
[size=100][color=#F44336][b]⭐ {{title}}[/b][/color][/size]
{{content}}
[/quote]
```
---
## 代码展示模板
### 单代码块
```bbcode
[size=100][color=#9C27B0][b]代码示例[/b][/color][/size]
[code]
{{code}}
[/code]
[size=80][color=gray]说明: {{description}}[/color][/size]
```
### 带标题的代码
```bbcode
[size=100][b]{{code_title}}[/b][/size]
[code]
{{code}}
[/code]
```
---
## 特性展示模板
### 特性列表
```bbcode
[size=120][color=#2196F3][b]功能特性[/b][/color][/size]
• [color=#4CAF50][b]✨ {{feature1}}[/b][/color] — {{desc1}}
• [color=#2196F3][b]🚀 {{feature2}}[/b][/color] — {{desc2}}
• [color=#FF9800][b]⚡ {{feature3}}[/b][/color] — {{desc3}}
```
### 详细特性卡片
```bbcode
[size=120][color=#2196F3][b]功能特性[/b][/color][/size]
[quote]
[size=100][color=#4CAF50][b]✨ {{feature1_title}}[/b][/color][/size]
{{feature1_description}}
[size=80][color=gray]适用场景: {{feature1_use_case}}[/color][/size]
[/quote]
[quote]
[size=100][color=#2196F3][b]🚀 {{feature2_title}}[/b][/color][/size]
{{feature2_description}}
[size=80][color=gray]适用场景: {{feature2_use_case}}[/color][/size]
[/quote]
```
---
## 步骤指南模板
```bbcode
[size=120][color=#2196F3][b]操作步骤[/b][/color][/size]
[size=100][color=#2196F3][b]步骤 1: {{step1_title}}[/b][/color][/size]
{{step1_content}}
[quote]
[size=100][color=#FF9800][b]💡 提示[/b][/color][/size]
{{step1_tip}}
[/quote]
[size=100][color=#2196F3][b]步骤 2: {{step2_title}}[/b][/color][/size]
{{step2_content}}
[size=100][color=#2196F3][b]步骤 3: {{step3_title}}[/b][/color][/size]
{{step3_content}}
---
[color=#4CAF50][b]✅ 完成![/b][/color] {{completion_message}}
```
---
## 版本更新模板
```bbcode
[size=150][color=#673AB7][b]🎉 版本 {{version}} 更新日志[/b][/color][/size]
---
[size=120][color=#4CAF50][b]✨ 新功能[/b][/color][/size]
• [b]{{new_feature1}}[/b]: {{new_feature1_desc}}
• [b]{{new_feature2}}[/b]: {{new_feature2_desc}}
[size=120][color=#2196F3][b]🔧 改进[/b][/color][/size]
• {{improvement1}}
• {{improvement2}}
[size=120][color=#F44336][b]🐛 修复[/b][/color][/size]
• {{bugfix1}}
• {{bugfix2}}
---
[url={{download_url}}][b]📥 立即下载[/b][/url]
```
---
## FAQ 模板
```bbcode
[size=120][color=#2196F3][b]❓ 常见问题[/b][/color][/size]
---
[size=100][color=#333][b]Q: {{question1}}[/b][/color][/size]
[b]A:[/b] {{answer1}}
---
[size=100][color=#333][b]Q: {{question2}}[/b][/color][/size]
[b]A:[/b] {{answer2}}
---
[size=100][color=#333][b]Q: {{question3}}[/b][/color][/size]
[b]A:[/b] {{answer3}}
```
---
## 转换检查清单
### 必须检查
- [ ] 标题使用像素值 (150/120/100)
- [ ] 分隔线使用 `---`
- [ ] 未使用 `[align]`
- [ ] 未使用 `[hr]`
- [ ] 未使用 HTML 标签
- [ ] Callout 标题 size=100
- [ ] 灰色备注 size=80
### 颜色规范
| 用途 | 颜色 |
|------|------|
| 主标题 | #2196F3 |
| 章节标题 | #2196F3 |
| 子标题 | #333 |
| 成功/提示 | #4CAF50 |
| 警告 | #FF9800 |
| 错误/危险 | #F44336 |
| 示例 | #9C27B0 |
| 备注 | gray |

View File

@@ -26,7 +26,9 @@ except ImportError:
sys.exit(1)
try:
from codexlens.semantic.embedder import get_embedder, clear_embedder_cache
from codexlens.semantic.factory import get_embedder as get_embedder_factory
from codexlens.semantic.factory import clear_embedder_cache
from codexlens.config import Config as CodexLensConfig
except ImportError:
print("Error: CodexLens not found. Install with: pip install codexlens[semantic]", file=sys.stderr)
sys.exit(1)
@@ -35,8 +37,6 @@ except ImportError:
class MemoryEmbedder:
"""Generate and search embeddings for memory chunks."""
EMBEDDING_DIM = 768 # jina-embeddings-v2-base-code dimension
def __init__(self, db_path: str):
"""Initialize embedder with database path."""
self.db_path = Path(db_path)
@@ -46,14 +46,61 @@ class MemoryEmbedder:
self.conn = sqlite3.connect(str(self.db_path))
self.conn.row_factory = sqlite3.Row
# Load CodexLens configuration for embedding settings
try:
self._config = CodexLensConfig.load()
except Exception as e:
print(f"Warning: Could not load CodexLens config, using defaults. Error: {e}", file=sys.stderr)
self._config = CodexLensConfig() # Use default config
# Lazy-load embedder to avoid ~0.8s model loading for status command
self._embedder = None
self._embedding_dim = None
@property
def embedding_dim(self) -> int:
"""Get embedding dimension from the embedder."""
if self._embedding_dim is None:
# Access embedder to get its dimension
self._embedding_dim = self.embedder.embedding_dim
return self._embedding_dim
@property
def embedder(self):
"""Lazy-load the embedder on first access."""
"""Lazy-load the embedder on first access using CodexLens config."""
if self._embedder is None:
self._embedder = get_embedder(profile="code")
# Use CodexLens configuration settings
backend = self._config.embedding_backend
model = self._config.embedding_model
use_gpu = self._config.embedding_use_gpu
# Use factory to create embedder based on backend type
if backend == "fastembed":
self._embedder = get_embedder_factory(
backend="fastembed",
profile=model,
use_gpu=use_gpu
)
elif backend == "litellm":
# For litellm backend, also pass endpoints if configured
endpoints = self._config.embedding_endpoints
strategy = self._config.embedding_strategy
cooldown = self._config.embedding_cooldown
self._embedder = get_embedder_factory(
backend="litellm",
model=model,
endpoints=endpoints if endpoints else None,
strategy=strategy,
cooldown=cooldown,
)
else:
# Fallback to fastembed with code profile
self._embedder = get_embedder_factory(
backend="fastembed",
profile="code",
use_gpu=True
)
return self._embedder
def close(self):

View File

@@ -7,6 +7,29 @@ import { join } from 'path';
import { homedir } from 'os';
import type { RouteContext } from './types.js';
/**
* Get the ccw-help index directory path (pure function)
* Priority: project path (.claude/skills/ccw-help/index) > user path (~/.claude/skills/ccw-help/index)
* @param projectPath - The project path to check first
*/
function getIndexDir(projectPath: string | null): string | null {
// Try project path first
if (projectPath) {
const projectIndexDir = join(projectPath, '.claude', 'skills', 'ccw-help', 'index');
if (existsSync(projectIndexDir)) {
return projectIndexDir;
}
}
// Fall back to user path
const userIndexDir = join(homedir(), '.claude', 'skills', 'ccw-help', 'index');
if (existsSync(userIndexDir)) {
return userIndexDir;
}
return null;
}
// ========== In-Memory Cache ==========
interface CacheEntry {
data: any;
@@ -61,14 +84,15 @@ let watchersInitialized = false;
/**
* Initialize file watchers for JSON indexes
* @param projectPath - The project path to resolve index directory
*/
function initializeFileWatchers(): void {
function initializeFileWatchers(projectPath: string | null): void {
if (watchersInitialized) return;
const indexDir = join(homedir(), '.claude', 'skills', 'command-guide', 'index');
const indexDir = getIndexDir(projectPath);
if (!existsSync(indexDir)) {
console.warn(`Command guide index directory not found: ${indexDir}`);
if (!indexDir) {
console.warn(`ccw-help index directory not found in project or user paths`);
return;
}
@@ -152,15 +176,20 @@ function groupCommandsByCategory(commands: any[]): any {
* @returns true if route was handled, false otherwise
*/
export async function handleHelpRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res } = ctx;
const { pathname, url, req, res, initialPath } = ctx;
// Initialize file watchers on first request
initializeFileWatchers();
initializeFileWatchers(initialPath);
const indexDir = join(homedir(), '.claude', 'skills', 'command-guide', 'index');
const indexDir = getIndexDir(initialPath);
// API: Get all commands with optional search
if (pathname === '/api/help/commands') {
if (!indexDir) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'ccw-help index directory not found' }));
return true;
}
const searchQuery = url.searchParams.get('q') || '';
const filePath = join(indexDir, 'all-commands.json');
@@ -191,6 +220,11 @@ export async function handleHelpRoutes(ctx: RouteContext): Promise<boolean> {
// API: Get workflow command relationships
if (pathname === '/api/help/workflows') {
if (!indexDir) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'ccw-help index directory not found' }));
return true;
}
const filePath = join(indexDir, 'command-relationships.json');
const relationships = getCachedData('command-relationships', filePath);
@@ -207,6 +241,11 @@ export async function handleHelpRoutes(ctx: RouteContext): Promise<boolean> {
// API: Get commands by category
if (pathname === '/api/help/commands/by-category') {
if (!indexDir) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'ccw-help index directory not found' }));
return true;
}
const filePath = join(indexDir, 'by-category.json');
const byCategory = getCachedData('by-category', filePath);

View File

@@ -334,12 +334,43 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
return true;
}
// Test connection using litellm client
const client = getLiteLLMClient();
const available = await client.isAvailable();
// Get the API key to test (prefer first key from apiKeys array, fall back to default apiKey)
let apiKeyValue: string | null = null;
if (provider.apiKeys && provider.apiKeys.length > 0) {
apiKeyValue = provider.apiKeys[0].key;
} else if (provider.apiKey) {
apiKeyValue = provider.apiKey;
}
if (!apiKeyValue) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'No API key configured for this provider' }));
return true;
}
// Resolve environment variables in the API key
const { resolveEnvVar } = await import('../../config/litellm-api-config-manager.js');
const resolvedKey = resolveEnvVar(apiKeyValue);
if (!resolvedKey) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'API key is empty or environment variable not set' }));
return true;
}
// Determine API base URL
const apiBase = provider.apiBase || getDefaultApiBase(provider.type);
// Test the API key connection
const testResult = await testApiKeyConnection(provider.type, apiBase, resolvedKey);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: available, provider: provider.type }));
res.end(JSON.stringify({
success: testResult.valid,
provider: provider.type,
latencyMs: testResult.latencyMs,
error: testResult.error,
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: (err as Error).message }));

View File

@@ -1256,5 +1256,89 @@ RULES: Be concise. Focus on practical understanding. Include function signatures
return true;
}
// API: Memory Queue - Add path to queue
if (pathname === '/api/memory/queue/add' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path: modulePath, tool = 'gemini', strategy = 'single-layer' } = body;
if (!modulePath) {
return { error: 'path is required', status: 400 };
}
try {
const { memoryQueueTool } = await import('../../tools/memory-update-queue.js');
const result = await memoryQueueTool.execute({
action: 'add',
path: modulePath,
tool,
strategy
}) as { queueSize?: number; willFlush?: boolean; flushed?: boolean };
// Broadcast queue update event
broadcastToClients({
type: 'MEMORY_QUEUE_UPDATED',
payload: {
action: 'add',
path: modulePath,
queueSize: result.queueSize || 0,
willFlush: result.willFlush || false,
flushed: result.flushed || false,
timestamp: new Date().toISOString()
}
});
return { success: true, ...result };
} catch (error: unknown) {
return { error: (error as Error).message, status: 500 };
}
});
return true;
}
// API: Memory Queue - Get queue status
if (pathname === '/api/memory/queue/status' && req.method === 'GET') {
try {
const { memoryQueueTool } = await import('../../tools/memory-update-queue.js');
const result = await memoryQueueTool.execute({ action: 'status' }) as Record<string, unknown>;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, ...result }));
} catch (error: unknown) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
}
return true;
}
// API: Memory Queue - Flush queue immediately
if (pathname === '/api/memory/queue/flush' && req.method === 'POST') {
handlePostRequest(req, res, async () => {
try {
const { memoryQueueTool } = await import('../../tools/memory-update-queue.js');
const result = await memoryQueueTool.execute({ action: 'flush' }) as {
processed?: number;
success?: boolean;
errors?: unknown[];
};
// Broadcast queue flushed event
broadcastToClients({
type: 'MEMORY_QUEUE_FLUSHED',
payload: {
processed: result.processed || 0,
success: result.success || false,
errors: result.errors?.length || 0,
timestamp: new Date().toISOString()
}
});
return { success: true, ...result };
} catch (error: unknown) {
return { error: (error as Error).message, status: 500 };
}
});
return true;
}
return false;
}

View File

@@ -9,6 +9,15 @@ import { getCliToolsStatus } from '../../tools/cli-executor.js';
import { checkVenvStatus, checkSemanticStatus } from '../../tools/codex-lens.js';
import type { RouteContext } from './types.js';
// Performance logging helper
const PERF_LOG_ENABLED = process.env.CCW_PERF_LOG === '1' || true; // Enable by default for debugging
function perfLog(label: string, startTime: number, extra?: Record<string, unknown>): void {
if (!PERF_LOG_ENABLED) return;
const duration = Date.now() - startTime;
const extraStr = extra ? ` | ${JSON.stringify(extra)}` : '';
console.log(`[PERF][Status] ${label}: ${duration}ms${extraStr}`);
}
/**
* Check CCW installation status
* Verifies that required workflow files are installed in user's home directory
@@ -62,16 +71,39 @@ export async function handleStatusRoutes(ctx: RouteContext): Promise<boolean> {
// API: Aggregated Status (all statuses in one call)
if (pathname === '/api/status/all') {
const totalStart = Date.now();
console.log('[PERF][Status] === /api/status/all START ===');
try {
// Check CCW installation status (sync, fast)
const ccwStart = Date.now();
const ccwInstallStatus = checkCcwInstallStatus();
perfLog('checkCcwInstallStatus', ccwStart);
// Execute all status checks in parallel with individual timing
const cliStart = Date.now();
const codexStart = Date.now();
const semanticStart = Date.now();
// Execute all status checks in parallel
const [cliStatus, codexLensStatus, semanticStatus] = await Promise.all([
getCliToolsStatus(),
checkVenvStatus(),
getCliToolsStatus().then(result => {
perfLog('getCliToolsStatus', cliStart, { toolCount: Object.keys(result).length });
return result;
}),
checkVenvStatus().then(result => {
perfLog('checkVenvStatus', codexStart, { ready: result.ready });
return result;
}),
// Always check semantic status (will return available: false if CodexLens not ready)
checkSemanticStatus().catch(() => ({ available: false, backend: null }))
checkSemanticStatus()
.then(result => {
perfLog('checkSemanticStatus', semanticStart, { available: result.available });
return result;
})
.catch(() => {
perfLog('checkSemanticStatus (error)', semanticStart);
return { available: false, backend: null };
})
]);
const response = {
@@ -82,10 +114,13 @@ export async function handleStatusRoutes(ctx: RouteContext): Promise<boolean> {
timestamp: new Date().toISOString()
};
perfLog('=== /api/status/all TOTAL ===', totalStart);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(response));
return true;
} catch (error) {
perfLog('=== /api/status/all ERROR ===', totalStart);
console.error('[Status Routes] Error fetching aggregated status:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));

View File

@@ -42,6 +42,10 @@ import { randomBytes } from 'crypto';
// Import health check service
import { getHealthCheckService } from './services/health-check-service.js';
// Import status check functions for warmup
import { checkSemanticStatus, checkVenvStatus } from '../tools/codex-lens.js';
import { getCliToolsStatus } from '../tools/cli-executor.js';
import type { ServerConfig } from '../types/config.js';
import type { PostRequestHandler } from './routes/types.js';
@@ -290,6 +294,56 @@ function setCsrfCookie(res: http.ServerResponse, token: string, maxAgeSeconds: n
appendSetCookie(res, attributes.join('; '));
}
/**
* Warmup function to pre-populate caches on server startup
* This runs asynchronously and non-blocking after the server starts
*/
async function warmupCaches(initialPath: string): Promise<void> {
console.log('[WARMUP] Starting cache warmup...');
const startTime = Date.now();
// Run all warmup tasks in parallel for faster startup
const warmupTasks = [
// Warmup semantic status cache (Python process startup - can be slow first time)
(async () => {
const taskStart = Date.now();
try {
const semanticStatus = await checkSemanticStatus();
console.log(`[WARMUP] Semantic status: ${semanticStatus.available ? 'available' : 'not available'} (${Date.now() - taskStart}ms)`);
} catch (err) {
console.warn(`[WARMUP] Semantic status check failed: ${(err as Error).message}`);
}
})(),
// Warmup venv status cache
(async () => {
const taskStart = Date.now();
try {
const venvStatus = await checkVenvStatus();
console.log(`[WARMUP] Venv status: ${venvStatus.ready ? 'ready' : 'not ready'} (${Date.now() - taskStart}ms)`);
} catch (err) {
console.warn(`[WARMUP] Venv status check failed: ${(err as Error).message}`);
}
})(),
// Warmup CLI tools status cache
(async () => {
const taskStart = Date.now();
try {
const cliStatus = await getCliToolsStatus();
const availableCount = Object.values(cliStatus).filter(s => s.available).length;
const totalCount = Object.keys(cliStatus).length;
console.log(`[WARMUP] CLI tools status: ${availableCount}/${totalCount} available (${Date.now() - taskStart}ms)`);
} catch (err) {
console.warn(`[WARMUP] CLI tools status check failed: ${(err as Error).message}`);
}
})()
];
await Promise.allSettled(warmupTasks);
console.log(`[WARMUP] Cache warmup complete (${Date.now() - startTime}ms total)`);
}
/**
* Generate dashboard HTML with embedded CSS and JS
*/
@@ -650,6 +704,14 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
console.warn('[Server] Failed to start health check service:', err);
}
// Start cache warmup asynchronously (non-blocking)
// Uses setImmediate to not delay server startup response
setImmediate(() => {
warmupCaches(initialPath).catch((err) => {
console.warn('[WARMUP] Cache warmup failed:', err);
});
});
resolve(server);
});
server.on('error', reject);

View File

@@ -72,6 +72,10 @@ export async function testApiKeyConnection(
return { valid: false, error: urlValidation.error };
}
// Normalize apiBase: remove trailing slashes to prevent URL construction issues
// e.g., "https://api.openai.com/v1/" -> "https://api.openai.com/v1"
const normalizedApiBase = apiBase.replace(/\/+$/, '');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const startTime = Date.now();
@@ -80,7 +84,7 @@ export async function testApiKeyConnection(
if (providerType === 'anthropic') {
// Anthropic format: Use /v1/models endpoint (no cost, no model dependency)
// This validates the API key without making a billable request
const response = await fetch(`${apiBase}/models`, {
const response = await fetch(`${normalizedApiBase}/models`, {
method: 'GET',
headers: {
'x-api-key': apiKey,
@@ -114,8 +118,10 @@ export async function testApiKeyConnection(
return { valid: false, error: errorMessage };
} else {
// OpenAI-compatible format: GET /v1/models
const modelsUrl = apiBase.endsWith('/v1') ? `${apiBase}/models` : `${apiBase}/v1/models`;
// OpenAI-compatible format: GET /v{N}/models
// Detect if URL already ends with a version pattern like /v1, /v2, /v4, etc.
const hasVersionSuffix = /\/v\d+$/.test(normalizedApiBase);
const modelsUrl = hasVersionSuffix ? `${normalizedApiBase}/models` : `${normalizedApiBase}/v1/models`;
const response = await fetch(modelsUrl, {
method: 'GET',
headers: {

View File

@@ -304,6 +304,51 @@
margin-top: 0;
}
/* Environment File Input Group */
.env-file-input-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.env-file-input-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.env-file-input-row .tool-config-input {
flex: 1;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.8125rem;
margin-top: 0;
}
.env-file-input-row .btn-sm {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
white-space: nowrap;
}
.env-file-hint {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin: 0;
padding: 0;
}
.env-file-hint i {
flex-shrink: 0;
opacity: 0.7;
}
.btn-ghost.text-destructive:hover {
background: hsl(var(--destructive) / 0.1);
}

View File

@@ -33,11 +33,14 @@ function initCliStatus() {
* Load all statuses using aggregated endpoint (single API call)
*/
async function loadAllStatuses() {
const totalStart = performance.now();
console.log('[PERF][Frontend] loadAllStatuses START');
// 1. 尝试从缓存获取(预加载的数据)
if (window.cacheManager) {
const cached = window.cacheManager.get('all-status');
if (cached) {
console.log('[CLI Status] Loaded all statuses from cache');
console.log(`[PERF][Frontend] Cache hit: ${(performance.now() - totalStart).toFixed(1)}ms`);
// 应用缓存数据
cliToolStatus = cached.cli || {};
codexLensStatus = cached.codexLens || { ready: false };
@@ -45,25 +48,32 @@ async function loadAllStatuses() {
ccwInstallStatus = cached.ccwInstall || { installed: true, workflowsInstalled: true, missingFiles: [], installPath: '' };
// Load CLI tools config, API endpoints, and CLI Settings这些有自己的缓存
const configStart = performance.now();
await Promise.all([
loadCliToolsConfig(),
loadApiEndpoints(),
loadCliSettingsEndpoints()
]);
console.log(`[PERF][Frontend] Config/Endpoints load: ${(performance.now() - configStart).toFixed(1)}ms`);
// Update badges
updateCliBadge();
updateCodexLensBadge();
updateCcwInstallBadge();
console.log(`[PERF][Frontend] loadAllStatuses TOTAL (cached): ${(performance.now() - totalStart).toFixed(1)}ms`);
return cached;
}
}
// 2. 缓存未命中,从服务器获取
try {
const fetchStart = performance.now();
console.log('[PERF][Frontend] Fetching /api/status/all...');
const response = await fetch('/api/status/all');
if (!response.ok) throw new Error('Failed to load status');
const data = await response.json();
console.log(`[PERF][Frontend] /api/status/all fetch: ${(performance.now() - fetchStart).toFixed(1)}ms`);
// 存入缓存
if (window.cacheManager) {
@@ -77,10 +87,11 @@ async function loadAllStatuses() {
ccwInstallStatus = data.ccwInstall || { installed: true, workflowsInstalled: true, missingFiles: [], installPath: '' };
// Load CLI tools config, API endpoints, and CLI Settings
await Promise.all([
loadCliToolsConfig(),
loadApiEndpoints(),
loadCliSettingsEndpoints()
const configStart = performance.now();
const [configResult, endpointsResult, settingsResult] = await Promise.all([
loadCliToolsConfig().then(r => { console.log(`[PERF][Frontend] loadCliToolsConfig: ${(performance.now() - configStart).toFixed(1)}ms`); return r; }),
loadApiEndpoints().then(r => { console.log(`[PERF][Frontend] loadApiEndpoints: ${(performance.now() - configStart).toFixed(1)}ms`); return r; }),
loadCliSettingsEndpoints().then(r => { console.log(`[PERF][Frontend] loadCliSettingsEndpoints: ${(performance.now() - configStart).toFixed(1)}ms`); return r; })
]);
// Update badges
@@ -88,9 +99,11 @@ async function loadAllStatuses() {
updateCodexLensBadge();
updateCcwInstallBadge();
console.log(`[PERF][Frontend] loadAllStatuses TOTAL: ${(performance.now() - totalStart).toFixed(1)}ms`);
return data;
} catch (err) {
console.error('Failed to load aggregated status:', err);
console.log(`[PERF][Frontend] loadAllStatuses ERROR after: ${(performance.now() - totalStart).toFixed(1)}ms`);
// Fallback to individual calls if aggregated endpoint fails
return await loadAllStatusesFallback();
}
@@ -1034,6 +1047,15 @@ async function startCodexLensInstall() {
progressBar.style.width = '100%';
statusText.textContent = 'Installation complete!';
// 清理缓存以确保刷新后获取最新状态
if (window.cacheManager) {
window.cacheManager.invalidate('all-status');
window.cacheManager.invalidate('dashboard-init');
}
if (typeof window.invalidateCodexLensCache === 'function') {
window.invalidateCodexLensCache();
}
setTimeout(() => {
closeCodexLensInstallWizard();
showRefreshToast('CodexLens installed successfully!', 'success');
@@ -1184,6 +1206,15 @@ async function startCodexLensUninstall() {
progressBar.style.width = '100%';
statusText.textContent = 'Uninstallation complete!';
// 清理缓存以确保刷新后获取最新状态
if (window.cacheManager) {
window.cacheManager.invalidate('all-status');
window.cacheManager.invalidate('dashboard-init');
}
if (typeof window.invalidateCodexLensCache === 'function') {
window.invalidateCodexLensCache();
}
setTimeout(() => {
closeCodexLensUninstallWizard();
showRefreshToast('CodexLens uninstalled successfully!', 'success');

View File

@@ -49,43 +49,17 @@ const HOOK_TEMPLATES = {
description: 'Auto-update code index when files are written or edited',
category: 'indexing'
},
'memory-update-related': {
'memory-update-queue': {
event: 'Stop',
matcher: '',
command: 'bash',
args: ['-c', 'ccw tool exec update_module_claude \'{"strategy":"related","tool":"gemini"}\''],
description: 'Update CLAUDE.md for changed modules when session ends',
args: ['-c', 'ccw tool exec memory_queue "{\\"action\\":\\"add\\",\\"path\\":\\"$CLAUDE_PROJECT_DIR\\"}"'],
description: 'Queue CLAUDE.md update when session ends (batched by threshold/timeout)',
category: 'memory',
configurable: true,
config: {
tool: { type: 'select', options: ['gemini', 'qwen', 'codex'], default: 'gemini', label: 'CLI Tool' },
strategy: { type: 'select', options: ['related', 'single-layer'], default: 'related', label: 'Strategy' }
}
},
'memory-update-periodic': {
event: 'PostToolUse',
matcher: 'Write|Edit',
command: 'bash',
args: ['-c', 'INTERVAL=300; LAST_FILE="$HOME/.claude/.last_memory_update"; mkdir -p "$HOME/.claude"; NOW=$(date +%s); LAST=0; [ -f "$LAST_FILE" ] && LAST=$(cat "$LAST_FILE" 2>/dev/null || echo 0); if [ $((NOW - LAST)) -ge $INTERVAL ]; then echo $NOW > "$LAST_FILE"; ccw tool exec update_module_claude \'{"strategy":"related","tool":"gemini"}\' & fi'],
description: 'Periodically update CLAUDE.md (default: 5 min interval)',
category: 'memory',
configurable: true,
config: {
tool: { type: 'select', options: ['gemini', 'qwen', 'codex'], default: 'gemini', label: 'CLI Tool' },
interval: { type: 'number', default: 300, min: 60, max: 3600, label: 'Interval (seconds)', step: 60 }
}
},
'memory-update-count-based': {
event: 'PostToolUse',
matcher: 'Write|Edit',
command: 'bash',
args: ['-c', 'THRESHOLD=10; COUNT_FILE="$HOME/.claude/.memory_update_count"; mkdir -p "$HOME/.claude"; INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty" 2>/dev/null); [ -z "$FILE_PATH" ] && exit 0; COUNT=0; [ -f "$COUNT_FILE" ] && COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0); COUNT=$((COUNT + 1)); echo $COUNT > "$COUNT_FILE"; if [ $COUNT -ge $THRESHOLD ]; then echo 0 > "$COUNT_FILE"; ccw tool exec update_module_claude \'{"strategy":"related","tool":"gemini"}\' & fi'],
description: 'Update CLAUDE.md when file changes reach threshold (default: 10 files)',
category: 'memory',
configurable: true,
config: {
tool: { type: 'select', options: ['gemini', 'qwen', 'codex'], default: 'gemini', label: 'CLI Tool' },
threshold: { type: 'number', default: 10, min: 3, max: 50, label: 'File count threshold', step: 1 }
threshold: { type: 'number', default: 5, min: 1, max: 20, label: 'Threshold (paths)', step: 1 },
timeout: { type: 'number', default: 300, min: 60, max: 1800, label: 'Timeout (seconds)', step: 60 }
}
},
// SKILL Context Loader templates
@@ -210,33 +184,19 @@ const HOOK_TEMPLATES = {
const WIZARD_TEMPLATES = {
'memory-update': {
name: 'Memory Update Hook',
description: 'Automatically update CLAUDE.md documentation based on code changes',
description: 'Queue-based CLAUDE.md updates with configurable threshold and timeout',
icon: 'brain',
options: [
{
id: 'on-stop',
name: 'On Session End',
description: 'Update documentation when Claude session ends',
templateId: 'memory-update-related'
},
{
id: 'periodic',
name: 'Periodic Update',
description: 'Update documentation at regular intervals during session',
templateId: 'memory-update-periodic'
},
{
id: 'count-based',
name: 'Count-Based Update',
description: 'Update documentation when file changes reach threshold',
templateId: 'memory-update-count-based'
id: 'queue',
name: 'Queue-Based Update',
description: 'Batch updates when threshold reached or timeout expires',
templateId: 'memory-update-queue'
}
],
configFields: [
{ key: 'tool', type: 'select', label: 'CLI Tool', options: ['gemini', 'qwen', 'codex'], default: 'gemini', description: 'Tool for documentation generation' },
{ key: 'interval', type: 'number', label: 'Interval (seconds)', default: 300, min: 60, max: 3600, step: 60, showFor: ['periodic'], description: 'Time between updates' },
{ key: 'threshold', type: 'number', label: 'File Count Threshold', default: 10, min: 3, max: 50, step: 1, showFor: ['count-based'], description: 'Number of file changes to trigger update' },
{ key: 'strategy', type: 'select', label: 'Update Strategy', options: ['related', 'single-layer'], default: 'related', description: 'Related: changed modules, Single-layer: current directory' }
{ key: 'threshold', type: 'number', label: 'Threshold (paths)', default: 5, min: 1, max: 20, step: 1, description: 'Number of paths to trigger batch update' },
{ key: 'timeout', type: 'number', label: 'Timeout (seconds)', default: 300, min: 60, max: 1800, step: 60, description: 'Auto-flush queue after this time' }
]
},
'skill-context': {
@@ -730,9 +690,7 @@ function renderWizardModalContent() {
// Helper to get translated option names
const getOptionName = (optId) => {
if (wizardId === 'memory-update') {
if (optId === 'on-stop') return t('hook.wizard.onSessionEnd');
if (optId === 'periodic') return t('hook.wizard.periodicUpdate');
if (optId === 'count-based') return t('hook.wizard.countBasedUpdate');
if (optId === 'queue') return t('hook.wizard.queueBasedUpdate') || 'Queue-Based Update';
}
if (wizardId === 'memory-setup') {
if (optId === 'file-read') return t('hook.wizard.fileReadTracker');
@@ -748,9 +706,7 @@ function renderWizardModalContent() {
const getOptionDesc = (optId) => {
if (wizardId === 'memory-update') {
if (optId === 'on-stop') return t('hook.wizard.onSessionEndDesc');
if (optId === 'periodic') return t('hook.wizard.periodicUpdateDesc');
if (optId === 'count-based') return t('hook.wizard.countBasedUpdateDesc');
if (optId === 'queue') return t('hook.wizard.queueBasedUpdateDesc') || 'Batch updates when threshold reached or timeout expires';
}
if (wizardId === 'memory-setup') {
if (optId === 'file-read') return t('hook.wizard.fileReadTrackerDesc');
@@ -767,20 +723,16 @@ function renderWizardModalContent() {
// Helper to get translated field labels
const getFieldLabel = (fieldKey) => {
const labels = {
'tool': t('hook.wizard.cliTool'),
'interval': t('hook.wizard.intervalSeconds'),
'threshold': t('hook.wizard.fileCountThreshold'),
'strategy': t('hook.wizard.updateStrategy')
'threshold': t('hook.wizard.thresholdPaths') || 'Threshold (paths)',
'timeout': t('hook.wizard.timeoutSeconds') || 'Timeout (seconds)'
};
return labels[fieldKey] || wizard.configFields.find(f => f.key === fieldKey)?.label || fieldKey;
};
const getFieldDesc = (fieldKey) => {
const descs = {
'tool': t('hook.wizard.toolForDocGen'),
'interval': t('hook.wizard.timeBetweenUpdates'),
'threshold': t('hook.wizard.fileCountThresholdDesc'),
'strategy': t('hook.wizard.relatedStrategy')
'threshold': t('hook.wizard.thresholdPathsDesc') || 'Number of paths to trigger batch update',
'timeout': t('hook.wizard.timeoutSecondsDesc') || 'Auto-flush queue after this time'
};
return descs[fieldKey] || wizard.configFields.find(f => f.key === fieldKey)?.description || '';
};
@@ -1154,21 +1106,10 @@ function generateWizardCommand() {
}
// Handle memory-update wizard (default)
const tool = wizardConfig.tool || 'gemini';
const strategy = wizardConfig.strategy || 'related';
const interval = wizardConfig.interval || 300;
const threshold = wizardConfig.threshold || 10;
// Build the ccw tool command based on configuration
const params = JSON.stringify({ strategy, tool });
if (triggerType === 'periodic') {
return `INTERVAL=${interval}; LAST_FILE="$HOME/.claude/.last_memory_update"; mkdir -p "$HOME/.claude"; NOW=$(date +%s); LAST=0; [ -f "$LAST_FILE" ] && LAST=$(cat "$LAST_FILE" 2>/dev/null || echo 0); if [ $((NOW - LAST)) -ge $INTERVAL ]; then echo $NOW > "$LAST_FILE"; ccw tool exec update_module_claude '${params}' & fi`;
} else if (triggerType === 'count-based') {
return `THRESHOLD=${threshold}; COUNT_FILE="$HOME/.claude/.memory_update_count"; mkdir -p "$HOME/.claude"; INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty" 2>/dev/null); [ -z "$FILE_PATH" ] && exit 0; COUNT=0; [ -f "$COUNT_FILE" ] && COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0); COUNT=$((COUNT + 1)); echo $COUNT > "$COUNT_FILE"; if [ $COUNT -ge $THRESHOLD ]; then echo 0 > "$COUNT_FILE"; ccw tool exec update_module_claude '${params}' & fi`;
} else {
return `ccw tool exec update_module_claude '${params}'`;
}
// Now uses memory_queue for batched updates with configurable threshold/timeout
// The command adds to queue, configuration is applied separately via submitHookWizard
const params = `"{\\"action\\":\\"add\\",\\"path\\":\\"$CLAUDE_PROJECT_DIR\\"}"`;
return `ccw tool exec memory_queue ${params}`;
}
async function submitHookWizard() {
@@ -1263,6 +1204,26 @@ async function submitHookWizard() {
}
await saveHook(scope, baseTemplate.event, hookData);
// For memory-update wizard, also configure queue settings
if (wizard.id === 'memory-update') {
const threshold = wizardConfig.threshold || 5;
const timeout = wizardConfig.timeout || 300;
try {
const configParams = JSON.stringify({ action: 'configure', threshold, timeout });
const response = await fetch('/api/tools/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool: 'memory_queue', params: configParams })
});
if (response.ok) {
showRefreshToast(`Queue configured: threshold=${threshold}, timeout=${timeout}s`, 'success');
}
} catch (e) {
console.warn('Failed to configure memory queue:', e);
}
}
closeHookWizardModal();
}

View File

@@ -84,6 +84,147 @@ function getCliMode() {
return currentCliMode;
}
// ========== Cross-Platform MCP Helpers ==========
/**
* Build cross-platform MCP server configuration
* On Windows, wraps npx/node/python commands with cmd /c for proper execution
* @param {string} command - The command to run (e.g., 'npx', 'node', 'python')
* @param {string[]} args - Command arguments
* @param {object} [options] - Additional options (env, type, etc.)
* @returns {object} MCP server configuration
*/
function buildCrossPlatformMcpConfig(command, args = [], options = {}) {
const { env, type, ...rest } = options;
// Commands that need cmd /c wrapper on Windows
const windowsWrappedCommands = ['npx', 'npm', 'node', 'python', 'python3', 'pip', 'pip3', 'pnpm', 'yarn', 'bun'];
const needsWindowsWrapper = isWindowsPlatform && windowsWrappedCommands.includes(command.toLowerCase());
const config = needsWindowsWrapper
? { command: 'cmd', args: ['/c', command, ...args] }
: { command, args };
// Add optional fields
if (type) config.type = type;
if (env && Object.keys(env).length > 0) config.env = env;
Object.assign(config, rest);
return config;
}
/**
* Check if MCP config needs Windows cmd /c wrapper
* @param {object} serverConfig - MCP server configuration
* @returns {object} { needsWrapper: boolean, command: string }
*/
function checkWindowsMcpCompatibility(serverConfig) {
if (!isWindowsPlatform) return { needsWrapper: false };
const command = serverConfig.command?.toLowerCase() || '';
const windowsWrappedCommands = ['npx', 'npm', 'node', 'python', 'python3', 'pip', 'pip3', 'pnpm', 'yarn', 'bun'];
// Already wrapped with cmd
if (command === 'cmd') return { needsWrapper: false };
const needsWrapper = windowsWrappedCommands.includes(command);
return { needsWrapper, command: serverConfig.command };
}
/**
* Auto-fix MCP config for Windows platform
* @param {object} serverConfig - Original MCP server configuration
* @returns {object} Fixed configuration (or original if no fix needed)
*/
function autoFixWindowsMcpConfig(serverConfig) {
const { needsWrapper, command } = checkWindowsMcpCompatibility(serverConfig);
if (!needsWrapper) return serverConfig;
// Create new config with cmd /c wrapper
const fixedConfig = {
...serverConfig,
command: 'cmd',
args: ['/c', command, ...(serverConfig.args || [])]
};
return fixedConfig;
}
/**
* Show Windows compatibility warning for MCP config
* @param {string} serverName - Name of the MCP server
* @param {object} serverConfig - MCP server configuration
* @returns {Promise<boolean>} True if user confirms auto-fix, false to keep original
*/
async function showWindowsMcpCompatibilityWarning(serverName, serverConfig) {
const { needsWrapper, command } = checkWindowsMcpCompatibility(serverConfig);
if (!needsWrapper) return false;
// Show warning toast with auto-fix option
const message = t('mcp.windows.compatibilityWarning', {
name: serverName,
command: command
});
return new Promise((resolve) => {
// Create custom toast with action buttons
const toastContainer = document.getElementById('refreshToast') || createToastContainer();
const toastId = `windows-mcp-warning-${Date.now()}`;
const toastHtml = `
<div id="${toastId}" class="fixed bottom-4 right-4 bg-warning text-warning-foreground p-4 rounded-lg shadow-lg max-w-md z-50 animate-slide-up">
<div class="flex items-start gap-3">
<i data-lucide="alert-triangle" class="w-5 h-5 flex-shrink-0 mt-0.5"></i>
<div class="flex-1">
<p class="font-medium mb-2">${t('mcp.windows.title')}</p>
<p class="text-sm opacity-90 mb-3">${message}</p>
<div class="flex gap-2">
<button class="px-3 py-1.5 text-sm bg-background text-foreground rounded hover:opacity-90"
onclick="document.getElementById('${toastId}').remove(); window._mcpWindowsResolve && window._mcpWindowsResolve(true)">
${t('mcp.windows.autoFix')}
</button>
<button class="px-3 py-1.5 text-sm border border-current rounded hover:opacity-90"
onclick="document.getElementById('${toastId}').remove(); window._mcpWindowsResolve && window._mcpWindowsResolve(false)">
${t('mcp.windows.keepOriginal')}
</button>
</div>
</div>
<button onclick="document.getElementById('${toastId}').remove(); window._mcpWindowsResolve && window._mcpWindowsResolve(false)"
class="text-current opacity-70 hover:opacity-100">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
</div>
`;
// Store resolve function globally for button clicks
window._mcpWindowsResolve = (result) => {
delete window._mcpWindowsResolve;
resolve(result);
};
document.body.insertAdjacentHTML('beforeend', toastHtml);
// Initialize icons
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
// Auto-dismiss after 15 seconds (keep original)
setTimeout(() => {
const toast = document.getElementById(toastId);
if (toast) {
toast.remove();
if (window._mcpWindowsResolve) {
window._mcpWindowsResolve(false);
}
}
}, 15000);
});
}
// ========== Codex MCP Functions ==========
/**
@@ -847,6 +988,19 @@ async function submitMcpCreateFromJson() {
}
async function createMcpServerWithConfig(name, serverConfig, scope = 'project') {
// Check Windows compatibility and offer auto-fix if needed
const { needsWrapper } = checkWindowsMcpCompatibility(serverConfig);
let finalConfig = serverConfig;
if (needsWrapper) {
// Show warning and ask user whether to auto-fix
const shouldAutoFix = await showWindowsMcpCompatibilityWarning(name, serverConfig);
if (shouldAutoFix) {
finalConfig = autoFixWindowsMcpConfig(serverConfig);
console.log('[MCP] Auto-fixed config for Windows:', finalConfig);
}
}
// Submit to API
try {
let response;
@@ -859,7 +1013,7 @@ async function createMcpServerWithConfig(name, serverConfig, scope = 'project')
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverName: name,
serverConfig: serverConfig
serverConfig: finalConfig
})
});
scopeLabel = 'Codex';
@@ -869,7 +1023,7 @@ async function createMcpServerWithConfig(name, serverConfig, scope = 'project')
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverName: name,
serverConfig: serverConfig
serverConfig: finalConfig
})
});
scopeLabel = 'global';
@@ -880,7 +1034,7 @@ async function createMcpServerWithConfig(name, serverConfig, scope = 'project')
body: JSON.stringify({
projectPath: projectPath,
serverName: name,
serverConfig: serverConfig
serverConfig: finalConfig
})
});
scopeLabel = 'project';
@@ -1231,16 +1385,14 @@ const RECOMMENDED_MCP_SERVERS = [
descKey: 'mcp.ace-tool.field.token.desc'
}
],
buildConfig: (values) => ({
command: 'npx',
args: [
'ace-tool',
'--base-url',
values.baseUrl || 'https://acemcp.heroman.wtf/relay/',
'--token',
values.token
]
})
// Uses buildCrossPlatformMcpConfig for automatic Windows cmd /c wrapping
buildConfig: (values) => buildCrossPlatformMcpConfig('npx', [
'ace-tool',
'--base-url',
values.baseUrl || 'https://acemcp.heroman.wtf/relay/',
'--token',
values.token
])
},
{
id: 'chrome-devtools',
@@ -1249,12 +1401,8 @@ const RECOMMENDED_MCP_SERVERS = [
icon: 'chrome',
category: 'browser',
fields: [],
buildConfig: () => ({
type: 'stdio',
command: 'npx',
args: ['chrome-devtools-mcp@latest'],
env: {}
})
// Uses buildCrossPlatformMcpConfig for automatic Windows cmd /c wrapping
buildConfig: () => buildCrossPlatformMcpConfig('npx', ['chrome-devtools-mcp@latest'], { type: 'stdio' })
},
{
id: 'exa',
@@ -1273,16 +1421,10 @@ const RECOMMENDED_MCP_SERVERS = [
descKey: 'mcp.exa.field.apiKey.desc'
}
],
// Uses buildCrossPlatformMcpConfig for automatic Windows cmd /c wrapping
buildConfig: (values) => {
const config = {
command: 'npx',
args: ['-y', 'exa-mcp-server']
};
// Only add env if API key is provided
if (values.apiKey) {
config.env = { EXA_API_KEY: values.apiKey };
}
return config;
const env = values.apiKey ? { EXA_API_KEY: values.apiKey } : undefined;
return buildCrossPlatformMcpConfig('npx', ['-y', 'exa-mcp-server'], { env });
}
}
];

View File

@@ -415,10 +415,15 @@ function handleNotification(data) {
'CodexLens'
);
}
// Invalidate CodexLens page cache to ensure fresh data on next visit
// Invalidate all CodexLens related caches to ensure fresh data on refresh
// Must clear both codexlens-specific cache AND global status cache
if (window.cacheManager) {
window.cacheManager.invalidate('all-status');
window.cacheManager.invalidate('dashboard-init');
}
if (typeof window.invalidateCodexLensCache === 'function') {
window.invalidateCodexLensCache();
console.log('[CodexLens] Cache invalidated after installation');
console.log('[CodexLens] All caches invalidated after installation');
}
// Refresh CLI status if active
if (typeof loadCodexLensStatus === 'function') {
@@ -443,10 +448,15 @@ function handleNotification(data) {
'CodexLens'
);
}
// Invalidate CodexLens page cache to ensure fresh data on next visit
// Invalidate all CodexLens related caches to ensure fresh data on refresh
// Must clear both codexlens-specific cache AND global status cache
if (window.cacheManager) {
window.cacheManager.invalidate('all-status');
window.cacheManager.invalidate('dashboard-init');
}
if (typeof window.invalidateCodexLensCache === 'function') {
window.invalidateCodexLensCache();
console.log('[CodexLens] Cache invalidated after uninstallation');
console.log('[CodexLens] All caches invalidated after uninstallation');
}
// Refresh CLI status if active
if (typeof loadCodexLensStatus === 'function') {

View File

@@ -261,6 +261,13 @@ const i18n = {
'cli.wrapper': 'Wrapper',
'cli.customClaudeSettings': 'Custom Claude CLI settings',
'cli.updateFailed': 'Failed to update',
// CLI Tool Config - Environment File
'cli.envFile': 'Environment File',
'cli.envFileOptional': '(optional)',
'cli.envFilePlaceholder': 'Path to .env file (e.g., ~/.gemini-env or C:/Users/xxx/.env)',
'cli.envFileHint': 'Load environment variables (e.g., API keys) before CLI execution. Supports ~ for home directory.',
'cli.envFileBrowse': 'Browse',
// CodexLens Configuration
'codexlens.config': 'CodexLens Configuration',
@@ -995,6 +1002,12 @@ const i18n = {
'mcp.clickToEdit': 'Click to edit',
'mcp.clickToViewDetails': 'Click to view details',
// Windows MCP Compatibility
'mcp.windows.title': 'Windows Compatibility Warning',
'mcp.windows.compatibilityWarning': 'The MCP server "{name}" uses "{command}" which requires "cmd /c" wrapper on Windows to work properly with Claude Code.',
'mcp.windows.autoFix': 'Auto-fix (Recommended)',
'mcp.windows.keepOriginal': 'Keep Original',
// Hook Manager
'hook.projectHooks': 'Project Hooks',
'hook.projectFile': '.claude/settings.json',
@@ -2415,6 +2428,13 @@ const i18n = {
'cli.wrapper': '封装',
'cli.customClaudeSettings': '自定义 Claude CLI 配置',
'cli.updateFailed': '更新失败',
// CLI 工具配置 - 环境文件
'cli.envFile': '环境文件',
'cli.envFileOptional': '(可选)',
'cli.envFilePlaceholder': '.env 文件路径(如 ~/.gemini-env 或 C:/Users/xxx/.env',
'cli.envFileHint': '在 CLI 执行前加载环境变量(如 API 密钥)。支持 ~ 表示用户目录。',
'cli.envFileBrowse': '浏览',
// CodexLens 配置
'codexlens.config': 'CodexLens 配置',
@@ -3128,6 +3148,12 @@ const i18n = {
'mcp.clickToEdit': '点击编辑',
'mcp.clickToViewDetails': '点击查看详情',
// Windows MCP 兼容性
'mcp.windows.title': 'Windows 兼容性警告',
'mcp.windows.compatibilityWarning': 'MCP 服务器 "{name}" 使用的 "{command}" 命令需要在 Windows 上添加 "cmd /c" 包装才能与 Claude Code 正常工作。',
'mcp.windows.autoFix': '自动修复(推荐)',
'mcp.windows.keepOriginal': '保持原样',
// Hook Manager
'hook.projectHooks': '项目钩子',
'hook.projectFile': '.claude/settings.json',

View File

@@ -523,6 +523,27 @@ function buildToolConfigModalContent(tool, config, models, status) {
'</div>' +
'</div>' +
// Environment File Section (only for builtin tools: gemini, qwen)
(tool === 'gemini' || tool === 'qwen' ? (
'<div class="tool-config-section">' +
'<h4><i data-lucide="file-key" class="w-3.5 h-3.5"></i> ' + t('cli.envFile') + ' <span class="text-muted">' + t('cli.envFileOptional') + '</span></h4>' +
'<div class="env-file-input-group">' +
'<div class="env-file-input-row">' +
'<input type="text" id="envFileInput" class="tool-config-input" ' +
'placeholder="' + t('cli.envFilePlaceholder') + '" ' +
'value="' + (config.envFile ? escapeHtml(config.envFile) : '') + '" />' +
'<button type="button" class="btn-sm btn-outline" id="envFileBrowseBtn">' +
'<i data-lucide="folder-open" class="w-3.5 h-3.5"></i> ' + t('cli.envFileBrowse') +
'</button>' +
'</div>' +
'<p class="env-file-hint">' +
'<i data-lucide="info" class="w-3 h-3"></i> ' +
t('cli.envFileHint') +
'</p>' +
'</div>' +
'</div>'
) : '') +
// Footer
'<div class="tool-config-footer">' +
'<button class="btn btn-outline" onclick="closeModal()">' + t('common.cancel') + '</button>' +
@@ -701,12 +722,23 @@ function initToolConfigModalEvents(tool, currentConfig, models) {
return;
}
// Get envFile value (only for gemini/qwen)
var envFileInput = document.getElementById('envFileInput');
var envFile = envFileInput ? envFileInput.value.trim() : '';
try {
await updateCliToolConfig(tool, {
var updateData = {
primaryModel: primaryModel,
secondaryModel: secondaryModel,
tags: currentTags
});
};
// Only include envFile for gemini/qwen tools
if (tool === 'gemini' || tool === 'qwen') {
updateData.envFile = envFile || null;
}
await updateCliToolConfig(tool, updateData);
// Reload config to reflect changes
await loadCliToolConfig();
showRefreshToast('Configuration saved', 'success');
@@ -719,6 +751,44 @@ function initToolConfigModalEvents(tool, currentConfig, models) {
};
}
// Environment file browse button (only for gemini/qwen)
var envFileBrowseBtn = document.getElementById('envFileBrowseBtn');
if (envFileBrowseBtn) {
envFileBrowseBtn.onclick = async function() {
try {
// Use file dialog API if available
var response = await fetch('/api/dialog/open-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: t('cli.envFile'),
filters: [
{ name: 'Environment Files', extensions: ['env'] },
{ name: 'All Files', extensions: ['*'] }
],
defaultPath: ''
})
});
if (response.ok) {
var data = await response.json();
if (data.filePath) {
var envFileInput = document.getElementById('envFileInput');
if (envFileInput) {
envFileInput.value = data.filePath;
}
}
} else {
// Fallback: prompt user to enter path manually
showRefreshToast('File dialog not available. Please enter path manually.', 'info');
}
} catch (err) {
console.error('Failed to open file dialog:', err);
showRefreshToast('File dialog not available. Please enter path manually.', 'info');
}
};
}
// Initialize lucide icons in modal
if (window.lucide) lucide.createIcons();
}

View File

@@ -72,6 +72,10 @@ function invalidateCache(key) {
Object.values(CACHE_KEY_MAP).forEach(function(k) {
window.cacheManager.invalidate(k);
});
// 重要:同时清理包含 CodexLens 状态的全局缓存
// 这些缓存在 cli-status.js 中使用,包含 codexLens.ready 状态
window.cacheManager.invalidate('all-status');
window.cacheManager.invalidate('dashboard-init');
}
}
@@ -788,6 +792,12 @@ function initCodexLensConfigEvents(currentConfig) {
if (result.success) {
showRefreshToast(t('codexlens.configSaved'), 'success');
// Invalidate config cache to ensure fresh data on next load
if (window.cacheManager) {
window.cacheManager.invalidate('codexlens-config');
}
closeModal();
// Refresh CodexLens status
@@ -5385,7 +5395,7 @@ function initCodexLensManagerPageEvents(currentConfig) {
try {
var response = await csrfFetch('/api/codexlens/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ index_dir: newIndexDir }) });
var result = await response.json();
if (result.success) { showRefreshToast(t('codexlens.configSaved'), 'success'); renderCodexLensManager(); }
if (result.success) { if (window.cacheManager) { window.cacheManager.invalidate('codexlens-config'); } showRefreshToast(t('codexlens.configSaved'), 'success'); renderCodexLensManager(); }
else { showRefreshToast(t('common.saveFailed') + ': ' + result.error, 'error'); }
} catch (err) { showRefreshToast(t('common.error') + ': ' + err.message, 'error'); }
saveBtn.disabled = false;

View File

@@ -34,6 +34,11 @@ export interface ClaudeCliTool {
* Used to lookup endpoint configuration in litellm-api-config.json
*/
id?: string;
/**
* Path to .env file for loading environment variables before CLI execution
* Supports both absolute paths and paths relative to home directory (e.g., ~/.my-env)
*/
envFile?: string;
}
export type CliToolName = 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode' | string;
@@ -808,6 +813,7 @@ export function getToolConfig(projectDir: string, tool: string): {
primaryModel: string;
secondaryModel: string;
tags?: string[];
envFile?: string;
} {
const config = loadClaudeCliTools(projectDir);
const toolConfig = config.tools[tool];
@@ -826,7 +832,8 @@ export function getToolConfig(projectDir: string, tool: string): {
enabled: toolConfig.enabled,
primaryModel: toolConfig.primaryModel ?? '',
secondaryModel: toolConfig.secondaryModel ?? '',
tags: toolConfig.tags
tags: toolConfig.tags,
envFile: toolConfig.envFile
};
}
@@ -841,6 +848,7 @@ export function updateToolConfig(
primaryModel: string;
secondaryModel: string;
tags: string[];
envFile: string | null;
}>
): ClaudeCliToolsConfig {
const config = loadClaudeCliTools(projectDir);
@@ -858,6 +866,14 @@ export function updateToolConfig(
if (updates.tags !== undefined) {
config.tools[tool].tags = updates.tags;
}
// Handle envFile: set to undefined if null/empty, otherwise set value
if (updates.envFile !== undefined) {
if (updates.envFile === null || updates.envFile === '') {
delete config.tools[tool].envFile;
} else {
config.tools[tool].envFile = updates.envFile;
}
}
saveClaudeCliTools(projectDir, config);
}

View File

@@ -6,6 +6,9 @@
import { z } from 'zod';
import type { ToolSchema, ToolResult } from '../types/tool.js';
import { spawn, ChildProcess } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { validatePath } from '../utils/path-resolver.js';
import { escapeWindowsArg } from '../utils/shell-escape.js';
import { buildCommand, checkToolAvailability, clearToolCache, debugLog, errorLog, type NativeResumeConfig, type ToolAvailability } from './cli-executor-utils.js';
@@ -82,7 +85,73 @@ import { findEndpointById } from '../config/litellm-api-config-manager.js';
// CLI Settings (CLI封装) integration
import { loadEndpointSettings, getSettingsFilePath, findEndpoint } from '../config/cli-settings-manager.js';
import { loadClaudeCliTools } from './claude-cli-tools.js';
import { loadClaudeCliTools, getToolConfig } from './claude-cli-tools.js';
/**
* Parse .env file content into key-value pairs
* Supports: KEY=value, KEY="value", KEY='value', comments (#), empty lines
*/
function parseEnvFile(content: string): Record<string, string> {
const env: Record<string, string> = {};
const lines = content.split(/\r?\n/);
for (const line of lines) {
// Skip empty lines and comments
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
// Find first = sign
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
const key = trimmed.substring(0, eqIndex).trim();
let value = trimmed.substring(eqIndex + 1).trim();
// Remove surrounding quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (key) {
env[key] = value;
}
}
return env;
}
/**
* Load environment variables from .env file
* Supports ~ for home directory expansion
*/
function loadEnvFile(envFilePath: string): Record<string, string> {
try {
// Expand ~ to home directory
let resolvedPath = envFilePath;
if (resolvedPath.startsWith('~')) {
resolvedPath = path.join(os.homedir(), resolvedPath.slice(1));
}
// Resolve relative paths
if (!path.isAbsolute(resolvedPath)) {
resolvedPath = path.resolve(resolvedPath);
}
if (!fs.existsSync(resolvedPath)) {
debugLog('ENV_FILE', `Env file not found: ${resolvedPath}`);
return {};
}
const content = fs.readFileSync(resolvedPath, 'utf-8');
const envVars = parseEnvFile(content);
debugLog('ENV_FILE', `Loaded ${Object.keys(envVars).length} env vars from ${resolvedPath}`);
return envVars;
} catch (err) {
errorLog('ENV_FILE', `Failed to load env file: ${envFilePath}`, err as Error);
return {};
}
}
/**
* Execute Claude CLI with custom settings file (CLI封装)
@@ -746,6 +815,19 @@ async function executeCliTool(
const commandToSpawn = isWindows ? escapeWindowsArg(command) : command;
const argsToSpawn = isWindows ? args.map(escapeWindowsArg) : args;
// Load custom environment variables from envFile if configured (for gemini/qwen)
const toolConfig = getToolConfig(workingDir, tool);
let customEnv: Record<string, string> = {};
if (toolConfig.envFile) {
customEnv = loadEnvFile(toolConfig.envFile);
}
// Merge custom env with process.env (custom env takes precedence)
const spawnEnv = {
...process.env,
...customEnv
};
debugLog('SPAWN', `Spawning process`, {
command,
args,
@@ -754,13 +836,16 @@ async function executeCliTool(
useStdin,
platform: process.platform,
fullCommand: `${command} ${args.join(' ')}`,
hasCustomEnv: Object.keys(customEnv).length > 0,
customEnvKeys: Object.keys(customEnv),
...(isWindows ? { escapedCommand: commandToSpawn, escapedArgs: argsToSpawn, escapedFullCommand: `${commandToSpawn} ${argsToSpawn.join(' ')}` } : {})
});
const child = spawn(commandToSpawn, argsToSpawn, {
cwd: workingDir,
shell: isWindows, // Enable shell on Windows for .cmd files
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe']
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
env: spawnEnv
});
// Track current child process for cleanup on interruption
@@ -1190,6 +1275,9 @@ export {
* - api-endpoint: Check LiteLLM endpoint configuration exists
*/
export async function getCliToolsStatus(): Promise<Record<string, ToolAvailability>> {
const funcStart = Date.now();
debugLog('PERF', 'getCliToolsStatus START');
// Default built-in tools
const builtInTools = ['gemini', 'qwen', 'codex', 'claude', 'opencode'];
@@ -1202,6 +1290,7 @@ export async function getCliToolsStatus(): Promise<Record<string, ToolAvailabili
}
let toolsInfo: ToolInfo[] = builtInTools.map(name => ({ name, type: 'builtin' }));
const configLoadStart = Date.now();
try {
// Dynamic import to avoid circular dependencies
const { loadClaudeCliTools } = await import('./claude-cli-tools.js');
@@ -1225,11 +1314,15 @@ export async function getCliToolsStatus(): Promise<Record<string, ToolAvailabili
// Fallback to built-in tools if config load fails
debugLog('cli-executor', `Using built-in tools (config load failed: ${(e as Error).message})`);
}
debugLog('PERF', `Config load: ${Date.now() - configLoadStart}ms, tools: ${toolsInfo.length}`);
const results: Record<string, ToolAvailability> = {};
const toolTimings: Record<string, number> = {};
const checksStart = Date.now();
await Promise.all(toolsInfo.map(async (toolInfo) => {
const { name, type, enabled, id } = toolInfo;
const toolStart = Date.now();
// Check availability based on tool type
if (type === 'cli-wrapper') {
@@ -1271,8 +1364,13 @@ export async function getCliToolsStatus(): Promise<Record<string, ToolAvailabili
// For builtin: check system PATH availability
results[name] = await checkToolAvailability(name);
}
toolTimings[name] = Date.now() - toolStart;
}));
debugLog('PERF', `Tool checks: ${Date.now() - checksStart}ms | Individual: ${JSON.stringify(toolTimings)}`);
debugLog('PERF', `getCliToolsStatus TOTAL: ${Date.now() - funcStart}ms`);
return results;
}
@@ -1520,6 +1618,9 @@ export type { PromptFormat, ConcatOptions } from './cli-prompt-builder.js';
// Export utility functions and tool definition for backward compatibility
export { executeCliTool, checkToolAvailability, clearToolCache };
// Export env file utilities for testing
export { parseEnvFile, loadEnvFile };
// Export prompt concatenation utilities
export { PromptConcatenator, createPromptConcatenator, buildPrompt, buildMultiTurnPrompt } from './cli-prompt-builder.js';

View File

@@ -49,6 +49,14 @@ interface VenvStatusCache {
let venvStatusCache: VenvStatusCache | null = null;
const VENV_STATUS_TTL = 5 * 60 * 1000; // 5 minutes TTL
// Semantic status cache with TTL (same as venv cache)
interface SemanticStatusCache {
status: SemanticStatus;
timestamp: number;
}
let semanticStatusCache: SemanticStatusCache | null = null;
const SEMANTIC_STATUS_TTL = 5 * 60 * 1000; // 5 minutes TTL
// Track running indexing process for cancellation
let currentIndexingProcess: ReturnType<typeof spawn> | null = null;
let currentIndexingAborted = false;
@@ -147,8 +155,12 @@ function clearVenvStatusCache(): void {
* @returns Ready status
*/
async function checkVenvStatus(force = false): Promise<ReadyStatus> {
const funcStart = Date.now();
console.log('[PERF][CodexLens] checkVenvStatus START');
// Use cached result if available and not expired
if (!force && venvStatusCache && (Date.now() - venvStatusCache.timestamp < VENV_STATUS_TTL)) {
console.log(`[PERF][CodexLens] checkVenvStatus CACHE HIT: ${Date.now() - funcStart}ms`);
return venvStatusCache.status;
}
@@ -156,6 +168,7 @@ async function checkVenvStatus(force = false): Promise<ReadyStatus> {
if (!existsSync(CODEXLENS_VENV)) {
const result = { ready: false, error: 'Venv not found' };
venvStatusCache = { status: result, timestamp: Date.now() };
console.log(`[PERF][CodexLens] checkVenvStatus (no venv): ${Date.now() - funcStart}ms`);
return result;
}
@@ -163,12 +176,16 @@ async function checkVenvStatus(force = false): Promise<ReadyStatus> {
if (!existsSync(VENV_PYTHON)) {
const result = { ready: false, error: 'Python executable not found in venv' };
venvStatusCache = { status: result, timestamp: Date.now() };
console.log(`[PERF][CodexLens] checkVenvStatus (no python): ${Date.now() - funcStart}ms`);
return result;
}
// Check codexlens is importable
// Check codexlens and core dependencies are importable
const spawnStart = Date.now();
console.log('[PERF][CodexLens] checkVenvStatus spawning Python...');
return new Promise((resolve) => {
const child = spawn(VENV_PYTHON, ['-c', 'import codexlens; print(codexlens.__version__)'], {
const child = spawn(VENV_PYTHON, ['-c', 'import codexlens; import watchdog; print(codexlens.__version__)'], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 10000,
});
@@ -192,29 +209,54 @@ async function checkVenvStatus(force = false): Promise<ReadyStatus> {
}
// Cache the result
venvStatusCache = { status: result, timestamp: Date.now() };
console.log(`[PERF][CodexLens] checkVenvStatus Python spawn: ${Date.now() - spawnStart}ms | TOTAL: ${Date.now() - funcStart}ms | ready: ${result.ready}`);
resolve(result);
});
child.on('error', (err) => {
const result = { ready: false, error: `Failed to check venv: ${err.message}` };
venvStatusCache = { status: result, timestamp: Date.now() };
console.log(`[PERF][CodexLens] checkVenvStatus ERROR: ${Date.now() - funcStart}ms`);
resolve(result);
});
});
}
/**
* Clear semantic status cache (call after install/uninstall operations)
*/
function clearSemanticStatusCache(): void {
semanticStatusCache = null;
}
/**
* Check if semantic search dependencies are installed
* @param force - Force refresh cache (default: false)
* @returns Semantic status
*/
async function checkSemanticStatus(): Promise<SemanticStatus> {
async function checkSemanticStatus(force = false): Promise<SemanticStatus> {
const funcStart = Date.now();
console.log('[PERF][CodexLens] checkSemanticStatus START');
// Use cached result if available and not expired
if (!force && semanticStatusCache && (Date.now() - semanticStatusCache.timestamp < SEMANTIC_STATUS_TTL)) {
console.log(`[PERF][CodexLens] checkSemanticStatus CACHE HIT: ${Date.now() - funcStart}ms`);
return semanticStatusCache.status;
}
// First check if CodexLens is installed
const venvStatus = await checkVenvStatus();
if (!venvStatus.ready) {
return { available: false, error: 'CodexLens not installed' };
const result: SemanticStatus = { available: false, error: 'CodexLens not installed' };
semanticStatusCache = { status: result, timestamp: Date.now() };
console.log(`[PERF][CodexLens] checkSemanticStatus (no venv): ${Date.now() - funcStart}ms`);
return result;
}
// Check semantic module availability and accelerator info
const spawnStart = Date.now();
console.log('[PERF][CodexLens] checkSemanticStatus spawning Python...');
return new Promise((resolve) => {
const checkCode = `
import sys
@@ -274,21 +316,31 @@ except Exception as e:
const output = stdout.trim();
try {
const result = JSON.parse(output);
resolve({
console.log(`[PERF][CodexLens] checkSemanticStatus Python spawn: ${Date.now() - spawnStart}ms | TOTAL: ${Date.now() - funcStart}ms | available: ${result.available}`);
const status: SemanticStatus = {
available: result.available || false,
backend: result.backend,
accelerator: result.accelerator || 'CPU',
providers: result.providers || [],
litellmAvailable: result.litellm_available || false,
error: result.error
});
};
// Cache the result
semanticStatusCache = { status, timestamp: Date.now() };
resolve(status);
} catch {
resolve({ available: false, error: output || stderr || 'Unknown error' });
console.log(`[PERF][CodexLens] checkSemanticStatus PARSE ERROR: ${Date.now() - funcStart}ms`);
const errorStatus: SemanticStatus = { available: false, error: output || stderr || 'Unknown error' };
semanticStatusCache = { status: errorStatus, timestamp: Date.now() };
resolve(errorStatus);
}
});
child.on('error', (err) => {
resolve({ available: false, error: `Check failed: ${err.message}` });
console.log(`[PERF][CodexLens] checkSemanticStatus ERROR: ${Date.now() - funcStart}ms`);
const errorStatus: SemanticStatus = { available: false, error: `Check failed: ${err.message}` };
semanticStatusCache = { status: errorStatus, timestamp: Date.now() };
resolve(errorStatus);
});
});
}
@@ -583,6 +635,7 @@ async function bootstrapWithUv(gpuMode: GpuMode = 'cpu'): Promise<BootstrapResul
// Clear cache after successful installation
clearVenvStatusCache();
clearSemanticStatusCache();
console.log(`[CodexLens] Bootstrap with UV complete (${gpuMode} mode)`);
return { success: true, message: `Installed with UV (${gpuMode} mode)` };
}
@@ -878,6 +931,7 @@ async function bootstrapVenv(): Promise<BootstrapResult> {
// Clear cache after successful installation
clearVenvStatusCache();
clearSemanticStatusCache();
return { success: true };
} catch (err) {
return { success: false, error: `Failed to install codexlens: ${(err as Error).message}` };
@@ -1631,6 +1685,7 @@ async function uninstallCodexLens(): Promise<BootstrapResult> {
bootstrapChecked = false;
bootstrapReady = false;
clearVenvStatusCache();
clearSemanticStatusCache();
console.log('[CodexLens] CodexLens uninstalled successfully');
return { success: true, message: 'CodexLens uninstalled successfully' };

View File

@@ -30,6 +30,7 @@ import type { ProgressInfo } from './codex-lens.js';
import { uiGeneratePreviewTool } from './ui-generate-preview.js';
import { uiInstantiatePrototypesTool } from './ui-instantiate-prototypes.js';
import { updateModuleClaudeTool } from './update-module-claude.js';
import { memoryQueueTool } from './memory-update-queue.js';
interface LegacyTool {
name: string;
@@ -366,6 +367,7 @@ registerTool(toLegacyTool(skillContextLoaderMod));
registerTool(uiGeneratePreviewTool);
registerTool(uiInstantiatePrototypesTool);
registerTool(updateModuleClaudeTool);
registerTool(memoryQueueTool);
// Export for external tool registration
export { registerTool };

View File

@@ -10,14 +10,36 @@
*/
import { spawn } from 'child_process';
import { promisify } from 'util';
import { existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
export interface LiteLLMConfig {
pythonPath?: string; // Default 'python'
pythonPath?: string; // Default: CodexLens venv Python
configPath?: string; // Configuration file path
timeout?: number; // Default 60000ms
}
// Platform-specific constants for CodexLens venv
const IS_WINDOWS = process.platform === 'win32';
const CODEXLENS_VENV = join(homedir(), '.codexlens', 'venv');
const VENV_BIN_DIR = IS_WINDOWS ? 'Scripts' : 'bin';
const PYTHON_EXECUTABLE = IS_WINDOWS ? 'python.exe' : 'python';
/**
* Get the Python path from CodexLens venv
* Falls back to system 'python' if venv doesn't exist
* @returns Path to Python executable
*/
export function getCodexLensVenvPython(): string {
const venvPython = join(CODEXLENS_VENV, VENV_BIN_DIR, PYTHON_EXECUTABLE);
if (existsSync(venvPython)) {
return venvPython;
}
// Fallback to system Python if venv not available
return 'python';
}
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
@@ -51,7 +73,7 @@ export class LiteLLMClient {
private timeout: number;
constructor(config: LiteLLMConfig = {}) {
this.pythonPath = config.pythonPath || 'python';
this.pythonPath = config.pythonPath || getCodexLensVenvPython();
this.configPath = config.configPath;
this.timeout = config.timeout || 60000;
}

View File

@@ -3,7 +3,7 @@
* Integrates with context-cache for file packing and LiteLLM client for API calls
*/
import { getLiteLLMClient } from './litellm-client.js';
import { getLiteLLMClient, getCodexLensVenvPython } from './litellm-client.js';
import { handler as contextCacheHandler } from './context-cache.js';
import {
findEndpointById,
@@ -179,7 +179,7 @@ export async function executeLiteLLMEndpoint(
}
const client = getLiteLLMClient({
pythonPath: 'python',
pythonPath: getCodexLensVenvPython(),
timeout: 120000, // 2 minutes
});

View File

@@ -0,0 +1,499 @@
/**
* Memory Update Queue Tool
* Queue mechanism for batching CLAUDE.md updates
*
* Configuration:
* - Threshold: 5 paths trigger update
* - Timeout: 5 minutes auto-trigger
* - Storage: ~/.claude/.memory-queue.json
* - Deduplication: Same path only kept once
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { homedir } from 'os';
// Default configuration
const DEFAULT_THRESHOLD = 5;
const DEFAULT_TIMEOUT_SECONDS = 300; // 5 minutes
const QUEUE_FILE_PATH = join(homedir(), '.claude', '.memory-queue.json');
/**
* Get queue configuration (from file or defaults)
* @returns {{ threshold: number, timeoutMs: number }}
*/
function getQueueConfig() {
try {
if (existsSync(QUEUE_FILE_PATH)) {
const content = readFileSync(QUEUE_FILE_PATH, 'utf8');
const data = JSON.parse(content);
return {
threshold: data.config?.threshold || DEFAULT_THRESHOLD,
timeoutMs: (data.config?.timeout || DEFAULT_TIMEOUT_SECONDS) * 1000
};
}
} catch (e) {
// Use defaults
}
return {
threshold: DEFAULT_THRESHOLD,
timeoutMs: DEFAULT_TIMEOUT_SECONDS * 1000
};
}
// In-memory timeout reference (for cross-call persistence, we track via file timestamp)
let scheduledTimeoutId = null;
/**
* Ensure parent directory exists
*/
function ensureDir(filePath) {
const dir = dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
/**
* Load queue from file
* @returns {{ items: Array<{path: string, tool: string, strategy: string, addedAt: string}>, createdAt: string | null, config?: { threshold: number, timeout: number } }}
*/
function loadQueue() {
try {
if (existsSync(QUEUE_FILE_PATH)) {
const content = readFileSync(QUEUE_FILE_PATH, 'utf8');
const data = JSON.parse(content);
return {
items: Array.isArray(data.items) ? data.items : [],
createdAt: data.createdAt || null,
config: data.config || null
};
}
} catch (e) {
console.error('[MemoryQueue] Failed to load queue:', e.message);
}
return { items: [], createdAt: null, config: null };
}
/**
* Save queue to file
* @param {{ items: Array<{path: string, tool: string, strategy: string, addedAt: string}>, createdAt: string | null }} data
*/
function saveQueue(data) {
try {
ensureDir(QUEUE_FILE_PATH);
writeFileSync(QUEUE_FILE_PATH, JSON.stringify(data, null, 2), 'utf8');
} catch (e) {
console.error('[MemoryQueue] Failed to save queue:', e.message);
throw e;
}
}
/**
* Normalize path for comparison (handle Windows/Unix differences)
* @param {string} p
* @returns {string}
*/
function normalizePath(p) {
return resolve(p).replace(/\\/g, '/').toLowerCase();
}
/**
* Add path to queue with deduplication
* @param {string} path - Module path to update
* @param {{ tool?: string, strategy?: string }} options
* @returns {{ queued: boolean, queueSize: number, willFlush: boolean, message: string }}
*/
function addToQueue(path, options = {}) {
const { tool = 'gemini', strategy = 'single-layer' } = options;
const queue = loadQueue();
const config = getQueueConfig();
const normalizedPath = normalizePath(path);
const now = new Date().toISOString();
// Check for duplicates
const existingIndex = queue.items.findIndex(
item => normalizePath(item.path) === normalizedPath
);
if (existingIndex !== -1) {
// Update existing entry timestamp but keep it deduplicated
queue.items[existingIndex].addedAt = now;
queue.items[existingIndex].tool = tool;
queue.items[existingIndex].strategy = strategy;
saveQueue(queue);
return {
queued: false,
queueSize: queue.items.length,
willFlush: queue.items.length >= config.threshold,
message: `Path already in queue (updated): ${path}`
};
}
// Add new item
queue.items.push({
path,
tool,
strategy,
addedAt: now
});
// Set createdAt if this is the first item
if (!queue.createdAt) {
queue.createdAt = now;
}
saveQueue(queue);
const willFlush = queue.items.length >= config.threshold;
// Schedule timeout if not already scheduled
scheduleTimeout();
return {
queued: true,
queueSize: queue.items.length,
willFlush,
message: willFlush
? `Queue threshold reached (${queue.items.length}/${config.threshold}), will flush`
: `Added to queue (${queue.items.length}/${config.threshold})`
};
}
/**
* Get current queue status
* @returns {{ queueSize: number, threshold: number, items: Array, timeoutMs: number | null, createdAt: string | null }}
*/
function getQueueStatus() {
const queue = loadQueue();
const config = getQueueConfig();
let timeUntilTimeout = null;
if (queue.createdAt && queue.items.length > 0) {
const createdTime = new Date(queue.createdAt).getTime();
const elapsed = Date.now() - createdTime;
timeUntilTimeout = Math.max(0, config.timeoutMs - elapsed);
}
return {
queueSize: queue.items.length,
threshold: config.threshold,
items: queue.items,
timeoutMs: config.timeoutMs,
timeoutSeconds: config.timeoutMs / 1000,
timeUntilTimeout,
createdAt: queue.createdAt
};
}
/**
* Configure queue settings
* @param {{ threshold?: number, timeout?: number }} settings
* @returns {{ success: boolean, config: { threshold: number, timeout: number } }}
*/
function configureQueue(settings) {
const queue = loadQueue();
const currentConfig = getQueueConfig();
const newConfig = {
threshold: settings.threshold || currentConfig.threshold,
timeout: settings.timeout || (currentConfig.timeoutMs / 1000)
};
// Validate
if (newConfig.threshold < 1 || newConfig.threshold > 20) {
throw new Error('Threshold must be between 1 and 20');
}
if (newConfig.timeout < 60 || newConfig.timeout > 1800) {
throw new Error('Timeout must be between 60 and 1800 seconds');
}
queue.config = newConfig;
saveQueue(queue);
return {
success: true,
config: newConfig,
message: `Queue configured: threshold=${newConfig.threshold}, timeout=${newConfig.timeout}s`
};
}
/**
* Flush queue - execute batch update
* @returns {Promise<{ success: boolean, processed: number, results: Array, errors: Array }>}
*/
async function flushQueue() {
const queue = loadQueue();
if (queue.items.length === 0) {
return {
success: true,
processed: 0,
results: [],
errors: [],
message: 'Queue is empty'
};
}
// Clear timeout
clearScheduledTimeout();
// Import update_module_claude dynamically to avoid circular deps
const { updateModuleClaudeTool } = await import('./update-module-claude.js');
const results = [];
const errors = [];
// Group by tool and strategy for efficiency
const groups = new Map();
for (const item of queue.items) {
const key = `${item.tool}:${item.strategy}`;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(item);
}
// Process each group
for (const [key, items] of groups) {
const [tool, strategy] = key.split(':');
console.log(`[MemoryQueue] Processing ${items.length} items with ${tool}/${strategy}`);
for (const item of items) {
try {
const result = await updateModuleClaudeTool.execute({
path: item.path,
tool: item.tool,
strategy: item.strategy
});
results.push({
path: item.path,
success: result.success !== false,
result
});
} catch (e) {
console.error(`[MemoryQueue] Failed to update ${item.path}:`, e.message);
errors.push({
path: item.path,
error: e.message
});
}
}
}
// Clear queue after processing
saveQueue({ items: [], createdAt: null });
return {
success: errors.length === 0,
processed: queue.items.length,
results,
errors,
message: `Processed ${results.length} items, ${errors.length} errors`
};
}
/**
* Schedule timeout for auto-flush
*/
function scheduleTimeout() {
// We use file-based timeout tracking for persistence across process restarts
// The actual timeout check happens on next add/status call
const queue = loadQueue();
const config = getQueueConfig();
if (!queue.createdAt || queue.items.length === 0) {
return;
}
const createdTime = new Date(queue.createdAt).getTime();
const elapsed = Date.now() - createdTime;
if (elapsed >= config.timeoutMs) {
// Timeout already exceeded, should flush
console.log('[MemoryQueue] Timeout exceeded, auto-flushing');
// Don't await here to avoid blocking
flushQueue().catch(e => {
console.error('[MemoryQueue] Auto-flush failed:', e.message);
});
} else if (!scheduledTimeoutId) {
// Schedule in-memory timeout for current process
const remaining = config.timeoutMs - elapsed;
scheduledTimeoutId = setTimeout(() => {
scheduledTimeoutId = null;
const currentQueue = loadQueue();
if (currentQueue.items.length > 0) {
console.log('[MemoryQueue] Timeout reached, auto-flushing');
flushQueue().catch(e => {
console.error('[MemoryQueue] Auto-flush failed:', e.message);
});
}
}, remaining);
// Prevent timeout from keeping process alive
if (scheduledTimeoutId.unref) {
scheduledTimeoutId.unref();
}
}
}
/**
* Clear scheduled timeout
*/
function clearScheduledTimeout() {
if (scheduledTimeoutId) {
clearTimeout(scheduledTimeoutId);
scheduledTimeoutId = null;
}
}
/**
* Check if timeout has expired and auto-flush if needed
* @returns {Promise<{ expired: boolean, flushed: boolean, result?: object }>}
*/
async function checkTimeout() {
const queue = loadQueue();
const config = getQueueConfig();
if (!queue.createdAt || queue.items.length === 0) {
return { expired: false, flushed: false };
}
const createdTime = new Date(queue.createdAt).getTime();
const elapsed = Date.now() - createdTime;
if (elapsed >= config.timeoutMs) {
console.log('[MemoryQueue] Timeout expired, triggering flush');
const result = await flushQueue();
return { expired: true, flushed: true, result };
}
return { expired: false, flushed: false };
}
/**
* Main execute function for tool interface
* @param {Record<string, unknown>} params
* @returns {Promise<unknown>}
*/
async function execute(params) {
const { action, path, tool = 'gemini', strategy = 'single-layer', threshold, timeout } = params;
switch (action) {
case 'add':
if (!path) {
throw new Error('Parameter "path" is required for add action');
}
// Check timeout first
const timeoutCheck = await checkTimeout();
if (timeoutCheck.flushed) {
// Queue was flushed due to timeout, add to fresh queue
const result = addToQueue(path, { tool, strategy });
return {
...result,
timeoutFlushed: true,
flushResult: timeoutCheck.result
};
}
const addResult = addToQueue(path, { tool, strategy });
// Auto-flush if threshold reached
if (addResult.willFlush) {
const flushResult = await flushQueue();
return {
...addResult,
flushed: true,
flushResult
};
}
return addResult;
case 'status':
// Check timeout first
await checkTimeout();
return getQueueStatus();
case 'flush':
return await flushQueue();
case 'configure':
return configureQueue({ threshold, timeout });
default:
throw new Error(`Unknown action: ${action}. Valid actions: add, status, flush, configure`);
}
}
/**
* Tool Definition
*/
export const memoryQueueTool = {
name: 'memory_queue',
description: `Memory update queue management. Batches CLAUDE.md updates for efficiency.
Actions:
- add: Add path to queue (auto-flushes at configured threshold/timeout)
- status: Get queue status and configuration
- flush: Immediately execute all queued updates
- configure: Set threshold and timeout settings`,
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['add', 'status', 'flush', 'configure'],
description: 'Queue action to perform'
},
path: {
type: 'string',
description: 'Module directory path (required for add action)'
},
threshold: {
type: 'number',
description: 'Number of paths to trigger flush (1-20, for configure action)',
minimum: 1,
maximum: 20
},
timeout: {
type: 'number',
description: 'Timeout in seconds to trigger flush (60-1800, for configure action)',
minimum: 60,
maximum: 1800
},
tool: {
type: 'string',
enum: ['gemini', 'qwen', 'codex'],
description: 'CLI tool to use (default: gemini)',
default: 'gemini'
},
strategy: {
type: 'string',
enum: ['single-layer', 'multi-layer'],
description: 'Update strategy (default: single-layer)',
default: 'single-layer'
}
},
required: ['action']
},
execute
};
// Export individual functions for direct use
export {
loadQueue,
saveQueue,
addToQueue,
getQueueStatus,
flushQueue,
configureQueue,
scheduleTimeout,
clearScheduledTimeout,
checkTimeout,
DEFAULT_THRESHOLD,
DEFAULT_TIMEOUT_SECONDS,
QUEUE_FILE_PATH
};

View File

@@ -0,0 +1,165 @@
/**
* Unit tests for API Key Tester service (ccw/src/core/services/api-key-tester.ts)
*
* Tests URL construction logic, version suffix detection, and trailing slash handling.
* Uses Node's built-in test runner (node:test).
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
// Import functions that don't require fetch
import { validateApiBaseUrl, getDefaultApiBase } from '../src/core/services/api-key-tester.js';
describe('API Key Tester', () => {
describe('validateApiBaseUrl', () => {
it('should accept valid HTTPS URLs', () => {
const result = validateApiBaseUrl('https://api.openai.com/v1');
assert.equal(result.valid, true);
});
it('should accept valid HTTP URLs (for local development)', () => {
const result = validateApiBaseUrl('http://localhost:8080');
assert.equal(result.valid, true);
});
it('should reject non-HTTP protocols', () => {
const result = validateApiBaseUrl('ftp://example.com');
assert.equal(result.valid, false);
assert.equal(result.error, 'URL must use HTTP or HTTPS protocol');
});
it('should reject invalid URL format', () => {
const result = validateApiBaseUrl('not-a-url');
assert.equal(result.valid, false);
assert.equal(result.error, 'Invalid URL format');
});
});
describe('getDefaultApiBase', () => {
it('should return OpenAI default for openai provider', () => {
assert.equal(getDefaultApiBase('openai'), 'https://api.openai.com/v1');
});
it('should return Anthropic default for anthropic provider', () => {
assert.equal(getDefaultApiBase('anthropic'), 'https://api.anthropic.com/v1');
});
it('should return OpenAI default for custom provider', () => {
assert.equal(getDefaultApiBase('custom'), 'https://api.openai.com/v1');
});
});
describe('URL Normalization Logic (Issue #70 fix verification)', () => {
// Test the regex pattern used in testApiKeyConnection
const normalizeUrl = (url: string) => url.replace(/\/+$/, '');
const hasVersionSuffix = (url: string) => /\/v\d+$/.test(url);
describe('Trailing slash removal', () => {
it('should remove single trailing slash', () => {
assert.equal(normalizeUrl('https://api.openai.com/v1/'), 'https://api.openai.com/v1');
});
it('should remove multiple trailing slashes', () => {
assert.equal(normalizeUrl('https://api.openai.com/v1///'), 'https://api.openai.com/v1');
});
it('should not modify URL without trailing slash', () => {
assert.equal(normalizeUrl('https://api.openai.com/v1'), 'https://api.openai.com/v1');
});
});
describe('Version suffix detection', () => {
it('should detect /v1 suffix', () => {
assert.equal(hasVersionSuffix('https://api.openai.com/v1'), true);
});
it('should detect /v2 suffix', () => {
assert.equal(hasVersionSuffix('https://api.custom.com/v2'), true);
});
it('should detect /v4 suffix (z.ai style)', () => {
assert.equal(hasVersionSuffix('https://api.z.ai/api/coding/paas/v4'), true);
});
it('should NOT detect version when URL has no version suffix', () => {
assert.equal(hasVersionSuffix('http://localhost:8080'), false);
});
it('should NOT detect version when followed by slash (before normalization)', () => {
// After normalization, the slash should be removed
assert.equal(hasVersionSuffix('https://api.openai.com/v1/'), false);
assert.equal(hasVersionSuffix(normalizeUrl('https://api.openai.com/v1/')), true);
});
});
describe('URL construction verification', () => {
const constructModelsUrl = (apiBase: string) => {
const normalized = normalizeUrl(apiBase);
return hasVersionSuffix(normalized) ? `${normalized}/models` : `${normalized}/v1/models`;
};
it('should construct correct URL for https://api.openai.com/v1', () => {
assert.equal(constructModelsUrl('https://api.openai.com/v1'), 'https://api.openai.com/v1/models');
});
it('should construct correct URL for https://api.openai.com/v1/ (with trailing slash)', () => {
assert.equal(constructModelsUrl('https://api.openai.com/v1/'), 'https://api.openai.com/v1/models');
});
it('should construct correct URL for https://api.custom.com/v2', () => {
assert.equal(constructModelsUrl('https://api.custom.com/v2'), 'https://api.custom.com/v2/models');
});
it('should construct correct URL for https://api.custom.com/v2/ (with trailing slash)', () => {
assert.equal(constructModelsUrl('https://api.custom.com/v2/'), 'https://api.custom.com/v2/models');
});
it('should construct correct URL for https://api.z.ai/api/coding/paas/v4', () => {
assert.equal(constructModelsUrl('https://api.z.ai/api/coding/paas/v4'), 'https://api.z.ai/api/coding/paas/v4/models');
});
it('should add /v1 when no version suffix: http://localhost:8080', () => {
assert.equal(constructModelsUrl('http://localhost:8080'), 'http://localhost:8080/v1/models');
});
it('should add /v1 when no version suffix: https://api.custom.com', () => {
assert.equal(constructModelsUrl('https://api.custom.com'), 'https://api.custom.com/v1/models');
});
it('should NOT produce double slashes in any case', () => {
const testCases = [
'https://api.openai.com/v1/',
'https://api.openai.com/v1//',
'https://api.anthropic.com/v1/',
'http://localhost:8080/',
];
for (const url of testCases) {
const result = constructModelsUrl(url);
assert.ok(!result.includes('//models'), `Double slash found in: ${result} (from: ${url})`);
}
});
});
});
describe('Anthropic URL construction', () => {
const constructAnthropicUrl = (apiBase: string) => {
const normalized = apiBase.replace(/\/+$/, '');
return `${normalized}/models`;
};
it('should construct correct Anthropic URL without trailing slash', () => {
assert.equal(constructAnthropicUrl('https://api.anthropic.com/v1'), 'https://api.anthropic.com/v1/models');
});
it('should construct correct Anthropic URL WITH trailing slash', () => {
assert.equal(constructAnthropicUrl('https://api.anthropic.com/v1/'), 'https://api.anthropic.com/v1/models');
});
it('should NOT produce double slashes', () => {
const result = constructAnthropicUrl('https://api.anthropic.com/v1//');
assert.ok(!result.includes('//models'), `Double slash found in: ${result}`);
});
});
});

View File

@@ -0,0 +1,258 @@
/**
* Unit tests for CLI env file loading mechanism
*
* Tests parseEnvFile and loadEnvFile functions without calling the actual CLI
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
// Set test CCW home before importing module
const TEST_CCW_HOME = mkdtempSync(join(tmpdir(), 'ccw-env-file-test-'));
process.env.CCW_DATA_DIR = TEST_CCW_HOME;
// Import from dist (built version)
const cliExecutorPath = new URL('../dist/tools/cli-executor-core.js', import.meta.url).href;
describe('Env File Loading Mechanism', async () => {
let parseEnvFile: (content: string) => Record<string, string>;
let loadEnvFile: (envFilePath: string) => Record<string, string>;
let testTempDir: string;
before(async () => {
const mod = await import(cliExecutorPath);
parseEnvFile = mod.parseEnvFile;
loadEnvFile = mod.loadEnvFile;
testTempDir = mkdtempSync(join(tmpdir(), 'env-test-'));
});
after(() => {
// Cleanup test directories
if (existsSync(testTempDir)) {
rmSync(testTempDir, { recursive: true, force: true });
}
if (existsSync(TEST_CCW_HOME)) {
rmSync(TEST_CCW_HOME, { recursive: true, force: true });
}
});
describe('parseEnvFile', () => {
it('should parse simple KEY=value pairs', () => {
const content = `API_KEY=abc123
SECRET=mysecret`;
const result = parseEnvFile(content);
assert.equal(result['API_KEY'], 'abc123');
assert.equal(result['SECRET'], 'mysecret');
});
it('should handle double-quoted values', () => {
const content = `API_KEY="value with spaces"
PATH="/usr/local/bin"`;
const result = parseEnvFile(content);
assert.equal(result['API_KEY'], 'value with spaces');
assert.equal(result['PATH'], '/usr/local/bin');
});
it('should handle single-quoted values', () => {
const content = `API_KEY='value with spaces'
NAME='John Doe'`;
const result = parseEnvFile(content);
assert.equal(result['API_KEY'], 'value with spaces');
assert.equal(result['NAME'], 'John Doe');
});
it('should skip comments', () => {
const content = `# This is a comment
API_KEY=value
# Another comment
SECRET=test`;
const result = parseEnvFile(content);
assert.equal(Object.keys(result).length, 2);
assert.equal(result['API_KEY'], 'value');
assert.equal(result['SECRET'], 'test');
});
it('should skip empty lines', () => {
const content = `
API_KEY=value
SECRET=test
`;
const result = parseEnvFile(content);
assert.equal(Object.keys(result).length, 2);
});
it('should handle values with equals signs', () => {
const content = `DATABASE_URL=postgresql://user:pass@host/db?sslmode=require`;
const result = parseEnvFile(content);
assert.equal(result['DATABASE_URL'], 'postgresql://user:pass@host/db?sslmode=require');
});
it('should handle Windows-style line endings (CRLF)', () => {
const content = `API_KEY=value\r\nSECRET=test\r\n`;
const result = parseEnvFile(content);
assert.equal(result['API_KEY'], 'value');
assert.equal(result['SECRET'], 'test');
});
it('should trim whitespace around keys and values', () => {
const content = ` API_KEY = value
SECRET = test `;
const result = parseEnvFile(content);
assert.equal(result['API_KEY'], 'value');
assert.equal(result['SECRET'], 'test');
});
it('should skip lines without equals sign', () => {
const content = `API_KEY=value
INVALID_LINE
SECRET=test`;
const result = parseEnvFile(content);
assert.equal(Object.keys(result).length, 2);
assert.equal(result['INVALID_LINE'], undefined);
});
it('should handle empty values', () => {
const content = `EMPTY_VALUE=
ANOTHER=test`;
const result = parseEnvFile(content);
assert.equal(result['EMPTY_VALUE'], '');
assert.equal(result['ANOTHER'], 'test');
});
it('should handle mixed format content', () => {
const content = `# Gemini API Configuration
GEMINI_API_KEY="sk-gemini-xxx"
# OpenAI compatible settings
OPENAI_API_BASE='https://api.example.com/v1'
OPENAI_API_KEY=abc123
# Feature flags
ENABLE_DEBUG=true`;
const result = parseEnvFile(content);
assert.equal(result['GEMINI_API_KEY'], 'sk-gemini-xxx');
assert.equal(result['OPENAI_API_BASE'], 'https://api.example.com/v1');
assert.equal(result['OPENAI_API_KEY'], 'abc123');
assert.equal(result['ENABLE_DEBUG'], 'true');
assert.equal(Object.keys(result).length, 4);
});
});
describe('loadEnvFile', () => {
it('should load env file from absolute path', () => {
const envPath = join(testTempDir, 'test.env');
writeFileSync(envPath, 'API_KEY=test_value\nSECRET=123');
const result = loadEnvFile(envPath);
assert.equal(result['API_KEY'], 'test_value');
assert.equal(result['SECRET'], '123');
});
it('should return empty object for non-existent file', () => {
const result = loadEnvFile('/non/existent/path/.env');
assert.deepEqual(result, {});
});
it('should expand ~ to home directory', () => {
// Create .env-test in home directory for testing
const homeEnvPath = join(homedir(), '.ccw-env-test');
writeFileSync(homeEnvPath, 'HOME_TEST_KEY=home_value');
try {
const result = loadEnvFile('~/.ccw-env-test');
assert.equal(result['HOME_TEST_KEY'], 'home_value');
} finally {
// Cleanup
if (existsSync(homeEnvPath)) {
rmSync(homeEnvPath);
}
}
});
it('should handle relative paths', () => {
const envPath = join(testTempDir, 'relative.env');
writeFileSync(envPath, 'RELATIVE_KEY=rel_value');
// Save and change cwd
const originalCwd = process.cwd();
try {
process.chdir(testTempDir);
const result = loadEnvFile('./relative.env');
assert.equal(result['RELATIVE_KEY'], 'rel_value');
} finally {
process.chdir(originalCwd);
}
});
it('should handle empty file', () => {
const envPath = join(testTempDir, 'empty.env');
writeFileSync(envPath, '');
const result = loadEnvFile(envPath);
assert.deepEqual(result, {});
});
it('should handle file with only comments', () => {
const envPath = join(testTempDir, 'comments.env');
writeFileSync(envPath, '# Just a comment\n# Another comment\n');
const result = loadEnvFile(envPath);
assert.deepEqual(result, {});
});
});
describe('Integration scenario: Gemini CLI env file', () => {
it('should correctly parse typical Gemini .env file', () => {
const geminiEnvContent = `# Gemini CLI Environment Configuration
# Created by CCW Dashboard
# Google AI API Key
GOOGLE_API_KEY="AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
# Optional: Custom API endpoint
# GOOGLE_API_BASE=https://generativelanguage.googleapis.com/v1beta
# Model configuration
GEMINI_MODEL=gemini-2.5-pro
# Rate limiting
GEMINI_RATE_LIMIT=60
`;
const envPath = join(testTempDir, '.gemini-env');
writeFileSync(envPath, geminiEnvContent);
const result = loadEnvFile(envPath);
assert.equal(result['GOOGLE_API_KEY'], 'AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX');
assert.equal(result['GEMINI_MODEL'], 'gemini-2.5-pro');
assert.equal(result['GEMINI_RATE_LIMIT'], '60');
assert.equal(result['GOOGLE_API_BASE'], undefined); // Commented out
});
it('should correctly parse typical Qwen .env file', () => {
const qwenEnvContent = `# Qwen CLI Environment Configuration
# DashScope API Key (Alibaba Cloud)
DASHSCOPE_API_KEY=sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# OpenAI-compatible endpoint settings
OPENAI_API_BASE=https://dashscope.aliyuncs.com/compatible-mode/v1
OPENAI_API_KEY=sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# Model selection
QWEN_MODEL=qwen2.5-coder-32b
`;
const envPath = join(testTempDir, '.qwen-env');
writeFileSync(envPath, qwenEnvContent);
const result = loadEnvFile(envPath);
assert.equal(result['DASHSCOPE_API_KEY'], 'sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX');
assert.equal(result['OPENAI_API_BASE'], 'https://dashscope.aliyuncs.com/compatible-mode/v1');
assert.equal(result['QWEN_MODEL'], 'qwen2.5-coder-32b');
});
});
});

View File

@@ -0,0 +1,127 @@
/**
* Unit tests for Help Routes (ccw/src/core/routes/help-routes.ts)
*
* Tests getIndexDir path resolution logic.
* Uses Node's built-in test runner (node:test).
*/
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { homedir } from 'node:os';
// Store original existsSync
import * as fs from 'node:fs';
const originalExistsSync = fs.existsSync;
// Track existsSync calls
const existsSyncCalls: string[] = [];
let existsSyncResults: Map<string, boolean> = new Map();
// Mock existsSync
(fs as any).existsSync = (path: string): boolean => {
existsSyncCalls.push(path);
return existsSyncResults.get(path) ?? false;
};
describe('Help Routes - getIndexDir', () => {
beforeEach(() => {
existsSyncCalls.length = 0;
existsSyncResults = new Map();
});
afterEach(() => {
(fs as any).existsSync = originalExistsSync;
});
describe('Path resolution priority (Issue #66 fix verification)', () => {
it('should prefer project path over user path when project path exists', () => {
const projectPath = '/test/project';
const projectIndexDir = join(projectPath, '.claude', 'skills', 'ccw-help', 'index');
const userIndexDir = join(homedir(), '.claude', 'skills', 'ccw-help', 'index');
// Both paths exist, but project path should be preferred
existsSyncResults.set(projectIndexDir, true);
existsSyncResults.set(userIndexDir, true);
// We can't directly test getIndexDir as it's not exported,
// but we can verify the expected path structure
assert.equal(projectIndexDir, '/test/project/.claude/skills/ccw-help/index');
assert.ok(projectIndexDir.includes('ccw-help')); // Correct directory name
assert.ok(!projectIndexDir.includes('command-guide')); // Old incorrect name
});
it('should fall back to user path when project path does not exist', () => {
const projectPath = '/test/project';
const projectIndexDir = join(projectPath, '.claude', 'skills', 'ccw-help', 'index');
const userIndexDir = join(homedir(), '.claude', 'skills', 'ccw-help', 'index');
// Only user path exists
existsSyncResults.set(projectIndexDir, false);
existsSyncResults.set(userIndexDir, true);
// Verify path structure
assert.ok(userIndexDir.includes('ccw-help'));
assert.ok(!userIndexDir.includes('command-guide'));
});
it('should use correct directory name ccw-help (not command-guide)', () => {
// Verify the correct directory name is used
const expectedDir = '.claude/skills/ccw-help/index';
const incorrectDir = '.claude/skills/command-guide/index';
assert.ok(expectedDir.includes('ccw-help'));
assert.ok(!expectedDir.includes('command-guide'));
assert.notEqual(expectedDir, incorrectDir);
});
it('should return null when neither path exists', () => {
const projectPath = '/test/project';
const projectIndexDir = join(projectPath, '.claude', 'skills', 'ccw-help', 'index');
const userIndexDir = join(homedir(), '.claude', 'skills', 'ccw-help', 'index');
// Neither path exists
existsSyncResults.set(projectIndexDir, false);
existsSyncResults.set(userIndexDir, false);
// Both should be checked
// The actual function would return null in this case
});
});
describe('Pure function behavior (Review recommendation)', () => {
it('should not rely on module-level state', () => {
// getIndexDir now accepts projectPath as parameter
// This test verifies the function signature expectation
const projectPath1 = '/project1';
const projectPath2 = '/project2';
// Different project paths should produce different index paths
const indexPath1 = join(projectPath1, '.claude', 'skills', 'ccw-help', 'index');
const indexPath2 = join(projectPath2, '.claude', 'skills', 'ccw-help', 'index');
assert.notEqual(indexPath1, indexPath2);
});
});
});
describe('Help Routes - Path Construction', () => {
it('should construct correct project index path', () => {
const projectPath = 'D:\\MyProject';
const expectedPath = join(projectPath, '.claude', 'skills', 'ccw-help', 'index');
// Verify path includes correct components
assert.ok(expectedPath.includes('.claude'));
assert.ok(expectedPath.includes('skills'));
assert.ok(expectedPath.includes('ccw-help'));
assert.ok(expectedPath.includes('index'));
});
it('should construct correct user index path', () => {
const expectedPath = join(homedir(), '.claude', 'skills', 'ccw-help', 'index');
// Verify path includes correct components
assert.ok(expectedPath.includes(homedir()));
assert.ok(expectedPath.includes('ccw-help'));
});
});

View File

@@ -346,3 +346,45 @@ describe('LiteLLM client bridge', () => {
assert.ok(String(status.error).includes('ccw_litellm not installed'));
});
});
describe('getCodexLensVenvPython (Issue #68 fix)', () => {
it('should be exported from the module', async () => {
assert.ok(typeof mod.getCodexLensVenvPython === 'function');
});
it('should return a string path', async () => {
const pythonPath = mod.getCodexLensVenvPython();
assert.equal(typeof pythonPath, 'string');
assert.ok(pythonPath.length > 0);
});
it('should return correct path structure for CodexLens venv', async () => {
const pythonPath = mod.getCodexLensVenvPython();
// On Windows: should contain Scripts/python.exe
// On Unix: should contain bin/python
const isWindows = process.platform === 'win32';
if (isWindows) {
// Either it's the venv path with Scripts, or fallback to 'python'
const isVenvPath = pythonPath.includes('Scripts') && pythonPath.includes('python');
const isFallback = pythonPath === 'python';
assert.ok(isVenvPath || isFallback, `Expected venv path or 'python' fallback, got: ${pythonPath}`);
} else {
// On Unix: either venv path with bin/python, or fallback
const isVenvPath = pythonPath.includes('bin') && pythonPath.includes('python');
const isFallback = pythonPath === 'python';
assert.ok(isVenvPath || isFallback, `Expected venv path or 'python' fallback, got: ${pythonPath}`);
}
});
it('should include .codexlens/venv in path when venv exists', async () => {
const pythonPath = mod.getCodexLensVenvPython();
// If not falling back to 'python', should contain .codexlens/venv
if (pythonPath !== 'python') {
assert.ok(pythonPath.includes('.codexlens'), `Expected .codexlens in path, got: ${pythonPath}`);
assert.ok(pythonPath.includes('venv'), `Expected venv in path, got: ${pythonPath}`);
}
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-workflow",
"version": "6.3.24",
"version": "6.3.26",
"description": "JSON-driven multi-agent development framework with intelligent CLI orchestration (Gemini/Qwen/Codex), context-first architecture, and automated workflow execution",
"type": "module",
"main": "ccw/src/index.js",