Manage orchestration skills per host (#3451)
* feat(skills): manage orchestration skills per host Agent skills execute on the daemon host, so their bundle, selection, and filesystem convergence must share that owner. Preserve the existing install semantics while letting every capable client manage the selected host and migrating the legacy desktop preference once. * fix(client): keep skill management internal Keep host skill management on the low-level daemon client used by the app. Do not expose daemon settings or the desktop-only compatibility migration through the shared Paseo API handed to plugins. * fix(e2e): declare skills fixture dependencies Use Playwright's required destructuring form for the test-scoped daemon sandbox fixture so CI can collect the real-host skills suite. * test(skills): use native managed paths Assert fixed skill targets relative to the daemon user's real home with platform-native path joining so the ownership contract holds on Windows and POSIX hosts.
This commit is contained in:
@@ -65,7 +65,7 @@ not retain non-Git directories.
|
|||||||
**Key modules:**
|
**Key modules:**
|
||||||
|
|
||||||
| Module | Responsibility |
|
| Module | Responsibility |
|
||||||
| ------------------------------- | ----------------------------------------------------------------------------- |
|
| ------------------------------- | ------------------------------------------------------------------------------ |
|
||||||
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
| `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/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
|
||||||
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||||
@@ -75,6 +75,7 @@ not retain non-Git directories.
|
|||||||
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
|
| `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/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/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/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||||
| `server/schedule/` | Cron-based scheduled agents |
|
| `server/schedule/` | Cron-based scheduled agents |
|
||||||
|
|
||||||
@@ -172,6 +173,10 @@ Electron wrapper for macOS, Linux, and Windows.
|
|||||||
- Native file access for workspace integration
|
- Native file access for workspace integration
|
||||||
- Same WebSocket client as mobile app
|
- 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 <path>` launch.
|
**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 <path>` 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.
|
> **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.
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId`
|
|||||||
|
|
||||||
Single file, validated with `PersistedConfigSchema`.
|
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,
|
`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
|
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
|
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 }
|
local: { modelsDir: string }
|
||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
|
skills?: {
|
||||||
|
selection?: { mode: "all" } | { mode: "custom", skills: string[] }
|
||||||
|
},
|
||||||
// ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the
|
// ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the
|
||||||
// current shape on load via `migrateProviderSettings`. Custom provider IDs must declare
|
// 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`.
|
// `extends` (one of the built-ins or `"acp"`) and `label`. See `provider-launch-config.ts`.
|
||||||
|
|||||||
@@ -178,11 +178,6 @@ buildNpmPackage {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
# Hicolor icon for desktop environments
|
||||||
install -Dm644 packages/desktop/assets/icon.png \
|
install -Dm644 packages/desktop/assets/icon.png \
|
||||||
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
|
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
|
||||||
|
|||||||
@@ -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<typeof openSkillSelection>[0]) {
|
||||||
|
return page.getByTestId("skill-selection-all-card");
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillListCard(page: Parameters<typeof openSkillSelection>[0]) {
|
||||||
|
return page.getByTestId("skill-selection-list-card");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundledSkillsLabel(page: Parameters<typeof openSkillSelection>[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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<SkillTargetName, string>;
|
||||||
|
blockAgentsDirectory(): Promise<void>;
|
||||||
|
unblockAgentsDirectory(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
getStatus(): ReturnType<AgentSkillsClient["getAgentSkillsStatus"]>;
|
||||||
|
install(selection: AgentSkillSelection): Promise<void>;
|
||||||
|
keepSkillOnlyIn(skill: string, target: SkillTargetName): Promise<void>;
|
||||||
|
placeSkillOnDisk(
|
||||||
|
skill: string,
|
||||||
|
target: SkillTargetName,
|
||||||
|
files?: Record<string, string>,
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startAgentSkillsSandbox(): Promise<AgentSkillsSandbox> {
|
||||||
|
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<AgentSkillsClient>({
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await page.getByRole("button", { name: "Choose skills", exact: true }).click();
|
||||||
|
await expectSkillSelectionOpen(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSkillSelectionOpen(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chooseCustomSkills(page: Page, selected: string[]): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelSkillSelection(page: Page): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await page.getByRole("checkbox", { name, exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function answerNextRemovalWarning(
|
||||||
|
page: Page,
|
||||||
|
answer: "accept" | "dismiss",
|
||||||
|
): Promise<Dialog> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await expect.poll(async () => (await sandbox.getStatus()).selection).toEqual(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSkillFile(
|
||||||
|
sandbox: AgentSkillsSandbox,
|
||||||
|
target: SkillTargetName,
|
||||||
|
skill: string,
|
||||||
|
relativePath: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await expect
|
||||||
|
.poll(() => readFile(path.join(sandbox.targets[target], skill, relativePath), "utf8"))
|
||||||
|
.not.toBe("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSkillChoices(page: Page, selected: string[]): Promise<void> {
|
||||||
|
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<string[]> {
|
||||||
|
return page
|
||||||
|
.getByRole("checkbox")
|
||||||
|
.evaluateAll((elements) =>
|
||||||
|
elements
|
||||||
|
.map((element) => element.getAttribute("aria-label") ?? "")
|
||||||
|
.filter((name) => name.length > 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readInstalledSkills(directory: string): Promise<string[]> {
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
@@ -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<AgentSkillOperation["kind"], number> = {
|
||||||
|
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(
|
||||||
|
() => <ThemedSettings size={ICON_SIZE.sm} uniProps={mutedMapping} />,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handleOpen = useCallback(() => setIsChoosing(true), []);
|
||||||
|
const handleClose = useCallback(() => setIsChoosing(false), []);
|
||||||
|
const handleOpenDocs = useCallback(() => {
|
||||||
|
void openExternalUrl(SKILLS_DOCS_URL);
|
||||||
|
}, []);
|
||||||
|
const docsIcon = useMemo(
|
||||||
|
() => <ThemedArrowUpRight size={ICON_SIZE.sm} uniProps={mutedMapping} />,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const trailing = useMemo(
|
||||||
|
() => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={docsIcon}
|
||||||
|
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||||
|
style={settingsStyles.sectionHeaderLink}
|
||||||
|
onPress={handleOpenDocs}
|
||||||
|
accessibilityLabel={t("settings.host.skills.openDocs")}
|
||||||
|
>
|
||||||
|
{t("settings.host.skills.docs")}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[docsIcon, handleOpenDocs, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!skills.connected || !skills.supported) {
|
||||||
|
return (
|
||||||
|
<SettingsSection title={t("settings.host.skills.sectionTitle")} trailing={trailing}>
|
||||||
|
<View style={settingsStyles.card} testID="host-agent-skills-unavailable">
|
||||||
|
<View style={styles.emptyCard}>
|
||||||
|
<Text style={styles.mutedText}>
|
||||||
|
{skills.connected
|
||||||
|
? t("settings.host.skills.unsupported")
|
||||||
|
: t("settings.host.skills.unavailable")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection title={t("settings.host.skills.sectionTitle")} trailing={trailing}>
|
||||||
|
<View style={settingsStyles.card} testID="host-agent-skills-card">
|
||||||
|
<View style={settingsStyles.row}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<View style={styles.rowTitleRow}>
|
||||||
|
<ThemedBlocks size={ICON_SIZE.md} uniProps={foregroundMapping} />
|
||||||
|
<Text style={settingsStyles.rowTitle}>{t("settings.host.skills.title")}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={settingsStyles.rowHint}>
|
||||||
|
{state === "drift"
|
||||||
|
? t("settings.host.skills.updateAvailable")
|
||||||
|
: t("settings.host.skills.description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.actionsRow}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={chooseIcon}
|
||||||
|
onPress={handleOpen}
|
||||||
|
disabled={skills.status === null || skills.isWorking}
|
||||||
|
accessibilityLabel={t("settings.host.skills.choose")}
|
||||||
|
/>
|
||||||
|
{hasSelected ? (
|
||||||
|
<SkillsActions
|
||||||
|
state={state}
|
||||||
|
isWorking={skills.isWorking}
|
||||||
|
onInstall={skills.reconcile}
|
||||||
|
onReconcile={handleReconcile}
|
||||||
|
onUninstall={handleUninstall}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{skills.status ? (
|
||||||
|
<SkillSelectionSheet
|
||||||
|
visible={isChoosing}
|
||||||
|
available={skills.status.available}
|
||||||
|
selection={skills.status.selection}
|
||||||
|
isSaving={skills.isWorking}
|
||||||
|
onSave={skills.saveSelection}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillsActions({
|
||||||
|
state,
|
||||||
|
isWorking,
|
||||||
|
onReconcile,
|
||||||
|
onInstall,
|
||||||
|
onUninstall,
|
||||||
|
}: {
|
||||||
|
state: AgentSkillsStatus["state"] | null;
|
||||||
|
isWorking: boolean;
|
||||||
|
onReconcile: () => void;
|
||||||
|
onInstall: () => void;
|
||||||
|
onUninstall: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (state === "up-to-date") {
|
||||||
|
return (
|
||||||
|
<View style={styles.actionsRow}>
|
||||||
|
<View style={styles.installedLabel}>
|
||||||
|
<ThemedCheck size={14} uniProps={mutedMapping} />
|
||||||
|
<Text style={styles.mutedText}>{t("settings.host.skills.actions.installed")}</Text>
|
||||||
|
</View>
|
||||||
|
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
||||||
|
{t("settings.host.skills.actions.uninstall")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === "drift") {
|
||||||
|
return (
|
||||||
|
<View style={styles.actionsRow}>
|
||||||
|
<Button variant="outline" size="sm" onPress={onReconcile} disabled={isWorking}>
|
||||||
|
{isWorking
|
||||||
|
? t("settings.host.skills.actions.working")
|
||||||
|
: t("settings.host.skills.actions.update")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
||||||
|
{t("settings.host.skills.actions.uninstall")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
|
||||||
|
{isWorking
|
||||||
|
? t("settings.host.skills.actions.installing")
|
||||||
|
: t("settings.host.skills.actions.install")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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] },
|
||||||
|
}));
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrateLegacyAgentSkillsSelection(
|
||||||
|
client: LegacySelectionClient,
|
||||||
|
read = readLegacySkillSelection,
|
||||||
|
remove = deleteLegacySkillSelection,
|
||||||
|
): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
+18
-24
@@ -10,7 +10,10 @@ import { SettingsSection } from "@/screens/settings/settings-section";
|
|||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
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 ThemedCheck = withUnistyles(Check);
|
||||||
const checkedIconMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
|
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
|
* 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({
|
export function SkillSelectionSheet({
|
||||||
visible,
|
visible,
|
||||||
@@ -87,11 +90,11 @@ export function SkillSelectionSheet({
|
|||||||
const attempt = await onSave(draft);
|
const attempt = await onSave(draft);
|
||||||
if (attempt.confirmationRequired) {
|
if (attempt.confirmationRequired) {
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
title: t("settings.integrations.skills.removeTitle"),
|
title: t("settings.host.skills.removeTitle"),
|
||||||
message: t("settings.integrations.skills.removeMessage", {
|
message: t("settings.host.skills.removeMessage", {
|
||||||
skills: attempt.confirmationRequired.removals.join(", "),
|
skills: attempt.confirmationRequired.removals.join(", "),
|
||||||
}),
|
}),
|
||||||
confirmLabel: t("settings.integrations.actions.remove"),
|
confirmLabel: t("settings.host.skills.actions.remove"),
|
||||||
destructive: true,
|
destructive: true,
|
||||||
});
|
});
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
@@ -99,13 +102,13 @@ export function SkillSelectionSheet({
|
|||||||
// Something else changed the directories again while the dialog was
|
// Something else changed the directories again while the dialog was
|
||||||
// up. Ask about the new list rather than deleting it unannounced.
|
// up. Ask about the new list rather than deleting it unannounced.
|
||||||
if (retry.confirmationRequired) {
|
if (retry.confirmationRequired) {
|
||||||
setSaveError(t("settings.integrations.skills.saveFailed"));
|
setSaveError(t("settings.host.skills.saveFailed"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch {
|
||||||
setSaveError(t("settings.integrations.skills.saveFailed"));
|
setSaveError(t("settings.host.skills.saveFailed"));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [draft, onClose, onSave, t]);
|
}, [draft, onClose, onSave, t]);
|
||||||
@@ -115,10 +118,7 @@ export function SkillSelectionSheet({
|
|||||||
onClose();
|
onClose();
|
||||||
}, [isSaving, onClose]);
|
}, [isSaving, onClose]);
|
||||||
|
|
||||||
const header = useMemo<SheetHeader>(
|
const header = useMemo<SheetHeader>(() => ({ title: t("settings.host.skills.choose") }), [t]);
|
||||||
() => ({ title: t("settings.integrations.skills.choose") }),
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const footer = useMemo(
|
const footer = useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -139,8 +139,8 @@ export function SkillSelectionSheet({
|
|||||||
testID="skill-selection-save"
|
testID="skill-selection-save"
|
||||||
>
|
>
|
||||||
{isSaving
|
{isSaving
|
||||||
? t("settings.integrations.actions.saving")
|
? t("settings.host.skills.actions.saving")
|
||||||
: t("settings.integrations.actions.save")}
|
: t("settings.host.skills.actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
@@ -160,18 +160,14 @@ export function SkillSelectionSheet({
|
|||||||
<View style={settingsStyles.card} testID="skill-selection-all-card">
|
<View style={settingsStyles.card} testID="skill-selection-all-card">
|
||||||
<View style={settingsStyles.row}>
|
<View style={settingsStyles.row}>
|
||||||
<View style={settingsStyles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={settingsStyles.rowTitle}>
|
<Text style={settingsStyles.rowTitle}>{t("settings.host.skills.chooseAll")}</Text>
|
||||||
{t("settings.integrations.skills.chooseAll")}
|
<Text style={settingsStyles.rowHint}>{t("settings.host.skills.chooseAllHint")}</Text>
|
||||||
</Text>
|
|
||||||
<Text style={settingsStyles.rowHint}>
|
|
||||||
{t("settings.integrations.skills.chooseAllHint")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={allSkills}
|
value={allSkills}
|
||||||
onValueChange={handleToggleAll}
|
onValueChange={handleToggleAll}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
accessibilityLabel={t("settings.integrations.skills.chooseAll")}
|
accessibilityLabel={t("settings.host.skills.chooseAll")}
|
||||||
testID="skill-selection-all"
|
testID="skill-selection-all"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -179,13 +175,11 @@ export function SkillSelectionSheet({
|
|||||||
|
|
||||||
{/* The sheet body already spaces its blocks apart, so the section owns
|
{/* The sheet body already spaces its blocks apart, so the section owns
|
||||||
only the gap between its label and the list the label names. */}
|
only the gap between its label and the list the label names. */}
|
||||||
<SettingsSection title={t("settings.integrations.skills.chooseList")} flush>
|
<SettingsSection title={t("settings.host.skills.chooseList")} flush>
|
||||||
<View style={settingsStyles.card} testID="skill-selection-list-card">
|
<View style={settingsStyles.card} testID="skill-selection-list-card">
|
||||||
{available.length === 0 ? (
|
{available.length === 0 ? (
|
||||||
<View style={settingsStyles.row}>
|
<View style={settingsStyles.row}>
|
||||||
<Text style={settingsStyles.rowHint}>
|
<Text style={settingsStyles.rowHint}>{t("settings.host.skills.chooseEmpty")}</Text>
|
||||||
{t("settings.integrations.skills.chooseEmpty")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
available.map((name, index) => (
|
available.map((name, index) => (
|
||||||
@@ -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<string, { getAgentSkillsStatus: ReturnType<typeof vi.fn> }>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<QueryClientProvider
|
||||||
|
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Error | null>(null);
|
||||||
|
const report = useCallback(
|
||||||
|
(message: string, error: Error) => {
|
||||||
|
console.error(`[Agent skills: ${serverId}]`, error);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
[serverId, toast],
|
||||||
|
);
|
||||||
|
const query = useFetchQuery<AgentSkillsStatus, Error>({
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-rout
|
|||||||
import { ThemedStack } from "@/navigation/themed-stack";
|
import { ThemedStack } from "@/navigation/themed-stack";
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { AgentNavigationListener } from "@/desktop/agent-navigation";
|
import { AgentNavigationListener } from "@/desktop/agent-navigation";
|
||||||
|
import { LegacyAgentSkillsMigration } from "@/agent-skills/legacy-migration";
|
||||||
import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration";
|
import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration";
|
||||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
@@ -596,6 +597,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
<DownloadToast />
|
<DownloadToast />
|
||||||
<RosettaCalloutSource />
|
<RosettaCalloutSource />
|
||||||
<UpdateCalloutSource />
|
<UpdateCalloutSource />
|
||||||
|
<LegacyAgentSkillsMigration />
|
||||||
<WorktreeSetupCalloutSource />
|
<WorktreeSetupCalloutSource />
|
||||||
<CommandCenterRootActions />
|
<CommandCenterRootActions />
|
||||||
<CommandCenterWorkspaceActions />
|
<CommandCenterWorkspaceActions />
|
||||||
|
|||||||
@@ -1,179 +1,53 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { useFocusEffect } from "@react-navigation/native";
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ArrowUpRight, Terminal, Blocks, Check, Settings2 } from "lucide-react-native";
|
import { ArrowUpRight, Check, Terminal } from "lucide-react-native";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
|
||||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
|
||||||
import { Button } from "@/components/ui/button";
|
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 { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
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";
|
|
||||||
|
|
||||||
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
||||||
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
|
||||||
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
|
|
||||||
const OP_KIND_LABEL_KEY: Record<SkillOp["kind"], string> = {
|
|
||||||
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() {
|
export function IntegrationsSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const showSection = shouldUseDesktopDaemon();
|
const showSection = shouldUseDesktopDaemon();
|
||||||
const {
|
const { status, isInstalling, install, refresh } = useCliInstall();
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
if (!showSection) return undefined;
|
if (showSection) refresh();
|
||||||
refreshCliStatus();
|
|
||||||
void refreshSkillsStatus();
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [refreshCliStatus, refreshSkillsStatus, showSection]),
|
}, [refresh, showSection]),
|
||||||
);
|
);
|
||||||
|
const docsIcon = useMemo(
|
||||||
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(
|
|
||||||
() => <ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
() => <ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||||
[theme.iconSize.sm, theme.colors.foregroundMuted],
|
[theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||||
);
|
);
|
||||||
|
const handleOpenDocs = useCallback(() => {
|
||||||
const chooseSkillsIcon = useMemo(
|
void openExternalUrl(CLI_DOCS_URL);
|
||||||
() => <Settings2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
}, []);
|
||||||
[theme.iconSize.sm, theme.colors.foregroundMuted],
|
|
||||||
);
|
|
||||||
|
|
||||||
const trailing = useMemo(
|
const trailing = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<View style={styles.headerLinks}>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
leftIcon={arrowIcon}
|
leftIcon={docsIcon}
|
||||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||||
style={settingsStyles.sectionHeaderLink}
|
style={settingsStyles.sectionHeaderLink}
|
||||||
onPress={handleOpenCliDocs}
|
onPress={handleOpenDocs}
|
||||||
accessibilityLabel={t("settings.integrations.docs.openCli")}
|
accessibilityLabel={t("settings.integrations.docs.openCli")}
|
||||||
>
|
>
|
||||||
{t("settings.integrations.docs.cli")}
|
{t("settings.integrations.docs.cli")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={arrowIcon}
|
|
||||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
|
||||||
style={settingsStyles.sectionHeaderLink}
|
|
||||||
onPress={handleOpenSkillsDocs}
|
|
||||||
accessibilityLabel={t("settings.integrations.docs.openSkills")}
|
|
||||||
>
|
|
||||||
{t("settings.integrations.docs.skills")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
),
|
),
|
||||||
[arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs, t],
|
[docsIcon, handleOpenDocs, t],
|
||||||
);
|
);
|
||||||
|
if (!showSection) return null;
|
||||||
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)));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={t("settings.integrations.title")} trailing={trailing}>
|
<SettingsSection title={t("settings.integrations.title")} trailing={trailing}>
|
||||||
<View style={settingsStyles.card}>
|
<View style={settingsStyles.card}>
|
||||||
@@ -189,144 +63,26 @@ export function IntegrationsSection() {
|
|||||||
{t("settings.integrations.commandLine.description")}
|
{t("settings.integrations.commandLine.description")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{cliStatus?.installed ? (
|
{status?.installed ? (
|
||||||
<View style={styles.installedLabel}>
|
<View style={styles.installedLabel}>
|
||||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
|
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button variant="outline" size="sm" onPress={install} disabled={isInstalling}>
|
||||||
variant="outline"
|
{isInstalling
|
||||||
size="sm"
|
|
||||||
onPress={handleInstallCli}
|
|
||||||
disabled={isInstallingCli}
|
|
||||||
>
|
|
||||||
{isInstallingCli
|
|
||||||
? t("settings.integrations.actions.installing")
|
? t("settings.integrations.actions.installing")
|
||||||
: t("settings.integrations.actions.install")}
|
: t("settings.integrations.actions.install")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
|
||||||
<View style={settingsStyles.rowContent}>
|
|
||||||
<View style={styles.rowTitleRow}>
|
|
||||||
<Blocks size={theme.iconSize.md} color={theme.colors.foreground} />
|
|
||||||
<Text style={settingsStyles.rowTitle}>{t("settings.integrations.skills.title")}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={settingsStyles.rowHint}>
|
|
||||||
{skillsState === "drift"
|
|
||||||
? t("settings.integrations.skills.updateAvailable")
|
|
||||||
: t("settings.integrations.skills.description")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.actionsRow}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={chooseSkillsIcon}
|
|
||||||
onPress={handleOpenSkillSelection}
|
|
||||||
disabled={skillsStatus === null || isSkillsWorking}
|
|
||||||
accessibilityLabel={t("settings.integrations.skills.choose")}
|
|
||||||
/>
|
|
||||||
{hasSelectedSkills ? (
|
|
||||||
<SkillsActions
|
|
||||||
state={skillsState}
|
|
||||||
isWorking={isSkillsWorking}
|
|
||||||
onInstall={handleInstallSkills}
|
|
||||||
onUpdate={handleUpdateSkills}
|
|
||||||
onUninstall={handleUninstallSkills}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{skillsStatus ? (
|
|
||||||
<SkillSelectionSheet
|
|
||||||
visible={isChoosingSkills}
|
|
||||||
available={skillsStatus.available}
|
|
||||||
selection={skillsStatus.selection}
|
|
||||||
isSaving={isSkillsWorking}
|
|
||||||
onSave={saveSkillSelection}
|
|
||||||
onClose={handleCloseSkillSelection}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SkillsActionsProps {
|
|
||||||
state: SkillsSnapshot["state"] | null;
|
|
||||||
isWorking: boolean;
|
|
||||||
onInstall: () => void;
|
|
||||||
onUpdate: () => void;
|
|
||||||
onUninstall: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
|
|
||||||
if (state === "up-to-date") {
|
|
||||||
return (
|
|
||||||
<View style={styles.actionsRow}>
|
|
||||||
<View style={styles.installedLabel}>
|
|
||||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
|
|
||||||
</View>
|
|
||||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
|
||||||
{t("settings.integrations.actions.uninstall")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === "drift") {
|
|
||||||
return (
|
|
||||||
<View style={styles.actionsRow}>
|
|
||||||
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}>
|
|
||||||
{isWorking
|
|
||||||
? t("settings.integrations.actions.working")
|
|
||||||
: t("settings.integrations.actions.update")}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
|
||||||
{t("settings.integrations.actions.uninstall")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
|
|
||||||
{isWorking
|
|
||||||
? t("settings.integrations.actions.installing")
|
|
||||||
: t("settings.integrations.actions.install")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
headerLinks: {
|
rowTitleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||||
flexDirection: "row",
|
installedLabel: { flexDirection: "row", alignItems: "center", gap: 4 },
|
||||||
alignItems: "center",
|
mutedText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base },
|
||||||
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],
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||||
import {
|
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
|
||||||
parseSkillsSaveResult,
|
|
||||||
parseSkillsSnapshot,
|
|
||||||
type SkillSelection,
|
|
||||||
type SkillsSaveResult,
|
|
||||||
type SkillsSnapshot,
|
|
||||||
} from "@/desktop/daemon/skills-snapshot";
|
|
||||||
|
|
||||||
export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
||||||
export type DesktopDaemonStopReason =
|
export type DesktopDaemonStopReason =
|
||||||
@@ -232,38 +226,12 @@ export async function installCli(): Promise<InstallStatus> {
|
|||||||
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
|
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export type {
|
// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16.
|
||||||
SkillOp,
|
export function readLegacySkillSelection(): Promise<AgentSkillSelection | null> {
|
||||||
SkillSelection,
|
return invokeDesktopCommand("read_legacy_skill_selection") as Promise<AgentSkillSelection | null>;
|
||||||
SkillsSaveResult,
|
|
||||||
SkillsSnapshot,
|
|
||||||
SkillsState,
|
|
||||||
} from "@/desktop/daemon/skills-snapshot";
|
|
||||||
|
|
||||||
export async function getSkillsSnapshot(): Promise<SkillsSnapshot> {
|
|
||||||
return parseSkillsSnapshot(await invokeDesktopCommand("get_skills_status"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installSkills(): Promise<SkillsSnapshot> {
|
// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16.
|
||||||
return parseSkillsSnapshot(await invokeDesktopCommand("install_skills"));
|
export async function deleteLegacySkillSelection(): Promise<void> {
|
||||||
}
|
await invokeDesktopCommand("delete_legacy_skill_selection");
|
||||||
|
|
||||||
export async function updateSkills(): Promise<SkillsSnapshot> {
|
|
||||||
return parseSkillsSnapshot(await invokeDesktopCommand("update_skills"));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uninstallSkills(): Promise<SkillsSnapshot> {
|
|
||||||
return parseSkillsSnapshot(await invokeDesktopCommand("uninstall_skills"));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveSkillsSelection(
|
|
||||||
selection: SkillSelection,
|
|
||||||
confirmedRemovals: readonly string[] = [],
|
|
||||||
): Promise<SkillsSaveResult> {
|
|
||||||
return parseSkillsSaveResult(
|
|
||||||
await invokeDesktopCommand("save_skills_selection", {
|
|
||||||
...selection,
|
|
||||||
confirmedRemovals: [...confirmedRemovals],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<string, unknown> {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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<TResult>(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<void> = 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,17 +3,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
getCliInstallStatus,
|
getCliInstallStatus,
|
||||||
getSkillsSnapshot,
|
|
||||||
installCli,
|
installCli,
|
||||||
installSkills,
|
|
||||||
saveSkillsSelection,
|
|
||||||
shouldUseDesktopDaemon,
|
shouldUseDesktopDaemon,
|
||||||
type InstallStatus,
|
type InstallStatus,
|
||||||
type SkillSelection,
|
|
||||||
type SkillsSaveResult,
|
|
||||||
type SkillsSnapshot,
|
|
||||||
uninstallSkills,
|
|
||||||
updateSkills,
|
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
} from "@/desktop/daemon/desktop-daemon";
|
||||||
import {
|
import {
|
||||||
useDesktopIpcErrorReporter,
|
useDesktopIpcErrorReporter,
|
||||||
@@ -21,7 +13,6 @@ import {
|
|||||||
} from "@/desktop/hooks/desktop-ipc-error";
|
} from "@/desktop/hooks/desktop-ipc-error";
|
||||||
|
|
||||||
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
|
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 {
|
interface DesktopInstallHookResult {
|
||||||
status: InstallStatus | null;
|
status: InstallStatus | null;
|
||||||
@@ -80,144 +71,3 @@ export function useCliInstall(): DesktopInstallHookResult {
|
|||||||
refresh,
|
refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillsStatusHookResult {
|
|
||||||
status: SkillsSnapshot | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
isWorking: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
install: () => Promise<void>;
|
|
||||||
update: () => Promise<void>;
|
|
||||||
uninstall: () => Promise<void>;
|
|
||||||
saveSelection: (
|
|
||||||
selection: SkillSelection,
|
|
||||||
confirmedRemovals?: readonly string[],
|
|
||||||
) => Promise<SkillsSaveResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSkillsStatus(): SkillsStatusHookResult {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const reportError = useDesktopIpcErrorReporter();
|
|
||||||
const enabled = shouldUseDesktopDaemon();
|
|
||||||
|
|
||||||
const statusQuery = useQuery<SkillsSnapshot, Error>({
|
|
||||||
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<SkillsSnapshot>(SKILLS_STATUS_QUERY_KEY, next);
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
const installMutation = useMutation<SkillsSnapshot, Error>({
|
|
||||||
mutationFn: installSkills,
|
|
||||||
onError: (error) => {
|
|
||||||
reportError({
|
|
||||||
error,
|
|
||||||
message: t("desktop.integrations.skills.installFailed"),
|
|
||||||
logLabel: "[Integrations] Failed to install skills",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: setStatus,
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMutation = useMutation<SkillsSnapshot, Error>({
|
|
||||||
mutationFn: updateSkills,
|
|
||||||
onError: (error) => {
|
|
||||||
reportError({
|
|
||||||
error,
|
|
||||||
message: t("desktop.integrations.skills.updateFailed"),
|
|
||||||
logLabel: "[Integrations] Failed to update skills",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: setStatus,
|
|
||||||
});
|
|
||||||
|
|
||||||
const uninstallMutation = useMutation<SkillsSnapshot, Error>({
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -258,7 +258,6 @@ describe("translation resources", () => {
|
|||||||
expect(en.settings.appearance.fonts.interfaceFont).toBe("Interface font");
|
expect(en.settings.appearance.fonts.interfaceFont).toBe("Interface font");
|
||||||
expect(en.settings.shortcuts.actions.rebind).toBe("Rebind");
|
expect(en.settings.shortcuts.actions.rebind).toBe("Rebind");
|
||||||
expect(en.settings.integrations.commandLine.title).toBe("Command line");
|
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.playSound).toBe("Play sound");
|
||||||
expect(en.settings.notifications.permission).toBe("Notification permission");
|
expect(en.settings.notifications.permission).toBe("Notification permission");
|
||||||
expect(en.settings.notifications.sentTitle).toBe("Test notification sent");
|
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.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.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", () => {
|
it("includes remaining utility chrome keys for the Batch 4T migration", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TranslationResources } from "./en";
|
import { en, type TranslationResources } from "./en";
|
||||||
import { pluginSettings } from "./plugin-settings";
|
import { pluginSettings } from "./plugin-settings";
|
||||||
|
|
||||||
export const ar: TranslationResources = {
|
export const ar: TranslationResources = {
|
||||||
@@ -1298,13 +1298,6 @@ export const ar: TranslationResources = {
|
|||||||
statusFailed: "غير قادر على التحقق من حالة تثبيت CLI.",
|
statusFailed: "غير قادر على التحقق من حالة تثبيت CLI.",
|
||||||
installFailed: "غير قادر على تثبيت PaseoCLI.",
|
installFailed: "غير قادر على تثبيت PaseoCLI.",
|
||||||
},
|
},
|
||||||
skills: {
|
|
||||||
statusFailed: "غير قادر على التحقق من حالة مهارات التنسيق.",
|
|
||||||
installFailed: "غير قادر على تثبيت مهارات التنسيق.",
|
|
||||||
updateFailed: "غير قادر على تحديث مهارات التنسيق.",
|
|
||||||
uninstallFailed: "غير قادر على إلغاء تثبيت مهارات التنسيق.",
|
|
||||||
saveSelectionFailed: "غير قادر على حفظ اختيار مهارات التنسيق.",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rootError: {
|
rootError: {
|
||||||
@@ -2081,47 +2074,16 @@ export const ar: TranslationResources = {
|
|||||||
title: "التكامل",
|
title: "التكامل",
|
||||||
docs: {
|
docs: {
|
||||||
cli: "مستندات CLI",
|
cli: "مستندات CLI",
|
||||||
skills: "وثائق المهارات",
|
|
||||||
openCli: "افتح وثائق CLI",
|
openCli: "افتح وثائق CLI",
|
||||||
openSkills: "فتح وثائق المهارات",
|
|
||||||
},
|
},
|
||||||
commandLine: {
|
commandLine: {
|
||||||
title: "سطر الأوامر",
|
title: "سطر الأوامر",
|
||||||
description: "وكلاء التحكم والبرنامج النصي من المحطة الطرفية الخاصة بك",
|
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: {
|
actions: {
|
||||||
install: "ثَبَّتَ",
|
install: "ثَبَّتَ",
|
||||||
installing: "جارٍ التثبيت...",
|
installing: "جارٍ التثبيت...",
|
||||||
installed: "تم التثبيت",
|
installed: "تم التثبيت",
|
||||||
update: "تحديث",
|
|
||||||
working: "عمل...",
|
|
||||||
remove: "إزالة",
|
|
||||||
uninstall: "إلغاء التثبيت",
|
|
||||||
save: "حفظ",
|
|
||||||
saving: "جارٍ الحفظ...",
|
|
||||||
},
|
|
||||||
operations: {
|
|
||||||
add: "أضف مهارة",
|
|
||||||
update: "تحديث المهارة",
|
|
||||||
delete: "حذف المهارة",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
permissions: {
|
permissions: {
|
||||||
@@ -2191,6 +2153,47 @@ export const ar: TranslationResources = {
|
|||||||
rowTitle: "إقران جهاز",
|
rowTitle: "إقران جهاز",
|
||||||
rowHint: "امسح رمز QR ضوئيًا أو انسخ رابطًا لتوصيل هاتفك بهذا المضيف",
|
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: {
|
orchestration: {
|
||||||
title: "التنسيق",
|
title: "التنسيق",
|
||||||
unavailable: "اتصل بهذا المضيف لإدارة التنسيق",
|
unavailable: "اتصل بهذا المضيف لإدارة التنسيق",
|
||||||
|
|||||||
@@ -1307,13 +1307,6 @@ export const en = {
|
|||||||
statusFailed: "Unable to check CLI install status.",
|
statusFailed: "Unable to check CLI install status.",
|
||||||
installFailed: "Unable to install the Paseo CLI.",
|
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: {
|
rootError: {
|
||||||
@@ -2145,48 +2138,16 @@ export const en = {
|
|||||||
title: "Integrations",
|
title: "Integrations",
|
||||||
docs: {
|
docs: {
|
||||||
cli: "CLI docs",
|
cli: "CLI docs",
|
||||||
skills: "Skills docs",
|
|
||||||
openCli: "Open CLI documentation",
|
openCli: "Open CLI documentation",
|
||||||
openSkills: "Open skills documentation",
|
|
||||||
},
|
},
|
||||||
commandLine: {
|
commandLine: {
|
||||||
title: "Command line",
|
title: "Command line",
|
||||||
description: "Control and script agents from your terminal",
|
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: {
|
actions: {
|
||||||
install: "Install",
|
install: "Install",
|
||||||
installing: "Installing...",
|
installing: "Installing...",
|
||||||
installed: "Installed",
|
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: {
|
permissions: {
|
||||||
@@ -2256,6 +2217,51 @@ export const en = {
|
|||||||
rowTitle: "Pair a device",
|
rowTitle: "Pair a device",
|
||||||
rowHint: "Scan a QR code or copy a link to connect your phone to this host",
|
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: {
|
orchestration: {
|
||||||
title: "Orchestration",
|
title: "Orchestration",
|
||||||
unavailable: "Connect to this host to manage orchestration",
|
unavailable: "Connect to this host to manage orchestration",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TranslationResources } from "./en";
|
import { en, type TranslationResources } from "./en";
|
||||||
import { pluginSettings } from "./plugin-settings";
|
import { pluginSettings } from "./plugin-settings";
|
||||||
|
|
||||||
export const es: TranslationResources = {
|
export const es: TranslationResources = {
|
||||||
@@ -1338,13 +1338,6 @@ export const es: TranslationResources = {
|
|||||||
statusFailed: "No se puede verificar el estado de instalación deCLI.",
|
statusFailed: "No se puede verificar el estado de instalación deCLI.",
|
||||||
installFailed: "No se puede instalar elPaseoCLI.",
|
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: {
|
rootError: {
|
||||||
@@ -2129,49 +2122,16 @@ export const es: TranslationResources = {
|
|||||||
title: "Integraciones",
|
title: "Integraciones",
|
||||||
docs: {
|
docs: {
|
||||||
cli: "DocumentosCLI",
|
cli: "DocumentosCLI",
|
||||||
skills: "Documentos de habilidades",
|
|
||||||
openCli: "Abrir la documentación deCLI",
|
openCli: "Abrir la documentación deCLI",
|
||||||
openSkills: "Documentación de habilidades abiertas",
|
|
||||||
},
|
},
|
||||||
commandLine: {
|
commandLine: {
|
||||||
title: "línea de comando",
|
title: "línea de comando",
|
||||||
description: "Agentes de control y script desde tu terminal",
|
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: {
|
actions: {
|
||||||
install: "Instalar",
|
install: "Instalar",
|
||||||
installing: "Instalando...",
|
installing: "Instalando...",
|
||||||
installed: "Instalado",
|
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: {
|
permissions: {
|
||||||
@@ -2241,6 +2201,49 @@ export const es: TranslationResources = {
|
|||||||
rowTitle: "Emparejar un dispositivo",
|
rowTitle: "Emparejar un dispositivo",
|
||||||
rowHint: "Escanee un códigoQRo copie un enlace para conectar su teléfono a este host",
|
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: {
|
orchestration: {
|
||||||
title: "Orquestación",
|
title: "Orquestación",
|
||||||
unavailable: "Conéctese a este host para administrar la orquestación",
|
unavailable: "Conéctese a este host para administrar la orquestación",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TranslationResources } from "./en";
|
import { en, type TranslationResources } from "./en";
|
||||||
import { pluginSettings } from "./plugin-settings";
|
import { pluginSettings } from "./plugin-settings";
|
||||||
|
|
||||||
export const fr: TranslationResources = {
|
export const fr: TranslationResources = {
|
||||||
@@ -1340,14 +1340,6 @@ export const fr: TranslationResources = {
|
|||||||
statusFailed: "Impossible de vérifier l'état de l'installation deCLI.",
|
statusFailed: "Impossible de vérifier l'état de l'installation deCLI.",
|
||||||
installFailed: "Impossible d'installer lePaseoCLI.",
|
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: {
|
rootError: {
|
||||||
@@ -2134,49 +2126,16 @@ export const fr: TranslationResources = {
|
|||||||
title: "Intégrations",
|
title: "Intégrations",
|
||||||
docs: {
|
docs: {
|
||||||
cli: "DocumentsCLI",
|
cli: "DocumentsCLI",
|
||||||
skills: "Documents de compétences",
|
|
||||||
openCli: "Ouvrir la documentationCLI",
|
openCli: "Ouvrir la documentationCLI",
|
||||||
openSkills: "Documentation des compétences ouvertes",
|
|
||||||
},
|
},
|
||||||
commandLine: {
|
commandLine: {
|
||||||
title: "Ligne de commande",
|
title: "Ligne de commande",
|
||||||
description: "Agents de contrôle et de script depuis votre terminal",
|
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: {
|
actions: {
|
||||||
install: "Installer",
|
install: "Installer",
|
||||||
installing: "Installation...",
|
installing: "Installation...",
|
||||||
installed: "Installé",
|
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: {
|
permissions: {
|
||||||
@@ -2246,6 +2205,50 @@ export const fr: TranslationResources = {
|
|||||||
rowTitle: "Associer un appareil",
|
rowTitle: "Associer un appareil",
|
||||||
rowHint: "Scannez un codeQRou copiez un lien pour connecter votre téléphone à cet hôte",
|
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: {
|
orchestration: {
|
||||||
title: "Orchestration",
|
title: "Orchestration",
|
||||||
unavailable: "Connectez-vous à cet hôte pour gérer l'orchestration",
|
unavailable: "Connectez-vous à cet hôte pour gérer l'orchestration",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TranslationResources } from "./en";
|
import { en, type TranslationResources } from "./en";
|
||||||
import { pluginSettings } from "./plugin-settings";
|
import { pluginSettings } from "./plugin-settings";
|
||||||
|
|
||||||
export const ja: TranslationResources = {
|
export const ja: TranslationResources = {
|
||||||
@@ -1311,13 +1311,6 @@ export const ja: TranslationResources = {
|
|||||||
statusFailed: "CLIのインストール状態を確認できません。",
|
statusFailed: "CLIのインストール状態を確認できません。",
|
||||||
installFailed: "Paseo CLIをインストールできません。",
|
installFailed: "Paseo CLIをインストールできません。",
|
||||||
},
|
},
|
||||||
skills: {
|
|
||||||
statusFailed: "オーケストレーションスキルのステータスを確認できません。",
|
|
||||||
installFailed: "オーケストレーションスキルをインストールできません。",
|
|
||||||
updateFailed: "オーケストレーションスキルを更新できません。",
|
|
||||||
uninstallFailed: "オーケストレーションスキルをアンインストールできません。",
|
|
||||||
saveSelectionFailed: "オーケストレーションスキルの選択を保存できません。",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rootError: {
|
rootError: {
|
||||||
@@ -2099,49 +2092,16 @@ export const ja: TranslationResources = {
|
|||||||
title: "連携",
|
title: "連携",
|
||||||
docs: {
|
docs: {
|
||||||
cli: "CLIドキュメント",
|
cli: "CLIドキュメント",
|
||||||
skills: "スキルドキュメント",
|
|
||||||
openCli: "CLIドキュメントを開く",
|
openCli: "CLIドキュメントを開く",
|
||||||
openSkills: "スキルドキュメントを開く",
|
|
||||||
},
|
},
|
||||||
commandLine: {
|
commandLine: {
|
||||||
title: "コマンドライン",
|
title: "コマンドライン",
|
||||||
description: "ターミナルからエージェントを制御し、スクリプトで操作",
|
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: {
|
actions: {
|
||||||
install: "インストール",
|
install: "インストール",
|
||||||
installing: "インストール中...",
|
installing: "インストール中...",
|
||||||
installed: "インストール済み",
|
installed: "インストール済み",
|
||||||
update: "更新",
|
|
||||||
working: "処理中...",
|
|
||||||
remove: "削除",
|
|
||||||
uninstall: "アンインストール",
|
|
||||||
save: "保存",
|
|
||||||
saving: "保存中...",
|
|
||||||
},
|
|
||||||
operations: {
|
|
||||||
add: "スキルを追加",
|
|
||||||
update: "スキルを更新",
|
|
||||||
delete: "スキルを削除",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
permissions: {
|
permissions: {
|
||||||
@@ -2211,6 +2171,49 @@ export const ja: TranslationResources = {
|
|||||||
rowTitle: "デバイスをペアリング",
|
rowTitle: "デバイスをペアリング",
|
||||||
rowHint: "QRコードをスキャンするかリンクをコピーしてスマートフォンをこのホストに接続",
|
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: {
|
orchestration: {
|
||||||
title: "オーケストレーション",
|
title: "オーケストレーション",
|
||||||
unavailable: "オーケストレーションを管理するにはこのホストに接続してください",
|
unavailable: "オーケストレーションを管理するにはこのホストに接続してください",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TranslationResources } from "./en";
|
import { en, type TranslationResources } from "./en";
|
||||||
import { pluginSettings } from "./plugin-settings";
|
import { pluginSettings } from "./plugin-settings";
|
||||||
|
|
||||||
export const ko: TranslationResources = {
|
export const ko: TranslationResources = {
|
||||||
@@ -1307,13 +1307,6 @@ export const ko: TranslationResources = {
|
|||||||
statusFailed: "CLI 설치 상태를 확인할 수 없습니다.",
|
statusFailed: "CLI 설치 상태를 확인할 수 없습니다.",
|
||||||
installFailed: "Paseo CLI를 설치할 수 없습니다.",
|
installFailed: "Paseo CLI를 설치할 수 없습니다.",
|
||||||
},
|
},
|
||||||
skills: {
|
|
||||||
statusFailed: "오케스트레이션 스킬 상태를 확인할 수 없습니다.",
|
|
||||||
installFailed: "오케스트레이션 스킬을 설치할 수 없습니다.",
|
|
||||||
updateFailed: "오케스트레이션 스킬을 업데이트할 수 없습니다.",
|
|
||||||
uninstallFailed: "오케스트레이션 스킬을 제거할 수 없습니다.",
|
|
||||||
saveSelectionFailed: "오케스트레이션 스킬 선택을 저장하지 못했습니다.",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rootError: {
|
rootError: {
|
||||||
@@ -2092,48 +2085,16 @@ export const ko: TranslationResources = {
|
|||||||
title: "통합",
|
title: "통합",
|
||||||
docs: {
|
docs: {
|
||||||
cli: "CLI 문서",
|
cli: "CLI 문서",
|
||||||
skills: "스킬 문서",
|
|
||||||
openCli: "CLI 문서 열기",
|
openCli: "CLI 문서 열기",
|
||||||
openSkills: "스킬 문서 열기",
|
|
||||||
},
|
},
|
||||||
commandLine: {
|
commandLine: {
|
||||||
title: "명령줄",
|
title: "명령줄",
|
||||||
description: "터미널에서 에이전트를 제어하고 스크립팅합니다",
|
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: {
|
actions: {
|
||||||
install: "설치",
|
install: "설치",
|
||||||
installing: "설치 중...",
|
installing: "설치 중...",
|
||||||
installed: "설치됨",
|
installed: "설치됨",
|
||||||
update: "업데이트",
|
|
||||||
working: "작업 중...",
|
|
||||||
remove: "제거",
|
|
||||||
uninstall: "제거",
|
|
||||||
save: "저장",
|
|
||||||
saving: "저장 중...",
|
|
||||||
},
|
|
||||||
operations: {
|
|
||||||
add: "스킬 추가",
|
|
||||||
update: "스킬 업데이트",
|
|
||||||
delete: "스킬 삭제",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
permissions: {
|
permissions: {
|
||||||
@@ -2203,6 +2164,48 @@ export const ko: TranslationResources = {
|
|||||||
rowTitle: "기기 페어링",
|
rowTitle: "기기 페어링",
|
||||||
rowHint: "QR 코드를 스캔하거나 링크를 복사하여 휴대폰을 이 호스트에 연결하세요",
|
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: {
|
orchestration: {
|
||||||
title: "오케스트레이션",
|
title: "오케스트레이션",
|
||||||
unavailable: "오케스트레이션을 관리하려면 이 호스트에 연결하세요",
|
unavailable: "오케스트레이션을 관리하려면 이 호스트에 연결하세요",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TranslationResources } from "./en";
|
import { en, type TranslationResources } from "./en";
|
||||||
import { pluginSettings } from "./plugin-settings";
|
import { pluginSettings } from "./plugin-settings";
|
||||||
|
|
||||||
export const ptBR: TranslationResources = {
|
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.",
|
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.",
|
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: {
|
rootError: {
|
||||||
@@ -2113,49 +2106,16 @@ export const ptBR: TranslationResources = {
|
|||||||
title: "Integrações",
|
title: "Integrações",
|
||||||
docs: {
|
docs: {
|
||||||
cli: "Docs da CLI",
|
cli: "Docs da CLI",
|
||||||
skills: "Docs das skills",
|
|
||||||
openCli: "Abrir documentação da CLI",
|
openCli: "Abrir documentação da CLI",
|
||||||
openSkills: "Abrir documentação das skills",
|
|
||||||
},
|
},
|
||||||
commandLine: {
|
commandLine: {
|
||||||
title: "Linha de comando",
|
title: "Linha de comando",
|
||||||
description: "Controle agentes e execute scripts pelo terminal",
|
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: {
|
actions: {
|
||||||
install: "Instalar",
|
install: "Instalar",
|
||||||
installing: "Instalando...",
|
installing: "Instalando...",
|
||||||
installed: "Instalado",
|
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: {
|
permissions: {
|
||||||
@@ -2225,6 +2185,49 @@ export const ptBR: TranslationResources = {
|
|||||||
rowTitle: "Parear um dispositivo",
|
rowTitle: "Parear um dispositivo",
|
||||||
rowHint: "Escaneie um QR code ou copie um link para conectar seu celular a este host",
|
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: {
|
orchestration: {
|
||||||
title: "Orquestração",
|
title: "Orquestração",
|
||||||
unavailable: "Conecte-se a este host para gerenciar a orquestração",
|
unavailable: "Conecte-se a este host para gerenciar a orquestração",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TranslationResources } from "./en";
|
import { en, type TranslationResources } from "./en";
|
||||||
import { pluginSettings } from "./plugin-settings";
|
import { pluginSettings } from "./plugin-settings";
|
||||||
|
|
||||||
export const ru: TranslationResources = {
|
export const ru: TranslationResources = {
|
||||||
@@ -1327,13 +1327,6 @@ export const ru: TranslationResources = {
|
|||||||
statusFailed: "Невозможно проверить статус установки CLI.",
|
statusFailed: "Невозможно проверить статус установки CLI.",
|
||||||
installFailed: "Невозможно установить PaseoCLI.",
|
installFailed: "Невозможно установить PaseoCLI.",
|
||||||
},
|
},
|
||||||
skills: {
|
|
||||||
statusFailed: "Невозможно проверить статус навыков оркестровки.",
|
|
||||||
installFailed: "Невозможно установить навыки оркестровки.",
|
|
||||||
updateFailed: "Невозможно обновить навыки оркестровки.",
|
|
||||||
uninstallFailed: "Невозможно удалить навыки оркестровки.",
|
|
||||||
saveSelectionFailed: "Не удалось сохранить выбор навыков оркестровки.",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rootError: {
|
rootError: {
|
||||||
@@ -2120,47 +2113,16 @@ export const ru: TranslationResources = {
|
|||||||
title: "Интеграции",
|
title: "Интеграции",
|
||||||
docs: {
|
docs: {
|
||||||
cli: "Документация CLI",
|
cli: "Документация CLI",
|
||||||
skills: "Документы по навыкам",
|
|
||||||
openCli: "Открыть документацию CLI",
|
openCli: "Открыть документацию CLI",
|
||||||
openSkills: "Открытая документация по навыкам",
|
|
||||||
},
|
},
|
||||||
commandLine: {
|
commandLine: {
|
||||||
title: "Командная строка",
|
title: "Командная строка",
|
||||||
description: "Агенты управления и сценариев с вашего терминала",
|
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: {
|
actions: {
|
||||||
install: "Установить",
|
install: "Установить",
|
||||||
installing: "Установка...",
|
installing: "Установка...",
|
||||||
installed: "Установлено",
|
installed: "Установлено",
|
||||||
update: "Обновлять",
|
|
||||||
working: "Работающий...",
|
|
||||||
remove: "Удалить",
|
|
||||||
uninstall: "Удалить",
|
|
||||||
save: "Сохранить",
|
|
||||||
saving: "Сохранение...",
|
|
||||||
},
|
|
||||||
operations: {
|
|
||||||
add: "Добавить навык",
|
|
||||||
update: "Обновить навык",
|
|
||||||
delete: "Удалить навык",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
permissions: {
|
permissions: {
|
||||||
@@ -2231,6 +2193,47 @@ export const ru: TranslationResources = {
|
|||||||
rowHint:
|
rowHint:
|
||||||
"Отсканируйте код QR или скопируйте ссылку, чтобы подключить свой телефон к этому хосту.",
|
"Отсканируйте код 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: {
|
orchestration: {
|
||||||
title: "оркестровка",
|
title: "оркестровка",
|
||||||
unavailable: "Подключитесь к этому хосту, чтобы управлять оркестрацией.",
|
unavailable: "Подключитесь к этому хосту, чтобы управлять оркестрацией.",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TranslationResources } from "./en";
|
import { en, type TranslationResources } from "./en";
|
||||||
import { pluginSettings } from "./plugin-settings";
|
import { pluginSettings } from "./plugin-settings";
|
||||||
|
|
||||||
export const zhCN: TranslationResources = {
|
export const zhCN: TranslationResources = {
|
||||||
@@ -1282,13 +1282,6 @@ export const zhCN: TranslationResources = {
|
|||||||
statusFailed: "无法检查 CLI 安装状态。",
|
statusFailed: "无法检查 CLI 安装状态。",
|
||||||
installFailed: "无法安装 Paseo CLI。",
|
installFailed: "无法安装 Paseo CLI。",
|
||||||
},
|
},
|
||||||
skills: {
|
|
||||||
statusFailed: "无法检查编排 skills 状态。",
|
|
||||||
installFailed: "无法安装编排 skills。",
|
|
||||||
updateFailed: "无法更新编排 skills。",
|
|
||||||
uninstallFailed: "无法卸载编排 skills。",
|
|
||||||
saveSelectionFailed: "无法保存编排 skills 的选择。",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rootError: {
|
rootError: {
|
||||||
@@ -2057,47 +2050,16 @@ export const zhCN: TranslationResources = {
|
|||||||
title: "集成",
|
title: "集成",
|
||||||
docs: {
|
docs: {
|
||||||
cli: "CLI 文档",
|
cli: "CLI 文档",
|
||||||
skills: "Skills 文档",
|
|
||||||
openCli: "打开 CLI 文档",
|
openCli: "打开 CLI 文档",
|
||||||
openSkills: "打开 skills 文档",
|
|
||||||
},
|
},
|
||||||
commandLine: {
|
commandLine: {
|
||||||
title: "命令行",
|
title: "命令行",
|
||||||
description: "从终端控制 Agent 并运行脚本",
|
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: {
|
actions: {
|
||||||
install: "安装",
|
install: "安装",
|
||||||
installing: "正在安装...",
|
installing: "正在安装...",
|
||||||
installed: "已安装",
|
installed: "已安装",
|
||||||
update: "更新",
|
|
||||||
working: "处理中...",
|
|
||||||
remove: "移除",
|
|
||||||
uninstall: "卸载",
|
|
||||||
save: "保存",
|
|
||||||
saving: "正在保存...",
|
|
||||||
},
|
|
||||||
operations: {
|
|
||||||
add: "添加 skill",
|
|
||||||
update: "更新 skill",
|
|
||||||
delete: "删除 skill",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
permissions: {
|
permissions: {
|
||||||
@@ -2167,6 +2129,47 @@ export const zhCN: TranslationResources = {
|
|||||||
rowTitle: "配对设备",
|
rowTitle: "配对设备",
|
||||||
rowHint: "扫描二维码或复制链接,将手机连接到这个 Host",
|
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: {
|
orchestration: {
|
||||||
title: "编排",
|
title: "编排",
|
||||||
unavailable: "连接到这个 Host 以管理编排",
|
unavailable: "连接到这个 Host 以管理编排",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
DEFAULT_TERMINAL_PROFILES,
|
DEFAULT_TERMINAL_PROFILES,
|
||||||
} from "@getpaseo/protocol/terminal-profiles";
|
} from "@getpaseo/protocol/terminal-profiles";
|
||||||
import { AgentProfilesSection } from "@/agent-profiles";
|
import { AgentProfilesSection } from "@/agent-profiles";
|
||||||
|
import { AgentSkillsSection } from "@/agent-skills";
|
||||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||||
import { Alert as InlineAlert } from "@/components/ui/alert";
|
import { Alert as InlineAlert } from "@/components/ui/alert";
|
||||||
@@ -275,6 +276,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
|
|||||||
<Text style={styles.emptyText}>{t("settings.host.agents.unavailable")}</Text>
|
<Text style={styles.emptyText}>{t("settings.host.agents.unavailable")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
<AgentSkillsSection serverId={serverId} />
|
||||||
<AgentProfilesSection serverId={serverId} />
|
<AgentProfilesSection serverId={serverId} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ import type {
|
|||||||
WorkspaceRecoveryState,
|
WorkspaceRecoveryState,
|
||||||
PluginListItem,
|
PluginListItem,
|
||||||
PluginLogEntry,
|
PluginLogEntry,
|
||||||
|
AgentSkillSelection,
|
||||||
|
AgentSkillsStatus,
|
||||||
|
AgentSkillsSaveResult,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
import type {
|
import type {
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
@@ -4766,6 +4769,61 @@ export class DaemonClient {
|
|||||||
return payload.entries;
|
return payload.entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAgentSkillsStatus(): Promise<AgentSkillsStatus> {
|
||||||
|
const requestId = this.createRequestId();
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
message: { type: "agent.skills.get_status.request", requestId },
|
||||||
|
responseType: "agent.skills.get_status.response",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconcileAgentSkills(): Promise<AgentSkillsStatus> {
|
||||||
|
const requestId = this.createRequestId();
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
message: { type: "agent.skills.reconcile.request", requestId },
|
||||||
|
responseType: "agent.skills.reconcile.response",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async uninstallAgentSkills(): Promise<AgentSkillsStatus> {
|
||||||
|
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<AgentSkillsSaveResult> {
|
||||||
|
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<PluginListItem> {
|
async installDirectoryPlugin(path: string, id?: string): Promise<PluginListItem> {
|
||||||
const requestId = this.createRequestId();
|
const requestId = this.createRequestId();
|
||||||
const payload = await this.sendCorrelatedSessionRequest({
|
const payload = await this.sendCorrelatedSessionRequest({
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own
|
|||||||
expect(Object.keys(paseo).sort()).toEqual(["agents", "config", "providers", "workspaces"]);
|
expect(Object.keys(paseo).sort()).toEqual(["agents", "config", "providers", "workspaces"]);
|
||||||
expect("connect" in paseo).toBe(false);
|
expect("connect" in paseo).toBe(false);
|
||||||
expect("close" 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 () => {
|
test("agent actions list the daemon directory without exposing the low-level client", async () => {
|
||||||
|
|||||||
@@ -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<void> {
|
|
||||||
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<void> {
|
|
||||||
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 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<SkillsSandbox>;
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<string, unknown> | null,
|
|
||||||
) => Promise<unknown>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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<void>;
|
|
||||||
/** Commits a selection through the real controller, as another window would. */
|
|
||||||
commitSelectionExternally: (
|
|
||||||
selection: SkillSelection,
|
|
||||||
confirmedRemovals?: readonly string[],
|
|
||||||
) => Promise<void>;
|
|
||||||
/** Waits until a held save reaches the desktop command boundary. */
|
|
||||||
waitForHeldSave: () => Promise<void>;
|
|
||||||
/** 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<void>;
|
|
||||||
cleanup: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, string> };
|
|
||||||
/** 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<SkillsSandbox> {
|
|
||||||
let releaseHeldSave: (() => void) | null = null;
|
|
||||||
let markSaveHeld: (() => void) | null = null;
|
|
||||||
const heldSave = new Promise<void>((resolve) => {
|
|
||||||
markSaveHeld = resolve;
|
|
||||||
});
|
|
||||||
const saveRelease = new Promise<void>((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<void> {
|
|
||||||
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<void> {
|
|
||||||
const handlers = createSkillsCommandHandlers({
|
|
||||||
controller: createSkillsController({
|
|
||||||
resolveTargets: () => sandbox.targets,
|
|
||||||
selectionStore: createSkillSelectionStore({ userDataPath: sandbox.userDataPath }),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.exposeFunction(
|
|
||||||
"__paseoSkillsInvoke",
|
|
||||||
async (command: string, args: Record<string, unknown> | 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<string, unknown>,
|
|
||||||
) => Promise<unknown>;
|
|
||||||
bridge.invoke = (command: string, args?: Record<string, unknown>) =>
|
|
||||||
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<SkillsSandbox> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
await openCompactSettings(page, buildOpenProjectRoute());
|
|
||||||
await openSettingsSection(page, "integrations");
|
|
||||||
await expect(page.getByText("Orchestration skills", { exact: true })).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openSkillSelection(page: Page): Promise<void> {
|
|
||||||
await page.getByRole("button", { name: "Choose skills", exact: true }).click();
|
|
||||||
await expectSkillSelectionOpen(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectSkillSelectionOpen(page: Page): Promise<void> {
|
|
||||||
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectSkillSelectionClosed(page: Page): Promise<void> {
|
|
||||||
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function chooseAllSkills(page: Page): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
await page.getByRole("checkbox", { name, exact: true }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveSkillSelection(page: Page): Promise<void> {
|
|
||||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelSkillSelection(page: Page): Promise<void> {
|
|
||||||
await page.getByRole("button", { name: "Cancel", exact: true }).click();
|
|
||||||
await expectSkillSelectionClosed(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listSkillChoices(page: Page): Promise<string[]> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
expect(await page.evaluate(() => window.__capturedDialogCall)).toBeUndefined();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectSkillPresentIn(
|
|
||||||
sandbox: SkillsSandbox,
|
|
||||||
target: SkillTargetName,
|
|
||||||
skill: string,
|
|
||||||
): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<string[]> {
|
|
||||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
||||||
return entries
|
|
||||||
.filter((entry) => entry.isDirectory())
|
|
||||||
.map((entry) => entry.name)
|
|
||||||
.sort();
|
|
||||||
}
|
|
||||||
@@ -29,8 +29,6 @@ asarUnpack:
|
|||||||
extraResources:
|
extraResources:
|
||||||
- from: ../app/dist
|
- from: ../app/dist
|
||||||
to: app-dist
|
to: app-dist
|
||||||
- from: ../../skills
|
|
||||||
to: skills
|
|
||||||
- from: assets/editor-targets
|
- from: assets/editor-targets
|
||||||
to: editor-target-icons
|
to: editor-target-icons
|
||||||
publish:
|
publish:
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
getCliInstallStatus,
|
getCliInstallStatus,
|
||||||
installCli,
|
installCli,
|
||||||
} from "../integrations/cli-install/index.js";
|
} from "../integrations/cli-install/index.js";
|
||||||
import { createSkillsCommandHandlers, getSkillsController } from "../integrations/skills/index.js";
|
|
||||||
import {
|
import {
|
||||||
openLocalTransportSession,
|
openLocalTransportSession,
|
||||||
sendLocalTransportMessage,
|
sendLocalTransportMessage,
|
||||||
@@ -39,6 +38,10 @@ import type { DesktopSettings } from "../settings/desktop-settings.js";
|
|||||||
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
|
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
|
||||||
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
|
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
|
||||||
import { getDesktopAppLogs } from "../diagnostics/app-logs.js";
|
import { getDesktopAppLogs } from "../diagnostics/app-logs.js";
|
||||||
|
import {
|
||||||
|
deleteLegacySkillSelection,
|
||||||
|
readLegacySkillSelection,
|
||||||
|
} from "../integrations/legacy-skill-selection.js";
|
||||||
import { tailFile } from "../diagnostics/tail-file.js";
|
import { tailFile } from "../diagnostics/tail-file.js";
|
||||||
|
|
||||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||||
@@ -569,7 +572,8 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
|||||||
get_local_daemon_version: () => getLocalDaemonVersion(),
|
get_local_daemon_version: () => getLocalDaemonVersion(),
|
||||||
install_cli: () => installCli(),
|
install_cli: () => installCli(),
|
||||||
get_cli_install_status: () => getCliInstallStatus(),
|
get_cli_install_status: () => getCliInstallStatus(),
|
||||||
...createSkillsCommandHandlers({ controller: getSkillsController() }),
|
read_legacy_skill_selection: () => readLegacySkillSelection(),
|
||||||
|
delete_legacy_skill_selection: () => deleteLegacySkillSelection(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,20 @@ describe("desktop packaging", () => {
|
|||||||
expect(config).toContain("!node_modules/@getpaseo/server/dist/server/web-ui/**");
|
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", () => {
|
it("registers Paseo agent links with the operating system", () => {
|
||||||
const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8");
|
const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8");
|
||||||
|
|
||||||
|
|||||||
@@ -36,24 +36,6 @@ describe("desktop startup", () => {
|
|||||||
expect(calls).toEqual(["cli", "env", "gui"]);
|
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 () => {
|
it("does not route open-project launches through CLI passthrough", async () => {
|
||||||
const runCliPassthroughIfRequested = vi.fn(async () => true);
|
const runCliPassthroughIfRequested = vi.fn(async () => true);
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export interface DesktopStartupDependencies {
|
|||||||
runCliPassthroughIfRequested: () => Promise<boolean>;
|
runCliPassthroughIfRequested: () => Promise<boolean>;
|
||||||
inheritLoginShellEnv: () => void;
|
inheritLoginShellEnv: () => void;
|
||||||
bootstrapGui: () => Promise<void>;
|
bootstrapGui: () => Promise<void>;
|
||||||
autoUpdateInstalledSkills?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runDesktopStartup(deps: DesktopStartupDependencies): Promise<void> {
|
export async function runDesktopStartup(deps: DesktopStartupDependencies): Promise<void> {
|
||||||
@@ -13,5 +12,4 @@ export async function runDesktopStartup(deps: DesktopStartupDependencies): Promi
|
|||||||
|
|
||||||
deps.inheritLoginShellEnv();
|
deps.inheritLoginShellEnv();
|
||||||
await deps.bootstrapGui();
|
await deps.bootstrapGui();
|
||||||
deps.autoUpdateInstalledSkills?.();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<AgentSkillSelection | null> {
|
||||||
|
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<void> {
|
||||||
|
await rm(legacyPath(), { force: true });
|
||||||
|
}
|
||||||
@@ -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<SkillsStatus> {
|
|
||||||
return getSkillsController().autoUpdate();
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
@@ -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<string>();
|
|
||||||
|
|
||||||
async function createTempUserDataDir(): Promise<string> {
|
|
||||||
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<void> {
|
|
||||||
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"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<SkillSelection>;
|
|
||||||
set(selection: unknown): Promise<SkillSelection>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, unknown> {
|
|
||||||
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<void> = Promise.resolve();
|
|
||||||
|
|
||||||
async function persist(document: PersistedSkillSelectionDocument): Promise<void> {
|
|
||||||
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<SkillSelection> {
|
|
||||||
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<SkillSelection> {
|
|
||||||
const document = coerceDocument({ selection });
|
|
||||||
await persist(document);
|
|
||||||
return document.selection;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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<string, DesktopCommandHandler> {
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -87,7 +87,6 @@ import {
|
|||||||
stopDesktopManagedDaemonOnQuitIfNeeded,
|
stopDesktopManagedDaemonOnQuitIfNeeded,
|
||||||
} from "./daemon/quit-lifecycle.js";
|
} from "./daemon/quit-lifecycle.js";
|
||||||
import { runDesktopStartup } from "./desktop-startup.js";
|
import { runDesktopStartup } from "./desktop-startup.js";
|
||||||
import { autoUpdateInstalledSkills } from "./integrations/skills/index.js";
|
|
||||||
import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js";
|
import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js";
|
||||||
import { BrowserKeyboard } from "./features/browser-keyboard/index.js";
|
import { BrowserKeyboard } from "./features/browser-keyboard/index.js";
|
||||||
import { installAppUpdateOnQuit } from "./features/auto-updater.js";
|
import { installAppUpdateOnQuit } from "./features/auto-updater.js";
|
||||||
@@ -1035,11 +1034,6 @@ void runDesktopStartup({
|
|||||||
runCliPassthroughIfRequested,
|
runCliPassthroughIfRequested,
|
||||||
inheritLoginShellEnv,
|
inheritLoginShellEnv,
|
||||||
bootstrapGui: bootstrap,
|
bootstrapGui: bootstrap,
|
||||||
autoUpdateInstalledSkills: () => {
|
|
||||||
void autoUpdateInstalledSkills().catch((error) => {
|
|
||||||
log.error("[skills] auto-update failed", error);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||||
process.stderr.write(`${message}\n`);
|
process.stderr.write(`${message}\n`);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -188,6 +188,12 @@ export const PluginSourceSchema = z.discriminatedUnion("source", [DirectoryPlugi
|
|||||||
|
|
||||||
export type PluginSource = z.infer<typeof PluginSourceSchema>;
|
export type PluginSource = z.infer<typeof PluginSourceSchema>;
|
||||||
|
|
||||||
|
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<typeof AgentSkillSelectionSchema>;
|
||||||
|
|
||||||
export const MutableDaemonConfigSchema = z
|
export const MutableDaemonConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
// COMPAT(relayConfig): added in v0.2.6, remove after 2027-01-31 when old daemons are unsupported.
|
// 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(""),
|
appendSystemPrompt: z.string().default(""),
|
||||||
terminalProfiles: z.array(TerminalProfileSchema).optional(),
|
terminalProfiles: z.array(TerminalProfileSchema).optional(),
|
||||||
agentProfiles: z.array(AgentProfileSchema).optional(),
|
agentProfiles: z.array(AgentProfileSchema).optional(),
|
||||||
|
skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(),
|
||||||
pluginsEnabled: z.boolean().optional(),
|
pluginsEnabled: z.boolean().optional(),
|
||||||
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
|
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
|
||||||
})
|
})
|
||||||
@@ -1395,6 +1402,59 @@ export const PluginRpcInvokeRequestSchema = z.object({
|
|||||||
input: z.unknown(),
|
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<typeof AgentSkillOperationSchema>;
|
||||||
|
|
||||||
|
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<typeof AgentSkillsStatusSchema>;
|
||||||
|
|
||||||
|
export const AgentSkillsConfirmationSchema = z.object({ removals: z.array(z.string()) }).strict();
|
||||||
|
export type AgentSkillsConfirmation = z.infer<typeof AgentSkillsConfirmationSchema>;
|
||||||
|
|
||||||
|
export const AgentSkillsSaveResultSchema = AgentSkillsStatusSchema.extend({
|
||||||
|
confirmationRequired: AgentSkillsConfirmationSchema.nullable(),
|
||||||
|
});
|
||||||
|
export type AgentSkillsSaveResult = z.infer<typeof AgentSkillsSaveResultSchema>;
|
||||||
|
|
||||||
|
function agentSkillsRequest<const Type extends string>(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({
|
export const GetDaemonConfigRequestMessageSchema = z.object({
|
||||||
type: z.literal("get_daemon_config_request"),
|
type: z.literal("get_daemon_config_request"),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
@@ -2875,6 +2935,11 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
PluginDisableRequestSchema,
|
PluginDisableRequestSchema,
|
||||||
PluginRemoveRequestSchema,
|
PluginRemoveRequestSchema,
|
||||||
PluginRpcInvokeRequestSchema,
|
PluginRpcInvokeRequestSchema,
|
||||||
|
AgentSkillsGetStatusRequestSchema,
|
||||||
|
AgentSkillsReconcileRequestSchema,
|
||||||
|
AgentSkillsUninstallRequestSchema,
|
||||||
|
AgentSkillsSaveSelectionRequestSchema,
|
||||||
|
AgentSkillsImportLegacySelectionRequestSchema,
|
||||||
GetDaemonConfigRequestMessageSchema,
|
GetDaemonConfigRequestMessageSchema,
|
||||||
SetDaemonConfigRequestMessageSchema,
|
SetDaemonConfigRequestMessageSchema,
|
||||||
ReadProjectConfigRequestMessageSchema,
|
ReadProjectConfigRequestMessageSchema,
|
||||||
@@ -3205,6 +3270,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
pluginManagement: z.boolean().optional(),
|
pluginManagement: z.boolean().optional(),
|
||||||
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
|
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
|
||||||
pluginLogs: z.boolean().optional(),
|
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.
|
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||||
"terminal-restore-modes": z.boolean().optional(),
|
"terminal-restore-modes": z.boolean().optional(),
|
||||||
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
|
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
|
||||||
@@ -5933,6 +6000,35 @@ export const PluginRpcInvokeResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function agentSkillsStatusResponse<const Type extends string>(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", [
|
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||||
HubExecutionAgentCreateResponseSchema,
|
HubExecutionAgentCreateResponseSchema,
|
||||||
HubExecutionAgentValidateResponseSchema,
|
HubExecutionAgentValidateResponseSchema,
|
||||||
@@ -5950,6 +6046,11 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
PluginDisableResponseSchema,
|
PluginDisableResponseSchema,
|
||||||
PluginRemoveResponseSchema,
|
PluginRemoveResponseSchema,
|
||||||
PluginRpcInvokeResponseSchema,
|
PluginRpcInvokeResponseSchema,
|
||||||
|
AgentSkillsGetStatusResponseSchema,
|
||||||
|
AgentSkillsReconcileResponseSchema,
|
||||||
|
AgentSkillsUninstallResponseSchema,
|
||||||
|
AgentSkillsSaveSelectionResponseSchema,
|
||||||
|
AgentSkillsImportLegacySelectionResponseSchema,
|
||||||
ActivityLogMessageSchema,
|
ActivityLogMessageSchema,
|
||||||
AssistantChunkMessageSchema,
|
AssistantChunkMessageSchema,
|
||||||
AudioOutputMessageSchema,
|
AudioOutputMessageSchema,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"clean": "node ../../scripts/clean-package-dist.mjs",
|
"clean": "node ../../scripts/clean-package-dist.mjs",
|
||||||
"build": "npm run build:lib && npm run build:scripts",
|
"build": "npm run build:lib && npm run build:scripts",
|
||||||
"build:clean": "npm run clean && npm run build",
|
"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');\"",
|
"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",
|
"prepack": "npm run build:clean && npm --prefix ../.. run build:daemon-web-ui",
|
||||||
"start": "node dist/scripts/supervisor-entrypoint.js",
|
"start": "node dist/scripts/supervisor-entrypoint.js",
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ import {
|
|||||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||||
import { ScheduleService } from "./schedule/service.js";
|
import { ScheduleService } from "./schedule/service.js";
|
||||||
import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.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 { resolveConfigFromPersisted, type CliConfigOverrides } from "./config.js";
|
||||||
import { BrowserToolsBroker } from "./browser-tools/broker.js";
|
import { BrowserToolsBroker } from "./browser-tools/broker.js";
|
||||||
import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.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 { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
|
||||||
import type {
|
import type {
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
|
AgentSkillSelection,
|
||||||
FirstAgentContext,
|
FirstAgentContext,
|
||||||
PluginSource,
|
PluginSource,
|
||||||
TerminalProfile,
|
TerminalProfile,
|
||||||
@@ -406,6 +408,7 @@ export interface PaseoDaemonConfig {
|
|||||||
appendSystemPrompt?: string;
|
appendSystemPrompt?: string;
|
||||||
terminalProfiles?: TerminalProfile[];
|
terminalProfiles?: TerminalProfile[];
|
||||||
agentProfiles?: AgentProfile[];
|
agentProfiles?: AgentProfile[];
|
||||||
|
skillSelection?: AgentSkillSelection;
|
||||||
pluginsEnabled?: boolean;
|
pluginsEnabled?: boolean;
|
||||||
plugins?: Record<string, PluginSource>;
|
plugins?: Record<string, PluginSource>;
|
||||||
staticDir: string;
|
staticDir: string;
|
||||||
@@ -551,6 +554,7 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae
|
|||||||
appendSystemPrompt: config.appendSystemPrompt ?? "",
|
appendSystemPrompt: config.appendSystemPrompt ?? "",
|
||||||
pluginsEnabled: config.pluginsEnabled ?? false,
|
pluginsEnabled: config.pluginsEnabled ?? false,
|
||||||
plugins: config.plugins ?? {},
|
plugins: config.plugins ?? {},
|
||||||
|
skills: { selection: config.skillSelection },
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.terminalProfiles !== undefined) {
|
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 browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore);
|
||||||
const browserToolsBroker = new BrowserToolsBroker({});
|
const browserToolsBroker = new BrowserToolsBroker({});
|
||||||
const pluginRuntime = new PluginService(logger, daemonConfigStore);
|
const pluginRuntime = new PluginService(logger, daemonConfigStore);
|
||||||
@@ -1634,6 +1642,7 @@ export async function createPaseoDaemon(
|
|||||||
hubRelationships,
|
hubRelationships,
|
||||||
workspaceSetupRuntime,
|
workspaceSetupRuntime,
|
||||||
pluginRuntime,
|
pluginRuntime,
|
||||||
|
orchestrationSkills,
|
||||||
);
|
);
|
||||||
pluginRuntime.bindPaseoSessionHost(wsServer);
|
pluginRuntime.bindPaseoSessionHost(wsServer);
|
||||||
await pluginRuntime.start();
|
await pluginRuntime.start();
|
||||||
|
|||||||
@@ -613,6 +613,7 @@ export function resolveConfigFromPersisted(
|
|||||||
appendSystemPrompt,
|
appendSystemPrompt,
|
||||||
terminalProfiles,
|
terminalProfiles,
|
||||||
agentProfiles,
|
agentProfiles,
|
||||||
|
skillSelection: persisted.agents?.skills?.selection,
|
||||||
pluginsEnabled: persisted.pluginsEnabled ?? false,
|
pluginsEnabled: persisted.pluginsEnabled ?? false,
|
||||||
plugins: persisted.plugins,
|
plugins: persisted.plugins,
|
||||||
mcpDebug: env.MCP_DEBUG === "1",
|
mcpDebug: env.MCP_DEBUG === "1",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
MutableDaemonConfigSchema,
|
MutableDaemonConfigSchema,
|
||||||
MutableDaemonConfigPatchSchema,
|
MutableDaemonConfigPatchSchema,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
|
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
|
||||||
|
|
||||||
export type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages";
|
export type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages";
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ interface SupportedMutableConfigPatch {
|
|||||||
appendSystemPrompt?: string;
|
appendSystemPrompt?: string;
|
||||||
terminalProfiles?: MutableDaemonConfig["terminalProfiles"];
|
terminalProfiles?: MutableDaemonConfig["terminalProfiles"];
|
||||||
agentProfiles?: MutableDaemonConfig["agentProfiles"];
|
agentProfiles?: MutableDaemonConfig["agentProfiles"];
|
||||||
|
skills?: MutableDaemonConfig["skills"];
|
||||||
pluginsEnabled?: boolean;
|
pluginsEnabled?: boolean;
|
||||||
plugins?: MutableDaemonConfig["plugins"];
|
plugins?: MutableDaemonConfig["plugins"];
|
||||||
}
|
}
|
||||||
@@ -185,6 +187,7 @@ const RELOADABLE_PATHS = [
|
|||||||
"agents.providers",
|
"agents.providers",
|
||||||
"agents.catalogRefreshTimeoutMs",
|
"agents.catalogRefreshTimeoutMs",
|
||||||
"agents.metadataGeneration",
|
"agents.metadataGeneration",
|
||||||
|
"agents.skills.selection",
|
||||||
"pluginsEnabled",
|
"pluginsEnabled",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -207,6 +210,7 @@ const PERSISTED_TO_MUTABLE_PATH = new Map<string, string>([
|
|||||||
["agents.providers", "providers"],
|
["agents.providers", "providers"],
|
||||||
["agents.catalogRefreshTimeoutMs", "catalogRefreshTimeoutMs"],
|
["agents.catalogRefreshTimeoutMs", "catalogRefreshTimeoutMs"],
|
||||||
["agents.metadataGeneration", "metadataGeneration"],
|
["agents.metadataGeneration", "metadataGeneration"],
|
||||||
|
["agents.skills.selection", "skills.selection"],
|
||||||
["pluginsEnabled", "pluginsEnabled"],
|
["pluginsEnabled", "pluginsEnabled"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -334,6 +338,14 @@ export class DaemonConfigStore {
|
|||||||
|
|
||||||
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
|
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
|
||||||
const parsedPatch = pickSupportedPatchFields(MutableDaemonConfigPatchSchema.parse(partial));
|
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) {
|
if (parsedPatch.relay?.enabled !== undefined && !this.relayEnabledMutable) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
|
"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 { removeProviders = [], ...configPatch } = parsedPatch;
|
||||||
const removedProviders = Array.from(new Set(removeProviders));
|
const removedProviders = Array.from(new Set(removeProviders));
|
||||||
const merged = deepMerge(this.current, configPatch);
|
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;
|
if (parsedPatch.plugins !== undefined) merged.plugins = parsedPatch.plugins;
|
||||||
const next = MutableDaemonConfigSchema.parse(
|
const next = MutableDaemonConfigSchema.parse(
|
||||||
omitMetadataGenerationProvidersFromConfig(
|
omitMetadataGenerationProvidersFromConfig(
|
||||||
@@ -576,6 +591,7 @@ function mergeMutableAgentPatch(
|
|||||||
if (
|
if (
|
||||||
patch.providers === undefined &&
|
patch.providers === undefined &&
|
||||||
patch.metadataGeneration === undefined &&
|
patch.metadataGeneration === undefined &&
|
||||||
|
patch.skills === undefined &&
|
||||||
removeProviders.length === 0
|
removeProviders.length === 0
|
||||||
) {
|
) {
|
||||||
return persistedAgents;
|
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;
|
return Object.keys(next).length > 0 ? (next as PersistedConfig["agents"]) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
|
||||||
|
|
||||||
|
import type { DaemonConfigStore } from "../daemon-config-store.js";
|
||||||
|
import {
|
||||||
|
createSkillsController,
|
||||||
|
type SkillsController,
|
||||||
|
type SkillsSaveResult,
|
||||||
|
type SkillsSnapshot,
|
||||||
|
} from "./internal/controller.js";
|
||||||
|
import { resolveSkillTargets } from "./internal/paths.js";
|
||||||
|
import { createSkillSelectionStore } from "./internal/selection-store.js";
|
||||||
|
|
||||||
|
export interface OrchestrationSkills {
|
||||||
|
getStatus(): Promise<SkillsSnapshot>;
|
||||||
|
reconcile(): Promise<SkillsSnapshot>;
|
||||||
|
uninstall(): Promise<SkillsSnapshot>;
|
||||||
|
saveSelection(
|
||||||
|
selection: AgentSkillSelection,
|
||||||
|
confirmedRemovals?: readonly string[],
|
||||||
|
): Promise<SkillsSaveResult>;
|
||||||
|
importLegacySelectionIfUnset(selection: AgentSkillSelection): Promise<{
|
||||||
|
imported: boolean;
|
||||||
|
selection: AgentSkillSelection;
|
||||||
|
}>;
|
||||||
|
autoUpdate(): Promise<SkillsSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOrchestrationSkills(
|
||||||
|
configStore: DaemonConfigStore,
|
||||||
|
resolveTargets = resolveSkillTargets,
|
||||||
|
): OrchestrationSkills {
|
||||||
|
const controller: SkillsController = createSkillsController({
|
||||||
|
resolveTargets,
|
||||||
|
selectionStore: createSkillSelectionStore(configStore),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
getStatus: () => controller.status(),
|
||||||
|
reconcile: () => controller.update(),
|
||||||
|
uninstall: () => controller.uninstall(),
|
||||||
|
saveSelection: (selection, confirmedRemovals = []) =>
|
||||||
|
controller.save({ ...selection, confirmedRemovals }),
|
||||||
|
importLegacySelectionIfUnset: (selection) => controller.importLegacySelectionIfUnset(selection),
|
||||||
|
autoUpdate: () => controller.autoUpdate(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { SkillsSaveResult, SkillsSnapshot } from "./internal/controller.js";
|
||||||
+49
-4
@@ -18,9 +18,9 @@ import path from "node:path";
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { SkillSelection, SkillTargets } from "./operations";
|
import type { SkillSelection, SkillTargets } from "./operations";
|
||||||
import { createSkillSelectionStore, type SkillSelectionStore } from "./selection-store";
|
import type { SkillSelectionStore } from "./selection-store";
|
||||||
import { createSkillsController, type SkillsController } from "./skills-controller";
|
import { createSkillsController, type SkillsController } from "./controller";
|
||||||
import { beginSkillsTransaction } from "./skills-transaction";
|
import { beginSkillsTransaction } from "./transaction";
|
||||||
|
|
||||||
interface Harness {
|
interface Harness {
|
||||||
root: string;
|
root: string;
|
||||||
@@ -43,8 +43,17 @@ async function makeHarness(selectionStore?: SkillSelectionStore): Promise<Harnes
|
|||||||
await mkdir(path.join(targets.sourceDir, name), { recursive: true });
|
await mkdir(path.join(targets.sourceDir, name), { recursive: true });
|
||||||
await writeFile(path.join(targets.sourceDir, name, "SKILL.md"), `${name}-v1`);
|
await writeFile(path.join(targets.sourceDir, name, "SKILL.md"), `${name}-v1`);
|
||||||
}
|
}
|
||||||
|
let selection: SkillSelection = { mode: "all" };
|
||||||
const store =
|
const store =
|
||||||
selectionStore ?? createSkillSelectionStore({ userDataPath: path.join(root, "user-data") });
|
selectionStore ??
|
||||||
|
({
|
||||||
|
get: async () => selection,
|
||||||
|
set: async (next: unknown) => {
|
||||||
|
selection = next as SkillSelection;
|
||||||
|
return selection;
|
||||||
|
},
|
||||||
|
isSet: async () => true,
|
||||||
|
} satisfies SkillSelectionStore);
|
||||||
return {
|
return {
|
||||||
root,
|
root,
|
||||||
targets,
|
targets,
|
||||||
@@ -64,6 +73,7 @@ async function makeHarness(selectionStore?: SkillSelectionStore): Promise<Harnes
|
|||||||
function createUnwritableSelectionStore(initial: SkillSelection): SkillSelectionStore {
|
function createUnwritableSelectionStore(initial: SkillSelection): SkillSelectionStore {
|
||||||
return {
|
return {
|
||||||
get: async () => initial,
|
get: async () => initial,
|
||||||
|
isSet: async () => true,
|
||||||
set: async () => {
|
set: async () => {
|
||||||
throw new Error("selection store is read-only");
|
throw new Error("selection store is read-only");
|
||||||
},
|
},
|
||||||
@@ -86,6 +96,7 @@ function createGatedUnwritableSelectionStore(initial: SkillSelection): {
|
|||||||
return {
|
return {
|
||||||
store: {
|
store: {
|
||||||
get: async () => initial,
|
get: async () => initial,
|
||||||
|
isSet: async () => true,
|
||||||
set: async () => {
|
set: async () => {
|
||||||
markStarted();
|
markStarted();
|
||||||
await failureGate;
|
await failureGate;
|
||||||
@@ -114,10 +125,12 @@ function createGatedSelectionStore(initial: SkillSelection): {
|
|||||||
return {
|
return {
|
||||||
store: {
|
store: {
|
||||||
get: async () => current,
|
get: async () => current,
|
||||||
|
isSet: async () => true,
|
||||||
set: async (selection) => {
|
set: async (selection) => {
|
||||||
markStarted();
|
markStarted();
|
||||||
await gate;
|
await gate;
|
||||||
current = selection;
|
current = selection;
|
||||||
|
return current;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
persistenceStarted,
|
persistenceStarted,
|
||||||
@@ -209,6 +222,38 @@ async function isInstalled(targets: SkillTargets, name: string): Promise<boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("skills controller", () => {
|
describe("skills controller", () => {
|
||||||
|
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;
|
let harness: Harness;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
+15
-4
@@ -13,10 +13,7 @@ import {
|
|||||||
coerceSkillSelection,
|
coerceSkillSelection,
|
||||||
type SkillSelectionStore,
|
type SkillSelectionStore,
|
||||||
} from "./selection-store.js";
|
} from "./selection-store.js";
|
||||||
import {
|
import { beginSkillsTransaction, recoverInterruptedSkillTransactions } from "./transaction.js";
|
||||||
beginSkillsTransaction,
|
|
||||||
recoverInterruptedSkillTransactions,
|
|
||||||
} from "./skills-transaction.js";
|
|
||||||
|
|
||||||
/** Everything the settings UI needs to render skills in one round trip. */
|
/** Everything the settings UI needs to render skills in one round trip. */
|
||||||
export interface SkillsSnapshot extends SkillsStatus {
|
export interface SkillsSnapshot extends SkillsStatus {
|
||||||
@@ -38,6 +35,10 @@ export interface SkillsController {
|
|||||||
uninstall(): Promise<SkillsSnapshot>;
|
uninstall(): Promise<SkillsSnapshot>;
|
||||||
autoUpdate(): Promise<SkillsSnapshot>;
|
autoUpdate(): Promise<SkillsSnapshot>;
|
||||||
save(request: unknown): Promise<SkillsSaveResult>;
|
save(request: unknown): Promise<SkillsSaveResult>;
|
||||||
|
importLegacySelectionIfUnset(selection: unknown): Promise<{
|
||||||
|
imported: boolean;
|
||||||
|
selection: SkillSelection;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Converge = (targets: SkillTargets, selection: SkillSelection) => Promise<SkillsStatus>;
|
type Converge = (targets: SkillTargets, selection: SkillSelection) => Promise<SkillsStatus>;
|
||||||
@@ -149,6 +150,14 @@ export function createSkillsController({
|
|||||||
throw new Error("Skills changed repeatedly while the selection was being saved");
|
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 {
|
return {
|
||||||
status: () => serialize(() => converge(getSkillsStatus)),
|
status: () => serialize(() => converge(getSkillsStatus)),
|
||||||
install: () => serialize(() => converge(updateSkills)),
|
install: () => serialize(() => converge(updateSkills)),
|
||||||
@@ -156,5 +165,7 @@ export function createSkillsController({
|
|||||||
uninstall: () => serialize(() => converge(uninstallSkills)),
|
uninstall: () => serialize(() => converge(uninstallSkills)),
|
||||||
autoUpdate: () => serialize(() => converge(autoUpdateInstalledSkills)),
|
autoUpdate: () => serialize(() => converge(autoUpdateInstalledSkills)),
|
||||||
save: (selection) => serialize(() => saveSelection(selection)),
|
save: (selection) => serialize(() => saveSelection(selection)),
|
||||||
|
importLegacySelectionIfUnset: (selection) =>
|
||||||
|
serialize(() => importLegacySelectionIfUnset(selection)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
|
||||||
import { listFilesRecursive, removeSkill, syncSkills } from "./sync.js";
|
import { listFilesRecursive, removeSkill, syncSkills } from "./sync.js";
|
||||||
|
|
||||||
export type SkillsState = "not-installed" | "up-to-date" | "drift";
|
export type SkillsState = "not-installed" | "up-to-date" | "drift";
|
||||||
@@ -11,7 +12,7 @@ export type SkillOp =
|
|||||||
| { kind: "delete"; name: string };
|
| { kind: "delete"; name: string };
|
||||||
|
|
||||||
/** What the user asked to have installed. `all` follows the bundle as it grows. */
|
/** 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 {
|
export interface SkillsStatus {
|
||||||
state: SkillsState;
|
state: SkillsState;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { resolveBundledSkillsDir, resolveSkillTargets } from "./paths.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("orchestration skill paths", () => {
|
||||||
|
it("finds the repository catalog from the source module", () => {
|
||||||
|
const repositoryRoot = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"../../../../../..",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolveBundledSkillsDir()).toBe(path.join(repositoryRoot, "skills"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the catalog beside the actual emitted server layout", async () => {
|
||||||
|
const packageRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-built-skills-"));
|
||||||
|
roots.push(packageRoot);
|
||||||
|
const catalog = path.join(packageRoot, "dist", "server", "skills");
|
||||||
|
await mkdir(catalog, { recursive: true });
|
||||||
|
const emittedModule = path.join(
|
||||||
|
packageRoot,
|
||||||
|
"dist",
|
||||||
|
"server",
|
||||||
|
"server",
|
||||||
|
"orchestration-skills",
|
||||||
|
"internal",
|
||||||
|
"paths.js",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolveBundledSkillsDir(pathToFileURL(emittedModule))).toBe(catalog);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the original fixed managed directories under the daemon user's home", () => {
|
||||||
|
const home = os.homedir();
|
||||||
|
|
||||||
|
expect(resolveSkillTargets()).toMatchObject({
|
||||||
|
agentsDir: path.join(home, ".agents", "skills"),
|
||||||
|
claudeDir: path.join(home, ".claude", "skills"),
|
||||||
|
codexDir: path.join(home, ".codex", "skills"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import type { SkillTargets } from "./operations.js";
|
||||||
|
|
||||||
|
export function resolveBundledSkillsDir(moduleUrl: string | URL = import.meta.url): string {
|
||||||
|
const moduleDir = path.dirname(fileURLToPath(moduleUrl));
|
||||||
|
const candidates = [
|
||||||
|
path.resolve(moduleDir, "..", "..", "..", "skills"),
|
||||||
|
path.resolve(moduleDir, "..", "..", "..", "..", "..", "..", "skills"),
|
||||||
|
];
|
||||||
|
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSkillTargets(home: string = os.homedir()): SkillTargets {
|
||||||
|
return {
|
||||||
|
sourceDir: resolveBundledSkillsDir(),
|
||||||
|
agentsDir: path.join(home, ".agents", "skills"),
|
||||||
|
claudeDir: path.join(home, ".claude", "skills"),
|
||||||
|
codexDir: path.join(home, ".codex", "skills"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { DaemonConfigStore, type MutableDaemonConfigPatch } from "../../daemon-config-store";
|
||||||
|
import { loadPersistedConfig } from "../../persisted-config";
|
||||||
|
import { createSkillSelectionStore } from "./selection-store";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createStore() {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-skills-config-"));
|
||||||
|
roots.push(root);
|
||||||
|
const config = new DaemonConfigStore(root, {
|
||||||
|
mcp: { injectIntoAgents: false },
|
||||||
|
browserTools: { enabled: false },
|
||||||
|
providers: {},
|
||||||
|
metadataGeneration: { providers: [] },
|
||||||
|
autoArchiveAfterMerge: false,
|
||||||
|
enableTerminalAgentHooks: false,
|
||||||
|
appendSystemPrompt: "",
|
||||||
|
});
|
||||||
|
return { config, root, store: createSkillSelectionStore(config) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("daemon agent skill selection", () => {
|
||||||
|
it("defaults missing selection to all without persisting it", async () => {
|
||||||
|
const { root, store } = await createStore();
|
||||||
|
expect(await store.get()).toEqual({ mode: "all" });
|
||||||
|
expect(await store.isSet()).toBe(false);
|
||||||
|
expect(loadPersistedConfig(root).agents?.skills).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists a normalized custom selection under agents.skills.selection", async () => {
|
||||||
|
const { root, store } = await createStore();
|
||||||
|
await store.set({ mode: "custom", skills: ["paseo-loop", "paseo", "paseo"] });
|
||||||
|
expect(loadPersistedConfig(root).agents?.skills?.selection).toEqual({
|
||||||
|
mode: "custom",
|
||||||
|
skills: ["paseo", "paseo-loop"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces a custom selection with all without retaining custom skill names", async () => {
|
||||||
|
const { config, root, store } = await createStore();
|
||||||
|
await store.set({ mode: "custom", skills: ["paseo"] });
|
||||||
|
|
||||||
|
await store.set({ mode: "all" });
|
||||||
|
|
||||||
|
expect(config.get().skills?.selection).toEqual({ mode: "all" });
|
||||||
|
expect(loadPersistedConfig(root).agents?.skills?.selection).toEqual({ mode: "all" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose selection through the generic config patch path", async () => {
|
||||||
|
const { config, root, store } = await createStore();
|
||||||
|
|
||||||
|
config.patch({
|
||||||
|
skills: { selection: { mode: "custom", skills: ["paseo"] } },
|
||||||
|
} as MutableDaemonConfigPatch);
|
||||||
|
|
||||||
|
expect(await store.isSet()).toBe(false);
|
||||||
|
expect(loadPersistedConfig(root).agents?.skills).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
|
||||||
|
|
||||||
|
import type { DaemonConfigStore } from "../../daemon-config-store.js";
|
||||||
|
|
||||||
|
export type SkillSelection = AgentSkillSelection;
|
||||||
|
|
||||||
|
export interface SkillSelectionStore {
|
||||||
|
get(): Promise<SkillSelection>;
|
||||||
|
set(selection: unknown): Promise<SkillSelection>;
|
||||||
|
isSet(): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SKILL_SELECTION: SkillSelection = { mode: "all" };
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerceSkillNames(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const names = value
|
||||||
|
.filter((entry): entry is string => typeof entry === "string")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
return [...new Set(names)].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerceSkillSelection(value: unknown): SkillSelection {
|
||||||
|
if (!isRecord(value)) return DEFAULT_SKILL_SELECTION;
|
||||||
|
if (value.mode === "all") return { mode: "all" };
|
||||||
|
if (value.mode === "custom") {
|
||||||
|
return { mode: "custom", skills: coerceSkillNames(value.skills) };
|
||||||
|
}
|
||||||
|
return DEFAULT_SKILL_SELECTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSkillSelectionStore(
|
||||||
|
configStore: Pick<DaemonConfigStore, "get" | "setAgentSkillSelection">,
|
||||||
|
): SkillSelectionStore {
|
||||||
|
return {
|
||||||
|
async get() {
|
||||||
|
return coerceSkillSelection(configStore.get().skills?.selection);
|
||||||
|
},
|
||||||
|
async set(selection) {
|
||||||
|
const parsed = coerceSkillSelection(selection);
|
||||||
|
configStore.setAgentSkillSelection(parsed);
|
||||||
|
return parsed;
|
||||||
|
},
|
||||||
|
async isSet() {
|
||||||
|
return configStore.get().skills?.selection !== undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-co
|
|||||||
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
|
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
|
||||||
import {
|
import {
|
||||||
AgentProfileSchema,
|
AgentProfileSchema,
|
||||||
|
AgentSkillSelectionSchema,
|
||||||
PluginIdSchema,
|
PluginIdSchema,
|
||||||
PluginSourceSchema,
|
PluginSourceSchema,
|
||||||
TerminalProfileSchema,
|
TerminalProfileSchema,
|
||||||
@@ -317,6 +318,7 @@ export const PersistedConfigSchema = z
|
|||||||
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
|
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
|
||||||
catalogRefreshTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(),
|
catalogRefreshTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(),
|
||||||
metadataGeneration: AgentMetadataGenerationSchema.optional(),
|
metadataGeneration: AgentMetadataGenerationSchema.optional(),
|
||||||
|
skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ interface SessionForTestOptions {
|
|||||||
targetedMessages?: Array<{ source: object; message: SessionOutboundMessage }>;
|
targetedMessages?: Array<{ source: object; message: SessionOutboundMessage }>;
|
||||||
binaryMessages?: Uint8Array[];
|
binaryMessages?: Uint8Array[];
|
||||||
pluginRuntime?: SessionOptions["pluginRuntime"];
|
pluginRuntime?: SessionOptions["pluginRuntime"];
|
||||||
|
orchestrationSkills?: SessionOptions["orchestrationSkills"];
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||||
@@ -407,6 +408,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
|||||||
onChange: vi.fn(() => () => {}),
|
onChange: vi.fn(() => () => {}),
|
||||||
}),
|
}),
|
||||||
pluginRuntime: options.pluginRuntime,
|
pluginRuntime: options.pluginRuntime,
|
||||||
|
orchestrationSkills: options.orchestrationSkills,
|
||||||
stt: options.stt ?? null,
|
stt: options.stt ?? null,
|
||||||
tts: null,
|
tts: null,
|
||||||
terminalManager: options.terminalManager ?? null,
|
terminalManager: options.terminalManager ?? null,
|
||||||
@@ -426,6 +428,45 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
|||||||
return new Session(sessionOptions);
|
return new Session(sessionOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("routes host-scoped agent skills requests through the daemon owner", async () => {
|
||||||
|
const messages: SessionOutboundMessage[] = [];
|
||||||
|
const status = {
|
||||||
|
state: "up-to-date" as const,
|
||||||
|
ops: [],
|
||||||
|
available: ["paseo"],
|
||||||
|
installed: ["paseo"],
|
||||||
|
selection: { mode: "all" as const },
|
||||||
|
};
|
||||||
|
const orchestrationSkills: NonNullable<SessionOptions["orchestrationSkills"]> = {
|
||||||
|
getStatus: vi.fn(async () => status),
|
||||||
|
reconcile: vi.fn(async () => status),
|
||||||
|
uninstall: vi.fn(async () => status),
|
||||||
|
saveSelection: vi.fn(async () => ({ ...status, confirmationRequired: null })),
|
||||||
|
importLegacySelectionIfUnset: vi.fn(async (selection) => ({
|
||||||
|
imported: true,
|
||||||
|
selection,
|
||||||
|
})),
|
||||||
|
autoUpdate: vi.fn(async () => status),
|
||||||
|
};
|
||||||
|
const session = createSessionForTest({ messages, orchestrationSkills });
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "agent.skills.save_selection.request",
|
||||||
|
requestId: "save-skills",
|
||||||
|
selection: { mode: "custom", skills: ["paseo"] },
|
||||||
|
confirmedRemovals: ["paseo-loop"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(orchestrationSkills.saveSelection).toHaveBeenCalledWith(
|
||||||
|
{ mode: "custom", skills: ["paseo"] },
|
||||||
|
["paseo-loop"],
|
||||||
|
);
|
||||||
|
expect(messages).toContainEqual({
|
||||||
|
type: "agent.skills.save_selection.response",
|
||||||
|
payload: { requestId: "save-skills", ...status, confirmationRequired: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("routes plugin requests and releases its owned catalog subscription on cleanup", async () => {
|
test("routes plugin requests and releases its owned catalog subscription on cleanup", async () => {
|
||||||
const messages: SessionOutboundMessage[] = [];
|
const messages: SessionOutboundMessage[] = [];
|
||||||
const listeners = new Set<(pluginId: string) => void>();
|
const listeners = new Set<(pluginId: string) => void>();
|
||||||
|
|||||||
@@ -476,6 +476,7 @@ export interface SessionOptions {
|
|||||||
catalog(): Array<{ id: string; clientBundle: string }>;
|
catalog(): Array<{ id: string; clientBundle: string }>;
|
||||||
invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown>;
|
invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
orchestrationSkills?: import("./orchestration-skills/index.js").OrchestrationSkills;
|
||||||
mcpBaseUrl?: string | null;
|
mcpBaseUrl?: string | null;
|
||||||
stt: Resolvable<SpeechToTextProvider | null>;
|
stt: Resolvable<SpeechToTextProvider | null>;
|
||||||
sttLanguage?: string;
|
sttLanguage?: string;
|
||||||
@@ -654,6 +655,7 @@ export class Session {
|
|||||||
private readonly daemonConfigStore: DaemonConfigStore;
|
private readonly daemonConfigStore: DaemonConfigStore;
|
||||||
private readonly pushNotifications: PushNotifications;
|
private readonly pushNotifications: PushNotifications;
|
||||||
private readonly pluginRuntime: SessionOptions["pluginRuntime"];
|
private readonly pluginRuntime: SessionOptions["pluginRuntime"];
|
||||||
|
private readonly orchestrationSkills: SessionOptions["orchestrationSkills"];
|
||||||
private unsubscribeAgentEvents: (() => void) | null = null;
|
private unsubscribeAgentEvents: (() => void) | null = null;
|
||||||
private unsubscribeProjectMutations: (() => void) | null = null;
|
private unsubscribeProjectMutations: (() => void) | null = null;
|
||||||
private unsubscribePluginChanges: (() => void) | null = null;
|
private unsubscribePluginChanges: (() => void) | null = null;
|
||||||
@@ -738,6 +740,7 @@ export class Session {
|
|||||||
workspaceAutoName,
|
workspaceAutoName,
|
||||||
daemonConfigStore,
|
daemonConfigStore,
|
||||||
pluginRuntime,
|
pluginRuntime,
|
||||||
|
orchestrationSkills,
|
||||||
stt,
|
stt,
|
||||||
sttLanguage,
|
sttLanguage,
|
||||||
tts,
|
tts,
|
||||||
@@ -778,6 +781,7 @@ export class Session {
|
|||||||
this.projectIcons = new ProjectIconReader(paseoHome);
|
this.projectIcons = new ProjectIconReader(paseoHome);
|
||||||
this.worktreesRoot = worktreesRoot;
|
this.worktreesRoot = worktreesRoot;
|
||||||
this.pluginRuntime = pluginRuntime;
|
this.pluginRuntime = pluginRuntime;
|
||||||
|
this.orchestrationSkills = orchestrationSkills;
|
||||||
this.unsubscribePluginChanges = this.subscribeToPluginChanges(pluginRuntime);
|
this.unsubscribePluginChanges = this.subscribeToPluginChanges(pluginRuntime);
|
||||||
this.sessionLogger = logger.child({
|
this.sessionLogger = logger.child({
|
||||||
module: "session",
|
module: "session",
|
||||||
@@ -1883,6 +1887,7 @@ export class Session {
|
|||||||
this.dispatchWorkspaceAndProjectMessage(msg) ??
|
this.dispatchWorkspaceAndProjectMessage(msg) ??
|
||||||
this.dispatchWorkspaceFileMessage(msg, source) ??
|
this.dispatchWorkspaceFileMessage(msg, source) ??
|
||||||
this.dispatchProviderMessage(msg) ??
|
this.dispatchProviderMessage(msg) ??
|
||||||
|
this.dispatchOrchestrationSkillsMessage(msg) ??
|
||||||
this.dispatchPluginDirectoryMessage(msg) ??
|
this.dispatchPluginDirectoryMessage(msg) ??
|
||||||
this.dispatchPluginMessage(msg) ??
|
this.dispatchPluginMessage(msg) ??
|
||||||
this.dispatchTerminalMessage(msg) ??
|
this.dispatchTerminalMessage(msg) ??
|
||||||
@@ -1891,6 +1896,66 @@ export class Session {
|
|||||||
if (promise) await promise;
|
if (promise) await promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private dispatchOrchestrationSkillsMessage(
|
||||||
|
msg: SessionInboundMessage,
|
||||||
|
): Promise<void> | undefined {
|
||||||
|
if (!this.orchestrationSkills || !msg.type.startsWith("agent.skills.")) return undefined;
|
||||||
|
const emitStatus = (
|
||||||
|
type:
|
||||||
|
| "agent.skills.get_status.response"
|
||||||
|
| "agent.skills.reconcile.response"
|
||||||
|
| "agent.skills.uninstall.response",
|
||||||
|
requestId: string,
|
||||||
|
operation: Promise<import("./orchestration-skills/index.js").SkillsSnapshot>,
|
||||||
|
) =>
|
||||||
|
operation.then((status) => {
|
||||||
|
this.emit({ type, payload: { requestId, ...status } });
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
switch (msg.type) {
|
||||||
|
case "agent.skills.get_status.request":
|
||||||
|
return emitStatus(
|
||||||
|
"agent.skills.get_status.response",
|
||||||
|
msg.requestId,
|
||||||
|
this.orchestrationSkills.getStatus(),
|
||||||
|
);
|
||||||
|
case "agent.skills.reconcile.request":
|
||||||
|
return emitStatus(
|
||||||
|
"agent.skills.reconcile.response",
|
||||||
|
msg.requestId,
|
||||||
|
this.orchestrationSkills.reconcile(),
|
||||||
|
);
|
||||||
|
case "agent.skills.uninstall.request":
|
||||||
|
return emitStatus(
|
||||||
|
"agent.skills.uninstall.response",
|
||||||
|
msg.requestId,
|
||||||
|
this.orchestrationSkills.uninstall(),
|
||||||
|
);
|
||||||
|
case "agent.skills.save_selection.request":
|
||||||
|
return this.orchestrationSkills
|
||||||
|
.saveSelection(msg.selection, msg.confirmedRemovals)
|
||||||
|
.then((result) => {
|
||||||
|
this.emit({
|
||||||
|
type: "agent.skills.save_selection.response",
|
||||||
|
payload: { requestId: msg.requestId, ...result },
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
case "agent.skills.import_legacy_selection.request":
|
||||||
|
return this.orchestrationSkills
|
||||||
|
.importLegacySelectionIfUnset(msg.selection)
|
||||||
|
.then((result) => {
|
||||||
|
this.emit({
|
||||||
|
type: "agent.skills.import_legacy_selection.response",
|
||||||
|
payload: { requestId: msg.requestId, ...result },
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private dispatchPluginMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
private dispatchPluginMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||||
if (msg.type === "plugin.list.request") {
|
if (msg.type === "plugin.list.request") {
|
||||||
this.emit({
|
this.emit({
|
||||||
|
|||||||
@@ -602,6 +602,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
private readonly advertiseRelayConfig: boolean;
|
private readonly advertiseRelayConfig: boolean;
|
||||||
private readonly directorySync = new DirectorySyncService();
|
private readonly directorySync = new DirectorySyncService();
|
||||||
private readonly pluginRuntime: SessionOptions["pluginRuntime"];
|
private readonly pluginRuntime: SessionOptions["pluginRuntime"];
|
||||||
|
private readonly orchestrationSkills: SessionOptions["orchestrationSkills"];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
server: HTTPServer,
|
server: HTTPServer,
|
||||||
@@ -647,6 +648,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
hubRelationships?: HubRelationshipManagement | null,
|
hubRelationships?: HubRelationshipManagement | null,
|
||||||
workspaceSetupRuntime: WorkspaceSetupRuntime = new WorkspaceSetupRuntime(),
|
workspaceSetupRuntime: WorkspaceSetupRuntime = new WorkspaceSetupRuntime(),
|
||||||
pluginRuntime?: SessionOptions["pluginRuntime"],
|
pluginRuntime?: SessionOptions["pluginRuntime"],
|
||||||
|
orchestrationSkills?: SessionOptions["orchestrationSkills"],
|
||||||
) {
|
) {
|
||||||
this.logger = logger.child({ module: "websocket-server" });
|
this.logger = logger.child({ module: "websocket-server" });
|
||||||
this.workspaceSetupRuntime = workspaceSetupRuntime;
|
this.workspaceSetupRuntime = workspaceSetupRuntime;
|
||||||
@@ -662,6 +664,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
this.browserToolsBroker = browserToolsBroker ?? null;
|
this.browserToolsBroker = browserToolsBroker ?? null;
|
||||||
this.hubRelationships = hubRelationships ?? null;
|
this.hubRelationships = hubRelationships ?? null;
|
||||||
this.pluginRuntime = pluginRuntime;
|
this.pluginRuntime = pluginRuntime;
|
||||||
|
this.orchestrationSkills = orchestrationSkills;
|
||||||
this.agentManager = agentManager;
|
this.agentManager = agentManager;
|
||||||
this.agentStorage = agentStorage;
|
this.agentStorage = agentStorage;
|
||||||
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
|
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
|
||||||
@@ -1433,6 +1436,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
workspaceAutoName: this.workspaceAutoName,
|
workspaceAutoName: this.workspaceAutoName,
|
||||||
daemonConfigStore: this.daemonConfigStore,
|
daemonConfigStore: this.daemonConfigStore,
|
||||||
pluginRuntime: this.pluginRuntime,
|
pluginRuntime: this.pluginRuntime,
|
||||||
|
orchestrationSkills: this.orchestrationSkills,
|
||||||
mcpBaseUrl: this.mcpBaseUrl,
|
mcpBaseUrl: this.mcpBaseUrl,
|
||||||
stt: () => this.speech?.resolveStt() ?? null,
|
stt: () => this.speech?.resolveStt() ?? null,
|
||||||
sttLanguage: this.speech?.resolveSttLanguage() ?? "en",
|
sttLanguage: this.speech?.resolveSttLanguage() ?? "en",
|
||||||
@@ -1665,6 +1669,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
plugins: true,
|
plugins: true,
|
||||||
pluginManagement: true,
|
pluginManagement: true,
|
||||||
pluginLogs: 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.
|
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||||
"terminal-restore-modes": true,
|
"terminal-restore-modes": true,
|
||||||
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
|
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ Start with [Orchestration](/docs/orchestration) if you want the mental model, or
|
|||||||
|
|
||||||
Two ways to install:
|
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.
|
- **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
|
## `/paseo`, Paseo Reference
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ const entries = [
|
|||||||
// Files read at runtime via fs APIs rather than `require`. nft only
|
// Files read at runtime via fs APIs rather than `require`. nft only
|
||||||
// traces the module graph; data files have to be listed explicitly.
|
// traces the module graph; data files have to be listed explicitly.
|
||||||
const additionalInputs = [
|
const additionalInputs = [
|
||||||
|
// Agent orchestration skill catalog loaded through filesystem paths
|
||||||
|
"packages/server/dist/server/skills/**",
|
||||||
// Shell integration scripts loaded by the terminal manager
|
// Shell integration scripts loaded by the terminal manager
|
||||||
"packages/server/dist/server/terminal/shell-integration/**",
|
"packages/server/dist/server/terminal/shell-integration/**",
|
||||||
// Silero VAD ONNX model (sherpa speech provider)
|
// Silero VAD ONNX model (sherpa speech provider)
|
||||||
|
|||||||
Reference in New Issue
Block a user