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
@@ -8,11 +8,7 @@ import { SettingsSection } from "@/screens/settings/settings-section";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { import { shouldUseDesktopDaemon, type SkillsStatus } from "@/desktop/daemon/desktop-daemon";
shouldUseDesktopDaemon,
type SkillOp,
type SkillsStatus,
} from "@/desktop/daemon/desktop-daemon";
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status"; import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli"; const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
@@ -21,21 +17,6 @@ const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const UNINSTALL_MESSAGE = const UNINSTALL_MESSAGE =
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex."; "Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
const OP_KIND_LABEL: Record<SkillOp["kind"], string> = {
add: "Add skill",
update: "Update skill",
delete: "Delete skill",
};
function formatUpdateMessage(ops: readonly SkillOp[]): string {
const sorted = [...ops].sort((a, b) => {
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
});
return sorted.map((op) => `${OP_KIND_LABEL[op.kind]} ${op.name}`).join("\n");
}
export function IntegrationsSection() { export function IntegrationsSection() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon(); const showSection = shouldUseDesktopDaemon();
@@ -75,15 +56,8 @@ export function IntegrationsSection() {
const handleUpdateSkills = useCallback(async () => { const handleUpdateSkills = useCallback(async () => {
if (isSkillsWorking) return; if (isSkillsWorking) return;
const ops = skillsStatus?.ops ?? [];
const confirmed = await confirmDialog({
title: "Update Paseo skills?",
message: ops.length > 0 ? formatUpdateMessage(ops) : "Sync bundled skills to your machine.",
confirmLabel: "Update",
});
if (!confirmed) return;
await updateSkills(); await updateSkills();
}, [isSkillsWorking, skillsStatus, updateSkills]); }, [isSkillsWorking, updateSkills]);
const handleUninstallSkills = useCallback(async () => { const handleUninstallSkills = useCallback(async () => {
if (isSkillsWorking) return; if (isSkillsWorking) return;
@@ -36,6 +36,24 @@ describe("desktop startup", () => {
expect(calls).toEqual(["cli", "env", "gui"]); expect(calls).toEqual(["cli", "env", "gui"]);
}); });
it("starts skills auto-update after GUI startup", async () => {
const calls: string[] = [];
await runDesktopStartup({
hasPendingOpenProjectPath: false,
runCliPassthroughIfRequested: vi.fn(async () => {
calls.push("cli");
return false;
}),
inheritLoginShellEnv: vi.fn(() => calls.push("env")),
bootstrapGui: vi.fn(async () => {
calls.push("gui");
}),
autoUpdateInstalledSkills: vi.fn(() => calls.push("skills")),
});
expect(calls).toEqual(["cli", "env", "gui", "skills"]);
});
it("does not route open-project launches through CLI passthrough", async () => { it("does not route open-project launches through CLI passthrough", async () => {
const runCliPassthroughIfRequested = vi.fn(async () => true); const runCliPassthroughIfRequested = vi.fn(async () => true);
const calls: string[] = []; const calls: string[] = [];
+2
View File
@@ -3,6 +3,7 @@ export interface DesktopStartupDependencies {
runCliPassthroughIfRequested: () => Promise<boolean>; runCliPassthroughIfRequested: () => Promise<boolean>;
inheritLoginShellEnv: () => void; inheritLoginShellEnv: () => void;
bootstrapGui: () => Promise<void>; bootstrapGui: () => Promise<void>;
autoUpdateInstalledSkills?: () => void;
} }
export async function runDesktopStartup(deps: DesktopStartupDependencies): Promise<void> { export async function runDesktopStartup(deps: DesktopStartupDependencies): Promise<void> {
@@ -12,4 +13,5 @@ export async function runDesktopStartup(deps: DesktopStartupDependencies): Promi
deps.inheritLoginShellEnv(); deps.inheritLoginShellEnv();
await deps.bootstrapGui(); await deps.bootstrapGui();
deps.autoUpdateInstalledSkills?.();
} }
@@ -1,4 +1,5 @@
export { export {
autoUpdateInstalledSkills,
getSkillsStatus, getSkillsStatus,
installSkills, installSkills,
uninstallSkills, uninstallSkills,
@@ -11,6 +11,7 @@ vi.mock("electron", () => ({
})); }));
import { import {
autoUpdateInstalledSkills,
getSkillsStatus, getSkillsStatus,
installSkills, installSkills,
PASEO_SKILL_NAMES, PASEO_SKILL_NAMES,
@@ -60,6 +61,18 @@ async function writeOnDiskSkill(
await writeFiles(path.join(agentsDir, name), files); 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> { async function writeCurrentBundle(sourceDir: string): Promise<void> {
await writeBundleSkill(sourceDir, "paseo", { "SKILL.md": "paseo-v1" }); await writeBundleSkill(sourceDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeBundleSkill(sourceDir, "paseo-loop", { "SKILL.md": "loop-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 () => { it("returns up-to-date when every bundled skill matches on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir); await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" }); await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-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); 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 () => { it("returns drift with a single update op when one bundled file diverges", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir); await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" }); 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.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); 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 () => { it("returns drift with add ops for the bundled skills missing from disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir); 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); 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 () => { it("returns drift with a delete op for a legacy skill name still on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir); await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" }); await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" }); await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" }); await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets); 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 () => { it("emits add + update + delete ops sorted by name when state is mixed", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir); await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" }); 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" }); await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets); 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 () => { it("is idempotent — running install twice keeps state at up-to-date", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir); await writeCurrentBundle(sandbox.targets.sourceDir);
@@ -41,6 +41,7 @@ export const PASEO_SKILL_NAMES = [
] as const; ] as const;
type SkillFiles = Map<string, string>; type SkillFiles = Map<string, string>;
type TargetSkills = Map<string, SkillFiles>;
function resolveSkillTargets(): SkillTargets { function resolveSkillTargets(): SkillTargets {
return { return {
@@ -74,23 +75,34 @@ async function hashSkills(rootDir: string): Promise<Map<string, SkillFiles>> {
return out; return out;
} }
function diff(bundle: Map<string, SkillFiles>, disk: Map<string, SkillFiles>): SkillOp[] { function diff(bundle: TargetSkills, disks: readonly TargetSkills[]): SkillOp[] {
const ops: SkillOp[] = []; const ops: SkillOp[] = [];
for (const name of PASEO_SKILL_NAMES) { for (const name of PASEO_SKILL_NAMES) {
const b = bundle.get(name); const b = bundle.get(name);
const d = disk.get(name); const targetFiles = disks.map((disk) => disk.get(name));
if (b && !d) ops.push({ kind: "add", name }); const installedTargets = targetFiles.filter(
else if (b && d && !filesEqual(b, d)) ops.push({ kind: "update", name }); (files): files is SkillFiles => files !== undefined,
else if (!b && d) ops.push({ kind: "delete", name }); );
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)); ops.sort((a, b) => compareStrings(a.name, b.name));
return ops; return ops;
} }
function filesEqual(a: SkillFiles, b: SkillFiles): boolean { function hasInstalledPaseoSkill(disks: readonly TargetSkills[]): boolean {
if (a.size !== b.size) return false; return disks.some((disk) => disk.size > 0);
for (const [rel, sha] of a) { }
if (b.get(rel) !== sha) return false;
function bundleFilesMatch(bundle: SkillFiles, disk: SkillFiles): boolean {
for (const [rel, sha] of bundle) {
if (disk.get(rel) !== sha) return false;
} }
return true; return true;
} }
@@ -107,16 +119,25 @@ function compareStrings(a: string, b: string): number {
export async function getSkillsStatus(targets?: SkillTargets): Promise<SkillsStatus> { export async function getSkillsStatus(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets(); const t = targets ?? resolveSkillTargets();
const [bundle, disk] = await Promise.all([hashSkills(t.sourceDir), hashSkills(t.agentsDir)]); const [bundle, agentsDisk, claudeDisk, codexDisk] = await Promise.all([
const ops = diff(bundle, disk); 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 }; if (ops.length === 0) return { state: "up-to-date", ops };
return { state: "drift", ops }; return { state: "drift", ops };
} }
async function applySkills(targets: SkillTargets): Promise<SkillsStatus> { async function applySkills(
const status = await getSkillsStatus(targets); targets: SkillTargets,
initialStatus?: SkillsStatus,
): Promise<SkillsStatus> {
const status = initialStatus ?? (await getSkillsStatus(targets));
const writes = status.ops const writes = status.ops
.filter((op) => op.kind === "add" || op.kind === "update") .filter((op) => op.kind === "add" || op.kind === "update")
@@ -151,6 +172,13 @@ export async function updateSkills(targets?: SkillTargets): Promise<SkillsStatus
return applySkills(targets ?? resolveSkillTargets()); 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> { export async function uninstallSkills(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets(); const t = targets ?? resolveSkillTargets();
for (const name of PASEO_SKILL_NAMES) { 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"); 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 () => { it("removes stale files previously written to managed skill dirs", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" }); await writeBundleSkill(sandbox.sourceDir, "paseo", {
"SKILL.md": "old",
"references/stale.md": "stale",
});
const onDiskSkill = path.join(sandbox.agentsDir, "paseo"); 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 syncSkills({
await fs.writeFile(path.join(onDiskSkill, "references", "stale.md"), "stale"); 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({ await syncSkills({
sourceDir: sandbox.sourceDir, 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, "SKILL.md"), "utf-8")).toBe("new");
expect(await fs.readFile(path.join(onDiskSkill, "references", "stale.md"), "utf-8")).toBe( await expect(fs.access(path.join(onDiskSkill, "references", "stale.md"))).rejects.toThrow();
"stale", 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 { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
@@ -15,6 +16,13 @@ export interface SkillSyncResult {
processedSkills: number; 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> { async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boolean> {
const src = await fs.readFile(srcPath); const src = await fs.readFile(srcPath);
const dst = await fs.readFile(dstPath).catch(() => null); 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 }); const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
const rel = path.relative(rootDir, full);
if (rel === MANAGED_FILES_MANIFEST) continue;
if (entry.isDirectory()) { if (entry.isDirectory()) {
await walk(full); await walk(full);
} else if (entry.isFile()) { } 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; 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> { async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<number> {
const files = await listFilesRecursive(srcDir); 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; let changed = 0;
for (const rel of files) { for (const rel of files) {
if (await writeFileIfChanged(path.join(srcDir, rel), path.join(dstDir, rel))) { if (await writeFileIfChanged(path.join(srcDir, rel), path.join(dstDir, rel))) {
changed++; 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; return changed;
} }
+6
View File
@@ -64,6 +64,7 @@ import {
stopDesktopManagedDaemonOnQuitIfNeeded, stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./daemon/quit-lifecycle.js"; } from "./daemon/quit-lifecycle.js";
import { runDesktopStartup } from "./desktop-startup.js"; import { runDesktopStartup } from "./desktop-startup.js";
import { autoUpdateInstalledSkills } from "./integrations/skills/index.js";
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
const APP_SCHEME = "paseo"; const APP_SCHEME = "paseo";
@@ -714,6 +715,11 @@ void runDesktopStartup({
runCliPassthroughIfRequested, runCliPassthroughIfRequested,
inheritLoginShellEnv, inheritLoginShellEnv,
bootstrapGui: bootstrap, bootstrapGui: bootstrap,
autoUpdateInstalledSkills: () => {
void autoUpdateInstalledSkills().catch((error) => {
log.error("[skills] auto-update failed", error);
});
},
}).catch((error) => { }).catch((error) => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error); const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`); process.stderr.write(`${message}\n`);
+2
View File
@@ -16,6 +16,8 @@ Two ways to install:
- **Desktop app:** Settings → Integrations → Install - **Desktop app:** Settings → Integrations → Install
- **Manual:** `npx skills add getpaseo/paseo`, this installs to `~/.agents/skills/` and sets up symlinks for each agent. - **Manual:** `npx skills add getpaseo/paseo`, this installs to `~/.agents/skills/` and sets up symlinks for each agent.
When the desktop app finds installed Paseo skills, it keeps the bundled skills up to date on startup. If automatic update fails, use Settings → Integrations → Update or the manual command above.
## `/paseo`, Paseo Reference ## `/paseo`, Paseo Reference
The foundational skill. Paseo reference for managing agents and worktrees. Load it when an agent needs to create agents, send them prompts, or manage worktrees. The foundational skill. Paseo reference for managing agents and worktrees. Load it when an agent needs to create agents, send them prompts, or manage worktrees.