autosnippet
Version:
Extract code patterns into a knowledge base for AI coding assistants
359 lines (358 loc) • 14.5 kB
JavaScript
#!/usr/bin/env node
/**
* 将 AutoSnippet 自带的 Agent Skills 安装到「当前项目根」的 Cursor 环境(项目根/.cursor/skills/)。
* 项目根:从当前工作目录向上查找含 AutoSnippet.boxspec.json 的目录的父级;未找到则用当前目录。
*
* V3 策略:静态索引 + MCP 按需检索
* - project-recipes-context.md:轻量索引(title | trigger | category | summary),不再塞全文
* - Agent 需要详情时调用 MCP: autosnippet_knowledge({ operation: "get" }) / autosnippet_search
* - guard-context.md:同为轻量索引(fallback 用),Guard 主路径走 MCP autosnippet_guard
*
* 运行方式:在项目根目录执行 npm run install:cursor-skill,或 asd install:cursor-skill,或 node scripts/install-cursor-skill.js
*/
import { SKILLS_DIR as _skillsSrc, PACKAGE_ROOT } from '../lib/shared/package-root.js';
const __dirname = import.meta.dirname;
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
import fs from 'node:fs';
import path from 'node:path';
import * as defaults from '../lib/infrastructure/config/Defaults.js';
const autoSnippetRoot = PACKAGE_ROOT;
const skillsSource = _skillsSrc;
let projectRoot = process.cwd();
// 首先在当前工作目录及其父目录中查找 AutoSnippet.boxspec.json(项目标记)
// 如果找到,其所在目录的父级就是项目根
function findProjectRootFromCwd() {
let current = path.resolve(process.cwd());
const maxLevels = 20;
let levels = 0;
while (levels < maxLevels) {
const boxspecPath = path.join(current, 'AutoSnippet', 'AutoSnippet.boxspec.json');
if (fs.existsSync(boxspecPath)) {
return current; // 当前目录就是项目根
}
// 还要检查当前目录本身就是知识库目录的情况(用户直接在 AutoSnippet/ 中运行)
const directBoxspec = path.join(current, 'AutoSnippet.boxspec.json');
if (fs.existsSync(directBoxspec)) {
return path.dirname(current); // 当前是知识库,其父级才是项目根
}
const parentPath = path.dirname(current);
if (parentPath === current) {
break;
}
current = parentPath;
levels++;
}
return null;
}
const found = findProjectRootFromCwd();
if (found) {
projectRoot = found;
}
else {
// 备选方案:使用PathFinder的查找逻辑
try {
const findPath = require(path.join(autoSnippetRoot, 'lib', 'infrastructure/paths/PathFinder.js'));
const fallback = findPath.findProjectRootSync(process.cwd());
if (fallback) {
projectRoot = fallback;
}
}
catch (_err) { }
}
const skillsTarget = path.join(projectRoot, '.cursor', 'skills');
if (!fs.existsSync(skillsSource)) {
console.error('❌ 未找到 skills 目录:', skillsSource);
process.exit(1);
}
const skillDirs = fs
.readdirSync(skillsSource, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
if (skillDirs.length === 0) {
process.exit(0);
}
function getRecipesDir(root) {
try {
// 尝试查找 boxspec.json 来确定 recipes 目录
const specCandidates = [
path.join(root, 'AutoSnippet', 'boxspec.json'),
path.join(root, 'AutoSnippet', 'AutoSnippet.boxspec.json'),
];
for (const specPath of specCandidates) {
if (fs.existsSync(specPath)) {
const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
const dir = spec?.recipes?.dir;
if (dir) {
return path.join(root, dir);
}
}
}
}
catch {
/* fallback */
}
return path.join(root, defaults.RECIPES_DIR);
}
function collectMdFiles(dir, baseDir, list = []) {
if (!fs.existsSync(dir)) {
return list;
}
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory() && !e.name.startsWith('.')) {
collectMdFiles(full, baseDir, list);
continue;
}
if (e.isFile() && e.name.toLowerCase().endsWith('.md')) {
list.push(path.relative(baseDir, full).replace(/\\/g, '/'));
}
}
return list;
}
/** 从 Markdown 的 YAML frontmatter 中提取指定字段(轻量实现,不依赖 YAML 库) */
function extractFrontmatterFields(content) {
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!m) {
return {};
}
const block = m[1];
const extract = (key) => {
const re = new RegExp(`^${key}:s*["']?(.+?)["']?s*$`, 'm');
const match = block.match(re);
return match ? match[1].trim() : null;
};
return {
id: extract('id'),
title: extract('title'),
trigger: extract('trigger'),
category: extract('category'),
language: extract('language'),
kind: extract('kind'),
summary_cn: extract('summary_cn'),
summary_en: extract('summary_en'),
knowledgeType: extract('knowledgeType'),
complexity: extract('complexity'),
};
}
/**
* V2: 生成轻量 Recipe 索引(title | trigger | category | summary)
* Agent 需要详情时调用 MCP: autosnippet_knowledge({ operation: "get" }) / autosnippet_search
*/
function buildProjectRecipesContext(projectRoot) {
const recipesDir = getRecipesDir(projectRoot);
if (!fs.existsSync(recipesDir)) {
return null;
}
const mdFiles = collectMdFiles(recipesDir, recipesDir).sort();
if (mdFiles.length === 0) {
return null;
}
const lines = [
'# Project Recipes Index\n\n',
'Generated by `asd install:cursor-skill`. **轻量索引** — 只含摘要信息。\n',
'Agent 需要 Recipe 全文时请调用 MCP: `autosnippet_knowledge({ operation: "get", id })` / `autosnippet_search(query)`\n\n',
`Total: ${mdFiles.length} recipes\n\n`,
'| # | File | Title | Trigger | Category | Language | Kind | Summary |\n',
'|---|------|-------|---------|----------|----------|------|---------|\n',
];
let idx = 0;
for (const rel of mdFiles) {
const full = path.join(recipesDir, rel);
idx++;
try {
const content = fs.readFileSync(full, 'utf8');
const fm = extractFrontmatterFields(content);
const title = fm.title || '(untitled)';
const trigger = fm.trigger || '';
const cat = fm.category || defaults.inferCategory(rel, content);
const lang = fm.language || '';
const kind = fm.kind || '';
const summary = (fm.summary_cn || fm.summary_en || '').replace(/\|/g, '/');
lines.push(`| ${idx} | ${rel} | ${title} | ${trigger} | ${cat} | ${lang} | ${kind} | ${summary} |\n`);
}
catch (_) {
lines.push(`| ${idx} | ${rel} | *(read error)* | | | | | |\n`);
}
}
// 按 category 统计
const catCounts = {};
for (const rel of mdFiles) {
try {
const content = fs.readFileSync(path.join(recipesDir, rel), 'utf8');
const fm = extractFrontmatterFields(content);
const cat = fm.category || defaults.inferCategory(rel, content);
catCounts[cat] = (catCounts[cat] || 0) + 1;
}
catch (_) { }
}
lines.push('\n## Category Distribution\n\n');
for (const [cat, count] of Object.entries(catCounts).sort((a, b) => b[1] - a[1])) {
lines.push(`- **${cat}**: ${count} recipes\n`);
}
lines.push('\n## Usage Tips\n\n');
lines.push('- 查找 Recipe: `autosnippet_search({ query })` 或 `autosnippet_search({ query, mode: "context" })`\n');
lines.push('- 获取详情: `autosnippet_knowledge({ operation: "get", id })` — 返回完整 Recipe 内容、关系、约束\n');
lines.push('- 按类型浏览: `autosnippet_knowledge({ operation: "list", kind: "rule" })` / `kind: "pattern"` / `kind: "fact"`\n');
lines.push('- Guard 检查: `autosnippet_guard({ code })` / `autosnippet_guard({ files })`\n');
return lines.join('');
}
function buildSpmmapSummary(projectRoot) {
const spmmapPath = path.join(projectRoot, defaults.SPMMAP_PATH);
if (!fs.existsSync(spmmapPath)) {
return null;
}
try {
const data = JSON.parse(fs.readFileSync(spmmapPath, 'utf8'));
const graph = data.graph || {};
const packages = graph.packages || {};
const edges = graph.edges || {};
const lines = [
'# SPM 依赖结构摘要\n',
`Generated by \`asd install:cursor-skill\`. Source: ${defaults.SPMMAP_PATH}\n`,
'\n## Packages\n',
];
for (const [pkg, info] of Object.entries(packages)) {
// @ts-expect-error TS migration: TS2339
const targets = (info.targets || []).join(', ');
lines.push(`- **${pkg}**: ${targets || '(no targets)'}\n`);
}
lines.push('\n## 依赖关系 (from → to)\n');
for (const [from, toList] of Object.entries(edges)) {
if (Array.isArray(toList)) {
lines.push(`- ${from} → ${toList.join(', ')}\n`);
}
}
return lines.join('');
}
catch (_) {
return null;
}
}
for (const name of skillDirs) {
const src = path.join(skillsSource, name);
const dest = path.join(skillsTarget, name);
// 合并模式:只覆盖源文件中存在的文件,保留用户在 skill 目录下自己添加的文件
fs.cpSync(src, dest, { recursive: true, force: true });
if (name === 'autosnippet-recipes') {
// V2: 生成轻量 Recipe 索引(替代 V1 全文拼接 + by-category 切片)
const context = buildProjectRecipesContext(projectRoot);
const refDir = path.join(dest, 'references');
if (!fs.existsSync(refDir)) {
fs.mkdirSync(refDir, { recursive: true });
}
const contextPath = path.join(refDir, 'project-recipes-context.md');
if (context) {
fs.writeFileSync(contextPath, context, 'utf8');
}
else {
if (fs.existsSync(contextPath)) {
fs.unlinkSync(contextPath);
}
}
// 清理 V1 遗留的 by-category 切片(如存在)
const oldCatDir = path.join(refDir, 'by-category');
if (fs.existsSync(oldCatDir)) {
fs.rmSync(oldCatDir, { recursive: true });
}
const oldIndexJson = path.join(refDir, 'index.json');
if (fs.existsSync(oldIndexJson)) {
fs.unlinkSync(oldIndexJson);
}
}
if (name === 'autosnippet-structure') {
// spmmap 摘要注入到 structure skill(替代已删除的 dep-graph skill)
const summary = buildSpmmapSummary(projectRoot);
const refDir = path.join(dest, 'references');
if (!fs.existsSync(refDir)) {
fs.mkdirSync(refDir, { recursive: true });
}
const summaryPath = path.join(refDir, 'spmmap-summary.md');
if (summary) {
fs.writeFileSync(summaryPath, summary, 'utf8');
}
else {
if (fs.existsSync(summaryPath)) {
fs.unlinkSync(summaryPath);
}
}
}
if (name === 'autosnippet-guard') {
// V2: Guard 索引(同 recipes 索引),Agent 主路径走 MCP guard_check
const context = buildProjectRecipesContext(projectRoot);
const refDir = path.join(dest, 'references');
if (!fs.existsSync(refDir)) {
fs.mkdirSync(refDir, { recursive: true });
}
const guardPath = path.join(refDir, 'guard-context.md');
if (context) {
fs.writeFileSync(guardPath, context, 'utf8');
}
else {
if (fs.existsSync(guardPath)) {
fs.unlinkSync(guardPath);
}
}
}
}
// 可选:写入 Cursor 规则(.cursor/rules/*.mdc),使会话中持久遵循 AutoSnippet 约定
const cursorRulesSource = path.join(autoSnippetRoot, 'templates', 'cursor-rules');
const cursorRulesTarget = path.join(projectRoot, '.cursor', 'rules');
if (fs.existsSync(cursorRulesSource)) {
const ruleFiles = fs
.readdirSync(cursorRulesSource, { withFileTypes: true })
.filter((d) => d.isFile() && d.name.toLowerCase().endsWith('.mdc'))
.map((d) => d.name);
if (ruleFiles.length > 0) {
if (!fs.existsSync(cursorRulesTarget)) {
fs.mkdirSync(cursorRulesTarget, { recursive: true });
}
for (const name of ruleFiles) {
const src = path.join(cursorRulesSource, name);
const dest = path.join(cursorRulesTarget, name);
fs.copyFileSync(src, dest);
}
}
}
// 可选:写入 MCP 配置,使 autosnippet_search 等工具可用(连接层封装在此)
const mcpPath = path.join(projectRoot, '.cursor', 'mcp.json');
const mcpServerScript = path.join(autoSnippetRoot, 'bin', 'mcp-server.js');
const addMcp = process.argv.includes('--mcp');
if (addMcp && fs.existsSync(mcpServerScript)) {
let mcp = { mcpServers: {} };
if (fs.existsSync(mcpPath)) {
try {
mcp = JSON.parse(fs.readFileSync(mcpPath, 'utf8'));
if (!mcp.mcpServers) {
mcp.mcpServers = {};
}
}
catch (_) { }
}
// @ts-expect-error TS migration: TS2339
mcp.mcpServers.autosnippet = {
type: 'stdio',
command: 'node',
args: [mcpServerScript],
env: { ASD_UI_URL: process.env.ASD_UI_URL || defaults.DEFAULT_ASD_UI_URL },
};
fs.mkdirSync(path.dirname(mcpPath), { recursive: true });
fs.writeFileSync(mcpPath, JSON.stringify(mcp, null, 2), 'utf8');
}
else if (addMcp) {
// mcp-server.js 不存在,已跳过
}
const runEmbed = process.argv.includes('--embed');
if (runEmbed) {
(async () => {
try {
const IndexingPipeline = require(path.join(autoSnippetRoot, 'lib', 'context', 'IndexingPipeline'));
const _result = await IndexingPipeline.run(projectRoot, { clear: false });
// 语义索引已更新
}
catch (e) {
console.warn('⚠️ 语义索引更新失败:', e.message);
}
})().catch(() => process.exit(1));
}