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 19:09:02 +08:00
committed by GitHub
parent 3681fd132b
commit c0a0e3a1c6
10 changed files with 297 additions and 55 deletions

View File

@@ -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<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() {
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;

View File

@@ -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[] = [];

View File

@@ -3,6 +3,7 @@ export interface DesktopStartupDependencies {
runCliPassthroughIfRequested: () => Promise<boolean>;
inheritLoginShellEnv: () => void;
bootstrapGui: () => Promise<void>;
autoUpdateInstalledSkills?: () => void;
}
export async function runDesktopStartup(deps: DesktopStartupDependencies): Promise<void> {
@@ -12,4 +13,5 @@ export async function runDesktopStartup(deps: DesktopStartupDependencies): Promi
deps.inheritLoginShellEnv();
await deps.bootstrapGui();
deps.autoUpdateInstalledSkills?.();
}

View File

@@ -1,4 +1,5 @@
export {
autoUpdateInstalledSkills,
getSkillsStatus,
installSkills,
uninstallSkills,

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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",
);
});

View File

@@ -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;
}

View File

@@ -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`);

View File

@@ -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.