diff --git a/apps/desktop/electron/main/agent-extensions-ipc.ts b/apps/desktop/electron/main/agent-extensions-ipc.ts index 90a5a51031..a6ccddfa45 100644 --- a/apps/desktop/electron/main/agent-extensions-ipc.ts +++ b/apps/desktop/electron/main/agent-extensions-ipc.ts @@ -1,3 +1,4 @@ +import { registerPiSkillDiscoveryIpc } from "./pi-skill-discovery-ipc"; /** * Electron IPC for plugin-contributed agent extensions (spec 07-plugins/16 §10.2). * @@ -20,6 +21,8 @@ export type AgentExtensionIpcDeps = NpmRecoveryDependencies & { bridge: AgentExtensionBridge; /** Directory the generated plugins live in, e.g. `/plugins/imported`. */ importRoot: string; + /** Registered imports, including disabled plugins; host storage is authoritative. */ + getImportedDescriptions?: () => Promise; /** Register the generated directory as a development plugin. */ loadDevPlugin: (path: string) => Promise; /** Run a registered command in one session's sidecar Runner. */ @@ -28,6 +31,7 @@ export type AgentExtensionIpcDeps = NpmRecoveryDependencies & { export function registerAgentExtensionIpc(deps: AgentExtensionIpcDeps): void { const { handle, bridge } = deps; + registerPiSkillDiscoveryIpc(deps); handle( IPC.invoke.extensionsCommandRun, diff --git a/apps/desktop/electron/main/ipc/register.ts b/apps/desktop/electron/main/ipc/register.ts index 09a147eff9..93ecb50e48 100644 --- a/apps/desktop/electron/main/ipc/register.ts +++ b/apps/desktop/electron/main/ipc/register.ts @@ -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"); diff --git a/apps/desktop/electron/main/pi-skill-discovery-ipc.ts b/apps/desktop/electron/main/pi-skill-discovery-ipc.ts new file mode 100644 index 0000000000..032ef724a3 --- /dev/null +++ b/apps/desktop/electron/main/pi-skill-discovery-ipc.ts @@ -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; + } + }); +} diff --git a/apps/desktop/electron/main/pi-skill-discovery.ts b/apps/desktop/electron/main/pi-skill-discovery.ts new file mode 100644 index 0000000000..dba90fa487 --- /dev/null +++ b/apps/desktop/electron/main/pi-skill-discovery.ts @@ -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 { + 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 { + 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 }; +} diff --git a/apps/desktop/src/components/settings/AgentSkillsPage.tsx b/apps/desktop/src/components/settings/AgentSkillsPage.tsx index 98747d8804..f149799282 100644 --- a/apps/desktop/src/components/settings/AgentSkillsPage.tsx +++ b/apps/desktop/src/components/settings/AgentSkillsPage.tsx @@ -42,6 +42,7 @@ import { IconPlus, IconTrash, } from "../icons"; +import { PiSkillDiscoveryPanel } from "./PiSkillDiscoveryPanel"; import { SkillMarketPanel } from "./SkillMarketPanel"; import { TooltipButton } from "../ui"; @@ -538,6 +539,7 @@ export function AgentSkillsPage() { /> } > + ({ 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
+

{t("plugins.piSkillsTitle")}

+

{t("plugins.piSkillsHint")}

+ + {loading ?

{t("plugins.piSkillsLoading")}

: result.candidates.length === 0 ?

{t("plugins.piSkillsEmpty")}

: result.candidates.map(candidate =>
+
+ {candidate.name} +
{candidate.path}
+
{candidate.skills.join(", ")}
+ {candidate.hasExtensions &&

{t("plugins.piSkillsExecutable")}

} +
+ +
)} + {error &&

{error}

} + {result.errors.map(message =>

{message}

)} +
; +} diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index ceb3fecb32..026efd406b 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -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(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< diff --git a/apps/desktop/src/styles/settings.css b/apps/desktop/src/styles/settings.css index f674e14151..47bb314858 100644 --- a/apps/desktop/src/styles/settings.css +++ b/apps/desktop/src/styles/settings.css @@ -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; +} diff --git a/apps/desktop/test/pi-skill-discovery.test.mjs b/apps/desktop/test/pi-skill-discovery.test.mjs new file mode 100644 index 0000000000..57cbee7bd2 --- /dev/null +++ b/apps/desktop/test/pi-skill-discovery.test.mjs @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { register } from 'node:module'; +import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync, symlinkSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +register(new URL('./helpers/ts-import-hooks.mjs', import.meta.url)); +const { discoverPiSkillPackages } = await import('../electron/main/pi-skill-discovery.ts'); +const { generateImportedExtensionPlugin } = await import('../electron/main/agent-extensions.ts'); +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), 'pi-discovery-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const modules = join(root, '.pi/agent/npm/node_modules'); + const imports = join(root, 'imports'); + const write = (name, path, body) => { const target = join(modules, name, path); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, body); }; + const pkg = (name) => { write(name, 'package.json', JSON.stringify({ name, pi: { skills: ['SKILL.md'] } })); write(name, 'SKILL.md', '---\nname: planning\ndescription: Plan work\n---\nRead references/guide.md'); write(name, 'references/guide.md', '# Guide'); }; + return { root, modules, imports, write, pkg }; +} +test('discovers installed plain and scoped packages without importing or executing them', async t => { + const f = fixture(t); f.pkg('planning-with-files'); f.pkg('@scope/skills'); + f.write('planning-with-files', 'index.js', "throw new Error('must not execute')"); + const result = await discoverPiSkillPackages(f.modules); + assert.deepEqual(result.candidates.map(c => c.name), ['@scope/skills', 'planning-with-files']); + assert.ok(result.candidates.every(c => c.skills.length === 1 && !c.hasExtensions && !c.imported)); + assert.equal(existsSync(f.imports), false); +}); +test('hoisted npm dependencies do not prevent discovery of installed skills', async t => { + const f = fixture(t); + for (let index = 0; index < 300; index++) { + const name = `dependency-${index}`; + f.write(name, 'package.json', JSON.stringify({ name, version: '1.0.0' })); + } + f.pkg('planning-with-files'); + f.pkg('@scope/skills'); + const result = await discoverPiSkillPackages(f.modules); + assert.deepEqual(result.candidates.map(candidate => candidate.name), ['@scope/skills', 'planning-with-files']); + assert.deepEqual(result.errors, []); + assert.equal(existsSync(f.imports), false); +}); +test('one malformed package does not hide healthy candidates; links are not followed', async t => { + const f = fixture(t); f.pkg('healthy'); f.write('broken', 'package.json', '{'); + symlinkSync(join(f.modules, 'healthy'), join(f.modules, 'linked'), process.platform === 'win32' ? 'junction' : 'dir'); + const result = await discoverPiSkillPackages(f.modules); + assert.deepEqual(result.candidates.map(c => c.name), ['healthy']); + assert.ok(result.errors.some(e => e.includes('broken'))); +}); +test('an unreadable scope reports a diagnostic without hiding healthy packages', { skip: process.platform === 'win32' || process.getuid?.() === 0 }, async t => { + const f = fixture(t); f.pkg('healthy'); + const blocked = join(f.modules, '@blocked'); + mkdirSync(blocked); chmodSync(blocked, 0); + try { + const result = await discoverPiSkillPackages(f.modules); + assert.deepEqual(result.candidates.map(candidate => candidate.name), ['healthy']); + assert.ok(result.errors.some(error => error.includes('@blocked') && error.includes('EACCES'))); + } finally { chmodSync(blocked, 0o755); } +}); +test('existing imports remain recognized after rediscovery without a second registry', async t => { + const f = fixture(t); f.pkg('planning-with-files'); + const imported = generateImportedExtensionPlugin(join(f.modules, 'planning-with-files'), f.imports); + const { readFileSync } = await import("node:fs"); + const description = JSON.parse(readFileSync(join(imported.path, "manifest.json"), "utf8")).description; + const result = await discoverPiSkillPackages(f.modules, [description]); + assert.equal((await discoverPiSkillPackages(f.modules)).candidates[0].imported, false, "unregistered leftover directories must not block import"); + assert.equal(result.candidates[0].imported, true); +}); +test('missing npm directory is empty, not a failure', async t => { + const f = fixture(t); + assert.deepEqual(await discoverPiSkillPackages(f.modules), { candidates: [], errors: [] }); +}); + +const { registerPiSkillDiscoveryIpc } = await import('../electron/main/pi-skill-discovery-ipc.ts'); +const { IPC } = await import('../../../packages/shared/dist/index.js'); +const { PluginRuntime } = await import('../electron/main/plugin-runtime.ts'); +const { fork } = await import('node:child_process'); +const { fileURLToPath } = await import('node:url'); +function harness(f, t, confirm = async () => ({ response: 1 })) { + const handlers = new Map(); + const descriptions = []; + const runtime = new PluginRuntime({ + hostEntry: fileURLToPath(new URL('../electron/main/plugin-host-process.mjs', import.meta.url)), + spawnProcess: ({ entry }) => { + const child = fork(entry, [], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }); + return { postMessage: m => { if (child.connected) child.send(m); }, onMessage: h => child.on('message', h), onExit: h => child.on('exit', c => h(c ?? 0)), kill: () => child.kill() }; + }, audit: () => {}, + }); + t.after(async () => { for (const item of runtime.listLoaded()) await runtime.unload(item.manifest.id); runtime.disposeWatchers(); }); + registerPiSkillDiscoveryIpc({ + handle: (key, fn) => handlers.set(key, fn), importRoot: f.imports, + bridge: { respond: () => false }, window: () => null, + dialogs: { showMessageBox: confirm }, getLocale: () => 'en', + getNpmPath: () => undefined, setNpmPath: () => {}, + getImportedDescriptions: async () => descriptions, + loadDevPlugin: async path => { + const { readFileSync } = await import("node:fs"); + // Production persists host registration before starting the plugin child. + descriptions.push(JSON.parse(readFileSync(join(path, "manifest.json"), "utf8")).description); + return runtime.loadFromPath(path); + }, + runCommand: async () => ({ handled: false }), + }, f.modules); + return { runtime, discover: () => handlers.get(IPC.invoke.piSkillDiscover)(), enable: id => handlers.get(IPC.invoke.piSkillImport)({ id }) }; +} +test('discover → cancel → confirm → read skill and resources → reload; duplicate import refused', async t => { + const f = fixture(t); f.pkg('planning-with-files'); + let consent = false; + const h = harness(f, t, async options => { assert.equal(options.defaultId, 0); return { response: consent ? 1 : 0 }; }); + const { candidates } = await h.discover(); + assert.deepEqual(h.runtime.getSkills(), []); + assert.deepEqual(await h.enable(candidates[0].id), { canceled: true }); + assert.equal(existsSync(f.imports), false); + consent = true; + const imported = await h.enable(candidates[0].id); + assert.equal(imported.canceled, false); + const skill = h.runtime.getSkills()[0]; + assert.equal(skill.name, 'planning'); + assert.equal(h.runtime.loadSkillBody(skill.id).body, 'Read references/guide.md'); + const { readFileSync } = await import('node:fs'); + assert.equal(readFileSync(join(imported.path, 'src/references/guide.md'), 'utf8'), '# Guide'); + assert.equal((await h.discover()).candidates[0].imported, true); + await assert.rejects(h.enable(candidates[0].id), /already imported/); + await h.runtime.unload(imported.id); + assert.deepEqual(h.runtime.getSkills(), []); + await h.runtime.loadFromPath(imported.path); + assert.equal(h.runtime.loadSkillBody(skill.id).body, 'Read references/guide.md'); +}); +test('metadata change during native confirmation invalidates consent', async t => { + const f = fixture(t); f.pkg('planning-with-files'); + const h = harness(f, t, async () => { + f.write('planning-with-files', 'package.json', JSON.stringify({ name: 'planning-with-files', pi: { skills: ['SKILL.md'], extensions: ['index.ts'] } })); + return { response: 1 }; + }); + const { candidates } = await h.discover(); + await assert.rejects(h.enable(candidates[0].id), /changed during confirmation/); + assert.equal(existsSync(f.imports), false); +}); +test('renderer cannot supply arbitrary paths or import concurrent confirmations', async t => { + const f = fixture(t); f.pkg('planning-with-files'); + let release; + let opened; + const ready = new Promise(resolve => { opened = resolve; }); + const h = harness(f, t, () => new Promise(resolve => { release = resolve; opened(); })); + await assert.rejects(h.enable('/tmp/arbitrary'), /Invalid/); + const { candidates } = await h.discover(); + const first = h.enable(candidates[0].id); + await ready; + await assert.rejects(h.enable(candidates[0].id), /already in progress/); + release({ response: 0 }); + await first; +}); + +test('published planning-with-files package follows discovery and confirmed import', { skip: !process.env.PI_SKILL_PACKAGE_FIXTURE }, async t => { + const f = fixture(t); + const { cpSync, readFileSync } = await import('node:fs'); + mkdirSync(f.modules, { recursive: true }); + cpSync(process.env.PI_SKILL_PACKAGE_FIXTURE, join(f.modules, 'planning-with-files'), { recursive: true }); + const h = harness(f, t, async options => { + assert.match(options.detail, /executable extensions/); + return { response: 1 }; + }); + const { candidates, errors } = await h.discover(); + assert.deepEqual(errors, []); + assert.equal(candidates[0].hasExtensions, true); + const imported = await h.enable(candidates[0].id); + assert.equal(imported.dependencies.state, 'skipped'); + const skill = h.runtime.getSkills().find(s => s.name === 'pi-planning-with-files'); + assert.ok(skill, 'the reported package skill reaches the runtime catalog'); + assert.ok(h.runtime.loadSkillBody(skill.id).body.length > 100); + assert.equal(readFileSync(join(imported.path, 'src/SKILL.md'), 'utf8'), readFileSync(join(f.modules, 'planning-with-files/SKILL.md'), 'utf8')); + assert.equal(h.runtime.getAgentExtensions().length, 1); +}); diff --git a/docs/adr/0214-trusted-extensions.md b/docs/adr/0214-trusted-extensions.md index 7de878157a..467f107a44 100644 --- a/docs/adr/0214-trusted-extensions.md +++ b/docs/adr/0214-trusted-extensions.md @@ -38,8 +38,10 @@ replace the desktop runtime with `pi-coding-agent`'s `AgentSession`. theme and is not reused.) 2. **Trusted, opt-in, no auto-import.** Extensions are labelled "Trusted extension", run with sidecar trust, and are disabled until the user enables - each one. D007 is unchanged: `~/.pi` is scanned for candidates, never - imported. Project-scoped extensions are enabled per project. + each one. D007 forbids silent import. Candidate discovery is limited to + installed npm skill packages, with native confirmation before import + (ADR pi-npm-skill-discovery); general `~/.pi` discovery is not implemented. + Project-scoped extensions are enabled per project. 3. **Explicit support classes.** Every `ExtensionAPI` member is Supported, Deferred, or Unsupported. Unsupported members are inert and produce diagnostics; they never throw. Terminal-UI surfaces stay Unsupported. diff --git a/docs/adr/README.md b/docs/adr/README.md index a1729f3fa7..7c9c22ae7e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -20,6 +20,7 @@ Each ADR includes: | ID | Title | Status | |---|---|---| +| pi-npm-skill-discovery | [Discover installed pi skills before explicit import](pi-npm-skill-discovery.md) | Accepted for implementation | | trusted-extension-operation-ownership | [Trusted extension operation ownership](trusted-extension-operation-ownership.md) | Implemented candidate | | scheduled-desktop-automations | [Desktop automation scheduling](scheduled-desktop-automations.md) | Accepted for implementation | | subagent-model-fallback | [Ordered subagent model fallback](subagent-model-fallback.md) | Accepted for implementation | diff --git a/docs/adr/pi-npm-skill-discovery.md b/docs/adr/pi-npm-skill-discovery.md new file mode 100644 index 0000000000..b4b7d38b66 --- /dev/null +++ b/docs/adr/pi-npm-skill-discovery.md @@ -0,0 +1,53 @@ +# ADR pi-npm-skill-discovery: Discover installed pi skills before explicit import + +- Status: Accepted for implementation +- Date: 2026-09-21 +- Related: Issue #236, ADR 0112, ADR 0214, ADR 0215 + +## Context + +A pi CLI npm package can declare skills that Desktop cannot discover. The +existing importer already preserves these skills and their resources, but +requires the user to locate a hidden package directory. Automatically loading +these packages would bypass the explicit trust decision, particularly for +packages that also contain executable extensions. + +## Decision + +The Skills page requests read-only candidates from the installed package level +of `~/.pi/agent/npm/node_modules`, including scoped packages. Electron main +reuses the existing `pi.skills` declaration parser. Discovery neither imports +nor executes code. Package metadata is bounded to 256 KiB and contribution +traversal retains the importer's bounds and path validation. Metadata reads +are asynchronous; hoisted npm dependencies do not impose a skill-discovery +cutoff. Invalid packages and unreadable scopes produce diagnostics without +hiding valid peers. + +The renderer sends a candidate identifier, never a source path. Main rediscovers +the candidate, displays native confirmation including its source and whether it +contains executable extensions, and revalidates its metadata and skill paths +before using the existing import, restricted dependency installation, host +registration and plugin runtime path. Cancellation is the default. Concurrent +imports are rejected. Existing registered imports, including disabled plugins, +are identified using host-owned plugin descriptions; orphaned import directories +are not an enablement registry and do not block retries. + +This narrowly amends the discovery wording of ADR 0214. ADR 0112's `.agents` +capability roots are unchanged: candidates are not enabled user-skill records. +No automatic import, CLI configuration mutation, background synchronization, +or new database schema is introduced. Broader pi package management remains +outside this change. + +## Consequences + +Users can find and explicitly import the package reported in #236 from Skills. +Imported packages remain managed in Plugins, where disable, reload and uninstall +retain their existing behavior. Source changes are not automatically copied. +Only the installed global npm package location is covered; this does not promise +support for every CLI custom path, linked package or third-party extension API. + +## Alternatives + +- Keep manual directory selection: preserves trust but leaves discovery missing. +- Load all discovered skills: rejected because it silently changes model input + and may enable executable package contributions without consent. diff --git a/docs/project/unreleased.md b/docs/project/unreleased.md index 30c029cd75..7786baa781 100644 --- a/docs/project/unreleased.md +++ b/docs/project/unreleased.md @@ -1,5 +1,9 @@ # Unreleased changes +- Skills now discovers installed pi CLI npm skill packages and offers explicit + import with a source and executable-extension confirmation. Imported packages + remain managed in Plugins; discovery never enables code automatically. + - Subagent topology cards and their live process rows now follow the main conversation's responsive width behavior: long descriptions, paths, commands, and summaries wrap inside the dock instead of requiring repeated diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 5ffa65dbff..26aa76e423 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -8410,7 +8410,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. | M5 (Chat file references) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file, E2E-CHAT-file-ref-opens-the-surface-that-owns-it | | M6+ (Chat file references) | E2E-PLUGIN-file-view-collapse-persists | | M6+ (project folder roots) | E2E-PLUGIN-file-view-switches-folder-per-project | -| Post-MVP | E2E-022A, E2E-022B, E2E-022C, E2E-024I, E2E-024J, E2E-024K, E2E-024L, E2E-024M (plugin roadmap R2/R3/R6) | +| Post-MVP | E2E-PLUGIN-pi-npm-skill-discovery, E2E-022A, E2E-022B, E2E-022C, E2E-024I, E2E-024J, E2E-024K, E2E-024L, E2E-024M (plugin roadmap R2/R3/R6) | | Post-baseline local automation | E2E-220 | | Post-MVP remote control | E2E-221, E2E-222, E2E-223, E2E-224, E2E-225, E2E-226, E2E-227, E2E-228, E2E-229, E2E-230, E2E-231, E2E-232 | | Trusted extensions (R7 v1) | E2E-DIALOG-long-text-boundaries, E2E-241, E2E-242, E2E-HOOKS-cancel-and-dispose, E2E-TRUSTED-EXTENSION-custom-agent-stream-and-binding, E2E-243, E2E-244, E2E-245, E2E-PLUGIN-imported-pi-package-skills, E2E-PLUGIN-import-extension-installs-dependencies, E2E-PLUGIN-import-extension-reports-missing-dependency, E2E-PLUGIN-declared-provider-appears-in-the-native-provider-list | @@ -14892,6 +14892,30 @@ the latest destination. These assertions measure work counts, not device FPS. scripts/e2e-scheduled-workspace.mjs`, using production Electron dispatch and real Rust/stdio/SQLite. Only external inference is replaced with an observer. +### E2E-PLUGIN-pi-npm-skill-discovery + +- **Preconditions:** Isolated npm package directory and plugin import storage; + a package declaring `pi.skills`, optionally executable extensions. No provider. +- **Steps:** Open Skills, discover the candidate, cancel import, confirm import, + read the registered skill body and resources, reload, and attempt a duplicate. + Change metadata during confirmation; retry discovery after an error; discover + with more than 256 hoisted dependencies and an unreadable scope. Simulate a + runtime load failure after host registration and inspect the refreshed state. +- **Expected:** No implicit import/execution, native explicit consent, preserved + resources and runtime skill body, stable imported state, no duplicate import, + stale consent refusal, and visible/recoverable errors. Unregistered leftover + directories do not count as imports; scoped packages are discovered. Unrelated + dependencies and unreadable scopes do not hide healthy skills. A registered + import remains marked imported after runtime failure while its error stays visible. +- **Specs:** 07-plugins/16-trusted-extensions; ADR pi-npm-skill-discovery. +- **Acceptance:** Plugin skill discovery and explicit trust boundary. +- **Milestone:** Post-MVP compatibility. +- **Status:** Automated via `apps/desktop/test/pi-skill-discovery.test.mjs` (real + import and plugin child process, native dialog boundary controlled) and + `node scripts/e2e-pi-skill-discovery-ui.mjs` (real React/Chromium panel with + controlled IPC results). Optional `PI_SKILL_PACKAGE_FIXTURE` points to an + unpacked published package for the reported planning-with-files path. + ### E2E-SESSION-temporary-attachment-fork: Preview and independent branch inputs - **Steps**: In a task without a project, paste text above the long-paste diff --git a/docs/spec/07-plugins/16-trusted-extensions.md b/docs/spec/07-plugins/16-trusted-extensions.md index 770a0b8a8d..9d6d601b90 100644 --- a/docs/spec/07-plugins/16-trusted-extensions.md +++ b/docs/spec/07-plugins/16-trusted-extensions.md @@ -199,15 +199,38 @@ and removes a partial copy on failure. The generated destination is created atomically and must not be inside the selected source. This is an explicit local import, not a pi CLI package manager. It never -automatically scans or imports `~/.pi`, does not read the CLI's installed -package registry, and does not run npm lifecycle scripts. When dependencies +automatically imports `~/.pi`, does not read the CLI's installed package +registry, and does not run npm lifecycle scripts. The Skills page separately +discovers global npm skill candidates as described below. When dependencies are declared, the bounded installer accepts only registry version specs and registry-resolved npm lockfiles, rejects unsafe package locations and nested dependency specs, disables git resolution, and isolates npm's config/cache from the user's credentials and proxy settings. Importing a package does not promise that every third-party extension dependency can execute. -## 4. Loading and runtime +### Installed npm skill candidates (issue #236) + +Settings → Skills lists read-only candidates from +`~/.pi/agent/npm/node_modules`, including scoped packages. Candidates display +package name, source path, declared skill paths, and a warning when executable +extensions are included. Discovery grants no permissions and does not execute +package code. Refresh retries discovery; invalid packages show diagnostics +without suppressing healthy candidates, including when a scoped directory is +unreadable. Hoisted npm dependencies do not cap discovery; metadata reads are +asynchronous and must read a regular file no larger than 256 KiB. Symbolic package +links are not followed. The existing contribution parser enforces path bounds. + +Import and enable asks for native confirmation (Cancel is the default), then +uses the same importer, dependency policy, registration, and runtime as manual +import. The renderer sends only a candidate id. Main rediscovers before and +after confirmation, rejecting stale metadata, changed declarations, arbitrary +paths and concurrent imports. Registered imported packages are marked Already +imported, including when disabled; manage them in Plugins. Unregistered leftover +directories do not block retry. If host registration succeeds but runtime loading +fails, the error remains visible and the panel refreshes the registered state; +recovery uses Plugins reload or app restart. No second persisted enablement registry exists. +No schema or host RPC version changes. General CLI configuration discovery and +source-update synchronization remain outside scope. See ADR pi-npm-skill-discovery. ## 4. Loading and runtime diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 434b3daef2..c38513753c 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -5375,7 +5375,7 @@ eleven-tool-round desktop paths are verified by | M5(聊天文件引用) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file、E2E-CHAT-file-ref-opens-the-surface-that-owns-it | | M6+(聊天文件引用) | E2E-PLUGIN-file-view-collapse-persists | | M6+(项目文件夹根) | E2E-PLUGIN-file-view-switches-folder-per-project | -| 后MVP | E2E-022A、E2E-022B、E2E-022C、E2E-024I、E2E-024J、E2E-024K、E2E-024L、E2E-024M(插件路线图 R2/R3/R6) | +| 后MVP | E2E-PLUGIN-pi-npm-skill-discovery, E2E-022A、E2E-022B、E2E-022C、E2E-024I、E2E-024J、E2E-024K、E2E-024L、E2E-024M(插件路线图 R2/R3/R6) | | 基线后本地自动化 | E2E-220 | | MVP 后远程控制 | E2E-221、E2E-222、E2E-223、E2E-224、E2E-225、E2E-226、E2E-227、E2E-228、E2E-229、E2E-230、E2E-231、E2E-232 | | 受信任扩展(R7 v1) | E2E-DIALOG-long-text-boundaries、E2E-241、E2E-242、E2E-HOOKS-cancel-and-dispose、E2E-243、E2E-244、E2E-245、E2E-PLUGIN-imported-pi-package-skills、E2E-PLUGIN-import-extension-installs-dependencies、E2E-PLUGIN-import-extension-reports-missing-dependency、E2E-PLUGIN-declared-provider-appears-in-the-native-provider-list | @@ -8701,6 +8701,30 @@ the latest destination. These assertions measure work counts, not device FPS. 宿主 RPC 路径由 `scripts/e2e-smoke.mjs` 覆盖提供商的创建与列举,但没有套件 驱动手工编辑的 `config_json`。 +### E2E-PLUGIN-pi-npm-skill-discovery + +- **Preconditions:** Isolated npm package directory and plugin import storage; + a package declaring `pi.skills`, optionally executable extensions. No provider. +- **Steps:** Open Skills, discover the candidate, cancel import, confirm import, + read the registered skill body and resources, reload, and attempt a duplicate. + Change metadata during confirmation; retry discovery after an error; discover + with more than 256 hoisted dependencies and an unreadable scope. Simulate a + runtime load failure after host registration and inspect the refreshed state. +- **Expected:** No implicit import/execution, native explicit consent, preserved + resources and runtime skill body, stable imported state, no duplicate import, + stale consent refusal, and visible/recoverable errors. Unregistered leftover + directories do not count as imports; scoped packages are discovered. Unrelated + dependencies and unreadable scopes do not hide healthy skills. A registered + import remains marked imported after runtime failure while its error stays visible. +- **Specs:** 07-plugins/16-trusted-extensions; ADR pi-npm-skill-discovery. +- **Acceptance:** Plugin skill discovery and explicit trust boundary. +- **Milestone:** Post-MVP compatibility. +- **Status:** Automated via `apps/desktop/test/pi-skill-discovery.test.mjs` (real + import and plugin child process, native dialog boundary controlled) and + `node scripts/e2e-pi-skill-discovery-ui.mjs` (real React/Chromium panel with + controlled IPC results). Optional `PI_SKILL_PACKAGE_FIXTURE` points to an + unpacked published package for the reported planning-with-files path. + ### E2E-SESSION-temporary-attachment-fork:临时任务预览与独立分支附件 - **步骤**:在没有项目的任务中粘贴超过长文本阈值的内容并发送,点击对话中的附件。 diff --git a/docs/zh-CN/spec/07-plugins/16-trusted-extensions.md b/docs/zh-CN/spec/07-plugins/16-trusted-extensions.md index d03cb8851b..9c9b257f48 100644 --- a/docs/zh-CN/spec/07-plugins/16-trusted-extensions.md +++ b/docs/zh-CN/spec/07-plugins/16-trusted-extensions.md @@ -112,11 +112,28 @@ Agent 扩展。 包内依赖目录。绝对 `pi.skills` 路径与后代符号链接会被拒绝;复制保留资源时也拒绝 符号链接,复制失败会清理部分生成的目录。生成目标以原子方式创建,不能位于所选源目录内。 -这是显式本地导入,不是 pi CLI 包管理器:不会自动扫描或导入 `~/.pi`,不会读取 CLI +这是显式本地导入,不是 pi CLI 包管理器:不会自动导入 `~/.pi`,不会读取 CLI 已安装包注册表,也不会执行 npm 生命周期脚本。声明依赖时,有界安装器只接受 registry 版本说明和 registry 来源的 npm lockfile,拒绝不安全的包路径和嵌套依赖 spec,禁用 git 解析,并隔离 npm 的配置/cache 与用户凭据和代理设置。导入包不代表其所有第三方扩展依赖都能执行。 +### 已安装 npm 技能候选(issue #236) + +设置 → 技能只读扫描 `~/.pi/agent/npm/node_modules` 下的普通包和作用域包, +展示包名、来源、技能路径以及可执行扩展警告。扫描不导入、不执行、不授予权限。 +逐包异步读取元数据,提升到同级目录的 npm 依赖数量不会截断技能发现; +元数据必须是至多 256 KiB 的普通文件,不跟随包符号链接; +技能声明沿用已有路径校验。损坏包和不可读的作用域目录显示诊断,不影响其他有效候选;刷新可重试。 + +“导入并启用”先显示原生确认,默认取消,再复用手动导入、受限依赖安装、宿主注册 +及插件运行时。渲染层只提交候选标识,主进程在确认前后重新发现,拒绝声明变更、 +任意路径及并发导入。已注册的导入包(含已禁用包)显示“已导入”,后续在插件页 +管理;未注册的残留目录不阻止重试。不新增启用状态库、不变更数据 schema 或宿主 +RPC 版本。通用 CLI 配置扫描和来源更新同步不在本次范围。参见 +[ADR pi-npm-skill-discovery](/adr/pi-npm-skill-discovery)。 + +若主机登记成功但运行时加载失败,页面保留错误并刷新登记状态;可在 Plugins 中重新加载或重启应用恢复。 + ## 4. 加载与运行时 ### 4.1 扩展在哪里运行 diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index ab893c444d..728a40de51 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -1795,6 +1795,16 @@ sklm: { "title": "Erweiterungen", "loadDev": "Lokales Plugin laden", "loadDevDone": "Lokales Plugin geladen", + piSkillsTitle: "Skills aus pi CLI", + piSkillsHint: "Installierte npm-Pakete werden nur gelesen. Importieren Sie nur vertrauenswürdige Pakete; verwalten Sie sie anschließend unter Plugins.", + piSkillsConfirm: "Dieses pi-Paket importieren und aktivieren?", + piSkillsExecutable: "Dieses Paket enthält ausführbare Erweiterungen. Der Import aktiviert vertrauenswürdigen Code im Agenten und installiert möglicherweise Abhängigkeiten.", + piSkillsPromptOnly: "Dieses Paket fügt dem Agenten Anweisungen hinzu. Ressourcen werden kopiert; deklarierte Abhängigkeiten werden gegebenenfalls installiert.", + piSkillsImport: "Importieren und aktivieren", + piSkillsImported: "Bereits importiert", + piSkillsRefresh: "pi-CLI-Skills aktualisieren", + piSkillsEmpty: "Keine pi-CLI-npm-Skill-Pakete gefunden.", + piSkillsLoading: "pi-CLI-Skills werden gesucht…", "importExtension": "pi-Erweiterung importieren", "importExtensionDone": "Als Plugin {{id}} importiert", "importExtensionDepsFailed": "Als Plugin {{id}} importiert, aber die Abhängigkeiten konnten nicht installiert werden: {{error}}", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index ee34873a15..1b38b008b3 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1814,6 +1814,16 @@ sklm: { title: "Extensions", loadDev: "Load local plugin", loadDevDone: "Local plugin loaded", + piSkillsTitle: "Skills from pi CLI", + piSkillsHint: "Installed npm packages are discovered read-only. Import only packages you trust; manage imported packages in Plugins.", + piSkillsConfirm: "Import and enable this pi package?", + piSkillsExecutable: "This package includes executable extensions. Importing enables trusted code in the agent runtime and may install dependencies.", + piSkillsPromptOnly: "This package adds instructions to the agent. Its resources will be copied; declared dependencies may be installed.", + piSkillsImport: "Import and enable", + piSkillsImported: "Already imported", + piSkillsRefresh: "Refresh pi CLI skills", + piSkillsEmpty: "No pi CLI npm skill packages found.", + piSkillsLoading: "Looking for pi CLI skills…", importExtension: "Import pi extension", importExtensionDone: "Imported as plugin {{id}}", importExtensionDepsFailed: "Imported as plugin {{id}}, but installing dependencies failed: {{error}}", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 1c0fc133b0..ca06fc3f23 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -1795,6 +1795,16 @@ sklm: { "title": "Extensiones", "loadDev": "Cargar complemento local", "loadDevDone": "Complemento local cargado", + piSkillsTitle: "Skills de pi CLI", + piSkillsHint: "Los paquetes npm instalados se detectan en modo de solo lectura. Importe únicamente paquetes de confianza y gestiónelos en Plugins.", + piSkillsConfirm: "¿Importar y activar este paquete pi?", + piSkillsExecutable: "Este paquete incluye extensiones ejecutables. La importación habilita código de confianza en el agente y puede instalar dependencias.", + piSkillsPromptOnly: "Este paquete añade instrucciones al agente. Se copiarán los recursos y se podrán instalar las dependencias declaradas.", + piSkillsImport: "Importar y activar", + piSkillsImported: "Ya importado", + piSkillsRefresh: "Actualizar skills de pi CLI", + piSkillsEmpty: "No se encontraron paquetes npm de skills de pi CLI.", + piSkillsLoading: "Buscando skills de pi CLI…", "importExtension": "Importar extensión de pi", "importExtensionDone": "Importada como complemento {{id}}", "importExtensionDepsFailed": "Importada como complemento {{id}}, pero falló la instalación de dependencias: {{error}}", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index ec32247ed9..9a918b2cac 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -1795,6 +1795,16 @@ sklm: { "title": "Extensions", "loadDev": "Charger le plugin local", "loadDevDone": "Plugin local chargé", + piSkillsTitle: "Skills de pi CLI", + piSkillsHint: "Les paquets npm installés sont détectés en lecture seule. Importez uniquement des paquets fiables, puis gérez-les dans Plugins.", + piSkillsConfirm: "Importer et activer ce paquet pi ?", + piSkillsExecutable: "Ce paquet contient des extensions exécutables. Son import active du code de confiance dans l’agent et peut installer des dépendances.", + piSkillsPromptOnly: "Ce paquet ajoute des instructions à l’agent. Les ressources seront copiées et les dépendances déclarées pourront être installées.", + piSkillsImport: "Importer et activer", + piSkillsImported: "Déjà importé", + piSkillsRefresh: "Actualiser les skills pi CLI", + piSkillsEmpty: "Aucun paquet npm de skills pi CLI trouvé.", + piSkillsLoading: "Recherche des skills pi CLI…", "importExtension": "Importer une extension pi", "importExtensionDone": "Importée comme plugin {{id}}", "importExtensionDepsFailed": "Importée comme plugin {{id}}, mais l'installation des dépendances a échoué : {{error}}", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 507f03b099..7ea1e5fde1 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -1811,6 +1811,16 @@ sklm: { title: "확장 기능", loadDev: "로컬 플러그인 불러오기", loadDevDone: "로컬 플러그인 불러옴", + piSkillsTitle: "pi CLI 스킬", + piSkillsHint: "설치된 npm 패키지를 읽기 전용으로 검색합니다. 신뢰하는 패키지만 가져오세요. 가져온 패키지는 플러그인에서 관리합니다.", + piSkillsConfirm: "이 pi 패키지를 가져와 활성화할까요?", + piSkillsExecutable: "이 패키지에는 실행 가능한 확장이 포함되어 있습니다. 가져오면 에이전트에서 신뢰된 코드가 활성화되며 종속성이 설치될 수 있습니다.", + piSkillsPromptOnly: "이 패키지는 에이전트에 지침을 추가합니다. 리소스가 복사되며 선언된 종속성이 설치될 수 있습니다.", + piSkillsImport: "가져와 활성화", + piSkillsImported: "이미 가져옴", + piSkillsRefresh: "pi CLI 스킬 새로고침", + piSkillsEmpty: "pi CLI npm 스킬 패키지를 찾을 수 없습니다.", + piSkillsLoading: "pi CLI 스킬 검색 중…", importExtension: "pi 확장 가져오기", importExtensionDone: "플러그인 {{id}}(으)로 가져왔습니다", importExtensionDepsFailed: "플러그인 {{id}}(으)로 가져왔지만 의존성 설치에 실패했습니다: {{error}}", diff --git a/packages/i18n/src/locales/pt-BR/index.ts b/packages/i18n/src/locales/pt-BR/index.ts index addd231394..f14b7b4101 100644 --- a/packages/i18n/src/locales/pt-BR/index.ts +++ b/packages/i18n/src/locales/pt-BR/index.ts @@ -1746,6 +1746,16 @@ export const ptBR = { title: "Extensões", loadDev: "Carregar plugin local", loadDevDone: "Plugin local carregado", + piSkillsTitle: "Habilidades do pi CLI", + piSkillsHint: "Os pacotes npm instalados são detectados sem alterações. Importe apenas pacotes confiáveis e gerencie os pacotes importados em Plugins.", + piSkillsConfirm: "Importar e ativar este pacote pi?", + piSkillsExecutable: "Este pacote inclui extensões executáveis. A importação ativa código confiável no ambiente de execução do agente e pode instalar dependências.", + piSkillsPromptOnly: "Este pacote adiciona instruções ao agente. Seus recursos serão copiados e as dependências declaradas poderão ser instaladas.", + piSkillsImport: "Importar e ativar", + piSkillsImported: "Já importado", + piSkillsRefresh: "Atualizar habilidades do pi CLI", + piSkillsEmpty: "Nenhum pacote npm de habilidades do pi CLI encontrado.", + piSkillsLoading: "Procurando habilidades do pi CLI…", importExtension: "Importar extensão do Pi", importExtensionDone: "Importada como plugin {{id}}", importExtensionDepsFailed: "Importado como plugin {{id}}, mas a instalação de dependências falhou: {{error}}", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index f7c037d1e3..a05ae2d67c 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -1801,6 +1801,16 @@ sklm: { title: "Uzantılar", loadDev: "Yerel eklenti yükle", loadDevDone: "Yerel eklenti yüklendi", + piSkillsTitle: "pi CLI becerileri", + piSkillsHint: "Yüklü npm paketleri salt okunur olarak keşfedilir. Yalnızca güvendiğiniz paketleri içe aktarın; sonra Eklentiler bölümünden yönetin.", + piSkillsConfirm: "Bu pi paketi içe aktarılsın ve etkinleştirilsin mi?", + piSkillsExecutable: "Bu paket çalıştırılabilir uzantılar içerir. İçe aktarma, ajanda güvenilir kodu etkinleştirir ve bağımlılıkları yükleyebilir.", + piSkillsPromptOnly: "Bu paket ajana yönergeler ekler. Kaynaklar kopyalanır ve bildirilen bağımlılıklar yüklenebilir.", + piSkillsImport: "İçe aktar ve etkinleştir", + piSkillsImported: "Zaten içe aktarıldı", + piSkillsRefresh: "pi CLI becerilerini yenile", + piSkillsEmpty: "pi CLI npm beceri paketi bulunamadı.", + piSkillsLoading: "pi CLI becerileri aranıyor…", importExtension: "pi uzantısını içe aktar", importExtensionDone: "{{id}} eklentisi olarak içe aktarıldı", importExtensionDepsFailed: "{{id}} eklentisi olarak içe aktarıldı, ancak bağımlılıklar yüklenemedi: {{error}}", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index 03266c024d..ff911e8551 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1781,6 +1781,16 @@ sklm: { title: "扩展", loadDev: "加载本地插件", loadDevDone: "本地插件已加载", + piSkillsTitle: "pi CLI 技能", + piSkillsHint: "只读发现已安装的 npm 包。请仅导入可信来源;导入后在插件页管理。", + piSkillsConfirm: "导入并启用这个 pi 包?", + piSkillsExecutable: "此包包含可执行扩展。导入会在 Agent 中启用可信代码,并可能安装依赖。", + piSkillsPromptOnly: "此包会向 Agent 添加指令。资源将被复制,并可能安装声明的依赖。", + piSkillsImport: "导入并启用", + piSkillsImported: "已导入", + piSkillsRefresh: "刷新 pi CLI 技能", + piSkillsEmpty: "未找到 pi CLI npm 技能包。", + piSkillsLoading: "正在查找 pi CLI 技能…", importExtension: "导入 pi 扩展", importExtensionDone: "已导入为插件 {{id}}", importExtensionDepsFailed: "已导入为插件 {{id}},但依赖安装失败:{{error}}", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index de4d5bb098..01a47e832f 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1781,6 +1781,16 @@ sklm: { title: "擴充套件", loadDev: "載入本地外掛", loadDevDone: "本地外掛已載入", + piSkillsTitle: "pi CLI 技能", + piSkillsHint: "唯讀探索已安裝的 npm 套件。請僅匯入可信來源;匯入後在外掛頁管理。", + piSkillsConfirm: "匯入並啟用此 pi 套件?", + piSkillsExecutable: "此套件包含可執行擴充功能。匯入會在 Agent 中啟用可信程式碼,並可能安裝相依套件。", + piSkillsPromptOnly: "此套件會向 Agent 新增指令。資源將被複製,並可能安裝宣告的相依套件。", + piSkillsImport: "匯入並啟用", + piSkillsImported: "已匯入", + piSkillsRefresh: "重新整理 pi CLI 技能", + piSkillsEmpty: "找不到 pi CLI npm 技能套件。", + piSkillsLoading: "正在尋找 pi CLI 技能…", importExtension: "匯入 pi 擴充", importExtensionDone: "已匯入為外掛 {{id}}", importExtensionDepsFailed: "已匯入為外掛 {{id}},但相依套件安裝失敗:{{error}}", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6093c777d1..221126fdbd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -68,3 +68,5 @@ export * from "./prompt-enhancement.js"; export * from "./native-web-search.js"; export * from "./native-web-search-transport.js"; export * from "./header-value.js"; + +export * from "./pi-skill-discovery.js"; diff --git a/packages/shared/src/pi-skill-discovery.ts b/packages/shared/src/pi-skill-discovery.ts new file mode 100644 index 0000000000..68912a28a9 --- /dev/null +++ b/packages/shared/src/pi-skill-discovery.ts @@ -0,0 +1,13 @@ +/** Read-only pi CLI npm package candidates. Discovery grants no permissions. */ +export interface PiSkillPackageCandidate { + id: string; + name: string; + path: string; + skills: string[]; + hasExtensions: boolean; + imported: boolean; +} +export interface PiSkillDiscovery { + candidates: PiSkillPackageCandidate[]; + errors: string[]; +} diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 035bfb822e..a7d2720a1c 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -231,6 +231,8 @@ export const IPC = { providersOauthDelete: "pi-desktop/providers/oauth/delete", pluginList: "pi-desktop/plugin/list", /** Plugin-contributed agent extensions (D387/D388, ADR 0214). */ + piSkillDiscover: "pi-desktop/plugin/discoverPiSkills", + piSkillImport: "pi-desktop/plugin/importPiSkills", pluginImportExtension: "pi-desktop/plugin/importExtension", extensionsCommandRun: "pi-desktop/extensions/commands/run", extensionsUiRespond: "pi-desktop/extensions/ui/respond", diff --git a/scripts/e2e-pi-skill-discovery-ui.mjs b/scripts/e2e-pi-skill-discovery-ui.mjs new file mode 100644 index 0000000000..83dba02a6c --- /dev/null +++ b/scripts/e2e-pi-skill-discovery-ui.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** Real React/Chromium coverage for the pi skill discovery panel. */ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveElectronBinary } from "./e2e/boot.mjs"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const require = createRequire(join(root, "packages/agent-runtime/package.json")); +const { build } = require("esbuild"); +const { electronBinary } = resolveElectronBinary(root); +const temp = await mkdtemp(join(tmpdir(), "pi-pi-skill-discovery-ui-")); +try { + await build({ + entryPoints: [join(root, "scripts/e2e/pi-skill-discovery-ui.tsx")], + outfile: join(temp, "renderer.js"), + bundle: true, + platform: "browser", + format: "iife", + jsx: "automatic", + loader: { ".woff": "file", ".woff2": "file", ".ttf": "file" }, + define: { "process.env.NODE_ENV": '"production"' }, + alias: { + "@pi-desktop/i18n": join(root, "packages/i18n/src/index.ts"), + // The fixture lives outside the desktop package; use its React instance. + react: join(root, "apps/desktop/node_modules/react"), + "react-dom": join(root, "apps/desktop/node_modules/react-dom"), + }, + nodePaths: [join(root, "apps/desktop/node_modules")], + }); + await writeFile( + join(temp, "index.html"), + 'Pi skill discovery interactions', + ); + await writeFile( + join(temp, "main.cjs"), + ` +const { app, BrowserWindow } = require("electron"); +const path = require("node:path"); +app.setPath("userData", path.join(__dirname, "profile")); +app.whenReady().then(async () => { + const window = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false } }); + window.webContents.on("console-message", (event) => console.error(event.message)); + try { + await window.loadFile(path.join(__dirname, "index.html")); + const result = await window.webContents.executeJavaScript("globalThis.piSkillDiscoveryProbe()"); + console.log("PI_SKILL_DISCOVERY_UI_PROBE " + JSON.stringify(result)); + app.quit(); + } catch (error) { + console.error("PI_SKILL_DISCOVERY_UI_PROBE " + JSON.stringify({ ok: false, error: String(error) })); + app.exit(1); + } +}); +`, + ); + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + const child = spawn(electronBinary, [join(temp, "main.cjs")], { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + for (const stream of [child.stdout, child.stderr]) + stream.on("data", (data) => { + output += data; + }); + const timeout = setTimeout(() => child.kill("SIGKILL"), 45_000); + let code; + try { + code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + } finally { + clearTimeout(timeout); + } + const line = output.split(/\r?\n/).find((line) => line.startsWith("PI_SKILL_DISCOVERY_UI_PROBE ")); + assert(line, `renderer returned no probe result (exit=${code}): ${output.slice(-2000)}`); + const result = JSON.parse(line.slice("PI_SKILL_DISCOVERY_UI_PROBE ".length)); + console.log("PI_SKILL_DISCOVERY_UI_PROBE " + JSON.stringify(result)); + assert.equal(code, 0, output.slice(-6000)); + assert.equal(result.ok, true); +} finally { + await rm(temp, { recursive: true, force: true }); +} diff --git a/scripts/e2e/pi-skill-discovery-ui.tsx b/scripts/e2e/pi-skill-discovery-ui.tsx new file mode 100644 index 0000000000..6430477096 --- /dev/null +++ b/scripts/e2e/pi-skill-discovery-ui.tsx @@ -0,0 +1,70 @@ +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { createInstance } from "i18next"; +import { I18nextProvider } from "react-i18next"; +import { catalogs } from "@pi-desktop/i18n"; +import { PiSkillDiscoveryPanel } from "../../apps/desktop/src/components/settings/PiSkillDiscoveryPanel"; +import { api } from "../../apps/desktop/src/lib/api"; +import "../../apps/desktop/src/styles/tokens.css"; +import "../../apps/desktop/src/styles/settings.css"; + +declare global { var piSkillDiscoveryProbe: () => Promise; } +const assert = (condition: unknown, message: string) => { if (!condition) throw new Error(message); }; +async function until(condition: () => boolean) { + const deadline = performance.now() + 6000; + while (!condition() && performance.now() < deadline) await new Promise(resolve => requestAnimationFrame(() => resolve())); + assert(condition(), "UI did not reach the expected state"); +} +globalThis.piSkillDiscoveryProbe = async () => { + const i18n = createInstance(); + await i18n.init({ lng: "en", resources: { en: { translation: catalogs.en } } }); + const candidate = { id: "fixture", name: "planning-with-files", path: "/fixture/.pi/agent/npm/node_modules/planning-with-files", skills: ["SKILL.md"], hasExtensions: false, imported: false }; + let calls = 0; + let cancel = true; + let fail = false; + let runtimeFailure = false; + api.discoverPiSkills = async () => { + if (fail) throw new Error("Discovery unavailable"); + return { candidates: [{ ...candidate }], errors: [] }; + }; + api.importPiSkills = async id => { + assert(id === candidate.id, "wrong candidate"); calls++; + if (!cancel) candidate.imported = true; + if (runtimeFailure) throw new Error("Plugin process failed"); + return { canceled: cancel }; + }; + const container = document.createElement("div"); document.body.append(container); + const root = createRoot(container); + flushSync(() => root.render()); + const button = (label: string) => [...container.querySelectorAll("button")].find(b => b.textContent === label)!; + await until(() => Boolean(button("Import and enable"))); + assert(calls === 0, "discovery must not import automatically"); + assert(container.textContent?.includes(candidate.path), "source path must be visible"); + flushSync(() => button("Import and enable").click()); + await until(() => !button("Import and enable").disabled); + assert(!candidate.imported && calls === 1, "cancel must leave candidate available"); + cancel = false; + flushSync(() => button("Import and enable").click()); + await until(() => Boolean(button("Already imported"))); + assert(button("Already imported").disabled, "duplicate import must be disabled"); + fail = true; + flushSync(() => button("Refresh pi CLI skills").click()); + await until(() => Boolean(container.querySelector('[role="alert"]'))); + assert(container.textContent?.includes("Discovery unavailable"), "failure must be visible"); + fail = false; + flushSync(() => button("Refresh pi CLI skills").click()); + await until(() => !button("Refresh pi CLI skills").disabled); + assert(!container.querySelector('[role="alert"]'), "retry must clear previous failure"); + candidate.imported = false; + flushSync(() => button("Refresh pi CLI skills").click()); + await until(() => Boolean(button("Import and enable"))); + runtimeFailure = true; + flushSync(() => button("Import and enable").click()); + await until(() => Boolean(container.querySelector('[role="alert"]'))); + await until(() => Boolean(button("Already imported"))); + assert(button("Already imported").disabled, "host registration must survive a runtime failure"); + assert(container.textContent?.includes("Plugin process failed"), "rediscovery must not hide the import error"); + assert(container.textContent?.includes("manage imported packages in Plugins"), "recovery location must remain visible"); + root.unmount(); container.remove(); + return { ok: true, scenarios: ["discover without import", "cancel", "enable", "duplicate", "failure and retry", "registered import runtime failure"] }; +};