Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/electron/main/agent-extensions-ipc.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { registerPiSkillDiscoveryIpc } from "./pi-skill-discovery-ipc";
/**
* Electron IPC for plugin-contributed agent extensions (spec 07-plugins/16 §10.2).
*
Expand All @@ -20,6 +21,8 @@ export type AgentExtensionIpcDeps = NpmRecoveryDependencies & {
bridge: AgentExtensionBridge;
/** Directory the generated plugins live in, e.g. `<dataDir>/plugins/imported`. */
importRoot: string;
/** Registered imports, including disabled plugins; host storage is authoritative. */
getImportedDescriptions?: () => Promise<string[]>;
/** Register the generated directory as a development plugin. */
loadDevPlugin: (path: string) => Promise<unknown>;
/** Run a registered command in one session's sidecar Runner. */
Expand All @@ -28,6 +31,7 @@ export type AgentExtensionIpcDeps = NpmRecoveryDependencies & {

export function registerAgentExtensionIpc(deps: AgentExtensionIpcDeps): void {
const { handle, bridge } = deps;
registerPiSkillDiscoveryIpc(deps);

handle(
IPC.invoke.extensionsCommandRun,
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/electron/main/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,12 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) {
getNpmPath: () => readNpmPath(dataDir),
setNpmPath: (path) => writeNpmPath(dataDir, path),
importRoot: join(dataDir, "plugins", "imported"),
getImportedDescriptions: async () => {
const currentHost = getHost();
if (!currentHost) throw new Error("host unavailable");
const { plugins: registered } = await currentHost.call<{ plugins: import("@pi-desktop/shared").PluginSummary[] }>("plugins.list");
return registered.flatMap(plugin => plugin.description ? [plugin.description] : []);
},
loadDevPlugin: async (path) => {
const currentHost = getHost();
if (!currentHost) throw new Error("host unavailable");
Expand Down
53 changes: 53 additions & 0 deletions apps/desktop/electron/main/pi-skill-discovery-ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { catalogs, resolveLocale } from "@pi-desktop/i18n";
import { ErrorCodes, IPC } from "@pi-desktop/shared";
import type { AgentExtensionIpcDeps } from "./agent-extensions-ipc";
import { generateImportedExtensionPlugin } from "./agent-extensions";
import { installDependenciesWithNpmRecovery } from "./npm-install-recovery";
import { discoverPiSkillPackages } from "./pi-skill-discovery";

/** Native confirmation binds consent to a freshly discovered, main-owned path. */
export function registerPiSkillDiscoveryIpc(
deps: AgentExtensionIpcDeps,
modules = join(homedir(), ".pi", "agent", "npm", "node_modules"),
): void {
let importing = false;
const discover = async () => discoverPiSkillPackages(modules, await deps.getImportedDescriptions?.() ?? []);
deps.handle(IPC.invoke.piSkillDiscover, discover);
deps.handle(IPC.invoke.piSkillImport, async (input: { id?: unknown }) => {
if (typeof input?.id !== "string" || !/^[a-f0-9]{64}$/.test(input.id)) {
throw Object.assign(new Error("Invalid pi skill candidate"), { errorCode: ErrorCodes.INVALID_ARGUMENT });
}
if (importing) throw new Error("A pi skill import is already in progress");
importing = true;
try {
const candidate = (await discover()).candidates.find(item => item.id === input.id);
if (!candidate || candidate.imported) throw new Error("Candidate changed or was already imported; refresh the list");
const labels = catalogs[resolveLocale(deps.getLocale())].plugins;
const options = {
type: "warning" as const,
title: labels.piSkillsTitle,
message: labels.piSkillsConfirm,
detail: `${candidate.name}\n${candidate.path}\n\n${candidate.skills.join("\n")}\n\n${candidate.hasExtensions ? labels.piSkillsExecutable : labels.piSkillsPromptOnly}`,
buttons: [catalogs[resolveLocale(deps.getLocale())].common.cancel, labels.piSkillsImport],
defaultId: 0,
cancelId: 0,
noLink: true,
};
const window = deps.window();
const { response } = window && !window.isDestroyed()
? await deps.dialogs.showMessageBox(window, options)
: await deps.dialogs.showMessageBox(options);
if (response !== 1) return { canceled: true };
const current = (await discover()).candidates.find(item => item.id === candidate.id);
if (!current || current.imported) throw new Error("Candidate changed during confirmation; refresh the list");
const generated = generateImportedExtensionPlugin(current.path, deps.importRoot);
const dependencies = await installDependenciesWithNpmRecovery(generated.path, deps);
await deps.loadDevPlugin(generated.path);
return { canceled: false, ...generated, dependencies };
} finally {
importing = false;
}
});
}
71 changes: 71 additions & 0 deletions apps/desktop/electron/main/pi-skill-discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createHash } from "node:crypto";
import type { Dirent } from "node:fs";
import { lstat, readdir, readFile, realpath } from "node:fs/promises";
import { join } from "node:path";
import type { PiSkillDiscovery, PiSkillPackageCandidate } from "@pi-desktop/shared";
import { discoverImportedPackageSkills } from "./imported-package-skills";

const MAX_METADATA_BYTES = 256 * 1024;

async function readMetadata(path: string): Promise<string> {
const info = await lstat(path);
if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_METADATA_BYTES) {
throw new Error("Package metadata must be a regular file smaller than 256 KiB");
}
return readFile(path, "utf8");
}

/** Read only the installed package level, never dependencies or executable modules. */
export async function discoverPiSkillPackages(modules: string, importedDescriptions: string[] = []): Promise<PiSkillDiscovery> {
const candidates: PiSkillPackageCandidate[] = [];
const errors: string[] = [];
const importedSources = new Set(importedDescriptions);
const paths: string[] = [];
let entries: Dirent[];
try {
const info = await lstat(modules);
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("pi npm directory must not be a symbolic link");
modules = await realpath(modules);
entries = await readdir(modules, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { candidates, errors };
throw error;
}
// npm hoists ordinary dependencies beside installed pi packages. Their count
// must not hide valid skills; metadata reads below yield between packages.
for (const entry of entries) {
if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name.startsWith(".")) continue;
const path = join(modules, entry.name);
if (!entry.name.startsWith("@")) {
paths.push(path);
continue;
}
try {
for (const child of await readdir(path, { withFileTypes: true })) {
if (child.isDirectory() && !child.isSymbolicLink()) paths.push(join(path, child.name));
}
} catch (error) {
errors.push(`${path}: ${error instanceof Error ? error.message : String(error)}`);
}
}
for (const path of paths.sort()) {
try {
const raw = await readMetadata(join(path, "package.json"));
const metadata: unknown = JSON.parse(raw);
if (!metadata || typeof metadata !== "object" || !("name" in metadata) || typeof metadata.name !== "string") continue;
const { skills, skillsOnly } = discoverImportedPackageSkills(path);
if (!skills.length) continue;
candidates.push({
id: createHash("sha256").update(path).update(raw).update(JSON.stringify(skills)).digest("hex"),
name: metadata.name,
path,
skills,
hasExtensions: !skillsOnly,
imported: importedSources.has(`Imported pi extension from ${path}`),
});
} catch (error) {
errors.push(`${path}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return { candidates, errors };
}
2 changes: 2 additions & 0 deletions apps/desktop/src/components/settings/AgentSkillsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
IconPlus,
IconTrash,
} from "../icons";
import { PiSkillDiscoveryPanel } from "./PiSkillDiscoveryPanel";
import { SkillMarketPanel } from "./SkillMarketPanel";

import { TooltipButton } from "../ui";
Expand Down Expand Up @@ -538,6 +539,7 @@ export function AgentSkillsPage() {
/>
}
>
<PiSkillDiscoveryPanel />
<CapabilityPanel
loading={loading}
refreshing={refreshing}
Expand Down
58 changes: 58 additions & 0 deletions apps/desktop/src/components/settings/PiSkillDiscoveryPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { PiSkillDiscovery } from "@pi-desktop/shared";
import { api } from "../../lib/api";

export function PiSkillDiscoveryPanel() {
const { t } = useTranslation();
const [result, setResult] = useState<PiSkillDiscovery>({ candidates: [], errors: [] });
const [refresh, setRefresh] = useState(0);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
setLoading(true);
void api.discoverPiSkills().then(value => {
if (active) setResult(value);
}, reason => {
if (active) setError(String(reason));
}).finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, [refresh]);
async function enable(id: string) {
setBusy(true);
setError("");
try {
const imported = await api.importPiSkills(id);
if (!imported.canceled) {
if (imported.dependencies?.state === "failed") {
setError(t("plugins.importExtensionDepsFailed", { id: imported.id, error: imported.dependencies.error }));
}
setRefresh(value => value + 1);
}
} catch (reason) {
setError(String(reason));
// Host registration can succeed before the runtime fails to start.
// Rediscover so a registered import stays manageable through Plugins.
setRefresh(value => value + 1);
}
finally { setBusy(false); }
}
return <section className="settings-card-block pi-skill-discovery" aria-label={t("plugins.piSkillsTitle")}>
<h3 className="settings-card-heading">{t("plugins.piSkillsTitle")}</h3>
<p className="settings-row-detail">{t("plugins.piSkillsHint")}</p>
<button className="btn btn-secondary" disabled={loading || busy} onClick={() => { setError(""); setRefresh(value => value + 1); }}>{t("plugins.piSkillsRefresh")}</button>
{loading ? <p role="status">{t("plugins.piSkillsLoading")}</p> : result.candidates.length === 0 ? <p>{t("plugins.piSkillsEmpty")}</p> : result.candidates.map(candidate => <div key={candidate.id} className="settings-row">
<div className="settings-row-copy">
<strong className="settings-row-title">{candidate.name}</strong>
<div className="settings-row-detail">{candidate.path}</div>
<div className="settings-row-detail">{candidate.skills.join(", ")}</div>
{candidate.hasExtensions && <p>{t("plugins.piSkillsExecutable")}</p>}
</div>
<button className="btn btn-secondary" disabled={busy || candidate.imported} onClick={() => { void enable(candidate.id); }}>{t(candidate.imported ? "plugins.piSkillsImported" : "plugins.piSkillsImport")}</button>
</div>)}
{error && <p role="alert">{error}</p>}
{result.errors.map(message => <p role="alert" key={message}>{message}</p>)}
</section>;
}
3 changes: 3 additions & 0 deletions apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1259,6 +1259,9 @@ export const api = {
/** Ask the running install to stop. Only a download can be interrupted. */
marketCancelInstall: (id: string) =>
invoke<{ cancelled: boolean; id: string }>(IPC.invoke.marketCancelInstall, { id }),
/** Read-only discovery never grants package permissions. */
discoverPiSkills: () => invoke<import("@pi-desktop/shared").PiSkillDiscovery>(IPC.invoke.piSkillDiscover),
importPiSkills: (id: string) => invoke<{ canceled: boolean; id?: string; dependencies?: { state: string; error?: string } }>(IPC.invoke.piSkillImport, { id }),
/** Import a pi CLI extension file or directory as a development plugin (spec 16 §3). */
importPiExtension: () =>
invoke<
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/styles/settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -3788,3 +3788,15 @@
.sklm-page-jump::placeholder {
color: var(--ds-text-muted);
}

/* Discovered local package paths remain readable at narrow settings widths. */
.pi-skill-discovery .settings-row-detail {
overflow-wrap: anywhere;
}
.pi-skill-discovery .settings-row {
margin-top: 8px;
flex-wrap: wrap;
}
.pi-skill-discovery .settings-row-copy {
flex-basis: 240px;
}
Loading
Loading