278 lines
8.0 KiB
TypeScript
278 lines
8.0 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import { mkdir } from "node:fs/promises";
|
|
import { dirname, isAbsolute, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
AGENT_GUIDELINES,
|
|
CLAUDE_AGENT_CONTENT,
|
|
CLAUDE_GUIDELINES,
|
|
COPILOT_GUIDELINES,
|
|
GEMINI_GUIDELINES,
|
|
MCP_AGENT_NUDGE,
|
|
README_GUIDELINES,
|
|
} from "./constants/index.ts";
|
|
import type { GitOperations } from "./git/operations.ts";
|
|
|
|
export type AgentInstructionFile =
|
|
| "AGENTS.md"
|
|
| "CLAUDE.md"
|
|
| "GEMINI.md"
|
|
| ".github/copilot-instructions.md"
|
|
| "README.md";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
async function loadContent(textOrPath: string): Promise<string> {
|
|
if (textOrPath.includes("\n")) return textOrPath;
|
|
try {
|
|
const path = isAbsolute(textOrPath) ? textOrPath : join(__dirname, textOrPath);
|
|
return await Bun.file(path).text();
|
|
} catch {
|
|
return textOrPath;
|
|
}
|
|
}
|
|
|
|
type GuidelineMarkerKind = "default" | "mcp";
|
|
|
|
/**
|
|
* Gets the appropriate markers for a given file type
|
|
*/
|
|
function getMarkers(fileName: string, kind: GuidelineMarkerKind = "default"): { start: string; end: string } {
|
|
const label = kind === "mcp" ? "BACKLOG.MD MCP GUIDELINES" : "BACKLOG.MD GUIDELINES";
|
|
if (fileName === ".cursorrules") {
|
|
// .cursorrules doesn't support HTML comments, use markdown-style comments
|
|
return {
|
|
start: `# === ${label} START ===`,
|
|
end: `# === ${label} END ===`,
|
|
};
|
|
}
|
|
// All markdown files support HTML comments
|
|
return {
|
|
start: `<!-- ${label} START -->`,
|
|
end: `<!-- ${label} END -->`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Checks if the Backlog.md guidelines are already present in the content
|
|
*/
|
|
function hasBacklogGuidelines(content: string, fileName: string): boolean {
|
|
const { start } = getMarkers(fileName);
|
|
return content.includes(start);
|
|
}
|
|
|
|
/**
|
|
* Wraps the Backlog.md guidelines with appropriate markers
|
|
*/
|
|
function wrapWithMarkers(content: string, fileName: string, kind: GuidelineMarkerKind = "default"): string {
|
|
const { start, end } = getMarkers(fileName, kind);
|
|
return `\n${start}\n${content}\n${end}\n`;
|
|
}
|
|
|
|
function stripGuidelineSection(
|
|
content: string,
|
|
fileName: string,
|
|
kind: GuidelineMarkerKind,
|
|
): { content: string; removed: boolean; firstIndex?: number } {
|
|
const { start, end } = getMarkers(fileName, kind);
|
|
let removed = false;
|
|
let result = content;
|
|
let firstIndex: number | undefined;
|
|
|
|
while (true) {
|
|
const startIndex = result.indexOf(start);
|
|
if (startIndex === -1) {
|
|
break;
|
|
}
|
|
|
|
const endIndex = result.indexOf(end, startIndex);
|
|
if (endIndex === -1) {
|
|
break;
|
|
}
|
|
|
|
let removalStart = startIndex;
|
|
while (removalStart > 0 && (result[removalStart - 1] === " " || result[removalStart - 1] === "\t")) {
|
|
removalStart -= 1;
|
|
}
|
|
if (removalStart > 0 && result[removalStart - 1] === "\n") {
|
|
removalStart -= 1;
|
|
if (removalStart > 0 && result[removalStart - 1] === "\r") {
|
|
removalStart -= 1;
|
|
}
|
|
} else if (removalStart > 0 && result[removalStart - 1] === "\r") {
|
|
removalStart -= 1;
|
|
}
|
|
|
|
let removalEnd = endIndex + end.length;
|
|
if (removalEnd < result.length && result[removalEnd] === "\r") {
|
|
removalEnd += 1;
|
|
}
|
|
if (removalEnd < result.length && result[removalEnd] === "\n") {
|
|
removalEnd += 1;
|
|
}
|
|
|
|
if (firstIndex === undefined) {
|
|
firstIndex = removalStart;
|
|
}
|
|
result = result.slice(0, removalStart) + result.slice(removalEnd);
|
|
removed = true;
|
|
}
|
|
|
|
return { content: result, removed, firstIndex };
|
|
}
|
|
|
|
export async function addAgentInstructions(
|
|
projectRoot: string,
|
|
git?: GitOperations,
|
|
files: AgentInstructionFile[] = ["AGENTS.md", "CLAUDE.md", "GEMINI.md", ".github/copilot-instructions.md"],
|
|
autoCommit = false,
|
|
): Promise<void> {
|
|
const mapping: Record<AgentInstructionFile, string> = {
|
|
"AGENTS.md": AGENT_GUIDELINES,
|
|
"CLAUDE.md": CLAUDE_GUIDELINES,
|
|
"GEMINI.md": GEMINI_GUIDELINES,
|
|
".github/copilot-instructions.md": COPILOT_GUIDELINES,
|
|
"README.md": README_GUIDELINES,
|
|
};
|
|
|
|
const paths: string[] = [];
|
|
for (const name of files) {
|
|
const content = await loadContent(mapping[name]);
|
|
const filePath = join(projectRoot, name);
|
|
let finalContent = "";
|
|
|
|
// Check if file exists first to avoid Windows hanging issue
|
|
if (existsSync(filePath)) {
|
|
try {
|
|
// On Windows, use synchronous read to avoid hanging
|
|
let existing: string;
|
|
if (process.platform === "win32") {
|
|
existing = readFileSync(filePath, "utf-8");
|
|
} else {
|
|
existing = await Bun.file(filePath).text();
|
|
}
|
|
|
|
const mcpStripped = stripGuidelineSection(existing, name, "mcp");
|
|
if (mcpStripped.removed) {
|
|
existing = mcpStripped.content;
|
|
}
|
|
|
|
// Check if Backlog.md guidelines are already present
|
|
if (hasBacklogGuidelines(existing, name)) {
|
|
// Guidelines already exist, skip this file
|
|
continue;
|
|
}
|
|
|
|
// Append Backlog.md guidelines with markers
|
|
if (!existing.endsWith("\n")) existing += "\n";
|
|
finalContent = existing + wrapWithMarkers(content, name);
|
|
} catch (error) {
|
|
console.error(`Error reading existing file ${filePath}:`, error);
|
|
// If we can't read it, just use the new content with markers
|
|
finalContent = wrapWithMarkers(content, name);
|
|
}
|
|
} else {
|
|
// File doesn't exist, create with markers
|
|
finalContent = wrapWithMarkers(content, name);
|
|
}
|
|
|
|
await mkdir(dirname(filePath), { recursive: true });
|
|
await Bun.write(filePath, finalContent);
|
|
paths.push(filePath);
|
|
}
|
|
|
|
if (git && paths.length > 0 && autoCommit) {
|
|
await git.addFiles(paths);
|
|
await git.commitChanges("Add AI agent instructions");
|
|
}
|
|
}
|
|
|
|
export { loadContent as _loadAgentGuideline };
|
|
|
|
function _hasMcpGuidelines(content: string, fileName: string): boolean {
|
|
const { start } = getMarkers(fileName, "mcp");
|
|
return content.includes(start);
|
|
}
|
|
|
|
async function readExistingFile(filePath: string): Promise<string> {
|
|
if (process.platform === "win32") {
|
|
return readFileSync(filePath, "utf-8");
|
|
}
|
|
return await Bun.file(filePath).text();
|
|
}
|
|
|
|
export interface EnsureMcpGuidelinesResult {
|
|
changed: boolean;
|
|
created: boolean;
|
|
fileName: AgentInstructionFile;
|
|
filePath: string;
|
|
}
|
|
|
|
export async function ensureMcpGuidelines(
|
|
projectRoot: string,
|
|
fileName: AgentInstructionFile,
|
|
): Promise<EnsureMcpGuidelinesResult> {
|
|
const filePath = join(projectRoot, fileName);
|
|
const fileExists = existsSync(filePath);
|
|
let existing = "";
|
|
let original = "";
|
|
let insertIndex: number | null = null;
|
|
|
|
if (fileExists) {
|
|
try {
|
|
existing = await readExistingFile(filePath);
|
|
original = existing;
|
|
const cliStripped = stripGuidelineSection(existing, fileName, "default");
|
|
if (cliStripped.removed && cliStripped.firstIndex !== undefined) {
|
|
insertIndex = cliStripped.firstIndex;
|
|
}
|
|
existing = cliStripped.content;
|
|
const mcpStripped = stripGuidelineSection(existing, fileName, "mcp");
|
|
if (mcpStripped.removed && mcpStripped.firstIndex !== undefined) {
|
|
insertIndex = mcpStripped.firstIndex;
|
|
}
|
|
existing = mcpStripped.content;
|
|
} catch (error) {
|
|
console.error(`Error reading existing file ${filePath}:`, error);
|
|
existing = "";
|
|
}
|
|
}
|
|
|
|
const nudgeBlock = wrapWithMarkers(MCP_AGENT_NUDGE, fileName, "mcp");
|
|
let nextContent: string;
|
|
if (insertIndex !== null) {
|
|
const normalizedIndex = Math.max(0, Math.min(insertIndex, existing.length));
|
|
nextContent = existing.slice(0, normalizedIndex) + nudgeBlock + existing.slice(normalizedIndex);
|
|
} else {
|
|
nextContent = existing;
|
|
if (nextContent && !nextContent.endsWith("\n")) {
|
|
nextContent += "\n";
|
|
}
|
|
nextContent += nudgeBlock;
|
|
}
|
|
|
|
const finalContent = nextContent;
|
|
const changed = !fileExists || finalContent !== original;
|
|
|
|
await mkdir(dirname(filePath), { recursive: true });
|
|
if (changed) {
|
|
await Bun.write(filePath, finalContent);
|
|
}
|
|
|
|
return { changed, created: !fileExists, fileName, filePath };
|
|
}
|
|
|
|
/**
|
|
* Installs the Claude Code backlog agent to the project's .claude/agents directory
|
|
*/
|
|
export async function installClaudeAgent(projectRoot: string): Promise<void> {
|
|
const agentDir = join(projectRoot, ".claude", "agents");
|
|
const agentPath = join(agentDir, "project-manager-backlog.md");
|
|
|
|
// Create the directory if it doesn't exist
|
|
await mkdir(agentDir, { recursive: true });
|
|
|
|
// Write the agent content
|
|
await Bun.write(agentPath, CLAUDE_AGENT_CONTENT);
|
|
}
|