paqad-ai
Version:
Spec-driven development framework — AI agents that think before they type
188 lines (169 loc) • 6.29 kB
JavaScript
import { appendFileSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
import fg from 'fast-glob';
import YAML from 'yaml';
const input = process.argv[2];
const cwd = process.argv[3] ?? process.cwd();
if (!input) {
process.stderr.write('skill-index-gen: missing output path\n');
process.exit(2);
}
const profilePath = join(cwd, '.paqad/project-profile.yaml');
const profile = YAML.parse(readFileSync(profilePath, 'utf8'));
const files = (
await fg(
['.claude/skills/**/SKILL.md', '.codex/skills/**/SKILL.md', '.gemini/skills/**/SKILL.md'],
{
cwd,
absolute: true,
},
)
).sort();
const STRING_KEYS = ['model_tier', 'cacheable', 'output_format', 'max_lines', 'max_output_tokens'];
const META_FILE_KEYS = [
'triggers',
'input_schema',
'cache_key_inputs',
'request_routing',
'on_complete',
'tools',
];
function pick(source, keys) {
return Object.fromEntries(
keys.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]),
);
}
const REMOVED_IN = '1.117.0';
const EVENTS_LOG = join(cwd, '.paqad/skills/events.jsonl');
class SkillMetaError extends Error {}
function sameValue(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}
function readSidecar(skill, folder, ref) {
if (typeof ref !== 'string' || ref.trim() === '') {
throw new SkillMetaError(
`Skill "${skill}": metadata.paqad-meta must name a file in the skill folder.`,
);
}
if (isAbsolute(ref) || ref.startsWith('/') || ref.split(/[\\/]/).includes('..')) {
throw new SkillMetaError(
`Skill "${skill}": metadata.paqad-meta "${ref}" must be a path inside the skill folder (no absolute path, no "..").`,
);
}
let text;
try {
const real = realpathSync(join(folder, ref));
if (!real.startsWith(`${realpathSync(folder)}${sep}`)) {
throw new SkillMetaError(
`Skill "${skill}": ${ref} leads through a symlink outside the skill folder.`,
);
}
text = readFileSync(real, 'utf8');
} catch (error) {
if (error instanceof SkillMetaError) throw error;
throw new SkillMetaError(`Skill "${skill}": ${ref} named by metadata.paqad-meta is missing.`);
}
let parsed;
try {
parsed = YAML.parse(text);
} catch (error) {
throw new SkillMetaError(`Skill "${skill}": ${ref} is not valid YAML: ${error.message}`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new SkillMetaError(`Skill "${skill}": ${ref} must be a YAML object.`);
}
return parsed;
}
function deprecationLine(skill, oldKeys, disagreeing) {
if (oldKeys.length === 0) return null;
const one = oldKeys.length === 1;
const line =
`Skill "${skill}": top-level ${[...oldKeys].sort().join(', ')} ${one ? 'is' : 'are'} deprecated and ` +
`stop${one ? 's' : ''} working in ${REMOVED_IN}; move ${one ? 'it' : 'them'} under metadata (or skill.meta.yaml).`;
if (disagreeing.length === 0) return line;
return `${line} ${[...disagreeing].sort().join(', ')} ${disagreeing.length === 1 ? 'disagrees' : 'disagree'} with metadata; the metadata value wins.`;
}
function loggedDeprecations() {
let text;
try {
text = readFileSync(EVENTS_LOG, 'utf8');
} catch (error) {
if (error?.code === 'ENOENT') return new Set();
throw error;
}
const seen = new Set();
for (const row of text.split('\n')) {
try {
const event = JSON.parse(row);
if (event.type === 'skill.metadata_deprecated') seen.add(`${event.path}\n${event.message}`);
} catch {}
}
return seen;
}
const seenDeprecations = loggedDeprecations();
function reportDeprecation(file, skill, message) {
if (seenDeprecations.has(`${file}\n${message}`)) return;
seenDeprecations.add(`${file}\n${message}`);
try {
mkdirSync(dirname(EVENTS_LOG), { recursive: true });
const event = {
ts: new Date().toISOString(),
type: 'skill.metadata_deprecated',
path: file,
skill,
message,
};
appendFileSync(EVENTS_LOG, `${JSON.stringify(event)}\n`, 'utf8');
} catch {}
process.stderr.write(`paqad: ${message}\n`);
}
function resolveMetadata(raw, folder, file) {
const skill =
typeof raw.name === 'string' && raw.name.trim() !== '' ? raw.name.trim() : basename(folder);
const metadata =
raw.metadata && typeof raw.metadata === 'object' && !Array.isArray(raw.metadata)
? raw.metadata
: {};
const sidecar =
metadata['paqad-meta'] === undefined ? {} : readSidecar(skill, folder, metadata['paqad-meta']);
const fresh = { ...pick(metadata, STRING_KEYS), ...pick(sidecar, META_FILE_KEYS) };
const oldKeys = [...STRING_KEYS, ...META_FILE_KEYS].filter((key) => raw[key] !== undefined);
const disagreeing = oldKeys.filter((key) => key in fresh && !sameValue(raw[key], fresh[key]));
const message = deprecationLine(skill, oldKeys, disagreeing);
if (message !== null) reportDeprecation(file, skill, message);
return { ...raw, ...fresh };
}
const index = files.flatMap((file) => {
const content = readFileSync(file, 'utf8');
const [, frontmatterBlock = ''] = content.split(/^---$/m);
let frontmatter;
try {
frontmatter = resolveMetadata(YAML.parse(frontmatterBlock) ?? {}, dirname(file), file);
} catch (error) {
if (!(error instanceof SkillMetaError)) throw error;
process.stderr.write(
`skill-index-gen: ${error.message} Left out of the index: ${relative(cwd, file)}\n`,
);
return [];
}
const model =
frontmatter.model_tier === 'fast'
? profile.model_routing.fast_model
: frontmatter.model_tier === 'reasoning' || frontmatter.model_tier === 'deep'
? profile.model_routing.reasoning_model
: profile.model_routing.default_model;
return [
{
name: frontmatter.name,
description: frontmatter.description,
triggers: frontmatter.triggers,
model_tier: frontmatter.model_tier === 'deep' ? 'reasoning' : frontmatter.model_tier,
resolved_model: model,
output_format: frontmatter.output_format,
file: relative(cwd, file),
},
];
});
mkdirSync(dirname(input), { recursive: true });
writeFileSync(input, `${JSON.stringify(index, null, 2)}\n`);