/** * codet-pi-init 核心逻辑 * * 纯函数 + 可适配 Context,可同时被两种宿主复用: * - Pi Extension(/init 命令,注册到 Pi 上) * - CLI(npx codet-pi-init,无 Pi 也可用) * * 设计原则(对齐 CodeT 实践方案): * - 一切皆文本:AGENTS.md / agent.md 等文本是唯一持久产物 * - 显式失败:已存在不自动覆盖,错误信息直接输出 * - 不接管 LLM:生成基于扫描结果 + 确定性模板,LLM 增强由宿主代理完成 */ import { execSync } from "node:child_process"; import { readFileSync, writeFileSync, existsSync, mkdirSync, } from "node:fs"; import { join, basename, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); // ============ 类型定义 ============ export interface ProjectInfo { name: string; root: string; structure: string; configs: Record; readme?: string; gitignore?: string[]; packageManager: string; language: string; framework: string; scripts: Record; } export interface InitOptions { force?: boolean; /** 输出文件名列表(默认: ["AGENTS.md"]) */ files?: string[]; dryRun?: boolean; depth?: number; /** 仅用确定性模板生成(不触发模型);为 true 时走模板填充路径 */ template?: boolean; /** 自定义模板文件路径(配合 template 使用) */ templatePath?: string; } /** 宿主通知级别,与 Pi 的 ctx.ui.notify 对齐 */ export type NotifyLevel = "info" | "warning" | "error"; /** 宿主上下文适配接口:Pi / CLI 各自实现 */ export interface HostContext { notify(message: string, level?: NotifyLevel): void; } export interface InitResult { ok: boolean; files: string[]; /** 因已存在而跳过、未写入的文件 */ skipped: string[]; } export type PackageManager = | "npm" | "yarn" | "pnpm" | "bun" | "pip" | "poetry" | "cargo" | "go" | "maven" | "gradle" | "unknown"; // ============ 工具函数 ============ export function detectPackageManager(root: string): PackageManager { if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm"; if (existsSync(join(root, "yarn.lock"))) return "yarn"; if (existsSync(join(root, "bun.lockb"))) return "bun"; if (existsSync(join(root, "package-lock.json"))) return "npm"; if (existsSync(join(root, "uv.lock")) || existsSync(join(root, "poetry.lock"))) return "poetry"; if (existsSync(join(root, "requirements.txt")) || existsSync(join(root, "pyproject.toml"))) return "pip"; if (existsSync(join(root, "Cargo.toml"))) return "cargo"; if (existsSync(join(root, "go.mod"))) return "go"; if (existsSync(join(root, "pom.xml"))) return "maven"; if (existsSync(join(root, "build.gradle")) || existsSync(join(root, "build.gradle.kts"))) return "gradle"; if (existsSync(join(root, "package.json"))) return "npm"; return "unknown"; } export function detectLanguage(root: string): string { if (existsSync(join(root, "package.json"))) return "JavaScript/TypeScript"; if (existsSync(join(root, "pyproject.toml")) || existsSync(join(root, "requirements.txt"))) return "Python"; if (existsSync(join(root, "Cargo.toml"))) return "Rust"; if (existsSync(join(root, "go.mod"))) return "Go"; if (existsSync(join(root, "pom.xml")) || existsSync(join(root, "build.gradle"))) return "Java/Kotlin"; if (existsSync(join(root, "composer.json"))) return "PHP"; if (existsSync(join(root, "Gemfile"))) return "Ruby"; return "Unknown"; } export function detectFramework(root: string): string { try { const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8")); const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }; const has = (n: string) => !!deps[n]; if (has("next")) return "Next.js"; if (has("nuxt")) return "Nuxt"; if (has("vue")) return "Vue.js"; if (has("@angular/core")) return "Angular"; if (has("svelte")) return "Svelte"; if (has("react")) return "React"; if (has("express")) return "Express"; if (has("fastify")) return "Fastify"; if (has("koa")) return "Koa"; if (has("vite")) return "Vite"; if (has("vitest")) return "Vitest"; if (has("jest")) return "Jest"; if (has("eslint")) return "ESLint"; if (has("electron")) return "Electron"; } catch {} if (existsSync(join(root, "pyproject.toml"))) { const content = readFileSync(join(root, "pyproject.toml"), "utf-8"); if (content.includes("django")) return "Django"; if (content.includes("fastapi")) return "FastAPI"; if (content.includes("flask")) return "Flask"; } if (existsSync(join(root, "go.mod"))) { const content = readFileSync(join(root, "go.mod"), "utf-8"); if (content.includes("gin-gonic")) return "Gin"; if (content.includes("gorilla/mux")) return "Gorilla Mux"; } if (existsSync(join(root, "pom.xml"))) { const content = readFileSync(join(root, "pom.xml"), "utf-8"); if (content.includes("spring-boot")) return "Spring Boot"; if (content.includes("mybatis")) return "MyBatis"; } return "Unknown"; } const SCAN_EXCLUDES = [ "node_modules", ".git", "dist", "build", ".next", ".nuxt", ".output", "__pycache__", ".venv", "venv", ".pytest_cache", ".mypy_cache", "target", "vendor", ".idea", ".vscode", "coverage", ".cache", ]; export function getProjectStructure(root: string, maxDepth = 3): string { try { const prunes = SCAN_EXCLUDES.map((d) => `-name ${d}`).join(" -o "); const cmd = `find . -maxdepth ${maxDepth} \\( ${prunes} \\) -prune -o -print 2>/dev/null | head -200`; return execSync(cmd, { cwd: root, encoding: "utf-8", timeout: 10000 }).trim(); } catch { return "(无法获取目录结构)"; } } const CONFIG_FILES = [ "package.json", "pyproject.toml", "go.mod", "Cargo.toml", "pom.xml", "build.gradle", "build.gradle.kts", "composer.json", "Gemfile", "tsconfig.json", ".eslintrc.json", ".prettierrc", ".prettierrc.json", "docker-compose.yml", "Dockerfile", ".env.example", "Makefile", "pnpm-workspace.yaml", ]; export function readConfigFiles(root: string, maxLen = 3000): Record { const configs: Record = {}; for (const f of CONFIG_FILES) { const p = join(root, f); if (existsSync(p)) { try { const content = readFileSync(p, "utf-8"); configs[f] = content.slice(0, maxLen); } catch { /* 不可读则跳过 */ } } } return configs; } export function readGitignore(root: string): string[] { const p = join(root, ".gitignore"); if (!existsSync(p)) return []; try { return readFileSync(p, "utf-8") .split("\n") .map((l) => l.trim()) .filter((l) => l && !l.startsWith("#")); } catch { return []; } } export function extractScripts(root: string): Record { try { const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8")); return pkg.scripts || {}; } catch { return {}; } } export function collectProjectInfo(root: string, depth = 3): ProjectInfo { const info: ProjectInfo = { name: basename(root), root, structure: getProjectStructure(root, depth), configs: readConfigFiles(root), gitignore: readGitignore(root), packageManager: detectPackageManager(root), language: detectLanguage(root), framework: detectFramework(root), scripts: extractScripts(root), }; for (const readme of ["README.md", "README.rst", "README.txt", "README.org"]) { const p = join(root, readme); if (existsSync(p)) { try { info.readme = readFileSync(p, "utf-8").slice(0, 5000); break; } catch { /* ignore */ } } } return info; } // ============ Prompt 构造 ============ export function formatScripts(scripts: Record): string { const entries = Object.entries(scripts); if (entries.length === 0) return " (无 scripts 信息)"; return entries.map(([k, v]) => ` - ${k}: ${v}`).join("\n"); } export function buildPrompt(info: ProjectInfo): string { const scriptsStr = formatScripts(info.scripts); return `你是一个资深软件架构师。请根据以下项目信息,生成一份标准的 AGENTS.md 文件。 这份文件将作为 AI 编程助手和人类开发者的共同开发规范。 ## 项目信息 **项目名称**: ${info.name} **主要语言**: ${info.language} **框架**: ${info.framework} **包管理器**: ${info.packageManager} ## 可用脚本 (package.json scripts) ${scriptsStr} ## 目录结构 \`\`\` ${info.structure} \`\`\` ## 配置文件内容 \`\`\` ${Object.entries(info.configs) .map(([f, c]) => `--- ${f} ---\n${c}`) .join("\n\n")} \`\`\` ## .gitignore 规则 \`\`\` ${info.gitignore?.join("\n") || "(无 .gitignore)"} \`\`\` ${info.readme ? `## 现有 README 摘要\n\n${info.readme}\n` : ""} --- ## 输出要求 请生成一份完整的 AGENTS.md,包含以下章节: ### 1. 项目概述 - 用 2-3 句话说明项目用途 - 列出核心技术栈和关键依赖 ### 2. 快速命令 - 安装依赖命令 - 本地启动命令 - 构建命令 - 测试命令 - Lint/格式化命令 - (从 scripts 中提取,没有就留空待补) ### 3. 目录结构说明 - 解释每个核心目录的职责 - 标注重要的入口文件 ### 4. 代码风格与规范 - 根据技术栈给出具体规范 - 命名约定 - 文件组织方式 ### 5. 禁止规则 (NEVER Rules) - 最重要 - 禁止修改 node_modules/、dist/、build/ 等构建产物 - 禁止随意修改 lock 文件(除非依赖变更) - 禁止硬编码敏感信息 - 禁止跳过测试直接提交 - 其他从 .gitignore 和项目特征推断的禁忌 ### 6. Git 工作流 - 分支策略 - 提交信息规范(推荐 Conventional Commits) - PR 规范 ### 7. 常见任务指南 - 如何添加新依赖 - 如何运行单个测试 - 如何调试 ## 格式要求 - 输出纯 Markdown,不要包含解释性文字 - 使用清晰的标题层级 - 代码块要标注语言 - 禁止规则用 🚫 前缀 - 警告用 ⚠️ 前缀 - 建议用 ✅ 前缀 直接输出 AGENTS.md 的完整内容:`; } // ============ 确定性模板生成(无 LLM 兜底) ============ const INSTALL_CMD: Record = { npm: "npm install", yarn: "yarn", pnpm: "pnpm install", bun: "bun install", pip: "pip install -r requirements.txt", poetry: "poetry install", cargo: "cargo build", go: "go mod tidy", maven: "mvn install", gradle: "gradle build", unknown: "请查看 README 或项目文档", }; export function inferNeverRules(info: ProjectInfo): string[] { const rules: string[] = []; const dirs = ["node_modules", "dist", "build", ".next", "target", "vendor"]; for (const d of dirs) { if (info.structure.includes(d) || existsSync(join(info.root, d))) { rules.push(`禁止手动修改 \`${d}/\` 构建产物目录`); } } if (info.gitignore?.some((l) => l.includes(".env") || l.includes("lock"))) { rules.push("禁止提交 .env / 锁文件(除非依赖变更)"); } if (info.scripts.test) { rules.push("禁止跳过测试直接提交代码"); } if (info.language === "JavaScript/TypeScript") { rules.push("禁止在代码中硬编码 API Key、密码等敏感信息"); } if (rules.length === 0) { rules.push("禁止在代码中硬编码敏感信息"); rules.push("禁止随意修改锁文件(除非依赖变更)"); } return rules; } export function generateAgentsMd(info: ProjectInfo): string { const lines: string[] = []; lines.push("# AGENTS.md"); lines.push(""); lines.push("> AI 编程助手和人类开发者的共同开发规范"); lines.push("> 本文件由 `codet-pi-init` 自动生成,请结合实际项目修订。"); lines.push(""); lines.push("## 1. 项目概述"); lines.push(""); const techParts = [info.language, info.framework].filter( (v) => v && v !== "Unknown" ); const techSummary = techParts.length > 0 ? `这是一个基于 **${techParts.join(" / ")}** 的项目。` : "项目技术栈待补充(未检测到明确的语言/框架)。"; lines.push(techSummary); lines.push(`- 项目名称: \`${info.name}\``); lines.push(`- 主要语言: ${info.language}`); lines.push(`- 框架: ${info.framework}`); lines.push(`- 包管理器: ${info.packageManager}`); lines.push(""); lines.push("## 2. 快速命令"); lines.push(""); lines.push("| 命令 | 用途 |"); lines.push("|------|------|"); lines.push(`| \`${INSTALL_CMD[info.packageManager] || INSTALL_CMD.unknown}\` | 安装依赖 |`); const scriptRows: Record = { dev: "本地启动", start: "启动", build: "构建", test: "测试", lint: "Lint 检查", format: "格式化", typecheck: "类型检查", }; for (const [key, label] of Object.entries(scriptRows)) { const val = info.scripts[key]; if (val) { lines.push(`| \`npm run ${key}\` | ${label}: \`${val}\` |`); } } if (Object.keys(info.scripts).length === 0) { lines.push("| (暂无 scripts,请补充) | - |"); } lines.push(""); lines.push("## 3. 目录结构说明"); lines.push(""); lines.push("```"); lines.push(info.structure || "(无法获取)"); lines.push("```"); lines.push(""); lines.push("> 说明:每个核心目录的职责待结合项目补充。"); lines.push(""); lines.push("## 4. 代码风格与规范"); lines.push(""); lines.push("- ✅ 遵循项目现有代码风格,不引入无关风格"); lines.push("- ✅ 命名约定与文件组织遵循主流惯例(结合技术栈)"); lines.push("- ⚠️ 提交前运行 Lint / 格式化命令(如存在)"); lines.push(""); lines.push("## 5. 禁止规则 (NEVER Rules)"); lines.push(""); for (const rule of inferNeverRules(info)) { lines.push(`- 🚫 ${rule}`); } lines.push(""); lines.push("## 6. Git 工作流"); lines.push(""); lines.push("- 提交规范:Conventional Commits"); lines.push(" - `feat:` 新功能 / `fix:` 修复 / `docs:` 文档 / `refactor:` 重构 / `test:` 测试 / `chore:` 杂项"); lines.push("- 分支策略:从 `main`(或默认分支)拉取 `feature/xxx` 开发"); lines.push("- PR 要求:通过所有测试 + Lint 检查"); lines.push(""); lines.push("## 7. 常见任务指南"); lines.push(""); lines.push("### 添加新依赖"); lines.push(`\`\`\`bash`); lines.push(`npm install `); lines.push(`\`\`\``); lines.push(""); lines.push("### 运行单个测试"); lines.push(`\`\`\`bash`); lines.push(`npm run test -- --run `); lines.push(`\`\`\``); lines.push(""); lines.push("### 调试"); lines.push(`\`\`\`bash`); lines.push(`npm run dev`); lines.push(`\`\`\``); lines.push(""); lines.push("---"); lines.push(""); lines.push(`_Generated by codet-pi-init @ ${new Date().toISOString().slice(0, 10)}_`); return lines.join("\n"); } // ============ 主流程 ============ /** 默认内置模板路径(相对于包根) */ export const DEFAULT_TEMPLATE_PATH = "templates/agents-md.tpl"; /** 模板占位符 → 文本 */ export function templatePlaceholders(info: ProjectInfo): Record { const scriptsRows = Object.entries(info.scripts) .map(([k, v]) => `| \`npm run ${k}\` | ${v} |`) .join("\n"); const neverRules = inferNeverRules(info) .map((r) => `- 🚫 ${r}`) .join("\n"); const configsBlock = Object.entries(info.configs) .map(([f, c]) => `--- ${f} ---\n${c}`) .join("\n\n"); const techParts = [info.language, info.framework].filter((v) => v && v !== "Unknown"); const techSummary = techParts.length > 0 ? `这是一个基于 **${techParts.join(" / ")}** 的项目。` : "项目技术栈待补充(未检测到明确的语言/框架)。"; return { PROJECT_NAME: info.name, LANGUAGE: info.language, FRAMEWORK: info.framework, PACKAGE_MANAGER: info.packageManager, INSTALL_CMD: INSTALL_CMD[info.packageManager] || INSTALL_CMD.unknown, TECH_SUMMARY: techSummary, STRUCTURE: info.structure || "(无法获取)", SCRIPTS_TABLE: scriptsRows || "| (暂无 scripts,请补充) | - |", CONFIGS: configsBlock || "(无配置文件)", GITIGNORE: (info.gitignore || []).join("\n") || "(无 .gitignore)", NEVER_RULES: neverRules, DATE: new Date().toISOString().slice(0, 10), }; } /** 把 {{KEY}} 占位符替换为数据 */ export function renderTemplate(template: string, info: ProjectInfo): string { const ph = templatePlaceholders(info); return template.replace(/\{\{(\w+)\}\}/g, (_m, key: string) => ph[key] ?? `{{${key}}}`); } /** 读模板文件并渲染;templatePath 缺省时用内置默认模板 */ export function generateFromTemplate(info: ProjectInfo, templatePath?: string): string { let tplPath: string; if (templatePath) { tplPath = templatePath; } else { // 在源码与构建后两种运行环境下都找得到:src/ 上溯1级,dist/src 上溯2级 const cands = [ join(__dirname, "..", DEFAULT_TEMPLATE_PATH), // 源码:src/ -> 包根/templates join(__dirname, "..", "..", DEFAULT_TEMPLATE_PATH), // 构建:dist/src/ -> 包根/templates join(process.cwd(), DEFAULT_TEMPLATE_PATH), // 当前工作目录 ]; tplPath = cands.find((p) => existsSync(p)) || cands[0]; } if (!existsSync(tplPath)) { throw new Error(`找不到模板文件: ${tplPath}`); } const tpl = readFileSync(tplPath, "utf-8"); return renderTemplate(tpl, info); } /** 发给宿主模型的"完善指令"(通过 pi.sendUserMessage 触发一次模型回合) */ export function buildRefineMessage(info: ProjectInfo, files: string[]): string { const fileList = files.map((f) => `\`${f}\``).join("、"); return `项目根目录已由 codet-pi-init 扫描并生成了初始规范文件(${fileList}), 内容为通用模板,含占位信息。 请使用 Read / Write / Edit 工具,结合项目实际情况,审阅并完善这两个文件,确保覆盖: 1. 项目概述:准确的技术栈、用途、关键依赖 2. 快速命令:真实的安装/启动/构建/测试/Lint 命令(优先取自 package.json scripts、Makefile、pyproject.toml 等项目配置) 3. 目录结构说明:基于实际目录,标注入口文件 4. 代码风格与规范:贴合技术栈的命名与文件组织约定 5. 禁止规则(NEVER Rules):禁止修改构建产物目录、锁文件、硬编码密钥、跳过测试等 6. Git 工作流:分支策略与 Conventional Commits 约定 7. 常见任务指南:加依赖、跑单个测试、调试等 项目扫描信息摘要: - 项目名称: ${info.name} - 主要语言: ${info.language} - 框架: ${info.framework} - 包管理器: ${info.packageManager} - 可用 scripts: ${Object.entries(info.scripts).map(([k, v]) => `${k}=${v}`).join(", ") || "(无)"} 完成后简要汇报你修改了哪些内容。`; } export function cleanLlmOutput(content: string): string { let c = content.trim(); if (c.startsWith("```markdown")) { c = c.replace(/^```markdown\n/, "").replace(/\n```$/, ""); } else if (c.startsWith("```")) { c = c.replace(/^```\w*\n/, "").replace(/\n```$/, ""); } return c.trim(); } export function buildOutputPath(root: string, output: string): string { const p = join(root, output); const dir = p.slice(0, p.lastIndexOf("/")); if (dir && !existsSync(dir)) { mkdirSync(dir, { recursive: true }); } return p; } /** 默认生成的规范文件列表 */ export const DEFAULT_FILES = ["AGENTS.md"]; /** * 异步主流程:扫描项目,向每个目标文件写入规范。 * 不调用 LLM —— LLM 增强由宿主(Pi 的 agent 会话 / 用户自选)完成。 */ export async function runInitAsync( root: string, options: InitOptions, host: HostContext ): Promise { const files = (options.files && options.files.length > 0 ? options.files : DEFAULT_FILES); const skipped: string[] = []; host.notify("🔍 正在扫描项目...", "info"); const info = collectProjectInfo(root, options.depth ?? 3); host.notify( ` 语言: ${info.language} | 框架: ${info.framework} | 包管理: ${info.packageManager}`, "info" ); // dry-run:只输出 LLM 提示词,不写文件 if (options.dryRun) { host.notify("--- DRY RUN: LLM 提示词(可让 Agent 据此生成规范)---", "info"); console.log(buildPrompt(info)); return { ok: true, files, skipped }; } const content = options.template ? generateFromTemplate(info, options.templatePath) : generateAgentsMd(info); let wroteAny = false; for (const outputFile of files) { const outPath = buildOutputPath(root, outputFile); // 已存在且未强制覆盖 → 显式失败(不自动合并) if (existsSync(outPath) && !options.force) { host.notify(`⏭️ ${outputFile} 已存在,跳过。使用 --force 覆盖。`, "warning"); skipped.push(outputFile); continue; } writeFileSync(outPath, content); host.notify(`✅ 已生成 ${outputFile}`, "info"); wroteAny = true; } if (!wroteAny) { host.notify("❌ 未写入任何文件(目标文件均已存在,且未使用 --force)", "error"); return { ok: false, files, skipped }; } host.notify(`📝 文件大小: ${content.length} 字符`, "info"); const preview = content.split("\n").slice(0, 15).join("\n"); host.notify("\n--- 预览 (前15行) ---", "info"); console.log(preview); host.notify("--- 预览结束 ---", "info"); return { ok: true, files, skipped }; }