blob: dc4b3411e024db63241ffd67b63c4277fb82bd53 [file]
import { createHash } from 'node:crypto';
import { homedir } from 'node:os';
import { lstat, readdir, realpath } from 'node:fs/promises';
import { join } from 'node:path';
import { isPathInside, readContainedRegularFile } from './path-containment.js';
import { validateSkillMetadata } from './skills-metadata.js';
import type { SkillValidationIssue } from './skills-metadata.js';
import {
getSkillRuntimePreference,
migrateSkillRuntimePreferences,
readSkillRuntimeState,
type SkillRuntimeStatus,
type SkillRuntimeStateReadResult,
} from './skills-state.js';
import type { MakaToolContext } from './tool-runtime.js';
/**
* Skill discovery: resolving discovery paths, normalising skill sources,
* scanning directories for `SKILL.md` files, and deduping by id.
*
* Depends on {@link skills-metadata} for front-matter validation and
* {@link skills-state} for per-workspace enablement state.
*/
// ── Types ─────────────────────────────────────────────────────────────────
export type SkillScope = 'project' | 'workspace' | 'user' | 'custom';
export type SkillDiscoverySource = 'maka' | 'agents' | 'legacy' | 'custom';
export interface SkillDiscoveryEntry {
dir: string;
containmentRoot: string;
scope?: SkillScope;
source?: SkillDiscoverySource;
/** Stable source identity; defaults to `${scope}:${source}`. */
refPrefix?: string;
}
export type SkillSource =
| string
| { dirs: string[]; stateRoot: string; entries?: SkillDiscoveryEntry[] };
export type SkillSourceResolver = (
context: Pick<MakaToolContext, 'sessionId' | 'cwd'>,
) => SkillSource;
/**
* Runtime-facing skill definition. Stable public shape produced by scanning
* the workspace skills directory. Desktop's `InstalledSkill` extends this
* with governance fields (source type, lock validation, managed status).
* The SKILL.md body and its content hash ride on {@link ScannedSkill} only,
* so the always-on prompt fragment and the lazy loader can use them without
* exposing them on the stable type.
*/
export interface RuntimeSkillDefinition {
/** Stable scope-aware identity used by governance state and UI actions. */
ref: string;
id: string;
name: string;
description: string;
path: string;
declaredTools: string[];
/**
* Tools the host must have registered for this skill to be advertised or
* loaded. Independent from `declaredTools` (which is declaration only): a
* missing `requiredTools` entry hard-hides the skill on incompatible hosts,
* while a missing `declaredTools` entry is only an informational hint.
*/
requiredTools: string[];
/** Host capability tags the host must provide for this skill to be eligible. */
requiredCapabilities: string[];
enabled: boolean;
pinned: boolean;
runtimeStatus: SkillRuntimeStatus;
scope: SkillScope;
source: SkillDiscoverySource;
/** Stable discovery ordering. Lower values have higher precedence. */
precedence: number;
/** Higher-precedence copy that suppresses this skill from the runtime catalog. */
shadowedBy?: string;
}
/**
* A scanned skill: {@link RuntimeSkillDefinition} plus the SKILL.md body
* (`content`, front matter stripped) and the sha256 of the original file
* bytes (`contentSha256`). Desktop governance consumes `contentSha256` to
* validate the lock without re-reading the file.
*/
export interface ScannedSkill extends RuntimeSkillDefinition {
content: string;
contentSha256: string;
/**
* The containment root this skill was discovered under (e.g. workspace root,
* home dir). Used to compute `relativePath` in `loadSkillInstructions` so
* legacy callers see `skills/<id>/SKILL.md` while multi-path callers see
* the actual subpath.
*/
discoveryRoot: string;
}
/** Diagnostics for one discovered skill directory, including rejected skills. */
export interface SkillScanDiagnostic {
id: string;
path: string;
issues: SkillValidationIssue[];
}
/** A configured discovery root that could not be inspected safely. */
export interface SkillDiscoveryDiagnostic {
path: string;
scope: SkillScope;
source: SkillDiscoverySource;
precedence: number;
reason: 'blocked_path' | 'read_failed';
}
export interface SkillScanResult {
skills: ScannedSkill[];
/** Every valid discovered skill, including lower-precedence shadowed copies. */
inventory: ScannedSkill[];
/** Discovered SKILL.md files rejected by typed metadata validation. */
rejected: RejectedSkillDefinition[];
diagnostics: SkillScanDiagnostic[];
/** Source-level failures that previously appeared as an empty catalog. */
discoveryDiagnostics: SkillDiscoveryDiagnostic[];
/** State snapshot used to resolve enabled/pinned values during this scan. */
runtimeState: SkillRuntimeStateReadResult;
}
export interface RejectedSkillDefinition {
ref: string;
id: string;
name: string;
description: string;
path: string;
discoveryRoot: string;
declaredTools: string[];
scope: SkillScope;
source: SkillDiscoverySource;
precedence: number;
issues: SkillValidationIssue[];
}
// ── Public API ────────────────────────────────────────────────────────────
/**
* Standard skill discovery paths per the Agent Skills spec
* (https://agentskills.io/client-implementation/adding-skills-support).
*
* Ordered by precedence: project-level paths win over user-level, and
* client-specific paths win over cross-client paths at the same scope.
* Collision resolution: first-found wins within the dedup pass.
*
* `{workspaceRoot}/skills/` is included for backward compatibility with
* existing desktop-installed skills.
*
* Returns containment roots so `scanSkillDir` can reject ancestor-level
* symlink escapes (e.g. `repo/.agents -> /outside`).
*/
export function resolveSkillDiscoveryPaths(
cwd: string,
workspaceRoot: string,
homeDir?: string,
): { entries: SkillDiscoveryEntry[]; dirs: string[]; stateRoot: string } {
const home = homeDir ?? homedir();
const entries: SkillDiscoveryEntry[] = [
{ dir: join(cwd, '.maka', 'skills'), containmentRoot: cwd, scope: 'project', source: 'maka' },
{
dir: join(cwd, '.agents', 'skills'),
containmentRoot: cwd,
scope: 'project',
source: 'agents',
},
{
dir: join(workspaceRoot, 'skills'),
containmentRoot: workspaceRoot,
scope: 'workspace',
source: 'legacy',
},
{ dir: join(home, '.maka', 'skills'), containmentRoot: home, scope: 'user', source: 'maka' },
{
dir: join(home, '.agents', 'skills'),
containmentRoot: home,
scope: 'user',
source: 'agents',
},
];
return { entries, dirs: entries.map((e) => e.dir), stateRoot: workspaceRoot };
}
/**
* Scan multiple skill directories and dedupe by `id` (first-found wins).
* `source` can be a workspace root string (scans `{root}/skills/`) or an
* explicit `{ dirs, stateRoot }` for multi-path discovery. Directories
* earlier in the list have higher precedence; ties within the same directory
* break alphabetically. Dedup and truncation preserve this order so
* project-level skills are never crowded out by user-level ones.
*/
export async function scanSkillsWithDiagnostics(source: SkillSource): Promise<SkillScanResult> {
const { entries, stateRoot } = normalizeSkillSource(source);
let runtimeState = await readSkillRuntimeState(stateRoot);
const seenIds = new Map<string, ScannedSkill>();
const seenNames = new Map<string, ScannedSkill>();
const out: ScannedSkill[] = [];
const inventory: ScannedSkill[] = [];
const rejected: RejectedSkillDefinition[] = [];
const discoveryDiagnostics: SkillDiscoveryDiagnostic[] = [];
const diagnostics = new Map<string, SkillScanDiagnostic>();
for (const [precedence, entry] of entries.entries()) {
const found = await scanSkillDir(entry, runtimeState, precedence);
rejected.push(...found.rejected);
discoveryDiagnostics.push(...found.discoveryDiagnostics);
for (const diagnostic of found.diagnostics) {
appendSkillDiagnostic(diagnostics, diagnostic.id, diagnostic.path, diagnostic.issues);
}
for (const skill of found.skills) {
const normalizedId = skill.id.toLowerCase();
const retainedId = seenIds.get(normalizedId);
if (retainedId) {
skill.shadowedBy = retainedId.ref;
inventory.push(skill);
appendSkillDiagnostic(diagnostics, skill.id, skill.path, [
{
code: 'duplicate_id',
severity: 'warning',
field: 'id',
message: `Skill id "${skill.id}" is shadowed by a higher-precedence discovered skill.`,
},
]);
continue;
}
seenIds.set(normalizedId, skill);
inventory.push(skill);
const normalizedName = skill.name.toLowerCase();
const retainedName = seenNames.get(normalizedName);
if (retainedName) {
const duplicateNameIssue: SkillValidationIssue = {
code: 'duplicate_name',
severity: 'warning',
field: 'name',
message: `Skill display name "${skill.name}" is also used by another discovered skill. Load by id to avoid ambiguity.`,
};
appendSkillDiagnostic(diagnostics, retainedName.id, retainedName.path, [
duplicateNameIssue,
]);
appendSkillDiagnostic(diagnostics, skill.id, skill.path, [duplicateNameIssue]);
} else {
seenNames.set(normalizedName, skill);
}
out.push(skill);
}
}
if (runtimeState.ok) {
const migration = migrateSkillRuntimePreferences(runtimeState, inventory);
for (const skill of inventory) {
const preference = getSkillRuntimePreference(migration, skill);
skill.enabled = preference.enabled;
skill.pinned = preference.pinned;
skill.runtimeStatus = preference.enabled ? 'enabled' : 'disabled';
}
runtimeState = {
...runtimeState,
states: new Map(
[...migration.preferences].map(([key, preference]) => [key, preference.enabled]),
),
preferences: migration.preferences,
needsReview: migration.needsReview,
};
}
return {
skills: out,
inventory,
rejected,
diagnostics: [...diagnostics.values()],
discoveryDiagnostics,
runtimeState,
};
}
/** Backward-compatible scan API. Use scanSkillsWithDiagnostics for inspection. */
export async function scanSkills(source: SkillSource): Promise<ScannedSkill[]> {
return (await scanSkillsWithDiagnostics(source)).skills;
}
/**
* Scan `{workspaceRoot}/skills/` for directories that contain a SKILL.md.
* Parse the YAML front matter for `name`, `description`, and `allowed-tools`,
* and read per-workspace enablement state. Invalid skills are excluded from
* this compatibility result; use the diagnostics variant to inspect them.
*
* This is the original single-root entry point; desktop governance uses it.
* New call sites should prefer {@link scanSkills} with a multi-path source.
*/
export async function scanWorkspaceSkills(root: string): Promise<ScannedSkill[]> {
return scanSkills(root);
}
/** Single-workspace convenience wrapper that preserves validation diagnostics. */
export async function scanWorkspaceSkillsWithDiagnostics(root: string): Promise<SkillScanResult> {
return scanSkillsWithDiagnostics(root);
}
// ── Internal helpers ─────────────────────────────────────────────────────
function normalizeSkillSource(source: SkillSource): {
entries: SkillDiscoveryEntry[];
stateRoot: string;
} {
if (typeof source === 'string') {
return {
entries: [
{
dir: join(source, 'skills'),
containmentRoot: source,
scope: 'workspace',
source: 'legacy',
},
],
stateRoot: source,
};
}
if (source.entries && source.entries.length > 0) {
return { entries: source.entries, stateRoot: source.stateRoot };
}
// Fallback for manually constructed { dirs, stateRoot } objects without
// entries: use each dir as its own containment root. This is the least
// permissive option that still works.
return {
entries: source.dirs.map((dir, index) => ({
dir,
containmentRoot: dir,
scope: 'custom' as const,
source: 'custom' as const,
refPrefix: `custom:${index}`,
})),
stateRoot: source.stateRoot,
};
}
/**
* Scan a single skill directory. Each immediate subdirectory containing a
* `SKILL.md` is parsed. Metadata validation errors exclude only the malformed
* skill and are returned as structured diagnostics.
*
* The directory itself must be a real directory (not a symlink) and its
* realpath must be contained within the realpath of its parent directory.
* This prevents ancestor-level symlinks (e.g. `repo/.agents -> /outside`)
* from escaping the expected boundary.
*/
async function scanSkillDir(
discovery: SkillDiscoveryEntry,
runtimeState: SkillRuntimeStateReadResult,
precedence: number,
): Promise<SkillScanResult> {
const { dir, containmentRoot } = discovery;
const scope = discovery.scope ?? 'custom';
const source = discovery.source ?? 'custom';
const empty = (discoveryDiagnostics: SkillDiscoveryDiagnostic[] = []): SkillScanResult => ({
skills: [],
inventory: [],
rejected: [],
diagnostics: [],
discoveryDiagnostics,
runtimeState,
});
const sourceDiagnostic = (
reason: SkillDiscoveryDiagnostic['reason'],
): SkillDiscoveryDiagnostic => ({
path: dir,
scope,
source,
precedence,
reason,
});
let entries: import('node:fs').Dirent[];
try {
const dirStat = await lstat(dir);
if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) {
return empty([sourceDiagnostic('blocked_path')]);
}
// Verify the resolved directory has not escaped its containment root via
// an ancestor symlink (e.g. `repo/.agents -> /outside`).
const [rootReal, dirReal] = await Promise.all([realpath(containmentRoot), realpath(dir)]);
if (!isPathInside(rootReal, dirReal)) {
return empty([sourceDiagnostic('blocked_path')]);
}
entries = await readdir(dir, { withFileTypes: true });
entries.sort((a, b) => a.name.localeCompare(b.name));
} catch (error) {
// An absent optional discovery root is normal. A configured path that
// exists but cannot be inspected is not: expose it to governance clients.
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return empty();
return empty([sourceDiagnostic('read_failed')]);
}
const out: ScannedSkill[] = [];
const rejected: RejectedSkillDefinition[] = [];
const diagnostics: SkillScanDiagnostic[] = [];
for (const dirEntry of entries) {
if (!dirEntry.isDirectory()) continue;
const skillPath = join(dir, dirEntry.name);
const skillFile = join(skillPath, 'SKILL.md');
try {
const read = await readContainedRegularFile(skillPath, skillFile);
if (!read.ok) continue;
const bytes = read.bytes;
const text = bytes.toString('utf8');
const validation = validateSkillMetadata(text);
const discoverySource = source;
const ref = `${discovery.refPrefix ?? `${scope}:${discoverySource}`}:${dirEntry.name}`;
if (validation.issues.length > 0) {
diagnostics.push({ id: dirEntry.name, path: skillPath, issues: validation.issues });
}
if (!validation.valid) {
rejected.push({
ref,
id: dirEntry.name,
name: validation.manifest.name ?? dirEntry.name,
description: validation.manifest.description ?? '',
path: skillPath,
discoveryRoot: containmentRoot,
declaredTools: validation.manifest.allowedTools,
scope,
source: discoverySource,
precedence,
issues: validation.issues,
});
continue;
}
const { name, description, allowedTools, requiredTools, requiredCapabilities } =
validation.manifest;
const preference = runtimeState.ok
? (runtimeState.preferences.get(ref) ?? runtimeState.preferences.get(dirEntry.name))
: undefined;
const runtimeStatus: SkillRuntimeStatus = runtimeState.ok
? preference?.enabled === false
? 'disabled'
: 'enabled'
: 'state_error';
out.push({
ref,
id: dirEntry.name,
name: name ?? dirEntry.name,
description: description ?? '',
path: skillPath,
declaredTools: allowedTools,
requiredTools,
requiredCapabilities,
content: validation.body,
contentSha256: `sha256:${sha256Buffer(bytes)}`,
discoveryRoot: containmentRoot,
enabled: runtimeStatus === 'enabled',
pinned: preference?.pinned === true,
runtimeStatus,
scope,
source: discoverySource,
precedence,
});
} catch {
// Skip directories without a readable SKILL.md.
}
}
out.sort((a, b) => a.name.localeCompare(b.name));
return {
skills: out,
inventory: out,
rejected,
diagnostics,
discoveryDiagnostics: [],
runtimeState,
};
}
function appendSkillDiagnostic(
diagnostics: Map<string, SkillScanDiagnostic>,
id: string,
path: string,
issues: SkillValidationIssue[],
): void {
if (issues.length === 0) return;
const existing = diagnostics.get(path);
if (!existing) {
diagnostics.set(path, { id, path, issues: [...issues] });
return;
}
for (const issue of issues) {
if (
!existing.issues.some(
(candidate) =>
candidate.code === issue.code &&
candidate.field === issue.field &&
candidate.message === issue.message,
)
) {
existing.issues.push(issue);
}
}
}
function sha256Buffer(buffer: Buffer): string {
return createHash('sha256').update(buffer).digest('hex');
}