@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
314 lines (313 loc) • 12.5 kB
JavaScript
import { skillEnabled } from "../modules/provision.js";
import * as fs from "fs";
import * as path from "path";
import { getProjectDirs } from "../shared.js";
import { parseSkillFrontmatter } from "../link/skills.js";
import { readSkillEnabledState } from "./state.js";
import { safeProjectPath } from "../utils.js";
function normalizeCommand(raw, fallbackName) {
const value = typeof raw === "string" && raw.trim() ? raw.trim() : `/${fallbackName}`;
return value.startsWith("/") ? value : `/${value}`;
}
function normalizeAliases(raw, command) {
if (!Array.isArray(raw))
return [];
const seen = new Set([command.toLowerCase()]);
const aliases = [];
for (const value of raw) {
if (typeof value !== "string" || !value.trim())
continue;
const normalized = value.trim().startsWith("/") ? value.trim() : `/${value.trim()}`;
const key = normalized.toLowerCase();
if (seen.has(key))
continue;
seen.add(key);
aliases.push(normalized);
}
return aliases;
}
function collectSkills(isEnabled, root, sourceLabel, scopeType, sourceKind, seen) {
if (!fs.existsSync(root))
return [];
const results = [];
const realRoot = (() => {
try {
return fs.realpathSync(root);
}
catch {
return path.resolve(root);
}
})();
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const isFolder = entry.isDirectory();
const candidatePath = isFolder
? path.join(root, entry.name, "SKILL.md")
: entry.name.endsWith(".md") ? path.join(root, entry.name) : null;
if (!candidatePath)
continue;
const safeCandidate = safeProjectPath(root, path.relative(root, candidatePath));
if (!safeCandidate || seen.has(safeCandidate) || !fs.existsSync(safeCandidate))
continue;
try {
if (fs.lstatSync(safeCandidate).isSymbolicLink())
continue;
const realCandidate = fs.realpathSync(safeCandidate);
if (realCandidate !== realRoot && !realCandidate.startsWith(realRoot + path.sep))
continue;
}
catch {
continue;
}
const filePath = safeCandidate;
seen.add(filePath);
const name = isFolder ? entry.name : entry.name.replace(/\.md$/, "");
const { frontmatter } = parseSkillFrontmatter(fs.readFileSync(filePath, "utf8"));
const command = normalizeCommand(frontmatter?.command, name);
results.push({
name,
source: sourceLabel,
scopeType,
sourceKind,
format: isFolder ? "folder" : "flat",
path: filePath,
root: isFolder ? path.dirname(filePath) : filePath,
description: frontmatter?.description,
enabled: isEnabled(sourceLabel, name),
command,
aliases: normalizeAliases(frontmatter?.aliases, command),
});
}
return results;
}
function getGlobalSkills(phrenPath, isEnabled) {
const seen = new Set();
return collectSkills(isEnabled, path.join(phrenPath, "global", "skills"), "global", "global", "canonical", seen);
}
function getProjectLocalSkills(phrenPath, project, isEnabled) {
const seen = new Set();
const projectDir = path.join(phrenPath, project);
return collectSkills(isEnabled, path.join(projectDir, "skills"), project, "project", "canonical", seen);
}
function skillPriority(skill) {
// "user" scope (priority 500) reserved for skills the user marks as untouchable —
// not yet wired to a scopeType value, but the priority slot is established here
// so the ordering is stable when we add it.
if (skill.scopeType === "project" && skill.sourceKind === "canonical")
return 400;
if (skill.scopeType === "global" && skill.sourceKind === "canonical")
return 200;
return 100;
}
function choosePreferredSkill(skills) {
return [...skills].sort((left, right) => {
const priority = skillPriority(right) - skillPriority(left);
if (priority !== 0)
return priority;
return left.path.localeCompare(right.path);
})[0];
}
function buildResolvedSkills(raw, mirrorDir) {
const grouped = new Map();
for (const skill of raw) {
const key = skill.name.toLowerCase();
const bucket = grouped.get(key) || [];
bucket.push(skill);
grouped.set(key, bucket);
}
const skills = [];
for (const [key, candidates] of grouped.entries()) {
const chosen = choosePreferredSkill(candidates);
const overrides = candidates
.filter((candidate) => candidate.path !== chosen.path)
.map((candidate) => ({
source: candidate.source,
path: candidate.path,
sourceKind: candidate.sourceKind,
}));
const destName = chosen.format === "folder" ? chosen.name : path.basename(chosen.path);
skills.push({
path: chosen.path,
format: chosen.format,
root: chosen.root,
name: chosen.name,
source: chosen.source,
enabled: chosen.enabled,
description: chosen.description,
command: chosen.command,
aliases: chosen.aliases,
scopeType: chosen.scopeType,
sourceKind: chosen.sourceKind,
visibleToAgents: chosen.enabled,
commandRegistered: true,
overrides,
mirrorTargets: mirrorDir ? [path.join(mirrorDir, destName)] : [],
});
grouped.delete(key);
}
skills.sort((left, right) => left.name.localeCompare(right.name));
const commandOwners = new Map();
for (const skill of skills) {
if (!skill.visibleToAgents)
continue;
for (const command of [skill.command, ...skill.aliases]) {
const key = command.toLowerCase();
const owners = commandOwners.get(key) || [];
owners.push({ skillId: skill.name, command });
commandOwners.set(key, owners);
}
}
const problems = [];
const collisionKeys = new Set();
for (const [key, owners] of commandOwners.entries()) {
if (owners.length < 2)
continue;
collisionKeys.add(key);
problems.push({
code: "command-collision",
command: owners[0]?.command,
skillIds: owners.map((owner) => owner.skillId),
message: `Command ${owners[0]?.command || key} resolves ambiguously across ${owners.map((owner) => owner.skillId).join(", ")}.`,
});
}
const commands = [];
for (const skill of skills) {
const commandEntries = [
{ command: skill.command, kind: "primary" },
...skill.aliases.map((alias) => ({ command: alias, kind: "alias" })),
];
skill.commandRegistered = skill.visibleToAgents && !collisionKeys.has(skill.command.toLowerCase());
for (const entry of commandEntries) {
commands.push({
command: entry.command,
type: "skill",
skillId: skill.name,
source: skill.source,
path: skill.path,
kind: entry.kind,
registered: skill.visibleToAgents && !collisionKeys.has(entry.command.toLowerCase()),
});
}
}
return {
scope: "global",
generatedAt: new Date().toISOString(),
skills,
commands,
problems,
};
}
function toResolvedSkill(skill) {
return {
path: skill.path,
format: skill.format,
root: skill.root,
name: skill.name,
source: skill.source,
enabled: skill.enabled,
description: skill.description,
command: skill.command,
aliases: skill.aliases,
scopeType: skill.scopeType,
sourceKind: skill.sourceKind,
visibleToAgents: skill.enabled,
commandRegistered: skill.enabled,
overrides: [],
mirrorTargets: [],
};
}
export function getAllSkills(phrenPath, profile) {
const skillState = readSkillEnabledState(phrenPath);
const isEnabled = (scope, name) => skillState(scope, name) && skillEnabled(phrenPath, name, profile || undefined);
const all = getGlobalSkills(phrenPath, isEnabled);
for (const dir of getProjectDirs(phrenPath, profile)) {
const source = path.basename(dir);
if (source === "global")
continue;
all.push(...getProjectLocalSkills(phrenPath, source, isEnabled));
}
return all;
}
function getLocalSkills(phrenPath, scope) {
const skillState = readSkillEnabledState(phrenPath);
const isEnabled = (scope, name) => skillState(scope, name) && skillEnabled(phrenPath, name);
if (scope.toLowerCase() === "global")
return getGlobalSkills(phrenPath, isEnabled);
return getProjectLocalSkills(phrenPath, scope, isEnabled);
}
export function buildSkillManifest(phrenPath, profile, scope, mirrorDir) {
const skillState = readSkillEnabledState(phrenPath);
const isEnabled = (scope, name) => skillState(scope, name) && skillEnabled(phrenPath, name, profile || undefined);
const manifest = scope.toLowerCase() === "global"
? buildResolvedSkills(getGlobalSkills(phrenPath, isEnabled), mirrorDir)
: buildResolvedSkills([...getGlobalSkills(phrenPath, isEnabled), ...getProjectLocalSkills(phrenPath, scope, isEnabled)], mirrorDir);
manifest.scope = scope;
manifest.project = scope.toLowerCase() === "global" ? undefined : scope;
manifest.generatedAt = new Date().toISOString();
return manifest;
}
export function getScopedSkills(phrenPath, profile, project) {
if (!project)
return getAllSkills(phrenPath, profile).map(toResolvedSkill);
return buildSkillManifest(phrenPath, profile, project).skills;
}
export function findLocalSkill(phrenPath, scope, name) {
const needle = name.replace(/\.md$/i, "").toLowerCase();
const matches = getLocalSkills(phrenPath, scope).filter((skill) => skill.name.toLowerCase() === needle);
if (matches.length === 0)
return null;
return toResolvedSkill(choosePreferredSkill(matches));
}
export function findSkill(phrenPath, profile, project, name) {
const needle = name.replace(/\.md$/i, "").toLowerCase();
if (project) {
const matches = buildSkillManifest(phrenPath, profile, project).skills.filter((skill) => skill.name.toLowerCase() === needle);
return matches[0] || null;
}
const matches = getAllSkills(phrenPath, profile).filter((skill) => skill.name.toLowerCase() === needle);
if (matches.length === 0)
return null;
if (matches.length > 1) {
return { error: `Skill '${name}' exists in multiple scopes: ${matches.map((match) => match.source).join(", ")}. Pass project= to disambiguate.` };
}
return toResolvedSkill(matches[0]);
}
export function renderSkillInstructionsSection(manifest) {
const visible = manifest.skills.filter((skill) => skill.visibleToAgents);
const disabled = manifest.skills.filter((skill) => !skill.visibleToAgents);
const lines = [
"<!-- phren:generated-skills -->",
"## Available Phren skills",
"",
"These skills are resolved from Phren source files and mirrored into `.claude/skills/` for agent discovery.",
"",
];
if (!visible.length) {
lines.push("No enabled skills are resolved for this scope.");
}
else {
lines.push("| Command | Skill | Scope | Source |");
lines.push("| --- | --- | --- | --- |");
for (const skill of visible) {
lines.push(`| \`${skill.command}\` | \`${skill.name}\` | \`${skill.source}\` | \`${skill.path}\` |`);
}
}
if (disabled.length) {
lines.push("");
lines.push("Disabled skills:");
for (const skill of disabled) {
lines.push(`- \`${skill.name}\` (${skill.source})`);
}
}
if (manifest.problems.length) {
lines.push("");
lines.push("Skill registry problems:");
for (const problem of manifest.problems) {
lines.push(`- ${problem.message}`);
}
}
lines.push("");
lines.push("Generated artifacts:");
lines.push("- `.claude/skill-manifest.json`");
lines.push("- `.claude/skill-commands.json`");
return lines.join("\n");
}