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.
91 lines
2.5 KiB
TypeScript
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;
|
|
}
|
|
}
|