fix(launch): include Pi native skill roots in 'all' mode; dedup 'discover' force-loads
Fast-follow for the two code-review findings on #555. Finding 1 — `all` mode dropped Pi's native skill roots. `mosaic` passes `--no-skills`, which suppresses Pi's own auto-discovery, so the `all` catalog must re-enumerate the native roots (`~/.pi/agent/skills/` and `<cwd>/.pi/skills/`) explicitly or skills living only there vanish. `discoverPiSkills` now scans those roots too. Also fixes a latent bug: the old enumerator skipped symlinked entries (`!isDirectory()`), but synced fleet skills land as symlinks — they were being dropped. Finding 2 — `discover` mode (which keeps native discovery ON) force-loaded fleet skills unconditionally, double-registering any skill Pi already finds natively. It now filters force-loads against the native-root realpath set. Implementation: realpath-based dedup throughout. New `skillRealPath`, `piNativeSkillRoots`, `enumerateSkillDirs` (accepts dirs + symlinks, dedup by realpath), `piNativeSkillRealPaths`. `mergeSkillArgs` dedups by realpath. `buildPiSkillArgs` gains an injectable 5th param for deterministic tests. Tests: discover-mode native-filter + intra-set dedup cases, plus real-FS coverage of `enumerateSkillDirs` (symlink acceptance, cross-root realpath dedup, SKILL.md gating). 308 pass; typecheck/lint/prettier green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QoYiBeKNh3BiYtAJS5Z587
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
buildPiSkillArgs,
|
||||
enumerateSkillDirs,
|
||||
piForceSkillNames,
|
||||
registerRuntimeLaunchers,
|
||||
type RuntimeLaunchHandler,
|
||||
@@ -116,11 +120,101 @@ describe('buildPiSkillArgs', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('force-loads fleet skills even under native Pi discovery', () => {
|
||||
it('force-loads fleet skills under native Pi discovery when not already discoverable', () => {
|
||||
// Empty native set => Pi would not find mosaic-tools on its own, so force it.
|
||||
expect(
|
||||
buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'discover' }, fakeSkills, fakeForced),
|
||||
buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'discover' }, fakeSkills, fakeForced, new Set()),
|
||||
).toEqual(['--skill', '/skills/mosaic-tools']);
|
||||
});
|
||||
|
||||
it('discover mode drops a forced skill Pi already discovers natively (no double-load)', () => {
|
||||
// mosaic-tools is reachable from a Pi native root, so native discovery
|
||||
// covers it — forcing it again would register the same skill twice.
|
||||
expect(
|
||||
buildPiSkillArgs(
|
||||
[],
|
||||
{ MOSAIC_PI_SKILL_MODE: 'discover' },
|
||||
fakeSkills,
|
||||
fakeForced,
|
||||
new Set(['/skills/mosaic-tools']),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('discover mode keeps a forced skill that no native root provides', () => {
|
||||
expect(
|
||||
buildPiSkillArgs(
|
||||
[],
|
||||
{ MOSAIC_PI_SKILL_MODE: 'discover' },
|
||||
fakeSkills,
|
||||
fakeForced,
|
||||
new Set(['/skills/some-other-skill']),
|
||||
),
|
||||
).toEqual(['--skill', '/skills/mosaic-tools']);
|
||||
});
|
||||
|
||||
it('discover mode collapses a forced skill listed twice to a single --skill', () => {
|
||||
// Mirror 'all' mode: intra-forced-set duplicates (same realpath) dedup.
|
||||
expect(
|
||||
buildPiSkillArgs(
|
||||
[],
|
||||
{ MOSAIC_PI_SKILL_MODE: 'discover' },
|
||||
fakeSkills,
|
||||
['--skill', '/skills/mosaic-tools', '--skill', '/skills/mosaic-tools'],
|
||||
new Set(),
|
||||
),
|
||||
).toEqual(['--skill', '/skills/mosaic-tools']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enumerateSkillDirs (real FS)', () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'mosaic-skills-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeSkill(parent: string, name: string): string {
|
||||
const dir = join(parent, name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'SKILL.md'), `# ${name}\n`);
|
||||
return dir;
|
||||
}
|
||||
|
||||
it('accepts a symlinked skill dir (regression: synced fleet skills are symlinks)', () => {
|
||||
// Real skill lives under `canonical/`; the scanned root only has a symlink to it.
|
||||
const canonical = makeSkill(join(root, 'canonical'), 'mosaic-tools');
|
||||
const scanned = join(root, 'scanned');
|
||||
mkdirSync(scanned, { recursive: true });
|
||||
symlinkSync(canonical, join(scanned, 'mosaic-tools'), 'dir');
|
||||
|
||||
expect(enumerateSkillDirs([scanned])).toEqual(['--skill', join(scanned, 'mosaic-tools')]);
|
||||
});
|
||||
|
||||
it('dedups by real path when the same skill is reachable from two roots', () => {
|
||||
// Root A holds the real dir; root B symlinks to it — one --skill, not two.
|
||||
const rootA = join(root, 'a');
|
||||
const rootB = join(root, 'b');
|
||||
const real = makeSkill(rootA, 'mosaic-tools');
|
||||
mkdirSync(rootB, { recursive: true });
|
||||
symlinkSync(real, join(rootB, 'mosaic-tools'), 'dir');
|
||||
|
||||
expect(enumerateSkillDirs([rootA, rootB])).toEqual(['--skill', real]);
|
||||
});
|
||||
|
||||
it('skips directories without a SKILL.md and missing roots', () => {
|
||||
mkdirSync(join(root, 'present', 'not-a-skill'), { recursive: true });
|
||||
makeSkill(join(root, 'present'), 'real-skill');
|
||||
|
||||
expect(enumerateSkillDirs([join(root, 'present'), join(root, 'does-not-exist')])).toEqual([
|
||||
'--skill',
|
||||
join(root, 'present', 'real-skill'),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('piForceSkillNames', () => {
|
||||
|
||||
Reference in New Issue
Block a user