Files
paseo/packages/desktop/src/integrations/skill-sync.ts
T
Mohamed Boudra 90feb8050c feat(desktop): auto-update bundled skills on app launch
Skills installed via Paseo desktop never updated after first run, so
users were stuck on already-fixed skill content. On every launch, if
the paseo base skill is present in `~/.agents/skills/`, sync each
bundled skill (incl. `references/`) into agents, claude (via symlink/
junction), and codex — overwriting only files whose content differs.
Files on disk that are not in the bundle are left alone; deprecation
goes through tombstones (e.g. `paseo-orchestrate` redirects to
`paseo-epic`).

Also refreshes the bundled skill set: drops `paseo-chat`, adds
`paseo-advisor` and `paseo-epic`, and turns `paseo-orchestrate` into
a tombstone redirecting to `paseo-epic`.

Adds a desktop test job to CI on Ubuntu and Windows so the
junction/symlink path is exercised on both platforms.
2026-05-02 13:28:17 +07:00

123 lines
3.4 KiB
TypeScript

import { promises as fs } from "node:fs";
import path from "node:path";
export interface SkillSyncOptions {
sourceDir: string;
agentsDir: string;
claudeDir: string;
codexDir: string;
skillNames: readonly string[];
platform?: NodeJS.Platform;
onSkillError?: (skillName: string, error: unknown) => void;
}
export interface SkillSyncResult {
changedFiles: number;
processedSkills: number;
}
async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boolean> {
const src = await fs.readFile(srcPath);
const dst = await fs.readFile(dstPath).catch(() => null);
if (dst && src.equals(dst)) return false;
await fs.mkdir(path.dirname(dstPath), { recursive: true });
await fs.writeFile(dstPath, src);
return true;
}
async function listFilesRecursive(rootDir: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(full);
} else if (entry.isFile()) {
out.push(path.relative(rootDir, full));
}
}
}
await walk(rootDir);
return out;
}
async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<number> {
const files = await listFilesRecursive(srcDir);
let changed = 0;
for (const rel of files) {
if (await writeFileIfChanged(path.join(srcDir, rel), path.join(dstDir, rel))) {
changed++;
}
}
return changed;
}
async function ensureClaudeSkillLink(
skillName: string,
agentsDir: string,
claudeDir: string,
platform: NodeJS.Platform,
): Promise<number> {
await fs.mkdir(claudeDir, { recursive: true });
const target = path.join(agentsDir, skillName);
const linkPath = path.join(claudeDir, skillName);
// Always rebuild the link rather than diffing it. fs.rm with force: true is
// a no-op when nothing is there, and matches existing install behavior.
// On Windows, `fs.rm` does not follow junctions, so the agents-side content
// is preserved.
await fs.rm(linkPath, { recursive: true, force: true });
if (platform === "win32") {
try {
// Junctions don't require Developer Mode / admin like regular symlinks do.
await fs.symlink(target, linkPath, "junction");
return 0;
} catch {
return await syncDirectoryFiles(target, linkPath);
}
}
await fs.symlink(target, linkPath);
return 0;
}
export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> {
const platform = options.platform ?? process.platform;
let changedFiles = 0;
let processedSkills = 0;
for (const skillName of options.skillNames) {
const bundleSkillDir = path.join(options.sourceDir, skillName);
const bundleStat = await fs.stat(bundleSkillDir).catch(() => null);
if (!bundleStat?.isDirectory()) continue;
try {
changedFiles += await syncDirectoryFiles(
bundleSkillDir,
path.join(options.agentsDir, skillName),
);
changedFiles += await ensureClaudeSkillLink(
skillName,
options.agentsDir,
options.claudeDir,
platform,
);
changedFiles += await syncDirectoryFiles(
bundleSkillDir,
path.join(options.codexDir, skillName),
);
processedSkills++;
} catch (error) {
options.onSkillError?.(skillName, error);
}
}
return { changedFiles, processedSkills };
}