From 41089607098a2c6aa570c7785fadadec241e3c46 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 18 Aug 2026 09:51:46 +0200 Subject: [PATCH] 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. --- docs/architecture.md | 31 +- docs/data-model.md | 7 + nix/desktop-package.nix | 5 - .../e2e/browser/agent-skills-settings.spec.ts | 215 +++++++++ .../app/e2e/support/helpers/agent-skills.ts | 244 +++++++++++ packages/app/src/agent-skills/index.tsx | 238 ++++++++++ .../src/agent-skills/legacy-migration.test.ts | 46 ++ .../app/src/agent-skills/legacy-migration.tsx | 50 +++ .../selection-sheet.tsx} | 42 +- .../agent-skills/use-agent-skills.test.tsx | 72 +++ .../app/src/agent-skills/use-agent-skills.ts | 108 +++++ packages/app/src/app/_layout.tsx | 2 + .../components/integrations-section.tsx | 308 ++----------- .../app/src/desktop/daemon/desktop-daemon.ts | 46 +- .../desktop/daemon/skills-snapshot.test.ts | 131 ------ .../app/src/desktop/daemon/skills-snapshot.ts | 113 ----- .../desktop/hooks/use-install-status.test.tsx | 282 ------------ .../src/desktop/hooks/use-install-status.ts | 150 ------- packages/app/src/i18n/resources.test.ts | 4 - packages/app/src/i18n/resources/ar.ts | 81 ++-- packages/app/src/i18n/resources/en.ts | 84 ++-- packages/app/src/i18n/resources/es.ts | 85 ++-- packages/app/src/i18n/resources/fr.ts | 87 ++-- packages/app/src/i18n/resources/ja.ts | 85 ++-- packages/app/src/i18n/resources/ko.ts | 83 ++-- packages/app/src/i18n/resources/pt-BR.ts | 85 ++-- packages/app/src/i18n/resources/ru.ts | 81 ++-- packages/app/src/i18n/resources/zh-CN.ts | 81 ++-- .../app/src/screens/settings/host-page.tsx | 2 + packages/client/src/daemon-client.ts | 58 +++ packages/client/src/index.test.ts | 1 + .../e2e/adaptive-sheet-content-inset.spec.ts | 77 ---- packages/desktop/e2e/skills-selection.spec.ts | 333 -------------- .../desktop/e2e/support/skills-selection.ts | 410 ------------------ packages/desktop/electron-builder.yml | 2 - packages/desktop/src/daemon/daemon-manager.ts | 8 +- .../src/daemon/desktop-packaging.test.ts | 14 + packages/desktop/src/desktop-startup.test.ts | 18 - packages/desktop/src/desktop-startup.ts | 2 - .../legacy-skill-selection.test.ts | 37 ++ .../integrations/legacy-skill-selection.ts | 50 +++ .../desktop/src/integrations/skills/index.ts | 49 --- .../desktop/src/integrations/skills/paths.ts | 22 - .../skills/selection-store.test.ts | 150 ------- .../integrations/skills/selection-store.ts | 101 ----- .../integrations/skills/skills-commands.ts | 16 - packages/desktop/src/main.ts | 6 - .../src/messages.agent-skills.test.ts | 49 +++ packages/protocol/src/messages.ts | 101 +++++ packages/server/package.json | 2 +- packages/server/src/server/bootstrap.ts | 9 + packages/server/src/server/config.ts | 1 + .../server/src/server/daemon-config-store.ts | 20 + .../src/server/orchestration-skills/index.ts | 47 ++ .../internal/controller.test.ts} | 53 ++- .../internal/controller.ts} | 19 +- .../internal}/operations.test.ts | 0 .../internal}/operations.ts | 3 +- .../internal/paths.test.ts | 53 +++ .../orchestration-skills/internal/paths.ts | 24 + .../internal/selection-store.test.ts | 67 +++ .../internal/selection-store.ts | 53 +++ .../internal}/sync.test.ts | 0 .../orchestration-skills/internal}/sync.ts | 0 .../internal/transaction.ts} | 0 .../server/src/server/persisted-config.ts | 2 + packages/server/src/server/session.test.ts | 41 ++ packages/server/src/server/session.ts | 65 +++ .../server/src/server/websocket-server.ts | 6 + public-docs/skills.md | 4 +- scripts/trace-daemon.mjs | 2 + 71 files changed, 2225 insertions(+), 2598 deletions(-) create mode 100644 packages/app/e2e/browser/agent-skills-settings.spec.ts create mode 100644 packages/app/e2e/support/helpers/agent-skills.ts create mode 100644 packages/app/src/agent-skills/index.tsx create mode 100644 packages/app/src/agent-skills/legacy-migration.test.ts create mode 100644 packages/app/src/agent-skills/legacy-migration.tsx rename packages/app/src/{desktop/components/skill-selection-sheet.tsx => agent-skills/selection-sheet.tsx} (85%) create mode 100644 packages/app/src/agent-skills/use-agent-skills.test.tsx create mode 100644 packages/app/src/agent-skills/use-agent-skills.ts delete mode 100644 packages/app/src/desktop/daemon/skills-snapshot.test.ts delete mode 100644 packages/app/src/desktop/daemon/skills-snapshot.ts delete mode 100644 packages/app/src/desktop/hooks/use-install-status.test.tsx delete mode 100644 packages/desktop/e2e/adaptive-sheet-content-inset.spec.ts delete mode 100644 packages/desktop/e2e/skills-selection.spec.ts delete mode 100644 packages/desktop/e2e/support/skills-selection.ts create mode 100644 packages/desktop/src/integrations/legacy-skill-selection.test.ts create mode 100644 packages/desktop/src/integrations/legacy-skill-selection.ts delete mode 100644 packages/desktop/src/integrations/skills/index.ts delete mode 100644 packages/desktop/src/integrations/skills/paths.ts delete mode 100644 packages/desktop/src/integrations/skills/selection-store.test.ts delete mode 100644 packages/desktop/src/integrations/skills/selection-store.ts delete mode 100644 packages/desktop/src/integrations/skills/skills-commands.ts create mode 100644 packages/protocol/src/messages.agent-skills.test.ts create mode 100644 packages/server/src/server/orchestration-skills/index.ts rename packages/{desktop/src/integrations/skills/skills-controller.test.ts => server/src/server/orchestration-skills/internal/controller.test.ts} (96%) rename packages/{desktop/src/integrations/skills/skills-controller.ts => server/src/server/orchestration-skills/internal/controller.ts} (90%) rename packages/{desktop/src/integrations/skills => server/src/server/orchestration-skills/internal}/operations.test.ts (100%) rename packages/{desktop/src/integrations/skills => server/src/server/orchestration-skills/internal}/operations.ts (98%) create mode 100644 packages/server/src/server/orchestration-skills/internal/paths.test.ts create mode 100644 packages/server/src/server/orchestration-skills/internal/paths.ts create mode 100644 packages/server/src/server/orchestration-skills/internal/selection-store.test.ts create mode 100644 packages/server/src/server/orchestration-skills/internal/selection-store.ts rename packages/{desktop/src/integrations/skills => server/src/server/orchestration-skills/internal}/sync.test.ts (100%) rename packages/{desktop/src/integrations/skills => server/src/server/orchestration-skills/internal}/sync.ts (100%) rename packages/{desktop/src/integrations/skills/skills-transaction.ts => server/src/server/orchestration-skills/internal/transaction.ts} (100%) diff --git a/docs/architecture.md b/docs/architecture.md index 780f42023..80fd553e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,19 +64,20 @@ not retain non-Git directories. **Key modules:** -| Module | Responsibility | -| ------------------------------- | ----------------------------------------------------------------------------- | -| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay | -| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing | -| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations | -| `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents | -| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management | -| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` | -| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation | -| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK | -| `server/agent/providers/` | Provider adapters (see "Agent providers" below) | -| `server/relay-transport.ts` | Outbound relay connection with E2E encryption | -| `server/schedule/` | Cron-based scheduled agents | +| Module | Responsibility | +| ------------------------------- | ------------------------------------------------------------------------------ | +| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay | +| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing | +| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations | +| `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents | +| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management | +| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` | +| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation | +| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK | +| `server/agent/providers/` | Provider adapters (see "Agent providers" below) | +| `server/orchestration-skills/` | Bundled catalog, host selection, convergence, and skill-directory transactions | +| `server/relay-transport.ts` | Outbound relay connection with E2E encryption | +| `server/schedule/` | Cron-based scheduled agents | ### `packages/protocol` — Wire schemas and shared protocol types @@ -172,6 +173,10 @@ Electron wrapper for macOS, Linux, and Windows. - Native file access for workspace integration - Same WebSocket client as mobile app +The desktop does not manage agent skills. It retains one compatibility reader for the old +`skill-selection.json`, imports that preference into its managed local daemon, then deletes the old +file after the daemon confirms persistence. + **Multi-window (hybrid land-on model).** `createWindow()` in `main.ts` is reusable: `⌘⇧N`/File→New Window, relaunching the app (`second-instance`), and the sidebar "Open in new window" action each open a fresh `BrowserWindow`. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-`webContents` `PendingOpenProjectStore`: each window pulls its own pending project path on mount (`paseo:get-pending-open-project`) and runs the normal open-project flow, identical to a CLI `paseo ` launch. > **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys. diff --git a/docs/data-model.md b/docs/data-model.md index 50f77dec1..6fd19b34a 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -175,6 +175,10 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId` Single file, validated with `PersistedConfigSchema`. +`agents.skills.selection` is the daemon host's orchestration-skill preference. Missing means +`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from its three managed +skill directories and keeps config plus filesystem convergence behind one serialized owner. + `paseo reload` reads and validates this file once inside the daemon. That snapshot drives resolution, classification, application, and reload bookkeeping. `DaemonConfigStore` owns applying runtime-safe fields and their removal/default semantics; session handlers and the CLI only relay the structured @@ -218,6 +222,9 @@ snapshot so a mixed edit can apply its live subset and still name the paths that local: { modelsDir: string } }, agents: { + skills?: { + selection?: { mode: "all" } | { mode: "custom", skills: string[] } + }, // ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the // current shape on load via `migrateProviderSettings`. Custom provider IDs must declare // `extends` (one of the built-ins or `"acp"`) and `label`. See `provider-launch-config.ts`. diff --git a/nix/desktop-package.nix b/nix/desktop-package.nix index 4f061151d..d00b38208 100644 --- a/nix/desktop-package.nix +++ b/nix/desktop-package.nix @@ -178,11 +178,6 @@ buildNpmPackage { exit 1 fi - # Skills directory referenced at runtime by some agents - if [ -d skills ]; then - cp -a skills $out/share/paseo-desktop/ - fi - # Hicolor icon for desktop environments install -Dm644 packages/desktop/assets/icon.png \ $out/share/icons/hicolor/512x512/apps/paseo-desktop.png diff --git a/packages/app/e2e/browser/agent-skills-settings.spec.ts b/packages/app/e2e/browser/agent-skills-settings.spec.ts new file mode 100644 index 000000000..e9f5d5a04 --- /dev/null +++ b/packages/app/e2e/browser/agent-skills-settings.spec.ts @@ -0,0 +1,215 @@ +import { access } from "node:fs/promises"; +import { expect, test as base } from "../support/fixtures"; +import { + answerNextRemovalWarning, + cancelSkillSelection, + chooseAllSkills, + chooseCustomSkills, + expectInstalledSkills, + expectSavedSelection, + expectSkillFile, + openAgentSkillsSettings, + openSkillSelection, + saveSkillSelection, + startAgentSkillsSandbox, + toggleSkill, + type AgentSkillsSandbox, +} from "../support/helpers/agent-skills"; +import { readVerticalGap, waitForSettledPosition } from "../support/helpers/sheet-layout"; + +const test = base.extend<{ skills: AgentSkillsSandbox }>({ + // oxlint-disable-next-line no-empty-pattern -- Playwright requires destructuring for fixture dependency discovery. + skills: async ({}, provide) => { + const sandbox = await startAgentSkillsSandbox(); + try { + await provide(sandbox); + } finally { + await sandbox.close(); + } + }, +}); + +test.describe("Host agent skills", () => { + test.describe.configure({ timeout: 120_000 }); + + test("installs a custom selection, cancels a draft, then installs all skills", async ({ + page, + skills, + }) => { + const custom = skills.available.slice(0, 2); + const cancelledAddition = skills.available[2]!; + await openAgentSkillsSettings(page, skills); + + await openSkillSelection(page); + await chooseCustomSkills(page, custom); + await saveSkillSelection(page); + await expectInstalledSkills(skills, custom); + await expectSavedSelection(skills, { mode: "custom", skills: custom }); + + await openSkillSelection(page); + await toggleSkill(page, cancelledAddition); + await cancelSkillSelection(page); + await expectInstalledSkills(skills, custom); + await expectSavedSelection(skills, { mode: "custom", skills: custom }); + + await openSkillSelection(page); + await chooseAllSkills(page); + await saveSkillSelection(page); + await expectSavedSelection(skills, { mode: "all" }); + await expectInstalledSkills(skills, skills.available); + }); + + test("dismisses and then accepts removal confirmation", async ({ page, skills }) => { + const retained = skills.available.slice(0, 2); + const removed = skills.available.slice(2); + await skills.install({ mode: "all" }); + await openAgentSkillsSettings(page, skills); + await openSkillSelection(page); + await chooseCustomSkills(page, retained); + + const dismissedWarning = answerNextRemovalWarning(page, "dismiss"); + await saveSkillSelection(page); + const dismissedDialog = await dismissedWarning; + expect(dismissedDialog.message()).toContain("Remove deselected skills?"); + for (const skill of removed) expect(dismissedDialog.message()).toContain(skill); + await expectInstalledSkills(skills, skills.available); + await expectSavedSelection(skills, { mode: "all" }); + + const acceptedWarning = answerNextRemovalWarning(page, "accept"); + await saveSkillSelection(page); + expect((await acceptedWarning).message()).toContain( + "Anything you added inside those skill folders", + ); + await expectInstalledSkills(skills, retained); + await expectSavedSelection(skills, { mode: "custom", skills: retained }); + }); + + test("warns before removing a partial installation", async ({ page, skills }) => { + const partial = skills.available.at(-1)!; + const retained = skills.available.filter((name) => name !== partial); + await skills.install({ mode: "all" }); + await skills.keepSkillOnlyIn(partial, "claude"); + await openAgentSkillsSettings(page, skills); + await openSkillSelection(page); + await chooseCustomSkills(page, retained); + + const warning = answerNextRemovalWarning(page, "dismiss"); + await saveSkillSelection(page); + expect((await warning).message()).toContain(partial); + await expectSkillFile(skills, "claude", partial, "SKILL.md"); + await expectSavedSelection(skills, { mode: "all" }); + }); + + test("warns without deleting user files from an externally restored skill", async ({ + page, + skills, + }) => { + const retained = skills.available.slice(0, 2); + const drift = skills.available[2]!; + await skills.install({ mode: "custom", skills: retained }); + await skills.placeSkillOnDisk(drift, "claude", { + "SKILL.md": `# ${drift}\n`, + "notes/mine.md": "user notes", + }); + await openAgentSkillsSettings(page, skills); + await openSkillSelection(page); + + const warning = answerNextRemovalWarning(page, "dismiss"); + await saveSkillSelection(page); + expect((await warning).message()).toContain(drift); + await expectSkillFile(skills, "claude", drift, "notes/mine.md"); + await expectSavedSelection(skills, { mode: "custom", skills: retained }); + }); + + test("keeps the sheet open with an error when convergence fails", async ({ page, skills }) => { + await openAgentSkillsSettings(page, skills); + await openSkillSelection(page); + await skills.blockAgentsDirectory(); + await chooseCustomSkills(page, [skills.available[0]!]); + await saveSkillSelection(page); + + await expect( + page.getByText("Could not save your skill selection.", { exact: true }), + ).toBeVisible(); + await expect(page.getByRole("switch", { name: "All skills", exact: true })).toBeVisible(); + await skills.unblockAgentsDirectory(); + await expectSavedSelection(skills, { mode: "all" }); + }); + + test("serializes concurrent saves through the daemon command surface", async ({ skills }) => { + const firstSelection = { mode: "custom" as const, skills: [skills.available[0]!] }; + const secondSelection = { mode: "custom" as const, skills: [skills.available[1]!] }; + + const first = skills.client.saveAgentSkillsSelection(firstSelection, skills.available); + const second = skills.client.saveAgentSkillsSelection(secondSelection, skills.available); + await expect(Promise.all([first, second])).resolves.toMatchObject([ + { confirmationRequired: null, selection: firstSelection }, + { confirmationRequired: null, selection: secondSelection }, + ]); + + await expectInstalledSkills(skills, secondSelection.skills); + await expectSavedSelection(skills, secondSelection); + }); + + test("ignores agent config environment overrides", async ({ skills }) => { + await skills.install({ mode: "custom", skills: [skills.available[0]!] }); + + await expect( + access(`${skills.home}/../ignored-claude-config/skills`).then( + () => true, + () => false, + ), + ).resolves.toBe(false); + await expect( + access(`${skills.home}/../ignored-codex-home/skills`).then( + () => true, + () => false, + ), + ).resolves.toBe(false); + }); +}); + +function allSkillsCard(page: Parameters[0]) { + return page.getByTestId("skill-selection-all-card"); +} + +function skillListCard(page: Parameters[0]) { + return page.getByTestId("skill-selection-list-card"); +} + +function bundledSkillsLabel(page: Parameters[0]) { + return page.getByText("Bundled skills", { exact: true }); +} + +test.describe("Agent skills sheet inset", () => { + test.describe.configure({ timeout: 120_000 }); + + test.describe("compact", () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test("keeps more space above the bundled skills label than below", async ({ page, skills }) => { + await openAgentSkillsSettings(page, skills, { compact: true }); + await openSkillSelection(page); + await waitForSettledPosition(page.getByTestId("skill-selection-sheet")); + + expect({ + aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)), + belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)), + }).toEqual({ aboveLabel: 16, belowLabel: 12 }); + }); + }); + + test.describe("desktop", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test("keeps more space above the bundled skills label than below", async ({ page, skills }) => { + await openAgentSkillsSettings(page, skills); + await openSkillSelection(page); + + expect({ + aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)), + belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)), + }).toEqual({ aboveLabel: 16, belowLabel: 12 }); + }); + }); +}); diff --git a/packages/app/e2e/support/helpers/agent-skills.ts b/packages/app/e2e/support/helpers/agent-skills.ts new file mode 100644 index 000000000..76c7a56d4 --- /dev/null +++ b/packages/app/e2e/support/helpers/agent-skills.ts @@ -0,0 +1,244 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { expect, type Dialog, type Page } from "@playwright/test"; +import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSkillSelection } from "@getpaseo/protocol/messages"; +import { buildOpenProjectRoute } from "@/utils/host-routes"; +import { gotoAppShell, openSettings } from "./app"; +import { connectDaemonClient } from "./daemon-client-loader"; +import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./isolated-host-daemon"; +import { + addDirectHostFromSettings, + goBackInSettings, + openCompactSettings, + openHostSection, + selectSettingsHost, +} from "./settings"; + +type AgentSkillsClient = Pick< + InternalDaemonClient, + "close" | "connect" | "getAgentSkillsStatus" | "saveAgentSkillsSelection" +>; + +export type SkillTargetName = "agents" | "claude" | "codex"; + +export interface AgentSkillsSandbox { + available: string[]; + client: AgentSkillsClient; + daemon: IsolatedHostDaemon; + home: string; + targets: Record; + blockAgentsDirectory(): Promise; + unblockAgentsDirectory(): Promise; + close(): Promise; + getStatus(): ReturnType; + install(selection: AgentSkillSelection): Promise; + keepSkillOnlyIn(skill: string, target: SkillTargetName): Promise; + placeSkillOnDisk( + skill: string, + target: SkillTargetName, + files?: Record, + ): Promise; +} + +export async function startAgentSkillsSandbox(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-skills-e2e-")); + const home = path.join(root, "home"); + await mkdir(home, { recursive: true }); + const daemon = await startIsolatedHostDaemon(`agent-skills-${randomUUID()}`, { + environment: { + HOME: home, + NODE_ENV: "development", + CLAUDE_CONFIG_DIR: path.join(root, "ignored-claude-config"), + CODEX_HOME: path.join(root, "ignored-codex-home"), + }, + }); + const client = await connectDaemonClient({ + clientIdPrefix: "app-e2e-agent-skills", + port: daemon.port, + }); + const available = (await client.getAgentSkillsStatus()).available; + const targets = { + agents: path.join(home, ".agents", "skills"), + claude: path.join(home, ".claude", "skills"), + codex: path.join(home, ".codex", "skills"), + }; + + return { + available, + client, + daemon, + home, + targets, + blockAgentsDirectory: async () => { + await mkdir(path.dirname(targets.agents), { recursive: true }); + await writeFile(targets.agents, "not a directory", "utf8"); + }, + unblockAgentsDirectory: async () => { + await rm(targets.agents, { force: true }); + await mkdir(targets.agents, { recursive: true }); + }, + install: async (selection) => { + const result = await client.saveAgentSkillsSelection(selection, available); + if (result.confirmationRequired) { + throw new Error(`Unexpected removal confirmation: ${result.confirmationRequired.removals}`); + } + }, + keepSkillOnlyIn: async (skill, target) => { + for (const [name, directory] of Object.entries(targets)) { + if (name === target) continue; + await rm(path.join(directory, skill), { recursive: true, force: true }); + } + }, + placeSkillOnDisk: async (skill, target, files = { "SKILL.md": `# ${skill}\n` }) => { + const directory = path.join(targets[target], skill); + for (const [relativePath, contents] of Object.entries(files)) { + const file = path.join(directory, relativePath); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, contents, "utf8"); + } + }, + close: async () => { + await client.close().catch(() => undefined); + await daemon.close().catch(() => undefined); + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + }, + getStatus: async () => { + try { + return await client.getAgentSkillsStatus(); + } catch { + await client.connect(); + return client.getAgentSkillsStatus(); + } + }, + }; +} + +export async function openAgentSkillsSettings( + page: Page, + sandbox: AgentSkillsSandbox, + options: { compact?: boolean } = {}, +): Promise { + await gotoAppShell(page); + if (options.compact) { + await openCompactSettings(page, buildOpenProjectRoute()); + } else { + await openSettings(page); + } + await addDirectHostFromSettings(page, { + host: "127.0.0.1", + port: sandbox.daemon.port, + }); + if (options.compact) await goBackInSettings(page); + await selectSettingsHost(page, sandbox.daemon.serverId); + await openHostSection(page, sandbox.daemon.serverId, "agents"); + await expect(page.getByTestId("host-agent-skills-card")).toBeVisible(); +} + +export async function openSkillSelection(page: Page): Promise { + await page.getByRole("button", { name: "Choose skills", exact: true }).click(); + await expectSkillSelectionOpen(page); +} + +export async function expectSkillSelectionOpen(page: Page): Promise { + await expect(page.getByRole("switch", { name: "All skills", exact: true })).toBeVisible(); +} + +export async function chooseCustomSkills(page: Page, selected: string[]): Promise { + const all = page.getByRole("switch", { name: "All skills", exact: true }); + await expect(all).toBeChecked(); + await all.click(); + const keep = new Set(selected); + for (const name of await listSkillChoices(page)) { + if (!keep.has(name)) await page.getByRole("checkbox", { name, exact: true }).click(); + } + await expectSkillChoices(page, selected); +} + +export async function chooseAllSkills(page: Page): Promise { + const all = page.getByRole("switch", { name: "All skills", exact: true }); + await expect(all).not.toBeChecked(); + await all.click(); + await expect(all).toBeChecked(); +} + +export async function saveSkillSelection(page: Page): Promise { + await page.getByRole("button", { name: "Save", exact: true }).click(); +} + +export async function cancelSkillSelection(page: Page): Promise { + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0); +} + +export async function toggleSkill(page: Page, name: string): Promise { + await page.getByRole("checkbox", { name, exact: true }).click(); +} + +export function answerNextRemovalWarning( + page: Page, + answer: "accept" | "dismiss", +): Promise { + return new Promise((resolve) => { + page.once("dialog", (dialog) => { + resolve(dialog); + void (answer === "accept" ? dialog.accept() : dialog.dismiss()); + }); + }); +} + +export async function expectInstalledSkills( + sandbox: AgentSkillsSandbox, + expected: string[], +): Promise { + for (const directory of Object.values(sandbox.targets)) { + await expect.poll(() => readInstalledSkills(directory)).toEqual([...expected].sort()); + } +} + +export async function expectSavedSelection( + sandbox: AgentSkillsSandbox, + expected: AgentSkillSelection, +): Promise { + await expect.poll(async () => (await sandbox.getStatus()).selection).toEqual(expected); +} + +export async function expectSkillFile( + sandbox: AgentSkillsSandbox, + target: SkillTargetName, + skill: string, + relativePath: string, +): Promise { + await expect + .poll(() => readFile(path.join(sandbox.targets[target], skill, relativePath), "utf8")) + .not.toBe(""); +} + +export async function expectSkillChoices(page: Page, selected: string[]): Promise { + const expected = new Set(selected); + for (const name of await listSkillChoices(page)) { + await expect(page.getByRole("checkbox", { name, exact: true })).toBeChecked({ + checked: expected.has(name), + }); + } +} + +async function listSkillChoices(page: Page): Promise { + return page + .getByRole("checkbox") + .evaluateAll((elements) => + elements + .map((element) => element.getAttribute("aria-label") ?? "") + .filter((name) => name.length > 0), + ); +} + +async function readInstalledSkills(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }).catch(() => []); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +} diff --git a/packages/app/src/agent-skills/index.tsx b/packages/app/src/agent-skills/index.tsx new file mode 100644 index 000000000..044c6acd1 --- /dev/null +++ b/packages/app/src/agent-skills/index.tsx @@ -0,0 +1,238 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { useFocusEffect } from "@react-navigation/native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { ArrowUpRight, Blocks, Check, Settings2 } from "lucide-react-native"; +import type { AgentSkillOperation, AgentSkillsStatus } from "@getpaseo/protocol/messages"; +import type { TFunction } from "i18next"; +import { Button } from "@/components/ui/button"; +import { SettingsSection } from "@/screens/settings/settings-section"; +import { settingsStyles } from "@/styles/settings"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { openExternalUrl } from "@/utils/open-external-url"; +import { SkillSelectionSheet } from "./selection-sheet"; +import { useAgentSkills } from "./use-agent-skills"; + +const OP_KIND_ORDER: Record = { + add: 0, + update: 1, + delete: 2, +}; +const ThemedBlocks = withUnistyles(Blocks); +const ThemedCheck = withUnistyles(Check); +const ThemedSettings = withUnistyles(Settings2); +const ThemedArrowUpRight = withUnistyles(ArrowUpRight); +const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; +const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function formatUpdateMessage(ops: readonly AgentSkillOperation[], t: TFunction): string { + return [...ops] + .sort((a, b) => OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind] || a.name.localeCompare(b.name)) + .map((op) => `${t(`settings.host.skills.operations.${op.kind}`)} ${op.name}`) + .join("\n"); +} + +export function AgentSkillsSection({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + const skills = useAgentSkills(serverId); + const refreshSkills = skills.refresh; + const skillsConnected = skills.connected; + const skillsSupported = skills.supported; + const [isChoosing, setIsChoosing] = useState(false); + useFocusEffect( + useCallback(() => { + if (skillsConnected && skillsSupported) void refreshSkills(); + return undefined; + }, [refreshSkills, skillsConnected, skillsSupported]), + ); + const maintenanceOps = useMemo( + () => skills.status?.ops.filter((op) => op.kind !== "delete") ?? [], + [skills.status?.ops], + ); + const state = + skills.status?.state === "drift" && maintenanceOps.length === 0 + ? "up-to-date" + : (skills.status?.state ?? null); + const hasSelected = + (skills.status?.selection.mode === "all" && skills.status.available.length > 0) || + (skills.status?.selection.mode === "custom" && + skills.status.selection.skills.some((name) => skills.status?.available.includes(name))); + + const handleReconcile = useCallback(async () => { + if (skills.isWorking) return; + const confirmed = await confirmDialog({ + title: t("settings.host.skills.updateTitle"), + message: + maintenanceOps.length > 0 + ? formatUpdateMessage(maintenanceOps, t) + : t("settings.host.skills.updateFallback"), + confirmLabel: t("settings.host.skills.actions.update"), + }); + if (confirmed) await skills.reconcile(); + }, [maintenanceOps, skills, t]); + const handleUninstall = useCallback(async () => { + if (skills.isWorking) return; + const confirmed = await confirmDialog({ + title: t("settings.host.skills.uninstallTitle"), + message: t("settings.host.skills.uninstallMessage"), + confirmLabel: t("settings.host.skills.actions.uninstall"), + destructive: true, + }); + if (confirmed) await skills.uninstall(); + }, [skills, t]); + const chooseIcon = useMemo( + () => , + [], + ); + const handleOpen = useCallback(() => setIsChoosing(true), []); + const handleClose = useCallback(() => setIsChoosing(false), []); + const handleOpenDocs = useCallback(() => { + void openExternalUrl(SKILLS_DOCS_URL); + }, []); + const docsIcon = useMemo( + () => , + [], + ); + const trailing = useMemo( + () => ( + + ), + [docsIcon, handleOpenDocs, t], + ); + + if (!skills.connected || !skills.supported) { + return ( + + + + + {skills.connected + ? t("settings.host.skills.unsupported") + : t("settings.host.skills.unavailable")} + + + + + ); + } + + return ( + + + + + + + {t("settings.host.skills.title")} + + + {state === "drift" + ? t("settings.host.skills.updateAvailable") + : t("settings.host.skills.description")} + + + + + + ); + } + if (state === "drift") { + return ( + + + + + ); + } + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + rowTitleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, + installedLabel: { flexDirection: "row", alignItems: "center", gap: 4 }, + mutedText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, + actionsRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, + emptyCard: { padding: theme.spacing[4] }, +})); diff --git a/packages/app/src/agent-skills/legacy-migration.test.ts b/packages/app/src/agent-skills/legacy-migration.test.ts new file mode 100644 index 000000000..87c577fb8 --- /dev/null +++ b/packages/app/src/agent-skills/legacy-migration.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; + +import { migrateLegacyAgentSkillsSelection } from "./legacy-migration"; + +describe("legacy agent skills migration", () => { + it("deletes the legacy file only after the daemon confirms persistence", async () => { + const selection = { mode: "custom" as const, skills: ["paseo"] }; + const order: string[] = []; + const client = { + importLegacyAgentSkillsSelection: vi.fn(async () => { + order.push("persist"); + }), + }; + const remove = vi.fn(async () => { + order.push("delete"); + }); + + await expect( + migrateLegacyAgentSkillsSelection(client, async () => selection, remove), + ).resolves.toBe(true); + expect(client.importLegacyAgentSkillsSelection).toHaveBeenCalledWith(selection); + expect(order).toEqual(["persist", "delete"]); + }); + + it("retains the legacy file when daemon persistence fails", async () => { + const failure = new Error("offline"); + const client = { importLegacyAgentSkillsSelection: vi.fn(async () => Promise.reject(failure)) }; + const remove = vi.fn(); + + await expect( + migrateLegacyAgentSkillsSelection(client, async () => ({ mode: "all" }), remove), + ).rejects.toBe(failure); + expect(remove).not.toHaveBeenCalled(); + }); + + it("does nothing when no legacy file remains", async () => { + const client = { importLegacyAgentSkillsSelection: vi.fn() }; + const remove = vi.fn(); + + await expect(migrateLegacyAgentSkillsSelection(client, async () => null, remove)).resolves.toBe( + false, + ); + expect(client.importLegacyAgentSkillsSelection).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/agent-skills/legacy-migration.tsx b/packages/app/src/agent-skills/legacy-migration.tsx new file mode 100644 index 000000000..2a1ab9aa3 --- /dev/null +++ b/packages/app/src/agent-skills/legacy-migration.tsx @@ -0,0 +1,50 @@ +import { useEffect } from "react"; +import type { AgentSkillSelection } from "@getpaseo/protocol/messages"; +import { getDesktopDaemonStatus, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; +import { + deleteLegacySkillSelection, + readLegacySkillSelection, +} from "@/desktop/daemon/desktop-daemon"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; + +interface LegacySelectionClient { + importLegacyAgentSkillsSelection(selection: AgentSkillSelection): Promise; +} + +export async function migrateLegacyAgentSkillsSelection( + client: LegacySelectionClient, + read = readLegacySkillSelection, + remove = deleteLegacySkillSelection, +): Promise { + const selection = await read(); + if (!selection) return false; + await client.importLegacyAgentSkillsSelection(selection); + await remove(); + return true; +} + +// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16. +export function LegacyAgentSkillsMigration() { + const connectedManagedHosts = useSessionStore((state) => + Object.entries(state.sessions) + .filter(([, session]) => session.serverInfo?.desktopManaged === true) + .map(([serverId]) => serverId) + .sort() + .join(","), + ); + useEffect(() => { + if (!shouldUseDesktopDaemon() || !connectedManagedHosts) return; + async function migrate(): Promise { + const status = await getDesktopDaemonStatus(); + if (status.status !== "running" || !status.desktopManaged) return; + const client = getHostRuntimeStore().getSnapshot(status.serverId)?.client; + if (!client) return; + await migrateLegacyAgentSkillsSelection(client); + } + void migrate().catch((error) => { + console.error("[Agent skills] Legacy selection migration failed; will retry", error); + }); + }, [connectedManagedHosts]); + return null; +} diff --git a/packages/app/src/desktop/components/skill-selection-sheet.tsx b/packages/app/src/agent-skills/selection-sheet.tsx similarity index 85% rename from packages/app/src/desktop/components/skill-selection-sheet.tsx rename to packages/app/src/agent-skills/selection-sheet.tsx index 89d974fc4..923d098ce 100644 --- a/packages/app/src/desktop/components/skill-selection-sheet.tsx +++ b/packages/app/src/agent-skills/selection-sheet.tsx @@ -10,7 +10,10 @@ import { SettingsSection } from "@/screens/settings/settings-section"; import { settingsStyles } from "@/styles/settings"; import type { Theme } from "@/styles/theme"; import { confirmDialog } from "@/utils/confirm-dialog"; -import type { SkillSelection, SkillsSaveResult } from "@/desktop/daemon/desktop-daemon"; +import type { + AgentSkillSelection as SkillSelection, + AgentSkillsSaveResult as SkillsSaveResult, +} from "@getpaseo/protocol/messages"; const ThemedCheck = withUnistyles(Check); const checkedIconMapping = (theme: Theme) => ({ color: theme.colors.accentForeground }); @@ -33,7 +36,7 @@ function isSkillSelected(draft: SkillSelection, name: string): boolean { /** * Draft form state only. The saved selection, the convergence across agent skill - * directories, and the resulting status all belong to the desktop skills module. + * directories, and the resulting status all belong to the selected host. */ export function SkillSelectionSheet({ visible, @@ -87,11 +90,11 @@ export function SkillSelectionSheet({ const attempt = await onSave(draft); if (attempt.confirmationRequired) { const confirmed = await confirmDialog({ - title: t("settings.integrations.skills.removeTitle"), - message: t("settings.integrations.skills.removeMessage", { + title: t("settings.host.skills.removeTitle"), + message: t("settings.host.skills.removeMessage", { skills: attempt.confirmationRequired.removals.join(", "), }), - confirmLabel: t("settings.integrations.actions.remove"), + confirmLabel: t("settings.host.skills.actions.remove"), destructive: true, }); if (!confirmed) return; @@ -99,13 +102,13 @@ export function SkillSelectionSheet({ // Something else changed the directories again while the dialog was // up. Ask about the new list rather than deleting it unannounced. if (retry.confirmationRequired) { - setSaveError(t("settings.integrations.skills.saveFailed")); + setSaveError(t("settings.host.skills.saveFailed")); return; } } onClose(); } catch { - setSaveError(t("settings.integrations.skills.saveFailed")); + setSaveError(t("settings.host.skills.saveFailed")); } })(); }, [draft, onClose, onSave, t]); @@ -115,10 +118,7 @@ export function SkillSelectionSheet({ onClose(); }, [isSaving, onClose]); - const header = useMemo( - () => ({ title: t("settings.integrations.skills.choose") }), - [t], - ); + const header = useMemo(() => ({ title: t("settings.host.skills.choose") }), [t]); const footer = useMemo( () => ( @@ -139,8 +139,8 @@ export function SkillSelectionSheet({ testID="skill-selection-save" > {isSaving - ? t("settings.integrations.actions.saving") - : t("settings.integrations.actions.save")} + ? t("settings.host.skills.actions.saving") + : t("settings.host.skills.actions.save")} ), @@ -160,18 +160,14 @@ export function SkillSelectionSheet({ - - {t("settings.integrations.skills.chooseAll")} - - - {t("settings.integrations.skills.chooseAllHint")} - + {t("settings.host.skills.chooseAll")} + {t("settings.host.skills.chooseAllHint")} @@ -179,13 +175,11 @@ export function SkillSelectionSheet({ {/* The sheet body already spaces its blocks apart, so the section owns only the gap between its label and the list the label names. */} - + {available.length === 0 ? ( - - {t("settings.integrations.skills.chooseEmpty")} - + {t("settings.host.skills.chooseEmpty")} ) : ( available.map((name, index) => ( diff --git a/packages/app/src/agent-skills/use-agent-skills.test.tsx b/packages/app/src/agent-skills/use-agent-skills.test.tsx new file mode 100644 index 000000000..edfd87238 --- /dev/null +++ b/packages/app/src/agent-skills/use-agent-skills.test.tsx @@ -0,0 +1,72 @@ +/** @vitest-environment jsdom */ +import React, { type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useAgentSkills } from "./use-agent-skills"; + +const runtime = vi.hoisted(() => ({ + connected: true, + supported: true, + clients: new Map }>(), +})); + +vi.mock("@/runtime/host-features", () => ({ + useHostFeature: (_serverId: string, feature: string) => + feature === "skillManagement" && runtime.supported, +})); +vi.mock("@/runtime/host-runtime", () => ({ + useHostRuntimeClient: (serverId: string) => runtime.clients.get(serverId) ?? null, + useHostRuntimeIsConnected: () => runtime.connected, +})); +vi.mock("@/contexts/toast-context", () => ({ + useToast: () => ({ error: vi.fn() }), +})); + +function wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +describe("host agent skills", () => { + beforeEach(() => { + runtime.connected = true; + runtime.supported = true; + runtime.clients.clear(); + }); + + it("reads status from the selected host client", async () => { + const local = { getAgentSkillsStatus: vi.fn() }; + const remote = { + getAgentSkillsStatus: vi.fn(async () => ({ + state: "not-installed", + ops: [], + available: ["paseo"], + installed: [], + selection: { mode: "all" }, + })), + }; + runtime.clients.set("local", local); + runtime.clients.set("remote", remote); + + const { result } = renderHook(() => useAgentSkills("remote"), { wrapper }); + await waitFor(() => expect(result.current.status?.available).toEqual(["paseo"])); + expect(remote.getAgentSkillsStatus).toHaveBeenCalledOnce(); + expect(local.getAgentSkillsStatus).not.toHaveBeenCalled(); + }); + + it("does not call an old host without the capability", async () => { + runtime.supported = false; + const client = { getAgentSkillsStatus: vi.fn() }; + runtime.clients.set("old-host", client); + + const { result } = renderHook(() => useAgentSkills("old-host"), { wrapper }); + expect(result.current.supported).toBe(false); + expect(client.getAgentSkillsStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/agent-skills/use-agent-skills.ts b/packages/app/src/agent-skills/use-agent-skills.ts new file mode 100644 index 000000000..d2c4612c7 --- /dev/null +++ b/packages/app/src/agent-skills/use-agent-skills.ts @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import type { + AgentSkillSelection, + AgentSkillsSaveResult, + AgentSkillsStatus, +} from "@getpaseo/protocol/messages"; +import { useToast } from "@/contexts/toast-context"; +import { useFetchQuery } from "@/data/query"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; + +export function useAgentSkills(serverId: string) { + const { t } = useTranslation(); + const toast = useToast(); + const queryClient = useQueryClient(); + const client = useHostRuntimeClient(serverId); + const connected = useHostRuntimeIsConnected(serverId); + const supported = useHostFeature(serverId, "skillManagement"); + const queryKey = useMemo(() => ["host", serverId, "agent-skills"] as const, [serverId]); + const reportedQueryError = useRef(null); + const report = useCallback( + (message: string, error: Error) => { + console.error(`[Agent skills: ${serverId}]`, error); + toast.error(message); + }, + [serverId, toast], + ); + const query = useFetchQuery({ + queryKey, + queryFn: () => { + if (!client) throw new Error(t("settings.host.skills.unavailable")); + return client.getAgentSkillsStatus(); + }, + enabled: supported && client !== null, + retry: false, + dataShape: "value", + staleTimeMs: 0, + }); + useEffect(() => { + if (!query.error || query.error === reportedQueryError.current) return; + reportedQueryError.current = query.error; + report(t("settings.host.skills.statusFailed"), query.error); + }, [query.error, report, t]); + const setStatus = useCallback( + (status: AgentSkillsStatus) => queryClient.setQueryData(queryKey, status), + [queryClient, queryKey], + ); + const reconcile = useMutation({ + mutationFn: async () => { + if (!client) throw new Error(t("settings.host.skills.unavailable")); + return client.reconcileAgentSkills(); + }, + onSuccess: setStatus, + onError: (error: Error) => report(t("settings.host.skills.updateFailed"), error), + }); + const uninstall = useMutation({ + mutationFn: async () => { + if (!client) throw new Error(t("settings.host.skills.unavailable")); + return client.uninstallAgentSkills(); + }, + onSuccess: setStatus, + onError: (error: Error) => report(t("settings.host.skills.uninstallFailed"), error), + }); + const save = useMutation< + AgentSkillsSaveResult, + Error, + { selection: AgentSkillSelection; confirmedRemovals: readonly string[] } + >({ + mutationFn: async ({ selection, confirmedRemovals }) => { + if (!client) throw new Error(t("settings.host.skills.unavailable")); + return client.saveAgentSkillsSelection(selection, confirmedRemovals); + }, + onSuccess: setStatus, + onError: (error) => report(t("settings.host.skills.saveSelectionFailed"), error), + }); + const isWorking = reconcile.isPending || uninstall.isPending || save.isPending; + const refetch = query.refetch; + const reconcileAsync = reconcile.mutateAsync; + const uninstallAsync = uninstall.mutateAsync; + const saveAsync = save.mutateAsync; + const refresh = useCallback(async () => { + await refetch(); + }, [refetch]); + const runReconcile = useCallback(async () => { + await reconcileAsync().catch(() => undefined); + }, [reconcileAsync]); + const runUninstall = useCallback(async () => { + await uninstallAsync().catch(() => undefined); + }, [uninstallAsync]); + const saveSelection = useCallback( + (selection: AgentSkillSelection, confirmedRemovals: readonly string[] = []) => + saveAsync({ selection, confirmedRemovals }), + [saveAsync], + ); + return { + connected, + supported, + status: query.data ?? null, + isLoading: query.isLoading, + isWorking, + refresh, + reconcile: runReconcile, + uninstall: runUninstall, + saveSelection, + }; +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 1f3442e2e..2bfef0985 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -72,6 +72,7 @@ import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-rout import { ThemedStack } from "@/navigation/themed-stack"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { AgentNavigationListener } from "@/desktop/agent-navigation"; +import { LegacyAgentSkillsMigration } from "@/agent-skills/legacy-migration"; import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration"; import { listenToDesktopEvent } from "@/desktop/electron/events"; import { updateDesktopWindowControls } from "@/desktop/electron/window"; @@ -596,6 +597,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon + diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index c3d3847c9..7d859beef 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -1,179 +1,53 @@ -import { useCallback, useMemo, useState } from "react"; -import type { TFunction } from "i18next"; +import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; import { useFocusEffect } from "@react-navigation/native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { ArrowUpRight, Terminal, Blocks, Check, Settings2 } from "lucide-react-native"; -import { settingsStyles } from "@/styles/settings"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { ArrowUpRight, Check, Terminal } from "lucide-react-native"; import { Button } from "@/components/ui/button"; +import { useCliInstall } from "@/desktop/hooks/use-install-status"; +import { SettingsSection } from "@/screens/settings/settings-section"; +import { settingsStyles } from "@/styles/settings"; import { openExternalUrl } from "@/utils/open-external-url"; -import { confirmDialog } from "@/utils/confirm-dialog"; -import { - shouldUseDesktopDaemon, - type SkillOp, - type SkillsSnapshot, -} from "@/desktop/daemon/desktop-daemon"; -import { SkillSelectionSheet } from "@/desktop/components/skill-selection-sheet"; -import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status"; +import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; const CLI_DOCS_URL = "https://paseo.sh/docs/cli"; -const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; -const OP_KIND_ORDER: Record = { add: 0, update: 1, delete: 2 }; -const OP_KIND_LABEL_KEY: Record = { - add: "settings.integrations.operations.add", - update: "settings.integrations.operations.update", - delete: "settings.integrations.operations.delete", -}; - -function formatUpdateMessage(ops: readonly SkillOp[], t: TFunction): string { - const sorted = [...ops].sort((a, b) => { - const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind]; - return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name); - }); - return sorted.map((op) => `${t(OP_KIND_LABEL_KEY[op.kind])} ${op.name}`).join("\n"); -} export function IntegrationsSection() { const { t } = useTranslation(); const { theme } = useUnistyles(); const showSection = shouldUseDesktopDaemon(); - const { - status: cliStatus, - isInstalling: isInstallingCli, - install: installCli, - refresh: refreshCliStatus, - } = useCliInstall(); - const { - status: skillsStatus, - isWorking: isSkillsWorking, - install: installSkills, - update: updateSkills, - uninstall: uninstallSkills, - saveSelection: saveSkillSelection, - refresh: refreshSkillsStatus, - } = useSkillsStatus(); - const [isChoosingSkills, setIsChoosingSkills] = useState(false); - const skillMaintenanceOps = useMemo( - () => skillsStatus?.ops.filter((op) => op.kind !== "delete") ?? [], - [skillsStatus?.ops], - ); - + const { status, isInstalling, install, refresh } = useCliInstall(); useFocusEffect( useCallback(() => { - if (!showSection) return undefined; - refreshCliStatus(); - void refreshSkillsStatus(); + if (showSection) refresh(); return undefined; - }, [refreshCliStatus, refreshSkillsStatus, showSection]), + }, [refresh, showSection]), ); - - const handleInstallCli = useCallback(() => { - if (isInstallingCli) return; - installCli(); - }, [installCli, isInstallingCli]); - - const handleInstallSkills = useCallback(() => { - if (isSkillsWorking) return; - void installSkills(); - }, [installSkills, isSkillsWorking]); - - const handleUpdateSkills = useCallback(async () => { - if (isSkillsWorking) return; - const confirmed = await confirmDialog({ - title: t("settings.integrations.skills.updateTitle"), - message: - skillMaintenanceOps.length > 0 - ? formatUpdateMessage(skillMaintenanceOps, t) - : t("settings.integrations.skills.updateFallback"), - confirmLabel: t("settings.integrations.actions.update"), - }); - if (!confirmed) return; - await updateSkills(); - }, [isSkillsWorking, skillMaintenanceOps, t, updateSkills]); - - const handleUninstallSkills = useCallback(async () => { - if (isSkillsWorking) return; - const confirmed = await confirmDialog({ - title: t("settings.integrations.skills.uninstallTitle"), - message: t("settings.integrations.skills.uninstallMessage"), - confirmLabel: t("settings.integrations.actions.uninstall"), - destructive: true, - }); - if (!confirmed) return; - await uninstallSkills(); - }, [isSkillsWorking, t, uninstallSkills]); - - const handleOpenSkillSelection = useCallback(() => { - setIsChoosingSkills(true); - }, []); - - const handleCloseSkillSelection = useCallback(() => { - setIsChoosingSkills(false); - }, []); - - const handleOpenCliDocs = useCallback(() => { - void openExternalUrl(CLI_DOCS_URL); - }, []); - - const handleOpenSkillsDocs = useCallback(() => { - void openExternalUrl(SKILLS_DOCS_URL); - }, []); - - const arrowIcon = useMemo( + const docsIcon = useMemo( () => , [theme.iconSize.sm, theme.colors.foregroundMuted], ); - - const chooseSkillsIcon = useMemo( - () => , - [theme.iconSize.sm, theme.colors.foregroundMuted], - ); - + const handleOpenDocs = useCallback(() => { + void openExternalUrl(CLI_DOCS_URL); + }, []); const trailing = useMemo( () => ( - - - - + ), - [arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs, t], + [docsIcon, handleOpenDocs, t], ); - - if (!showSection) { - return null; - } - - const skillsState = - skillsStatus?.state === "drift" && skillMaintenanceOps.length === 0 - ? "up-to-date" - : (skillsStatus?.state ?? null); - const hasSelectedSkills = - (skillsStatus?.selection.mode === "all" && skillsStatus.available.length > 0) || - (skillsStatus?.selection.mode === "custom" && - skillsStatus.selection.skills.some((name) => skillsStatus.available.includes(name))); - + if (!showSection) return null; return ( @@ -189,144 +63,26 @@ export function IntegrationsSection() { {t("settings.integrations.commandLine.description")} - {cliStatus?.installed ? ( + {status?.installed ? ( {t("settings.integrations.actions.installed")} ) : ( - )} - - - - - {t("settings.integrations.skills.title")} - - - {skillsState === "drift" - ? t("settings.integrations.skills.updateAvailable") - : t("settings.integrations.skills.description")} - - - - - - ); - } - - if (state === "drift") { - return ( - - - - - ); - } - - return ( - - ); -} - const styles = StyleSheet.create((theme) => ({ - headerLinks: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[0], - }, - rowTitleRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - installedLabel: { - flexDirection: "row", - alignItems: "center", - gap: 4, - }, - mutedText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - }, - actionsRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, + rowTitleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, + installedLabel: { flexDirection: "row", alignItems: "center", gap: 4 }, + mutedText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base }, })); diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index af202a73a..04cad6cc1 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -1,12 +1,6 @@ import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { invokeDesktopCommand } from "@/desktop/electron/invoke"; -import { - parseSkillsSaveResult, - parseSkillsSnapshot, - type SkillSelection, - type SkillsSaveResult, - type SkillsSnapshot, -} from "@/desktop/daemon/skills-snapshot"; +import type { AgentSkillSelection } from "@getpaseo/protocol/messages"; export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored"; export type DesktopDaemonStopReason = @@ -232,38 +226,12 @@ export async function installCli(): Promise { return parseInstallStatus(await invokeDesktopCommand("install_cli")); } -export type { - SkillOp, - SkillSelection, - SkillsSaveResult, - SkillsSnapshot, - SkillsState, -} from "@/desktop/daemon/skills-snapshot"; - -export async function getSkillsSnapshot(): Promise { - return parseSkillsSnapshot(await invokeDesktopCommand("get_skills_status")); +// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16. +export function readLegacySkillSelection(): Promise { + return invokeDesktopCommand("read_legacy_skill_selection") as Promise; } -export async function installSkills(): Promise { - return parseSkillsSnapshot(await invokeDesktopCommand("install_skills")); -} - -export async function updateSkills(): Promise { - return parseSkillsSnapshot(await invokeDesktopCommand("update_skills")); -} - -export async function uninstallSkills(): Promise { - return parseSkillsSnapshot(await invokeDesktopCommand("uninstall_skills")); -} - -export async function saveSkillsSelection( - selection: SkillSelection, - confirmedRemovals: readonly string[] = [], -): Promise { - return parseSkillsSaveResult( - await invokeDesktopCommand("save_skills_selection", { - ...selection, - confirmedRemovals: [...confirmedRemovals], - }), - ); +// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16. +export async function deleteLegacySkillSelection(): Promise { + await invokeDesktopCommand("delete_legacy_skill_selection"); } diff --git a/packages/app/src/desktop/daemon/skills-snapshot.test.ts b/packages/app/src/desktop/daemon/skills-snapshot.test.ts deleted file mode 100644 index 3a860deb7..000000000 --- a/packages/app/src/desktop/daemon/skills-snapshot.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseSkillsSaveResult, parseSkillsSnapshot } from "./skills-snapshot"; - -describe("parseSkillsSnapshot", () => { - it("parses a full snapshot from the desktop command surface", () => { - expect( - parseSkillsSnapshot({ - state: "drift", - ops: [ - { kind: "add", name: "paseo-loop" }, - { kind: "delete", name: "paseo-chat" }, - ], - available: ["paseo", "paseo-loop"], - installed: ["paseo"], - selection: { mode: "custom", skills: ["paseo", "paseo-loop"] }, - }), - ).toEqual({ - state: "drift", - ops: [ - { kind: "add", name: "paseo-loop" }, - { kind: "delete", name: "paseo-chat" }, - ], - available: ["paseo", "paseo-loop"], - installed: ["paseo"], - selection: { mode: "custom", skills: ["paseo", "paseo-loop"] }, - }); - }); - - it("parses the installed skills the host reports", () => { - expect( - parseSkillsSnapshot({ - state: "drift", - ops: [{ kind: "add", name: "paseo-loop" }], - available: ["paseo", "paseo-loop"], - installed: ["paseo-loop", 7], - selection: { mode: "all" }, - }).installed, - ).toEqual(["paseo-loop"]); - }); - - it("assumes the saved selection is installed when the host does not report it", () => { - // An older host has no `installed` field. Assuming the selection is on disk - // keeps the destructive confirmation firing rather than silently skipping it. - expect( - parseSkillsSnapshot({ - state: "up-to-date", - ops: [], - available: ["paseo", "paseo-advisor", "paseo-loop"], - selection: { mode: "custom", skills: ["paseo", "paseo-loop"] }, - }).installed, - ).toEqual(["paseo", "paseo-loop"]); - }); - - it("assumes every bundled skill is installed for an all selection from an older host", () => { - expect( - parseSkillsSnapshot({ - state: "up-to-date", - ops: [], - available: ["paseo", "paseo-loop"], - }).installed, - ).toEqual(["paseo", "paseo-loop"]); - }); - - it("reads a snapshot with no saved selection as all skills", () => { - expect(parseSkillsSnapshot({ state: "up-to-date", ops: [], available: ["paseo"] })).toEqual({ - state: "up-to-date", - ops: [], - available: ["paseo"], - installed: ["paseo"], - selection: { mode: "all" }, - }); - }); - - it("drops catalog and selection entries that are not skill names", () => { - expect( - parseSkillsSnapshot({ - state: "up-to-date", - ops: [], - available: ["paseo", 7, null, "paseo-loop"], - selection: { mode: "custom", skills: ["paseo", 7] }, - }), - ).toEqual({ - state: "up-to-date", - ops: [], - available: ["paseo", "paseo-loop"], - installed: ["paseo"], - selection: { mode: "custom", skills: ["paseo"] }, - }); - }); - - it("reads the removals the host wants confirmed", () => { - expect( - parseSkillsSaveResult({ - state: "up-to-date", - ops: [], - available: ["paseo", "paseo-loop"], - installed: ["paseo", "paseo-loop"], - selection: { mode: "all" }, - confirmationRequired: { removals: ["paseo-loop", 7] }, - }).confirmationRequired, - ).toEqual({ removals: ["paseo-loop"] }); - }); - - it("treats a save with no confirmation request as applied", () => { - expect( - parseSkillsSaveResult({ - state: "up-to-date", - ops: [], - available: ["paseo"], - installed: ["paseo"], - selection: { mode: "all" }, - }).confirmationRequired, - ).toBeNull(); - }); - - it("rejects a response that is not an object", () => { - expect(() => parseSkillsSnapshot("nope")).toThrow("Unexpected skills status response."); - }); - - it("rejects an unknown install state", () => { - expect(() => parseSkillsSnapshot({ state: "half-installed", ops: [] })).toThrow( - "Unexpected skills status state: half-installed", - ); - }); - - it("rejects an unknown pending operation kind", () => { - expect(() => - parseSkillsSnapshot({ state: "drift", ops: [{ kind: "relocate", name: "paseo" }] }), - ).toThrow("Unexpected skill op kind: relocate"); - }); -}); diff --git a/packages/app/src/desktop/daemon/skills-snapshot.ts b/packages/app/src/desktop/daemon/skills-snapshot.ts deleted file mode 100644 index 5129e1215..000000000 --- a/packages/app/src/desktop/daemon/skills-snapshot.ts +++ /dev/null @@ -1,113 +0,0 @@ -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 = { mode: "all" } | { mode: "custom"; skills: string[] }; - -export interface SkillsSnapshot { - state: SkillsState; - ops: SkillOp[]; - /** Every skill the host currently bundles, sorted. The selectable catalog. */ - available: string[]; - /** - * Skills with a directory in at least one agent home. An `add` op only means - * "missing from at least one target", so it cannot stand in for this. - */ - installed: string[]; - selection: SkillSelection; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function parseSkillsState(value: unknown): SkillsState { - switch (value) { - case "not-installed": - case "up-to-date": - case "drift": - return value; - default: - throw new Error(`Unexpected skills status state: ${String(value)}`); - } -} - -function parseSkillOp(raw: unknown): SkillOp { - if (!isRecord(raw)) { - throw new Error("Unexpected skill op response."); - } - const name = typeof raw.name === "string" ? raw.name.trim() : ""; - if (!name) throw new Error("Skill op missing name."); - switch (raw.kind) { - case "add": - return { kind: "add", name }; - case "update": - return { kind: "update", name }; - case "delete": - return { kind: "delete", name }; - default: - throw new Error(`Unexpected skill op kind: ${String(raw.kind)}`); - } -} - -function parseSkillNames(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value.filter((entry): entry is string => typeof entry === "string"); -} - -function parseSkillSelection(value: unknown): SkillSelection { - // A host that predates the picker sends no selection; it manages every - // bundled skill, which is exactly what `all` means. - if (!isRecord(value)) return { mode: "all" }; - if (value.mode === "custom") return { mode: "custom", skills: parseSkillNames(value.skills) }; - return { mode: "all" }; -} - -function selectedSkills(selection: SkillSelection, available: readonly string[]): string[] { - if (selection.mode === "all") return [...available]; - const chosen = new Set(selection.skills); - return available.filter((name) => chosen.has(name)); -} - -export interface SkillsSaveResult extends SkillsSnapshot { - /** - * Non-null when the host refused the save because these directories would be - * deleted. Confirm the names with the user and retry with them. - */ - confirmationRequired: { removals: string[] } | null; -} - -export function parseSkillsSaveResult(raw: unknown): SkillsSaveResult { - const snapshot = parseSkillsSnapshot(raw); - const required = - isRecord(raw) && isRecord(raw.confirmationRequired) - ? parseSkillNames(raw.confirmationRequired.removals) - : []; - return { - ...snapshot, - confirmationRequired: required.length > 0 ? { removals: required } : null, - }; -} - -export function parseSkillsSnapshot(raw: unknown): SkillsSnapshot { - if (!isRecord(raw)) { - throw new Error("Unexpected skills status response."); - } - const available = parseSkillNames(raw.available); - const selection = parseSkillSelection(raw.selection); - return { - state: parseSkillsState(raw.state), - ops: Array.isArray(raw.ops) ? raw.ops.map(parseSkillOp) : [], - available, - // A host that predates this field cannot say what is on disk. Assume the - // saved selection is, so removing a skill still asks before deleting it. - installed: Array.isArray(raw.installed) - ? parseSkillNames(raw.installed) - : selectedSkills(selection, available), - selection, - }; -} diff --git a/packages/app/src/desktop/hooks/use-install-status.test.tsx b/packages/app/src/desktop/hooks/use-install-status.test.tsx deleted file mode 100644 index fda486986..000000000 --- a/packages/app/src/desktop/hooks/use-install-status.test.tsx +++ /dev/null @@ -1,282 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { i18n } from "@/i18n/i18next"; -import { useCliInstall, useSkillsStatus } from "./use-install-status"; - -const toast = vi.hoisted(() => ({ - error: vi.fn(), - show: vi.fn(), - copied: vi.fn(), -})); - -const desktopDaemon = vi.hoisted(() => ({ - getCliInstallStatus: vi.fn(), - installCli: vi.fn(), - getSkillsSnapshot: vi.fn(), - installSkills: vi.fn(), - updateSkills: vi.fn(), - uninstallSkills: vi.fn(), - saveSkillsSelection: vi.fn(), - shouldUseDesktopDaemon: vi.fn(() => true), -})); - -vi.mock("@/contexts/toast-context", () => ({ - useToast: () => toast, -})); - -vi.mock("@/desktop/daemon/desktop-daemon", () => desktopDaemon); - -function createQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); -} - -function renderDesktopHook(callback: () => TResult) { - const queryClient = createQueryClient(); - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - - return renderHook(callback, { wrapper }); -} - -describe("useCliInstall", () => { - beforeEach(() => { - vi.spyOn(console, "error").mockImplementation(() => {}); - desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: true }); - desktopDaemon.installCli.mockResolvedValue({ installed: true }); - }); - - afterEach(() => { - void i18n.changeLanguage("en"); - vi.restoreAllMocks(); - vi.clearAllMocks(); - }); - - it("loads CLI install status", async () => { - const { result } = renderDesktopHook(() => useCliInstall()); - - await waitFor(() => { - expect(result.current.status).toEqual({ installed: true }); - }); - - expect(toast.error).not.toHaveBeenCalled(); - }); - - it("toasts and exposes CLI install errors", async () => { - const error = new Error("Missing IPC handler"); - desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: false }); - desktopDaemon.installCli.mockRejectedValue(error); - const { result } = renderDesktopHook(() => useCliInstall()); - - await waitFor(() => { - expect(result.current.status).toEqual({ installed: false }); - }); - - act(() => { - result.current.install(); - }); - - await waitFor(() => { - expect(result.current.error).toBe(error); - }); - - expect(toast.error).toHaveBeenCalledWith("Unable to install the Paseo CLI."); - expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install CLI", error); - }); - - it("uses the active language for CLI install errors", async () => { - await i18n.changeLanguage("zh-CN"); - const error = new Error("Missing IPC handler"); - desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: false }); - desktopDaemon.installCli.mockRejectedValue(error); - const { result } = renderDesktopHook(() => useCliInstall()); - - await waitFor(() => { - expect(result.current.status).toEqual({ installed: false }); - }); - - act(() => { - result.current.install(); - }); - - await waitFor(() => { - expect(result.current.error).toBe(error); - }); - - expect(toast.error).toHaveBeenCalledWith("无法安装 Paseo CLI。"); - }); -}); - -describe("useSkillsStatus", () => { - beforeEach(() => { - vi.spyOn(console, "error").mockImplementation(() => {}); - }); - - afterEach(() => { - void i18n.changeLanguage("en"); - vi.restoreAllMocks(); - vi.clearAllMocks(); - }); - - it("loads the current skills status", async () => { - desktopDaemon.getSkillsSnapshot.mockResolvedValue({ - state: "up-to-date", - ops: [], - }); - - const { result } = renderDesktopHook(() => useSkillsStatus()); - - await waitFor(() => { - expect(result.current.status).toEqual({ state: "up-to-date", ops: [] }); - }); - expect(result.current.isWorking).toBe(false); - expect(toast.error).not.toHaveBeenCalled(); - }); - - it("install transitions a not-installed status to up-to-date and reflects the response directly", async () => { - desktopDaemon.getSkillsSnapshot.mockResolvedValue({ - state: "not-installed", - ops: [{ kind: "add", name: "paseo" }], - }); - desktopDaemon.installSkills.mockResolvedValue({ state: "up-to-date", ops: [] }); - - const { result } = renderDesktopHook(() => useSkillsStatus()); - - await waitFor(() => { - expect(result.current.status?.state).toBe("not-installed"); - }); - - await act(async () => { - await result.current.install(); - }); - - expect(desktopDaemon.installSkills).toHaveBeenCalledOnce(); - await waitFor(() => { - expect(result.current.status).toEqual({ state: "up-to-date", ops: [] }); - }); - }); - - it("update transitions drift to up-to-date", async () => { - desktopDaemon.getSkillsSnapshot.mockResolvedValue({ - state: "drift", - ops: [{ kind: "update", name: "paseo" }], - }); - desktopDaemon.updateSkills.mockResolvedValue({ state: "up-to-date", ops: [] }); - - const { result } = renderDesktopHook(() => useSkillsStatus()); - - await waitFor(() => { - expect(result.current.status?.state).toBe("drift"); - }); - - await act(async () => { - await result.current.update(); - }); - - expect(desktopDaemon.updateSkills).toHaveBeenCalledOnce(); - await waitFor(() => { - expect(result.current.status).toEqual({ state: "up-to-date", ops: [] }); - }); - }); - - it("uninstall transitions up-to-date back to not-installed", async () => { - desktopDaemon.getSkillsSnapshot.mockResolvedValue({ state: "up-to-date", ops: [] }); - desktopDaemon.uninstallSkills.mockResolvedValue({ - state: "not-installed", - ops: [{ kind: "add", name: "paseo" }], - }); - - const { result } = renderDesktopHook(() => useSkillsStatus()); - - await waitFor(() => { - expect(result.current.status?.state).toBe("up-to-date"); - }); - - await act(async () => { - await result.current.uninstall(); - }); - - expect(desktopDaemon.uninstallSkills).toHaveBeenCalledOnce(); - await waitFor(() => { - expect(result.current.status).toEqual({ - state: "not-installed", - ops: [{ kind: "add", name: "paseo" }], - }); - }); - }); - - it("isWorking flips while a mutation is in flight", async () => { - desktopDaemon.getSkillsSnapshot.mockResolvedValue({ - state: "not-installed", - ops: [{ kind: "add", name: "paseo" }], - }); - - let resolveInstall: ((value: unknown) => void) | null = null; - desktopDaemon.installSkills.mockImplementation( - () => - new Promise((resolve) => { - resolveInstall = resolve; - }), - ); - - const { result } = renderDesktopHook(() => useSkillsStatus()); - - await waitFor(() => { - expect(result.current.status?.state).toBe("not-installed"); - }); - expect(result.current.isWorking).toBe(false); - - let installPromise: Promise = Promise.resolve(); - act(() => { - installPromise = result.current.install(); - }); - - await waitFor(() => { - expect(result.current.isWorking).toBe(true); - }); - - await act(async () => { - resolveInstall?.({ state: "up-to-date", ops: [] }); - await installPromise; - }); - - await waitFor(() => { - expect(result.current.isWorking).toBe(false); - }); - expect(result.current.status).toEqual({ state: "up-to-date", ops: [] }); - }); - - it("toasts and exposes errors when install fails", async () => { - const error = new Error("Missing IPC handler"); - desktopDaemon.getSkillsSnapshot.mockResolvedValue({ - state: "not-installed", - ops: [{ kind: "add", name: "paseo" }], - }); - desktopDaemon.installSkills.mockRejectedValue(error); - - const { result } = renderDesktopHook(() => useSkillsStatus()); - - await waitFor(() => { - expect(result.current.status?.state).toBe("not-installed"); - }); - - await act(async () => { - await result.current.install(); - }); - - await waitFor(() => { - expect(result.current.error).toBe(error); - }); - expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills."); - expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error); - }); -}); diff --git a/packages/app/src/desktop/hooks/use-install-status.ts b/packages/app/src/desktop/hooks/use-install-status.ts index 44595710c..98bc3da80 100644 --- a/packages/app/src/desktop/hooks/use-install-status.ts +++ b/packages/app/src/desktop/hooks/use-install-status.ts @@ -3,17 +3,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { getCliInstallStatus, - getSkillsSnapshot, installCli, - installSkills, - saveSkillsSelection, shouldUseDesktopDaemon, type InstallStatus, - type SkillSelection, - type SkillsSaveResult, - type SkillsSnapshot, - uninstallSkills, - updateSkills, } from "@/desktop/daemon/desktop-daemon"; import { useDesktopIpcErrorReporter, @@ -21,7 +13,6 @@ import { } from "@/desktop/hooks/desktop-ipc-error"; const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const; -const SKILLS_STATUS_QUERY_KEY = ["desktop", "integrations", "skills-status"] as const; interface DesktopInstallHookResult { status: InstallStatus | null; @@ -80,144 +71,3 @@ export function useCliInstall(): DesktopInstallHookResult { refresh, }; } - -export interface SkillsStatusHookResult { - status: SkillsSnapshot | null; - isLoading: boolean; - isWorking: boolean; - error: Error | null; - refresh: () => Promise; - install: () => Promise; - update: () => Promise; - uninstall: () => Promise; - saveSelection: ( - selection: SkillSelection, - confirmedRemovals?: readonly string[], - ) => Promise; -} - -export function useSkillsStatus(): SkillsStatusHookResult { - const { t } = useTranslation(); - const queryClient = useQueryClient(); - const reportError = useDesktopIpcErrorReporter(); - const enabled = shouldUseDesktopDaemon(); - - const statusQuery = useQuery({ - queryKey: SKILLS_STATUS_QUERY_KEY, - queryFn: getSkillsSnapshot, - enabled, - retry: false, - }); - const { data: status, error: statusError, isLoading, refetch } = statusQuery; - useDesktopIpcQueryErrorToast({ - error: statusQuery.error, - message: t("desktop.integrations.skills.statusFailed"), - logLabel: "[Integrations] Failed to load skills status", - }); - - const setStatus = useCallback( - (next: SkillsSnapshot) => { - queryClient.setQueryData(SKILLS_STATUS_QUERY_KEY, next); - }, - [queryClient], - ); - - const installMutation = useMutation({ - mutationFn: installSkills, - onError: (error) => { - reportError({ - error, - message: t("desktop.integrations.skills.installFailed"), - logLabel: "[Integrations] Failed to install skills", - }); - }, - onSuccess: setStatus, - }); - - const updateMutation = useMutation({ - mutationFn: updateSkills, - onError: (error) => { - reportError({ - error, - message: t("desktop.integrations.skills.updateFailed"), - logLabel: "[Integrations] Failed to update skills", - }); - }, - onSuccess: setStatus, - }); - - const uninstallMutation = useMutation({ - mutationFn: uninstallSkills, - onError: (error) => { - reportError({ - error, - message: t("desktop.integrations.skills.uninstallFailed"), - logLabel: "[Integrations] Failed to uninstall skills", - }); - }, - onSuccess: setStatus, - }); - - const saveSelectionMutation = useMutation< - SkillsSaveResult, - Error, - { selection: SkillSelection; confirmedRemovals: readonly string[] } - >({ - mutationFn: ({ selection, confirmedRemovals }) => - saveSkillsSelection(selection, confirmedRemovals), - onError: (error) => { - reportError({ - error, - message: t("desktop.integrations.skills.saveSelectionFailed"), - logLabel: "[Integrations] Failed to save skills selection", - }); - }, - onSuccess: setStatus, - }); - - const isWorking = - installMutation.isPending || - updateMutation.isPending || - uninstallMutation.isPending || - saveSelectionMutation.isPending; - - const refresh = useCallback(async () => { - await refetch(); - }, [refetch]); - - const install = useCallback(async () => { - await installMutation.mutateAsync().catch(() => undefined); - }, [installMutation]); - - const update = useCallback(async () => { - await updateMutation.mutateAsync().catch(() => undefined); - }, [updateMutation]); - - const uninstall = useCallback(async () => { - await uninstallMutation.mutateAsync().catch(() => undefined); - }, [uninstallMutation]); - - const saveSelection = useCallback( - async (selection: SkillSelection, confirmedRemovals: readonly string[] = []) => - saveSelectionMutation.mutateAsync({ selection, confirmedRemovals }), - [saveSelectionMutation], - ); - - return { - status: status ?? null, - isLoading, - isWorking, - error: - statusError ?? - installMutation.error ?? - updateMutation.error ?? - uninstallMutation.error ?? - saveSelectionMutation.error ?? - null, - refresh, - install, - update, - uninstall, - saveSelection, - }; -} diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 3f11aa8be..a9b8743ad 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -258,7 +258,6 @@ describe("translation resources", () => { expect(en.settings.appearance.fonts.interfaceFont).toBe("Interface font"); expect(en.settings.shortcuts.actions.rebind).toBe("Rebind"); expect(en.settings.integrations.commandLine.title).toBe("Command line"); - expect(en.settings.integrations.skills.updateAvailable).toBe("Update available"); expect(en.settings.notifications.playSound).toBe("Play sound"); expect(en.settings.notifications.permission).toBe("Notification permission"); expect(en.settings.notifications.sentTitle).toBe("Test notification sent"); @@ -675,9 +674,6 @@ describe("translation resources", () => { ); expect(en.desktop.daemon.loadFailed).toBe("Unable to load desktop daemon status."); expect(en.desktop.integrations.cli.installFailed).toBe("Unable to install the Paseo CLI."); - expect(en.desktop.integrations.skills.installFailed).toBe( - "Unable to install orchestration skills.", - ); }); it("includes remaining utility chrome keys for the Batch 4T migration", () => { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 1f717663d..be3badda9 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1,4 +1,4 @@ -import type { TranslationResources } from "./en"; +import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ar: TranslationResources = { @@ -1298,13 +1298,6 @@ export const ar: TranslationResources = { statusFailed: "غير قادر على التحقق من حالة تثبيت CLI.", installFailed: "غير قادر على تثبيت PaseoCLI.", }, - skills: { - statusFailed: "غير قادر على التحقق من حالة مهارات التنسيق.", - installFailed: "غير قادر على تثبيت مهارات التنسيق.", - updateFailed: "غير قادر على تحديث مهارات التنسيق.", - uninstallFailed: "غير قادر على إلغاء تثبيت مهارات التنسيق.", - saveSelectionFailed: "غير قادر على حفظ اختيار مهارات التنسيق.", - }, }, }, rootError: { @@ -2081,47 +2074,16 @@ export const ar: TranslationResources = { title: "التكامل", docs: { cli: "مستندات CLI", - skills: "وثائق المهارات", openCli: "افتح وثائق CLI", - openSkills: "فتح وثائق المهارات", }, commandLine: { title: "سطر الأوامر", description: "وكلاء التحكم والبرنامج النصي من المحطة الطرفية الخاصة بك", }, - skills: { - title: "مهارات التنسيق", - description: "قم بتعليم عملائك كيفية التنسيق من خلال CLI", - updateAvailable: "التحديث متاح", - updateTitle: "تحديث مهارات Paseo ؟", - updateFallback: "مزامنة المهارات المجمعة لجهازك.", - uninstallTitle: "إلغاء تثبيت مهارات Paseo ؟", - uninstallMessage: "يزيل جميع مهارات تنسيق Paseo من ~/.agents ، ~/.claude ، ~/.codex.", - choose: "اختيار المهارات", - chooseAll: "كل المهارات", - chooseAllHint: "أبقِ جميع المهارات المرفقة مثبتة، بما فيها ما يُضاف لاحقًا.", - chooseList: "المهارات المرفقة", - chooseEmpty: "لا تتضمن هذه النسخة أي مهارات.", - removeTitle: "إزالة المهارات غير المحددة؟", - removeMessage: - "سيتم حذف {{skills}} من ~/.agents و~/.claude و~/.codex. كما يُحذف كل ما أضفته داخل مجلدات المهارات تلك.", - saveFailed: "تعذر حفظ اختيار المهارات.", - }, actions: { install: "ثَبَّتَ", installing: "جارٍ التثبيت...", installed: "تم التثبيت", - update: "تحديث", - working: "عمل...", - remove: "إزالة", - uninstall: "إلغاء التثبيت", - save: "حفظ", - saving: "جارٍ الحفظ...", - }, - operations: { - add: "أضف مهارة", - update: "تحديث المهارة", - delete: "حذف المهارة", }, }, permissions: { @@ -2191,6 +2153,47 @@ export const ar: TranslationResources = { rowTitle: "إقران جهاز", rowHint: "امسح رمز QR ضوئيًا أو انسخ رابطًا لتوصيل هاتفك بهذا المضيف", }, + skills: { + ...en.settings.host.skills, + sectionTitle: "مهارات التنسيق", + title: "مهارات التنسيق", + description: "قم بتعليم عملائك كيفية التنسيق من خلال CLI", + updateAvailable: "التحديث متاح", + updateTitle: "تحديث مهارات Paseo ؟", + updateFallback: "مزامنة المهارات المجمعة لجهازك.", + uninstallTitle: "إلغاء تثبيت مهارات Paseo ؟", + uninstallMessage: "يزيل جميع مهارات تنسيق Paseo من ~/.agents ، ~/.claude ، ~/.codex.", + choose: "اختيار المهارات", + chooseAll: "كل المهارات", + chooseAllHint: "أبقِ جميع المهارات المرفقة مثبتة، بما فيها ما يُضاف لاحقًا.", + chooseList: "المهارات المرفقة", + chooseEmpty: "لا تتضمن هذه النسخة أي مهارات.", + removeTitle: "إزالة المهارات غير المحددة؟", + removeMessage: + "سيتم حذف {{skills}} من ~/.agents و~/.claude و~/.codex. كما يُحذف كل ما أضفته داخل مجلدات المهارات تلك.", + saveFailed: "تعذر حفظ اختيار المهارات.", + statusFailed: "غير قادر على التحقق من حالة مهارات التنسيق.", + updateFailed: "غير قادر على تحديث مهارات التنسيق.", + uninstallFailed: "غير قادر على إلغاء تثبيت مهارات التنسيق.", + saveSelectionFailed: "غير قادر على حفظ اختيار مهارات التنسيق.", + actions: { + install: "ثَبَّتَ", + installing: "جارٍ التثبيت...", + installed: "تم التثبيت", + update: "تحديث", + working: "عمل...", + remove: "إزالة", + uninstall: "إلغاء التثبيت", + save: "حفظ", + saving: "جارٍ الحفظ...", + cancel: "يلغي", + }, + operations: { + add: "أضف مهارة", + update: "تحديث المهارة", + delete: "حذف المهارة", + }, + }, orchestration: { title: "التنسيق", unavailable: "اتصل بهذا المضيف لإدارة التنسيق", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 20c82b1c7..8e300872b 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1307,13 +1307,6 @@ export const en = { statusFailed: "Unable to check CLI install status.", installFailed: "Unable to install the Paseo CLI.", }, - skills: { - statusFailed: "Unable to check orchestration skills status.", - installFailed: "Unable to install orchestration skills.", - updateFailed: "Unable to update orchestration skills.", - uninstallFailed: "Unable to uninstall orchestration skills.", - saveSelectionFailed: "Unable to save the orchestration skills selection.", - }, }, }, rootError: { @@ -2145,48 +2138,16 @@ export const en = { title: "Integrations", docs: { cli: "CLI docs", - skills: "Skills docs", openCli: "Open CLI documentation", - openSkills: "Open skills documentation", }, commandLine: { title: "Command line", description: "Control and script agents from your terminal", }, - skills: { - title: "Orchestration skills", - description: "Teach your agents to orchestrate through the CLI", - updateAvailable: "Update available", - updateTitle: "Update Paseo skills?", - updateFallback: "Sync bundled skills to your machine.", - uninstallTitle: "Uninstall Paseo skills?", - uninstallMessage: - "Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.", - choose: "Choose skills", - chooseAll: "All skills", - chooseAllHint: "Keep every bundled skill installed, including ones added later.", - chooseList: "Bundled skills", - chooseEmpty: "This build bundles no skills.", - removeTitle: "Remove deselected skills?", - removeMessage: - "{{skills}} will be deleted from ~/.agents, ~/.claude, and ~/.codex. Anything you added inside those skill folders is deleted too.", - saveFailed: "Could not save your skill selection.", - }, actions: { install: "Install", installing: "Installing...", installed: "Installed", - update: "Update", - working: "Working...", - remove: "Remove", - uninstall: "Uninstall", - save: "Save", - saving: "Saving...", - }, - operations: { - add: "Add skill", - update: "Update skill", - delete: "Delete skill", }, }, permissions: { @@ -2256,6 +2217,51 @@ export const en = { rowTitle: "Pair a device", rowHint: "Scan a QR code or copy a link to connect your phone to this host", }, + skills: { + sectionTitle: "Orchestration skills", + title: "Orchestration skills", + description: "Teach your agents to orchestrate through the CLI", + docs: "Skills", + openDocs: "Open skills documentation", + unavailable: "Connect to this host to manage orchestration skills", + unsupported: "Update this host to manage orchestration skills", + statusFailed: "Unable to check orchestration skills status.", + updateFailed: "Unable to update orchestration skills.", + uninstallFailed: "Unable to uninstall orchestration skills.", + saveSelectionFailed: "Unable to save the orchestration skills selection.", + updateAvailable: "Update available", + updateTitle: "Update Paseo skills?", + updateFallback: "Sync bundled skills to this host.", + uninstallTitle: "Uninstall Paseo skills?", + uninstallMessage: + "Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex on this host.", + choose: "Choose skills", + chooseAll: "All skills", + chooseAllHint: "Keep every bundled skill installed, including ones added later.", + chooseList: "Bundled skills", + chooseEmpty: "This host bundles no skills.", + removeTitle: "Remove deselected skills?", + removeMessage: + "{{skills}} will be deleted from ~/.agents, ~/.claude, and ~/.codex on this host. Anything you added inside those skill folders is deleted too.", + saveFailed: "Could not save your skill selection.", + actions: { + install: "Install", + installing: "Installing...", + installed: "Installed", + update: "Update", + working: "Working...", + remove: "Remove", + uninstall: "Uninstall", + save: "Save", + saving: "Saving...", + cancel: "Cancel", + }, + operations: { + add: "Add skill", + update: "Update skill", + delete: "Delete skill", + }, + }, orchestration: { title: "Orchestration", unavailable: "Connect to this host to manage orchestration", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index e9ce560a8..046ffbed0 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1,4 +1,4 @@ -import type { TranslationResources } from "./en"; +import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const es: TranslationResources = { @@ -1338,13 +1338,6 @@ export const es: TranslationResources = { statusFailed: "No se puede verificar el estado de instalación deCLI.", installFailed: "No se puede instalar elPaseoCLI.", }, - skills: { - statusFailed: "No se puede comprobar el estado de las habilidades de orquestación.", - installFailed: "No se pueden instalar habilidades de orquestación.", - updateFailed: "No se pueden actualizar las habilidades de orquestación.", - uninstallFailed: "No se pueden desinstalar las habilidades de orquestación.", - saveSelectionFailed: "No se puede guardar la selección de habilidades de orquestación.", - }, }, }, rootError: { @@ -2129,49 +2122,16 @@ export const es: TranslationResources = { title: "Integraciones", docs: { cli: "DocumentosCLI", - skills: "Documentos de habilidades", openCli: "Abrir la documentación deCLI", - openSkills: "Documentación de habilidades abiertas", }, commandLine: { title: "línea de comando", description: "Agentes de control y script desde tu terminal", }, - skills: { - title: "Habilidades de orquestación", - description: "Enseñe a sus agentes a orquestar a través delCLI", - updateAvailable: "Actualización disponible", - updateTitle: "¿Actualizar las habilidades dePaseo?", - updateFallback: "Sincronice las habilidades incluidas con su máquina.", - uninstallTitle: "¿Desinstalar las habilidadesPaseo?", - uninstallMessage: - "Elimina todas las habilidades de orquestaciónPaseode ~/.agents, ~/.claude, ~/.codex.", - choose: "Elegir habilidades", - chooseAll: "Todas las habilidades", - chooseAllHint: - "Mantén instaladas todas las habilidades incluidas, también las que se agreguen después.", - chooseList: "Habilidades incluidas", - chooseEmpty: "Esta versión no incluye habilidades.", - removeTitle: "¿Eliminar las habilidades deseleccionadas?", - removeMessage: - "{{skills}} se eliminarán de ~/.agents, ~/.claude y ~/.codex. También se elimina todo lo que hayas añadido dentro de esas carpetas de habilidades.", - saveFailed: "No se pudo guardar tu selección de habilidades.", - }, actions: { install: "Instalar", installing: "Instalando...", installed: "Instalado", - update: "Actualizar", - working: "Laboral...", - remove: "Eliminar", - uninstall: "Desinstalar", - save: "Guardar", - saving: "Guardando...", - }, - operations: { - add: "Agregar habilidad", - update: "Actualizar habilidad", - delete: "Eliminar habilidad", }, }, permissions: { @@ -2241,6 +2201,49 @@ export const es: TranslationResources = { rowTitle: "Emparejar un dispositivo", rowHint: "Escanee un códigoQRo copie un enlace para conectar su teléfono a este host", }, + skills: { + ...en.settings.host.skills, + sectionTitle: "Habilidades de orquestación", + title: "Habilidades de orquestación", + description: "Enseñe a sus agentes a orquestar a través delCLI", + updateAvailable: "Actualización disponible", + updateTitle: "¿Actualizar las habilidades dePaseo?", + updateFallback: "Sincronice las habilidades incluidas con su máquina.", + uninstallTitle: "¿Desinstalar las habilidadesPaseo?", + uninstallMessage: + "Elimina todas las habilidades de orquestaciónPaseode ~/.agents, ~/.claude, ~/.codex.", + choose: "Elegir habilidades", + chooseAll: "Todas las habilidades", + chooseAllHint: + "Mantén instaladas todas las habilidades incluidas, también las que se agreguen después.", + chooseList: "Habilidades incluidas", + chooseEmpty: "Esta versión no incluye habilidades.", + removeTitle: "¿Eliminar las habilidades deseleccionadas?", + removeMessage: + "{{skills}} se eliminarán de ~/.agents, ~/.claude y ~/.codex. También se elimina todo lo que hayas añadido dentro de esas carpetas de habilidades.", + saveFailed: "No se pudo guardar tu selección de habilidades.", + statusFailed: "No se puede comprobar el estado de las habilidades de orquestación.", + updateFailed: "No se pueden actualizar las habilidades de orquestación.", + uninstallFailed: "No se pueden desinstalar las habilidades de orquestación.", + saveSelectionFailed: "No se puede guardar la selección de habilidades de orquestación.", + actions: { + install: "Instalar", + installing: "Instalando...", + installed: "Instalado", + update: "Actualizar", + working: "Laboral...", + remove: "Eliminar", + uninstall: "Desinstalar", + save: "Guardar", + saving: "Guardando...", + cancel: "Cancelar", + }, + operations: { + add: "Agregar habilidad", + update: "Actualizar habilidad", + delete: "Eliminar habilidad", + }, + }, orchestration: { title: "Orquestación", unavailable: "Conéctese a este host para administrar la orquestación", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 6a60080d8..637cd88f7 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1,4 +1,4 @@ -import type { TranslationResources } from "./en"; +import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const fr: TranslationResources = { @@ -1340,14 +1340,6 @@ export const fr: TranslationResources = { statusFailed: "Impossible de vérifier l'état de l'installation deCLI.", installFailed: "Impossible d'installer lePaseoCLI.", }, - skills: { - statusFailed: "Impossible de vérifier l'état des compétences d'orchestration.", - installFailed: "Impossible d'installer les compétences d'orchestration.", - updateFailed: "Impossible de mettre à jour les compétences d'orchestration.", - uninstallFailed: "Impossible de désinstaller les compétences d'orchestration.", - saveSelectionFailed: - "Impossible d'enregistrer la sélection des compétences d'orchestration.", - }, }, }, rootError: { @@ -2134,49 +2126,16 @@ export const fr: TranslationResources = { title: "Intégrations", docs: { cli: "DocumentsCLI", - skills: "Documents de compétences", openCli: "Ouvrir la documentationCLI", - openSkills: "Documentation des compétences ouvertes", }, commandLine: { title: "Ligne de commande", description: "Agents de contrôle et de script depuis votre terminal", }, - skills: { - title: "Compétences en orchestration", - description: "Apprenez à vos agents à orchestrer via leCLI", - updateAvailable: "Mise à jour disponible", - updateTitle: "Mettre à jour les compétencesPaseo?", - updateFallback: "Synchronisez les compétences regroupées sur votre machine.", - uninstallTitle: "Désinstaller les compétencesPaseo?", - uninstallMessage: - "Supprime toutes les compétences d'orchestrationPaseode ~/.agents, ~/.claude, ~/.codex.", - choose: "Choisir les compétences", - chooseAll: "Toutes les compétences", - chooseAllHint: - "Gardez installées toutes les compétences fournies, y compris celles ajoutées plus tard.", - chooseList: "Compétences fournies", - chooseEmpty: "Cette version ne fournit aucune compétence.", - removeTitle: "Supprimer les compétences décochées ?", - removeMessage: - "{{skills}} seront supprimées de ~/.agents, ~/.claude et ~/.codex. Tout ce que vous avez ajouté dans ces dossiers de compétences est également supprimé.", - saveFailed: "Impossible d'enregistrer votre sélection de compétences.", - }, actions: { install: "Installer", installing: "Installation...", installed: "Installé", - update: "Mise à jour", - working: "Fonctionnement...", - remove: "Supprimer", - uninstall: "Désinstaller", - save: "Enregistrer", - saving: "Enregistrement...", - }, - operations: { - add: "Ajouter une compétence", - update: "Mettre à jour la compétence", - delete: "Supprimer la compétence", }, }, permissions: { @@ -2246,6 +2205,50 @@ export const fr: TranslationResources = { rowTitle: "Associer un appareil", rowHint: "Scannez un codeQRou copiez un lien pour connecter votre téléphone à cet hôte", }, + skills: { + ...en.settings.host.skills, + sectionTitle: "Compétences en orchestration", + title: "Compétences en orchestration", + description: "Apprenez à vos agents à orchestrer via leCLI", + updateAvailable: "Mise à jour disponible", + updateTitle: "Mettre à jour les compétencesPaseo?", + updateFallback: "Synchronisez les compétences regroupées sur votre machine.", + uninstallTitle: "Désinstaller les compétencesPaseo?", + uninstallMessage: + "Supprime toutes les compétences d'orchestrationPaseode ~/.agents, ~/.claude, ~/.codex.", + choose: "Choisir les compétences", + chooseAll: "Toutes les compétences", + chooseAllHint: + "Gardez installées toutes les compétences fournies, y compris celles ajoutées plus tard.", + chooseList: "Compétences fournies", + chooseEmpty: "Cette version ne fournit aucune compétence.", + removeTitle: "Supprimer les compétences décochées ?", + removeMessage: + "{{skills}} seront supprimées de ~/.agents, ~/.claude et ~/.codex. Tout ce que vous avez ajouté dans ces dossiers de compétences est également supprimé.", + saveFailed: "Impossible d'enregistrer votre sélection de compétences.", + statusFailed: "Impossible de vérifier l'état des compétences d'orchestration.", + updateFailed: "Impossible de mettre à jour les compétences d'orchestration.", + uninstallFailed: "Impossible de désinstaller les compétences d'orchestration.", + saveSelectionFailed: + "Impossible d'enregistrer la sélection des compétences d'orchestration.", + actions: { + install: "Installer", + installing: "Installation...", + installed: "Installé", + update: "Mise à jour", + working: "Fonctionnement...", + remove: "Supprimer", + uninstall: "Désinstaller", + save: "Enregistrer", + saving: "Enregistrement...", + cancel: "Annuler", + }, + operations: { + add: "Ajouter une compétence", + update: "Mettre à jour la compétence", + delete: "Supprimer la compétence", + }, + }, orchestration: { title: "Orchestration", unavailable: "Connectez-vous à cet hôte pour gérer l'orchestration", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 270a9820e..cd8cc0a50 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1,4 +1,4 @@ -import type { TranslationResources } from "./en"; +import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ja: TranslationResources = { @@ -1311,13 +1311,6 @@ export const ja: TranslationResources = { statusFailed: "CLIのインストール状態を確認できません。", installFailed: "Paseo CLIをインストールできません。", }, - skills: { - statusFailed: "オーケストレーションスキルのステータスを確認できません。", - installFailed: "オーケストレーションスキルをインストールできません。", - updateFailed: "オーケストレーションスキルを更新できません。", - uninstallFailed: "オーケストレーションスキルをアンインストールできません。", - saveSelectionFailed: "オーケストレーションスキルの選択を保存できません。", - }, }, }, rootError: { @@ -2099,49 +2092,16 @@ export const ja: TranslationResources = { title: "連携", docs: { cli: "CLIドキュメント", - skills: "スキルドキュメント", openCli: "CLIドキュメントを開く", - openSkills: "スキルドキュメントを開く", }, commandLine: { title: "コマンドライン", description: "ターミナルからエージェントを制御し、スクリプトで操作", }, - skills: { - title: "オーケストレーションスキル", - description: "エージェントがCLI経由でオーケストレーションできるようにします。", - updateAvailable: "更新が利用可能", - updateTitle: "Paseoスキルを更新しますか?", - updateFallback: "バンドルされたスキルをマシンに同期します。", - uninstallTitle: "Paseoスキルをアンインストールしますか?", - uninstallMessage: - "~/.agents、~/.claude、~/.codexからすべてのPaseoオーケストレーションスキルを削除します。", - choose: "スキルを選択", - chooseAll: "すべてのスキル", - chooseAllHint: - "後から追加されるものも含め、バンドルされたスキルをすべてインストールしたままにします。", - chooseList: "バンドルされたスキル", - chooseEmpty: "このビルドにはスキルが含まれていません。", - removeTitle: "選択を外したスキルを削除しますか?", - removeMessage: - "{{skills}} を ~/.agents、~/.claude、~/.codex から削除します。それらのスキルフォルダー内に追加したものもすべて削除されます。", - saveFailed: "スキルの選択を保存できませんでした。", - }, actions: { install: "インストール", installing: "インストール中...", installed: "インストール済み", - update: "更新", - working: "処理中...", - remove: "削除", - uninstall: "アンインストール", - save: "保存", - saving: "保存中...", - }, - operations: { - add: "スキルを追加", - update: "スキルを更新", - delete: "スキルを削除", }, }, permissions: { @@ -2211,6 +2171,49 @@ export const ja: TranslationResources = { rowTitle: "デバイスをペアリング", rowHint: "QRコードをスキャンするかリンクをコピーしてスマートフォンをこのホストに接続", }, + skills: { + ...en.settings.host.skills, + sectionTitle: "オーケストレーションスキル", + title: "オーケストレーションスキル", + description: "エージェントがCLI経由でオーケストレーションできるようにします。", + updateAvailable: "更新が利用可能", + updateTitle: "Paseoスキルを更新しますか?", + updateFallback: "バンドルされたスキルをマシンに同期します。", + uninstallTitle: "Paseoスキルをアンインストールしますか?", + uninstallMessage: + "~/.agents、~/.claude、~/.codexからすべてのPaseoオーケストレーションスキルを削除します。", + choose: "スキルを選択", + chooseAll: "すべてのスキル", + chooseAllHint: + "後から追加されるものも含め、バンドルされたスキルをすべてインストールしたままにします。", + chooseList: "バンドルされたスキル", + chooseEmpty: "このビルドにはスキルが含まれていません。", + removeTitle: "選択を外したスキルを削除しますか?", + removeMessage: + "{{skills}} を ~/.agents、~/.claude、~/.codex から削除します。それらのスキルフォルダー内に追加したものもすべて削除されます。", + saveFailed: "スキルの選択を保存できませんでした。", + statusFailed: "オーケストレーションスキルのステータスを確認できません。", + updateFailed: "オーケストレーションスキルを更新できません。", + uninstallFailed: "オーケストレーションスキルをアンインストールできません。", + saveSelectionFailed: "オーケストレーションスキルの選択を保存できません。", + actions: { + install: "インストール", + installing: "インストール中...", + installed: "インストール済み", + update: "更新", + working: "処理中...", + remove: "削除", + uninstall: "アンインストール", + save: "保存", + saving: "保存中...", + cancel: "キャンセル", + }, + operations: { + add: "スキルを追加", + update: "スキルを更新", + delete: "スキルを削除", + }, + }, orchestration: { title: "オーケストレーション", unavailable: "オーケストレーションを管理するにはこのホストに接続してください", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 56f57f267..c13c0ed3c 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1,4 +1,4 @@ -import type { TranslationResources } from "./en"; +import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ko: TranslationResources = { @@ -1307,13 +1307,6 @@ export const ko: TranslationResources = { statusFailed: "CLI 설치 상태를 확인할 수 없습니다.", installFailed: "Paseo CLI를 설치할 수 없습니다.", }, - skills: { - statusFailed: "오케스트레이션 스킬 상태를 확인할 수 없습니다.", - installFailed: "오케스트레이션 스킬을 설치할 수 없습니다.", - updateFailed: "오케스트레이션 스킬을 업데이트할 수 없습니다.", - uninstallFailed: "오케스트레이션 스킬을 제거할 수 없습니다.", - saveSelectionFailed: "오케스트레이션 스킬 선택을 저장하지 못했습니다.", - }, }, }, rootError: { @@ -2092,48 +2085,16 @@ export const ko: TranslationResources = { title: "통합", docs: { cli: "CLI 문서", - skills: "스킬 문서", openCli: "CLI 문서 열기", - openSkills: "스킬 문서 열기", }, commandLine: { title: "명령줄", description: "터미널에서 에이전트를 제어하고 스크립팅합니다", }, - skills: { - title: "오케스트레이션 스킬", - description: "CLI를 통해 에이전트가 오케스트레이션하도록 가르칩니다", - updateAvailable: "업데이트 사용 가능", - updateTitle: "Paseo 스킬을 업데이트할까요?", - updateFallback: "번들된 스킬을 내 컴퓨터에 동기화합니다.", - uninstallTitle: "Paseo 스킬을 제거할까요?", - uninstallMessage: - "~/.agents, ~/.claude, ~/.codex에서 모든 Paseo 오케스트레이션 스킬을 제거합니다.", - choose: "스킬 선택", - chooseAll: "모든 스킬", - chooseAllHint: "나중에 추가되는 항목을 포함하여 모든 번들 스킬을 설치된 상태로 유지합니다.", - chooseList: "번들 스킬", - chooseEmpty: "이 빌드에는 번들 스킬이 없습니다.", - removeTitle: "선택 해제한 스킬을 제거할까요?", - removeMessage: - "{{skills}}는 ~/.agents, ~/.claude 및 ~/.codex에서 삭제됩니다. 해당 스킬 폴더 안에 추가한 모든 항목도 삭제됩니다.", - saveFailed: "선택한 스킬을 저장하지 못했습니다.", - }, actions: { install: "설치", installing: "설치 중...", installed: "설치됨", - update: "업데이트", - working: "작업 중...", - remove: "제거", - uninstall: "제거", - save: "저장", - saving: "저장 중...", - }, - operations: { - add: "스킬 추가", - update: "스킬 업데이트", - delete: "스킬 삭제", }, }, permissions: { @@ -2203,6 +2164,48 @@ export const ko: TranslationResources = { rowTitle: "기기 페어링", rowHint: "QR 코드를 스캔하거나 링크를 복사하여 휴대폰을 이 호스트에 연결하세요", }, + skills: { + ...en.settings.host.skills, + sectionTitle: "오케스트레이션 스킬", + title: "오케스트레이션 스킬", + description: "CLI를 통해 에이전트가 오케스트레이션하도록 가르칩니다", + updateAvailable: "업데이트 사용 가능", + updateTitle: "Paseo 스킬을 업데이트할까요?", + updateFallback: "번들된 스킬을 내 컴퓨터에 동기화합니다.", + uninstallTitle: "Paseo 스킬을 제거할까요?", + uninstallMessage: + "~/.agents, ~/.claude, ~/.codex에서 모든 Paseo 오케스트레이션 스킬을 제거합니다.", + choose: "스킬 선택", + chooseAll: "모든 스킬", + chooseAllHint: "나중에 추가되는 항목을 포함하여 모든 번들 스킬을 설치된 상태로 유지합니다.", + chooseList: "번들 스킬", + chooseEmpty: "이 빌드에는 번들 스킬이 없습니다.", + removeTitle: "선택 해제한 스킬을 제거할까요?", + removeMessage: + "{{skills}}는 ~/.agents, ~/.claude 및 ~/.codex에서 삭제됩니다. 해당 스킬 폴더 안에 추가한 모든 항목도 삭제됩니다.", + saveFailed: "선택한 스킬을 저장하지 못했습니다.", + statusFailed: "오케스트레이션 스킬 상태를 확인할 수 없습니다.", + updateFailed: "오케스트레이션 스킬을 업데이트할 수 없습니다.", + uninstallFailed: "오케스트레이션 스킬을 제거할 수 없습니다.", + saveSelectionFailed: "오케스트레이션 스킬 선택을 저장하지 못했습니다.", + actions: { + install: "설치", + installing: "설치 중...", + installed: "설치됨", + update: "업데이트", + working: "작업 중...", + remove: "제거", + uninstall: "제거", + save: "저장", + saving: "저장 중...", + cancel: "취소", + }, + operations: { + add: "스킬 추가", + update: "스킬 업데이트", + delete: "스킬 삭제", + }, + }, orchestration: { title: "오케스트레이션", unavailable: "오케스트레이션을 관리하려면 이 호스트에 연결하세요", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index e91bb66fb..26340cc50 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1,4 +1,4 @@ -import type { TranslationResources } from "./en"; +import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ptBR: TranslationResources = { @@ -1324,13 +1324,6 @@ export const ptBR: TranslationResources = { statusFailed: "Não foi possível verificar o status de instalação da CLI.", installFailed: "Não foi possível instalar a CLI do Paseo.", }, - skills: { - statusFailed: "Não foi possível verificar o status das skills de orquestração.", - installFailed: "Não foi possível instalar as skills de orquestração.", - updateFailed: "Não foi possível atualizar as skills de orquestração.", - uninstallFailed: "Não foi possível desinstalar as skills de orquestração.", - saveSelectionFailed: "Não foi possível salvar a seleção de skills de orquestração.", - }, }, }, rootError: { @@ -2113,49 +2106,16 @@ export const ptBR: TranslationResources = { title: "Integrações", docs: { cli: "Docs da CLI", - skills: "Docs das skills", openCli: "Abrir documentação da CLI", - openSkills: "Abrir documentação das skills", }, commandLine: { title: "Linha de comando", description: "Controle agentes e execute scripts pelo terminal", }, - skills: { - title: "Skills de orquestração", - description: "Ensine seus agentes a orquestrar pela CLI", - updateAvailable: "Atualização disponível", - updateTitle: "Atualizar Paseo skills?", - updateFallback: "Sincronize as skills incluídas com sua máquina.", - uninstallTitle: "Desinstalar Paseo skills?", - uninstallMessage: - "Remove todas as skills de orquestração do Paseo de ~/.agents, ~/.claude e ~/.codex.", - choose: "Escolher skills", - chooseAll: "Todas as skills", - chooseAllHint: - "Mantenha instaladas todas as skills incluídas, inclusive as adicionadas depois.", - chooseList: "Skills incluídas", - chooseEmpty: "Esta versão não inclui skills.", - removeTitle: "Remover as skills desmarcadas?", - removeMessage: - "{{skills}} serão excluídas de ~/.agents, ~/.claude e ~/.codex. Tudo o que você adicionou dentro dessas pastas de skills também é excluído.", - saveFailed: "Não foi possível salvar sua seleção de skills.", - }, actions: { install: "Instalar", installing: "Instalando...", installed: "Instalado", - update: "Atualizar", - working: "Trabalhando...", - remove: "Remover", - uninstall: "Desinstalar", - save: "Salvar", - saving: "Salvando...", - }, - operations: { - add: "Adicionar skill", - update: "Atualizar skill", - delete: "Excluir skill", }, }, permissions: { @@ -2225,6 +2185,49 @@ export const ptBR: TranslationResources = { rowTitle: "Parear um dispositivo", rowHint: "Escaneie um QR code ou copie um link para conectar seu celular a este host", }, + skills: { + ...en.settings.host.skills, + sectionTitle: "Skills de orquestração", + title: "Skills de orquestração", + description: "Ensine seus agentes a orquestrar pela CLI", + updateAvailable: "Atualização disponível", + updateTitle: "Atualizar Paseo skills?", + updateFallback: "Sincronize as skills incluídas com sua máquina.", + uninstallTitle: "Desinstalar Paseo skills?", + uninstallMessage: + "Remove todas as skills de orquestração do Paseo de ~/.agents, ~/.claude e ~/.codex.", + choose: "Escolher skills", + chooseAll: "Todas as skills", + chooseAllHint: + "Mantenha instaladas todas as skills incluídas, inclusive as adicionadas depois.", + chooseList: "Skills incluídas", + chooseEmpty: "Esta versão não inclui skills.", + removeTitle: "Remover as skills desmarcadas?", + removeMessage: + "{{skills}} serão excluídas de ~/.agents, ~/.claude e ~/.codex. Tudo o que você adicionou dentro dessas pastas de skills também é excluído.", + saveFailed: "Não foi possível salvar sua seleção de skills.", + statusFailed: "Não foi possível verificar o status das skills de orquestração.", + updateFailed: "Não foi possível atualizar as skills de orquestração.", + uninstallFailed: "Não foi possível desinstalar as skills de orquestração.", + saveSelectionFailed: "Não foi possível salvar a seleção de skills de orquestração.", + actions: { + install: "Instalar", + installing: "Instalando...", + installed: "Instalado", + update: "Atualizar", + working: "Trabalhando...", + remove: "Remover", + uninstall: "Desinstalar", + save: "Salvar", + saving: "Salvando...", + cancel: "Cancelar", + }, + operations: { + add: "Adicionar skill", + update: "Atualizar skill", + delete: "Excluir skill", + }, + }, orchestration: { title: "Orquestração", unavailable: "Conecte-se a este host para gerenciar a orquestração", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 1fdcaf44a..d54d9ae9b 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1,4 +1,4 @@ -import type { TranslationResources } from "./en"; +import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ru: TranslationResources = { @@ -1327,13 +1327,6 @@ export const ru: TranslationResources = { statusFailed: "Невозможно проверить статус установки CLI.", installFailed: "Невозможно установить PaseoCLI.", }, - skills: { - statusFailed: "Невозможно проверить статус навыков оркестровки.", - installFailed: "Невозможно установить навыки оркестровки.", - updateFailed: "Невозможно обновить навыки оркестровки.", - uninstallFailed: "Невозможно удалить навыки оркестровки.", - saveSelectionFailed: "Не удалось сохранить выбор навыков оркестровки.", - }, }, }, rootError: { @@ -2120,47 +2113,16 @@ export const ru: TranslationResources = { title: "Интеграции", docs: { cli: "Документация CLI", - skills: "Документы по навыкам", openCli: "Открыть документацию CLI", - openSkills: "Открытая документация по навыкам", }, commandLine: { title: "Командная строка", description: "Агенты управления и сценариев с вашего терминала", }, - skills: { - title: "Навыки оркестровки", - description: "Научите своих агентов организовывать работу через CLI", - updateAvailable: "Доступно обновление", - updateTitle: "Обновить навыки Paseo?", - updateFallback: "Синхронизируйте связанные навыки с вашим компьютером.", - uninstallTitle: "Удалить навыки Paseo?", - uninstallMessage: "Удаляет все навыки оркестровки Paseo из ~/.agents, ~/.claude, ~/.codex.", - choose: "Выбрать навыки", - chooseAll: "Все навыки", - chooseAllHint: "Держите установленными все входящие навыки, включая добавленные позже.", - chooseList: "Входящие навыки", - chooseEmpty: "В этой сборке нет навыков.", - removeTitle: "Удалить снятые навыки?", - removeMessage: - "{{skills}} будут удалены из ~/.agents, ~/.claude и ~/.codex. Всё, что вы добавили внутри этих папок навыков, тоже удаляется.", - saveFailed: "Не удалось сохранить выбор навыков.", - }, actions: { install: "Установить", installing: "Установка...", installed: "Установлено", - update: "Обновлять", - working: "Работающий...", - remove: "Удалить", - uninstall: "Удалить", - save: "Сохранить", - saving: "Сохранение...", - }, - operations: { - add: "Добавить навык", - update: "Обновить навык", - delete: "Удалить навык", }, }, permissions: { @@ -2231,6 +2193,47 @@ export const ru: TranslationResources = { rowHint: "Отсканируйте код QR или скопируйте ссылку, чтобы подключить свой телефон к этому хосту.", }, + skills: { + ...en.settings.host.skills, + sectionTitle: "Навыки оркестровки", + title: "Навыки оркестровки", + description: "Научите своих агентов организовывать работу через CLI", + updateAvailable: "Доступно обновление", + updateTitle: "Обновить навыки Paseo?", + updateFallback: "Синхронизируйте связанные навыки с вашим компьютером.", + uninstallTitle: "Удалить навыки Paseo?", + uninstallMessage: "Удаляет все навыки оркестровки Paseo из ~/.agents, ~/.claude, ~/.codex.", + choose: "Выбрать навыки", + chooseAll: "Все навыки", + chooseAllHint: "Держите установленными все входящие навыки, включая добавленные позже.", + chooseList: "Входящие навыки", + chooseEmpty: "В этой сборке нет навыков.", + removeTitle: "Удалить снятые навыки?", + removeMessage: + "{{skills}} будут удалены из ~/.agents, ~/.claude и ~/.codex. Всё, что вы добавили внутри этих папок навыков, тоже удаляется.", + saveFailed: "Не удалось сохранить выбор навыков.", + statusFailed: "Невозможно проверить статус навыков оркестровки.", + updateFailed: "Невозможно обновить навыки оркестровки.", + uninstallFailed: "Невозможно удалить навыки оркестровки.", + saveSelectionFailed: "Не удалось сохранить выбор навыков оркестровки.", + actions: { + install: "Установить", + installing: "Установка...", + installed: "Установлено", + update: "Обновлять", + working: "Работающий...", + remove: "Удалить", + uninstall: "Удалить", + save: "Сохранить", + saving: "Сохранение...", + cancel: "Отмена", + }, + operations: { + add: "Добавить навык", + update: "Обновить навык", + delete: "Удалить навык", + }, + }, orchestration: { title: "оркестровка", unavailable: "Подключитесь к этому хосту, чтобы управлять оркестрацией.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 8371118eb..30a88d4d6 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1,4 +1,4 @@ -import type { TranslationResources } from "./en"; +import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const zhCN: TranslationResources = { @@ -1282,13 +1282,6 @@ export const zhCN: TranslationResources = { statusFailed: "无法检查 CLI 安装状态。", installFailed: "无法安装 Paseo CLI。", }, - skills: { - statusFailed: "无法检查编排 skills 状态。", - installFailed: "无法安装编排 skills。", - updateFailed: "无法更新编排 skills。", - uninstallFailed: "无法卸载编排 skills。", - saveSelectionFailed: "无法保存编排 skills 的选择。", - }, }, }, rootError: { @@ -2057,47 +2050,16 @@ export const zhCN: TranslationResources = { title: "集成", docs: { cli: "CLI 文档", - skills: "Skills 文档", openCli: "打开 CLI 文档", - openSkills: "打开 skills 文档", }, commandLine: { title: "命令行", description: "从终端控制 Agent 并运行脚本", }, - skills: { - title: "编排 skills", - description: "教会 Agent 通过 CLI 编排任务", - updateAvailable: "有更新可用", - updateTitle: "更新 Paseo skills?", - updateFallback: "将内置 skills 同步到你的机器。", - uninstallTitle: "卸载 Paseo skills?", - uninstallMessage: "会从 ~/.agents、~/.claude、~/.codex 移除所有 Paseo 编排 skills。", - choose: "选择 skills", - chooseAll: "全部 skills", - chooseAllHint: "保持安装所有内置 skills,包括以后新增的。", - chooseList: "内置 skills", - chooseEmpty: "此版本未内置 skills。", - removeTitle: "移除取消勾选的 skills?", - removeMessage: - "将从 ~/.agents、~/.claude、~/.codex 中删除 {{skills}}。你在这些 skill 文件夹里添加的内容也会一并删除。", - saveFailed: "无法保存你的 skills 选择。", - }, actions: { install: "安装", installing: "正在安装...", installed: "已安装", - update: "更新", - working: "处理中...", - remove: "移除", - uninstall: "卸载", - save: "保存", - saving: "正在保存...", - }, - operations: { - add: "添加 skill", - update: "更新 skill", - delete: "删除 skill", }, }, permissions: { @@ -2167,6 +2129,47 @@ export const zhCN: TranslationResources = { rowTitle: "配对设备", rowHint: "扫描二维码或复制链接,将手机连接到这个 Host", }, + skills: { + ...en.settings.host.skills, + sectionTitle: "编排 skills", + title: "编排 skills", + description: "教会 Agent 通过 CLI 编排任务", + updateAvailable: "有更新可用", + updateTitle: "更新 Paseo skills?", + updateFallback: "将内置 skills 同步到你的机器。", + uninstallTitle: "卸载 Paseo skills?", + uninstallMessage: "会从 ~/.agents、~/.claude、~/.codex 移除所有 Paseo 编排 skills。", + choose: "选择 skills", + chooseAll: "全部 skills", + chooseAllHint: "保持安装所有内置 skills,包括以后新增的。", + chooseList: "内置 skills", + chooseEmpty: "此版本未内置 skills。", + removeTitle: "移除取消勾选的 skills?", + removeMessage: + "将从 ~/.agents、~/.claude、~/.codex 中删除 {{skills}}。你在这些 skill 文件夹里添加的内容也会一并删除。", + saveFailed: "无法保存你的 skills 选择。", + statusFailed: "无法检查编排 skills 状态。", + updateFailed: "无法更新编排 skills。", + uninstallFailed: "无法卸载编排 skills。", + saveSelectionFailed: "无法保存编排 skills 的选择。", + actions: { + install: "安装", + installing: "正在安装...", + installed: "已安装", + update: "更新", + working: "处理中...", + remove: "移除", + uninstall: "卸载", + save: "保存", + saving: "正在保存...", + cancel: "取消", + }, + operations: { + add: "添加 skill", + update: "更新 skill", + delete: "删除 skill", + }, + }, orchestration: { title: "编排", unavailable: "连接到这个 Host 以管理编排", diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 38637d80b..90c1fb15f 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -22,6 +22,7 @@ import { DEFAULT_TERMINAL_PROFILES, } from "@getpaseo/protocol/terminal-profiles"; import { AgentProfilesSection } from "@/agent-profiles"; +import { AgentSkillsSection } from "@/agent-skills"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { Alert as InlineAlert } from "@/components/ui/alert"; @@ -275,6 +276,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) { {t("settings.host.agents.unavailable")} )} + ); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 55a3a7299..a76332821 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -105,6 +105,9 @@ import type { WorkspaceRecoveryState, PluginListItem, PluginLogEntry, + AgentSkillSelection, + AgentSkillsStatus, + AgentSkillsSaveResult, } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest, @@ -4766,6 +4769,61 @@ export class DaemonClient { return payload.entries; } + async getAgentSkillsStatus(): Promise { + const requestId = this.createRequestId(); + return this.sendCorrelatedSessionRequest({ + message: { type: "agent.skills.get_status.request", requestId }, + responseType: "agent.skills.get_status.response", + }); + } + + async reconcileAgentSkills(): Promise { + const requestId = this.createRequestId(); + return this.sendCorrelatedSessionRequest({ + message: { type: "agent.skills.reconcile.request", requestId }, + responseType: "agent.skills.reconcile.response", + }); + } + + async uninstallAgentSkills(): Promise { + const requestId = this.createRequestId(); + return this.sendCorrelatedSessionRequest({ + message: { type: "agent.skills.uninstall.request", requestId }, + responseType: "agent.skills.uninstall.response", + }); + } + + async saveAgentSkillsSelection( + selection: AgentSkillSelection, + confirmedRemovals?: readonly string[], + ): Promise { + const requestId = this.createRequestId(); + return this.sendCorrelatedSessionRequest({ + message: { + type: "agent.skills.save_selection.request", + requestId, + selection, + ...(confirmedRemovals ? { confirmedRemovals: [...confirmedRemovals] } : {}), + }, + responseType: "agent.skills.save_selection.response", + }); + } + + async importLegacyAgentSkillsSelection(selection: AgentSkillSelection): Promise<{ + imported: boolean; + selection: AgentSkillSelection; + }> { + const requestId = this.createRequestId(); + return this.sendCorrelatedSessionRequest({ + message: { + type: "agent.skills.import_legacy_selection.request", + requestId, + selection, + }, + responseType: "agent.skills.import_legacy_selection.response", + }); + } + async installDirectoryPlugin(path: string, id?: string): Promise { const requestId = this.createRequestId(); const payload = await this.sendCorrelatedSessionRequest({ diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index f298fd612..6d6b2e826 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -227,6 +227,7 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own expect(Object.keys(paseo).sort()).toEqual(["agents", "config", "providers", "workspaces"]); expect("connect" in paseo).toBe(false); expect("close" in paseo).toBe(false); + expect("skills" in paseo.agents).toBe(false); }); test("agent actions list the daemon directory without exposing the low-level client", async () => { diff --git a/packages/desktop/e2e/adaptive-sheet-content-inset.spec.ts b/packages/desktop/e2e/adaptive-sheet-content-inset.spec.ts deleted file mode 100644 index 58613234d..000000000 --- a/packages/desktop/e2e/adaptive-sheet-content-inset.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { expect, test, type Page } from "../../app/e2e/support/fixtures"; -import { gotoAppShell } from "../../app/e2e/support/helpers/app"; -import { - readVerticalGap, - waitForSettledPosition, -} from "../../app/e2e/support/helpers/sheet-layout"; -import { - openCompactSkillsIntegrations, - openSkillSelection, - openSkillsIntegrations, - startSkillsSandbox, - type SkillsSandbox, -} from "./support/skills-selection"; - -const COMPACT_VIEWPORT = { width: 390, height: 844 }; -const DESKTOP_VIEWPORT = { width: 1280, height: 900 }; - -const sandboxes: SkillsSandbox[] = []; - -test.afterEach(async () => { - for (const sandbox of sandboxes.splice(0)) { - await sandbox.cleanup(); - } -}); - -async function openChooseSkillsOnPhone(page: Page): Promise { - sandboxes.push(await startSkillsSandbox(page)); - await gotoAppShell(page); - await openCompactSkillsIntegrations(page); - await openSkillSelection(page); - await waitForSettledPosition(page.getByTestId("skill-selection-sheet")); -} - -async function openChooseSkillsOnDesktop(page: Page): Promise { - sandboxes.push(await startSkillsSandbox(page)); - await gotoAppShell(page); - await openSkillsIntegrations(page); - await openSkillSelection(page); -} - -function allSkillsCard(page: Page) { - return page.getByTestId("skill-selection-all-card"); -} - -function skillListCard(page: Page) { - return page.getByTestId("skill-selection-list-card"); -} - -function bundledSkillsLabel(page: Page) { - return page.getByText("Bundled skills", { exact: true }); -} - -test.describe("Choose skills sheet on a compact form factor", () => { - test.use({ viewport: COMPACT_VIEWPORT }); - - test("gives the Bundled skills label more room above it than below", async ({ page }) => { - await openChooseSkillsOnPhone(page); - - expect({ - aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)), - belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)), - }).toEqual({ aboveLabel: 16, belowLabel: 12 }); - }); -}); - -test.describe("Choose skills sheet on a desktop form factor", () => { - test.use({ viewport: DESKTOP_VIEWPORT }); - - test("gives the Bundled skills label more room above it than below", async ({ page }) => { - await openChooseSkillsOnDesktop(page); - - expect({ - aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)), - belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)), - }).toEqual({ aboveLabel: 16, belowLabel: 12 }); - }); -}); diff --git a/packages/desktop/e2e/skills-selection.spec.ts b/packages/desktop/e2e/skills-selection.spec.ts deleted file mode 100644 index c10fc78b1..000000000 --- a/packages/desktop/e2e/skills-selection.spec.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { test as base } from "../../app/e2e/support/fixtures"; -import { expect } from "@playwright/test"; -import { gotoAppShell } from "../../app/e2e/support/helpers/app"; -import { installDesktopRuntime } from "./support/runtime"; -import { getServerId } from "../../app/e2e/support/helpers/server-id"; -import { - cancelSkillSelection, - chooseAllSkills, - chooseCustomSkills, - createSkillsSandbox, - expectAllSkillsSelected, - expectNoRemovalWarning, - expectRemovalWarning, - expectSavedSkillSelection, - expectSaveErrorKeepsSheetOpen, - expectSelectedSkills, - expectSkillChoices, - expectSkillFilePresent, - expectSkillPresentIn, - expectSkillSelectionOpen, - expectSkillsInstalled, - openSkillsIntegrations, - openSkillSelection, - saveSkillSelection, - toggleSkill, - serveRealSkillsCommands, - type SkillsSandbox, - type SkillsSandboxOptions, -} from "./support/skills-selection"; - -interface SkillsSandboxSetup extends SkillsSandboxOptions { - /** What the desktop confirm dialog answers when the removal warning fires. */ - confirmRemoval?: boolean; -} - -type StartSkills = (setup?: SkillsSandboxSetup) => Promise; - -// Every skills command runs against the real desktop handlers over a temp bundle -// and temp user data, so the assertions cover persistence and convergence, not a -// browser stand-in for them. -const test = base.extend<{ startSkills: StartSkills }>({ - startSkills: async ({ page }, provide) => { - const sandboxes: SkillsSandbox[] = []; - await provide(async (setup = {}) => { - const { confirmRemoval, ...options } = setup; - const sandbox = await createSkillsSandbox(options); - await installDesktopRuntime(page, { - serverId: getServerId(), - confirmShouldAccept: confirmRemoval ?? false, - }); - await serveRealSkillsCommands(page, sandbox); - sandboxes.push(sandbox); - return sandbox; - }); - for (const sandbox of sandboxes) { - await sandbox.cleanup(); - } - }, -}); - -test.describe("Choosing installed skills", () => { - test("installs only the skills selected in Settings", async ({ page, startSkills }) => { - const skills = await startSkills(); - await gotoAppShell(page); - await openSkillsIntegrations(page); - - await openSkillSelection(page); - await chooseCustomSkills(page, ["paseo", "paseo-loop"]); - await saveSkillSelection(page); - - await expectSkillsInstalled(skills, ["paseo", "paseo-loop"]); - await expectSelectedSkills(page, ["paseo", "paseo-loop"]); - }); - - test("all skills includes every available skill", async ({ page, startSkills }) => { - const skills = await startSkills(); - await gotoAppShell(page); - await openSkillsIntegrations(page); - await openSkillSelection(page); - await chooseCustomSkills(page, ["paseo"]); - await saveSkillSelection(page); - - await openSkillSelection(page); - await chooseAllSkills(page); - await saveSkillSelection(page); - - await expectSkillsInstalled(skills, skills.bundledSkills); - await expectAllSkillsSelected(page); - }); - - test("cancelling leaves the installed skills untouched", async ({ page, startSkills }) => { - const skills = await startSkills(); - await gotoAppShell(page); - await openSkillsIntegrations(page); - await openSkillSelection(page); - await chooseCustomSkills(page, ["paseo"]); - await saveSkillSelection(page); - - await openSkillSelection(page); - await toggleSkill(page, "paseo-loop"); - await cancelSkillSelection(page); - - await expectSkillsInstalled(skills, ["paseo"]); - await expectSelectedSkills(page, ["paseo"]); - }); - - test("a failed save keeps the sheet open with an error", async ({ page, startSkills }) => { - const skills = await startSkills({ blockAgentsDir: true }); - await gotoAppShell(page); - await openSkillsIntegrations(page); - - await openSkillSelection(page); - await chooseCustomSkills(page, ["paseo"]); - await saveSkillSelection(page); - - await expectSaveErrorKeepsSheetOpen(page); - await expectSkillsInstalled(skills, []); - }); - - test("an empty selection does not offer an Install action that cannot install anything", async ({ - page, - startSkills, - }) => { - await startSkills({ installed: { mode: "all" }, confirmRemoval: true }); - await gotoAppShell(page); - await openSkillsIntegrations(page); - - await openSkillSelection(page); - await chooseCustomSkills(page, []); - await saveSkillSelection(page); - - // The remaining Install action belongs to the command-line integration. - await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1); - await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible(); - }); - - test("a selection containing only retired skills does not offer a no-op Install", async ({ - page, - startSkills, - }) => { - await startSkills({ installed: { mode: "custom", skills: ["retired-skill"] } }); - await gotoAppShell(page); - await openSkillsIntegrations(page); - - // The remaining Install action belongs to the command-line integration. - await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1); - await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible(); - }); - - test("an empty bundled catalog does not offer a no-op Install", async ({ page, startSkills }) => { - await startSkills({ bundledSkills: [] }); - await gotoAppShell(page); - await openSkillsIntegrations(page); - - // The remaining Install action belongs to the command-line integration. - await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1); - await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible(); - }); - - test("cannot dismiss the sheet while Save is pending", async ({ page, startSkills }) => { - const skills = await startSkills({ holdSave: true }); - await gotoAppShell(page); - await openSkillsIntegrations(page); - await openSkillSelection(page); - await chooseCustomSkills(page, ["paseo"]); - - await saveSkillSelection(page); - await skills.waitForHeldSave(); - await page.keyboard.press("Escape"); - await expectSkillSelectionOpen(page); - - skills.releaseHeldSave(); - await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0); - }); -}); - -test.describe("Removing an installed skill", () => { - test("delete-only drift stays in Choose skills instead of Update", async ({ - page, - startSkills, - }) => { - await startSkills({ - installed: { mode: "custom", skills: ["paseo"] }, - placeOnDisk: { - skill: "paseo-loop", - target: "claude", - files: { "SKILL.md": "# paseo-loop\n", "notes/mine.md": "my notes" }, - }, - }); - await gotoAppShell(page); - await openSkillsIntegrations(page); - - await expect(page.getByText("Update available", { exact: true })).toHaveCount(0); - await expect(page.getByRole("button", { name: "Update", exact: true })).toHaveCount(0); - await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible(); - }); - - test("warns before deleting the skill folders", async ({ page, startSkills }) => { - const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: false }); - await gotoAppShell(page); - await openSkillsIntegrations(page); - - await openSkillSelection(page); - await chooseCustomSkills(page, ["paseo", "paseo-advisor"]); - await saveSkillSelection(page); - - await expectRemovalWarning(page, ["paseo-loop"]); - await expectSkillSelectionOpen(page); - await expectSkillsInstalled(skills, skills.bundledSkills); - await expectSavedSkillSelection(skills, { mode: "all" }); - }); - - test("confirming the warning removes the skill", async ({ page, startSkills }) => { - const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: true }); - await gotoAppShell(page); - await openSkillsIntegrations(page); - - await openSkillSelection(page); - await chooseCustomSkills(page, ["paseo", "paseo-advisor"]); - await saveSkillSelection(page); - - await expectRemovalWarning(page, ["paseo-loop"]); - await expectSkillsInstalled(skills, ["paseo", "paseo-advisor"]); - await expectSelectedSkills(page, ["paseo", "paseo-advisor"]); - }); - - test("warns for a skill installed in only one agent directory", async ({ page, startSkills }) => { - const skills = await startSkills({ - installed: { mode: "all" }, - keepOnlyIn: { skill: "paseo-loop", target: "claude" }, - confirmRemoval: false, - }); - await gotoAppShell(page); - await openSkillsIntegrations(page); - - await openSkillSelection(page); - await chooseCustomSkills(page, ["paseo", "paseo-advisor"]); - await saveSkillSelection(page); - - await expectRemovalWarning(page, ["paseo-loop"]); - await expectSkillSelectionOpen(page); - await expectSkillPresentIn(skills, "claude", "paseo-loop"); - await expectSavedSkillSelection(skills, { mode: "all" }); - }); - - test("warns for a skill on disk that the saved selection never mentioned", async ({ - page, - startSkills, - }) => { - // The committed preference excludes paseo-loop, but the directory is back — - // a manual reinstall or an external sync. Saving any edit deletes it. - const skills = await startSkills({ - installed: { mode: "custom", skills: ["paseo"] }, - placeOnDisk: { - skill: "paseo-loop", - target: "claude", - files: { "SKILL.md": "# paseo-loop\n", "notes/mine.md": "my notes" }, - }, - confirmRemoval: false, - }); - await gotoAppShell(page); - await openSkillsIntegrations(page); - - await openSkillSelection(page); - await toggleSkill(page, "paseo-advisor"); - await saveSkillSelection(page); - - await expectRemovalWarning(page, ["paseo-loop"]); - await expectSkillSelectionOpen(page); - await expectSkillFilePresent(skills, "claude", "paseo-loop", "notes/mine.md"); - await expectSavedSkillSelection(skills, { mode: "custom", skills: ["paseo"] }); - }); - - test("warns about a directory that appears after the sheet was opened", async ({ - page, - startSkills, - }) => { - const skills = await startSkills({ - installed: { mode: "custom", skills: ["paseo"] }, - confirmRemoval: false, - }); - await gotoAppShell(page); - await openSkillsIntegrations(page); - await openSkillSelection(page); - - // Nothing to delete when the sheet opened; the host looks again at save time. - await skills.placeSkillOnDisk("paseo-loop", "claude"); - await toggleSkill(page, "paseo-advisor"); - await saveSkillSelection(page); - - await expectRemovalWarning(page, ["paseo-loop"]); - await expectSkillSelectionOpen(page); - await expectSkillPresentIn(skills, "claude", "paseo-loop"); - await expectSavedSkillSelection(skills, { mode: "custom", skills: ["paseo"] }); - }); - - test("keeps draft choices when fresher status arrives while the sheet is open", async ({ - page, - startSkills, - }) => { - const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: false }); - await gotoAppShell(page); - await openSkillsIntegrations(page); - await openSkillSelection(page); - await chooseCustomSkills(page, ["paseo", "paseo-advisor"]); - - // Another window commits a different selection. Saving picks that up, so the - // sheet is handed a selection it did not start from while an edit is open. - await skills.commitSelectionExternally({ mode: "custom", skills: ["paseo", "paseo-loop"] }, [ - "paseo-advisor", - ]); - await saveSkillSelection(page); - await expectRemovalWarning(page, ["paseo-loop"]); - - await expectSkillChoices(page, ["paseo", "paseo-advisor"]); - }); - - test("adding a skill saves without a warning", async ({ page, startSkills }) => { - const skills = await startSkills({ - installed: { mode: "custom", skills: ["paseo"] }, - confirmRemoval: false, - }); - await gotoAppShell(page); - await openSkillsIntegrations(page); - - await openSkillSelection(page); - await toggleSkill(page, "paseo-loop"); - await saveSkillSelection(page); - - await expectSkillsInstalled(skills, ["paseo", "paseo-loop"]); - await expectNoRemovalWarning(page); - }); -}); diff --git a/packages/desktop/e2e/support/skills-selection.ts b/packages/desktop/e2e/support/skills-selection.ts deleted file mode 100644 index f1c8869d9..000000000 --- a/packages/desktop/e2e/support/skills-selection.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { expect, type Page } from "@playwright/test"; -import { buildOpenProjectRoute } from "../../../app/src/utils/host-routes"; -import { openSettings } from "../../../app/e2e/support/helpers/app"; -import { installDesktopRuntime } from "./runtime"; -import { getServerId } from "../../../app/e2e/support/helpers/server-id"; -import { - openCompactSettings, - openSettingsSection, -} from "../../../app/e2e/support/helpers/settings"; -// The desktop skills module is the real command surface: the same handlers the -// Electron main process registers, running against a temp bundle and temp user -// data. Nothing about persistence or convergence is simulated in the browser. -import type { SkillSelection, SkillTargets } from "../../src/integrations/skills/operations.js"; -import { createSkillSelectionStore } from "../../src/integrations/skills/selection-store.js"; -import { createSkillsCommandHandlers } from "../../src/integrations/skills/skills-commands.js"; -import { createSkillsController } from "../../src/integrations/skills/skills-controller.js"; - -const SKILL_COMMANDS = [ - "get_skills_status", - "install_skills", - "update_skills", - "uninstall_skills", - "save_skills_selection", -] as const; - -declare global { - interface Window { - __paseoSkillsInvoke?: ( - command: string, - args: Record | null, - ) => Promise; - } -} - -export interface SkillsSandbox { - targets: SkillTargets; - userDataPath: string; - bundledSkills: string[]; - /** Writes a managed skill directory the way an external tool would. */ - placeSkillOnDisk: (skill: string, target: SkillTargetName) => Promise; - /** Commits a selection through the real controller, as another window would. */ - commitSelectionExternally: ( - selection: SkillSelection, - confirmedRemovals?: readonly string[], - ) => Promise; - /** Waits until a held save reaches the desktop command boundary. */ - waitForHeldSave: () => Promise; - /** Releases a save held at the desktop command boundary. */ - releaseHeldSave: () => void; - /** Test-only command-boundary gate used to keep the renderer in its pending state. */ - holdSaveResponse?: () => Promise; - cleanup: () => Promise; -} - -export interface SkillsSandboxOptions { - bundledSkills?: string[]; - /** - * Puts a regular file where the `.agents` skills tree has to go, so the real - * convergence fails with ENOTDIR instead of a stubbed rejection. - */ - blockAgentsDir?: boolean; - /** Converge and commit this selection before the app loads, through the real controller. */ - installed?: SkillSelection; - /** - * Leaves a skill installed in one agent home only, the way a partial install - * or a hand-deleted directory leaves it. - */ - keepOnlyIn?: { skill: string; target: SkillTargetName }; - /** - * Writes a skill directory straight into one agent home after the selection is - * committed, the way a manual reinstall or an external sync leaves a directory - * the saved preference does not mention. - */ - placeOnDisk?: { skill: string; target: SkillTargetName; files: Record }; - /** Holds the save response so dismissal behavior can be asserted while pending. */ - holdSave?: boolean; -} - -export type SkillTargetName = "agents" | "claude" | "codex"; - -function targetDir(targets: SkillTargets, target: SkillTargetName): string { - if (target === "agents") return targets.agentsDir; - if (target === "claude") return targets.claudeDir; - return targets.codexDir; -} - -export async function createSkillsSandbox( - options: SkillsSandboxOptions = {}, -): Promise { - let releaseHeldSave: (() => void) | null = null; - let markSaveHeld: (() => void) | null = null; - const heldSave = new Promise((resolve) => { - markSaveHeld = resolve; - }); - const saveRelease = new Promise((resolve) => { - releaseHeldSave = resolve; - }); - const bundledSkills = options.bundledSkills ?? ["paseo", "paseo-advisor", "paseo-loop"]; - const root = await mkdtemp(path.join(os.tmpdir(), "paseo-e2e-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"), - }; - - for (const name of bundledSkills) { - await mkdir(path.join(targets.sourceDir, name), { recursive: true }); - await writeFile(path.join(targets.sourceDir, name, "SKILL.md"), `# ${name}\n`, "utf8"); - } - - const userDataPath = path.join(root, "user-data"); - if (options.installed) { - await createSkillsController({ - resolveTargets: () => targets, - selectionStore: createSkillSelectionStore({ userDataPath }), - }).save(options.installed); - } - - if (options.keepOnlyIn) { - const kept = targetDir(targets, options.keepOnlyIn.target); - for (const dir of [targets.agentsDir, targets.claudeDir, targets.codexDir]) { - if (dir === kept) continue; - await rm(path.join(dir, options.keepOnlyIn.skill), { recursive: true, force: true }); - } - } - - if (options.placeOnDisk) { - const dir = path.join( - targetDir(targets, options.placeOnDisk.target), - options.placeOnDisk.skill, - ); - for (const [rel, contents] of Object.entries(options.placeOnDisk.files)) { - const file = path.join(dir, rel); - await mkdir(path.dirname(file), { recursive: true }); - await writeFile(file, contents, "utf8"); - } - } - - if (options.blockAgentsDir) { - await mkdir(path.dirname(targets.agentsDir), { recursive: true }); - await writeFile(targets.agentsDir, "not a directory", "utf8"); - } - - return { - targets, - userDataPath, - bundledSkills, - placeSkillOnDisk: async (skill, target) => { - const dir = path.join(targetDir(targets, target), skill); - await mkdir(dir, { recursive: true }); - await writeFile(path.join(dir, "SKILL.md"), `# ${skill}\n`, "utf8"); - }, - commitSelectionExternally: async (selection, confirmedRemovals = []) => { - await createSkillsController({ - resolveTargets: () => targets, - selectionStore: createSkillSelectionStore({ userDataPath }), - }).save({ ...selection, confirmedRemovals: [...confirmedRemovals] }); - }, - waitForHeldSave: () => heldSave, - releaseHeldSave: () => releaseHeldSave?.(), - holdSaveResponse: options.holdSave - ? async () => { - markSaveHeld?.(); - await saveRelease; - } - : undefined, - // The save path atomically renames skill directories. On Linux/overlayfs, - // recursive removal can briefly observe a repopulated directory and fail - // with ENOTEMPTY even after the command has completed. Node only retries - // that transient when maxRetries is set. - cleanup: () => rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), - }; -} - -/** Reads the committed selection back through the same store the app writes. */ -export async function expectSavedSkillSelection( - sandbox: SkillsSandbox, - expected: SkillSelection, -): Promise { - const store = createSkillSelectionStore({ userDataPath: sandbox.userDataPath }); - await expect.poll(() => store.get(), { timeout: 15_000 }).toEqual(expected); -} - -/** - * Routes the skills desktop commands to the real handlers in Node, leaving every - * other command on the injected desktop bridge. Call after `installDesktopRuntime` - * and before navigation. - */ -export async function serveRealSkillsCommands(page: Page, sandbox: SkillsSandbox): Promise { - const handlers = createSkillsCommandHandlers({ - controller: createSkillsController({ - resolveTargets: () => sandbox.targets, - selectionStore: createSkillSelectionStore({ userDataPath: sandbox.userDataPath }), - }), - }); - - await page.exposeFunction( - "__paseoSkillsInvoke", - async (command: string, args: Record | null) => { - const handler = handlers[command]; - if (!handler) throw new Error(`Unknown skills command: ${command}`); - const result = await handler(args ?? undefined); - if (command === "save_skills_selection") { - await sandbox.holdSaveResponse?.(); - } - return result; - }, - ); - - await page.addInitScript((commands: readonly string[]) => { - const bridge = (window as unknown as { paseoDesktop?: { invoke: unknown } }).paseoDesktop; - if (!bridge) throw new Error("Desktop bridge must be injected before the skills bridge."); - const skillCommands = new Set(commands); - const inner = bridge.invoke as ( - command: string, - args?: Record, - ) => Promise; - bridge.invoke = (command: string, args?: Record) => - skillCommands.has(command) - ? window.__paseoSkillsInvoke!(command, args ?? null) - : inner(command, args); - }, SKILL_COMMANDS); -} - -/** Real skills commands over a temp bundle, wired to this page's desktop bridge. */ -export async function startSkillsSandbox( - page: Page, - options: SkillsSandboxOptions = {}, -): Promise { - const sandbox = await createSkillsSandbox(options); - await installDesktopRuntime(page, { serverId: getServerId(), confirmShouldAccept: false }); - await serveRealSkillsCommands(page, sandbox); - return sandbox; -} - -export async function openSkillsIntegrations(page: Page): Promise { - await openSettings(page); - await openSettingsSection(page, "integrations"); - await expect(page.getByText("Orchestration skills", { exact: true })).toBeVisible(); -} - -/** Settings on a compact form factor stacks list and detail, so it needs the menu first. */ -export async function openCompactSkillsIntegrations(page: Page): Promise { - await openCompactSettings(page, buildOpenProjectRoute()); - await openSettingsSection(page, "integrations"); - await expect(page.getByText("Orchestration skills", { exact: true })).toBeVisible(); -} - -export async function openSkillSelection(page: Page): Promise { - await page.getByRole("button", { name: "Choose skills", exact: true }).click(); - await expectSkillSelectionOpen(page); -} - -export async function expectSkillSelectionOpen(page: Page): Promise { - await expect(page.getByRole("switch", { name: "All skills", exact: true })).toBeVisible(); -} - -export async function expectSkillSelectionClosed(page: Page): Promise { - await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0); -} - -export async function chooseAllSkills(page: Page): Promise { - const allSkills = page.getByRole("switch", { name: "All skills", exact: true }); - await expect(allSkills).not.toBeChecked(); - await allSkills.click(); - await expect(allSkills).toBeChecked(); -} - -export async function chooseCustomSkills(page: Page, skills: string[]): Promise { - const allSkills = page.getByRole("switch", { name: "All skills", exact: true }); - await expect(allSkills).toBeChecked(); - await allSkills.click(); - await expect(allSkills).not.toBeChecked(); - - const keep = new Set(skills); - for (const name of await listSkillChoices(page)) { - if (keep.has(name)) continue; - await page.getByRole("checkbox", { name, exact: true }).click(); - } - await expectSkillChoices(page, skills); -} - -export async function toggleSkill(page: Page, name: string): Promise { - await page.getByRole("checkbox", { name, exact: true }).click(); -} - -export async function saveSkillSelection(page: Page): Promise { - await page.getByRole("button", { name: "Save", exact: true }).click(); -} - -export async function cancelSkillSelection(page: Page): Promise { - await page.getByRole("button", { name: "Cancel", exact: true }).click(); - await expectSkillSelectionClosed(page); -} - -export async function listSkillChoices(page: Page): Promise { - const rows = page.getByRole("checkbox"); - const names = await rows.evaluateAll((elements) => - elements.map((element) => element.getAttribute("aria-label") ?? ""), - ); - return names.filter((name) => name.length > 0); -} - -/** Asserts exactly which skills are checked in the open sheet. */ -export async function expectSkillChoices(page: Page, skills: string[]): Promise { - const expected = new Set(skills); - for (const name of await listSkillChoices(page)) { - const choice = page.getByRole("checkbox", { name, exact: true }); - if (expected.has(name)) { - await expect(choice).toBeChecked(); - continue; - } - await expect(choice).not.toBeChecked(); - } -} - -/** Reopens the saved selection and asserts what the user sees, then closes it. */ -export async function expectSelectedSkills(page: Page, skills: string[]): Promise { - await openSkillSelection(page); - await expect(page.getByRole("switch", { name: "All skills", exact: true })).not.toBeChecked(); - await expectSkillChoices(page, skills); - await cancelSkillSelection(page); -} - -export async function expectAllSkillsSelected(page: Page): Promise { - await openSkillSelection(page); - const allSkills = page.getByRole("switch", { name: "All skills", exact: true }); - await expect(allSkills).toBeChecked(); - await expectSkillChoices(page, await listSkillChoices(page)); - await cancelSkillSelection(page); -} - -export async function expectSaveErrorKeepsSheetOpen(page: Page): Promise { - await expect(page.getByText("Could not save your skill selection.", { exact: true })).toBeVisible( - { timeout: 15_000 }, - ); - await expectSkillSelectionOpen(page); -} - -/** - * The removal warning is the desktop confirm dialog, which has no DOM to query. - * The injected bridge records the call the same way the daemon-management tests - * assert their confirmation copy. - */ -export async function expectRemovalWarning(page: Page, skills: string[]): Promise { - await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 15_000 }); - const call = await page.evaluate(() => window.__capturedDialogCall); - expect(call?.title).toBe("Remove deselected skills?"); - for (const name of skills) { - expect(call?.message).toContain(name); - } - for (const dir of ["~/.agents", "~/.claude", "~/.codex"]) { - expect(call?.message).toContain(dir); - } - expect(call?.message).toContain("Anything you added inside those skill folders is deleted too."); -} - -export async function expectNoRemovalWarning(page: Page): Promise { - expect(await page.evaluate(() => window.__capturedDialogCall)).toBeUndefined(); -} - -export async function expectSkillPresentIn( - sandbox: SkillsSandbox, - target: SkillTargetName, - skill: string, -): Promise { - await expect - .poll(() => readInstalledSkills(targetDir(sandbox.targets, target)), { timeout: 15_000 }) - .toContain(skill); -} - -export async function expectSkillFilePresent( - sandbox: SkillsSandbox, - target: SkillTargetName, - skill: string, - relativePath: string, -): Promise { - const file = path.join(targetDir(sandbox.targets, target), skill, relativePath); - await expect - .poll(() => readFile(file, "utf8").catch(() => null), { timeout: 15_000 }) - .not.toBeNull(); -} - -export async function expectSkillsInstalled( - sandbox: SkillsSandbox, - skills: string[], -): Promise { - const dirs = [ - sandbox.targets.agentsDir, - sandbox.targets.claudeDir, - sandbox.targets.codexDir, - ] as const; - for (const dir of dirs) { - await expect - .poll(async () => readInstalledSkills(dir), { timeout: 15_000 }) - .toEqual([...skills].sort()); - } -} - -async function readInstalledSkills(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); - return entries - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(); -} diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index c80200f93..7e1cf10b5 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -29,8 +29,6 @@ asarUnpack: extraResources: - from: ../app/dist to: app-dist - - from: ../../skills - to: skills - from: assets/editor-targets to: editor-target-icons publish: diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index faf4a7a10..cf4a6d3c6 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -23,7 +23,6 @@ import { getCliInstallStatus, installCli, } from "../integrations/cli-install/index.js"; -import { createSkillsCommandHandlers, getSkillsController } from "../integrations/skills/index.js"; import { openLocalTransportSession, sendLocalTransportMessage, @@ -39,6 +38,10 @@ import type { DesktopSettings } from "../settings/desktop-settings.js"; import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js"; import { isRunningUnderARM64Translation } from "../system/arm64-translation.js"; import { getDesktopAppLogs } from "../diagnostics/app-logs.js"; +import { + deleteLegacySkillSelection, + readLegacySkillSelection, +} from "../integrations/legacy-skill-selection.js"; import { tailFile } from "../diagnostics/tail-file.js"; const DAEMON_LOG_FILENAME = "daemon.log"; @@ -569,7 +572,8 @@ export function createDaemonCommandHandlers(): Record getLocalDaemonVersion(), install_cli: () => installCli(), get_cli_install_status: () => getCliInstallStatus(), - ...createSkillsCommandHandlers({ controller: getSkillsController() }), + read_legacy_skill_selection: () => readLegacySkillSelection(), + delete_legacy_skill_selection: () => deleteLegacySkillSelection(), }; } diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index 8eac5ecc1..cbea764dc 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -89,6 +89,20 @@ describe("desktop packaging", () => { expect(config).toContain("!node_modules/@getpaseo/server/dist/server/web-ui/**"); }); + it("uses the server skill catalog without a duplicate desktop resource", () => { + const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); + const serverPackage = readFileSync(join(packageRoot, "..", "server", "package.json"), "utf8"); + const runtimeTrace = readFileSync( + join(packageRoot, "..", "..", "scripts", "trace-daemon.mjs"), + "utf8", + ); + + expect(config).not.toContain("from: ../../skills"); + expect(serverPackage).toContain("fs.rmSync('dist/server/skills',{recursive:true,force:true})"); + expect(serverPackage).toContain("fs.cpSync('../../skills','dist/server/skills'"); + expect(runtimeTrace).toContain('"packages/server/dist/server/skills/**"'); + }); + it("registers Paseo agent links with the operating system", () => { const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); diff --git a/packages/desktop/src/desktop-startup.test.ts b/packages/desktop/src/desktop-startup.test.ts index a820efecb..0875c6aff 100644 --- a/packages/desktop/src/desktop-startup.test.ts +++ b/packages/desktop/src/desktop-startup.test.ts @@ -36,24 +36,6 @@ describe("desktop startup", () => { expect(calls).toEqual(["cli", "env", "gui"]); }); - it("starts skills auto-update after GUI startup", async () => { - const calls: string[] = []; - await runDesktopStartup({ - hasPendingGuiLaunchRequest: false, - runCliPassthroughIfRequested: vi.fn(async () => { - calls.push("cli"); - return false; - }), - inheritLoginShellEnv: vi.fn(() => calls.push("env")), - bootstrapGui: vi.fn(async () => { - calls.push("gui"); - }), - autoUpdateInstalledSkills: vi.fn(() => calls.push("skills")), - }); - - expect(calls).toEqual(["cli", "env", "gui", "skills"]); - }); - it("does not route open-project launches through CLI passthrough", async () => { const runCliPassthroughIfRequested = vi.fn(async () => true); const calls: string[] = []; diff --git a/packages/desktop/src/desktop-startup.ts b/packages/desktop/src/desktop-startup.ts index d1be69217..3e4b51f39 100644 --- a/packages/desktop/src/desktop-startup.ts +++ b/packages/desktop/src/desktop-startup.ts @@ -3,7 +3,6 @@ export interface DesktopStartupDependencies { runCliPassthroughIfRequested: () => Promise; inheritLoginShellEnv: () => void; bootstrapGui: () => Promise; - autoUpdateInstalledSkills?: () => void; } export async function runDesktopStartup(deps: DesktopStartupDependencies): Promise { @@ -13,5 +12,4 @@ export async function runDesktopStartup(deps: DesktopStartupDependencies): Promi deps.inheritLoginShellEnv(); await deps.bootstrapGui(); - deps.autoUpdateInstalledSkills?.(); } diff --git a/packages/desktop/src/integrations/legacy-skill-selection.test.ts b/packages/desktop/src/integrations/legacy-skill-selection.test.ts new file mode 100644 index 000000000..0a568ffb7 --- /dev/null +++ b/packages/desktop/src/integrations/legacy-skill-selection.test.ts @@ -0,0 +1,37 @@ +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +let userDataPath = ""; +vi.mock("electron", () => ({ app: { getPath: () => userDataPath } })); + +import { deleteLegacySkillSelection, readLegacySkillSelection } from "./legacy-skill-selection"; + +beforeEach(async () => { + userDataPath = await mkdtemp(path.join(os.tmpdir(), "paseo-legacy-skills-")); +}); + +describe("legacy desktop skill selection", () => { + it("reads and normalizes the legacy preference", async () => { + await writeFile( + path.join(userDataPath, "skill-selection.json"), + JSON.stringify({ + version: 1, + selection: { mode: "custom", skills: ["paseo-loop", "paseo", "paseo"] }, + }), + ); + expect(await readLegacySkillSelection()).toEqual({ + mode: "custom", + skills: ["paseo", "paseo-loop"], + }); + }); + + it("deletes only when the caller confirms daemon persistence", async () => { + const file = path.join(userDataPath, "skill-selection.json"); + await writeFile(file, JSON.stringify({ version: 1, selection: { mode: "all" } })); + await expect(readFile(file, "utf8")).resolves.toContain('"mode":"all"'); + await deleteLegacySkillSelection(); + await expect(readFile(file, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/packages/desktop/src/integrations/legacy-skill-selection.ts b/packages/desktop/src/integrations/legacy-skill-selection.ts new file mode 100644 index 000000000..a7a3833b1 --- /dev/null +++ b/packages/desktop/src/integrations/legacy-skill-selection.ts @@ -0,0 +1,50 @@ +import { readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { app } from "electron"; + +type AgentSkillSelection = { mode: "all" } | { mode: "custom"; skills: string[] }; + +const LEGACY_FILENAME = "skill-selection.json"; + +function parseSelection(value: unknown): AgentSkillSelection | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return { mode: "all" }; + const document = value as { selection?: unknown }; + if (!document.selection || typeof document.selection !== "object") return { mode: "all" }; + const selection = document.selection as { mode?: unknown; skills?: unknown }; + if (selection.mode === "all") return { mode: "all" }; + if (selection.mode !== "custom" || !Array.isArray(selection.skills)) return { mode: "all" }; + return { + mode: "custom", + skills: [ + ...new Set( + selection.skills + .filter((name): name is string => typeof name === "string") + .map((name) => name.trim()) + .filter(Boolean), + ), + ].sort(), + }; +} + +function legacyPath(): string { + return path.join(app.getPath("userData"), LEGACY_FILENAME); +} + +// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16. +export async function readLegacySkillSelection(): Promise { + const raw = await readFile(legacyPath(), "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (raw === null) return null; + try { + return parseSelection(JSON.parse(raw)); + } catch { + return { mode: "all" }; + } +} + +// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16. +export async function deleteLegacySkillSelection(): Promise { + await rm(legacyPath(), { force: true }); +} diff --git a/packages/desktop/src/integrations/skills/index.ts b/packages/desktop/src/integrations/skills/index.ts deleted file mode 100644 index 49306a995..000000000 --- a/packages/desktop/src/integrations/skills/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { app } from "electron"; - -import type { SkillsStatus, SkillTargets } from "./operations.js"; -import { - getAgentsSkillsDir, - getBundledSkillsDir, - getClaudeSkillsDir, - getCodexSkillsDir, -} from "./paths.js"; -import { createSkillSelectionStore } from "./selection-store.js"; -import { createSkillsController, type SkillsController } from "./skills-controller.js"; - -export { createSkillsCommandHandlers } from "./skills-commands.js"; -export { type SkillsController, type SkillsSnapshot } from "./skills-controller.js"; -export type { - SkillOp, - SkillSelection, - SkillsState, - SkillsStatus, - SkillTargets, -} from "./operations.js"; - -let controller: SkillsController | null = null; - -function getSkillTargets(): SkillTargets { - return { - sourceDir: getBundledSkillsDir(), - agentsDir: getAgentsSkillsDir(), - claudeDir: getClaudeSkillsDir(), - codexDir: getCodexSkillsDir(), - }; -} - -/** - * One controller per process. Startup convergence and the settings commands must - * share it, or they are two writers over the same directories again. - */ -export function getSkillsController(): SkillsController { - controller ??= createSkillsController({ - resolveTargets: getSkillTargets, - selectionStore: createSkillSelectionStore({ userDataPath: app.getPath("userData") }), - }); - return controller; -} - -/** Startup convergence: keep whatever the user selected in sync with the bundle. */ -export async function autoUpdateInstalledSkills(): Promise { - return getSkillsController().autoUpdate(); -} diff --git a/packages/desktop/src/integrations/skills/paths.ts b/packages/desktop/src/integrations/skills/paths.ts deleted file mode 100644 index cc7423576..000000000 --- a/packages/desktop/src/integrations/skills/paths.ts +++ /dev/null @@ -1,22 +0,0 @@ -import path from "node:path"; -import os from "node:os"; -import { app } from "electron"; - -export function getBundledSkillsDir(): string { - if (app.isPackaged) { - return path.join(process.resourcesPath, "skills"); - } - return path.join(__dirname, "..", "..", "..", "..", "..", "skills"); -} - -export function getAgentsSkillsDir(): string { - return path.join(os.homedir(), ".agents", "skills"); -} - -export function getClaudeSkillsDir(): string { - return path.join(os.homedir(), ".claude", "skills"); -} - -export function getCodexSkillsDir(): string { - return path.join(os.homedir(), ".codex", "skills"); -} diff --git a/packages/desktop/src/integrations/skills/selection-store.test.ts b/packages/desktop/src/integrations/skills/selection-store.test.ts deleted file mode 100644 index 3f1ddc932..000000000 --- a/packages/desktop/src/integrations/skills/selection-store.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; - -import { createSkillSelectionStore } from "./selection-store"; - -const directories = new Set(); - -async function createTempUserDataDir(): Promise { - const dir = await mkdtemp(path.join(os.tmpdir(), "paseo-skill-selection-")); - directories.add(dir); - return dir; -} - -function selectionFilePath(userDataPath: string): string { - return path.join(userDataPath, "skill-selection.json"); -} - -async function seedSelectionFile(userDataPath: string, raw: string): Promise { - await writeFile(selectionFilePath(userDataPath), raw, "utf8"); -} - -afterEach(async () => { - for (const dir of directories) { - await rm(dir, { recursive: true, force: true }); - } - directories.clear(); -}); - -describe("createSkillSelectionStore", () => { - it("reports all skills when nothing has ever been saved", async () => { - const userDataPath = await createTempUserDataDir(); - const store = createSkillSelectionStore({ userDataPath }); - - expect(await store.get()).toEqual({ mode: "all" }); - expect(await readdir(userDataPath)).toEqual([]); - }); - - it("round-trips a custom selection through a fresh store", async () => { - const userDataPath = await createTempUserDataDir(); - - const saved = await createSkillSelectionStore({ userDataPath }).set({ - mode: "custom", - skills: ["paseo", "paseo-loop"], - }); - const reloaded = await createSkillSelectionStore({ userDataPath }).get(); - - expect(saved).toEqual({ mode: "custom", skills: ["paseo", "paseo-loop"] }); - expect(reloaded).toEqual({ mode: "custom", skills: ["paseo", "paseo-loop"] }); - expect(await readdir(userDataPath)).toEqual(["skill-selection.json"]); - }); - - it("sorts, de-duplicates, and drops non-string names before persisting", async () => { - const userDataPath = await createTempUserDataDir(); - const store = createSkillSelectionStore({ userDataPath }); - - const saved = await store.set({ - mode: "custom", - skills: ["paseo-loop", "paseo", "paseo-loop", " ", 7, null], - }); - - expect(saved).toEqual({ mode: "custom", skills: ["paseo", "paseo-loop"] }); - expect(JSON.parse(await readFile(selectionFilePath(userDataPath), "utf8"))).toEqual({ - version: 1, - selection: { mode: "custom", skills: ["paseo", "paseo-loop"] }, - }); - }); - - it("keeps an empty custom selection as a deliberate choice", async () => { - const userDataPath = await createTempUserDataDir(); - - await createSkillSelectionStore({ userDataPath }).set({ mode: "custom", skills: [] }); - - expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({ - mode: "custom", - skills: [], - }); - }); - - it("switches back to all and forgets the previous custom names", async () => { - const userDataPath = await createTempUserDataDir(); - const store = createSkillSelectionStore({ userDataPath }); - await store.set({ mode: "custom", skills: ["paseo"] }); - - const saved = await store.set({ mode: "all" }); - - expect(saved).toEqual({ mode: "all" }); - expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({ mode: "all" }); - }); - - it("falls back to all when the saved file is not valid JSON", async () => { - const userDataPath = await createTempUserDataDir(); - await seedSelectionFile(userDataPath, "{ not valid json"); - - expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({ mode: "all" }); - }); - - it("falls back to all when the saved mode is unknown", async () => { - const userDataPath = await createTempUserDataDir(); - await seedSelectionFile( - userDataPath, - JSON.stringify({ version: 1, selection: { mode: "some-future-mode", skills: ["paseo"] } }), - ); - - expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({ mode: "all" }); - }); - - it("normalizes a custom selection that was hand-edited on disk", async () => { - const userDataPath = await createTempUserDataDir(); - await seedSelectionFile( - userDataPath, - JSON.stringify({ - version: 1, - selection: { mode: "custom", skills: ["paseo-loop", 3, "paseo", "paseo"] }, - }), - ); - - expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({ - mode: "custom", - skills: ["paseo", "paseo-loop"], - }); - }); - - it("treats a custom selection with no usable skills array as an empty selection", async () => { - const userDataPath = await createTempUserDataDir(); - await seedSelectionFile( - userDataPath, - JSON.stringify({ version: 1, selection: { mode: "custom" } }), - ); - - expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({ - mode: "custom", - skills: [], - }); - }); - - it("leaves no temp files behind when saves overlap", async () => { - const userDataPath = await createTempUserDataDir(); - const store = createSkillSelectionStore({ userDataPath }); - - await Promise.all([ - store.set({ mode: "custom", skills: ["paseo"] }), - store.set({ mode: "all" }), - store.set({ mode: "custom", skills: ["paseo-loop"] }), - ]); - - expect(await readdir(userDataPath)).toEqual(["skill-selection.json"]); - }); -}); diff --git a/packages/desktop/src/integrations/skills/selection-store.ts b/packages/desktop/src/integrations/skills/selection-store.ts deleted file mode 100644 index 3de94c651..000000000 --- a/packages/desktop/src/integrations/skills/selection-store.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; -import path from "node:path"; - -import type { SkillSelection } from "./operations.js"; - -export interface SkillSelectionStore { - get(): Promise; - set(selection: unknown): Promise; -} - -interface PersistedSkillSelectionDocument { - version: 1; - selection: SkillSelection; -} - -const SKILL_SELECTION_FILENAME = "skill-selection.json"; - -// Installs that predate the picker have no file. They keep every bundled skill, -// which is what they had before, and what `all` keeps meaning as the bundle grows. -const DEFAULT_SKILL_SELECTION: SkillSelection = { mode: "all" }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isNodeError(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error; -} - -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(); -} - -/** - * The one place an unvalidated selection becomes a `SkillSelection`. Whether a - * name is actually in the bundle is not decided here — the catalog belongs to - * the bundle directory, so `operations` resolves that against the live catalog. - */ -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; -} - -function coerceDocument(value: unknown): PersistedSkillSelectionDocument { - const selection = isRecord(value) - ? coerceSkillSelection(value.selection) - : DEFAULT_SKILL_SELECTION; - return { version: 1, selection }; -} - -export function createSkillSelectionStore({ - userDataPath, -}: { - userDataPath: string; -}): SkillSelectionStore { - const filePath = path.join(userDataPath, SKILL_SELECTION_FILENAME); - let persistQueue: Promise = Promise.resolve(); - - async function persist(document: PersistedSkillSelectionDocument): Promise { - const write = async () => { - await mkdir(userDataPath, { recursive: true }); - const tempFilePath = `${filePath}.tmp.${process.pid}.${randomUUID()}`; - await writeFile(tempFilePath, `${JSON.stringify(document, null, 2)}\n`, "utf8"); - await rename(tempFilePath, filePath); - }; - const queued = persistQueue.then(write, write); - persistQueue = queued.catch(() => undefined); - await queued; - } - - return { - async get(): Promise { - let raw: string; - try { - raw = await readFile(filePath, "utf8"); - } catch (error) { - if (!isNodeError(error) || error.code !== "ENOENT") throw error; - return DEFAULT_SKILL_SELECTION; - } - try { - return coerceDocument(JSON.parse(raw)).selection; - } catch { - return DEFAULT_SKILL_SELECTION; - } - }, - - async set(selection: unknown): Promise { - const document = coerceDocument({ selection }); - await persist(document); - return document.selection; - }, - }; -} diff --git a/packages/desktop/src/integrations/skills/skills-commands.ts b/packages/desktop/src/integrations/skills/skills-commands.ts deleted file mode 100644 index 82cee1738..000000000 --- a/packages/desktop/src/integrations/skills/skills-commands.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { DesktopCommandHandler } from "../../settings/desktop-settings-commands.js"; -import type { SkillsController } from "./skills-controller.js"; - -export function createSkillsCommandHandlers({ - controller, -}: { - controller: SkillsController; -}): Record { - return { - get_skills_status: () => controller.status(), - install_skills: () => controller.install(), - update_skills: () => controller.update(), - uninstall_skills: () => controller.uninstall(), - save_skills_selection: (args) => controller.save(args), - }; -} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index cb0ae0aa5..da3630ff4 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -87,7 +87,6 @@ import { stopDesktopManagedDaemonOnQuitIfNeeded, } from "./daemon/quit-lifecycle.js"; import { runDesktopStartup } from "./desktop-startup.js"; -import { autoUpdateInstalledSkills } from "./integrations/skills/index.js"; import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js"; import { BrowserKeyboard } from "./features/browser-keyboard/index.js"; import { installAppUpdateOnQuit } from "./features/auto-updater.js"; @@ -1035,11 +1034,6 @@ void runDesktopStartup({ runCliPassthroughIfRequested, inheritLoginShellEnv, bootstrapGui: bootstrap, - autoUpdateInstalledSkills: () => { - void autoUpdateInstalledSkills().catch((error) => { - log.error("[skills] auto-update failed", error); - }); - }, }).catch((error) => { const message = error instanceof Error ? (error.stack ?? error.message) : String(error); process.stderr.write(`${message}\n`); diff --git a/packages/protocol/src/messages.agent-skills.test.ts b/packages/protocol/src/messages.agent-skills.test.ts new file mode 100644 index 000000000..d1ff646af --- /dev/null +++ b/packages/protocol/src/messages.agent-skills.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + AgentSkillsSaveSelectionRequestSchema, + ServerInfoStatusPayloadSchema, + SessionInboundMessageSchema, + SessionOutboundMessageSchema, +} from "./messages"; + +describe("agent skills protocol", () => { + it("parses dotted selection requests and confirmation", () => { + expect( + AgentSkillsSaveSelectionRequestSchema.parse({ + type: "agent.skills.save_selection.request", + requestId: "request-1", + selection: { mode: "custom", skills: ["paseo"] }, + confirmedRemovals: ["paseo-loop"], + }), + ).toMatchObject({ requestId: "request-1" }); + expect( + SessionInboundMessageSchema.safeParse({ + type: "agent.skills.get_status.request", + requestId: "request-2", + }).success, + ).toBe(true); + }); + + it("parses status responses and keeps the capability optional", () => { + expect( + SessionOutboundMessageSchema.safeParse({ + type: "agent.skills.get_status.response", + payload: { + requestId: "request-3", + state: "not-installed", + ops: [{ kind: "add", name: "paseo" }], + available: ["paseo"], + installed: [], + selection: { mode: "all" }, + }, + }).success, + ).toBe(true); + expect( + ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "old-daemon", + features: {}, + }).features.skillManagement, + ).toBeUndefined(); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 9505bd277..72e9b51a4 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -188,6 +188,12 @@ export const PluginSourceSchema = z.discriminatedUnion("source", [DirectoryPlugi export type PluginSource = z.infer; +export const AgentSkillSelectionSchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("all") }).strict(), + z.object({ mode: z.literal("custom"), skills: z.array(z.string()) }).strict(), +]); +export type AgentSkillSelection = z.infer; + export const MutableDaemonConfigSchema = z .object({ // COMPAT(relayConfig): added in v0.2.6, remove after 2027-01-31 when old daemons are unsupported. @@ -222,6 +228,7 @@ export const MutableDaemonConfigSchema = z appendSystemPrompt: z.string().default(""), terminalProfiles: z.array(TerminalProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(), + skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), }) @@ -1395,6 +1402,59 @@ export const PluginRpcInvokeRequestSchema = z.object({ input: z.unknown(), }); +export const AgentSkillOperationSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("add"), name: z.string() }).strict(), + z.object({ kind: z.literal("update"), name: z.string() }).strict(), + z.object({ kind: z.literal("delete"), name: z.string() }).strict(), +]); +export type AgentSkillOperation = z.infer; + +export const AgentSkillsStatusSchema = z.object({ + state: z.enum(["not-installed", "up-to-date", "drift"]), + ops: z.array(AgentSkillOperationSchema), + available: z.array(z.string()), + installed: z.array(z.string()), + selection: AgentSkillSelectionSchema, +}); +export type AgentSkillsStatus = z.infer; + +export const AgentSkillsConfirmationSchema = z.object({ removals: z.array(z.string()) }).strict(); +export type AgentSkillsConfirmation = z.infer; + +export const AgentSkillsSaveResultSchema = AgentSkillsStatusSchema.extend({ + confirmationRequired: AgentSkillsConfirmationSchema.nullable(), +}); +export type AgentSkillsSaveResult = z.infer; + +function agentSkillsRequest(type: Type) { + return z.object({ type: z.literal(type), requestId: z.string() }).strict(); +} + +export const AgentSkillsGetStatusRequestSchema = agentSkillsRequest( + "agent.skills.get_status.request", +); +export const AgentSkillsReconcileRequestSchema = agentSkillsRequest( + "agent.skills.reconcile.request", +); +export const AgentSkillsUninstallRequestSchema = agentSkillsRequest( + "agent.skills.uninstall.request", +); +export const AgentSkillsSaveSelectionRequestSchema = z + .object({ + type: z.literal("agent.skills.save_selection.request"), + requestId: z.string(), + selection: AgentSkillSelectionSchema, + confirmedRemovals: z.array(z.string()).optional(), + }) + .strict(); +export const AgentSkillsImportLegacySelectionRequestSchema = z + .object({ + type: z.literal("agent.skills.import_legacy_selection.request"), + requestId: z.string(), + selection: AgentSkillSelectionSchema, + }) + .strict(); + export const GetDaemonConfigRequestMessageSchema = z.object({ type: z.literal("get_daemon_config_request"), requestId: z.string(), @@ -2875,6 +2935,11 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ PluginDisableRequestSchema, PluginRemoveRequestSchema, PluginRpcInvokeRequestSchema, + AgentSkillsGetStatusRequestSchema, + AgentSkillsReconcileRequestSchema, + AgentSkillsUninstallRequestSchema, + AgentSkillsSaveSelectionRequestSchema, + AgentSkillsImportLegacySelectionRequestSchema, GetDaemonConfigRequestMessageSchema, SetDaemonConfigRequestMessageSchema, ReadProjectConfigRequestMessageSchema, @@ -3205,6 +3270,8 @@ export const ServerInfoStatusPayloadSchema = z pluginManagement: z.boolean().optional(), // COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16. pluginLogs: z.boolean().optional(), + // COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16. + skillManagement: z.boolean().optional(), // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": z.boolean().optional(), // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. @@ -5933,6 +6000,35 @@ export const PluginRpcInvokeResponseSchema = z.object({ }), }); +function agentSkillsStatusResponse(type: Type) { + return z.object({ + type: z.literal(type), + payload: AgentSkillsStatusSchema.extend({ requestId: z.string() }), + }); +} + +export const AgentSkillsGetStatusResponseSchema = agentSkillsStatusResponse( + "agent.skills.get_status.response", +); +export const AgentSkillsReconcileResponseSchema = agentSkillsStatusResponse( + "agent.skills.reconcile.response", +); +export const AgentSkillsUninstallResponseSchema = agentSkillsStatusResponse( + "agent.skills.uninstall.response", +); +export const AgentSkillsSaveSelectionResponseSchema = z.object({ + type: z.literal("agent.skills.save_selection.response"), + payload: AgentSkillsSaveResultSchema.extend({ requestId: z.string() }), +}); +export const AgentSkillsImportLegacySelectionResponseSchema = z.object({ + type: z.literal("agent.skills.import_legacy_selection.response"), + payload: z.object({ + requestId: z.string(), + imported: z.boolean(), + selection: AgentSkillSelectionSchema, + }), +}); + export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ HubExecutionAgentCreateResponseSchema, HubExecutionAgentValidateResponseSchema, @@ -5950,6 +6046,11 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ PluginDisableResponseSchema, PluginRemoveResponseSchema, PluginRpcInvokeResponseSchema, + AgentSkillsGetStatusResponseSchema, + AgentSkillsReconcileResponseSchema, + AgentSkillsUninstallResponseSchema, + AgentSkillsSaveSelectionResponseSchema, + AgentSkillsImportLegacySelectionResponseSchema, ActivityLogMessageSchema, AssistantChunkMessageSchema, AudioOutputMessageSchema, diff --git a/packages/server/package.json b/packages/server/package.json index 429056e14..b4131e55d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -38,7 +38,7 @@ "clean": "node ../../scripts/clean-package-dist.mjs", "build": "npm run build:lib && npm run build:scripts", "build:clean": "npm run clean && npm run build", - "build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs');\"", + "build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true});\"", "build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"", "prepack": "npm run build:clean && npm --prefix ../.. run build:daemon-web-ui", "start": "node dist/scripts/supervisor-entrypoint.js", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 20d860cc1..b50342bfc 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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; 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(); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 0c4bfedcd..7ed5f1651 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -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", diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 69f3aae2a..922d0dac1 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -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([ ["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; } diff --git a/packages/server/src/server/orchestration-skills/index.ts b/packages/server/src/server/orchestration-skills/index.ts new file mode 100644 index 000000000..9ed0bd244 --- /dev/null +++ b/packages/server/src/server/orchestration-skills/index.ts @@ -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; + reconcile(): Promise; + uninstall(): Promise; + saveSelection( + selection: AgentSkillSelection, + confirmedRemovals?: readonly string[], + ): Promise; + importLegacySelectionIfUnset(selection: AgentSkillSelection): Promise<{ + imported: boolean; + selection: AgentSkillSelection; + }>; + autoUpdate(): Promise; +} + +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"; diff --git a/packages/desktop/src/integrations/skills/skills-controller.test.ts b/packages/server/src/server/orchestration-skills/internal/controller.test.ts similarity index 96% rename from packages/desktop/src/integrations/skills/skills-controller.test.ts rename to packages/server/src/server/orchestration-skills/internal/controller.test.ts index cd94a18f5..d3dab5aa7 100644 --- a/packages/desktop/src/integrations/skills/skills-controller.test.ts +++ b/packages/server/src/server/orchestration-skills/internal/controller.test.ts @@ -18,9 +18,9 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { SkillSelection, SkillTargets } from "./operations"; -import { createSkillSelectionStore, type SkillSelectionStore } from "./selection-store"; -import { createSkillsController, type SkillsController } from "./skills-controller"; -import { beginSkillsTransaction } from "./skills-transaction"; +import type { SkillSelectionStore } from "./selection-store"; +import { createSkillsController, type SkillsController } from "./controller"; +import { beginSkillsTransaction } from "./transaction"; interface Harness { root: string; @@ -43,8 +43,17 @@ async function makeHarness(selectionStore?: SkillSelectionStore): Promise selection, + set: async (next: unknown) => { + selection = next as SkillSelection; + return selection; + }, + isSet: async () => true, + } satisfies SkillSelectionStore); return { root, targets, @@ -64,6 +73,7 @@ async function makeHarness(selectionStore?: SkillSelectionStore): Promise initial, + isSet: async () => true, set: async () => { throw new Error("selection store is read-only"); }, @@ -86,6 +96,7 @@ function createGatedUnwritableSelectionStore(initial: SkillSelection): { return { store: { get: async () => initial, + isSet: async () => true, set: async () => { markStarted(); await failureGate; @@ -114,10 +125,12 @@ function createGatedSelectionStore(initial: SkillSelection): { return { store: { get: async () => current, + isSet: async () => true, set: async (selection) => { markStarted(); await gate; current = selection; + return current; }, }, persistenceStarted, @@ -209,6 +222,38 @@ async function isInstalled(targets: SkillTargets, name: string): Promise { + it("imports a legacy selection only while daemon selection is absent", async () => { + let current: SkillSelection = { mode: "all" }; + let explicit = false; + const store: SkillSelectionStore = { + get: async () => current, + set: async (selection) => { + current = selection as SkillSelection; + explicit = true; + return current; + }, + isSet: async () => explicit, + }; + const harness = await makeHarness(store); + + await expect( + harness.controller.importLegacySelectionIfUnset({ + mode: "custom", + skills: ["paseo", "paseo-loop"], + }), + ).resolves.toEqual({ + imported: true, + selection: { mode: "custom", skills: ["paseo", "paseo-loop"] }, + }); + await expect(harness.controller.importLegacySelectionIfUnset({ mode: "all" })).resolves.toEqual( + { + imported: false, + selection: { mode: "custom", skills: ["paseo", "paseo-loop"] }, + }, + ); + expect(await installedEverywhere(harness.targets)).toEqual([[], [], []]); + }); + let harness: Harness; beforeEach(async () => { diff --git a/packages/desktop/src/integrations/skills/skills-controller.ts b/packages/server/src/server/orchestration-skills/internal/controller.ts similarity index 90% rename from packages/desktop/src/integrations/skills/skills-controller.ts rename to packages/server/src/server/orchestration-skills/internal/controller.ts index 7c627bc8d..3cbeced32 100644 --- a/packages/desktop/src/integrations/skills/skills-controller.ts +++ b/packages/server/src/server/orchestration-skills/internal/controller.ts @@ -13,10 +13,7 @@ import { coerceSkillSelection, type SkillSelectionStore, } from "./selection-store.js"; -import { - beginSkillsTransaction, - recoverInterruptedSkillTransactions, -} from "./skills-transaction.js"; +import { beginSkillsTransaction, recoverInterruptedSkillTransactions } from "./transaction.js"; /** Everything the settings UI needs to render skills in one round trip. */ export interface SkillsSnapshot extends SkillsStatus { @@ -38,6 +35,10 @@ export interface SkillsController { uninstall(): Promise; autoUpdate(): Promise; save(request: unknown): Promise; + importLegacySelectionIfUnset(selection: unknown): Promise<{ + imported: boolean; + selection: SkillSelection; + }>; } type Converge = (targets: SkillTargets, selection: SkillSelection) => Promise; @@ -149,6 +150,14 @@ export function createSkillsController({ 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)), @@ -156,5 +165,7 @@ export function createSkillsController({ uninstall: () => serialize(() => converge(uninstallSkills)), autoUpdate: () => serialize(() => converge(autoUpdateInstalledSkills)), save: (selection) => serialize(() => saveSelection(selection)), + importLegacySelectionIfUnset: (selection) => + serialize(() => importLegacySelectionIfUnset(selection)), }; } diff --git a/packages/desktop/src/integrations/skills/operations.test.ts b/packages/server/src/server/orchestration-skills/internal/operations.test.ts similarity index 100% rename from packages/desktop/src/integrations/skills/operations.test.ts rename to packages/server/src/server/orchestration-skills/internal/operations.test.ts diff --git a/packages/desktop/src/integrations/skills/operations.ts b/packages/server/src/server/orchestration-skills/internal/operations.ts similarity index 98% rename from packages/desktop/src/integrations/skills/operations.ts rename to packages/server/src/server/orchestration-skills/internal/operations.ts index da118a171..a3b083112 100644 --- a/packages/desktop/src/integrations/skills/operations.ts +++ b/packages/server/src/server/orchestration-skills/internal/operations.ts @@ -1,6 +1,7 @@ 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"; @@ -11,7 +12,7 @@ export type SkillOp = | { kind: "delete"; name: string }; /** What the user asked to have installed. `all` follows the bundle as it grows. */ -export type SkillSelection = { mode: "all" } | { mode: "custom"; skills: readonly string[] }; +export type SkillSelection = AgentSkillSelection; export interface SkillsStatus { state: SkillsState; diff --git a/packages/server/src/server/orchestration-skills/internal/paths.test.ts b/packages/server/src/server/orchestration-skills/internal/paths.test.ts new file mode 100644 index 000000000..d02076219 --- /dev/null +++ b/packages/server/src/server/orchestration-skills/internal/paths.test.ts @@ -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"), + }); + }); +}); diff --git a/packages/server/src/server/orchestration-skills/internal/paths.ts b/packages/server/src/server/orchestration-skills/internal/paths.ts new file mode 100644 index 000000000..e4a6e0967 --- /dev/null +++ b/packages/server/src/server/orchestration-skills/internal/paths.ts @@ -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"), + }; +} diff --git a/packages/server/src/server/orchestration-skills/internal/selection-store.test.ts b/packages/server/src/server/orchestration-skills/internal/selection-store.test.ts new file mode 100644 index 000000000..5a42fd0da --- /dev/null +++ b/packages/server/src/server/orchestration-skills/internal/selection-store.test.ts @@ -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(); + }); +}); diff --git a/packages/server/src/server/orchestration-skills/internal/selection-store.ts b/packages/server/src/server/orchestration-skills/internal/selection-store.ts new file mode 100644 index 000000000..6fa00af6e --- /dev/null +++ b/packages/server/src/server/orchestration-skills/internal/selection-store.ts @@ -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; + set(selection: unknown): Promise; + isSet(): Promise; +} + +const DEFAULT_SKILL_SELECTION: SkillSelection = { mode: "all" }; + +function isRecord(value: unknown): value is Record { + 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, +): 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; + }, + }; +} diff --git a/packages/desktop/src/integrations/skills/sync.test.ts b/packages/server/src/server/orchestration-skills/internal/sync.test.ts similarity index 100% rename from packages/desktop/src/integrations/skills/sync.test.ts rename to packages/server/src/server/orchestration-skills/internal/sync.test.ts diff --git a/packages/desktop/src/integrations/skills/sync.ts b/packages/server/src/server/orchestration-skills/internal/sync.ts similarity index 100% rename from packages/desktop/src/integrations/skills/sync.ts rename to packages/server/src/server/orchestration-skills/internal/sync.ts diff --git a/packages/desktop/src/integrations/skills/skills-transaction.ts b/packages/server/src/server/orchestration-skills/internal/transaction.ts similarity index 100% rename from packages/desktop/src/integrations/skills/skills-transaction.ts rename to packages/server/src/server/orchestration-skills/internal/transaction.ts diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 2012ba093..1cdaf209d 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -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(), diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 662724ff8..de0fd07ec 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -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 = { + 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>(); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 696ca138e..11b6b4e8b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -476,6 +476,7 @@ export interface SessionOptions { catalog(): Array<{ id: string; clientBundle: string }>; invokePluginRpc(pluginId: string, method: string, input: unknown): Promise; }; + orchestrationSkills?: import("./orchestration-skills/index.js").OrchestrationSkills; mcpBaseUrl?: string | null; stt: Resolvable; 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 | 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, + ) => + 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 | undefined { if (msg.type === "plugin.list.request") { this.emit({ diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index b88a118f3..285b42a7a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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. diff --git a/public-docs/skills.md b/public-docs/skills.md index 88a9c63fa..c1fae0151 100644 --- a/public-docs/skills.md +++ b/public-docs/skills.md @@ -16,10 +16,10 @@ Start with [Orchestration](/docs/orchestration) if you want the mental model, or Two ways to install: -- **Desktop app:** Settings → Integrations → Install +- **Paseo app:** Connect to the host, then open Settings → Host → Agents → Orchestration skills. The selected host installs the skills on its own machine. - **Manual:** `npx skills add getpaseo/paseo`, this installs to `~/.agents/skills/` and sets up symlinks for each agent. -When the desktop app finds installed Paseo skills, it keeps the bundled skills up to date on startup. If automatic update fails, use Settings → Integrations → Update or the manual command above. +When a daemon finds installed Paseo skills, it keeps the selected bundled skills up to date on startup without removing deselected directories. Use the host's Orchestration skills card to install, update, choose, or uninstall skills. Removal always asks for confirmation. ## `/paseo`, Paseo Reference diff --git a/scripts/trace-daemon.mjs b/scripts/trace-daemon.mjs index a4ece424f..4e8968d0d 100644 --- a/scripts/trace-daemon.mjs +++ b/scripts/trace-daemon.mjs @@ -51,6 +51,8 @@ const entries = [ // Files read at runtime via fs APIs rather than `require`. nft only // traces the module graph; data files have to be listed explicitly. const additionalInputs = [ + // Agent orchestration skill catalog loaded through filesystem paths + "packages/server/dist/server/skills/**", // Shell integration scripts loaded by the terminal manager "packages/server/dist/server/terminal/shell-integration/**", // Silero VAD ONNX model (sherpa speech provider)