Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
"supabase": {
"type": "http",
"url": "https://mcp.supabase.com/mcp?features=docs"
},
"linear": {
"type": "http",
"url": "https://mcp.linear.app/mcp"
}
}
}
132 changes: 118 additions & 14 deletions packages/skills-build/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import {
validateSkillExists,
} from "./config.js";
import { parseRuleFile } from "./parser.js";
import type { Metadata, Rule, Section } from "./types.js";
import {
filterRulesForProfile,
listProfiles,
loadProfile,
} from "./profiles.js";
import type { Metadata, Profile, Rule, Section } from "./types.js";
import { validateRuleFile } from "./validate.js";

/**
Expand Down Expand Up @@ -225,8 +230,13 @@ export function generateSectionMap(
/**
* Build AGENTS.md for a specific skill
*/
function buildSkill(paths: SkillPaths): void {
console.log(`[${paths.name}] Building AGENTS.md...`);
function buildSkill(paths: SkillPaths, profile?: Profile): void {
const profileSuffix = profile ? `.${profile.name}` : "";
const outputFile = profile
? paths.agentsOutput.replace(".md", `${profileSuffix}.md`)
: paths.agentsOutput;

console.log(`[${paths.name}] Building AGENTS${profileSuffix}.md...`);

// Load metadata and sections
const metadata = loadMetadata(paths.skillFile, paths.name);
Expand All @@ -237,10 +247,7 @@ function buildSkill(paths: SkillPaths): void {
// Check if references directory exists
if (!existsSync(paths.referencesDir)) {
console.log(` No references directory found. Generating empty AGENTS.md.`);
writeFileSync(
paths.agentsOutput,
`# ${skillTitle}\n\nNo rules defined yet.\n`,
);
writeFileSync(outputFile, `# ${skillTitle}\n\nNo rules defined yet.\n`);
return;
}

Expand Down Expand Up @@ -272,10 +279,19 @@ function buildSkill(paths: SkillPaths): void {
}
}

// Filter rules by profile if specified
let filteredRules = rules;
if (profile) {
filteredRules = filterRulesForProfile(rules, profile);
console.log(
` Filtered to ${filteredRules.length} rules for profile "${profile.name}"`,
);
}

// Group rules by section and assign IDs
const rulesBySection = new Map<number, Rule[]>();

for (const rule of rules) {
for (const rule of filteredRules) {
const sectionRules = rulesBySection.get(rule.section) || [];
sectionRules.push(rule);
rulesBySection.set(rule.section, sectionRules);
Expand Down Expand Up @@ -350,6 +366,20 @@ function buildSkill(paths: SkillPaths): void {
output.push(`**Impact: ${rule.impact}**\n`);
}

// Add prerequisites if minVersion or extensions are specified
const prerequisites: string[] = [];
if (rule.minVersion) {
prerequisites.push(`PostgreSQL ${rule.minVersion}+`);
}
if (rule.extensions && rule.extensions.length > 0) {
prerequisites.push(
`Extension${rule.extensions.length > 1 ? "s" : ""}: ${rule.extensions.join(", ")}`,
);
}
if (prerequisites.length > 0) {
output.push(`**Prerequisites:** ${prerequisites.join(" | ")}\n`);
}

output.push(`${rule.explanation}\n`);

for (const example of rule.examples) {
Expand Down Expand Up @@ -394,9 +424,56 @@ function buildSkill(paths: SkillPaths): void {
}

// Write output
writeFileSync(paths.agentsOutput, output.join("\n"));
console.log(` Generated: ${paths.agentsOutput}`);
console.log(` Total rules: ${rules.length}`);
writeFileSync(outputFile, output.join("\n"));
console.log(` Generated: ${outputFile}`);
console.log(` Total rules: ${filteredRules.length}`);
}

/**
* Parse CLI arguments
*/
function parseArgs(): {
skill?: string;
profile?: string;
allProfiles: boolean;
} {
const args = process.argv.slice(2);
let skill: string | undefined;
let profile: string | undefined;
let allProfiles = false;

for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--profile" && args[i + 1]) {
profile = args[i + 1];
i++;
} else if (arg === "--all-profiles") {
allProfiles = true;
} else if (!arg.startsWith("--")) {
skill = arg;
}
}

return { skill, profile, allProfiles };
}

/**
* Build a skill with all available profiles
*/
function buildSkillWithAllProfiles(paths: SkillPaths): void {
const profilesDir = join(paths.skillDir, "profiles");
const profiles = listProfiles(profilesDir);

// Build default (no profile)
buildSkill(paths);

// Build each profile variant
for (const profileName of profiles) {
const profile = loadProfile(profilesDir, profileName);
if (profile) {
buildSkill(paths, profile);
}
}
}

// Run build when executed directly
Expand All @@ -405,7 +482,7 @@ const isMainModule =
process.argv[1]?.endsWith("build.js");

if (isMainModule) {
const targetSkill = process.argv[2];
const { skill: targetSkill, profile: profileName, allProfiles } = parseArgs();

if (targetSkill) {
// Build specific skill
Expand All @@ -417,7 +494,29 @@ if (isMainModule) {
}
process.exit(1);
}
buildSkill(getSkillPaths(targetSkill));

const paths = getSkillPaths(targetSkill);

if (allProfiles) {
// Build all profile variants
buildSkillWithAllProfiles(paths);
} else if (profileName) {
// Build with specific profile
const profilesDir = join(paths.skillDir, "profiles");
const profile = loadProfile(profilesDir, profileName);
if (!profile) {
console.error(`Error: Profile "${profileName}" not found`);
const available = listProfiles(profilesDir);
if (available.length > 0) {
console.error(`Available profiles: ${available.join(", ")}`);
}
process.exit(1);
}
buildSkill(paths, profile);
} else {
// Build default
buildSkill(paths);
}
} else {
// Build all skills
const skills = discoverSkills();
Expand All @@ -428,7 +527,12 @@ if (isMainModule) {

console.log(`Found ${skills.length} skill(s): ${skills.join(", ")}\n`);
for (const skill of skills) {
buildSkill(getSkillPaths(skill));
const paths = getSkillPaths(skill);
if (allProfiles) {
buildSkillWithAllProfiles(paths);
} else {
buildSkill(paths);
}
console.log("");
}
}
Expand Down
4 changes: 4 additions & 0 deletions packages/skills-build/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ export function parseRuleFile(
const examples = extractExamples(body);

const tags = frontmatter.tags?.split(",").map((t) => t.trim()) || [];
const extensions =
frontmatter.extensions?.split(",").map((e) => e.trim()) || [];

// Validation warnings
if (!explanation || explanation.length < 20) {
Expand All @@ -271,6 +273,8 @@ export function parseRuleFile(
examples,
references: extractReferences(body),
tags: tags.length > 0 ? tags : undefined,
minVersion: frontmatter.minVersion || undefined,
extensions: extensions.length > 0 ? extensions : undefined,
};

return { success: true, rule, errors, warnings };
Expand Down
111 changes: 111 additions & 0 deletions packages/skills-build/src/profiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { Profile, Rule } from "./types.js";

/**
* Load a profile from the profiles directory
*/
export function loadProfile(
profilesDir: string,
profileName: string,
): Profile | null {
const profileFile = join(profilesDir, `${profileName}.json`);
if (!existsSync(profileFile)) {
return null;
}

try {
return JSON.parse(readFileSync(profileFile, "utf-8"));
} catch (error) {
console.error(`Error loading profile ${profileName}:`, error);
return null;
}
}

/**
* List all available profiles in the profiles directory
*/
export function listProfiles(profilesDir: string): string[] {
if (!existsSync(profilesDir)) {
return [];
}

return readdirSync(profilesDir)
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(".json", ""));
}

/**
* Compare version strings (e.g., "9.5", "11", "14.2")
* Returns: negative if a < b, 0 if equal, positive if a > b
*/
function compareVersions(a: string, b: string): number {
const partsA = a.split(".").map(Number);
const partsB = b.split(".").map(Number);

const maxLen = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLen; i++) {
const numA = partsA[i] || 0;
const numB = partsB[i] || 0;
if (numA !== numB) {
return numA - numB;
}
}
return 0;
}

/**
* Check if a rule is compatible with a profile
*/
export function isRuleCompatibleWithProfile(
rule: Rule,
profile: Profile,
): boolean {
// Check version requirement
if (rule.minVersion) {
if (compareVersions(rule.minVersion, profile.minVersion) > 0) {
// Rule requires a higher version than profile supports
return false;
}
if (
profile.maxVersion &&
compareVersions(rule.minVersion, profile.maxVersion) > 0
) {
// Rule requires a version higher than profile's max
return false;
}
}

// Check extension requirements
if (rule.extensions && rule.extensions.length > 0) {
const allExtensions = [
...(profile.extensions.available || []),
...(profile.extensions.installable || []),
];

for (const ext of rule.extensions) {
if (profile.extensions.unavailable?.includes(ext)) {
// Extension is explicitly unavailable in this profile
return false;
}
if (!allExtensions.includes(ext)) {
// Extension is not available or installable
return false;
}
}
}

// Check if rule is explicitly excluded
if (profile.excludeRules?.includes(rule.id)) {
return false;
}

return true;
}

/**
* Filter rules based on profile constraints
*/
export function filterRulesForProfile(rules: Rule[], profile: Profile): Rule[] {
return rules.filter((rule) => isRuleCompatibleWithProfile(rule, profile));
}
15 changes: 15 additions & 0 deletions packages/skills-build/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export interface Rule {
references?: string[];
tags?: string[];
supabaseNotes?: string;
minVersion?: string; // Minimum PostgreSQL version required (e.g., "11", "14")
extensions?: string[]; // Required PostgreSQL extensions (e.g., ["pg_stat_statements"])
}

export interface Section {
Expand Down Expand Up @@ -57,3 +59,16 @@ export interface ValidationResult {
errors: string[];
warnings: string[];
}

export interface Profile {
name: string;
minVersion: string;
maxVersion?: string;
extensions: {
available: string[];
installable?: string[];
unavailable: string[];
};
excludeRules?: string[];
notes?: string;
}
Loading