Compare commits
1 Commits
fix/pi-ski
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 87f561c1f8 |
@@ -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', () => {
|
||||
|
||||
@@ -6,7 +6,15 @@
|
||||
*/
|
||||
|
||||
import { execFileSync, execSync, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
} from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
@@ -428,25 +436,74 @@ function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void {
|
||||
|
||||
// ─── Pi skill/extension discovery ────────────────────────────────────────────
|
||||
|
||||
function discoverPiSkills(): string[] {
|
||||
/** Resolve a skill dir to its canonical real path so symlinked duplicates
|
||||
* (e.g. ~/.pi/agent/skills/X -> ~/.config/mosaic/skills/X) collapse to one key.
|
||||
* Falls back to the literal path if it can't be resolved (e.g. broken link). */
|
||||
function skillRealPath(dir: string): string {
|
||||
try {
|
||||
return realpathSync(dir);
|
||||
} catch {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
|
||||
* skills dir and the project-local one relative to the launch cwd. */
|
||||
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
|
||||
return [join(homedir(), '.pi', 'agent', 'skills'), join(cwd, '.pi', 'skills')];
|
||||
}
|
||||
|
||||
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
|
||||
* counts as a skill when it (or its symlink target) contains a SKILL.md.
|
||||
* Exported for tests (real-FS coverage of symlink acceptance + realpath dedup). */
|
||||
export function enumerateSkillDirs(roots: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const args: string[] = [];
|
||||
for (const skillsRoot of [join(MOSAIC_HOME, 'skills'), join(MOSAIC_HOME, 'skills-local')]) {
|
||||
for (const skillsRoot of roots) {
|
||||
if (!existsSync(skillsRoot)) continue;
|
||||
try {
|
||||
for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
// Synced fleet skills land as symlinks, so accept both dirs and links.
|
||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
||||
const skillDir = join(skillsRoot, entry.name);
|
||||
if (existsSync(join(skillDir, 'SKILL.md'))) {
|
||||
if (!existsSync(join(skillDir, 'SKILL.md'))) continue;
|
||||
const key = skillRealPath(skillDir);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
args.push('--skill', skillDir);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
// skip unreadable roots
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Every skill dir Pi would link under `MOSAIC_PI_SKILL_MODE=all`: the Mosaic
|
||||
* global/local catalog plus Pi's own native roots. `--no-skills` suppresses
|
||||
* native auto-discovery, so 'all' must re-add the native roots explicitly or
|
||||
* they would be silently dropped. Deduped by real path. */
|
||||
function discoverPiSkills(cwd: string = process.cwd()): string[] {
|
||||
return enumerateSkillDirs([
|
||||
join(MOSAIC_HOME, 'skills'),
|
||||
join(MOSAIC_HOME, 'skills-local'),
|
||||
...piNativeSkillRoots(cwd),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Real paths of skills Pi will auto-discover from its native roots. Used to
|
||||
* drop redundant force-loads in 'discover' mode (which keeps native discovery
|
||||
* on) so the same skill is not registered twice. */
|
||||
function piNativeSkillRealPaths(cwd: string = process.cwd()): Set<string> {
|
||||
const args = enumerateSkillDirs(piNativeSkillRoots(cwd));
|
||||
const set = new Set<string>();
|
||||
for (let i = 1; i < args.length; i += 2) {
|
||||
const dir = args[i];
|
||||
if (dir !== undefined) set.add(skillRealPath(dir));
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
type PiSkillMode = 'none' | 'all' | 'discover';
|
||||
|
||||
function normalizePiSkillMode(env: NodeJS.ProcessEnv): PiSkillMode {
|
||||
@@ -492,15 +549,19 @@ function forcedPiSkillArgs(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Concatenate `--skill <dir>` arg groups, dropping any directory already seen. */
|
||||
/** Concatenate `--skill <dir>` arg groups, dropping any skill already seen.
|
||||
* Dedup is by real path, so a forced skill and the same skill reached via a
|
||||
* different (e.g. symlinked) directory collapse to a single `--skill`. */
|
||||
function mergeSkillArgs(...groups: string[][]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const group of groups) {
|
||||
for (let i = 0; i < group.length; i += 2) {
|
||||
const dir = group[i + 1];
|
||||
if (group[i] !== '--skill' || dir === undefined || seen.has(dir)) continue;
|
||||
seen.add(dir);
|
||||
if (group[i] !== '--skill' || dir === undefined) continue;
|
||||
const key = skillRealPath(dir);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push('--skill', dir);
|
||||
}
|
||||
}
|
||||
@@ -512,17 +573,31 @@ export function buildPiSkillArgs(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
discoveredSkillArgs: string[] = discoverPiSkills(),
|
||||
forcedSkillArgs: string[] = forcedPiSkillArgs(env),
|
||||
nativeSkillRealPaths: Set<string> = piNativeSkillRealPaths(),
|
||||
): string[] {
|
||||
const mode = normalizePiSkillMode(env);
|
||||
|
||||
if (mode === 'discover') {
|
||||
// Native Pi discovery handles the rest; still force-load the fleet skills.
|
||||
return [...forcedSkillArgs];
|
||||
// Native Pi discovery stays on, so only force-load fleet skills it will NOT
|
||||
// already find under its native roots — otherwise the same skill is
|
||||
// registered twice (once natively, once via --skill). mergeSkillArgs first
|
||||
// collapses any intra-forced-set realpath duplicates, mirroring 'all' mode.
|
||||
const deduped = mergeSkillArgs(forcedSkillArgs);
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < deduped.length; i += 2) {
|
||||
const dir = deduped[i + 1];
|
||||
if (deduped[i] !== '--skill' || dir === undefined) continue;
|
||||
if (nativeSkillRealPaths.has(skillRealPath(dir))) continue;
|
||||
out.push('--skill', dir);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (mode === 'all') {
|
||||
// 'all' links the full catalog; merge in the forced set so fleet-critical
|
||||
// skills are guaranteed present even if they live only under skills-local/.
|
||||
// discoverPiSkills already covers Pi's native roots, which `--no-skills`
|
||||
// would otherwise suppress.
|
||||
return ['--no-skills', ...mergeSkillArgs(discoveredSkillArgs, forcedSkillArgs)];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user