Keep desktop skills up to date automatically (#1309)

* Keep installed desktop skills up to date

* Preserve user files during skill sync
This commit is contained in:
Mohamed Boudra
2026-06-03 11:09:02 +00:00
committed by GitHub
parent 3681fd132b
commit c0a0e3a1c6
10 changed files with 297 additions and 55 deletions
@@ -1,4 +1,5 @@
export {
autoUpdateInstalledSkills,
getSkillsStatus,
installSkills,
uninstallSkills,
@@ -11,6 +11,7 @@ vi.mock("electron", () => ({
}));
import {
autoUpdateInstalledSkills,
getSkillsStatus,
installSkills,
PASEO_SKILL_NAMES,
@@ -60,6 +61,18 @@ async function writeOnDiskSkill(
await writeFiles(path.join(agentsDir, name), files);
}
async function writeOnDiskSkillToAllTargets(
targets: SkillTargets,
name: string,
files: Record<string, string>,
): Promise<void> {
await Promise.all([
writeOnDiskSkill(targets.agentsDir, name, files),
writeOnDiskSkill(targets.claudeDir, name, files),
writeOnDiskSkill(targets.codexDir, name, files),
]);
}
async function writeCurrentBundle(sourceDir: string): Promise<void> {
await writeBundleSkill(sourceDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeBundleSkill(sourceDir, "paseo-loop", { "SKILL.md": "loop-v1" });
@@ -112,8 +125,22 @@ describe("getSkillsStatus", () => {
it("returns up-to-date when every bundled skill matches on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
});
it("ignores user-added files inside current managed skill dirs", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", {
"SKILL.md": "paseo-v1",
"my-context.md": "user context",
});
const status = await getSkillsStatus(sandbox.targets);
@@ -123,7 +150,24 @@ describe("getSkillsStatus", () => {
it("returns drift with a single update op when one bundled file diverges", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]);
});
it("returns drift when a secondary agent target is stale", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets);
@@ -133,7 +177,7 @@ describe("getSkillsStatus", () => {
it("returns drift with add ops for the bundled skills missing from disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
const status = await getSkillsStatus(sandbox.targets);
@@ -143,8 +187,8 @@ describe("getSkillsStatus", () => {
it("returns drift with a delete op for a legacy skill name still on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets);
@@ -156,6 +200,8 @@ describe("getSkillsStatus", () => {
it("emits add + update + delete ops sorted by name when state is mixed", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets);
@@ -226,6 +272,51 @@ describe("installSkills / updateSkills", () => {
}
});
it("repairs secondary agent targets even when agents skills are current", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await updateSkills(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
expect(
await fs.readFile(path.join(sandbox.targets.claudeDir, "paseo-loop", "SKILL.md"), "utf-8"),
).toBe("loop-v1");
});
it("auto-updates drifted installed skills", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
const status = await autoUpdateInstalledSkills(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"),
).toBe("paseo-v1");
});
it("does not auto-install skills on a clean machine", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const status = await autoUpdateInstalledSkills(sandbox.targets);
expect(status).toEqual({
state: "not-installed",
ops: [
{ kind: "add", name: "paseo" },
{ kind: "add", name: "paseo-loop" },
],
});
expect(await pathExists(path.join(sandbox.targets.agentsDir, "paseo"))).toBe(false);
expect(await pathExists(path.join(sandbox.targets.claudeDir, "paseo"))).toBe(false);
expect(await pathExists(path.join(sandbox.targets.codexDir, "paseo"))).toBe(false);
});
it("is idempotent — running install twice keeps state at up-to-date", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
@@ -41,6 +41,7 @@ export const PASEO_SKILL_NAMES = [
] as const;
type SkillFiles = Map<string, string>;
type TargetSkills = Map<string, SkillFiles>;
function resolveSkillTargets(): SkillTargets {
return {
@@ -74,23 +75,34 @@ async function hashSkills(rootDir: string): Promise<Map<string, SkillFiles>> {
return out;
}
function diff(bundle: Map<string, SkillFiles>, disk: Map<string, SkillFiles>): SkillOp[] {
function diff(bundle: TargetSkills, disks: readonly TargetSkills[]): SkillOp[] {
const ops: SkillOp[] = [];
for (const name of PASEO_SKILL_NAMES) {
const b = bundle.get(name);
const d = disk.get(name);
if (b && !d) ops.push({ kind: "add", name });
else if (b && d && !filesEqual(b, d)) ops.push({ kind: "update", name });
else if (!b && d) ops.push({ kind: "delete", name });
const targetFiles = disks.map((disk) => disk.get(name));
const installedTargets = targetFiles.filter(
(files): files is SkillFiles => files !== undefined,
);
if (b) {
const missingTargets = installedTargets.length < disks.length;
const changedTargets = installedTargets.some((files) => !bundleFilesMatch(b, files));
if (missingTargets) ops.push({ kind: "add", name });
else if (changedTargets) ops.push({ kind: "update", name });
} else if (installedTargets.length > 0) {
ops.push({ kind: "delete", name });
}
}
ops.sort((a, b) => compareStrings(a.name, b.name));
return ops;
}
function filesEqual(a: SkillFiles, b: SkillFiles): boolean {
if (a.size !== b.size) return false;
for (const [rel, sha] of a) {
if (b.get(rel) !== sha) return false;
function hasInstalledPaseoSkill(disks: readonly TargetSkills[]): boolean {
return disks.some((disk) => disk.size > 0);
}
function bundleFilesMatch(bundle: SkillFiles, disk: SkillFiles): boolean {
for (const [rel, sha] of bundle) {
if (disk.get(rel) !== sha) return false;
}
return true;
}
@@ -107,16 +119,25 @@ function compareStrings(a: string, b: string): number {
export async function getSkillsStatus(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets();
const [bundle, disk] = await Promise.all([hashSkills(t.sourceDir), hashSkills(t.agentsDir)]);
const ops = diff(bundle, disk);
const [bundle, agentsDisk, claudeDisk, codexDisk] = await Promise.all([
hashSkills(t.sourceDir),
hashSkills(t.agentsDir),
hashSkills(t.claudeDir),
hashSkills(t.codexDir),
]);
const disks = [agentsDisk, claudeDisk, codexDisk];
const ops = diff(bundle, disks);
if (disk.size === 0) return { state: "not-installed", ops };
if (!hasInstalledPaseoSkill(disks)) return { state: "not-installed", ops };
if (ops.length === 0) return { state: "up-to-date", ops };
return { state: "drift", ops };
}
async function applySkills(targets: SkillTargets): Promise<SkillsStatus> {
const status = await getSkillsStatus(targets);
async function applySkills(
targets: SkillTargets,
initialStatus?: SkillsStatus,
): Promise<SkillsStatus> {
const status = initialStatus ?? (await getSkillsStatus(targets));
const writes = status.ops
.filter((op) => op.kind === "add" || op.kind === "update")
@@ -151,6 +172,13 @@ export async function updateSkills(targets?: SkillTargets): Promise<SkillsStatus
return applySkills(targets ?? resolveSkillTargets());
}
export async function autoUpdateInstalledSkills(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets();
const status = await getSkillsStatus(t);
if (status.state !== "drift") return status;
return applySkills(t, status);
}
export async function uninstallSkills(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets();
for (const name of PASEO_SKILL_NAMES) {
@@ -137,12 +137,23 @@ describe("syncSkills", () => {
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" });
it("removes stale files previously written to managed skill dirs", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", {
"SKILL.md": "old",
"references/stale.md": "stale",
});
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"],
});
await fs.rm(path.join(sandbox.sourceDir, "paseo"), { recursive: true, force: true });
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" });
await syncSkills({
sourceDir: sandbox.sourceDir,
@@ -153,8 +164,32 @@ describe("syncSkills", () => {
});
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",
await expect(fs.access(path.join(onDiskSkill, "references", "stale.md"))).rejects.toThrow();
await expect(fs.access(path.join(onDiskSkill, "references"))).rejects.toThrow();
});
it("preserves user-added files in managed skill dirs", 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, "my-context.md"), "user context");
await fs.writeFile(path.join(onDiskSkill, "references", "notes.md"), "user notes");
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, "my-context.md"), "utf-8")).toBe(
"user context",
);
expect(await fs.readFile(path.join(onDiskSkill, "references", "notes.md"), "utf-8")).toBe(
"user notes",
);
});
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
@@ -15,6 +16,13 @@ export interface SkillSyncResult {
processedSkills: number;
}
const MANAGED_FILES_MANIFEST = ".paseo-managed-files.json";
interface ManagedFilesManifest {
version: 1;
files: Record<string, string>;
}
async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boolean> {
const src = await fs.readFile(srcPath);
const dst = await fs.readFile(dstPath).catch(() => null);
@@ -30,10 +38,12 @@ export async function listFilesRecursive(rootDir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
const rel = path.relative(rootDir, full);
if (rel === MANAGED_FILES_MANIFEST) continue;
if (entry.isDirectory()) {
await walk(full);
} else if (entry.isFile()) {
out.push(path.relative(rootDir, full));
out.push(rel);
}
}
}
@@ -41,14 +51,89 @@ export async function listFilesRecursive(rootDir: string): Promise<string[]> {
return out;
}
async function readManagedFilesManifest(dstDir: string): Promise<ManagedFilesManifest | null> {
const raw = await fs
.readFile(path.join(dstDir, MANAGED_FILES_MANIFEST), "utf-8")
.catch(() => null);
if (!raw) return null;
const parsed = safeParseJson(raw) as Partial<ManagedFilesManifest> | null;
if (!parsed) return null;
if (parsed.version !== 1 || typeof parsed.files !== "object" || parsed.files === null)
return null;
return { version: 1, files: parsed.files as Record<string, string> };
}
function safeParseJson(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return null;
}
}
async function hashFile(filePath: string): Promise<string> {
const buf = await fs.readFile(filePath);
return createHash("sha256").update(buf).digest("hex");
}
async function writeManifestIfChanged(
dstDir: string,
files: Record<string, string>,
): Promise<boolean> {
const manifest: ManagedFilesManifest = { version: 1, files };
const next = `${JSON.stringify(manifest, null, 2)}\n`;
const manifestPath = path.join(dstDir, MANAGED_FILES_MANIFEST);
const current = await fs.readFile(manifestPath, "utf-8").catch(() => null);
if (current === next) return false;
await fs.mkdir(dstDir, { recursive: true });
await fs.writeFile(manifestPath, next);
return true;
}
async function pruneEmptyParentDirs(rootDir: string, rels: readonly string[]): Promise<void> {
const dirs = new Set<string>();
for (const rel of rels) {
let dir = path.dirname(rel);
while (dir !== ".") {
dirs.add(dir);
dir = path.dirname(dir);
}
}
const deepestFirst = [...dirs].sort(
(a, b) => b.split(path.sep).length - a.split(path.sep).length,
);
for (const rel of deepestFirst) {
await fs.rmdir(path.join(rootDir, rel)).catch(() => {});
}
}
async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<number> {
const files = await listFilesRecursive(srcDir);
const srcFileSet = new Set(files);
const srcHashes: Record<string, string> = {};
for (const rel of files) {
srcHashes[rel] = await hashFile(path.join(srcDir, rel));
}
const previousManifest = await readManagedFilesManifest(dstDir);
let changed = 0;
for (const rel of files) {
if (await writeFileIfChanged(path.join(srcDir, rel), path.join(dstDir, rel))) {
changed++;
}
}
const deletedRels: string[] = [];
for (const [rel, previousHash] of Object.entries(previousManifest?.files ?? {})) {
if (srcFileSet.has(rel)) continue;
const dstPath = path.join(dstDir, rel);
const currentHash = await hashFile(dstPath).catch(() => null);
if (currentHash !== previousHash) continue;
await fs.rm(dstPath, { force: true });
deletedRels.push(rel);
changed++;
}
await pruneEmptyParentDirs(dstDir, deletedRels);
if (await writeManifestIfChanged(dstDir, srcHashes)) changed++;
return changed;
}