diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index 4fcddef99..2b66f357b 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -8,11 +8,7 @@ import { SettingsSection } from "@/screens/settings/settings-section"; import { Button } from "@/components/ui/button"; import { openExternalUrl } from "@/utils/open-external-url"; import { confirmDialog } from "@/utils/confirm-dialog"; -import { - shouldUseDesktopDaemon, - type SkillOp, - type SkillsStatus, -} from "@/desktop/daemon/desktop-daemon"; +import { shouldUseDesktopDaemon, type SkillsStatus } from "@/desktop/daemon/desktop-daemon"; import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status"; 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 = "Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex."; -const OP_KIND_ORDER: Record = { add: 0, update: 1, delete: 2 }; -const OP_KIND_LABEL: Record = { - 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() { const { theme } = useUnistyles(); const showSection = shouldUseDesktopDaemon(); @@ -75,15 +56,8 @@ export function IntegrationsSection() { const handleUpdateSkills = useCallback(async () => { 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(); - }, [isSkillsWorking, skillsStatus, updateSkills]); + }, [isSkillsWorking, updateSkills]); const handleUninstallSkills = useCallback(async () => { if (isSkillsWorking) return; diff --git a/packages/desktop/src/desktop-startup.test.ts b/packages/desktop/src/desktop-startup.test.ts index c766a22a7..c447c216d 100644 --- a/packages/desktop/src/desktop-startup.test.ts +++ b/packages/desktop/src/desktop-startup.test.ts @@ -36,6 +36,24 @@ describe("desktop startup", () => { 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 () => { const runCliPassthroughIfRequested = vi.fn(async () => true); const calls: string[] = []; diff --git a/packages/desktop/src/desktop-startup.ts b/packages/desktop/src/desktop-startup.ts index 6ee8a0f0f..25943e342 100644 --- a/packages/desktop/src/desktop-startup.ts +++ b/packages/desktop/src/desktop-startup.ts @@ -3,6 +3,7 @@ export interface DesktopStartupDependencies { runCliPassthroughIfRequested: () => Promise; inheritLoginShellEnv: () => void; bootstrapGui: () => Promise; + autoUpdateInstalledSkills?: () => void; } export async function runDesktopStartup(deps: DesktopStartupDependencies): Promise { @@ -12,4 +13,5 @@ export async function runDesktopStartup(deps: DesktopStartupDependencies): Promi deps.inheritLoginShellEnv(); await deps.bootstrapGui(); + deps.autoUpdateInstalledSkills?.(); } diff --git a/packages/desktop/src/integrations/skills/index.ts b/packages/desktop/src/integrations/skills/index.ts index 5020b97f0..d568e7436 100644 --- a/packages/desktop/src/integrations/skills/index.ts +++ b/packages/desktop/src/integrations/skills/index.ts @@ -1,4 +1,5 @@ export { + autoUpdateInstalledSkills, getSkillsStatus, installSkills, uninstallSkills, diff --git a/packages/desktop/src/integrations/skills/operations.test.ts b/packages/desktop/src/integrations/skills/operations.test.ts index ee8617492..a31a24266 100644 --- a/packages/desktop/src/integrations/skills/operations.test.ts +++ b/packages/desktop/src/integrations/skills/operations.test.ts @@ -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, +): Promise { + await Promise.all([ + writeOnDiskSkill(targets.agentsDir, name, files), + writeOnDiskSkill(targets.claudeDir, name, files), + writeOnDiskSkill(targets.codexDir, name, files), + ]); +} + async function writeCurrentBundle(sourceDir: string): Promise { 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); diff --git a/packages/desktop/src/integrations/skills/operations.ts b/packages/desktop/src/integrations/skills/operations.ts index 8acaa17b7..35fd6f060 100644 --- a/packages/desktop/src/integrations/skills/operations.ts +++ b/packages/desktop/src/integrations/skills/operations.ts @@ -41,6 +41,7 @@ export const PASEO_SKILL_NAMES = [ ] as const; type SkillFiles = Map; +type TargetSkills = Map; function resolveSkillTargets(): SkillTargets { return { @@ -74,23 +75,34 @@ async function hashSkills(rootDir: string): Promise> { return out; } -function diff(bundle: Map, disk: Map): 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 { 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 { - const status = await getSkillsStatus(targets); +async function applySkills( + targets: SkillTargets, + initialStatus?: SkillsStatus, +): Promise { + 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 { + 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 { const t = targets ?? resolveSkillTargets(); for (const name of PASEO_SKILL_NAMES) { diff --git a/packages/desktop/src/integrations/skills/sync.test.ts b/packages/desktop/src/integrations/skills/sync.test.ts index 52c7bbbcc..0bd7e4915 100644 --- a/packages/desktop/src/integrations/skills/sync.test.ts +++ b/packages/desktop/src/integrations/skills/sync.test.ts @@ -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", ); }); diff --git a/packages/desktop/src/integrations/skills/sync.ts b/packages/desktop/src/integrations/skills/sync.ts index 8004b3a79..4741101ed 100644 --- a/packages/desktop/src/integrations/skills/sync.ts +++ b/packages/desktop/src/integrations/skills/sync.ts @@ -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; +} + async function writeFileIfChanged(srcPath: string, dstPath: string): Promise { 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 { 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 { return out; } +async function readManagedFilesManifest(dstDir: string): Promise { + 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 | 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 }; +} + +function safeParseJson(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +async function hashFile(filePath: string): Promise { + const buf = await fs.readFile(filePath); + return createHash("sha256").update(buf).digest("hex"); +} + +async function writeManifestIfChanged( + dstDir: string, + files: Record, +): Promise { + 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 { + const dirs = new Set(); + 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 { const files = await listFilesRecursive(srcDir); + const srcFileSet = new Set(files); + const srcHashes: Record = {}; + 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; } diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 42324bf03..01e4883da 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -64,6 +64,7 @@ import { stopDesktopManagedDaemonOnQuitIfNeeded, } from "./daemon/quit-lifecycle.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 APP_SCHEME = "paseo"; @@ -714,6 +715,11 @@ void runDesktopStartup({ runCliPassthroughIfRequested, inheritLoginShellEnv, bootstrapGui: bootstrap, + autoUpdateInstalledSkills: () => { + void autoUpdateInstalledSkills().catch((error) => { + log.error("[skills] auto-update failed", error); + }); + }, }).catch((error) => { const message = error instanceof Error ? (error.stack ?? error.message) : String(error); process.stderr.write(`${message}\n`); diff --git a/public-docs/skills.md b/public-docs/skills.md index 99518d6d7..f1b5a0755 100644 --- a/public-docs/skills.md +++ b/public-docs/skills.md @@ -16,6 +16,8 @@ Two ways to install: - **Desktop app:** Settings → Integrations → Install - **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 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.