193 lines
5.1 KiB
TypeScript
193 lines
5.1 KiB
TypeScript
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
import { join, relative } from 'node:path';
|
|
|
|
const IGNORED_DIRS = new Set(['node_modules', 'dist', '.git', '.next', '__pycache__']);
|
|
const MAX_OUTPUT_CHARS = 8000;
|
|
const README_EXCERPT_CHARS = 500;
|
|
const MAX_DEPS_SHOWN = 10;
|
|
|
|
interface DirectoryEntry {
|
|
name: string;
|
|
isDir: boolean;
|
|
children?: DirectoryEntry[];
|
|
}
|
|
|
|
async function readDirTree(dir: string, depth: number, maxDepth: number): Promise<DirectoryEntry[]> {
|
|
if (depth > maxDepth) return [];
|
|
|
|
let entries: string[];
|
|
try {
|
|
entries = await readdir(dir);
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
const result: DirectoryEntry[] = [];
|
|
|
|
for (const name of entries.sort()) {
|
|
if (IGNORED_DIRS.has(name) || name.startsWith('.')) continue;
|
|
|
|
const fullPath = join(dir, name);
|
|
let isDir = false;
|
|
|
|
try {
|
|
const s = await stat(fullPath);
|
|
isDir = s.isDirectory();
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const entry: DirectoryEntry = { name, isDir };
|
|
|
|
if (isDir && depth < maxDepth) {
|
|
entry.children = await readDirTree(fullPath, depth + 1, maxDepth);
|
|
}
|
|
|
|
result.push(entry);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function formatDirTree(entries: DirectoryEntry[], indent = 0): string {
|
|
const lines: string[] = [];
|
|
const prefix = ' '.repeat(indent);
|
|
|
|
for (const entry of entries) {
|
|
if (entry.isDir) {
|
|
lines.push(`${prefix}${entry.name}/`);
|
|
if (entry.children && entry.children.length > 0) {
|
|
lines.push(formatDirTree(entry.children, indent + 1));
|
|
}
|
|
} else {
|
|
lines.push(`${prefix}${entry.name}`);
|
|
}
|
|
}
|
|
|
|
return lines.filter(Boolean).join('\n');
|
|
}
|
|
|
|
interface PackageJson {
|
|
name?: string;
|
|
description?: string;
|
|
dependencies?: Record<string, string>;
|
|
devDependencies?: Record<string, string>;
|
|
peerDependencies?: Record<string, string>;
|
|
}
|
|
|
|
interface TsConfig {
|
|
compilerOptions?: {
|
|
target?: string;
|
|
paths?: Record<string, string[]>;
|
|
};
|
|
}
|
|
|
|
async function tryReadJson<T>(filePath: string): Promise<T | null> {
|
|
try {
|
|
const content = await readFile(filePath, 'utf-8');
|
|
return JSON.parse(content) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function tryReadText(filePath: string): Promise<string | null> {
|
|
try {
|
|
return await readFile(filePath, 'utf-8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function truncate(text: string, maxChars: number): string {
|
|
if (text.length <= maxChars) return text;
|
|
return text.slice(0, maxChars) + '\n[...truncated]';
|
|
}
|
|
|
|
/**
|
|
* Builds a structured project context summary (~2000 tokens) for an AI coding agent.
|
|
* Scans package.json, tsconfig.json, CLAUDE.md, and README.md alongside the directory tree.
|
|
*/
|
|
export async function buildProjectContext(projectDir: string): Promise<string> {
|
|
const sections: string[] = [];
|
|
|
|
// --- package.json ---
|
|
const pkg = await tryReadJson<PackageJson>(join(projectDir, 'package.json'));
|
|
const projectName = pkg?.name ?? (relative(process.cwd(), projectDir) || projectDir);
|
|
const description = pkg?.description ?? '';
|
|
|
|
sections.push(`## Project: ${projectName}`);
|
|
if (description) {
|
|
sections.push(description);
|
|
}
|
|
sections.push('');
|
|
|
|
// --- Tech stack from package.json ---
|
|
if (pkg) {
|
|
const allDeps: Record<string, string> = {
|
|
...pkg.dependencies,
|
|
...pkg.devDependencies,
|
|
...pkg.peerDependencies,
|
|
};
|
|
|
|
const depEntries = Object.entries(allDeps).slice(0, MAX_DEPS_SHOWN);
|
|
if (depEntries.length > 0) {
|
|
sections.push('### Key Dependencies');
|
|
for (const [name, version] of depEntries) {
|
|
sections.push(`- ${name}: ${version}`);
|
|
}
|
|
sections.push('');
|
|
}
|
|
}
|
|
|
|
// --- tsconfig.json ---
|
|
const tsconfig = await tryReadJson<TsConfig>(join(projectDir, 'tsconfig.json'));
|
|
if (tsconfig?.compilerOptions) {
|
|
const { target, paths } = tsconfig.compilerOptions;
|
|
const techLines: string[] = [];
|
|
|
|
if (target) techLines.push(`- TypeScript target: ${target}`);
|
|
|
|
if (paths && Object.keys(paths).length > 0) {
|
|
techLines.push(`- Path aliases: ${Object.keys(paths).join(', ')}`);
|
|
}
|
|
|
|
if (techLines.length > 0) {
|
|
sections.push('### Tech Stack');
|
|
sections.push(...techLines);
|
|
sections.push('');
|
|
}
|
|
}
|
|
|
|
// --- Directory tree (depth 3) ---
|
|
const tree = await readDirTree(projectDir, 0, 3);
|
|
const treeText = formatDirTree(tree);
|
|
if (treeText) {
|
|
sections.push('### Structure');
|
|
sections.push(treeText);
|
|
sections.push('');
|
|
}
|
|
|
|
// --- CLAUDE.md ---
|
|
const claudeRoot = await tryReadText(join(projectDir, 'CLAUDE.md'));
|
|
const claudeDotDir = await tryReadText(join(projectDir, '.claude', 'CLAUDE.md'));
|
|
let claudeContent = claudeRoot ?? claudeDotDir;
|
|
|
|
if (claudeContent) {
|
|
sections.push('### Project Instructions');
|
|
sections.push(claudeContent.trim());
|
|
sections.push('');
|
|
}
|
|
|
|
// --- README.md ---
|
|
const readme = await tryReadText(join(projectDir, 'README.md'));
|
|
if (readme) {
|
|
const excerpt = readme.slice(0, README_EXCERPT_CHARS);
|
|
sections.push('### README');
|
|
sections.push(excerpt + (readme.length > README_EXCERPT_CHARS ? '\n[...truncated]' : ''));
|
|
sections.push('');
|
|
}
|
|
|
|
const combined = sections.join('\n');
|
|
return truncate(combined, MAX_OUTPUT_CHARS);
|
|
}
|