Files
stack/packages/mosaic/src/skills/catalog.ts
T
fargo 1556982dbc skills: single install path — canonical skills ship with the framework
Phase D2 of plan 2026-08-19: single package, single install, single command.

The framework installer already treats skills/** as a shipped, manifest-owned
framework subtree, so the folded skills now install into
$MOSAIC_HOME/skills with the rest of the framework — no second repository,
no separate sync step:

- mosaic-sync-skills (bash + powershell): the fetch machinery is gone (clone,
  pull, dirty-state migration, rsync from sources/agent-skills). The script
  now only links installed skills into runtime homes. --link-only is a compat
  no-op; --no-link exits having nothing to do.
- catalog.ts: the sources/agent-skills fallback is dead and removed.
- install.sh, launch.ts, defaults/README.md, README.md, skills/README.md:
  references to the second repo rewritten to describe the shipped path.

Verified: clean install into a fresh MOSAIC_HOME produces 102 skills with no
sources/ directory; the linker then links the selected skills into the four
runtime homes with no git involvement.
2026-08-19 14:39:28 -05:00

91 lines
2.5 KiB
TypeScript

import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { parse as parseYaml } from 'yaml';
import { RECOMMENDED_SKILLS } from '../constants.js';
export interface SkillEntry {
name: string;
description: string;
version?: string;
recommended: boolean;
source: 'canonical' | 'local';
}
export function loadSkillsCatalog(mosaicHome: string): SkillEntry[] {
const skills: SkillEntry[] = [];
// Load canonical skills
const canonicalDir = join(mosaicHome, 'skills');
if (existsSync(canonicalDir)) {
skills.push(...loadSkillsFromDir(canonicalDir, 'canonical'));
}
// Load local skills
const localDir = join(mosaicHome, 'skills-local');
if (existsSync(localDir)) {
skills.push(...loadSkillsFromDir(localDir, 'local'));
}
return skills.sort((a, b) => a.name.localeCompare(b.name));
}
function loadSkillsFromDir(dir: string, source: 'canonical' | 'local'): SkillEntry[] {
const entries: SkillEntry[] = [];
let dirEntries;
try {
dirEntries = readdirSync(dir, { withFileTypes: true });
} catch {
return entries;
}
for (const entry of dirEntries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
const skillMdPath = join(dir, entry.name, 'SKILL.md');
if (!existsSync(skillMdPath)) continue;
try {
const content = readFileSync(skillMdPath, 'utf-8');
const frontmatter = parseFrontmatter(content);
entries.push({
name: (frontmatter['name'] as string | undefined) ?? entry.name,
description: (frontmatter['description'] as string | undefined) ?? '',
version: frontmatter['version'] as string | undefined,
recommended: RECOMMENDED_SKILLS.has(entry.name),
source,
});
} catch {
// Skip malformed skills
entries.push({
name: entry.name,
description: '',
recommended: RECOMMENDED_SKILLS.has(entry.name),
source,
});
}
}
return entries;
}
function parseFrontmatter(content: string): Record<string, unknown> {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match?.[1]) return {};
try {
return (parseYaml(match[1]) as Record<string, unknown>) ?? {};
} catch {
// Fallback: simple key-value parsing
const result: Record<string, string> = {};
for (const line of match[1].split('\n')) {
const kv = line.match(/^(\w[\w-]*)\s*:\s*(.+)/);
if (kv?.[1] !== undefined && kv[2] !== undefined) {
result[kv[1]] = kv[2].replace(/^['"]|['"]$/g, '');
}
}
return result;
}
}