Manage orchestration skills per host (#3451)
* feat(skills): manage orchestration skills per host Agent skills execute on the daemon host, so their bundle, selection, and filesystem convergence must share that owner. Preserve the existing install semantics while letting every capable client manage the selected host and migrating the legacy desktop preference once. * fix(client): keep skill management internal Keep host skill management on the low-level daemon client used by the app. Do not expose daemon settings or the desktop-only compatibility migration through the shared Paseo API handed to plugins. * fix(e2e): declare skills fixture dependencies Use Playwright's required destructuring form for the test-scoped daemon sandbox fixture so CI can collect the real-host skills suite. * test(skills): use native managed paths Assert fixed skill targets relative to the daemon user's real home with platform-native path joining so the ownership contract holds on Windows and POSIX hosts.
This commit is contained in:
@@ -148,6 +148,7 @@ import {
|
||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import { ScheduleService } from "./schedule/service.js";
|
||||
import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js";
|
||||
import { createOrchestrationSkills } from "./orchestration-skills/index.js";
|
||||
import { resolveConfigFromPersisted, type CliConfigOverrides } from "./config.js";
|
||||
import { BrowserToolsBroker } from "./browser-tools/broker.js";
|
||||
import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js";
|
||||
@@ -172,6 +173,7 @@ import { resolveDaemonVersion } from "./daemon-version.js";
|
||||
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import type {
|
||||
AgentProfile,
|
||||
AgentSkillSelection,
|
||||
FirstAgentContext,
|
||||
PluginSource,
|
||||
TerminalProfile,
|
||||
@@ -406,6 +408,7 @@ export interface PaseoDaemonConfig {
|
||||
appendSystemPrompt?: string;
|
||||
terminalProfiles?: TerminalProfile[];
|
||||
agentProfiles?: AgentProfile[];
|
||||
skillSelection?: AgentSkillSelection;
|
||||
pluginsEnabled?: boolean;
|
||||
plugins?: Record<string, PluginSource>;
|
||||
staticDir: string;
|
||||
@@ -551,6 +554,7 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae
|
||||
appendSystemPrompt: config.appendSystemPrompt ?? "",
|
||||
pluginsEnabled: config.pluginsEnabled ?? false,
|
||||
plugins: config.plugins ?? {},
|
||||
skills: { selection: config.skillSelection },
|
||||
};
|
||||
|
||||
if (config.terminalProfiles !== undefined) {
|
||||
@@ -592,6 +596,10 @@ export async function createPaseoDaemon(
|
||||
},
|
||||
},
|
||||
});
|
||||
const orchestrationSkills = createOrchestrationSkills(daemonConfigStore);
|
||||
void orchestrationSkills.autoUpdate().catch((error) => {
|
||||
logger.error({ err: error }, "Failed to maintain orchestration skills at startup");
|
||||
});
|
||||
const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore);
|
||||
const browserToolsBroker = new BrowserToolsBroker({});
|
||||
const pluginRuntime = new PluginService(logger, daemonConfigStore);
|
||||
@@ -1634,6 +1642,7 @@ export async function createPaseoDaemon(
|
||||
hubRelationships,
|
||||
workspaceSetupRuntime,
|
||||
pluginRuntime,
|
||||
orchestrationSkills,
|
||||
);
|
||||
pluginRuntime.bindPaseoSessionHost(wsServer);
|
||||
await pluginRuntime.start();
|
||||
|
||||
@@ -613,6 +613,7 @@ export function resolveConfigFromPersisted(
|
||||
appendSystemPrompt,
|
||||
terminalProfiles,
|
||||
agentProfiles,
|
||||
skillSelection: persisted.agents?.skills?.selection,
|
||||
pluginsEnabled: persisted.pluginsEnabled ?? false,
|
||||
plugins: persisted.plugins,
|
||||
mcpDebug: env.MCP_DEBUG === "1",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MutableDaemonConfigSchema,
|
||||
MutableDaemonConfigPatchSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages";
|
||||
|
||||
@@ -27,6 +28,7 @@ interface SupportedMutableConfigPatch {
|
||||
appendSystemPrompt?: string;
|
||||
terminalProfiles?: MutableDaemonConfig["terminalProfiles"];
|
||||
agentProfiles?: MutableDaemonConfig["agentProfiles"];
|
||||
skills?: MutableDaemonConfig["skills"];
|
||||
pluginsEnabled?: boolean;
|
||||
plugins?: MutableDaemonConfig["plugins"];
|
||||
}
|
||||
@@ -185,6 +187,7 @@ const RELOADABLE_PATHS = [
|
||||
"agents.providers",
|
||||
"agents.catalogRefreshTimeoutMs",
|
||||
"agents.metadataGeneration",
|
||||
"agents.skills.selection",
|
||||
"pluginsEnabled",
|
||||
] as const;
|
||||
|
||||
@@ -207,6 +210,7 @@ const PERSISTED_TO_MUTABLE_PATH = new Map<string, string>([
|
||||
["agents.providers", "providers"],
|
||||
["agents.catalogRefreshTimeoutMs", "catalogRefreshTimeoutMs"],
|
||||
["agents.metadataGeneration", "metadataGeneration"],
|
||||
["agents.skills.selection", "skills.selection"],
|
||||
["pluginsEnabled", "pluginsEnabled"],
|
||||
]);
|
||||
|
||||
@@ -334,6 +338,14 @@ export class DaemonConfigStore {
|
||||
|
||||
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
|
||||
const parsedPatch = pickSupportedPatchFields(MutableDaemonConfigPatchSchema.parse(partial));
|
||||
return this.applySupportedPatch(parsedPatch);
|
||||
}
|
||||
|
||||
public setAgentSkillSelection(selection: AgentSkillSelection): MutableDaemonConfig {
|
||||
return this.applySupportedPatch({ skills: { selection } });
|
||||
}
|
||||
|
||||
private applySupportedPatch(parsedPatch: SupportedMutableConfigPatch): MutableDaemonConfig {
|
||||
if (parsedPatch.relay?.enabled !== undefined && !this.relayEnabledMutable) {
|
||||
throw new Error(
|
||||
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
|
||||
@@ -342,6 +354,9 @@ export class DaemonConfigStore {
|
||||
const { removeProviders = [], ...configPatch } = parsedPatch;
|
||||
const removedProviders = Array.from(new Set(removeProviders));
|
||||
const merged = deepMerge(this.current, configPatch);
|
||||
if (parsedPatch.skills?.selection !== undefined) {
|
||||
merged.skills = { selection: parsedPatch.skills.selection };
|
||||
}
|
||||
if (parsedPatch.plugins !== undefined) merged.plugins = parsedPatch.plugins;
|
||||
const next = MutableDaemonConfigSchema.parse(
|
||||
omitMetadataGenerationProvidersFromConfig(
|
||||
@@ -576,6 +591,7 @@ function mergeMutableAgentPatch(
|
||||
if (
|
||||
patch.providers === undefined &&
|
||||
patch.metadataGeneration === undefined &&
|
||||
patch.skills === undefined &&
|
||||
removeProviders.length === 0
|
||||
) {
|
||||
return persistedAgents;
|
||||
@@ -604,6 +620,10 @@ function mergeMutableAgentPatch(
|
||||
};
|
||||
}
|
||||
|
||||
if (patch.skills?.selection !== undefined) {
|
||||
next["skills"] = { selection: patch.skills.selection };
|
||||
}
|
||||
|
||||
return Object.keys(next).length > 0 ? (next as PersistedConfig["agents"]) : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
|
||||
|
||||
import type { DaemonConfigStore } from "../daemon-config-store.js";
|
||||
import {
|
||||
createSkillsController,
|
||||
type SkillsController,
|
||||
type SkillsSaveResult,
|
||||
type SkillsSnapshot,
|
||||
} from "./internal/controller.js";
|
||||
import { resolveSkillTargets } from "./internal/paths.js";
|
||||
import { createSkillSelectionStore } from "./internal/selection-store.js";
|
||||
|
||||
export interface OrchestrationSkills {
|
||||
getStatus(): Promise<SkillsSnapshot>;
|
||||
reconcile(): Promise<SkillsSnapshot>;
|
||||
uninstall(): Promise<SkillsSnapshot>;
|
||||
saveSelection(
|
||||
selection: AgentSkillSelection,
|
||||
confirmedRemovals?: readonly string[],
|
||||
): Promise<SkillsSaveResult>;
|
||||
importLegacySelectionIfUnset(selection: AgentSkillSelection): Promise<{
|
||||
imported: boolean;
|
||||
selection: AgentSkillSelection;
|
||||
}>;
|
||||
autoUpdate(): Promise<SkillsSnapshot>;
|
||||
}
|
||||
|
||||
export function createOrchestrationSkills(
|
||||
configStore: DaemonConfigStore,
|
||||
resolveTargets = resolveSkillTargets,
|
||||
): OrchestrationSkills {
|
||||
const controller: SkillsController = createSkillsController({
|
||||
resolveTargets,
|
||||
selectionStore: createSkillSelectionStore(configStore),
|
||||
});
|
||||
return {
|
||||
getStatus: () => controller.status(),
|
||||
reconcile: () => controller.update(),
|
||||
uninstall: () => controller.uninstall(),
|
||||
saveSelection: (selection, confirmedRemovals = []) =>
|
||||
controller.save({ ...selection, confirmedRemovals }),
|
||||
importLegacySelectionIfUnset: (selection) => controller.importLegacySelectionIfUnset(selection),
|
||||
autoUpdate: () => controller.autoUpdate(),
|
||||
};
|
||||
}
|
||||
|
||||
export type { SkillsSaveResult, SkillsSnapshot } from "./internal/controller.js";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
autoUpdateInstalledSkills,
|
||||
getSkillsStatus,
|
||||
installSkills,
|
||||
type SkillSelection,
|
||||
type SkillsStatus,
|
||||
type SkillTargets,
|
||||
uninstallSkills,
|
||||
updateSkills,
|
||||
} from "./operations.js";
|
||||
import {
|
||||
coerceSkillNames,
|
||||
coerceSkillSelection,
|
||||
type SkillSelectionStore,
|
||||
} from "./selection-store.js";
|
||||
import { beginSkillsTransaction, recoverInterruptedSkillTransactions } from "./transaction.js";
|
||||
|
||||
/** Everything the settings UI needs to render skills in one round trip. */
|
||||
export interface SkillsSnapshot extends SkillsStatus {
|
||||
selection: SkillSelection;
|
||||
}
|
||||
|
||||
export interface SkillsSaveResult extends SkillsSnapshot {
|
||||
/**
|
||||
* Non-null when the save did nothing because these directories would be
|
||||
* deleted and the request had not confirmed them. Retry with the names.
|
||||
*/
|
||||
confirmationRequired: { removals: string[] } | null;
|
||||
}
|
||||
|
||||
export interface SkillsController {
|
||||
status(): Promise<SkillsSnapshot>;
|
||||
install(): Promise<SkillsSnapshot>;
|
||||
update(): Promise<SkillsSnapshot>;
|
||||
uninstall(): Promise<SkillsSnapshot>;
|
||||
autoUpdate(): Promise<SkillsSnapshot>;
|
||||
save(request: unknown): Promise<SkillsSaveResult>;
|
||||
importLegacySelectionIfUnset(selection: unknown): Promise<{
|
||||
imported: boolean;
|
||||
selection: SkillSelection;
|
||||
}>;
|
||||
}
|
||||
|
||||
type Converge = (targets: SkillTargets, selection: SkillSelection) => Promise<SkillsStatus>;
|
||||
const MAX_SAVE_ATTEMPTS = 3;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The single writer for installed skills. Startup convergence and every settings
|
||||
* command run through one queue, so no operation applies a plan computed from a
|
||||
* selection another one has already replaced, and no read sees a half-applied
|
||||
* directory. Ordering between concurrent callers is not promised — consistency
|
||||
* after both finish is.
|
||||
*/
|
||||
export function createSkillsController({
|
||||
resolveTargets,
|
||||
selectionStore,
|
||||
}: {
|
||||
// Resolved per operation, not at wiring time: the bundle path depends on how
|
||||
// the app was packaged, which is not knowable when the controller is built.
|
||||
resolveTargets: () => SkillTargets;
|
||||
selectionStore: SkillSelectionStore;
|
||||
}): SkillsController {
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
function serialize<T>(run: () => Promise<T>): Promise<T> {
|
||||
const next = queue.then(run, run);
|
||||
queue = next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
async function converge(apply: Converge): Promise<SkillsSnapshot> {
|
||||
const selection = await selectionStore.get();
|
||||
await recoverInterruptedSkillTransactions(resolveTargets(), selection);
|
||||
return { ...(await apply(resolveTargets(), selection)), selection };
|
||||
}
|
||||
|
||||
async function saveSelection(request: unknown): Promise<SkillsSaveResult> {
|
||||
const targets = resolveTargets();
|
||||
const next = coerceSkillSelection(request);
|
||||
const previous = await selectionStore.get();
|
||||
await recoverInterruptedSkillTransactions(targets, previous);
|
||||
const confirmed = new Set(
|
||||
coerceSkillNames(isRecord(request) ? request.confirmedRemovals : null),
|
||||
);
|
||||
|
||||
// The plan comes from a scan taken here, inside the queue, and is the exact
|
||||
// plan applied below. Deciding what will be deleted from an older snapshot
|
||||
// leaves a window for a directory to appear and be deleted unannounced.
|
||||
let plan = await getSkillsStatus(targets, next);
|
||||
for (let attempt = 0; attempt < MAX_SAVE_ATTEMPTS; attempt += 1) {
|
||||
const removals = plan.ops.filter((op) => op.kind === "delete").map((op) => op.name);
|
||||
if (removals.some((name) => !confirmed.has(name))) {
|
||||
return {
|
||||
...(await getSkillsStatus(targets, previous)),
|
||||
selection: previous,
|
||||
confirmationRequired: { removals },
|
||||
};
|
||||
}
|
||||
|
||||
// Capture only paths this frozen plan can mutate. Capturing every managed
|
||||
// path would make rollback delete a new external directory that appeared
|
||||
// after the scan merely because it was absent from the snapshot.
|
||||
const transaction = await beginSkillsTransaction(targets, previous, next, plan.ops);
|
||||
let status: SkillsStatus;
|
||||
try {
|
||||
status = await installSkills(targets, next, {
|
||||
...plan,
|
||||
// beginSkillsTransaction atomically staged these directories. Running
|
||||
// removeSkill afterward would delete a path another writer recreated.
|
||||
ops: plan.ops.filter((op) => op.kind !== "delete"),
|
||||
});
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
|
||||
// An empty custom selection converges to the intentional not-installed
|
||||
// state; zero remaining operations is the invariant, not the label.
|
||||
if (status.ops.length === 0) {
|
||||
try {
|
||||
await selectionStore.set(next);
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await transaction.commit();
|
||||
} catch {
|
||||
// Selection and disk convergence are already durable. The owned
|
||||
// manifest stays behind so the next serialized operation retries
|
||||
// cleanup; reporting this save as failed would misstate its outcome.
|
||||
}
|
||||
return { ...status, selection: next, confirmationRequired: null };
|
||||
}
|
||||
|
||||
// The frozen plan became incomplete while it was being applied. Put only
|
||||
// its mutations back, rescan, and either request confirmation for a new
|
||||
// deletion or retry harmless additions/updates from the fresh plan.
|
||||
await transaction.rollback();
|
||||
plan = await getSkillsStatus(targets, next);
|
||||
}
|
||||
|
||||
throw new Error("Skills changed repeatedly while the selection was being saved");
|
||||
}
|
||||
|
||||
async function importLegacySelectionIfUnset(selection: unknown) {
|
||||
if (await selectionStore.isSet()) {
|
||||
return { imported: false, selection: await selectionStore.get() };
|
||||
}
|
||||
const imported = await selectionStore.set(selection);
|
||||
return { imported: true, selection: imported };
|
||||
}
|
||||
|
||||
return {
|
||||
status: () => serialize(() => converge(getSkillsStatus)),
|
||||
install: () => serialize(() => converge(updateSkills)),
|
||||
update: () => serialize(() => converge(updateSkills)),
|
||||
uninstall: () => serialize(() => converge(uninstallSkills)),
|
||||
autoUpdate: () => serialize(() => converge(autoUpdateInstalledSkills)),
|
||||
save: (selection) => serialize(() => saveSelection(selection)),
|
||||
importLegacySelectionIfUnset: (selection) =>
|
||||
serialize(() => importLegacySelectionIfUnset(selection)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
autoUpdateInstalledSkills,
|
||||
getSkillsStatus,
|
||||
installSkills,
|
||||
LEGACY_SKILL_NAMES,
|
||||
type SkillSelection,
|
||||
type SkillTargets,
|
||||
uninstallSkills,
|
||||
updateSkills,
|
||||
} from "./operations";
|
||||
|
||||
const ALL_SKILLS: SkillSelection = { mode: "all" };
|
||||
|
||||
function only(...skills: string[]): SkillSelection {
|
||||
return { mode: "custom", skills };
|
||||
}
|
||||
|
||||
interface Sandbox {
|
||||
root: string;
|
||||
targets: SkillTargets;
|
||||
}
|
||||
|
||||
async function makeSandbox(): Promise<Sandbox> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-skills-"));
|
||||
const targets: SkillTargets = {
|
||||
sourceDir: path.join(root, "bundle"),
|
||||
agentsDir: path.join(root, "home", ".agents", "skills"),
|
||||
claudeDir: path.join(root, "home", ".claude", "skills"),
|
||||
codexDir: path.join(root, "home", ".codex", "skills"),
|
||||
};
|
||||
await fs.mkdir(targets.sourceDir, { recursive: true });
|
||||
return { root, targets };
|
||||
}
|
||||
|
||||
async function writeFiles(rootDir: string, files: Record<string, string>): Promise<void> {
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = path.join(rootDir, rel);
|
||||
await fs.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.writeFile(full, content);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeBundleSkill(
|
||||
sourceDir: string,
|
||||
name: string,
|
||||
files: Record<string, string>,
|
||||
): Promise<void> {
|
||||
await writeFiles(path.join(sourceDir, name), files);
|
||||
}
|
||||
|
||||
async function writeOnDiskSkill(
|
||||
agentsDir: string,
|
||||
name: string,
|
||||
files: Record<string, string>,
|
||||
): Promise<void> {
|
||||
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" });
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
return fs
|
||||
.access(p)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async function installedIn(targets: SkillTargets, name: string): Promise<boolean[]> {
|
||||
return Promise.all(
|
||||
[targets.agentsDir, targets.claudeDir, targets.codexDir].map((dir) =>
|
||||
pathExists(path.join(dir, name)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("getSkillsStatus", () => {
|
||||
let sandbox: Sandbox;
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = await makeSandbox();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns not-installed with add ops for every bundled skill when nothing is on disk", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
expect(status.state).toBe("not-installed");
|
||||
expect(status.ops).toEqual([
|
||||
{ kind: "add", name: "paseo" },
|
||||
{ kind: "add", name: "paseo-loop" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports every bundled skill as available", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeBundleSkill(sandbox.targets.sourceDir, "paseo-advisor", {
|
||||
"SKILL.md": "advisor-v1",
|
||||
});
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets, only("paseo"));
|
||||
|
||||
expect(status.available).toEqual(["paseo", "paseo-advisor", "paseo-loop"]);
|
||||
});
|
||||
|
||||
it("reports a skill present in only one target as installed", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "paseo-v1" });
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
// `add` means "missing from at least one target", so it cannot answer
|
||||
// "is there a directory here to delete". `installed` answers that.
|
||||
expect(status.installed).toEqual(["paseo"]);
|
||||
expect(status.ops).toEqual([
|
||||
{ kind: "add", name: "paseo" },
|
||||
{ kind: "add", name: "paseo-loop" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports legacy skill directories left on disk as installed", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
|
||||
expect((await getSkillsStatus(sandbox.targets, ALL_SKILLS)).installed).toEqual(["paseo-chat"]);
|
||||
});
|
||||
|
||||
it("returns not-installed when only user-personal skill dirs exist (the live bug)", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
for (const name of ["unslop", "tdd", "devbox"]) {
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
|
||||
}
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
expect(status.state).toBe("not-installed");
|
||||
expect(status.ops).toEqual([
|
||||
{ kind: "add", name: "paseo" },
|
||||
{ kind: "add", name: "paseo-loop" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns up-to-date when every bundled skill matches on disk", 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" });
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
expect(status).toEqual({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
available: ["paseo", "paseo-loop"],
|
||||
installed: ["paseo", "paseo-loop"],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores user-added files inside current managed skill dirs in every target", 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",
|
||||
});
|
||||
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", {
|
||||
"SKILL.md": "paseo-v1",
|
||||
"commands/local.md": "user command",
|
||||
});
|
||||
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", {
|
||||
"SKILL.md": "paseo-v1",
|
||||
"hooks/guard.sh": "user guard",
|
||||
});
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
expect(status).toEqual({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
available: ["paseo", "paseo-loop"],
|
||||
installed: ["paseo", "paseo-loop"],
|
||||
});
|
||||
});
|
||||
|
||||
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, ALL_SKILLS);
|
||||
|
||||
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, ALL_SKILLS);
|
||||
|
||||
expect(status.state).toBe("drift");
|
||||
expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]);
|
||||
});
|
||||
|
||||
it("returns drift with add ops for the bundled skills missing from disk", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
expect(status.state).toBe("drift");
|
||||
expect(status.ops).toEqual([{ kind: "add", name: "paseo-loop" }]);
|
||||
});
|
||||
|
||||
it("returns drift with a delete op for a legacy skill name still on disk", 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-chat", { "SKILL.md": "chat-old" });
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
expect(status.state).toBe("drift");
|
||||
expect(status.ops).toEqual([{ kind: "delete", name: "paseo-chat" }]);
|
||||
});
|
||||
|
||||
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, ALL_SKILLS);
|
||||
|
||||
expect(status.state).toBe("drift");
|
||||
expect(status.ops).toEqual([
|
||||
{ kind: "update", name: "paseo" },
|
||||
{ kind: "delete", name: "paseo-chat" },
|
||||
{ kind: "add", name: "paseo-loop" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom skill selection", () => {
|
||||
let sandbox: Sandbox;
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = await makeSandbox();
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeBundleSkill(sandbox.targets.sourceDir, "paseo-advisor", {
|
||||
"SKILL.md": "advisor-v1",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("installs only the selected skills", async () => {
|
||||
const status = await installSkills(sandbox.targets, only("paseo", "paseo-loop"));
|
||||
|
||||
expect(status).toEqual({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
available: ["paseo", "paseo-advisor", "paseo-loop"],
|
||||
installed: ["paseo", "paseo-loop"],
|
||||
});
|
||||
expect(await installedIn(sandbox.targets, "paseo")).toEqual([true, true, true]);
|
||||
expect(await installedIn(sandbox.targets, "paseo-loop")).toEqual([true, true, true]);
|
||||
expect(await installedIn(sandbox.targets, "paseo-advisor")).toEqual([false, false, false]);
|
||||
});
|
||||
|
||||
it("reports up-to-date while an unselected bundled skill is absent", async () => {
|
||||
await installSkills(sandbox.targets, only("paseo"));
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets, only("paseo"));
|
||||
|
||||
expect(status).toEqual({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
available: ["paseo", "paseo-advisor", "paseo-loop"],
|
||||
installed: ["paseo"],
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a previously installed skill once it leaves the selection", async () => {
|
||||
await installSkills(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
const status = await installSkills(sandbox.targets, only("paseo"));
|
||||
|
||||
expect(status.state).toBe("up-to-date");
|
||||
expect(await installedIn(sandbox.targets, "paseo")).toEqual([true, true, true]);
|
||||
expect(await installedIn(sandbox.targets, "paseo-loop")).toEqual([false, false, false]);
|
||||
expect(await installedIn(sandbox.targets, "paseo-advisor")).toEqual([false, false, false]);
|
||||
});
|
||||
|
||||
it("reports a delete op for a deselected skill before it is applied", async () => {
|
||||
await installSkills(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
const status = await getSkillsStatus(sandbox.targets, only("paseo", "paseo-advisor"));
|
||||
|
||||
expect(status.state).toBe("drift");
|
||||
expect(status.ops).toEqual([{ kind: "delete", name: "paseo-loop" }]);
|
||||
});
|
||||
|
||||
it("leaves unrelated user skills untouched", async () => {
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "unslop", { "SKILL.md": "user-unslop" });
|
||||
|
||||
await installSkills(sandbox.targets, only("paseo"));
|
||||
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, "unslop", "SKILL.md"), "utf-8"),
|
||||
).toBe("user-unslop");
|
||||
});
|
||||
|
||||
it("treats an empty custom selection as no managed skills installed", async () => {
|
||||
await installSkills(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
const status = await installSkills(sandbox.targets, only());
|
||||
|
||||
expect(status).toEqual({
|
||||
state: "not-installed",
|
||||
ops: [],
|
||||
available: ["paseo", "paseo-advisor", "paseo-loop"],
|
||||
installed: [],
|
||||
});
|
||||
expect(await installedIn(sandbox.targets, "paseo")).toEqual([false, false, false]);
|
||||
expect(await installedIn(sandbox.targets, "paseo-loop")).toEqual([false, false, false]);
|
||||
});
|
||||
|
||||
it("ignores selected names that the bundle does not ship", async () => {
|
||||
const status = await installSkills(sandbox.targets, only("paseo", "not-a-skill"));
|
||||
|
||||
expect(status).toEqual({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
available: ["paseo", "paseo-advisor", "paseo-loop"],
|
||||
installed: ["paseo"],
|
||||
});
|
||||
expect(await installedIn(sandbox.targets, "not-a-skill")).toEqual([false, false, false]);
|
||||
});
|
||||
|
||||
it("still deletes legacy skill names that are not selectable", async () => {
|
||||
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-orchestrator", {
|
||||
"SKILL.md": "orchestrator-old",
|
||||
});
|
||||
|
||||
await installSkills(sandbox.targets, only("paseo"));
|
||||
|
||||
expect(await installedIn(sandbox.targets, "paseo-orchestrator")).toEqual([false, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installSkills / updateSkills", () => {
|
||||
let sandbox: Sandbox;
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = await makeSandbox();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("installs from a clean machine, populates all three targets, and leaves user dirs alone", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "unslop", { "SKILL.md": "user-unslop" });
|
||||
|
||||
const status = await installSkills(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
expect(status).toEqual({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
available: ["paseo", "paseo-loop"],
|
||||
installed: ["paseo", "paseo-loop"],
|
||||
});
|
||||
for (const name of ["paseo", "paseo-loop"]) {
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
|
||||
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.codexDir, name, "SKILL.md"), "utf-8"),
|
||||
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
|
||||
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(true);
|
||||
}
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, "unslop", "SKILL.md"), "utf-8"),
|
||||
).toBe("user-unslop");
|
||||
});
|
||||
|
||||
it("repairs missing and edited skills without deleting a legacy directory", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
|
||||
const status = await updateSkills(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
expect(status).toEqual({
|
||||
state: "drift",
|
||||
ops: [{ kind: "delete", name: "paseo-chat" }],
|
||||
available: ["paseo", "paseo-loop"],
|
||||
installed: ["paseo", "paseo-chat", "paseo-loop"],
|
||||
});
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"),
|
||||
).toBe("paseo-v1");
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo-loop", "SKILL.md"), "utf-8"),
|
||||
).toBe("loop-v1");
|
||||
for (const dir of [
|
||||
sandbox.targets.agentsDir,
|
||||
sandbox.targets.claudeDir,
|
||||
sandbox.targets.codexDir,
|
||||
]) {
|
||||
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("defines updated as the state reached after preserving user files", 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": "stale",
|
||||
"hooks/guard.sh": "user guard",
|
||||
});
|
||||
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", {
|
||||
"SKILL.md": "paseo-v1",
|
||||
"notes/local.md": "claude notes",
|
||||
});
|
||||
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", {
|
||||
"SKILL.md": "paseo-v1",
|
||||
"prompts/local.md": "codex prompt",
|
||||
});
|
||||
|
||||
const status = await updateSkills(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
expect(status.state).toBe("up-to-date");
|
||||
expect(await getSkillsStatus(sandbox.targets, ALL_SKILLS)).toEqual({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
available: ["paseo", "paseo-loop"],
|
||||
installed: ["paseo", "paseo-loop"],
|
||||
});
|
||||
expect(
|
||||
await fs.readFile(
|
||||
path.join(sandbox.targets.agentsDir, "paseo", "hooks", "guard.sh"),
|
||||
"utf-8",
|
||||
),
|
||||
).toBe("user guard");
|
||||
expect(
|
||||
await fs.readFile(
|
||||
path.join(sandbox.targets.claudeDir, "paseo", "notes", "local.md"),
|
||||
"utf-8",
|
||||
),
|
||||
).toBe("claude notes");
|
||||
expect(
|
||||
await fs.readFile(
|
||||
path.join(sandbox.targets.codexDir, "paseo", "prompts", "local.md"),
|
||||
"utf-8",
|
||||
),
|
||||
).toBe("codex prompt");
|
||||
});
|
||||
|
||||
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, ALL_SKILLS);
|
||||
|
||||
expect(status.state).toBe("up-to-date");
|
||||
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",
|
||||
"hooks/guard.sh": "user guard",
|
||||
});
|
||||
|
||||
const status = await autoUpdateInstalledSkills(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
expect(status.state).toBe("up-to-date");
|
||||
expect((await getSkillsStatus(sandbox.targets, ALL_SKILLS)).state).toBe("up-to-date");
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"),
|
||||
).toBe("paseo-v1");
|
||||
expect(
|
||||
await fs.readFile(
|
||||
path.join(sandbox.targets.agentsDir, "paseo", "hooks", "guard.sh"),
|
||||
"utf-8",
|
||||
),
|
||||
).toBe("user guard");
|
||||
});
|
||||
|
||||
it("does not auto-install skills on a clean machine", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
|
||||
const status = await autoUpdateInstalledSkills(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
expect(status).toEqual({
|
||||
state: "not-installed",
|
||||
ops: [
|
||||
{ kind: "add", name: "paseo" },
|
||||
{ kind: "add", name: "paseo-loop" },
|
||||
],
|
||||
available: ["paseo", "paseo-loop"],
|
||||
installed: [],
|
||||
});
|
||||
expect(await installedIn(sandbox.targets, "paseo")).toEqual([false, false, false]);
|
||||
});
|
||||
|
||||
it("is idempotent — running install twice keeps state at up-to-date", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
|
||||
const first = await installSkills(sandbox.targets, ALL_SKILLS);
|
||||
const second = await installSkills(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
expect(first.state).toBe("up-to-date");
|
||||
expect(second.state).toBe("up-to-date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("uninstallSkills", () => {
|
||||
let sandbox: Sandbox;
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = await makeSandbox();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("removes every Paseo skill from all three targets and preserves user dirs", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
await installSkills(sandbox.targets, ALL_SKILLS);
|
||||
for (const name of ["unslop", "tdd", "devbox"]) {
|
||||
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
|
||||
}
|
||||
|
||||
const status = await uninstallSkills(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
expect(status.state).toBe("not-installed");
|
||||
for (const name of ["paseo", "paseo-loop", ...LEGACY_SKILL_NAMES]) {
|
||||
expect(await installedIn(sandbox.targets, name)).toEqual([false, false, false]);
|
||||
}
|
||||
for (const name of ["unslop", "tdd", "devbox"]) {
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
|
||||
).toBe(`user-${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("is a no-op when nothing is installed", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
|
||||
const status = await uninstallSkills(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
expect(status.state).toBe("not-installed");
|
||||
});
|
||||
|
||||
it("cleans up legacy skill names that linger in agents, claude, and codex", async () => {
|
||||
await writeCurrentBundle(sandbox.targets.sourceDir);
|
||||
for (const dir of [
|
||||
sandbox.targets.agentsDir,
|
||||
sandbox.targets.claudeDir,
|
||||
sandbox.targets.codexDir,
|
||||
]) {
|
||||
await writeOnDiskSkill(dir, "paseo-chat", { "SKILL.md": "chat-old" });
|
||||
}
|
||||
|
||||
const status = await uninstallSkills(sandbox.targets, ALL_SKILLS);
|
||||
|
||||
expect(status.state).toBe("not-installed");
|
||||
expect(await installedIn(sandbox.targets, "paseo-chat")).toEqual([false, false, false]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
|
||||
import { listFilesRecursive, removeSkill, syncSkills } from "./sync.js";
|
||||
|
||||
export type SkillsState = "not-installed" | "up-to-date" | "drift";
|
||||
|
||||
export type SkillOp =
|
||||
| { kind: "add"; name: string }
|
||||
| { kind: "update"; name: string }
|
||||
| { kind: "delete"; name: string };
|
||||
|
||||
/** What the user asked to have installed. `all` follows the bundle as it grows. */
|
||||
export type SkillSelection = AgentSkillSelection;
|
||||
|
||||
export interface SkillsStatus {
|
||||
state: SkillsState;
|
||||
ops: SkillOp[];
|
||||
/** Every skill the bundle currently ships, sorted. The selectable catalog. */
|
||||
available: string[];
|
||||
/**
|
||||
* Managed skills with a directory in at least one agent home, sorted. An `add`
|
||||
* op means "missing from at least one target", so it cannot answer whether
|
||||
* there is anything on disk to delete — this can.
|
||||
*/
|
||||
installed: string[];
|
||||
}
|
||||
|
||||
export interface SkillTargets {
|
||||
sourceDir: string;
|
||||
agentsDir: string;
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
}
|
||||
|
||||
// Names the bundle used to ship. They are never selectable, but every scan still
|
||||
// covers them so an older install's copies get cleaned up.
|
||||
export const LEGACY_SKILL_NAMES = [
|
||||
"paseo-chat",
|
||||
"paseo-epic",
|
||||
"paseo-orchestrate",
|
||||
"paseo-orchestrator",
|
||||
] as const;
|
||||
|
||||
type SkillFiles = Map<string, string>;
|
||||
type TargetSkills = Map<string, SkillFiles>;
|
||||
|
||||
/**
|
||||
* The bundle directory is the catalog. Reading it instead of a hardcoded list is
|
||||
* what makes `all` pick up skills added in a later release with no code change.
|
||||
*/
|
||||
async function listBundledSkills(sourceDir: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(sourceDir, { withFileTypes: true }).catch(() => []);
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort(compareStrings);
|
||||
}
|
||||
|
||||
/** Every name Paseo owns on disk: what it ships now plus what it used to ship. */
|
||||
function managedSkillNames(available: readonly string[]): string[] {
|
||||
return [...new Set([...available, ...LEGACY_SKILL_NAMES])].sort(compareStrings);
|
||||
}
|
||||
|
||||
/** The names a convergence may create, replace, or delete. */
|
||||
export async function listManagedSkillNames(sourceDir: string): Promise<string[]> {
|
||||
return managedSkillNames(await listBundledSkills(sourceDir));
|
||||
}
|
||||
|
||||
function resolveDesiredSkills(
|
||||
selection: SkillSelection,
|
||||
available: readonly string[],
|
||||
): Set<string> {
|
||||
if (selection.mode === "all") return new Set(available);
|
||||
const chosen = new Set(selection.skills);
|
||||
return new Set(available.filter((name) => chosen.has(name)));
|
||||
}
|
||||
|
||||
async function hashSkillDir(skillDir: string): Promise<SkillFiles | null> {
|
||||
const stat = await fs.stat(skillDir).catch(() => null);
|
||||
if (!stat?.isDirectory()) return null;
|
||||
|
||||
const rels = await listFilesRecursive(skillDir);
|
||||
const files: SkillFiles = new Map();
|
||||
for (const rel of rels) {
|
||||
const buf = await fs.readFile(path.join(skillDir, rel));
|
||||
const sha = createHash("sha256").update(buf).digest("hex");
|
||||
files.set(toPosix(rel), sha);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function hashSkills(rootDir: string, names: readonly string[]): Promise<TargetSkills> {
|
||||
const out: TargetSkills = new Map();
|
||||
for (const name of names) {
|
||||
const files = await hashSkillDir(path.join(rootDir, name));
|
||||
if (files !== null) out.set(name, files);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function diff(
|
||||
bundle: TargetSkills,
|
||||
disks: readonly TargetSkills[],
|
||||
names: readonly string[],
|
||||
desired: ReadonlySet<string>,
|
||||
): SkillOp[] {
|
||||
const ops: SkillOp[] = [];
|
||||
for (const name of names) {
|
||||
const b = desired.has(name) ? bundle.get(name) : undefined;
|
||||
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 hasInstalledPaseoSkill(disks: readonly TargetSkills[]): boolean {
|
||||
return disks.some((disk) => disk.size > 0);
|
||||
}
|
||||
|
||||
function installedSkillNames(disks: readonly TargetSkills[], names: readonly string[]): string[] {
|
||||
return names.filter((name) => disks.some((disk) => disk.has(name)));
|
||||
}
|
||||
|
||||
function bundleFilesMatch(bundle: SkillFiles, disk: SkillFiles): boolean {
|
||||
for (const [rel, sha] of bundle) {
|
||||
if (disk.get(rel) !== sha) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function toPosix(p: string): string {
|
||||
return p.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function compareStrings(a: string, b: string): number {
|
||||
if (a < b) return -1;
|
||||
if (a > b) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function getSkillsStatus(
|
||||
targets: SkillTargets,
|
||||
selection: SkillSelection,
|
||||
): Promise<SkillsStatus> {
|
||||
const available = await listBundledSkills(targets.sourceDir);
|
||||
const names = managedSkillNames(available);
|
||||
const [bundle, agentsDisk, claudeDisk, codexDisk] = await Promise.all([
|
||||
hashSkills(targets.sourceDir, available),
|
||||
hashSkills(targets.agentsDir, names),
|
||||
hashSkills(targets.claudeDir, names),
|
||||
hashSkills(targets.codexDir, names),
|
||||
]);
|
||||
const disks = [agentsDisk, claudeDisk, codexDisk];
|
||||
const ops = diff(bundle, disks, names, resolveDesiredSkills(selection, available));
|
||||
const installed = installedSkillNames(disks, names);
|
||||
|
||||
if (!hasInstalledPaseoSkill(disks)) return { state: "not-installed", ops, available, installed };
|
||||
if (ops.length === 0) return { state: "up-to-date", ops, available, installed };
|
||||
return { state: "drift", ops, available, installed };
|
||||
}
|
||||
|
||||
async function applySkills(
|
||||
targets: SkillTargets,
|
||||
selection: SkillSelection,
|
||||
initialStatus?: SkillsStatus,
|
||||
): Promise<SkillsStatus> {
|
||||
const status = initialStatus ?? (await getSkillsStatus(targets, selection));
|
||||
|
||||
const writes = status.ops
|
||||
.filter((op) => op.kind === "add" || op.kind === "update")
|
||||
.map((op) => op.name);
|
||||
if (writes.length > 0) {
|
||||
await syncSkills({
|
||||
sourceDir: targets.sourceDir,
|
||||
agentsDir: targets.agentsDir,
|
||||
claudeDir: targets.claudeDir,
|
||||
codexDir: targets.codexDir,
|
||||
skillNames: writes,
|
||||
});
|
||||
}
|
||||
|
||||
for (const op of status.ops) {
|
||||
if (op.kind !== "delete") continue;
|
||||
await removeSkill(op.name, {
|
||||
agentsDir: targets.agentsDir,
|
||||
claudeDir: targets.claudeDir,
|
||||
codexDir: targets.codexDir,
|
||||
});
|
||||
}
|
||||
|
||||
return getSkillsStatus(targets, selection);
|
||||
}
|
||||
|
||||
export async function installSkills(
|
||||
targets: SkillTargets,
|
||||
selection: SkillSelection,
|
||||
/** Apply exactly this plan instead of rescanning, so a confirmed plan is the applied plan. */
|
||||
plan?: SkillsStatus,
|
||||
): Promise<SkillsStatus> {
|
||||
return applySkills(targets, selection, plan);
|
||||
}
|
||||
|
||||
export async function updateSkills(
|
||||
targets: SkillTargets,
|
||||
selection: SkillSelection,
|
||||
): Promise<SkillsStatus> {
|
||||
const status = await getSkillsStatus(targets, selection);
|
||||
return applySkills(targets, selection, nonDestructivePlan(status));
|
||||
}
|
||||
|
||||
function nonDestructivePlan(status: SkillsStatus): SkillsStatus {
|
||||
return { ...status, ops: status.ops.filter((op) => op.kind !== "delete") };
|
||||
}
|
||||
|
||||
export async function autoUpdateInstalledSkills(
|
||||
targets: SkillTargets,
|
||||
selection: SkillSelection,
|
||||
): Promise<SkillsStatus> {
|
||||
const status = await getSkillsStatus(targets, selection);
|
||||
if (status.state !== "drift") return status;
|
||||
// Automatic maintenance may repair selected skills, but removal is an
|
||||
// interactive operation because managed directories can contain user files.
|
||||
return applySkills(targets, selection, nonDestructivePlan(status));
|
||||
}
|
||||
|
||||
export async function uninstallSkills(
|
||||
targets: SkillTargets,
|
||||
selection: SkillSelection,
|
||||
): Promise<SkillsStatus> {
|
||||
const available = await listBundledSkills(targets.sourceDir);
|
||||
for (const name of managedSkillNames(available)) {
|
||||
await removeSkill(name, {
|
||||
agentsDir: targets.agentsDir,
|
||||
claudeDir: targets.claudeDir,
|
||||
codexDir: targets.codexDir,
|
||||
});
|
||||
}
|
||||
return getSkillsStatus(targets, selection);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveBundledSkillsDir, resolveSkillTargets } from "./paths.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("orchestration skill paths", () => {
|
||||
it("finds the repository catalog from the source module", () => {
|
||||
const repositoryRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../../..",
|
||||
);
|
||||
|
||||
expect(resolveBundledSkillsDir()).toBe(path.join(repositoryRoot, "skills"));
|
||||
});
|
||||
|
||||
it("finds the catalog beside the actual emitted server layout", async () => {
|
||||
const packageRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-built-skills-"));
|
||||
roots.push(packageRoot);
|
||||
const catalog = path.join(packageRoot, "dist", "server", "skills");
|
||||
await mkdir(catalog, { recursive: true });
|
||||
const emittedModule = path.join(
|
||||
packageRoot,
|
||||
"dist",
|
||||
"server",
|
||||
"server",
|
||||
"orchestration-skills",
|
||||
"internal",
|
||||
"paths.js",
|
||||
);
|
||||
|
||||
expect(resolveBundledSkillsDir(pathToFileURL(emittedModule))).toBe(catalog);
|
||||
});
|
||||
|
||||
it("keeps the original fixed managed directories under the daemon user's home", () => {
|
||||
const home = os.homedir();
|
||||
|
||||
expect(resolveSkillTargets()).toMatchObject({
|
||||
agentsDir: path.join(home, ".agents", "skills"),
|
||||
claudeDir: path.join(home, ".claude", "skills"),
|
||||
codexDir: path.join(home, ".codex", "skills"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { SkillTargets } from "./operations.js";
|
||||
|
||||
export function resolveBundledSkillsDir(moduleUrl: string | URL = import.meta.url): string {
|
||||
const moduleDir = path.dirname(fileURLToPath(moduleUrl));
|
||||
const candidates = [
|
||||
path.resolve(moduleDir, "..", "..", "..", "skills"),
|
||||
path.resolve(moduleDir, "..", "..", "..", "..", "..", "..", "skills"),
|
||||
];
|
||||
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]!;
|
||||
}
|
||||
|
||||
export function resolveSkillTargets(home: string = os.homedir()): SkillTargets {
|
||||
return {
|
||||
sourceDir: resolveBundledSkillsDir(),
|
||||
agentsDir: path.join(home, ".agents", "skills"),
|
||||
claudeDir: path.join(home, ".claude", "skills"),
|
||||
codexDir: path.join(home, ".codex", "skills"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { DaemonConfigStore, type MutableDaemonConfigPatch } from "../../daemon-config-store";
|
||||
import { loadPersistedConfig } from "../../persisted-config";
|
||||
import { createSkillSelectionStore } from "./selection-store";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function createStore() {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-skills-config-"));
|
||||
roots.push(root);
|
||||
const config = new DaemonConfigStore(root, {
|
||||
mcp: { injectIntoAgents: false },
|
||||
browserTools: { enabled: false },
|
||||
providers: {},
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
enableTerminalAgentHooks: false,
|
||||
appendSystemPrompt: "",
|
||||
});
|
||||
return { config, root, store: createSkillSelectionStore(config) };
|
||||
}
|
||||
|
||||
describe("daemon agent skill selection", () => {
|
||||
it("defaults missing selection to all without persisting it", async () => {
|
||||
const { root, store } = await createStore();
|
||||
expect(await store.get()).toEqual({ mode: "all" });
|
||||
expect(await store.isSet()).toBe(false);
|
||||
expect(loadPersistedConfig(root).agents?.skills).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists a normalized custom selection under agents.skills.selection", async () => {
|
||||
const { root, store } = await createStore();
|
||||
await store.set({ mode: "custom", skills: ["paseo-loop", "paseo", "paseo"] });
|
||||
expect(loadPersistedConfig(root).agents?.skills?.selection).toEqual({
|
||||
mode: "custom",
|
||||
skills: ["paseo", "paseo-loop"],
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces a custom selection with all without retaining custom skill names", async () => {
|
||||
const { config, root, store } = await createStore();
|
||||
await store.set({ mode: "custom", skills: ["paseo"] });
|
||||
|
||||
await store.set({ mode: "all" });
|
||||
|
||||
expect(config.get().skills?.selection).toEqual({ mode: "all" });
|
||||
expect(loadPersistedConfig(root).agents?.skills?.selection).toEqual({ mode: "all" });
|
||||
});
|
||||
|
||||
it("does not expose selection through the generic config patch path", async () => {
|
||||
const { config, root, store } = await createStore();
|
||||
|
||||
config.patch({
|
||||
skills: { selection: { mode: "custom", skills: ["paseo"] } },
|
||||
} as MutableDaemonConfigPatch);
|
||||
|
||||
expect(await store.isSet()).toBe(false);
|
||||
expect(loadPersistedConfig(root).agents?.skills).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
|
||||
|
||||
import type { DaemonConfigStore } from "../../daemon-config-store.js";
|
||||
|
||||
export type SkillSelection = AgentSkillSelection;
|
||||
|
||||
export interface SkillSelectionStore {
|
||||
get(): Promise<SkillSelection>;
|
||||
set(selection: unknown): Promise<SkillSelection>;
|
||||
isSet(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const DEFAULT_SKILL_SELECTION: SkillSelection = { mode: "all" };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function coerceSkillNames(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const names = value
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
return [...new Set(names)].sort();
|
||||
}
|
||||
|
||||
export function coerceSkillSelection(value: unknown): SkillSelection {
|
||||
if (!isRecord(value)) return DEFAULT_SKILL_SELECTION;
|
||||
if (value.mode === "all") return { mode: "all" };
|
||||
if (value.mode === "custom") {
|
||||
return { mode: "custom", skills: coerceSkillNames(value.skills) };
|
||||
}
|
||||
return DEFAULT_SKILL_SELECTION;
|
||||
}
|
||||
|
||||
export function createSkillSelectionStore(
|
||||
configStore: Pick<DaemonConfigStore, "get" | "setAgentSkillSelection">,
|
||||
): SkillSelectionStore {
|
||||
return {
|
||||
async get() {
|
||||
return coerceSkillSelection(configStore.get().skills?.selection);
|
||||
},
|
||||
async set(selection) {
|
||||
const parsed = coerceSkillSelection(selection);
|
||||
configStore.setAgentSkillSelection(parsed);
|
||||
return parsed;
|
||||
},
|
||||
async isSet() {
|
||||
return configStore.get().skills?.selection !== undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { removeSkill, syncSkills } from "./sync";
|
||||
|
||||
interface Sandbox {
|
||||
root: string;
|
||||
sourceDir: string;
|
||||
agentsDir: string;
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
}
|
||||
|
||||
async function makeSandbox(): Promise<Sandbox> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-skill-sync-"));
|
||||
const sourceDir = path.join(root, "bundle");
|
||||
const agentsDir = path.join(root, "home", ".agents", "skills");
|
||||
const claudeDir = path.join(root, "home", ".claude", "skills");
|
||||
const codexDir = path.join(root, "home", ".codex", "skills");
|
||||
await fs.mkdir(sourceDir, { recursive: true });
|
||||
return { root, sourceDir, agentsDir, claudeDir, codexDir };
|
||||
}
|
||||
|
||||
async function writeBundleSkill(
|
||||
sourceDir: string,
|
||||
name: string,
|
||||
files: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const skillDir = path.join(sourceDir, name);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = path.join(skillDir, rel);
|
||||
await fs.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.writeFile(full, content);
|
||||
}
|
||||
}
|
||||
|
||||
describe("syncSkills", () => {
|
||||
let sandbox: Sandbox;
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = await makeSandbox();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("overwrites on-disk skill content when the bundle differs", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", {
|
||||
"SKILL.md": "new paseo content",
|
||||
});
|
||||
const onDiskSkill = path.join(sandbox.agentsDir, "paseo");
|
||||
await fs.mkdir(onDiskSkill, { recursive: true });
|
||||
await fs.writeFile(path.join(onDiskSkill, "SKILL.md"), "old paseo content");
|
||||
|
||||
const result = await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
});
|
||||
|
||||
expect(result.processedSkills).toBe(1);
|
||||
expect(result.changedFiles).toBeGreaterThan(0);
|
||||
const agentsContent = await fs.readFile(
|
||||
path.join(sandbox.agentsDir, "paseo", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(agentsContent).toBe("new paseo content");
|
||||
const claudeContent = await fs.readFile(
|
||||
path.join(sandbox.claudeDir, "paseo", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(claudeContent).toBe("new paseo content");
|
||||
const codexContent = await fs.readFile(
|
||||
path.join(sandbox.codexDir, "paseo", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(codexContent).toBe("new paseo content");
|
||||
});
|
||||
|
||||
it("installs new bundled skills, including references/, when not present on disk", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo-committee", {
|
||||
"SKILL.md": "committee content",
|
||||
"references/roles.md": "roles content",
|
||||
});
|
||||
|
||||
await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo-committee"],
|
||||
});
|
||||
|
||||
expect(
|
||||
await fs.readFile(path.join(sandbox.agentsDir, "paseo-committee", "SKILL.md"), "utf-8"),
|
||||
).toBe("committee content");
|
||||
expect(
|
||||
await fs.readFile(
|
||||
path.join(sandbox.agentsDir, "paseo-committee", "references", "roles.md"),
|
||||
"utf-8",
|
||||
),
|
||||
).toBe("roles content");
|
||||
expect(
|
||||
await fs.readFile(
|
||||
path.join(sandbox.codexDir, "paseo-committee", "references", "roles.md"),
|
||||
"utf-8",
|
||||
),
|
||||
).toBe("roles content");
|
||||
|
||||
const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-committee");
|
||||
expect((await fs.lstat(claudeSkillDir)).isDirectory()).toBe(true);
|
||||
expect(await fs.readFile(path.join(claudeSkillDir, "SKILL.md"), "utf-8")).toBe(
|
||||
"committee content",
|
||||
);
|
||||
expect(await fs.readFile(path.join(claudeSkillDir, "references", "roles.md"), "utf-8")).toBe(
|
||||
"roles content",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves on-disk skills not in the bundle untouched", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" });
|
||||
const customSkill = path.join(sandbox.agentsDir, "user-custom-skill");
|
||||
await fs.mkdir(customSkill, { recursive: true });
|
||||
await fs.writeFile(path.join(customSkill, "SKILL.md"), "user content");
|
||||
|
||||
await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
});
|
||||
|
||||
expect(await fs.readFile(path.join(customSkill, "SKILL.md"), "utf-8")).toBe("user content");
|
||||
});
|
||||
|
||||
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 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,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
});
|
||||
|
||||
expect(await fs.readFile(path.join(onDiskSkill, "SKILL.md"), "utf-8")).toBe("new");
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects managed paths that would write through a symlink",
|
||||
async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", {
|
||||
"SKILL.md": "new skill",
|
||||
"references/guide.md": "new guide",
|
||||
});
|
||||
const onDiskSkill = path.join(sandbox.agentsDir, "paseo");
|
||||
const externalReferences = path.join(sandbox.root, "external-references");
|
||||
await fs.mkdir(onDiskSkill, { recursive: true });
|
||||
await fs.mkdir(externalReferences, { recursive: true });
|
||||
await fs.writeFile(path.join(onDiskSkill, "SKILL.md"), "old skill");
|
||||
await fs.writeFile(path.join(externalReferences, "guide.md"), "external guide");
|
||||
await fs.symlink(externalReferences, path.join(onDiskSkill, "references"), "dir");
|
||||
|
||||
await expect(
|
||||
syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
}),
|
||||
).rejects.toThrow("Cannot sync through symbolic link");
|
||||
|
||||
expect(await fs.readFile(path.join(onDiskSkill, "SKILL.md"), "utf-8")).toBe("old skill");
|
||||
expect(await fs.readFile(path.join(externalReferences, "guide.md"), "utf-8")).toBe(
|
||||
"external guide",
|
||||
);
|
||||
expect((await fs.lstat(path.join(onDiskSkill, "references"))).isSymbolicLink()).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("reports zero changed files on a no-op resync", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", {
|
||||
"SKILL.md": "content",
|
||||
"references/extra.md": "ref",
|
||||
});
|
||||
|
||||
const first = await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
});
|
||||
expect(first.changedFiles).toBeGreaterThan(0);
|
||||
|
||||
const second = await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
});
|
||||
expect(second.changedFiles).toBe(0);
|
||||
});
|
||||
|
||||
it("skips skills listed in skillNames that are missing from the bundle without raising", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content" });
|
||||
// "paseo-removed" is in skillNames but not in the bundle on disk.
|
||||
|
||||
const errors: Array<{ name: string; error: unknown }> = [];
|
||||
const result = await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo", "paseo-removed"],
|
||||
onSkillError: (name, error) => errors.push({ name, error }),
|
||||
});
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(result.processedSkills).toBe(1);
|
||||
expect(await fs.readFile(path.join(sandbox.agentsDir, "paseo", "SKILL.md"), "utf-8")).toBe(
|
||||
"content",
|
||||
);
|
||||
await expect(fs.access(path.join(sandbox.agentsDir, "paseo-removed"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("leaves on-disk skill content alone when the skill has been removed from the bundle", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "current" });
|
||||
const deprecatedDir = path.join(sandbox.agentsDir, "paseo-deprecated");
|
||||
await fs.mkdir(deprecatedDir, { recursive: true });
|
||||
await fs.writeFile(path.join(deprecatedDir, "SKILL.md"), "old content");
|
||||
|
||||
await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo", "paseo-deprecated"],
|
||||
});
|
||||
|
||||
expect(await fs.readFile(path.join(deprecatedDir, "SKILL.md"), "utf-8")).toBe("old content");
|
||||
});
|
||||
|
||||
it("does not crash when the source bundle directory is missing", async () => {
|
||||
const missingSourceDir = path.join(sandbox.root, "no-bundle-here");
|
||||
|
||||
const errors: string[] = [];
|
||||
const result = await syncSkills({
|
||||
sourceDir: missingSourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
onSkillError: (skillName) => errors.push(skillName),
|
||||
});
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(result).toEqual({ changedFiles: 0, processedSkills: 0 });
|
||||
});
|
||||
|
||||
it("reports per-skill errors via onSkillError without throwing", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content" });
|
||||
// Make agents skill path a file (not a directory) to force a write error
|
||||
await fs.mkdir(sandbox.agentsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(sandbox.agentsDir, "paseo"), "blocking file");
|
||||
|
||||
const errors: string[] = [];
|
||||
const result = await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
onSkillError: (skillName) => errors.push(skillName),
|
||||
});
|
||||
|
||||
expect(errors).toContain("paseo");
|
||||
expect(result.processedSkills).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeSkill", () => {
|
||||
let sandbox: Sandbox;
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = await makeSandbox();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("removes the skill from all three targets when present", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content" });
|
||||
await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
});
|
||||
|
||||
await removeSkill("paseo", {
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
});
|
||||
|
||||
await expect(fs.access(path.join(sandbox.agentsDir, "paseo"))).rejects.toThrow();
|
||||
await expect(fs.access(path.join(sandbox.claudeDir, "paseo"))).rejects.toThrow();
|
||||
await expect(fs.access(path.join(sandbox.codexDir, "paseo"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("does not throw when targets are missing", async () => {
|
||||
await expect(
|
||||
removeSkill("does-not-exist", {
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface SkillSyncOptions {
|
||||
sourceDir: string;
|
||||
agentsDir: string;
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
skillNames: readonly string[];
|
||||
onSkillError?: (skillName: string, error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface SkillSyncResult {
|
||||
changedFiles: 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> {
|
||||
const src = await fs.readFile(srcPath);
|
||||
const dst = await fs.readFile(dstPath).catch(() => null);
|
||||
if (dst && src.equals(dst)) return false;
|
||||
await fs.mkdir(path.dirname(dstPath), { recursive: true });
|
||||
await fs.writeFile(dstPath, src);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
async function walk(dir: string): Promise<void> {
|
||||
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(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(rootDir);
|
||||
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 assertManagedPathsStayInsideSkill(rootDir: string, rels: readonly string[]) {
|
||||
for (const rel of rels) {
|
||||
const normalized = path.normalize(rel);
|
||||
if (
|
||||
path.isAbsolute(normalized) ||
|
||||
normalized === ".." ||
|
||||
normalized.startsWith(`..${path.sep}`)
|
||||
) {
|
||||
throw new Error(`Cannot sync managed path outside skill directory: ${rel}`);
|
||||
}
|
||||
let current = rootDir;
|
||||
for (const segment of normalized.split(path.sep)) {
|
||||
current = path.join(current, segment);
|
||||
const info = await fs.lstat(current).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (info === null) break;
|
||||
if (info.isSymbolicLink()) {
|
||||
throw new Error(`Cannot sync through symbolic link: ${current}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
await assertManagedPathsStayInsideSkill(dstDir, [MANAGED_FILES_MANIFEST]);
|
||||
const previousManifest = await readManagedFilesManifest(dstDir);
|
||||
await assertManagedPathsStayInsideSkill(dstDir, [
|
||||
MANAGED_FILES_MANIFEST,
|
||||
...files,
|
||||
...Object.keys(previousManifest?.files ?? {}),
|
||||
]);
|
||||
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;
|
||||
}
|
||||
|
||||
export interface RemoveSkillTargets {
|
||||
agentsDir: string;
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
}
|
||||
|
||||
export async function removeSkill(skillName: string, targets: RemoveSkillTargets): Promise<void> {
|
||||
const paths = [
|
||||
path.join(targets.agentsDir, skillName),
|
||||
path.join(targets.claudeDir, skillName),
|
||||
path.join(targets.codexDir, skillName),
|
||||
];
|
||||
for (const p of paths) {
|
||||
await fs.rm(p, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> {
|
||||
let changedFiles = 0;
|
||||
let processedSkills = 0;
|
||||
|
||||
for (const skillName of options.skillNames) {
|
||||
const bundleSkillDir = path.join(options.sourceDir, skillName);
|
||||
|
||||
const bundleStat = await fs.stat(bundleSkillDir).catch(() => null);
|
||||
if (!bundleStat?.isDirectory()) continue;
|
||||
|
||||
try {
|
||||
changedFiles += await syncDirectoryFiles(
|
||||
bundleSkillDir,
|
||||
path.join(options.agentsDir, skillName),
|
||||
);
|
||||
|
||||
changedFiles += await syncDirectoryFiles(
|
||||
bundleSkillDir,
|
||||
path.join(options.claudeDir, skillName),
|
||||
);
|
||||
|
||||
changedFiles += await syncDirectoryFiles(
|
||||
bundleSkillDir,
|
||||
path.join(options.codexDir, skillName),
|
||||
);
|
||||
|
||||
processedSkills++;
|
||||
} catch (error) {
|
||||
if (!options.onSkillError) throw error;
|
||||
options.onSkillError(skillName, error);
|
||||
}
|
||||
}
|
||||
|
||||
return { changedFiles, processedSkills };
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
readlink,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
listManagedSkillNames,
|
||||
type SkillOp,
|
||||
type SkillSelection,
|
||||
type SkillTargets,
|
||||
} from "./operations.js";
|
||||
import { listFilesRecursive } from "./sync.js";
|
||||
|
||||
export interface SkillsTransaction {
|
||||
/** Convergence stuck. Drop the staged copies. */
|
||||
commit(): Promise<void>;
|
||||
/** Put every captured directory back exactly as it was, then drop the staging. */
|
||||
rollback(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Written before anything is touched and updated once capture finishes. A
|
||||
* directory without a readable one of these is not ours and is never deleted —
|
||||
* the prefix alone is a guess, and guessing here deletes someone's files.
|
||||
*/
|
||||
interface TransactionManifest {
|
||||
owner: typeof MANIFEST_OWNER;
|
||||
version: 1;
|
||||
/** `capturing` means convergence never started, so there is nothing to undo. */
|
||||
phase: "capturing" | "converging";
|
||||
previousSelection: SkillSelection;
|
||||
nextSelection: SkillSelection;
|
||||
entries: CapturedDirectory[];
|
||||
}
|
||||
|
||||
interface CapturedDirectory {
|
||||
livePath: string;
|
||||
kind: SkillOp["kind"];
|
||||
/** Relative to the manifest directory, or an absolute same-filesystem delete stage. */
|
||||
backupPath: string | null;
|
||||
}
|
||||
|
||||
const MANIFEST_OWNER = "paseo-skills-transaction";
|
||||
const MANIFEST_FILENAME = "transaction.json";
|
||||
const TRANSACTION_PREFIX = ".paseo-skills-transaction-";
|
||||
const RECOVERED_PREFIX = ".paseo-skills-recovered-";
|
||||
const BACKUP_DIRNAME = "backup";
|
||||
const MANAGED_FILES_MANIFEST = ".paseo-managed-files.json";
|
||||
|
||||
async function isDirectory(target: string): Promise<boolean> {
|
||||
const info = await stat(target).catch(() => null);
|
||||
return info?.isDirectory() === true;
|
||||
}
|
||||
|
||||
function isSkillSelection(value: unknown): value is SkillSelection {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const selection = value as Partial<SkillSelection>;
|
||||
if (selection.mode === "all") return true;
|
||||
return (
|
||||
selection.mode === "custom" &&
|
||||
Array.isArray(selection.skills) &&
|
||||
selection.skills.every((name) => typeof name === "string")
|
||||
);
|
||||
}
|
||||
|
||||
async function readManifest(transactionDir: string): Promise<TransactionManifest | null> {
|
||||
const raw = await readFile(path.join(transactionDir, MANIFEST_FILENAME), "utf8").catch(
|
||||
() => null,
|
||||
);
|
||||
if (raw === null) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
const manifest = parsed as Partial<TransactionManifest>;
|
||||
if (manifest.owner !== MANIFEST_OWNER || manifest.version !== 1) return null;
|
||||
if (manifest.phase !== "capturing" && manifest.phase !== "converging") return null;
|
||||
if (!isSkillSelection(manifest.previousSelection) || !isSkillSelection(manifest.nextSelection)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(manifest.entries)) return null;
|
||||
const validEntries = manifest.entries.every(
|
||||
(entry) =>
|
||||
typeof entry?.livePath === "string" &&
|
||||
(entry.kind === "add" || entry.kind === "update" || entry.kind === "delete") &&
|
||||
(entry.backupPath === null || typeof entry.backupPath === "string"),
|
||||
);
|
||||
if (!validEntries) return null;
|
||||
const entries = manifest.entries as CapturedDirectory[];
|
||||
return {
|
||||
owner: MANIFEST_OWNER,
|
||||
version: 1,
|
||||
phase: manifest.phase,
|
||||
previousSelection: manifest.previousSelection,
|
||||
nextSelection: manifest.nextSelection,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeManifest(transactionDir: string, manifest: TransactionManifest): Promise<void> {
|
||||
await writeFile(
|
||||
path.join(transactionDir, MANIFEST_FILENAME),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function selectionsEqual(left: SkillSelection, right: SkillSelection): boolean {
|
||||
if (left.mode !== right.mode) return false;
|
||||
if (left.mode === "all" || right.mode === "all") return true;
|
||||
return (
|
||||
left.skills.length === right.skills.length &&
|
||||
left.skills.every((name, index) => name === right.skills[index])
|
||||
);
|
||||
}
|
||||
|
||||
async function validateEntries(
|
||||
targets: SkillTargets,
|
||||
transactionDir: string,
|
||||
entries: CapturedDirectory[],
|
||||
): Promise<boolean> {
|
||||
const names = new Set(await listManagedSkillNames(targets.sourceDir));
|
||||
const roots = [targets.agentsDir, targets.claudeDir, targets.codexDir];
|
||||
return entries.every((entry) => {
|
||||
const rootIndex = roots.findIndex((root) => path.dirname(entry.livePath) === root);
|
||||
if (rootIndex === -1) return false;
|
||||
const name = path.basename(entry.livePath);
|
||||
if (!names.has(name)) return false;
|
||||
if (entry.backupPath === null) return true;
|
||||
const expected = path.join(BACKUP_DIRNAME, String(rootIndex), name);
|
||||
const sameFilesystemStage = path.join(roots[rootIndex]!, path.basename(transactionDir), name);
|
||||
if (entry.kind === "delete" && entry.backupPath === sameFilesystemStage) return true;
|
||||
return (
|
||||
entry.backupPath === expected &&
|
||||
path
|
||||
.resolve(transactionDir, entry.backupPath)
|
||||
.startsWith(`${path.resolve(transactionDir)}${path.sep}`)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveBackupPath(transactionDir: string, backupPath: string | null): string | null {
|
||||
if (backupPath === null) return null;
|
||||
return path.isAbsolute(backupPath) ? backupPath : path.join(transactionDir, backupPath);
|
||||
}
|
||||
|
||||
async function discardTransaction(
|
||||
transactionDir: string,
|
||||
entries: readonly CapturedDirectory[],
|
||||
): Promise<void> {
|
||||
const stagingDirs = new Set(
|
||||
entries.flatMap((entry) =>
|
||||
entry.backupPath !== null && path.isAbsolute(entry.backupPath)
|
||||
? [path.dirname(entry.backupPath)]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
for (const stagingDir of stagingDirs) {
|
||||
await rm(stagingDir, { recursive: true, force: true });
|
||||
}
|
||||
await rm(transactionDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function listAllFiles(rootDir: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
async function walk(dir: string): Promise<void> {
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) await walk(full);
|
||||
else if (entry.isFile()) files.push(path.relative(rootDir, full));
|
||||
}
|
||||
}
|
||||
await walk(rootDir);
|
||||
return files;
|
||||
}
|
||||
|
||||
async function expectedSyncedFiles(sourceDir: string, name: string): Promise<Map<string, Buffer>> {
|
||||
const skillDir = path.join(sourceDir, name);
|
||||
const files = new Map<string, Buffer>();
|
||||
const hashes: Record<string, string> = {};
|
||||
for (const rel of await listFilesRecursive(skillDir)) {
|
||||
const contents = await readFile(path.join(skillDir, rel));
|
||||
files.set(rel, contents);
|
||||
hashes[rel] = createHash("sha256").update(contents).digest("hex");
|
||||
}
|
||||
files.set(
|
||||
MANAGED_FILES_MANIFEST,
|
||||
Buffer.from(`${JSON.stringify({ version: 1, files: hashes }, null, 2)}\n`),
|
||||
);
|
||||
return files;
|
||||
}
|
||||
|
||||
async function copyStagedEntry(source: string, target: string): Promise<void> {
|
||||
const info = await lstat(source);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await cp(source, target, {
|
||||
recursive: info.isDirectory(),
|
||||
preserveTimestamps: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function mergeBackupDirectory(livePath: string, backupPath: string | null): Promise<boolean> {
|
||||
if (backupPath === null) return false;
|
||||
const liveInfo = await lstat(livePath).catch(() => null);
|
||||
const destination = liveInfo?.isSymbolicLink() ? await realpath(livePath) : livePath;
|
||||
await mkdir(destination, { recursive: true });
|
||||
let hasConflict = false;
|
||||
|
||||
async function mergeMissing(sourceDir: string, destinationDir: string): Promise<void> {
|
||||
for (const entry of await readdir(sourceDir, { withFileTypes: true })) {
|
||||
const source = path.join(sourceDir, entry.name);
|
||||
const target = path.join(destinationDir, entry.name);
|
||||
const sourceInfo = await lstat(source);
|
||||
const targetInfo = await lstat(target).catch(() => null);
|
||||
if (targetInfo === null) {
|
||||
await copyStagedEntry(source, target);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
sourceInfo.isDirectory() &&
|
||||
!sourceInfo.isSymbolicLink() &&
|
||||
targetInfo.isDirectory() &&
|
||||
!targetInfo.isSymbolicLink()
|
||||
) {
|
||||
if ((sourceInfo.mode & 0o777) !== (targetInfo.mode & 0o777)) hasConflict = true;
|
||||
await mergeMissing(source, target);
|
||||
continue;
|
||||
}
|
||||
if (sourceInfo.isFile() && targetInfo.isFile()) {
|
||||
const [sourceContents, targetContents] = await Promise.all([
|
||||
readFile(source),
|
||||
readFile(target),
|
||||
]);
|
||||
if (
|
||||
!sourceContents.equals(targetContents) ||
|
||||
(sourceInfo.mode & 0o777) !== (targetInfo.mode & 0o777)
|
||||
) {
|
||||
hasConflict = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (sourceInfo.isSymbolicLink() && targetInfo.isSymbolicLink()) {
|
||||
const [sourceTarget, targetTarget] = await Promise.all([
|
||||
readlink(source),
|
||||
readlink(target),
|
||||
]);
|
||||
if (sourceTarget !== targetTarget) hasConflict = true;
|
||||
continue;
|
||||
}
|
||||
hasConflict = true;
|
||||
}
|
||||
}
|
||||
|
||||
await mergeMissing(backupPath, destination);
|
||||
return hasConflict;
|
||||
}
|
||||
|
||||
function recoveryBase(transactionDir: string, livePath: string, backupPath: string): string {
|
||||
const transactionId = path.basename(transactionDir).replace(TRANSACTION_PREFIX, "");
|
||||
// Update backups live inside the central transaction directory; delete stages
|
||||
// live beside their target root. Keep quarantine on the backup's filesystem
|
||||
// so preserving it remains an atomic rename across mounted agent homes.
|
||||
const recoveryParent = backupPath.startsWith(`${transactionDir}${path.sep}`)
|
||||
? path.dirname(transactionDir)
|
||||
: path.dirname(livePath);
|
||||
return path.join(
|
||||
recoveryParent,
|
||||
`${RECOVERED_PREFIX}${path.basename(livePath)}-${transactionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function findQuarantinedEntry(
|
||||
transactionDir: string,
|
||||
livePath: string,
|
||||
backupPath: string,
|
||||
claimed: ReadonlySet<string>,
|
||||
): Promise<string | null> {
|
||||
const base = recoveryBase(transactionDir, livePath, backupPath);
|
||||
const name = path.basename(base);
|
||||
const matches = (await readdir(path.dirname(base)).catch(() => []))
|
||||
.filter((entry) => entry === name || entry.startsWith(`${name}-`))
|
||||
.map((entry) => path.join(path.dirname(base), entry))
|
||||
.filter((entry) => !claimed.has(entry))
|
||||
.sort();
|
||||
return matches[0] ?? null;
|
||||
}
|
||||
|
||||
async function quarantineStagedEntry(
|
||||
transactionDir: string,
|
||||
livePath: string,
|
||||
backupPath: string,
|
||||
): Promise<void> {
|
||||
const base = recoveryBase(transactionDir, livePath, backupPath);
|
||||
let destination = base;
|
||||
for (let suffix = 1; await lstat(destination).catch(() => null); suffix += 1) {
|
||||
destination = `${base}-${suffix}`;
|
||||
}
|
||||
await rename(backupPath, destination);
|
||||
}
|
||||
|
||||
async function restoreDeletedDirectory(
|
||||
transactionDir: string,
|
||||
livePath: string,
|
||||
backupPath: string | null,
|
||||
): Promise<void> {
|
||||
if (backupPath === null) return;
|
||||
const liveInfo = await lstat(livePath).catch(() => null);
|
||||
if (liveInfo === null) {
|
||||
await mkdir(path.dirname(livePath), { recursive: true });
|
||||
await rename(backupPath, livePath);
|
||||
return;
|
||||
}
|
||||
const backupInfo = await lstat(backupPath);
|
||||
if (backupInfo.isDirectory() && !backupInfo.isSymbolicLink() && (await isDirectory(livePath))) {
|
||||
const hasConflict = await mergeBackupDirectory(livePath, backupPath);
|
||||
if (hasConflict) await quarantineStagedEntry(transactionDir, livePath, backupPath);
|
||||
return;
|
||||
}
|
||||
// A concurrent writer recreated the path with an incompatible type. Keep its
|
||||
// entry live and move the staged original outside transaction cleanup so
|
||||
// neither side is overwritten and later controller operations can proceed.
|
||||
await quarantineStagedEntry(transactionDir, livePath, backupPath);
|
||||
}
|
||||
|
||||
async function restoreStagedEntry(
|
||||
liveRoot: string,
|
||||
backupRoot: string | null,
|
||||
rel: string,
|
||||
): Promise<void> {
|
||||
const live = path.join(liveRoot, rel);
|
||||
const backup = backupRoot && path.join(backupRoot, rel);
|
||||
await rm(live, { recursive: true, force: true });
|
||||
if (backup === null || !(await lstat(backup).catch(() => null))) return;
|
||||
await copyStagedEntry(backup, live);
|
||||
}
|
||||
|
||||
async function undoSyncedDirectory(
|
||||
transactionDir: string,
|
||||
livePath: string,
|
||||
backupPath: string | null,
|
||||
expectedFiles: ReadonlyMap<string, Buffer>,
|
||||
): Promise<void> {
|
||||
for (const rel of await listAllFiles(livePath)) {
|
||||
const target = path.join(livePath, rel);
|
||||
const current = await readFile(target).catch(() => null);
|
||||
const expected = expectedFiles.get(rel);
|
||||
if (current === null || expected === undefined || !current.equals(expected)) continue;
|
||||
await restoreStagedEntry(livePath, backupPath, rel);
|
||||
}
|
||||
|
||||
// Sync can prune files that an older managed-files manifest owned. Restore
|
||||
// missing staged entries with their type and metadata, but never overwrite a
|
||||
// path another writer recreated.
|
||||
const hasConflict = await mergeBackupDirectory(livePath, backupPath);
|
||||
if (hasConflict && backupPath !== null) {
|
||||
await quarantineStagedEntry(transactionDir, livePath, backupPath);
|
||||
}
|
||||
if ((await readdir(livePath).catch(() => [])).length === 0) {
|
||||
await rm(livePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(
|
||||
targets: SkillTargets,
|
||||
transactionDir: string,
|
||||
manifest: TransactionManifest,
|
||||
): Promise<void> {
|
||||
// Nothing was converged yet, so the live tree is already the pre-save state.
|
||||
if (manifest.phase === "capturing") return;
|
||||
const expectedByName = new Map<string, Map<string, Buffer>>();
|
||||
const claimedQuarantines = new Set<string>();
|
||||
for (const entry of manifest.entries) {
|
||||
const backup = resolveBackupPath(transactionDir, entry.backupPath);
|
||||
if (backup !== null && !(await lstat(backup).catch(() => null))) {
|
||||
// Atomic rename leaves either the live path or its stage. If neither is
|
||||
// present, an external actor deleted it; there is nothing this
|
||||
// transaction can restore. Other captured entries still need rollback.
|
||||
if (entry.kind === "delete") continue;
|
||||
const quarantined = await findQuarantinedEntry(
|
||||
transactionDir,
|
||||
entry.livePath,
|
||||
backup,
|
||||
claimedQuarantines,
|
||||
);
|
||||
if (quarantined !== null) {
|
||||
claimedQuarantines.add(quarantined);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Skills transaction backup is missing: ${backup}`);
|
||||
}
|
||||
if (entry.kind === "delete") {
|
||||
await restoreDeletedDirectory(transactionDir, entry.livePath, backup);
|
||||
continue;
|
||||
}
|
||||
const liveInfo = await lstat(entry.livePath).catch(() => null);
|
||||
if (liveInfo !== null && !(await isDirectory(entry.livePath))) {
|
||||
// Add/update rollback has the same conflict state as delete rollback. A
|
||||
// recreated file or file symlink belongs to the external writer; preserve
|
||||
// it, and preserve any captured original outside transaction cleanup.
|
||||
if (backup !== null) {
|
||||
await quarantineStagedEntry(transactionDir, entry.livePath, backup);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const backupInfo = backup && (await lstat(backup).catch(() => null));
|
||||
if (backupInfo && !backupInfo.isDirectory()) {
|
||||
if (liveInfo !== null && (await isDirectory(entry.livePath))) {
|
||||
// The captured file was replaced by a directory after the transaction
|
||||
// started. Preserve the external tree and quarantine the captured entry
|
||||
// instead of recursively deleting the replacement during recovery.
|
||||
await quarantineStagedEntry(transactionDir, entry.livePath, backup);
|
||||
continue;
|
||||
}
|
||||
await restoreStagedEntry(entry.livePath, backup, ".");
|
||||
continue;
|
||||
}
|
||||
const name = path.basename(entry.livePath);
|
||||
let expected = expectedByName.get(name);
|
||||
if (!expected) {
|
||||
expected = await expectedSyncedFiles(targets.sourceDir, name);
|
||||
expectedByName.set(name, expected);
|
||||
}
|
||||
await undoSyncedDirectory(transactionDir, entry.livePath, backup, expected);
|
||||
}
|
||||
}
|
||||
|
||||
function transactionParents(targets: SkillTargets): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
[targets.agentsDir, targets.claudeDir, targets.codexDir].map((root) => path.dirname(root)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishes what an interrupted save started. A transaction directory only exists
|
||||
* while a save is mid-flight, so finding one means the process died between
|
||||
* mutating the directories and committing the selection — the staged copies are
|
||||
* the only place the user's files still exist. Restore them, then drop the
|
||||
* transaction. Anything without our manifest is left where it is.
|
||||
*/
|
||||
export async function recoverInterruptedSkillTransactions(
|
||||
targets: SkillTargets,
|
||||
committedSelection: SkillSelection,
|
||||
): Promise<void> {
|
||||
for (const parent of transactionParents(targets)) {
|
||||
const entries = await readdir(parent).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith(TRANSACTION_PREFIX)) continue;
|
||||
const transactionDir = path.join(parent, entry);
|
||||
const manifest = await readManifest(transactionDir);
|
||||
if (!manifest) continue;
|
||||
if (!(await validateEntries(targets, transactionDir, manifest.entries))) continue;
|
||||
if (selectionsEqual(committedSelection, manifest.nextSelection)) {
|
||||
await discardTransaction(transactionDir, manifest.entries);
|
||||
continue;
|
||||
}
|
||||
if (!selectionsEqual(committedSelection, manifest.previousSelection)) {
|
||||
throw new Error(
|
||||
`Cannot safely recover interrupted skills transaction at ${transactionDir}`,
|
||||
);
|
||||
}
|
||||
await restore(targets, transactionDir, manifest);
|
||||
await discardTransaction(transactionDir, manifest.entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the managed skill directories so a failed save can put the machine
|
||||
* back exactly as it was — including files the user added inside them, which
|
||||
* re-running an install cannot reconstruct.
|
||||
*/
|
||||
export async function beginSkillsTransaction(
|
||||
targets: SkillTargets,
|
||||
previousSelection: SkillSelection,
|
||||
nextSelection: SkillSelection,
|
||||
ops: readonly SkillOp[],
|
||||
): Promise<SkillsTransaction> {
|
||||
const roots = [targets.agentsDir, targets.claudeDir, targets.codexDir];
|
||||
const seenRoots = new Set<string>();
|
||||
const uniqueRoots: Array<{ root: string; rootIndex: number }> = [];
|
||||
for (const [rootIndex, root] of roots.entries()) {
|
||||
const identity = await realpath(root).catch(() => path.resolve(root));
|
||||
if (seenRoots.has(identity)) continue;
|
||||
seenRoots.add(identity);
|
||||
uniqueRoots.push({ root, rootIndex });
|
||||
}
|
||||
// Staged next to the skills tree rather than inside it: same filesystem, so a
|
||||
// restore is a local copy, and never a directory a skill scan walks into.
|
||||
const transactionDir = path.join(path.dirname(roots[0]!), `${TRANSACTION_PREFIX}${randomUUID()}`);
|
||||
const entries: CapturedDirectory[] = [];
|
||||
|
||||
async function discard(): Promise<void> {
|
||||
await discardTransaction(transactionDir, entries);
|
||||
}
|
||||
|
||||
await mkdir(transactionDir, { recursive: true });
|
||||
await writeManifest(transactionDir, {
|
||||
owner: MANIFEST_OWNER,
|
||||
version: 1,
|
||||
phase: "capturing",
|
||||
previousSelection,
|
||||
nextSelection,
|
||||
entries: [],
|
||||
});
|
||||
|
||||
try {
|
||||
for (const { root, rootIndex } of uniqueRoots) {
|
||||
for (const op of ops) {
|
||||
const name = op.name;
|
||||
const livePath = path.join(root, name);
|
||||
const liveInfo = await lstat(livePath).catch(() => null);
|
||||
if (liveInfo === null) {
|
||||
entries.push({ livePath, kind: op.kind, backupPath: null });
|
||||
continue;
|
||||
}
|
||||
const liveIsDirectory = await isDirectory(livePath);
|
||||
const backupPath =
|
||||
op.kind === "delete"
|
||||
? path.join(root, path.basename(transactionDir), name)
|
||||
: path.join(BACKUP_DIRNAME, String(rootIndex), name);
|
||||
if (op.kind !== "delete") {
|
||||
const captureSource =
|
||||
liveInfo.isSymbolicLink() && liveIsDirectory ? await realpath(livePath) : livePath;
|
||||
const captureInfo = await lstat(captureSource);
|
||||
await cp(captureSource, resolveBackupPath(transactionDir, backupPath)!, {
|
||||
recursive: captureInfo.isDirectory(),
|
||||
preserveTimestamps: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
}
|
||||
entries.push({ livePath, kind: op.kind, backupPath });
|
||||
}
|
||||
}
|
||||
await writeManifest(transactionDir, {
|
||||
owner: MANIFEST_OWNER,
|
||||
version: 1,
|
||||
phase: "converging",
|
||||
previousSelection,
|
||||
nextSelection,
|
||||
entries,
|
||||
});
|
||||
} catch (error) {
|
||||
// Nothing has been converged yet, so failing to stage means failing the save
|
||||
// before it touches anything.
|
||||
await discard();
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
// Each delete is staged inside its own skills root, so rename stays atomic
|
||||
// even when the agent homes are symlinks or mounts on different filesystems.
|
||||
// Any writer
|
||||
// holding the old directory keeps writing into the staged inode; any writer
|
||||
// recreating the old path remains live and is merged on rollback. The later
|
||||
// convergence plan deliberately omits deletes.
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== "delete" || entry.backupPath === null) continue;
|
||||
const backup = resolveBackupPath(transactionDir, entry.backupPath)!;
|
||||
await mkdir(path.dirname(backup), { recursive: true });
|
||||
await rename(entry.livePath, backup);
|
||||
}
|
||||
} catch (error) {
|
||||
await restore(targets, transactionDir, {
|
||||
owner: MANIFEST_OWNER,
|
||||
version: 1,
|
||||
phase: "converging",
|
||||
previousSelection,
|
||||
nextSelection,
|
||||
entries,
|
||||
});
|
||||
await discard();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
commit: discard,
|
||||
async rollback(): Promise<void> {
|
||||
await restore(targets, transactionDir, {
|
||||
owner: MANIFEST_OWNER,
|
||||
version: 1,
|
||||
phase: "converging",
|
||||
previousSelection,
|
||||
nextSelection,
|
||||
entries,
|
||||
});
|
||||
await discard();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-co
|
||||
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
|
||||
import {
|
||||
AgentProfileSchema,
|
||||
AgentSkillSelectionSchema,
|
||||
PluginIdSchema,
|
||||
PluginSourceSchema,
|
||||
TerminalProfileSchema,
|
||||
@@ -317,6 +318,7 @@ export const PersistedConfigSchema = z
|
||||
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
|
||||
catalogRefreshTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(),
|
||||
metadataGeneration: AgentMetadataGenerationSchema.optional(),
|
||||
skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -321,6 +321,7 @@ interface SessionForTestOptions {
|
||||
targetedMessages?: Array<{ source: object; message: SessionOutboundMessage }>;
|
||||
binaryMessages?: Uint8Array[];
|
||||
pluginRuntime?: SessionOptions["pluginRuntime"];
|
||||
orchestrationSkills?: SessionOptions["orchestrationSkills"];
|
||||
}
|
||||
|
||||
function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
@@ -407,6 +408,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
onChange: vi.fn(() => () => {}),
|
||||
}),
|
||||
pluginRuntime: options.pluginRuntime,
|
||||
orchestrationSkills: options.orchestrationSkills,
|
||||
stt: options.stt ?? null,
|
||||
tts: null,
|
||||
terminalManager: options.terminalManager ?? null,
|
||||
@@ -426,6 +428,45 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
return new Session(sessionOptions);
|
||||
}
|
||||
|
||||
test("routes host-scoped agent skills requests through the daemon owner", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const status = {
|
||||
state: "up-to-date" as const,
|
||||
ops: [],
|
||||
available: ["paseo"],
|
||||
installed: ["paseo"],
|
||||
selection: { mode: "all" as const },
|
||||
};
|
||||
const orchestrationSkills: NonNullable<SessionOptions["orchestrationSkills"]> = {
|
||||
getStatus: vi.fn(async () => status),
|
||||
reconcile: vi.fn(async () => status),
|
||||
uninstall: vi.fn(async () => status),
|
||||
saveSelection: vi.fn(async () => ({ ...status, confirmationRequired: null })),
|
||||
importLegacySelectionIfUnset: vi.fn(async (selection) => ({
|
||||
imported: true,
|
||||
selection,
|
||||
})),
|
||||
autoUpdate: vi.fn(async () => status),
|
||||
};
|
||||
const session = createSessionForTest({ messages, orchestrationSkills });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "agent.skills.save_selection.request",
|
||||
requestId: "save-skills",
|
||||
selection: { mode: "custom", skills: ["paseo"] },
|
||||
confirmedRemovals: ["paseo-loop"],
|
||||
});
|
||||
|
||||
expect(orchestrationSkills.saveSelection).toHaveBeenCalledWith(
|
||||
{ mode: "custom", skills: ["paseo"] },
|
||||
["paseo-loop"],
|
||||
);
|
||||
expect(messages).toContainEqual({
|
||||
type: "agent.skills.save_selection.response",
|
||||
payload: { requestId: "save-skills", ...status, confirmationRequired: null },
|
||||
});
|
||||
});
|
||||
|
||||
test("routes plugin requests and releases its owned catalog subscription on cleanup", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const listeners = new Set<(pluginId: string) => void>();
|
||||
|
||||
@@ -476,6 +476,7 @@ export interface SessionOptions {
|
||||
catalog(): Array<{ id: string; clientBundle: string }>;
|
||||
invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown>;
|
||||
};
|
||||
orchestrationSkills?: import("./orchestration-skills/index.js").OrchestrationSkills;
|
||||
mcpBaseUrl?: string | null;
|
||||
stt: Resolvable<SpeechToTextProvider | null>;
|
||||
sttLanguage?: string;
|
||||
@@ -654,6 +655,7 @@ export class Session {
|
||||
private readonly daemonConfigStore: DaemonConfigStore;
|
||||
private readonly pushNotifications: PushNotifications;
|
||||
private readonly pluginRuntime: SessionOptions["pluginRuntime"];
|
||||
private readonly orchestrationSkills: SessionOptions["orchestrationSkills"];
|
||||
private unsubscribeAgentEvents: (() => void) | null = null;
|
||||
private unsubscribeProjectMutations: (() => void) | null = null;
|
||||
private unsubscribePluginChanges: (() => void) | null = null;
|
||||
@@ -738,6 +740,7 @@ export class Session {
|
||||
workspaceAutoName,
|
||||
daemonConfigStore,
|
||||
pluginRuntime,
|
||||
orchestrationSkills,
|
||||
stt,
|
||||
sttLanguage,
|
||||
tts,
|
||||
@@ -778,6 +781,7 @@ export class Session {
|
||||
this.projectIcons = new ProjectIconReader(paseoHome);
|
||||
this.worktreesRoot = worktreesRoot;
|
||||
this.pluginRuntime = pluginRuntime;
|
||||
this.orchestrationSkills = orchestrationSkills;
|
||||
this.unsubscribePluginChanges = this.subscribeToPluginChanges(pluginRuntime);
|
||||
this.sessionLogger = logger.child({
|
||||
module: "session",
|
||||
@@ -1883,6 +1887,7 @@ export class Session {
|
||||
this.dispatchWorkspaceAndProjectMessage(msg) ??
|
||||
this.dispatchWorkspaceFileMessage(msg, source) ??
|
||||
this.dispatchProviderMessage(msg) ??
|
||||
this.dispatchOrchestrationSkillsMessage(msg) ??
|
||||
this.dispatchPluginDirectoryMessage(msg) ??
|
||||
this.dispatchPluginMessage(msg) ??
|
||||
this.dispatchTerminalMessage(msg) ??
|
||||
@@ -1891,6 +1896,66 @@ export class Session {
|
||||
if (promise) await promise;
|
||||
}
|
||||
|
||||
private dispatchOrchestrationSkillsMessage(
|
||||
msg: SessionInboundMessage,
|
||||
): Promise<void> | undefined {
|
||||
if (!this.orchestrationSkills || !msg.type.startsWith("agent.skills.")) return undefined;
|
||||
const emitStatus = (
|
||||
type:
|
||||
| "agent.skills.get_status.response"
|
||||
| "agent.skills.reconcile.response"
|
||||
| "agent.skills.uninstall.response",
|
||||
requestId: string,
|
||||
operation: Promise<import("./orchestration-skills/index.js").SkillsSnapshot>,
|
||||
) =>
|
||||
operation.then((status) => {
|
||||
this.emit({ type, payload: { requestId, ...status } });
|
||||
return undefined;
|
||||
});
|
||||
switch (msg.type) {
|
||||
case "agent.skills.get_status.request":
|
||||
return emitStatus(
|
||||
"agent.skills.get_status.response",
|
||||
msg.requestId,
|
||||
this.orchestrationSkills.getStatus(),
|
||||
);
|
||||
case "agent.skills.reconcile.request":
|
||||
return emitStatus(
|
||||
"agent.skills.reconcile.response",
|
||||
msg.requestId,
|
||||
this.orchestrationSkills.reconcile(),
|
||||
);
|
||||
case "agent.skills.uninstall.request":
|
||||
return emitStatus(
|
||||
"agent.skills.uninstall.response",
|
||||
msg.requestId,
|
||||
this.orchestrationSkills.uninstall(),
|
||||
);
|
||||
case "agent.skills.save_selection.request":
|
||||
return this.orchestrationSkills
|
||||
.saveSelection(msg.selection, msg.confirmedRemovals)
|
||||
.then((result) => {
|
||||
this.emit({
|
||||
type: "agent.skills.save_selection.response",
|
||||
payload: { requestId: msg.requestId, ...result },
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
case "agent.skills.import_legacy_selection.request":
|
||||
return this.orchestrationSkills
|
||||
.importLegacySelectionIfUnset(msg.selection)
|
||||
.then((result) => {
|
||||
this.emit({
|
||||
type: "agent.skills.import_legacy_selection.response",
|
||||
payload: { requestId: msg.requestId, ...result },
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchPluginMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
if (msg.type === "plugin.list.request") {
|
||||
this.emit({
|
||||
|
||||
@@ -602,6 +602,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly advertiseRelayConfig: boolean;
|
||||
private readonly directorySync = new DirectorySyncService();
|
||||
private readonly pluginRuntime: SessionOptions["pluginRuntime"];
|
||||
private readonly orchestrationSkills: SessionOptions["orchestrationSkills"];
|
||||
|
||||
constructor(
|
||||
server: HTTPServer,
|
||||
@@ -647,6 +648,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
hubRelationships?: HubRelationshipManagement | null,
|
||||
workspaceSetupRuntime: WorkspaceSetupRuntime = new WorkspaceSetupRuntime(),
|
||||
pluginRuntime?: SessionOptions["pluginRuntime"],
|
||||
orchestrationSkills?: SessionOptions["orchestrationSkills"],
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
this.workspaceSetupRuntime = workspaceSetupRuntime;
|
||||
@@ -662,6 +664,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.browserToolsBroker = browserToolsBroker ?? null;
|
||||
this.hubRelationships = hubRelationships ?? null;
|
||||
this.pluginRuntime = pluginRuntime;
|
||||
this.orchestrationSkills = orchestrationSkills;
|
||||
this.agentManager = agentManager;
|
||||
this.agentStorage = agentStorage;
|
||||
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
|
||||
@@ -1433,6 +1436,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
workspaceAutoName: this.workspaceAutoName,
|
||||
daemonConfigStore: this.daemonConfigStore,
|
||||
pluginRuntime: this.pluginRuntime,
|
||||
orchestrationSkills: this.orchestrationSkills,
|
||||
mcpBaseUrl: this.mcpBaseUrl,
|
||||
stt: () => this.speech?.resolveStt() ?? null,
|
||||
sttLanguage: this.speech?.resolveSttLanguage() ?? "en",
|
||||
@@ -1665,6 +1669,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
plugins: true,
|
||||
pluginManagement: true,
|
||||
pluginLogs: true,
|
||||
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
|
||||
skillManagement: true,
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
"terminal-restore-modes": true,
|
||||
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
|
||||
|
||||
Reference in New Issue
Block a user