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.
This commit is contained in:
Mohamed Boudra
2026-05-02 13:28:17 +07:00
parent d46d92528a
commit 90feb8050c
24 changed files with 1035 additions and 2258 deletions
@@ -4,6 +4,7 @@ import os from "node:os";
import { app } from "electron";
import log from "electron-log/main";
import { resolveCliInstallSourcePath } from "./cli-install-path.js";
import { syncSkills } from "./skill-sync.js";
// ---------------------------------------------------------------------------
// Types
@@ -19,11 +20,12 @@ interface InstallStatus {
const SKILL_NAMES = [
"paseo",
"paseo-loop",
"paseo-handoff",
"paseo-orchestrator",
"paseo-chat",
"paseo-advisor",
"paseo-committee",
"paseo-epic",
"paseo-handoff",
"paseo-loop",
"paseo-orchestrate",
];
// ---------------------------------------------------------------------------
@@ -238,60 +240,70 @@ export async function getCliInstallStatus(): Promise<InstallStatus> {
// Skills Installation
// ---------------------------------------------------------------------------
async function copySkillFile(
sourceFile: string,
destDir: string,
skillName: string,
): Promise<void> {
const destSkillDir = path.join(destDir, skillName);
await fs.mkdir(destSkillDir, { recursive: true });
await fs.copyFile(sourceFile, path.join(destSkillDir, "SKILL.md"));
}
async function symlinkSkillDir(
skillName: string,
targetDir: string,
linkParentDir: string,
): Promise<void> {
await fs.mkdir(linkParentDir, { recursive: true });
const target = path.join(targetDir, skillName);
const linkPath = path.join(linkParentDir, skillName);
if (await pathOrSymlinkExists(linkPath)) {
await fs.rm(linkPath, { recursive: true, force: true });
}
if (process.platform === "win32") {
try {
await fs.symlink(target, linkPath, "junction");
} catch {
await copySkillFile(path.join(target, "SKILL.md"), linkParentDir, skillName);
}
} else {
await fs.symlink(target, linkPath);
}
function getSkillSyncTargets(): {
sourceDir: string;
agentsDir: string;
claudeDir: string;
codexDir: string;
} {
return {
sourceDir: getBundledSkillsDir(),
agentsDir: getAgentsSkillsDir(),
claudeDir: getClaudeSkillsDir(),
codexDir: getCodexSkillsDir(),
};
}
export async function installSkills(): Promise<InstallStatus> {
const sourceDir = getBundledSkillsDir();
const agentsDir = getAgentsSkillsDir();
const claudeDir = getClaudeSkillsDir();
const codexDir = getCodexSkillsDir();
const targets = getSkillSyncTargets();
log.info("[integrations] installSkills", targets);
log.info("[integrations] installSkills", { sourceDir, agentsDir, claudeDir, codexDir });
await Promise.all(
SKILL_NAMES.map(async (skillName) => {
const sourceFile = path.join(sourceDir, skillName, "SKILL.md");
await copySkillFile(sourceFile, agentsDir, skillName);
await symlinkSkillDir(skillName, agentsDir, claudeDir);
await copySkillFile(sourceFile, codexDir, skillName);
}),
);
const result = await syncSkills({
...targets,
skillNames: SKILL_NAMES,
onSkillError: (skillName, error) => {
log.warn("[integrations] skill install failed", { skillName, error });
},
});
log.info("[integrations] installSkills done", result);
return getSkillsInstallStatus();
}
export async function autoUpdateSkillsIfInstalled(): Promise<{
ran: boolean;
changedFiles: number;
processedSkills: number;
}> {
const targets = getSkillSyncTargets();
const installedMarker = path.join(targets.agentsDir, "paseo", "SKILL.md");
try {
await fs.access(installedMarker);
} catch {
return { ran: false, changedFiles: 0, processedSkills: 0 };
}
try {
const result = await syncSkills({
...targets,
skillNames: SKILL_NAMES,
onSkillError: (skillName, error) => {
log.warn("[integrations] skill auto-update failed", { skillName, error });
},
});
if (result.changedFiles > 0) {
log.info("[integrations] auto-updated paseo skills", result);
} else {
log.info("[integrations] paseo skills already up to date", result);
}
return { ran: true, ...result };
} catch (error) {
log.warn("[integrations] auto-update skills aborted", { error });
return { ran: false, changedFiles: 0, processedSkills: 0 };
}
}
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
const claudeDir = getClaudeSkillsDir();
const accessResults = await Promise.all(
@@ -0,0 +1,256 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { syncSkills } from "./skill-sync";
interface Sandbox {
root: string;
sourceDir: string;
agentsDir: string;
claudeDir: string;
codexDir: string;
}
async function makeSandbox(): Promise<Sandbox> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-skill-sync-"));
const sourceDir = path.join(root, "bundle");
const agentsDir = path.join(root, "home", ".agents", "skills");
const claudeDir = path.join(root, "home", ".claude", "skills");
const codexDir = path.join(root, "home", ".codex", "skills");
await fs.mkdir(sourceDir, { recursive: true });
return { root, sourceDir, agentsDir, claudeDir, codexDir };
}
async function writeBundleSkill(
sourceDir: string,
name: string,
files: Record<string, string>,
): Promise<void> {
const skillDir = path.join(sourceDir, name);
await fs.mkdir(skillDir, { recursive: true });
for (const [rel, content] of Object.entries(files)) {
const full = path.join(skillDir, rel);
await fs.mkdir(path.dirname(full), { recursive: true });
await fs.writeFile(full, content);
}
}
describe("syncSkills", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("overwrites on-disk skill content when the bundle differs", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", {
"SKILL.md": "new paseo content",
});
const onDiskSkill = path.join(sandbox.agentsDir, "paseo");
await fs.mkdir(onDiskSkill, { recursive: true });
await fs.writeFile(path.join(onDiskSkill, "SKILL.md"), "old paseo content");
const result = await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
expect(result.processedSkills).toBe(1);
expect(result.changedFiles).toBeGreaterThan(0);
const agentsContent = await fs.readFile(
path.join(sandbox.agentsDir, "paseo", "SKILL.md"),
"utf-8",
);
expect(agentsContent).toBe("new paseo content");
const codexContent = await fs.readFile(
path.join(sandbox.codexDir, "paseo", "SKILL.md"),
"utf-8",
);
expect(codexContent).toBe("new paseo content");
});
it("installs new bundled skills, including references/, when not present on disk", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo-epic", {
"SKILL.md": "epic content",
"references/roles.md": "roles content",
});
await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo-epic"],
});
expect(await fs.readFile(path.join(sandbox.agentsDir, "paseo-epic", "SKILL.md"), "utf-8")).toBe(
"epic content",
);
expect(
await fs.readFile(
path.join(sandbox.agentsDir, "paseo-epic", "references", "roles.md"),
"utf-8",
),
).toBe("roles content");
expect(
await fs.readFile(
path.join(sandbox.codexDir, "paseo-epic", "references", "roles.md"),
"utf-8",
),
).toBe("roles content");
const claudeLink = path.join(sandbox.claudeDir, "paseo-epic");
const lstat = await fs.lstat(claudeLink);
expect(lstat.isSymbolicLink()).toBe(true);
expect(await fs.readFile(path.join(claudeLink, "references", "roles.md"), "utf-8")).toBe(
"roles content",
);
});
it("leaves on-disk skills not in the bundle untouched", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" });
const customSkill = path.join(sandbox.agentsDir, "user-custom-skill");
await fs.mkdir(customSkill, { recursive: true });
await fs.writeFile(path.join(customSkill, "SKILL.md"), "user content");
await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
expect(await fs.readFile(path.join(customSkill, "SKILL.md"), "utf-8")).toBe("user content");
});
it("does not delete files on disk that are no longer in the bundle", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" });
const onDiskSkill = path.join(sandbox.agentsDir, "paseo");
await fs.mkdir(path.join(onDiskSkill, "references"), { recursive: true });
await fs.writeFile(path.join(onDiskSkill, "SKILL.md"), "old");
await fs.writeFile(path.join(onDiskSkill, "references", "stale.md"), "stale");
await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
expect(await fs.readFile(path.join(onDiskSkill, "SKILL.md"), "utf-8")).toBe("new");
expect(await fs.readFile(path.join(onDiskSkill, "references", "stale.md"), "utf-8")).toBe(
"stale",
);
});
it("reports zero changed files on a no-op resync", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", {
"SKILL.md": "content",
"references/extra.md": "ref",
});
const first = await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
expect(first.changedFiles).toBeGreaterThan(0);
const second = await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
expect(second.changedFiles).toBe(0);
});
it("skips skills listed in skillNames that are missing from the bundle without raising", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content" });
// "paseo-removed" is in skillNames but not in the bundle on disk.
const errors: Array<{ name: string; error: unknown }> = [];
const result = await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo", "paseo-removed"],
onSkillError: (name, error) => errors.push({ name, error }),
});
expect(errors).toEqual([]);
expect(result.processedSkills).toBe(1);
expect(await fs.readFile(path.join(sandbox.agentsDir, "paseo", "SKILL.md"), "utf-8")).toBe(
"content",
);
await expect(fs.access(path.join(sandbox.agentsDir, "paseo-removed"))).rejects.toThrow();
});
it("leaves on-disk skill content alone when the skill has been removed from the bundle", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "current" });
const deprecatedDir = path.join(sandbox.agentsDir, "paseo-deprecated");
await fs.mkdir(deprecatedDir, { recursive: true });
await fs.writeFile(path.join(deprecatedDir, "SKILL.md"), "old content");
await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo", "paseo-deprecated"],
});
expect(await fs.readFile(path.join(deprecatedDir, "SKILL.md"), "utf-8")).toBe("old content");
});
it("does not crash when the source bundle directory is missing", async () => {
const missingSourceDir = path.join(sandbox.root, "no-bundle-here");
const errors: string[] = [];
const result = await syncSkills({
sourceDir: missingSourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
onSkillError: (skillName) => errors.push(skillName),
});
expect(errors).toEqual([]);
expect(result).toEqual({ changedFiles: 0, processedSkills: 0 });
});
it("reports per-skill errors via onSkillError without throwing", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content" });
// Make agents skill path a file (not a directory) to force a write error
await fs.mkdir(sandbox.agentsDir, { recursive: true });
await fs.writeFile(path.join(sandbox.agentsDir, "paseo"), "blocking file");
const errors: string[] = [];
const result = await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
onSkillError: (skillName) => errors.push(skillName),
});
expect(errors).toContain("paseo");
expect(result.processedSkills).toBe(0);
});
});
@@ -0,0 +1,122 @@
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 };
}
+6
View File
@@ -43,6 +43,7 @@ import {
createBeforeQuitHandler,
stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./daemon/quit-lifecycle.js";
import { autoUpdateSkillsIfInstalled } from "./integrations/integrations-manager.js";
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
const APP_SCHEME = "paseo";
@@ -392,6 +393,11 @@ async function bootstrap(): Promise<void> {
registerDialogHandlers();
registerNotificationHandlers();
registerOpenerHandlers();
void autoUpdateSkillsIfInstalled().catch((error) => {
log.warn("[integrations] auto-update skills failed", error);
});
await createMainWindow();
app.on("activate", async () => {