Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:48:32 +08:00
commit b0bc2cf2e3
11 changed files with 2373 additions and 0 deletions

View File

@@ -0,0 +1,391 @@
# Expert Consultation Examples
Complete usage examples for the ask-expert skill.
## Complete Workflow Examples
### Example 1: Bug Investigation
**Scenario**: JWT refresh token causing unexpected logouts
```bash
# 1. Create consultation document
cat > auth-bug-consultation.md << 'EOF'
# Expert Consultation: JWT Refresh Token Bug
## 1. Problem
Users are getting logged out unexpectedly after 15 minutes despite having valid refresh tokens.
## 2. Our Solution
Modified the token refresh logic in AuthService to use a sliding window approach instead of fixed expiration.
## 3. Concerns
- This couples authentication to session management
- Might introduce race conditions with concurrent requests
- Token refresh happens in middleware which feels wrong
## 4. Alternatives
- Separate auth service with dedicated refresh endpoint
- Use Redis for session management
- Switch to stateless JWTs
## 5. Architecture Overview
```
┌─────────┐ ┌──────────────┐ ┌───────────┐
│ Client │────▶│ Middleware │────▶│ Auth │
│ │◀────│ (Refresh) │◀────│ Service │
└─────────┘ └──────────────┘ └───────────┘
┌──────────┐
│ Token │
│ Manager │
└──────────┘
```
---
# Complete Architecture Context
EOF
# 2. Extract code with size tracking
node scripts/extract-code.js \
--track-size --output=auth-bug-consultation.md \
--section="What Changed" \
src/auth/AuthService.cs:diff \
--section="Auth Flow (COMPLETE)" \
src/auth/AuthController.cs \
src/auth/TokenManager.cs \
--section="Middleware" \
src/middleware/AuthMiddleware.cs \
--section="Tests" \
tests/auth/AuthFlowShould.cs:1-150
# 3. Add expert questions
cat >> auth-bug-consultation.md << 'EOF'
---
# Expert Guidance Request
## Questions
1. Does our sliding window approach introduce security risks?
2. Better patterns for handling token refresh in middleware?
3. How to test race conditions effectively?
4. Should authentication and session management be separate concerns?
## Success Criteria
- Backward compatible with mobile clients
- No data loss during token refresh
- Clear security model
- Testable solution
**Please answer in English**
EOF
# 4. Verify size
wc -c auth-bug-consultation.md
```
### Example 2: API Redesign
**Scenario**: Need expert review of new REST API design
```bash
# Use config file for complex extraction
cat > api-redesign-plan.json << 'EOF'
{
"output": "api-redesign-consultation.md",
"trackSize": true,
"sections": [
{
"header": "Current API Design",
"files": [
"src/controllers/ApiController.cs",
"src/models/ApiRequest.cs",
"src/models/ApiResponse.cs"
]
},
{
"header": "Service Layer",
"files": [
"src/services/ApiService.cs:1-200",
"src/services/ApiService.Validation.cs"
]
},
{
"header": "Test Coverage",
"files": [
"tests/ApiControllerShould.cs:100-300"
]
}
]
}
EOF
node scripts/extract-code.js \
--config=api-redesign-plan.json
```
### Example 3: Architecture Review
**Scenario**: TypeScript strict mode migration
```bash
# 1. Write problem context
cat > typescript-strict-consultation.md << 'EOF'
# Expert Consultation: TypeScript Strict Mode Migration
## 1. Problem
Legacy codebase has `strict: false` in tsconfig.json. Need to enable strict mode without breaking production.
## 2. Our Solution
Incremental migration by file, starting with new code and migrating old files gradually.
## 3. Concerns
- 500+ files to migrate
- Some patterns don't work well with strict mode (dynamic property access)
- Team unfamiliar with strict mode patterns
## 4. Alternatives
- Big bang migration with dedicated sprint
- Stay on non-strict mode indefinitely
- Use strict mode only for new files
## 5. Architecture Overview
[Diagram showing file dependency graph]
---
# Complete Architecture Context
EOF
# 2. Batch extract multiple files efficiently
node scripts/extract-code.js \
--track-size --output=typescript-strict-consultation.md \
--section="Type Definitions" \
src/types/payloads.ts src/types/responses.ts \
--section="Core Files (COMPLETE)" \
src/handlers/base-handler.ts \
src/handlers/intents.ts \
--section="Config" \
tsconfig.json \
--section="Example Migrated Files" \
src/services/user.ts src/services/auth.ts
# 3. Add questions
cat >> typescript-strict-consultation.md << 'EOF'
---
# Expert Guidance Request
## Questions
1. Recommended migration order for 500+ files?
2. Common patterns that break in strict mode and their fixes?
3. Tooling to automate parts of the migration?
4. Testing strategy during migration?
## Success Criteria
- Zero runtime regressions
- Team can maintain strict mode going forward
- Migration completable in 2-3 sprints
**Please answer in English**
EOF
```
## Extract-Code Script Usage
### Basic Patterns
**Extract full files:**
```bash
node scripts/extract-code.js \
src/Service.cs tests/ServiceTests.cs
```
**Extract line ranges:**
```bash
node scripts/extract-code.js \
src/Service.cs:100-200 tests/ServiceTests.cs:50-75
```
**Multiple ranges from one file:**
```bash
node scripts/extract-code.js \
src/Service.cs:1-30,86-213,500-600
```
**Mix full files and ranges:**
```bash
node scripts/extract-code.js \
src/Models/User.cs src/Service.cs:100-150
```
### Git Diff Patterns
**Diff vs master (default):**
```bash
node scripts/extract-code.js \
src/Service.cs:diff
```
**Explicit diff range:**
```bash
node scripts/extract-code.js \
src/Service.cs:diff=master..feature-branch \
src/Helper.cs:diff=HEAD~3..HEAD
```
**Recent changes:**
```bash
node scripts/extract-code.js \
src/Service.cs:diff=HEAD~5
```
**Combine diffs with regular files:**
```bash
node scripts/extract-code.js \
src/Service.cs:diff \
src/Tests.cs:100-200 \
src/Models.cs
```
### Size Tracking Patterns
**Basic size tracking:**
```bash
node scripts/extract-code.js \
--track-size --output=consultation.md \
src/Service.cs
```
**With sections:**
```bash
node scripts/extract-code.js \
--track-size --output=doc.md \
--section="Core Interfaces" \
Interface.cs BaseClass.cs \
--section="Domain Models" \
Contact.cs Company.cs \
--section="Tests" \
Tests.cs:100-200
```
**Incremental building (appends each time):**
```bash
node scripts/extract-code.js \
--track-size -o doc.md File1.cs
node scripts/extract-code.js \
--track-size -o doc.md File2.cs # Appends
node scripts/extract-code.js \
--track-size -o doc.md File3.cs # Appends again
```
### Config File Patterns
**Simple config:**
```json
{
"output": "consultation.md",
"trackSize": true,
"sections": [
{
"header": "Core Implementation",
"files": ["src/Service.cs", "src/Model.cs"]
}
]
}
```
**Complex config with diffs:**
```json
{
"output": "feature-consultation.md",
"trackSize": true,
"sections": [
{
"header": "What We Changed",
"files": [
"src/Service.cs:diff",
"src/Helper.cs:diff=master..feature-branch"
]
},
{
"header": "Frontend Component (COMPLETE)",
"files": ["src/components/MyComponent.vue"]
},
{
"header": "Component Tests (COMPLETE)",
"files": ["tests/components/MyComponent.test.ts"]
},
{
"header": "Core Interfaces",
"files": ["src/interfaces/IMyService.cs"]
},
{
"header": "Domain Models",
"files": [
"src/models/MyModel.cs",
"src/models/RelatedModel.cs"
]
},
{
"header": "Service Implementation (Relevant Methods)",
"files": ["src/services/MyService.cs:100-500"]
}
]
}
```
**Run config:**
```bash
node scripts/extract-code.js \
--config=extraction-plan.json
```
## Size Tracking Output
The script shows real-time progress:
```
📄 consultation.md: 4.9 KB (existing)
[1/8] NetworkIndex.vue → +25.5 KB (30.4 KB / 125 KB, 24.3%)
[2/8] NetworkIndex.test.ts → +14.0 KB (44.4 KB / 125 KB, 35.5%)
[3/8] NetworkController.cs → +12.3 KB (56.7 KB / 125 KB, 45.4%)
[4/8] INetworkService.cs → +3.2 KB (59.9 KB / 125 KB, 47.9%)
[5/8] Contact.cs → +8.1 KB (68.0 KB / 125 KB, 54.4%)
[6/8] Company.cs → +7.4 KB (75.4 KB / 125 KB, 60.3%)
[7/8] NetworkService.cs → +9.2 KB (84.6 KB / 125 KB, 67.7%)
[8/8] Tests.cs → +2.7 KB (87.3 KB / 125 KB, 69.8%)
✅ Saved: 8 files to consultation.md (87.3 KB / 125 KB)
```
**Warnings at thresholds:**
```
⚠️ Approaching 100 KB (at 100 KB)
⚠️ Very close to limit! (at 115 KB)
❌ Exceeded 125 KB limit (stops processing)
```
## Traditional Redirection
You can also use traditional shell redirection:
```bash
node scripts/extract-code.js \
src/Service.cs > expert-consultation.md
node scripts/extract-code.js \
src/Tests.cs >> expert-consultation.md # Append
```
**Note**: Without `--track-size`, you won't see progress or warnings.
## Tips for Efficiency
1. **Batch files together** - One call is better than many
2. **Use config files** for complex extractions you'll repeat
3. **Use full files** when possible - better context for expert
4. **Use diffs** to show "what changed" concisely
5. **Track size** to avoid hitting 125 KB limit
6. **Verify early** - run `wc -c` to check size before adding more

View File

@@ -0,0 +1,66 @@
# Ask Expert Skill
> Creates expert consultation documents with automated code extraction, git diffs, and size tracking
## Overview
This skill helps Claude create comprehensive technical consultation documents for external expert review. It automatically activates when you ask Claude to prepare code for expert analysis.
**For complete plugin documentation, see the [main README](../../README.md).**
## Quick Reference
**Example prompts:**
```
"Create an expert consultation document for our authentication refactor"
"Prepare code for expert review about our API design"
"I need to ask an expert about our database schema"
```
**What it does:**
- Guides you through structuring consultation documents
- Extracts code with size tracking (125KB limit)
- Organizes content with markdown sections
- Supports full files, line ranges, and git diffs
**Allowed tools:** Bash, Read, Write, Edit
## Script Usage
The skill uses a bundled extraction script. For manual usage:
**Basic extraction:**
```bash
node scripts/extract-code.js \
--track-size --output=doc.md \
src/file1.ts src/file2.ts
```
**With sections:**
```bash
node scripts/extract-code.js \
--track-size --output=doc.md \
--section="What Changed" src/Service.cs:diff \
--section="Implementation" src/Service.cs
```
**Git diffs:**
```bash
node scripts/extract-code.js \
src/Service.cs:diff=master..feature-branch
```
## Documentation
- **[SKILL.md](SKILL.md)** - Skill definition for Claude
- **[EXAMPLES.md](EXAMPLES.md)** - Detailed usage examples with complete workflows
- **[Script Reference](scripts/extract-code.js)** - Run with `--help` for all options
## Requirements
- Node.js 18+
- Git (for diff functionality)
## License
MIT - See [LICENSE](../../LICENSE)

208
skills/ask-expert/SKILL.md Normal file
View File

@@ -0,0 +1,208 @@
---
name: ask-expert
description: Creates expert consultation documents with code extraction, git diffs, and size tracking (125KB limit). Use when user wants to prepare comprehensive technical documentation for external review, gather code context for architecture consultations, or create detailed technical analysis documents with full source context. Requires Node.js 18+.
allowed-tools: [Bash, Read, Write, Edit]
---
# Expert Consultation Document Creator
Create comprehensive technical consultation documents by extracting code, diffs, and architectural context within LLM token limits (125KB).
## Document Structure
Follow this proven structure:
### Part 1: Problem Context (~15-25 KB)
1. **Problem** - Issue, errors, test failures
2. **Our Solution** - What was implemented and why
3. **Concerns** - Code smells, coupling, architectural questions
4. **Alternatives** - Other approaches, trade-offs
### Part 2: Complete Architecture (~60-90 KB)
5. **Architecture Overview** - ASCII diagram, data flow, patterns
6. **Components** - Frontend, tests, controllers
7. **Services** - Implementation and interfaces
8. **Models** - Domain entities with relationships
### Part 3: Expert Request (~5-10 KB)
9. **Questions** - Specific technical questions
10. **Success Criteria** - Requirements and priorities
## Workflow
### Step 1: Write Problem Context
Create descriptive filename like `{topic}-consultation.md`:
```bash
cat > feature-consultation.md << 'EOF'
# Expert Consultation: [Feature Name]
## 1. Problem
[Describe the issue]
## 2. Our Solution
[What was implemented]
## 3. Concerns
[Technical concerns]
## 4. Alternatives
[Other approaches considered]
## 5. Architecture Overview
[ASCII diagram]
---
# Complete Architecture Context
EOF
```
### Step 2: Extract Code
Use the bundled extraction script with size tracking.
**💡 The script accepts multiple files in one call** - batch files for efficiency:
```bash
node scripts/extract-code.js \
--track-size --output=doc.md \
--section="Core Files" \
file1.ts file2.ts file3.ts \
--section="Tests" \
test1.ts test2.ts
```
**File format options:**
- Full file: `src/Service.cs`
- Line ranges: `src/Service.cs:100-200` or `src/Service.cs:1-30,100-150`
- Git diff: `src/Service.cs:diff` or `src/Service.cs:diff=master..HEAD`
**Prefer FULL files over chunks** for better expert analysis. Use chunks only for very large files.
### Step 3: Add Expert Request
```bash
cat >> consultation.md << 'EOF'
---
# Expert Guidance Request
## Questions
1. [Specific question about architecture]
2. [Question about trade-offs]
3. [Question about refactoring approach]
## Success Criteria
- [Required constraints]
- [Priorities]
**Please answer in English**
EOF
```
### Step 4: Verify Size
```bash
wc -c consultation.md # Should be 100-125 KB
```
DO NOT read the full file back (exceeds context).
## Code Extraction Examples
See [EXAMPLES.md](EXAMPLES.md) for detailed usage patterns.
**Basic extraction:**
```bash
node scripts/extract-code.js \
--track-size --output=doc.md \
src/Component.vue tests/Component.test.ts
```
**With sections:**
```bash
node scripts/extract-code.js \
--track-size --output=doc.md \
--section="What Changed" \
src/Service.cs:diff \
--section="Implementation" \
src/Service.cs src/Model.cs
```
**Using config file:**
```bash
node scripts/extract-code.js \
--config=extraction-plan.json
```
## Config File Format
Create reusable extraction plans:
```json
{
"output": "consultation.md",
"trackSize": true,
"sections": [
{
"header": "What Changed",
"files": ["src/Service.cs:diff"]
},
{
"header": "Core Implementation",
"files": ["src/Service.cs", "src/Model.cs"]
}
]
}
```
See `scripts/extract-code-example.json` for complete example.
## Critical Rules
- ✅ Use `--track-size` to stay within 125 KB
- ✅ Batch multiple files in single command
- ✅ Use absolute path to script from any directory
- ✅ Include FULL files when possible
- ✅ Add architecture diagrams
- ✅ Include working AND failing tests
- ❌ Don't read completed file back
- ❌ Don't send only bug fix without context
## Troubleshooting
**Script not found:**
```bash
# Verify script exists
ls scripts/extract-code.js
# Show help
node scripts/extract-code.js --help
```
**Git diff errors:**
```bash
git status # Verify git repo
git rev-parse master # Verify branch exists
```
**Exceeding 125 KB:**
- Use line ranges instead of full files for large services
- Remove boilerplate and simple DTOs
- Focus on core interfaces and modified code
- Split into multiple consultations
## Code Inclusion Priority
**Must include:**
- Core interfaces/abstractions
- Modified/bug-fix code
- Domain models
- Key service methods
- Test examples
**Skip if tight on space:**
- Boilerplate
- Simple DTOs
- Repetitive test setups

View File

@@ -0,0 +1,48 @@
{
"output": "expert-consultation.md",
"trackSize": true,
"sections": [
{
"header": "What We Changed",
"files": [
"src/services/UserService.ts:diff",
"src/utils/ApiHelper.ts:diff=master..HEAD"
]
},
{
"header": "Frontend Components",
"files": [
"src/components/UserProfile.vue",
"src/components/UserSettings.vue:1-100"
]
},
{
"header": "API Layer",
"files": [
"src/api/users.ts",
"src/api/auth.ts",
"src/types/User.ts"
]
},
{
"header": "Backend Services",
"files": [
"backend/services/UserService.cs:1-150",
"backend/services/AuthService.cs:200-400"
]
},
{
"header": "Database Models",
"files": [
"backend/models/User.cs",
"backend/models/Session.cs:50-120"
]
},
{
"header": "Test Examples",
"files": [
"tests/UserService.test.ts:100-200,500-600"
]
}
]
}

View File

@@ -0,0 +1,985 @@
#!/usr/bin/env node
/**
* Code Extractor for Expert Consultations
*
* Extracts file contents, line ranges, or git diffs with automatic size tracking
* to stay within the 125 KB limit for expert consultation documents.
*
* @author Propstreet
* @license MIT
* @requires Node.js 18+
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { parseArgs } from "util";
import { execSync } from "child_process";
// ============================================================================
// Constants
// ============================================================================
/** Maximum size for expert consultation documents (125 KB) */
const MAX_SIZE_BYTES = 125 * 1024;
/** Warning threshold at 100 KB */
const WARNING_THRESHOLD_1 = 100 * 1024;
/** Warning threshold at 115 KB (very close to limit) */
const WARNING_THRESHOLD_2 = 115 * 1024;
/** Regex pattern for parsing file arguments with ranges/diffs */
const FILE_ARG_PATTERN = /^(.+?):([\d,:-]+|diff(?:=.+)?)$/;
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Format bytes as human-readable size in KB
* @param {number} bytes - Size in bytes
* @returns {string} Formatted size (e.g., "25.5 KB")
*/
function formatSize(bytes) {
return (bytes / 1024).toFixed(1) + " KB";
}
/**
* Detect programming language from file extension
* @param {string} filePath - Path to file
* @returns {string} Language identifier for syntax highlighting
*/
function detectLanguage(filePath) {
const ext = path.extname(filePath).toLowerCase();
const langMap = {
".cs": "csharp",
".js": "javascript",
".ts": "typescript",
".vue": "vue",
".json": "json",
".md": "markdown",
".sql": "sql",
".html": "html",
".css": "css",
".scss": "scss",
".xml": "xml",
".yaml": "yaml",
".yml": "yaml",
".sh": "bash",
".py": "python",
".jsx": "jsx",
".tsx": "tsx",
};
return langMap[ext] || "text";
}
// ============================================================================
// File Argument Parsing
// ============================================================================
/**
* Parse file argument into path and range/diff specification
* Supports formats:
* - "path/to/file.cs" (full file)
* - "path/to/file.cs:100-200" (line range)
* - "path/to/file.cs:1-30,100-150" (multiple ranges)
* - "path/to/file.cs:diff" (git diff vs master)
* - "path/to/file.cs:diff=master..HEAD" (git diff with range)
*
* @param {string} fileArg - File argument from command line
* @returns {{filePath: string, rangeStr: string|null}} Parsed components
*/
function parseFileArgument(fileArg) {
const rangeMatch = fileArg.match(FILE_ARG_PATTERN);
if (rangeMatch) {
return {
filePath: rangeMatch[1],
rangeStr: rangeMatch[2],
};
}
return {
filePath: fileArg,
rangeStr: null,
};
}
// ============================================================================
// Git Operations
// ============================================================================
/**
* Check if current directory is in a git repository
* @throws {Error} If not in a git repository
*/
function validateGitRepository() {
try {
execSync("git rev-parse --git-dir", { stdio: "pipe" });
} catch {
throw new Error("Not in a git repository");
}
}
/**
* Split git diff range into individual refs for validation
* @param {string} diffRange - Git range (e.g., "master", "master..HEAD", "HEAD~3")
* @returns {string[]} Array of git refs to validate
*/
function splitGitRefs(diffRange) {
return diffRange.includes("..")
? diffRange.split("..").filter((r) => r)
: [diffRange];
}
/**
* Validate that git references exist
* @param {string[]} refs - Array of git refs to validate
* @throws {Error} If any ref is invalid
*/
function validateGitRefs(refs) {
for (const ref of refs) {
try {
execSync(`git rev-parse --verify ${ref}`, { stdio: "pipe" });
} catch {
throw new Error(`Invalid git reference: ${ref}`);
}
}
}
/**
* Parse diff specification from range string
* @param {string|null} specStr - Diff specification (e.g., "diff", "diff=master..HEAD")
* @returns {{type: string, range: string}|null} Parsed diff spec or null
*/
function parseDiffSpec(specStr) {
if (!specStr) {
return null;
}
if (specStr === "diff") {
return { type: "diff", range: "master" };
}
const match = specStr.match(/^diff=(.+)$/);
if (match) {
return { type: "diff", range: match[1] };
}
return null;
}
/**
* Read git diff content for a file
* @param {string} filePath - Absolute path to the file
* @param {string} diffRange - Git range (e.g., "master", "master..HEAD", "HEAD~3")
* @returns {string} Unified diff output
* @throws {Error} If git operations fail
*/
function readDiffContent(filePath, diffRange) {
try {
validateGitRepository();
// Validate all refs in the range
const refs = splitGitRefs(diffRange);
validateGitRefs(refs);
// Get relative path from git root for git diff
const gitRoot = execSync("git rev-parse --show-toplevel", {
encoding: "utf8",
}).trim();
const relativePath = path.relative(gitRoot, filePath);
// Execute git diff
const diffCommand = diffRange.includes("..")
? `git diff ${diffRange} -- "${relativePath}"`
: `git diff ${diffRange} -- "${relativePath}"`;
return execSync(diffCommand, {
encoding: "utf8",
cwd: gitRoot,
});
} catch (error) {
// Re-throw with context
if (
error.message.includes("Not in a git repository") ||
error.message.includes("Invalid git reference")
) {
throw error;
}
throw new Error(`Git diff failed: ${error.message}`);
}
}
// ============================================================================
// Line Range Operations
// ============================================================================
/**
* Parse line range string into structured format
* Supports: "10-20", "10:20", "10-20,50-60,100-150"
*
* @param {string|null} rangeStr - Line range string
* @returns {{from: number, to: number}[]|null} Array of range objects or null
* @throws {Error} If range format is invalid
*/
function parseLineRanges(rangeStr) {
if (!rangeStr) {
return null;
}
const ranges = rangeStr.split(",").map((r) => r.trim());
const parsed = [];
for (const range of ranges) {
const match = range.match(/^(\d+)[-:](\d+)$/);
if (!match) {
throw new Error(
`Invalid line range format: "${range}". Use format "10-20" or "10:20"`
);
}
const from = parseInt(match[1], 10);
const to = parseInt(match[2], 10);
if (from < 1) {
throw new Error(`Line numbers must be >= 1, got ${from}`);
}
if (to < from) {
throw new Error(`End line (${to}) must be >= start line (${from})`);
}
parsed.push({ from, to });
}
return parsed;
}
/**
* Read file content and optionally extract line ranges
* @param {string} filePath - Absolute path to file
* @param {{from: number, to: number}[]|null} lineRanges - Line ranges to extract
* @returns {string} File content (full or extracted ranges)
* @throws {Error} If line ranges exceed file length
*/
function readFileContent(filePath, lineRanges) {
const content = fs.readFileSync(filePath, "utf8");
if (!lineRanges || lineRanges.length === 0) {
return content;
}
const lines = content.split("\n");
const totalLines = lines.length;
const extractedSegments = [];
for (const range of lineRanges) {
if (range.from > totalLines) {
throw new Error(
`Start line ${range.from} exceeds file length (${totalLines} lines)`
);
}
const endLine = Math.min(range.to, totalLines);
const segment = lines.slice(range.from - 1, endLine);
extractedSegments.push(segment.join("\n"));
}
return extractedSegments.join("\n\n");
}
// ============================================================================
// Output Formatting
// ============================================================================
/**
* Format file content as markdown code block
* @param {string} filePath - Path to file
* @param {string} language - Language for syntax highlighting
* @param {string} content - File content
* @param {{from: number, to: number}[]|null} lineRanges - Line ranges (for display)
* @returns {string} Formatted markdown code block
*/
function formatCodeBlock(filePath, language, content, lineRanges) {
let lineRangeStr = "";
if (lineRanges && lineRanges.length > 0) {
const rangeStrings = lineRanges.map((r) => `${r.from}-${r.to}`);
lineRangeStr = ` (lines ${rangeStrings.join(", ")})`;
}
return `# File: ${filePath}${lineRangeStr}
\`\`\`${language}
${content}
\`\`\``;
}
/**
* Format git diff output as markdown code block
* @param {string} filePath - Path to file
* @param {string} diffContent - Git diff output
* @param {string} diffRange - Git range for display
* @returns {string} Formatted markdown diff block
*/
function formatDiffBlock(filePath, diffContent, diffRange) {
return `# File: ${filePath} (diff=${diffRange})
\`\`\`diff
${diffContent}
\`\`\``;
}
// ============================================================================
// File Validation
// ============================================================================
/**
* Validate file argument without processing it
* Checks file existence, git refs, and line ranges
*
* @param {string} fileArg - File argument from command line
* @returns {{valid: boolean, fileArg?: string, error?: string}} Validation result
*/
function validateFile(fileArg) {
const { filePath: parsedPath, rangeStr } = parseFileArgument(fileArg);
// Resolve to absolute path
const filePath = path.isAbsolute(parsedPath)
? parsedPath
: path.resolve(process.cwd(), parsedPath);
// Check if file exists
if (!fs.existsSync(filePath)) {
const cwd = process.cwd();
const suggestion = getSuggestion(filePath);
return {
valid: false,
fileArg,
error: `File not found: ${filePath}\n Current directory: ${cwd}${suggestion}`,
};
}
// Validate range specification if present
if (rangeStr) {
const diffSpec = parseDiffSpec(rangeStr);
if (diffSpec) {
// Validate git diff specification
try {
validateGitRepository();
const refs = splitGitRefs(diffSpec.range);
validateGitRefs(refs);
} catch (error) {
return {
valid: false,
fileArg,
error: error.message,
};
}
} else {
// Validate line ranges
try {
const lineRanges = parseLineRanges(rangeStr);
const content = fs.readFileSync(filePath, "utf8");
const totalLines = content.split("\n").length;
for (const range of lineRanges) {
if (range.from > totalLines || range.to > totalLines) {
return {
valid: false,
fileArg,
error: `Line range ${range.from}-${range.to} exceeds file length (${totalLines} lines) in ${filePath}`,
};
}
}
} catch (error) {
return {
valid: false,
fileArg,
error: `Invalid line range format in "${fileArg}": ${error.message}`,
};
}
}
}
return { valid: true };
}
/**
* Process a single file argument and return formatted content
* @param {string} fileArg - File argument (path with optional range/diff spec)
* @returns {string} Formatted markdown output
* @throws {Error} If file processing fails
*/
function processFile(fileArg) {
const { filePath: parsedPath, rangeStr } = parseFileArgument(fileArg);
// Resolve to absolute path
const filePath = path.isAbsolute(parsedPath)
? parsedPath
: path.resolve(process.cwd(), parsedPath);
// Check if file exists
if (!fs.existsSync(filePath)) {
const cwd = process.cwd();
const suggestion = getSuggestion(filePath);
throw new Error(
`File not found: ${filePath}\n Current directory: ${cwd}${suggestion}`
);
}
// Check for diff specification
const diffSpec = rangeStr ? parseDiffSpec(rangeStr) : null;
if (diffSpec) {
// Handle git diff mode
const diffContent = readDiffContent(filePath, diffSpec.range);
// Handle empty diff
if (!diffContent || diffContent.trim() === "") {
return formatDiffBlock(
filePath,
`(No changes between ${diffSpec.range})`,
diffSpec.range
);
}
return formatDiffBlock(filePath, diffContent, diffSpec.range);
}
// Handle line range or full file mode
const lineRanges = rangeStr ? parseLineRanges(rangeStr) : null;
const language = detectLanguage(filePath);
const content = readFileContent(filePath, lineRanges);
return formatCodeBlock(filePath, language, content, lineRanges);
}
// ============================================================================
// Config File Operations
// ============================================================================
/**
* Read and validate JSON config file
* @param {string} configPath - Path to config file
* @returns {object} Validated config object
* @throws {Error} If config is invalid
*/
function readConfigFile(configPath) {
if (!fs.existsSync(configPath)) {
throw new Error(`Config file not found: ${configPath}`);
}
let config;
try {
const content = fs.readFileSync(configPath, "utf8");
config = JSON.parse(content);
} catch (error) {
throw new Error(`Failed to parse config file: ${error.message}`);
}
// Validate schema
if (!config.sections || !Array.isArray(config.sections)) {
throw new Error(
'Config must have "sections" array. See example config for format.'
);
}
if (config.sections.length === 0) {
throw new Error("Config must have at least one section");
}
for (const [index, section] of config.sections.entries()) {
if (!section.files || !Array.isArray(section.files)) {
throw new Error(
`Section ${index + 1} must have "files" array. Header: ${section.header || "(no header)"}`
);
}
if (section.files.length === 0) {
throw new Error(
`Section ${index + 1} must have at least one file. Header: ${section.header || "(no header)"}`
);
}
}
return config;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Provide helpful error suggestions when file is not found
* @param {string} filePath - Path that was not found
* @returns {string} Suggestion text (empty if none applicable)
*/
function getSuggestion(filePath) {
// Provide helpful suggestion for relative path issues
if (!path.isAbsolute(filePath) && filePath.includes(path.sep)) {
return `\n 💡 Tip: Verify you're in the correct directory:\n pwd # Check current directory\n ls ${path.dirname(filePath)} # Check parent directory exists`;
}
// Check if file exists with different casing
const dir = path.dirname(filePath);
const filename = path.basename(filePath);
if (fs.existsSync(dir)) {
try {
const files = fs.readdirSync(dir);
const match = files.find(
(f) => f.toLowerCase() === filename.toLowerCase()
);
if (match && match !== filename) {
return `\n 💡 Tip: File exists with different casing: ${match}`;
}
} catch {
// Ignore directory read errors
}
}
return "";
}
// ============================================================================
// CLI Interface
// ============================================================================
/**
* Display help message
*/
function showHelp() {
console.log(`
📄 Code Extractor for Expert Consultations
Usage:
extract-code [options] <file1> [file2] [file3] ...
extract-code --help
Arguments:
file File path with optional line range(s) or diff spec
Formats:
/path/to/file.cs (extract full file)
/path/to/file.cs:10-50 (extract lines 10-50)
/path/to/file.cs:10:50 (extract lines 10-50, alternative)
/path/to/file.cs:10-50,100-150 (multiple ranges, comma-separated)
/path/to/file.cs:1-30,86-213,500-600 (multiple ranges)
/path/to/file.cs:diff (git diff vs master)
/path/to/file.cs:diff=master..HEAD (git diff with explicit range)
/path/to/file.cs:diff=HEAD~3 (git diff vs 3 commits ago)
relative/path/file.cs (resolved to absolute path)
relative/path/file.cs:5-15 (with line range)
Options:
--help, -h Show this help message
--output, -o <file> Write output to file (appends to existing file)
--track-size Show size tracking and progress (requires --output)
--section <header> Add markdown section header before next file
Can be used multiple times for different files
--config <file> Use JSON config file for batch extraction
See example-config.json for format
Output:
Prints markdown-formatted code blocks with file paths and line ranges.
Output can be redirected to a file or piped to other commands.
Examples:
# Extract full files
extract-code src/Service.cs tests/ServiceTests.cs
# Extract specific line ranges
extract-code src/Service.cs:100-200 tests/ServiceTests.cs:50-75
# Extract multiple ranges from a single file
extract-code src/Service.cs:1-30,86-213
# Mix full files and ranges
extract-code src/Models/User.cs src/Service.cs:100-150
# Show git diff vs master (default)
extract-code src/Service.cs:diff
# Show git diff with explicit range
extract-code src/Service.cs:diff=master..feature-branch
extract-code src/Service.cs:diff=HEAD~3..HEAD
# Show what changed in recent commits
extract-code src/Service.cs:diff=HEAD~5
# Combine diffs with regular files
extract-code src/Service.cs:diff src/Tests.cs:100-200
# Save to file with size tracking (appends to existing file)
extract-code --track-size --output=consultation.md src/Service.cs
# Add section headers
extract-code --section="Core Interfaces" Interface.cs \\
--section="Domain Models" Contact.cs Company.cs \\
--output=doc.md
# Combined: size tracking + sections
extract-code --track-size --output=doc.md \\
--section="Core" Interface.cs \\
--section="Tests" Tests.cs:100-200
# Include diffs in consultation documents
extract-code --track-size --output=consultation.md \\
--section="What Changed" \\
src/NetworkService.cs:diff \\
src/PineconeHelper.cs:diff=master..feature-branch
# Build document incrementally (appends each time)
extract-code --track-size -o doc.md File1.cs
extract-code --track-size -o doc.md File2.cs # Appends to existing
extract-code --track-size -o doc.md File3.cs # Appends again
# Traditional output redirection still works
extract-code src/Service.cs > expert-consultation.md
extract-code src/Tests.cs >> expert-consultation.md # Append
# Use config file for complex extractions
extract-code --config=extraction-plan.json
extract-code --config=extraction-plan.json --track-size # Override trackSize
Notes:
• Automatically detects language from file extension
• Line numbers are 1-indexed (first line is line 1)
• Line ranges are inclusive (10-20 includes both lines 10 and 20)
• Multiple ranges are separated by blank lines in output
• Supports 20+ file types (cs, js, ts, vue, py, etc.)
• Error messages go to stderr, formatted output to stdout
• --output mode always appends (matches >> behavior)
• Size tracking shows warnings at 100KB, 115KB, errors at 125KB
• Section headers apply to the immediately following file only
• Diff mode requires git repository and valid refs
• Diff output uses unified diff format (standard git diff)
`);
}
/**
* Main entry point
*/
function main() {
const options = {
help: {
type: "boolean",
short: "h",
},
"track-size": {
type: "boolean",
},
output: {
type: "string",
short: "o",
},
section: {
type: "string",
multiple: true,
},
config: {
type: "string",
},
};
let args;
try {
const parsed = parseArgs({ options, allowPositionals: true });
args = parsed.values;
args.positionals = parsed.positionals;
} catch (error) {
console.error(`${error.message}`);
showHelp();
process.exit(1);
}
if (args.help) {
showHelp();
process.exit(0);
}
// Handle config file mode
if (args.config) {
try {
const config = readConfigFile(args.config);
processConfigFile(config, args);
return;
} catch (error) {
console.error(`❌ Error processing config file: ${error.message}`);
process.exit(1);
}
}
if (!args.positionals || args.positionals.length === 0) {
console.error("❌ No files specified");
showHelp();
process.exit(1);
}
// Filter out empty arguments
const fileArgs = args.positionals.filter((arg) => arg && arg.trim() !== "");
// VALIDATE ALL FILES FIRST - before writing anything
const validationErrors = [];
for (const fileArg of fileArgs) {
const validation = validateFile(fileArg);
if (!validation.valid) {
validationErrors.push({
fileArg: validation.fileArg,
error: validation.error,
});
}
}
// If any validation errors, report them all and exit without modifying output
if (validationErrors.length > 0) {
console.error(
`❌ Validation failed for ${validationErrors.length} file(s):\n`
);
for (const { fileArg, error } of validationErrors) {
console.error(` • "${fileArg}":`);
console.error(` ${error.replace(/\n/g, "\n ")}`);
console.error("");
}
console.error("⚠️ No files were written to avoid partial output.");
process.exit(1);
}
const results = [];
let hasErrors = false;
let totalBytes = 0;
let sectionIndex = 0;
// Read existing file size if output file specified
if (args.output && fs.existsSync(args.output)) {
const stats = fs.statSync(args.output);
totalBytes = stats.size;
if (args["track-size"]) {
console.error(`📄 ${args.output}: ${formatSize(totalBytes)} (existing)`);
}
}
// Process each file
for (const [index, fileArg] of fileArgs.entries()) {
try {
// Add section header if specified
let output = "";
if (args.section && sectionIndex < args.section.length) {
const sectionHeader = args.section[sectionIndex];
if (sectionHeader) {
output = `### ${sectionHeader}\n\n`;
}
sectionIndex++;
}
const result = processFile(fileArg);
output += result;
results.push(output);
// Calculate size
const contentSize = Buffer.byteLength(output + "\n\n", "utf8");
totalBytes += contentSize;
// Write to file or collect for stdout
if (args.output) {
fs.appendFileSync(args.output, output + "\n\n", "utf8");
}
// Show progress if tracking size
if (args["track-size"]) {
const percent = ((totalBytes / MAX_SIZE_BYTES) * 100).toFixed(1);
const filename = path.basename(fileArg.split(":")[0]);
console.error(
`[${index + 1}/${fileArgs.length}] ${filename} → +${formatSize(contentSize)} (${formatSize(totalBytes)} / 125 KB, ${percent}%)`
);
// Check thresholds
if (totalBytes >= MAX_SIZE_BYTES) {
console.error(
`❌ Error: Exceeded 125 KB limit (${formatSize(totalBytes)})`
);
console.error(
` Stop processing to stay within expert consultation limits`
);
process.exit(1);
} else if (totalBytes >= WARNING_THRESHOLD_2) {
console.error(`⚠️ Very close to 125 KB limit!`);
} else if (totalBytes >= WARNING_THRESHOLD_1) {
console.error(`⚠️ Approaching 100 KB`);
}
}
} catch (error) {
console.error(`❌ Error processing "${fileArg}": ${error.message}`);
hasErrors = true;
}
}
if (results.length === 0) {
console.error("\n❌ No files were successfully processed");
process.exit(1);
}
// Output results to stdout if no output file specified
if (!args.output) {
console.log(results.join("\n\n"));
} else if (args["track-size"]) {
const status = hasErrors ? "⚠️ Completed with errors" : "✅ Saved";
const fileCount = `${results.length} ${results.length === 1 ? "file" : "files"}`;
console.error(
`${status}: ${fileCount} to ${args.output} (${formatSize(totalBytes)} / 125 KB)`
);
}
process.exit(hasErrors ? 1 : 0);
}
/**
* Process files from config file
* @param {object} config - Validated config object
* @param {object} args - Parsed command-line arguments
*/
function processConfigFile(config, args) {
let totalBytes = 0;
let totalFilesProcessed = 0;
let hasErrors = false;
// Use output from config or args
const outputFile = args.output || config.output;
const trackSize = args["track-size"] || config.trackSize || false;
if (!outputFile) {
console.error(
"❌ Config mode requires output file. Specify in config file or use --output flag"
);
process.exit(1);
}
// Read existing file size if output file exists
if (fs.existsSync(outputFile)) {
const stats = fs.statSync(outputFile);
totalBytes = stats.size;
if (trackSize) {
console.error(`📄 ${outputFile}: ${formatSize(totalBytes)} (existing)`);
}
}
// VALIDATE ALL FILES FIRST - before writing anything
const validationErrors = [];
for (const section of config.sections) {
for (const fileArg of section.files) {
const validation = validateFile(fileArg);
if (!validation.valid) {
validationErrors.push({
fileArg: validation.fileArg,
error: validation.error,
section: section.header || "(no header)",
});
}
}
}
// If any validation errors, report them all and exit without modifying output
if (validationErrors.length > 0) {
console.error(
`❌ Validation failed for ${validationErrors.length} file(s):\n`
);
for (const { fileArg, error, section } of validationErrors) {
console.error(` • "${fileArg}" in section "${section}":`);
console.error(` ${error.replace(/\n/g, "\n ")}`);
console.error("");
}
console.error("⚠️ No files were written to avoid partial output.");
process.exit(1);
}
// Process each section
for (const [sectionIndex, section] of config.sections.entries()) {
if (trackSize) {
console.error(
`[Section ${sectionIndex + 1}/${config.sections.length}] ${section.header || "(no header)"}`
);
}
// Add section header if specified
if (section.header) {
const headerContent = `### ${section.header}\n\n`;
fs.appendFileSync(outputFile, headerContent, "utf8");
totalBytes += Buffer.byteLength(headerContent, "utf8");
}
// Process each file in section
for (const [fileIndex, fileArg] of section.files.entries()) {
try {
const result = processFile(fileArg);
const content = result + "\n\n";
const contentSize = Buffer.byteLength(content, "utf8");
totalBytes += contentSize;
// Write to file
fs.appendFileSync(outputFile, content, "utf8");
totalFilesProcessed++;
// Show progress if tracking size
if (trackSize) {
const percent = ((totalBytes / MAX_SIZE_BYTES) * 100).toFixed(1);
const filename = path.basename(fileArg.split(":")[0]);
console.error(
` [${fileIndex + 1}/${section.files.length}] ${filename} → +${formatSize(contentSize)} (${formatSize(totalBytes)} / 125 KB, ${percent}%)`
);
// Check thresholds
if (totalBytes >= MAX_SIZE_BYTES) {
console.error(
`❌ Error: Exceeded 125 KB limit (${formatSize(totalBytes)})`
);
console.error(
` Stop processing to stay within expert consultation limits`
);
process.exit(1);
} else if (totalBytes >= WARNING_THRESHOLD_2) {
console.error(`⚠️ Very close to 125 KB limit!`);
} else if (totalBytes >= WARNING_THRESHOLD_1) {
console.error(`⚠️ Approaching 100 KB`);
}
}
} catch (error) {
console.error(
`❌ Error processing "${fileArg}" in section "${section.header || "(no header)"}": ${error.message}`
);
hasErrors = true;
}
}
}
if (trackSize) {
const status = hasErrors ? "⚠️ Completed with errors" : "✅ Saved";
const fileCount = `${totalFilesProcessed} ${totalFilesProcessed === 1 ? "file" : "files"}`;
const sectionCount = `${config.sections.length} ${config.sections.length === 1 ? "section" : "sections"}`;
console.error(
`${status}: ${fileCount}, ${sectionCount} to ${outputFile} (${formatSize(totalBytes)} / 125 KB)`
);
}
process.exit(hasErrors ? 1 : 0);
}
// ============================================================================
// Entry Point
// ============================================================================
// Handle unhandled errors
process.on("unhandledRejection", (err) => {
console.error("❌ Error:", err.message);
process.exit(1);
});
// Run the script only if executed directly (not imported)
const scriptPath = path.normalize(process.argv[1]);
const modulePath = path.normalize(fileURLToPath(import.meta.url));
if (modulePath === scriptPath) {
try {
main();
} catch (err) {
console.error("❌ Error:", err.message);
process.exit(1);
}
}