Commit e12e933c authored by LongLD's avatar LongLD

feat: implement auto workspace detection and fallback support for rules

parent 5bb65263
...@@ -6,16 +6,57 @@ import { z } from 'zod'; ...@@ -6,16 +6,57 @@ import { z } from 'zod';
import { RuleIndexer } from './rules/indexer.js'; import { RuleIndexer } from './rules/indexer.js';
import { Composer } from './rules/composer.js'; import { Composer } from './rules/composer.js';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
// --- Configuration --- // --- Configuration ---
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
// Package directory contains the rules (one level up from src) // Package directory contains the rules (one level up from src)
const PACKAGE_DIR = path.resolve(__dirname, '..'); const PACKAGE_DIR = path.resolve(__dirname, '..');
// Allow override via command line argument, environment variable, or use package rules as fallback // Function to determine the best root directory
const ROOT_DIR = process.argv[2] || process.env.AWING_RULES_ROOT || PACKAGE_DIR; function determineRootDir() {
// 1. Command line argument has highest priority
if (process.argv[2]) {
return path.resolve(process.argv[2]);
}
// 2. Environment variable
if (process.env.AWING_RULES_ROOT) {
return path.resolve(process.env.AWING_RULES_ROOT);
}
// 3. Try to find workspace root by looking for common indicators
const currentWorkingDir = process.cwd();
const workspaceIndicators = [
'package.json',
'.git',
'tsconfig.json',
'pyproject.toml',
'Cargo.toml',
'go.mod',
'pom.xml',
'.vscode'
];
// Check if current working directory has workspace indicators
for (const indicator of workspaceIndicators) {
const indicatorPath = path.join(currentWorkingDir, indicator);
try {
if (fs.existsSync(indicatorPath)) {
// Found workspace root, use it
return currentWorkingDir;
}
}
catch (error) {
// Ignore errors, continue checking
}
}
// 4. Fallback to package directory (for development/testing)
return PACKAGE_DIR;
}
const ROOT_DIR = determineRootDir();
console.error(`Awing Rules MCP Server starting...`);
console.error(`Root directory: ${ROOT_DIR}`);
console.error(`Current working directory: ${process.cwd()}`);
// --- Initialization --- // --- Initialization ---
const indexer = new RuleIndexer(ROOT_DIR); const indexer = new RuleIndexer(ROOT_DIR, PACKAGE_DIR);
const composer = new Composer(ROOT_DIR); const composer = new Composer(ROOT_DIR);
// Init index on startup (async but we assume fast enough or lazy) // Init index on startup (async but we assume fast enough or lazy)
indexer.init().catch(err => console.error('Failed to init index:', err)); indexer.init().catch(err => console.error('Failed to init index:', err));
......
...@@ -5,8 +5,8 @@ export class RuleIndexer { ...@@ -5,8 +5,8 @@ export class RuleIndexer {
scorer; scorer;
cachedRules = []; cachedRules = [];
lastIndexed = 0; lastIndexed = 0;
constructor(baseDir) { constructor(baseDir, fallbackDir) {
this.loader = new RuleLoader(baseDir); this.loader = new RuleLoader(baseDir, fallbackDir);
this.scorer = new Scorer(); this.scorer = new Scorer();
} }
async init() { async init() {
......
...@@ -4,17 +4,19 @@ import matter from 'gray-matter'; ...@@ -4,17 +4,19 @@ import matter from 'gray-matter';
import { findMarkdownFiles } from '../utils/glob.js'; import { findMarkdownFiles } from '../utils/glob.js';
export class RuleLoader { export class RuleLoader {
rootDir; rootDir;
constructor(rootDir) { fallbackDir;
constructor(rootDir, fallbackDir) {
this.rootDir = path.resolve(rootDir); this.rootDir = path.resolve(rootDir);
this.fallbackDir = fallbackDir ? path.resolve(fallbackDir) : undefined;
} }
async loadAllRules() { async loadAllRules() {
const files = await findMarkdownFiles(this.rootDir); let files = await findMarkdownFiles(this.rootDir);
const rules = []; let rules = [];
console.error(`Found ${files.length} rule files in ${this.rootDir}`);
// Load rules from root directory
for (const file of files) { for (const file of files) {
// Note: We used to skip base.md here, but we want it indexed for search.
// if (path.basename(file).toLowerCase() === 'base.md') continue;
try { try {
const rule = await this.parseRule(file); const rule = await this.parseRule(file, 'primary');
if (rule) if (rule)
rules.push(rule); rules.push(rule);
} }
...@@ -22,38 +24,65 @@ export class RuleLoader { ...@@ -22,38 +24,65 @@ export class RuleLoader {
console.error(`Failed to parse rule file: ${file}`, error); console.error(`Failed to parse rule file: ${file}`, error);
} }
} }
// If no rules found and fallback directory exists, load from fallback
if (rules.length === 0 && this.fallbackDir && this.fallbackDir !== this.rootDir) {
console.error(`No rules found in primary directory, trying fallback: ${this.fallbackDir}`);
const fallbackFiles = await findMarkdownFiles(this.fallbackDir);
console.error(`Found ${fallbackFiles.length} fallback rule files`);
for (const file of fallbackFiles) {
try {
const rule = await this.parseRule(file, 'fallback');
if (rule)
rules.push(rule);
}
catch (error) {
console.error(`Failed to parse fallback rule file: ${file}`, error);
}
}
}
return rules; return rules;
} }
async loadBaseRule() { async loadBaseRule() {
const basePath = path.join(this.rootDir, 'base.md'); // Try to load base.md from root directory first
let basePath = path.join(this.rootDir, 'base.md');
try { try {
const content = await fs.readFile(basePath, 'utf-8'); const content = await fs.readFile(basePath, 'utf-8');
return content; return content;
} }
catch (error) { catch (error) {
// If base.md doesn't exist, return empty string or default warning // If not found and fallback directory exists, try fallback
if (this.fallbackDir && this.fallbackDir !== this.rootDir) {
basePath = path.join(this.fallbackDir, 'base.md');
try {
const content = await fs.readFile(basePath, 'utf-8');
return content;
}
catch (fallbackError) {
// Return empty string if neither exists
return '';
}
}
return ''; return '';
} }
} }
async parseRule(filePath) { async parseRule(filePath, source = 'primary') {
const rawContent = await fs.readFile(filePath, 'utf-8'); const rawContent = await fs.readFile(filePath, 'utf-8');
const { data, content } = matter(rawContent); const { data, content } = matter(rawContent);
const fm = data; const fm = data;
const stats = await fs.stat(filePath); const stats = await fs.stat(filePath);
// Filter out rules explicitly marked to be avoided if necessary? // Use the appropriate root directory for calculating relative path
// No, 'avoid' is a string[] field for query matching, not a flag to ignore the file itself basically. const rootForRelPath = source === 'primary' ? this.rootDir : (this.fallbackDir || this.rootDir);
// Unless frontmatter is drastically invalid. const relativePath = path.relative(rootForRelPath, filePath);
const relativePath = path.relative(this.rootDir, filePath);
const id = fm.id || relativePath.replace(/\\/g, '/').replace(/\.md$/, ''); const id = fm.id || relativePath.replace(/\\/g, '/').replace(/\.md$/, '');
const title = fm.title || path.basename(filePath, '.md'); const title = fm.title || path.basename(filePath, '.md');
return { return {
id, id: source === 'fallback' ? `fallback:${id}` : id,
path: filePath, path: filePath,
relativePath, relativePath,
title, title: source === 'fallback' ? `[Fallback] ${title}` : title,
content, content,
tags: fm.tags || [], tags: [...(fm.tags || []), ...(source === 'fallback' ? ['fallback'] : [])],
priority: typeof fm.priority === 'number' ? fm.priority : 50, priority: typeof fm.priority === 'number' ? fm.priority : (source === 'fallback' ? 10 : 50), // Lower priority for fallback
paths: fm.paths || [], paths: fm.paths || [],
applies_when: fm.applies_when || [], applies_when: fm.applies_when || [],
avoid: fm.avoid || [], avoid: fm.avoid || [],
......
{"name": "empty-project", "version": "1.0.0"}
...@@ -11,6 +11,8 @@ A Model Context Protocol (MCP) server designed to help Claude Code dynamically f ...@@ -11,6 +11,8 @@ A Model Context Protocol (MCP) server designed to help Claude Code dynamically f
- **Rule Composition**: Dynamically bundles a global `base.md` with selected rules into a single, deduplicated Markdown prompt. - **Rule Composition**: Dynamically bundles a global `base.md` with selected rules into a single, deduplicated Markdown prompt.
- **Deduplication**: Automatically removes duplicate generic bullet points to keep the context window efficient. - **Deduplication**: Automatically removes duplicate generic bullet points to keep the context window efficient.
- **Diversification**: Ensures results are not dominated by a single category/folder. - **Diversification**: Ensures results are not dominated by a single category/folder.
- **🆕 Auto Workspace Detection**: Automatically detects the current project workspace and looks for rules there first
- **🆕 Fallback Support**: Falls back to built-in rules when no project-specific rules are found
## Installation ## Installation
...@@ -47,28 +49,78 @@ To use this with Claude Code or Claude Desktop, add it to your MCP configuration ...@@ -47,28 +49,78 @@ To use this with Claude Code or Claude Desktop, add it to your MCP configuration
*Note: Replace the path with the absolute path to your `dist/index.js` file.* *Note: Replace the path with the absolute path to your `dist/index.js` file.*
## 🚀 How it works
### Workspace Detection
The MCP server automatically detects your current workspace using these strategies (in order):
1. **Command line argument**: `node awing-rules-mcp.js /path/to/project`
2. **Environment variable**: `AWING_RULES_ROOT=/path/to/project`
3. **🆕 Auto-detection**: Looks for workspace indicators in current working directory:
- `package.json` (Node.js)
- `.git` (Git repository)
- `tsconfig.json` (TypeScript)
- `pyproject.toml` (Python)
- `Cargo.toml` (Rust)
- `go.mod` (Go)
- `pom.xml` (Java/Maven)
- `.vscode` (VS Code workspace)
4. **Fallback**: Uses built-in rules from the MCP package
### Rule Priority
- **Project rules**: Rules found in your workspace have priority
- **Fallback rules**: Built-in rules are used when no project rules exist
- **Fallback rules are tagged**: Marked with `[Fallback]` and `fallback` tag for easy identification
## Usage with Claude Code ## Usage with Claude Code
Once configured, Claude can use the following tools: Once configured, Claude can use the following tools:
### 1. `rules.search` ### 1. `rules_search`
Searches for rules relevant to the current task. Searches for rules relevant to the current task.
- **Inputs**: `query` (task description), `openFiles`, `changedFiles`, `tags`. - **Inputs**: `query` (task description), `openFiles`, `changedFiles`, `tags`.
- **Outputs**: List of ranked rules with their IDs and scores. - **Outputs**: List of ranked rules with their IDs and scores.
### 2. `rules.compose` ### 2. `rules_compose`
Creates the final rule bundle. Creates the final rule bundle.
- **Inputs**: `selected` (list of rule IDs/paths obtained from search). - **Inputs**: `selected` (list of rule IDs/paths obtained from search).
- **Outputs**: A single markdown string containing the `base.md` content followed by the selected rules, optimized for the context window. - **Outputs**: A single markdown string containing the `base.md` content followed by the selected rules, optimized for the context window.
### 3. `rules_get`
Get details of a specific rule by ID or path.
### 4. `rules_refresh`
Refresh the rule index from disk.
### Example Workflow ### Example Workflow
1. User asks: "Refactor the login component to use the new hook." 1. User asks: "Refactor the login component to use the new hook."
2. Claude calls `rules.search(query="refactor login hook", openFiles=["src/Login.tsx"], ...)` 2. Claude calls `rules_search(query="refactor login hook", openFiles=["src/Login.tsx"], ...)`
3. Server returns top relevant rules (e.g., `frontend/react-hooks.md`, `style/typescript.md`). 3. Server returns top relevant rules (e.g., `frontend/react-hooks.md`, `style/typescript.md`).
4. Claude calls `rules.compose(selected=[{id: "react-hooks"}, {id: "typescript"}])`. 4. Claude calls `rules_compose(selected=[{id: "react-hooks"}, {id: "typescript"}])`.
5. Server returns the combined markdown bundle. 5. Server returns the combined markdown bundle.
6. Claude reads the bundle and executes the refactoring task following the rules. 6. Claude reads the bundle and executes the refactoring task following the rules.
## 📝 Creating Project Rules
### Quick Start
1. Create `.md` files in your project root or subdirectories
2. Add frontmatter with `title`, `tags`, and `priority`
3. The MCP will automatically detect and index them
### Example Project Structure
```
your-project/
├── package.json # ← Workspace indicator
├── base.md # ← Global rules for your project
├── frontend/
│ ├── react.md # ← React-specific rules
│ └── components.md # ← Component guidelines
├── backend/
│ └── api.md # ← API guidelines
└── style/
└── typescript.md # ← TypeScript conventions
```
## Rule File Structure ## Rule File Structure
Place your markdown rule files in the root directory or subdirectories. Place your markdown rule files in the root directory or subdirectories.
......
...@@ -11,6 +11,7 @@ import { z } from 'zod'; ...@@ -11,6 +11,7 @@ import { z } from 'zod';
import { RuleIndexer } from './rules/indexer.js'; import { RuleIndexer } from './rules/indexer.js';
import { Composer } from './rules/composer.js'; import { Composer } from './rules/composer.js';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
...@@ -19,11 +20,57 @@ const __filename = fileURLToPath(import.meta.url); ...@@ -19,11 +20,57 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
// Package directory contains the rules (one level up from src) // Package directory contains the rules (one level up from src)
const PACKAGE_DIR = path.resolve(__dirname, '..'); const PACKAGE_DIR = path.resolve(__dirname, '..');
// Allow override via command line argument, environment variable, or use package rules as fallback
const ROOT_DIR = process.argv[2] || process.env.AWING_RULES_ROOT || PACKAGE_DIR; // Function to determine the best root directory
function determineRootDir(): string {
// 1. Command line argument has highest priority
if (process.argv[2]) {
return path.resolve(process.argv[2]);
}
// 2. Environment variable
if (process.env.AWING_RULES_ROOT) {
return path.resolve(process.env.AWING_RULES_ROOT);
}
// 3. Try to find workspace root by looking for common indicators
const currentWorkingDir = process.cwd();
const workspaceIndicators = [
'package.json',
'.git',
'tsconfig.json',
'pyproject.toml',
'Cargo.toml',
'go.mod',
'pom.xml',
'.vscode'
];
// Check if current working directory has workspace indicators
for (const indicator of workspaceIndicators) {
const indicatorPath = path.join(currentWorkingDir, indicator);
try {
if (fs.existsSync(indicatorPath)) {
// Found workspace root, use it
return currentWorkingDir;
}
} catch (error) {
// Ignore errors, continue checking
}
}
// 4. Fallback to package directory (for development/testing)
return PACKAGE_DIR;
}
const ROOT_DIR = determineRootDir();
console.error(`Awing Rules MCP Server starting...`);
console.error(`Root directory: ${ROOT_DIR}`);
console.error(`Current working directory: ${process.cwd()}`);
// --- Initialization --- // --- Initialization ---
const indexer = new RuleIndexer(ROOT_DIR); const indexer = new RuleIndexer(ROOT_DIR, PACKAGE_DIR);
const composer = new Composer(ROOT_DIR); const composer = new Composer(ROOT_DIR);
// Init index on startup (async but we assume fast enough or lazy) // Init index on startup (async but we assume fast enough or lazy)
......
...@@ -8,8 +8,8 @@ export class RuleIndexer { ...@@ -8,8 +8,8 @@ export class RuleIndexer {
private cachedRules: Rule[] = []; private cachedRules: Rule[] = [];
private lastIndexed: number = 0; private lastIndexed: number = 0;
constructor(baseDir: string) { constructor(baseDir: string, fallbackDir?: string) {
this.loader = new RuleLoader(baseDir); this.loader = new RuleLoader(baseDir, fallbackDir);
this.scorer = new Scorer(); this.scorer = new Scorer();
} }
......
...@@ -6,63 +6,90 @@ import { findMarkdownFiles } from '../utils/glob.js'; ...@@ -6,63 +6,90 @@ import { findMarkdownFiles } from '../utils/glob.js';
export class RuleLoader { export class RuleLoader {
private rootDir: string; private rootDir: string;
private fallbackDir?: string;
constructor(rootDir: string) { constructor(rootDir: string, fallbackDir?: string) {
this.rootDir = path.resolve(rootDir); this.rootDir = path.resolve(rootDir);
this.fallbackDir = fallbackDir ? path.resolve(fallbackDir) : undefined;
} }
async loadAllRules(): Promise<Rule[]> { async loadAllRules(): Promise<Rule[]> {
const files = await findMarkdownFiles(this.rootDir); let files = await findMarkdownFiles(this.rootDir);
const rules: Rule[] = []; let rules: Rule[] = [];
console.error(`Found ${files.length} rule files in ${this.rootDir}`);
// Load rules from root directory
for (const file of files) { for (const file of files) {
// Note: We used to skip base.md here, but we want it indexed for search.
// if (path.basename(file).toLowerCase() === 'base.md') continue;
try { try {
const rule = await this.parseRule(file); const rule = await this.parseRule(file, 'primary');
if (rule) rules.push(rule); if (rule) rules.push(rule);
} catch (error) { } catch (error) {
console.error(`Failed to parse rule file: ${file}`, error); console.error(`Failed to parse rule file: ${file}`, error);
} }
} }
// If no rules found and fallback directory exists, load from fallback
if (rules.length === 0 && this.fallbackDir && this.fallbackDir !== this.rootDir) {
console.error(`No rules found in primary directory, trying fallback: ${this.fallbackDir}`);
const fallbackFiles = await findMarkdownFiles(this.fallbackDir);
console.error(`Found ${fallbackFiles.length} fallback rule files`);
for (const file of fallbackFiles) {
try {
const rule = await this.parseRule(file, 'fallback');
if (rule) rules.push(rule);
} catch (error) {
console.error(`Failed to parse fallback rule file: ${file}`, error);
}
}
}
return rules; return rules;
} }
async loadBaseRule(): Promise<string> { async loadBaseRule(): Promise<string> {
const basePath = path.join(this.rootDir, 'base.md'); // Try to load base.md from root directory first
let basePath = path.join(this.rootDir, 'base.md');
try { try {
const content = await fs.readFile(basePath, 'utf-8'); const content = await fs.readFile(basePath, 'utf-8');
return content; return content;
} catch (error) { } catch (error) {
// If base.md doesn't exist, return empty string or default warning // If not found and fallback directory exists, try fallback
if (this.fallbackDir && this.fallbackDir !== this.rootDir) {
basePath = path.join(this.fallbackDir, 'base.md');
try {
const content = await fs.readFile(basePath, 'utf-8');
return content;
} catch (fallbackError) {
// Return empty string if neither exists
return '';
}
}
return ''; return '';
} }
} }
private async parseRule(filePath: string): Promise<Rule | null> { private async parseRule(filePath: string, source: 'primary' | 'fallback' = 'primary'): Promise<Rule | null> {
const rawContent = await fs.readFile(filePath, 'utf-8'); const rawContent = await fs.readFile(filePath, 'utf-8');
const { data, content } = matter(rawContent); const { data, content } = matter(rawContent);
const fm = data as RuleFrontmatter; const fm = data as RuleFrontmatter;
const stats = await fs.stat(filePath); const stats = await fs.stat(filePath);
// Filter out rules explicitly marked to be avoided if necessary? // Use the appropriate root directory for calculating relative path
// No, 'avoid' is a string[] field for query matching, not a flag to ignore the file itself basically. const rootForRelPath = source === 'primary' ? this.rootDir : (this.fallbackDir || this.rootDir);
// Unless frontmatter is drastically invalid. const relativePath = path.relative(rootForRelPath, filePath);
const relativePath = path.relative(this.rootDir, filePath);
const id = fm.id || relativePath.replace(/\\/g, '/').replace(/\.md$/, ''); const id = fm.id || relativePath.replace(/\\/g, '/').replace(/\.md$/, '');
const title = fm.title || path.basename(filePath, '.md'); const title = fm.title || path.basename(filePath, '.md');
return { return {
id, id: source === 'fallback' ? `fallback:${id}` : id,
path: filePath, path: filePath,
relativePath, relativePath,
title, title: source === 'fallback' ? `[Fallback] ${title}` : title,
content, content,
tags: fm.tags || [], tags: [...(fm.tags || []), ...(source === 'fallback' ? ['fallback'] : [])],
priority: typeof fm.priority === 'number' ? fm.priority : 50, priority: typeof fm.priority === 'number' ? fm.priority : (source === 'fallback' ? 10 : 50), // Lower priority for fallback
paths: fm.paths || [], paths: fm.paths || [],
applies_when: fm.applies_when || [], applies_when: fm.applies_when || [],
avoid: fm.avoid || [], avoid: fm.avoid || [],
......
#!/usr/bin/env node
// Test script to see if MCP can detect workspace correctly
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Create a temporary project directory to test
const testProjectDir = path.join(__dirname, 'test-project');
// Create test project
if (!fs.existsSync(testProjectDir)) {
fs.mkdirSync(testProjectDir, { recursive: true });
}
// Create a package.json to indicate this is a workspace
fs.writeFileSync(path.join(testProjectDir, 'package.json'), JSON.stringify({
name: 'test-project',
version: '1.0.0'
}, null, 2));
// Create a test rule file
fs.writeFileSync(path.join(testProjectDir, 'test-rule.md'), `---
title: Test Rule
tags: [test]
priority: 100
---
# Test Rule
This is a test rule to verify the MCP can find rules in project directories.
`);
console.log(`Created test project at: ${testProjectDir}`);
console.log('Test files:');
console.log('- package.json (workspace indicator)');
console.log('- test-rule.md (test rule)');
console.log('\nTo test:');
console.log(`1. cd ${testProjectDir}`);
console.log(`2. Run MCP from that directory`);
console.log(`3. Use rules_search tool with query "test"`);
\ No newline at end of file
{
"name": "test-project",
"version": "1.0.0"
}
\ No newline at end of file
---
title: Test Rule
tags: [test]
priority: 100
---
# Test Rule
This is a test rule to verify the MCP can find rules in project directories.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment