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';
import { RuleIndexer } from './rules/indexer.js';
import { Composer } from './rules/composer.js';
import * as path from 'path';
import * as fs from 'fs';
import { fileURLToPath } from 'url';
// --- Configuration ---
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Package directory contains the rules (one level up from src)
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() {
// 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 ---
const indexer = new RuleIndexer(ROOT_DIR);
const indexer = new RuleIndexer(ROOT_DIR, PACKAGE_DIR);
const composer = new Composer(ROOT_DIR);
// Init index on startup (async but we assume fast enough or lazy)
indexer.init().catch(err => console.error('Failed to init index:', err));
......
......@@ -5,8 +5,8 @@ export class RuleIndexer {
scorer;
cachedRules = [];
lastIndexed = 0;
constructor(baseDir) {
this.loader = new RuleLoader(baseDir);
constructor(baseDir, fallbackDir) {
this.loader = new RuleLoader(baseDir, fallbackDir);
this.scorer = new Scorer();
}
async init() {
......
......@@ -4,17 +4,19 @@ import matter from 'gray-matter';
import { findMarkdownFiles } from '../utils/glob.js';
export class RuleLoader {
rootDir;
constructor(rootDir) {
fallbackDir;
constructor(rootDir, fallbackDir) {
this.rootDir = path.resolve(rootDir);
this.fallbackDir = fallbackDir ? path.resolve(fallbackDir) : undefined;
}
async loadAllRules() {
const files = await findMarkdownFiles(this.rootDir);
const rules = [];
let files = await findMarkdownFiles(this.rootDir);
let rules = [];
console.error(`Found ${files.length} rule files in ${this.rootDir}`);
// Load rules from root directory
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 {
const rule = await this.parseRule(file);
const rule = await this.parseRule(file, 'primary');
if (rule)
rules.push(rule);
}
......@@ -22,38 +24,65 @@ export class RuleLoader {
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;
}
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 {
const content = await fs.readFile(basePath, 'utf-8');
return content;
}
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 '';
}
}
async parseRule(filePath) {
async parseRule(filePath, source = 'primary') {
const rawContent = await fs.readFile(filePath, 'utf-8');
const { data, content } = matter(rawContent);
const fm = data;
const stats = await fs.stat(filePath);
// Filter out rules explicitly marked to be avoided if necessary?
// No, 'avoid' is a string[] field for query matching, not a flag to ignore the file itself basically.
// Unless frontmatter is drastically invalid.
const relativePath = path.relative(this.rootDir, filePath);
// Use the appropriate root directory for calculating relative path
const rootForRelPath = source === 'primary' ? this.rootDir : (this.fallbackDir || this.rootDir);
const relativePath = path.relative(rootForRelPath, filePath);
const id = fm.id || relativePath.replace(/\\/g, '/').replace(/\.md$/, '');
const title = fm.title || path.basename(filePath, '.md');
return {
id,
id: source === 'fallback' ? `fallback:${id}` : id,
path: filePath,
relativePath,
title,
title: source === 'fallback' ? `[Fallback] ${title}` : title,
content,
tags: fm.tags || [],
priority: typeof fm.priority === 'number' ? fm.priority : 50,
tags: [...(fm.tags || []), ...(source === 'fallback' ? ['fallback'] : [])],
priority: typeof fm.priority === 'number' ? fm.priority : (source === 'fallback' ? 10 : 50), // Lower priority for fallback
paths: fm.paths || [],
applies_when: fm.applies_when || [],
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
- **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.
- **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
......@@ -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.*
## 🚀 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
Once configured, Claude can use the following tools:
### 1. `rules.search`
### 1. `rules_search`
Searches for rules relevant to the current task.
- **Inputs**: `query` (task description), `openFiles`, `changedFiles`, `tags`.
- **Outputs**: List of ranked rules with their IDs and scores.
### 2. `rules.compose`
### 2. `rules_compose`
Creates the final rule bundle.
- **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.
### 3. `rules_get`
Get details of a specific rule by ID or path.
### 4. `rules_refresh`
Refresh the rule index from disk.
### Example Workflow
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`).
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.
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
Place your markdown rule files in the root directory or subdirectories.
......
......@@ -11,6 +11,7 @@ import { z } from 'zod';
import { RuleIndexer } from './rules/indexer.js';
import { Composer } from './rules/composer.js';
import * as path from 'path';
import * as fs from 'fs';
import { fileURLToPath } from 'url';
......@@ -19,11 +20,57 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Package directory contains the rules (one level up from src)
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 ---
const indexer = new RuleIndexer(ROOT_DIR);
const indexer = new RuleIndexer(ROOT_DIR, PACKAGE_DIR);
const composer = new Composer(ROOT_DIR);
// Init index on startup (async but we assume fast enough or lazy)
......
......@@ -8,8 +8,8 @@ export class RuleIndexer {
private cachedRules: Rule[] = [];
private lastIndexed: number = 0;
constructor(baseDir: string) {
this.loader = new RuleLoader(baseDir);
constructor(baseDir: string, fallbackDir?: string) {
this.loader = new RuleLoader(baseDir, fallbackDir);
this.scorer = new Scorer();
}
......
......@@ -6,63 +6,90 @@ import { findMarkdownFiles } from '../utils/glob.js';
export class RuleLoader {
private rootDir: string;
private fallbackDir?: string;
constructor(rootDir: string) {
constructor(rootDir: string, fallbackDir?: string) {
this.rootDir = path.resolve(rootDir);
this.fallbackDir = fallbackDir ? path.resolve(fallbackDir) : undefined;
}
async loadAllRules(): Promise<Rule[]> {
const files = await findMarkdownFiles(this.rootDir);
const rules: Rule[] = [];
let files = await findMarkdownFiles(this.rootDir);
let rules: Rule[] = [];
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;
console.error(`Found ${files.length} rule files in ${this.rootDir}`);
// Load rules from root directory
for (const file of files) {
try {
const rule = await this.parseRule(file);
const rule = await this.parseRule(file, 'primary');
if (rule) rules.push(rule);
} catch (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;
}
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 {
const content = await fs.readFile(basePath, 'utf-8');
return content;
} 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 '';
}
}
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 { data, content } = matter(rawContent);
const fm = data as RuleFrontmatter;
const stats = await fs.stat(filePath);
// Filter out rules explicitly marked to be avoided if necessary?
// No, 'avoid' is a string[] field for query matching, not a flag to ignore the file itself basically.
// Unless frontmatter is drastically invalid.
const relativePath = path.relative(this.rootDir, filePath);
// Use the appropriate root directory for calculating relative path
const rootForRelPath = source === 'primary' ? this.rootDir : (this.fallbackDir || this.rootDir);
const relativePath = path.relative(rootForRelPath, filePath);
const id = fm.id || relativePath.replace(/\\/g, '/').replace(/\.md$/, '');
const title = fm.title || path.basename(filePath, '.md');
return {
id,
id: source === 'fallback' ? `fallback:${id}` : id,
path: filePath,
relativePath,
title,
title: source === 'fallback' ? `[Fallback] ${title}` : title,
content,
tags: fm.tags || [],
priority: typeof fm.priority === 'number' ? fm.priority : 50,
tags: [...(fm.tags || []), ...(source === 'fallback' ? ['fallback'] : [])],
priority: typeof fm.priority === 'number' ? fm.priority : (source === 'fallback' ? 10 : 50), // Lower priority for fallback
paths: fm.paths || [],
applies_when: fm.applies_when || [],
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