mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user