feat(mosaic): add secure skill registration CLI (#826)
All checks were successful
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #826.
This commit is contained in:
2026-07-17 23:45:35 +00:00
parent d3bf52898b
commit d801d6c4c8
18 changed files with 1240 additions and 26 deletions

View File

@@ -181,13 +181,19 @@ The installer rejects unrecognized flags or positional arguments before making c
## Universal Skills
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`, then links each skill into runtime directories.
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`. Install, wizard finalization, and `mosaic update` automatically reconcile every canonical skill into Claude Code's `~/.claude/skills/` directory.
```bash
mosaic sync # Full sync (clone + link)
~/.config/mosaic/bin/mosaic-sync-skills --link-only # Re-link only
mosaic sync # Full canonical catalog sync
mosaic skill list # Show registered, missing, dangling, and foreign entries
mosaic skill register <name> # Register or repair one canonical Claude link
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
```
Skill names are direct children using `[A-Za-z0-9][A-Za-z0-9._-]*`, not paths. Registration rejects traversal/control characters and never replaces foreign files, directories, or symlinks; unregister removes only links that point inside the canonical Mosaic skill root. After registering during a running Claude Code session, use `/reload-skills` or start a new session.
M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosaic root through its launcher configuration. Codex parity remains follow-up scope and continues to use the existing full skill-sync linker.
## Health Audit
```bash

View File

@@ -161,6 +161,7 @@ link_targets=(
)
canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")"
local_real="$(readlink -f "$MOSAIC_LOCAL_SKILLS_DIR")"
# Build an associative array from the colon-separated whitelist for O(1) lookup.
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
@@ -203,7 +204,14 @@ link_skill_into_target() {
link_path="$target_dir/$name"
if [[ -L "$link_path" ]]; then
ln -sfn "$skill_path" "$link_path"
local raw_target resolved_target
raw_target="$(readlink "$link_path")"
resolved_target="$(node -e 'const p=require("node:path"); process.stdout.write(p.resolve(p.dirname(process.argv[1]), process.argv[2]));' "$link_path" "$raw_target")"
if [[ "$resolved_target" == "$canonical_real/"* || "$resolved_target" == "$local_real/"* ]]; then
ln -sfn "$skill_path" "$link_path"
else
echo "[mosaic-skills] Preserve foreign runtime symlink: $link_path"
fi
return
fi
@@ -234,14 +242,10 @@ prune_stale_links_in_target() {
continue
fi
resolved="$(readlink -f "$link_path" 2>/dev/null || true)"
if [[ -z "$resolved" ]]; then
rm -f "$link_path"
echo "[mosaic-skills] Removed stale broken skill link: $link_path"
continue
fi
if [[ "$resolved" == "$MOSAIC_HOME/"* ]]; then
# -m resolves lexical dangling targets too. If resolution fails, ownership
# is unproven and the link must be preserved.
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
if [[ -n "$resolved" && "$resolved" == "$canonical_real/"* ]]; then
rm -f "$link_path"
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
fi

View File

@@ -79,9 +79,26 @@ function Link-SkillIntoTarget {
$linkPath = Join-Path $TargetDir $name
# Already a junction/symlink — recreate
# Recreate only Mosaic-owned junctions/symlinks. Foreign reparse points are
# runtime-owned and must never be clobbered by install/upgrade auto-sync.
$existing = Get-Item $linkPath -Force -ErrorAction SilentlyContinue
if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
$rawTarget = @($existing.Target)[0]
$candidate = if ([System.IO.Path]::IsPathRooted($rawTarget)) {
$rawTarget
}
else {
Join-Path (Split-Path $linkPath -Parent) $rawTarget
}
$resolvedTarget = [System.IO.Path]::GetFullPath($candidate)
$canonicalRoot = [System.IO.Path]::GetFullPath($MosaicSkillsDir).TrimEnd('\') + '\'
$localRoot = [System.IO.Path]::GetFullPath($MosaicLocalSkillsDir).TrimEnd('\') + '\'
$owned = $resolvedTarget.StartsWith($canonicalRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
$resolvedTarget.StartsWith($localRoot, [System.StringComparison]::OrdinalIgnoreCase)
if (-not $owned) {
Write-Host "[mosaic-skills] Preserve foreign runtime symlink: $linkPath"
return
}
Remove-Item $linkPath -Force
}
elseif ($existing) {

View File

@@ -53,6 +53,7 @@
},
"devDependencies": {
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^2.0.0",
"@types/react": "^18.3.0",
"tsx": "^4.0.0",
"typescript": "^5.8.0",

View File

@@ -18,6 +18,7 @@ import { registerFleetCommand } from './commands/fleet.js';
import { registerMissionCommand } from './commands/mission.js';
import { registerUninstallCommand } from './commands/uninstall.js';
import { registerRestoreCommand } from './commands/restore.js';
import { registerSkillCommand } from './commands/skill.js';
// prdy is registered via launch.ts
import { registerLaunchCommands } from './commands/launch.js';
import { registerAuthCommand } from './commands/auth.js';
@@ -67,7 +68,7 @@ Command Groups:
Runtime: tui, login, sessions
Gateway: gateway
Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, sync, upgrade, wizard, yolo
Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, skill, sync, upgrade, wizard, yolo
Platform: update
Runtimes: claude, codex, opencode, pi
`,
@@ -411,6 +412,10 @@ registerUninstallCommand(program);
registerRestoreCommand(program);
// ─── skill ───────────────────────────────────────────────────────────────────
registerSkillCommand(program);
// ─── telemetry ───────────────────────────────────────────────────────────────
registerTelemetryCommand(program);
@@ -471,6 +476,18 @@ program
return;
}
console.log('✔ Framework re-seeded.');
if (reseed.skillSyncError) {
console.error(` ⚠ Claude skill reconciliation skipped: ${reseed.skillSyncError}`);
}
const skillConflicts = reseed.skillSync?.conflicts ?? [];
const skillChanges =
(reseed.skillSync?.registered.length ?? 0) + (reseed.skillSync?.repaired.length ?? 0);
if (skillChanges > 0) {
console.log(`✔ Registered ${skillChanges.toString()} Mosaic skill(s) with Claude Code.`);
}
for (const conflict of skillConflicts) {
console.error(` ⚠ Skill registration skipped for ${conflict.name}: ${conflict.reason}`);
}
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
const units = refreshActiveFleetUnits();

View File

@@ -0,0 +1,421 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Command } from 'commander';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readlinkSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
listSkills,
registerSkill,
registerSkillCommand,
syncClaudeSkills,
unregisterSkill,
type SkillPaths,
} from './skill.js';
const LEGACY_SYNC_SCRIPT = fileURLToPath(
new URL('../../framework/tools/_scripts/mosaic-sync-skills', import.meta.url),
);
describe('Claude skill bridge', () => {
let root: string;
let paths: SkillPaths;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'mosaic-skill-cli-'));
paths = {
mosaicSkillsDir: join(root, '.config', 'mosaic', 'skills'),
claudeSkillsDir: join(root, '.claude', 'skills'),
};
mkdirSync(paths.mosaicSkillsDir, { recursive: true });
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function createSkill(name: string): string {
const skillDir = join(paths.mosaicSkillsDir, name);
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, 'SKILL.md'), `# ${name}\n`);
return skillDir;
}
function expectCorrectLink(name: string): void {
const linkPath = join(paths.claudeSkillsDir, name);
expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
expect(readlinkSync(linkPath)).toBe(join(paths.mosaicSkillsDir, name));
}
describe('name validation', () => {
const invalidNames = [
'../../etc',
'/abs/path',
'a/b',
String.raw`a\b`,
'-rf',
'..',
'safe.',
'space name',
'line\nbreak',
'escape\u001B[31m',
];
for (const name of invalidNames) {
it(`rejects ${JSON.stringify(name)} before register can escape its roots`, () => {
expect(() => registerSkill(name, paths)).toThrow(/invalid skill name/i);
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
});
it(`rejects ${JSON.stringify(name)} before unregister can escape its roots`, () => {
expect(() => unregisterSkill(name, paths)).toThrow(/invalid skill name/i);
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
});
}
});
describe('CLI validation errors', () => {
let previousExitCode: number | string | null | undefined;
beforeEach(() => {
previousExitCode = process.exitCode;
process.exitCode = undefined;
});
afterEach(() => {
process.exitCode = previousExitCode;
});
it.each(['register', 'unregister'])(
'reports invalid %s names on stderr and sets a nonzero exit status',
async (subcommand) => {
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const program = new Command().exitOverride();
registerSkillCommand(program, paths);
await program.parseAsync(['node', 'mosaic', 'skill', subcommand, '../../etc']);
expect(error).toHaveBeenCalledWith(expect.stringMatching(/invalid skill name/i));
expect(process.exitCode).toBe(1);
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
error.mockRestore();
},
);
});
describe('CLI status output', () => {
let previousExitCode: number | string | null | undefined;
beforeEach(() => {
previousExitCode = process.exitCode;
process.exitCode = undefined;
});
afterEach(() => {
process.exitCode = previousExitCode;
});
async function run(...args: string[]): Promise<void> {
const program = new Command().exitOverride();
registerSkillCommand(program, paths);
await program.parseAsync(['node', 'mosaic', 'skill', ...args]);
}
it('reports register repair/idempotency and unregister idempotency statuses', async () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
createSkill('status-skill');
await run('register', 'status-skill');
await run('register', 'status-skill');
rmSync(join(paths.claudeSkillsDir, 'status-skill'));
symlinkSync(
join(paths.mosaicSkillsDir, 'retired'),
join(paths.claudeSkillsDir, 'status-skill'),
);
await run('register', 'status-skill');
await run('unregister', 'status-skill');
await run('unregister', 'status-skill');
expect(log.mock.calls.flat()).toEqual([
'status-skill: registered',
'status-skill: already registered',
'status-skill: repaired dangling registration',
'status-skill: unregistered',
'status-skill: already unregistered',
]);
log.mockRestore();
});
it('reports empty and populated skill lists', async () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
await run('list');
createSkill('listed');
await run('list');
expect(log).toHaveBeenCalledWith('No Mosaic or Claude Code skills found.');
expect(log).toHaveBeenCalledWith(expect.stringMatching(/^unregistered\s+listed$/));
log.mockRestore();
});
});
describe('registerSkill', () => {
it('creates the exact canonical symlink and is idempotent', () => {
createSkill('new-skill');
expect(registerSkill('new-skill', paths).status).toBe('registered');
expectCorrectLink('new-skill');
expect(registerSkill('new-skill', paths).status).toBe('already-registered');
expectCorrectLink('new-skill');
});
it('repairs a dangling Mosaic-owned symlink', () => {
createSkill('new-skill');
mkdirSync(paths.claudeSkillsDir, { recursive: true });
symlinkSync(
join(paths.mosaicSkillsDir, 'retired-skill'),
join(paths.claudeSkillsDir, 'new-skill'),
);
expect(registerSkill('new-skill', paths).status).toBe('repaired');
expectCorrectLink('new-skill');
});
it.each(['file', 'directory', 'symlink'] as const)(
'refuses to clobber a foreign %s at the target',
(kind) => {
createSkill('protected');
mkdirSync(paths.claudeSkillsDir, { recursive: true });
const target = join(paths.claudeSkillsDir, 'protected');
const foreign = join(root, 'foreign');
if (kind === 'file') writeFileSync(target, 'keep me\n');
if (kind === 'directory') mkdirSync(target);
if (kind === 'symlink') {
writeFileSync(foreign, 'keep me\n');
symlinkSync(foreign, target);
}
expect(() => registerSkill('protected', paths)).toThrow(/foreign|refus/i);
if (kind === 'file') expect(lstatSync(target).isFile()).toBe(true);
if (kind === 'directory') expect(lstatSync(target).isDirectory()).toBe(true);
if (kind === 'symlink') expect(readlinkSync(target)).toBe(foreign);
},
);
it('refuses a symlinked Claude skills ancestor instead of writing outside the bridge root', () => {
createSkill('protected');
const externalClaude = join(root, 'external-claude');
mkdirSync(externalClaude);
symlinkSync(externalClaude, join(root, '.claude'));
expect(() => registerSkill('protected', paths)).toThrow(
/symlink.*ancestor|ancestor.*symlink/i,
);
expect(existsSync(join(externalClaude, 'skills', 'protected'))).toBe(false);
});
it('refuses a symlinked canonical skills root instead of registering an external source', () => {
rmSync(paths.mosaicSkillsDir, { recursive: true });
const externalSkills = join(root, 'external-skills');
mkdirSync(join(externalSkills, 'protected'), { recursive: true });
symlinkSync(externalSkills, paths.mosaicSkillsDir);
expect(() => registerSkill('protected', paths)).toThrow(
/symlink.*ancestor|ancestor.*symlink/i,
);
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
});
it('refuses a dangling foreign symlink rather than treating it as repairable', () => {
createSkill('protected');
mkdirSync(paths.claudeSkillsDir, { recursive: true });
const foreignMissing = join(root, 'foreign-missing');
const target = join(paths.claudeSkillsDir, 'protected');
symlinkSync(foreignMissing, target);
expect(() => registerSkill('protected', paths)).toThrow(/foreign|refus/i);
expect(readlinkSync(target)).toBe(foreignMissing);
});
});
describe('unregisterSkill', () => {
it('removes a Mosaic-owned symlink and is idempotent when absent', () => {
createSkill('removable');
registerSkill('removable', paths);
expect(unregisterSkill('removable', paths).status).toBe('unregistered');
expect(existsSync(join(paths.claudeSkillsDir, 'removable'))).toBe(false);
expect(unregisterSkill('removable', paths).status).toBe('already-unregistered');
});
it('refuses to remove a misdirected Mosaic-root symlink', () => {
createSkill('other');
mkdirSync(paths.claudeSkillsDir, { recursive: true });
const requested = join(paths.claudeSkillsDir, 'requested');
symlinkSync(join(paths.mosaicSkillsDir, 'other'), requested);
expect(() => unregisterSkill('requested', paths)).toThrow(/misdirected/i);
expect(readlinkSync(requested)).toBe(join(paths.mosaicSkillsDir, 'other'));
});
it.each(['file', 'directory', 'symlink'] as const)('refuses to remove a foreign %s', (kind) => {
mkdirSync(paths.claudeSkillsDir, { recursive: true });
const target = join(paths.claudeSkillsDir, 'protected');
const foreign = join(root, 'foreign');
if (kind === 'file') writeFileSync(target, 'keep me\n');
if (kind === 'directory') mkdirSync(target);
if (kind === 'symlink') {
writeFileSync(foreign, 'keep me\n');
symlinkSync(foreign, target);
}
expect(() => unregisterSkill('protected', paths)).toThrow(/foreign|refus/i);
expect(lstatSync(target)).toBeDefined();
if (kind === 'symlink') expect(readlinkSync(target)).toBe(foreign);
});
});
describe('listSkills', () => {
it('flags registered, unregistered, Mosaic-owned dangling, and foreign entries', () => {
createSkill('registered');
createSkill('unregistered');
registerSkill('registered', paths);
symlinkSync(join(paths.mosaicSkillsDir, 'retired'), join(paths.claudeSkillsDir, 'dangling'));
writeFileSync(join(paths.claudeSkillsDir, 'foreign-file'), 'keep me\n');
symlinkSync(join(root, 'missing-foreign'), join(paths.claudeSkillsDir, 'foreign-link'));
expect(listSkills(paths)).toEqual([
expect.objectContaining({ name: 'dangling', status: 'dangling' }),
expect.objectContaining({ name: 'foreign-file', status: 'foreign' }),
expect.objectContaining({ name: 'foreign-link', status: 'foreign-dangling' }),
expect.objectContaining({ name: 'registered', status: 'registered' }),
expect.objectContaining({ name: 'unregistered', status: 'unregistered' }),
]);
});
});
describe('install linker compatibility', () => {
it('preserves foreign-name links into Mosaic home but outside canonical skills', () => {
createSkill('missing');
mkdirSync(paths.claudeSkillsDir, { recursive: true });
const mosaicHome = join(root, '.config', 'mosaic');
const liveForeignTarget = join(mosaicHome, 'foreign-non-skill-target');
mkdirSync(liveForeignTarget);
const liveForeignLink = join(paths.claudeSkillsDir, 'foreign-tool');
const danglingForeignLink = join(paths.claudeSkillsDir, 'unresolvable-foreign');
symlinkSync(liveForeignTarget, liveForeignLink);
symlinkSync(join(mosaicHome, 'foreign-missing'), danglingForeignLink);
const result = spawnSync('bash', [LEGACY_SYNC_SCRIPT, '--link-only'], {
encoding: 'utf8',
env: { ...process.env, HOME: root, MOSAIC_HOME: mosaicHome },
});
expect(result.status, result.stderr).toBe(0);
expect(readlinkSync(liveForeignLink)).toBe(liveForeignTarget);
expect(readlinkSync(danglingForeignLink)).toBe(join(mosaicHome, 'foreign-missing'));
expect(readlinkSync(join(paths.claudeSkillsDir, 'missing'))).toBe(
join(paths.mosaicSkillsDir, 'missing'),
);
});
it('preserves live and dangling foreign Claude symlinks while linking missing skills', () => {
createSkill('dangling-foreign');
createSkill('live-foreign');
createSkill('missing');
mkdirSync(paths.claudeSkillsDir, { recursive: true });
const external = join(root, 'external');
mkdirSync(external);
const liveLink = join(paths.claudeSkillsDir, 'live-foreign');
const danglingLink = join(paths.claudeSkillsDir, 'dangling-foreign');
symlinkSync(external, liveLink);
symlinkSync(join(root, 'external-missing'), danglingLink);
const result = spawnSync('bash', [LEGACY_SYNC_SCRIPT, '--link-only'], {
encoding: 'utf8',
env: { ...process.env, HOME: root, MOSAIC_HOME: join(root, '.config', 'mosaic') },
});
expect(result.status, result.stderr).toBe(0);
expect(readlinkSync(liveLink)).toBe(external);
expect(readlinkSync(danglingLink)).toBe(join(root, 'external-missing'));
expect(readlinkSync(join(paths.claudeSkillsDir, 'missing'))).toBe(
join(paths.mosaicSkillsDir, 'missing'),
);
});
});
describe('syncClaudeSkills', () => {
it('generically creates every missing canonical link and repairs managed broken links', () => {
createSkill('added-after-setup');
createSkill('another-new-skill');
mkdirSync(paths.claudeSkillsDir, { recursive: true });
symlinkSync(
join(paths.mosaicSkillsDir, 'retired'),
join(paths.claudeSkillsDir, 'added-after-setup'),
);
const result = syncClaudeSkills(paths);
expect(result).toEqual({
registered: ['another-new-skill'],
repaired: ['added-after-setup'],
unchanged: [],
conflicts: [],
});
expectCorrectLink('added-after-setup');
expectCorrectLink('another-new-skill');
});
it('escapes an invalid filesystem-derived name in conflict output', () => {
createSkill('line\nbreak');
const result = syncClaudeSkills(paths);
expect(result.registered).toEqual([]);
expect(result.conflicts).toEqual([
expect.objectContaining({
name: '"line\\nbreak"',
reason: expect.stringMatching(/invalid/i),
}),
]);
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
});
it('continues syncing other skills without clobbering foreign entries', () => {
createSkill('blocked');
createSkill('link-me');
mkdirSync(paths.claudeSkillsDir, { recursive: true });
const blocked = join(paths.claudeSkillsDir, 'blocked');
writeFileSync(blocked, 'keep me\n');
const result = syncClaudeSkills(paths);
expect(result.registered).toEqual(['link-me']);
expect(result.conflicts).toEqual([
expect.objectContaining({
name: 'blocked',
reason: expect.stringMatching(/foreign|refus/i),
}),
]);
expect(readlinkSync(join(paths.claudeSkillsDir, 'link-me'))).toBe(
join(paths.mosaicSkillsDir, 'link-me'),
);
expect(lstatSync(blocked).isFile()).toBe(true);
});
});
});

View File

@@ -0,0 +1,419 @@
import {
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readlinkSync,
symlinkSync,
unlinkSync,
type Stats,
} from 'node:fs';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
import type { Command } from 'commander';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
export interface SkillPaths {
mosaicSkillsDir: string;
claudeSkillsDir: string;
}
export type SkillRegistrationStatus = 'registered' | 'already-registered' | 'repaired';
export type SkillUnregistrationStatus = 'unregistered' | 'already-unregistered';
export type SkillListStatus =
| 'registered'
| 'unregistered'
| 'dangling'
| 'foreign'
| 'foreign-dangling'
| 'misdirected';
export interface SkillRegistrationResult {
name: string;
status: SkillRegistrationStatus;
sourcePath: string;
linkPath: string;
}
export interface SkillUnregistrationResult {
name: string;
status: SkillUnregistrationStatus;
linkPath: string;
}
export interface SkillListEntry {
name: string;
status: SkillListStatus;
sourcePath?: string;
linkPath: string;
targetPath?: string;
}
export interface SkillSyncConflict {
name: string;
reason: string;
}
export interface SkillSyncResult {
registered: string[];
repaired: string[];
unchanged: string[];
conflicts: SkillSyncConflict[];
}
const SAFE_SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
export class SkillBridgeError extends Error {
public constructor(message: string) {
super(message);
this.name = 'SkillBridgeError';
}
}
/** Resolve the production bridge paths while keeping tests injectable. */
export function getDefaultSkillPaths(): SkillPaths {
const mosaicHome = process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME;
const claudeHome = process.env['CLAUDE_HOME'] ?? join(homedir(), '.claude');
return {
mosaicSkillsDir: join(mosaicHome, 'skills'),
claudeSkillsDir: join(claudeHome, 'skills'),
};
}
/**
* Reject a user-supplied name before any filesystem operation.
* A skill name must identify one direct child in both managed roots.
*/
export function validateSkillName(name: string): void {
if (
name.length === 0 ||
name.startsWith('-') ||
name.endsWith('.') ||
name.includes('..') ||
name.includes('/') ||
name.includes('\\') ||
isAbsolute(name) ||
!SAFE_SKILL_NAME.test(name)
) {
throw new SkillBridgeError(
`Invalid skill name ${JSON.stringify(name)}: use letters, numbers, dots, underscores, or hyphens; start with a letter or number; and do not use paths, "..", or a leading "-".`,
);
}
}
function displaySkillName(name: string): string {
return SAFE_SKILL_NAME.test(name) ? name : JSON.stringify(name);
}
function lstatIfPresent(path: string): Stats | undefined {
try {
return lstatSync(path);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined;
throw error;
}
}
function assertNoSymlinkAncestors(path: string): void {
const absolute = resolve(path);
const pathRoot = parse(absolute).root;
let current = pathRoot;
for (const segment of relative(pathRoot, absolute).split(sep)) {
if (segment.length === 0) continue;
current = join(current, segment);
const entry = lstatIfPresent(current);
if (!entry) break;
if (entry.isSymbolicLink()) {
throw new SkillBridgeError(
`Refusing symlink ancestor at ${current}; managed skill roots must resolve without symlink traversal.`,
);
}
}
}
function assertManagedRoots(paths: SkillPaths): void {
assertNoSymlinkAncestors(paths.mosaicSkillsDir);
assertNoSymlinkAncestors(paths.claudeSkillsDir);
}
function directChild(root: string, name: string): string {
const resolvedRoot = resolve(root);
const child = resolve(resolvedRoot, name);
if (dirname(child) !== resolvedRoot) {
throw new SkillBridgeError(`Invalid skill name "${name}": resolved path escapes its root.`);
}
return child;
}
function resolveLinkTarget(linkPath: string): string {
return resolve(dirname(linkPath), readlinkSync(linkPath));
}
function isInsideSkillsRoot(targetPath: string, skillsRoot: string): boolean {
const rel = relative(resolve(skillsRoot), resolve(targetPath));
return rel.length > 0 && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
}
function isDangling(linkPath: string): boolean {
return !existsSync(linkPath);
}
function assertSourceSkill(name: string, paths: SkillPaths): string {
const sourcePath = directChild(paths.mosaicSkillsDir, name);
const source = lstatIfPresent(sourcePath);
if (!source?.isDirectory()) {
throw new SkillBridgeError(
`Canonical skill directory not found: ${sourcePath}. Add the skill under the Mosaic skills directory before registering it.`,
);
}
return sourcePath;
}
function foreignTargetError(linkPath: string): SkillBridgeError {
return new SkillBridgeError(
`Refusing to modify foreign entry at ${linkPath}; only symlinks pointing inside the Mosaic skills directory are managed.`,
);
}
/** Register one canonical skill with Claude Code without clobbering foreign entries. */
export function registerSkill(
name: string,
paths: SkillPaths = getDefaultSkillPaths(),
): SkillRegistrationResult {
validateSkillName(name);
assertManagedRoots(paths);
const sourcePath = assertSourceSkill(name, paths);
const linkPath = directChild(paths.claudeSkillsDir, name);
const existing = lstatIfPresent(linkPath);
if (!existing) {
mkdirSync(paths.claudeSkillsDir, { recursive: true });
symlinkSync(sourcePath, linkPath);
return { name, status: 'registered', sourcePath, linkPath };
}
if (!existing.isSymbolicLink()) throw foreignTargetError(linkPath);
const existingTarget = resolveLinkTarget(linkPath);
if (!isInsideSkillsRoot(existingTarget, paths.mosaicSkillsDir)) {
throw foreignTargetError(linkPath);
}
if (existingTarget === resolve(sourcePath) && !isDangling(linkPath)) {
return { name, status: 'already-registered', sourcePath, linkPath };
}
if (!isDangling(linkPath)) {
throw new SkillBridgeError(
`Refusing to replace live Mosaic skill symlink at ${linkPath}; it points to ${existingTarget}, not ${sourcePath}.`,
);
}
unlinkSync(linkPath);
symlinkSync(sourcePath, linkPath);
return { name, status: 'repaired', sourcePath, linkPath };
}
/** Unregister only a symlink owned by the canonical Mosaic skills root. */
export function unregisterSkill(
name: string,
paths: SkillPaths = getDefaultSkillPaths(),
): SkillUnregistrationResult {
validateSkillName(name);
assertManagedRoots(paths);
const linkPath = directChild(paths.claudeSkillsDir, name);
const existing = lstatIfPresent(linkPath);
if (!existing) return { name, status: 'already-unregistered', linkPath };
if (!existing.isSymbolicLink()) throw foreignTargetError(linkPath);
const targetPath = resolveLinkTarget(linkPath);
if (!isInsideSkillsRoot(targetPath, paths.mosaicSkillsDir)) throw foreignTargetError(linkPath);
const expectedTarget = resolve(directChild(paths.mosaicSkillsDir, name));
if (targetPath !== expectedTarget) {
throw new SkillBridgeError(
`Refusing to unregister misdirected Mosaic skill symlink at ${linkPath}; it points to ${targetPath}, not ${expectedTarget}.`,
);
}
unlinkSync(linkPath);
return { name, status: 'unregistered', linkPath };
}
function canonicalSkillNames(paths: SkillPaths): string[] {
assertNoSymlinkAncestors(paths.mosaicSkillsDir);
const root = lstatIfPresent(paths.mosaicSkillsDir);
if (!root) return [];
if (!root.isDirectory()) {
throw new SkillBridgeError(
`Canonical skills path is not a directory: ${paths.mosaicSkillsDir}`,
);
}
return readdirSync(paths.mosaicSkillsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
}
function claudeEntryNames(paths: SkillPaths): string[] {
assertNoSymlinkAncestors(paths.claudeSkillsDir);
const root = lstatIfPresent(paths.claudeSkillsDir);
if (!root) return [];
if (!root.isDirectory()) {
throw new SkillBridgeError(`Claude skills path is not a directory: ${paths.claudeSkillsDir}`);
}
return readdirSync(paths.claudeSkillsDir)
.filter((name) => name.length > 0)
.sort();
}
/** Return a deterministic union of canonical skills and Claude bridge entries. */
export function listSkills(paths: SkillPaths = getDefaultSkillPaths()): SkillListEntry[] {
const canonicalNames = new Set(canonicalSkillNames(paths));
const names = new Set([...canonicalNames, ...claudeEntryNames(paths)]);
const entries: SkillListEntry[] = [];
for (const name of [...names].sort()) {
const sourcePath = canonicalNames.has(name)
? directChild(paths.mosaicSkillsDir, name)
: undefined;
const linkPath = directChild(paths.claudeSkillsDir, name);
const installed = lstatIfPresent(linkPath);
if (!installed) {
if (sourcePath) entries.push({ name, status: 'unregistered', sourcePath, linkPath });
continue;
}
if (!installed.isSymbolicLink()) {
entries.push({ name, status: 'foreign', sourcePath, linkPath });
continue;
}
const targetPath = resolveLinkTarget(linkPath);
const owned = isInsideSkillsRoot(targetPath, paths.mosaicSkillsDir);
const dangling = isDangling(linkPath);
if (!owned) {
entries.push({
name,
status: dangling ? 'foreign-dangling' : 'foreign',
sourcePath,
linkPath,
targetPath,
});
continue;
}
if (dangling) {
entries.push({ name, status: 'dangling', sourcePath, linkPath, targetPath });
continue;
}
entries.push({
name,
status: sourcePath && targetPath === resolve(sourcePath) ? 'registered' : 'misdirected',
sourcePath,
linkPath,
targetPath,
});
}
return entries;
}
/** Reconcile every canonical skill directory while preserving all foreign entries. */
export function syncClaudeSkills(paths: SkillPaths = getDefaultSkillPaths()): SkillSyncResult {
const result: SkillSyncResult = {
registered: [],
repaired: [],
unchanged: [],
conflicts: [],
};
for (const name of canonicalSkillNames(paths)) {
try {
const registration = registerSkill(name, paths);
if (registration.status === 'registered') result.registered.push(name);
if (registration.status === 'repaired') result.repaired.push(name);
if (registration.status === 'already-registered') result.unchanged.push(name);
} catch (error: unknown) {
result.conflicts.push({
name: displaySkillName(name),
reason: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}
function reportCommandError(error: unknown): void {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
/** Register the `mosaic skill` command group. */
export function registerSkillCommand(
program: Command,
paths: SkillPaths = getDefaultSkillPaths(),
): void {
const skill = program
.command('skill')
.description('Manage Claude Code skill registrations')
.configureHelp({ sortSubcommands: true });
skill
.command('register <name>')
.description('Register a Mosaic skill with Claude Code')
.action((name: string) => {
try {
const result = registerSkill(name, paths);
if (result.status === 'already-registered') {
console.log(`${name}: already registered`);
} else if (result.status === 'repaired') {
console.log(`${name}: repaired dangling registration`);
} else {
console.log(`${name}: registered`);
}
} catch (error: unknown) {
reportCommandError(error);
}
});
skill
.command('unregister <name>')
.description('Unregister a Mosaic skill from Claude Code')
.action((name: string) => {
try {
const result = unregisterSkill(name, paths);
console.log(
result.status === 'already-unregistered'
? `${name}: already unregistered`
: `${name}: unregistered`,
);
} catch (error: unknown) {
reportCommandError(error);
}
});
skill
.command('list')
.description('List registered, dangling, foreign, and unregistered skills')
.action(() => {
try {
const entries = listSkills(paths);
if (entries.length === 0) {
console.log('No Mosaic or Claude Code skills found.');
return;
}
for (const entry of entries) {
console.log(`${entry.status.padEnd(17)} ${displaySkillName(entry.name)}`);
}
} catch (error: unknown) {
reportCommandError(error);
}
});
}

View File

@@ -7,6 +7,7 @@ import {
mkdirSync,
readdirSync,
readFileSync,
readlinkSync,
rmSync,
statSync,
symlinkSync,
@@ -300,6 +301,50 @@ describe('repairFleetCommsTools', () => {
});
describe('runFrameworkReseed', () => {
it('auto-registers every canonical skill after a successful upgrade re-seed', () => {
const root = mkdtempSync(join(tmpdir(), 'mosaic-reseed-skills-'));
const framework = join(root, 'framework');
const home = join(root, 'mosaic');
const claudeSkills = join(root, '.claude', 'skills');
mkdirSync(framework, { recursive: true });
mkdirSync(join(home, 'skills', 'added-after-setup'), { recursive: true });
mkdirSync(join(home, 'skills', 'another-new-skill'), { recursive: true });
writeFileSync(join(framework, 'install.sh'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
const res = runFrameworkReseed(framework, home, claudeSkills);
expect(res.ok).toBe(true);
expect(res.skillSync).toMatchObject({
registered: ['added-after-setup', 'another-new-skill'],
conflicts: [],
});
expect(readlinkSync(join(claudeSkills, 'added-after-setup'))).toBe(
join(home, 'skills', 'added-after-setup'),
);
expect(readlinkSync(join(claudeSkills, 'another-new-skill'))).toBe(
join(home, 'skills', 'another-new-skill'),
);
rmSync(root, { recursive: true, force: true });
});
it('keeps a successful framework re-seed successful when bridge reconciliation fails', () => {
const root = mkdtempSync(join(tmpdir(), 'mosaic-reseed-bridge-failure-'));
const framework = join(root, 'framework');
const home = join(root, 'mosaic');
const claudeSkills = join(root, '.claude', 'skills');
mkdirSync(framework, { recursive: true });
mkdirSync(home, { recursive: true });
writeFileSync(join(home, 'skills'), 'invalid canonical root\n');
writeFileSync(join(framework, 'install.sh'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
const res = runFrameworkReseed(framework, home, claudeSkills);
expect(res.ok).toBe(true);
expect(res.skillSync).toBeUndefined();
expect(res.skillSyncError).toMatch(/not a directory/i);
rmSync(root, { recursive: true, force: true });
});
it('reports not-ok (not throw) when the installer is absent', () => {
const missing = mkdtempSync(join(tmpdir(), 'mosaic-noinstaller-'));
const res = runFrameworkReseed(missing, join(missing, 'home'));

View File

@@ -43,6 +43,7 @@ import {
ensureManagedDirectory,
readRegularFileSecure,
} from '../fleet/secure-file.js';
import { getDefaultSkillPaths, syncClaudeSkills, type SkillSyncResult } from '../commands/skill.js';
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -871,19 +872,39 @@ export function repairFleetCommsTools(
* describing what happened (so callers can message + decide on relaunch).
* Best-effort: a missing installer or a non-zero exit is reported, not thrown.
*/
export interface FrameworkReseedResult {
ok: boolean;
reason?: string;
skillSync?: SkillSyncResult;
skillSyncError?: string;
}
export function runFrameworkReseed(
frameworkRoot = resolveBundledFrameworkRoot(),
mosaicHome = join(homedir(), '.config', 'mosaic'),
): { ok: boolean; reason?: string } {
claudeSkillsDir = getDefaultSkillPaths().claudeSkillsDir,
): FrameworkReseedResult {
const { installer, command, env } = buildReseedCommand(frameworkRoot, mosaicHome);
if (!existsSync(installer)) {
return { ok: false, reason: `installer not found: ${installer}` };
}
try {
execSync(command, { stdio: 'inherit', env: { ...process.env, ...env }, timeout: 120_000 });
return { ok: true };
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
} catch (error: unknown) {
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
}
try {
const skillSync = syncClaudeSkills({
mosaicSkillsDir: join(mosaicHome, 'skills'),
claudeSkillsDir,
});
return { ok: true, skillSync };
} catch (error: unknown) {
return {
ok: true,
skillSyncError: error instanceof Error ? error.message : String(error),
};
}
}

View File

@@ -9,7 +9,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, readlinkSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { WizardState } from '../types.js';
@@ -113,6 +113,40 @@ describe('finalizeStage — skill installer', () => {
);
}
it('auto-registers every canonical skill even when it was added after initial setup', async () => {
const claudeHome = join(tmp, '.claude');
const previousClaudeHome = process.env['CLAUDE_HOME'];
process.env['CLAUDE_HOME'] = claudeHome;
mkdirSync(join(tmp, 'skills', 'added-after-setup'), { recursive: true });
mkdirSync(join(tmp, 'skills', 'another-new-skill'), { recursive: true });
try {
await finalizeStage(buildPrompter(), makeState(tmp, []), makeConfigService());
expect(readlinkSync(join(claudeHome, 'skills', 'added-after-setup'))).toBe(
join(tmp, 'skills', 'added-after-setup'),
);
expect(readlinkSync(join(claudeHome, 'skills', 'another-new-skill'))).toBe(
join(tmp, 'skills', 'another-new-skill'),
);
} finally {
if (previousClaudeHome === undefined) delete process.env['CLAUDE_HOME'];
else process.env['CLAUDE_HOME'] = previousClaudeHome;
}
});
it('warns and completes finalization when bridge-wide reconciliation fails', async () => {
writeFileSync(join(tmp, 'skills'), 'invalid canonical root\n');
const p = buildPrompter();
await finalizeStage(p, makeState(tmp, []), makeConfigService());
expect(p.warn).toHaveBeenCalledWith(
expect.stringMatching(/Claude skill reconciliation skipped.*not a directory/i),
);
expect(p.outro).toHaveBeenCalledWith('Mosaic is ready.');
});
it('passes MOSAIC_INSTALL_SKILLS with the selected skill list', async () => {
const state = makeState(tmp, ['brainstorming', 'lint', 'systematic-debugging']);
const p = buildPrompter();

View File

@@ -7,6 +7,11 @@ import type { ConfigService } from '../config/config-service.js';
import type { WizardState } from '../types.js';
import { getShellProfilePath } from '../platform/detect.js';
import { ManifestError } from '../framework/manifest.js';
import {
getDefaultSkillPaths,
syncClaudeSkills,
type SkillSyncResult as ClaudeSkillSyncResult,
} from '../commands/skill.js';
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
@@ -205,7 +210,27 @@ export async function finalizeStage(
skillsResult = syncSkills(state.mosaicHome, state.selectedSkills);
}
// 5. Run doctor
// 5. Reconcile every canonical Mosaic skill into Claude Code. This is
// intentionally independent of the first-run selected-skill fetch above:
// framework installs/upgrades must also register skills added after setup.
spin.update('Registering Mosaic skills with Claude Code...');
let bridgeResult: ClaudeSkillSyncResult = {
registered: [],
repaired: [],
unchanged: [],
conflicts: [],
};
let bridgeFailure: string | undefined;
try {
bridgeResult = syncClaudeSkills({
mosaicSkillsDir: join(state.mosaicHome, 'skills'),
claudeSkillsDir: getDefaultSkillPaths().claudeSkillsDir,
});
} catch (error: unknown) {
bridgeFailure = error instanceof Error ? error.message : String(error);
}
// 6. Run doctor
spin.update('Running health audit...');
const doctorResult = runDoctor(state.mosaicHome);
@@ -217,10 +242,15 @@ export async function finalizeStage(
p.warn("Run 'mosaic sync' manually after installation to install skills.");
}
// 6. PATH setup
if (bridgeFailure) p.warn(`Claude skill reconciliation skipped: ${bridgeFailure}`);
for (const conflict of bridgeResult.conflicts) {
p.warn(`Skill registration skipped for ${conflict.name}: ${conflict.reason}`);
}
// 7. PATH setup
const pathAction = setupPath(state.mosaicHome, p);
// 7. Summary
// 8. Summary
const skillsSummary = skillsResult.success
? skillsResult.installedCount > 0
? `${skillsResult.installedCount.toString()} installed`
@@ -245,7 +275,7 @@ export async function finalizeStage(
p.note(summary.join('\n'), 'Installation Summary');
// 8. Next steps
// 9. Next steps
const nextSteps: string[] = [];
if (pathAction === 'added') {
const profilePath = getShellProfilePath();

View File

@@ -5,5 +5,16 @@ export default defineConfig({
globals: true,
environment: 'node',
testTimeout: 30_000,
coverage: {
provider: 'v8',
include: ['src/commands/skill.ts'],
reporter: ['text', 'json-summary'],
thresholds: {
statements: 85,
branches: 85,
functions: 85,
lines: 85,
},
},
},
});