From 8146c03ae09268583f31dd7283c07c83c7c13619 Mon Sep 17 00:00:00 2001 From: keliangliang Date: Sat, 17 Oct 2026 21:44:49 +0800 Subject: [PATCH] feat: add Pi extension marketplace --- CHANGELOG.md | 5 + docs/pi-extension-marketplace/intent.md | 29 + docs/pi-extension-marketplace/plan.md | 49 ++ docs/pi-extension-marketplace/spec.md | 85 +++ electron/appState.ts | 25 + electron/contracts.test.ts | 4 + electron/contracts.ts | 4 + electron/domains/extensions/catalog.test.ts | 177 ++++++ electron/domains/extensions/catalog.ts | 322 +++++++++++ .../packageManagerEnvironment.test.ts | 67 +++ .../extensions/packageManagerEnvironment.ts | 150 +++++ electron/domains/extensions/service.test.ts | 144 +++++ electron/domains/extensions/service.ts | 115 ++++ electron/domains/extensions/sourcePolicy.ts | 20 + electron/domains/settings/index.ts | 36 ++ electron/domains/settings/settings.test.ts | 54 ++ electron/ipc.test.ts | 45 +- electron/ipc.ts | 24 +- src/bindings/generated.ts | 24 + src/bindings/index.ts | 24 + src/features/settings/ExtensionsSettings.tsx | 545 ++++++++++++++++++ src/features/settings/SettingsPage.test.tsx | 172 +++++- src/features/settings/SettingsPage.tsx | 82 +-- src/features/settings/settingsApi.ts | 4 + src/i18n/en.ts | 57 +- src/i18n/zh-CN.ts | 57 +- src/styles/index.css | 227 ++++++++ 27 files changed, 2460 insertions(+), 87 deletions(-) create mode 100644 docs/pi-extension-marketplace/intent.md create mode 100644 docs/pi-extension-marketplace/plan.md create mode 100644 docs/pi-extension-marketplace/spec.md create mode 100644 electron/domains/extensions/catalog.test.ts create mode 100644 electron/domains/extensions/catalog.ts create mode 100644 electron/domains/extensions/packageManagerEnvironment.test.ts create mode 100644 electron/domains/extensions/packageManagerEnvironment.ts create mode 100644 electron/domains/extensions/service.test.ts create mode 100644 electron/domains/extensions/service.ts create mode 100644 electron/domains/extensions/sourcePolicy.ts create mode 100644 src/features/settings/ExtensionsSettings.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c7e671..2e4a472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Pi Extension Marketplace in Settings for browsing community catalog packages and safely installing, updating, or uninstalling user-scoped npm extensions through Pi's package manager. - Complete Files CRUD with inline file/folder creation and rename, duplication, cut/copy/paste moves, system Trash deletion, file-manager reveal, active-file reveal, and secure worktree-relative Main-process operations. - Public contribution, security, support, conduct, issue, and pull request guidance. - Cross-platform development packaging for macOS arm64, Windows x64, and Linux x64. @@ -15,12 +16,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Changed +- Made Installed the first and default Extensions tab, loading the remote Marketplace only when opened. +- Added safe `Remove from Pi` actions for user-configured local extension packages; this removes only their settings reference and never deletes local source files. - Moved Projects and Settings controls into the Projects rail and redesigned Settings as an Orca-inspired full-window page with grouped navigation and search. - Upgraded Git Changes into an Orca-inspired Source Control panel with filtering, collapsible conflict-aware groups, rich status metadata, safe stage/unstage/discard actions, and per-worktree commit drafts. - Refined the workbench with quieter neutral chrome, a narrower Chat reading column, accessible icon tooltips, and a System appearance option that follows the operating system theme. ### Fixed +- Fixed `Remove from Pi` for relative local package sources by resolving the configured package path before asking Pi's PackageManager to remove the settings reference. +- Restored Marketplace extension installation for desktop-launched apps by locating the user's Node.js/npm runtime before invoking Pi's package manager. - Widened Chat history and kept delete actions visible when session titles are too long for the popover row. ### Removed diff --git a/docs/pi-extension-marketplace/intent.md b/docs/pi-extension-marketplace/intent.md new file mode 100644 index 0000000..f3da324 --- /dev/null +++ b/docs/pi-extension-marketplace/intent.md @@ -0,0 +1,29 @@ +# Intent — Pi Extension Marketplace + +## Problem + +SpireCode can currently enumerate Pi extensions resolved from `~/.pi/agent/settings.json`, but users must leave the application and use the Pi CLI to discover, install, update, or remove packages. This makes the extension system hard to discover and encourages manual edits to Pi configuration. + +Pi extensions execute with the same operating-system permissions as the Pi agent. A marketplace therefore crosses process, network, package-installation, persistence, and trust boundaries; it cannot be implemented as an embedded remote web page or a generic command runner. + +## Outcome + +Add an Extensions experience in Settings with two views: + +- **Marketplace** browses community extension packages from Pi's package catalog. +- **Installed** shows user-configured resources and manages packages owned by Pi's package manager. + +Users can install, update, and uninstall user-scoped npm packages through Pi's SDK after an explicit trust warning. The UI never labels catalog inclusion as official verification and never silently restarts an active agent session. + +## Non-goals + +- Project-scoped installation or mutation of `.pi/settings.json`. +- Git, local-path, private-registry, or arbitrary package sources. +- Ratings, reviews, favorites, automatic updates, or background installation. +- Security scanning or inferred fine-grained permissions. +- Loading `pi.dev` HTML or scripts in the Renderer. +- Treating package names, download counts, publishers, or catalog inclusion as verification. + +## Risk classification + +This is a **high-risk change** because it adds cross-process contracts, remote catalog ingestion, package code installation, and mutation of Pi's durable user settings. The user approved the proposed scope and security model before implementation. diff --git a/docs/pi-extension-marketplace/plan.md b/docs/pi-extension-marketplace/plan.md new file mode 100644 index 0000000..f4d82d7 --- /dev/null +++ b/docs/pi-extension-marketplace/plan.md @@ -0,0 +1,49 @@ +# Plan — Pi Extension Marketplace + +## 1. Establish contracts with red-green tests + +- Add catalog and managed-package types to Main and Renderer bindings. +- Extend IPC allowlists and argument validation with bounded query/page/sort and conservative npm source validation. +- Add tests proving unexpected fields, invalid names, arbitrary URLs/specifiers, stale sources, and project/Git sources are rejected; local removal tests must prove only exact configured user path sources are accepted. + +## 2. Implement the catalog adapter + +- Add `electron/domains/extensions/catalog.ts` with an injected fetch function. +- Construct only fixed-origin Pi Catalog URLs. +- Enforce timeout, manual redirects, response/content limits, and bounded cards. +- Parse the catalog's server-rendered `data-package-*` cards into validated plain objects. +- Cover realistic HTML fixtures, escaped content, pagination, malformed pages, unsafe links, redirects, oversized responses, and network failure. + +## 3. Implement package management + +- Add `electron/domains/extensions/service.ts` around an injected Pi `PackageManager` factory. +- Construct the production `DefaultPackageManager` with user agent directory and a trusted SettingsManager only for reading current user configuration; package mutations remain user-scoped. +- Map configured user npm sources and removable local settings references to resolved extension inventory without exposing resolved absolute managed install roots. +- Serialize install/update/remove and refresh authoritative extension state after success. Local removal delegates to PackageManager only for settings cleanup and must not delete source files. +- Add service tests with a fake PackageManager before production wiring. + +## 4. Wire Main and generated bindings + +- Add the extension service to `AppState`. +- Dispatch the four narrow commands in `electron/ipc.ts`. +- Add typed Renderer command wrappers and settings API methods. +- Keep the preload bridge unchanged because it already exposes only allowlisted command invocation. + +## 5. Build the Settings experience + +- Extract the Extensions section into focused components if needed. +- Add Marketplace/Installed tabs, search, sort, pagination, retry/empty states, package cards, authoritative install state, confirmation dialogs, mutation errors, and restart notice. +- Preserve installed resource information; show Update/Uninstall for npm packages, Remove from Pi for configured user local packages, and read-only behavior for direct/project resources. +- Add English and Simplified Chinese strings and responsive neutral-chrome styles. + +## 6. Verify + +- Run new domain, IPC, bindings, Settings UI, i18n, and style-contract tests. +- Run formatter, lint, typecheck, and full `pnpm check`. +- Review the final diff for arbitrary URL/command/path exposure, project-settings mutation, unsafe remote rendering, optimistic false success, and accidental official/verified wording. + +## Acceptance evidence + +- Deterministic tests show fixed-origin catalog ingestion and fail-closed package mutation. +- UI tests show trust confirmation, install single-flight, installed refresh, update/uninstall restrictions, failure recovery, and restart notice. +- `pnpm check` exits successfully. diff --git a/docs/pi-extension-marketplace/spec.md b/docs/pi-extension-marketplace/spec.md new file mode 100644 index 0000000..d75b33c --- /dev/null +++ b/docs/pi-extension-marketplace/spec.md @@ -0,0 +1,85 @@ +# Spec — Pi Extension Marketplace + +## Product surface + +`Settings → Extensions` contains `Marketplace` and `Installed` tabs. + +### Marketplace + +- Loads extension-only results from `https://pi.dev/packages`. +- Supports a trimmed text query, `downloads | recent | name` sorting, and numbered pagination. +- Displays package name, description, publisher, monthly downloads, relative publication time, package types, and trusted HTTPS links returned by Main. +- Labels entries as `Community` and explains that Pi Catalog inclusion is not verification. +- Represents loading, empty, unavailable, and retry states without affecting Installed. +- Shows `Install`, `Installing…`, or `Installed` from the authoritative installed-package result. + +### Installed + +- Preserves the existing resolved-extension inventory. +- Only entries backed by a configured user npm package are manageable. +- User-scoped npm packages expose Update and Uninstall. User-scoped local package sources expose Remove from Pi, which removes only the exact settings reference and never deletes local files. Direct extension files and project-scoped resources remain read-only. +- Mutations are single-flight in the Renderer and serialized in Main. +- Successful mutations refresh the installed inventory and show that agent sessions must be restarted before the change is fully active. + +## Trust confirmation + +Before installation, a modal identifies the package and publisher and states that extensions run with the Pi agent's OS permissions and may access project files, prompts, tool calls, credentials, environment variables, session data, network, and subprocesses. Installation requires an explicit confirmation action. + +Uninstall requires confirmation. Updating does not remove data and may run after direct activation, but still shows the restart notice on success. + +## Main-process boundary + +### Catalog + +- Renderer supplies only `query`, `sort`, and `page`. +- Main constructs the URL for the fixed `https://pi.dev/packages` origin with `type=extension`. +- Query is limited in UTF-8 size; page is a bounded positive integer; sort is an enum. +- Fetch uses a timeout, manual redirect policy, response-size limit, HTML content validation, and a bounded result count. At most one canonicalization redirect is followed, and only when it remains on `https://pi.dev/packages` with `type=extension`; all other redirects fail closed. +- Main parses server-rendered package cards and returns validated plain data. Remote HTML is never passed to Renderer. +- Only HTTPS npm, repository, and Pi Catalog links on expected hosts are returned. +- Catalog parser failure is a typed error and leaves Installed usable. + +### Package mutations + +- Main accepts only a validated npm package name and constructs `npm:` itself for installation. +- Update accepts only the exact source of a currently configured **user-scoped npm package**. +- Uninstall accepts configured user npm packages. Remove from Pi accepts only exact, unfiltered user local package paths, resolves a relative configured source to PackageManager's installed-path identity in Main, and delegates only to PackageManager's settings removal; it never deletes the local source directory. Unknown, project, Git, filtered, or built-in sources are rejected. +- Main invokes Pi SDK `DefaultPackageManager`; it does not spawn a shell or implement npm installation itself. +- When Pi has no explicit `npmCommand`, Main locates a complete Node.js/npm runtime from the inherited `PATH` or bounded platform-specific user installation roots (including NVM, Volta, fnm, Homebrew, and standard Windows Node locations) and prepends only that runtime directory. An explicit Pi `npmCommand` remains authoritative. +- User scope is represented by SDK calls without `{ local: true }`. +- Mutations are serialized through one queue and authoritative state is reread afterward. + +## IPC contracts + +- `settings_extension_catalog_list(query, sort, page)` +- `settings_extension_package_install(packageName)` +- `settings_extension_package_update(source)` +- `settings_extension_package_remove(source)` + +The existing `settings_extensions_list(worktreeId?)` result gains optional package source and management-kind metadata sufficient to distinguish npm management from removable local settings references, without exposing resolved absolute installation paths to Renderer. + +## Package-name validation + +Accept npm names matching unscoped `name` or scoped `@scope/name` syntax, using lowercase URL-safe npm identifier characters and a conservative length bound. Reject whitespace, control characters, versions/specifiers, paths, URLs, Git sources, flags, and empty segments. + +## Lifecycle and concurrency + +- Catalog reads may run concurrently with installed-list reads. +- Main package mutations run sequentially. +- A failed SDK operation does not alter Renderer state optimistically. +- A successful operation returns the refreshed installed inventory. +- SpireCode never hot-injects an extension into a running session and never restarts a session without user action. + +## Accessibility and responsive behavior + +- Tabs use tab semantics and keyboard-focusable buttons. +- Search, sort, pagination, links, and mutation buttons have accessible names. +- Confirmation dialogs trap focus through the existing Radix dialog primitive and expose title and description. +- Long package names and descriptions wrap or truncate without pushing actions outside the Settings content area. + +## Failure behavior + +- Offline, timeout, remote schema drift, invalid content, and HTTP failures produce a retryable catalog error. +- Invalid or stale package mutation requests fail closed with `INVALID_ARGUMENT` or `NOT_FOUND`. +- SDK failures surface their safe message and do not report success. +- A link rejected by Main is omitted rather than forwarded. diff --git a/electron/appState.ts b/electron/appState.ts index 5a7bfd0..8fdac95 100644 --- a/electron/appState.ts +++ b/electron/appState.ts @@ -23,6 +23,13 @@ import { KeyedLock } from "./core/keyedLock.js"; import { WindowCloseGuard } from "./windowCloseGuard.js"; import { DiagnosticsService } from "./domains/diagnostics/service.js"; import { resolveSpireResources } from "./domains/chat/spireSettings.js"; +import { + DefaultPackageManager, + SettingsManager, +} from "@earendil-works/pi-coding-agent"; +import { ExtensionCatalogService } from "./domains/extensions/catalog.js"; +import { ExtensionPackageService } from "./domains/extensions/service.js"; +import { ensureNpmOnPath } from "./domains/extensions/packageManagerEnvironment.js"; export interface SubscriptionEvent { subscriptionId: string; @@ -40,6 +47,8 @@ export class AppState { readonly watchers = new WatcherRegistry(); readonly windowCloseGuard = new WindowCloseGuard(); readonly diagnostics: DiagnosticsService; + readonly extensionCatalog: ExtensionCatalogService; + readonly extensionPackages: ExtensionPackageService; private constructor( readonly projects: ProjectService, @@ -48,6 +57,22 @@ export class AppState { dataDirectory: string, ) { this.diagnostics = new DiagnosticsService(dataDirectory, "0.1.0"); + this.extensionCatalog = new ExtensionCatalogService(); + const agentDir = path.join(homedir(), ".pi", "agent"); + const createPackageSettings = () => + SettingsManager.create(homedir(), agentDir, { projectTrusted: false }); + this.extensionPackages = new ExtensionPackageService( + () => + new DefaultPackageManager({ + cwd: homedir(), + agentDir, + settingsManager: createPackageSettings(), + }), + async () => { + if (!createPackageSettings().getNpmCommand()?.length) + await ensureNpmOnPath(); + }, + ); const root = (id: string) => projects.root(id); const worktreeOperationLock = new KeyedLock(); this.filesystem = new FilesystemService(root, { diff --git a/electron/contracts.test.ts b/electron/contracts.test.ts index 26675b3..e4c293e 100644 --- a/electron/contracts.test.ts +++ b/electron/contracts.test.ts @@ -27,6 +27,10 @@ describe("Electron host allowlists", () => { "settings_language_get", "settings_language_set", "settings_agent_readiness", + "settings_extension_catalog_list", + "settings_extension_package_install", + "settings_extension_package_update", + "settings_extension_package_remove", "settings_skills_list", "settings_skill_set_enabled", ]), diff --git a/electron/contracts.ts b/electron/contracts.ts index 705bcf7..b77a8e7 100644 --- a/electron/contracts.ts +++ b/electron/contracts.ts @@ -46,6 +46,10 @@ export const COMMANDS = [ "chat_session_abort", "chat_session_delete", "settings_extensions_list", + "settings_extension_catalog_list", + "settings_extension_package_install", + "settings_extension_package_update", + "settings_extension_package_remove", "settings_skills_list", "settings_skill_set_enabled", "settings_language_get", diff --git a/electron/domains/extensions/catalog.test.ts b/electron/domains/extensions/catalog.test.ts new file mode 100644 index 0000000..a9f1113 --- /dev/null +++ b/electron/domains/extensions/catalog.test.ts @@ -0,0 +1,177 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; +import { ExtensionCatalogService, parseCatalogHtml } from "./catalog.js"; + +const catalogHtml = ` +
1-2 / 2 (of 5)
+
+
+
+

pi-safe

+

Safe & useful

+
alice1.2K/mo2d ago
+
extension
+ +
+
+
+
+

@scope/pi-tools

+

Tools <without remote markup>

+
bob42/mo1mo ago
+
extensionskill
+ +
+
+
+ +`; + +describe("parseCatalogHtml", () => { + it("returns validated plain package data and omits unsafe links", () => { + expect(parseCatalogHtml(catalogHtml, 1)).toEqual({ + items: [ + { + name: "pi-safe", + description: "Safe & useful", + publisher: "alice", + downloads: 1234, + downloadsLabel: "1.2K/mo", + publishedLabel: "2d ago", + types: ["extension"], + catalogUrl: "https://pi.dev/packages/pi-safe?type=extension", + npmUrl: "https://www.npmjs.com/package/pi-safe", + repositoryUrl: "https://github.com/example/pi-safe", + }, + { + name: "@scope/pi-tools", + description: "Tools ", + publisher: "bob", + downloads: 42, + downloadsLabel: "42/mo", + publishedLabel: "1mo ago", + types: ["extension", "skill"], + catalogUrl: "https://pi.dev/packages/@scope/pi-tools?type=extension", + }, + ], + page: 1, + total: 2, + totalPages: 3, + }); + }); + + it("returns an empty result only for an explicit empty catalog", () => { + expect( + parseCatalogHtml( + '0 / 0 (of 5)
', + 1, + ), + ).toEqual({ items: [], page: 1, total: 0, totalPages: 1 }); + }); + + it("fails closed when the catalog schema has no package cards", () => { + expect(() => parseCatalogHtml("changed", 1)).toThrow( + "package cards", + ); + }); +}); + +describe("ExtensionCatalogService", () => { + it("constructs only the fixed Pi catalog URL", async () => { + const fetcher = vi.fn().mockResolvedValue( + new Response(catalogHtml, { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }), + ); + const service = new ExtensionCatalogService(fetcher); + + await service.list({ query: "safe tools", sort: "recent", page: 2 }); + + const [url, init] = fetcher.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + "https://pi.dev/packages?type=extension&name=safe+tools&sort=recent&page=2", + ); + expect(init.redirect).toBe("manual"); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it("omits default query values that Pi canonicalizes with a redirect", async () => { + const fetcher = vi.fn().mockResolvedValue( + new Response(catalogHtml, { + status: 200, + headers: { "content-type": "text/html" }, + }), + ); + const service = new ExtensionCatalogService(fetcher); + + await service.list({ query: "", sort: "downloads", page: 1 }); + + expect(fetcher.mock.calls[0]?.[0]).toBe( + "https://pi.dev/packages?type=extension", + ); + }); + + it("follows one same-origin catalog canonicalization redirect", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { + status: 302, + headers: { location: "/packages?type=extension" }, + }), + ) + .mockResolvedValueOnce( + new Response(catalogHtml, { + status: 200, + headers: { "content-type": "text/html" }, + }), + ); + const service = new ExtensionCatalogService(fetcher); + + await expect( + service.list({ query: "", sort: "downloads", page: 1 }), + ).resolves.toMatchObject({ page: 1, total: 2 }); + expect(fetcher.mock.calls[1]?.[0]).toBe( + "https://pi.dev/packages?type=extension", + ); + }); + + it("rejects cross-origin redirects, non-HTML responses, and oversized bodies", async () => { + const redirect = new ExtensionCatalogService( + vi.fn().mockResolvedValue( + new Response(null, { + status: 302, + headers: { location: "https://evil.example/packages" }, + }), + ), + ); + await expect( + redirect.list({ query: "", sort: "downloads", page: 1 }), + ).rejects.toThrow("redirect"); + + const json = new ExtensionCatalogService( + vi.fn().mockResolvedValue( + new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + await expect( + json.list({ query: "", sort: "downloads", page: 1 }), + ).rejects.toThrow("HTML"); + + const oversized = new ExtensionCatalogService( + vi.fn().mockResolvedValue( + new Response("x".repeat(2_000_001), { + status: 200, + headers: { "content-type": "text/html" }, + }), + ), + ); + await expect( + oversized.list({ query: "", sort: "downloads", page: 1 }), + ).rejects.toThrow("too large"); + }); +}); diff --git a/electron/domains/extensions/catalog.ts b/electron/domains/extensions/catalog.ts new file mode 100644 index 0000000..361b814 --- /dev/null +++ b/electron/domains/extensions/catalog.ts @@ -0,0 +1,322 @@ +import { CommandError } from "../../core/errors.js"; + +export type ExtensionCatalogSort = "downloads" | "recent" | "name"; + +export interface ExtensionCatalogQuery { + query: string; + sort: ExtensionCatalogSort; + page: number; +} + +export interface ExtensionCatalogItem { + name: string; + description: string; + publisher: string; + downloads: number; + downloadsLabel: string; + publishedLabel: string; + types: string[]; + catalogUrl: string; + npmUrl?: string; + repositoryUrl?: string; +} + +export interface ExtensionCatalogResult { + items: ExtensionCatalogItem[]; + page: number; + total: number; + totalPages: number; +} + +type Fetcher = (input: string, init: RequestInit) => Promise; + +const CATALOG_ORIGIN = "https://pi.dev"; +const MAX_RESPONSE_BYTES = 2_000_000; +const MAX_ITEMS = 50; +const TIMEOUT_MS = 10_000; + +export class ExtensionCatalogService { + constructor(private readonly fetcher: Fetcher = fetch) {} + + async list(query: ExtensionCatalogQuery): Promise { + validateQuery(query); + const url = new URL("/packages", CATALOG_ORIGIN); + url.searchParams.set("type", "extension"); + if (query.query) url.searchParams.set("name", query.query); + if (query.sort !== "downloads") url.searchParams.set("sort", query.sort); + if (query.page !== 1) url.searchParams.set("page", String(query.page)); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const response = await this.fetchCatalogResponse(url, controller.signal); + if (!response.ok) + throw new Error(`Pi Catalog request failed (${response.status})`); + if (!(response.headers.get("content-type") ?? "").includes("text/html")) + throw new Error("Pi Catalog did not return HTML"); + const declaredLength = Number(response.headers.get("content-length")); + if ( + Number.isFinite(declaredLength) && + declaredLength > MAX_RESPONSE_BYTES + ) + throw new Error("Pi Catalog response is too large"); + return parseCatalogHtml(await readBoundedText(response), query.page); + } finally { + clearTimeout(timeout); + } + } + + private async fetchCatalogResponse( + initialUrl: URL, + signal: AbortSignal, + ): Promise { + let url = initialUrl; + for (let redirects = 0; redirects <= 1; redirects += 1) { + const response = await this.fetcher(url.toString(), { + method: "GET", + redirect: "manual", + signal, + headers: { accept: "text/html" }, + }); + if (response.status < 300 || response.status >= 400) return response; + if (redirects === 1) throw new Error("Pi Catalog redirect was rejected"); + const location = response.headers.get("location"); + let redirected: URL; + try { + redirected = new URL(location ?? "", url); + } catch { + throw new Error("Pi Catalog redirect was rejected"); + } + if ( + redirected.origin !== CATALOG_ORIGIN || + redirected.pathname !== "/packages" || + redirected.searchParams.get("type") !== "extension" || + redirected.username || + redirected.password + ) + throw new Error("Pi Catalog redirect was rejected"); + await response.body?.cancel(); + url = redirected; + } + throw new Error("Pi Catalog redirect was rejected"); + } +} + +async function readBoundedText(response: Response): Promise { + if (!response.body) return ""; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let bytes = 0; + let result = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > MAX_RESPONSE_BYTES) { + await reader.cancel(); + throw new Error("Pi Catalog response is too large"); + } + result += decoder.decode(value, { stream: true }); + } + return result + decoder.decode(); + } finally { + reader.releaseLock(); + } +} + +export function parseCatalogHtml( + html: string, + page: number, +): ExtensionCatalogResult { + const articles = html.match( + /]*data-package-card=(?:"true"|'true')[\s\S]*?<\/article>/gi, + ); + if (!articles?.length) { + const countText = textOfFirst(html, "packages-count"); + if (/\b0\s*\/\s*0\b/.test(countText) && /\bpackages-grid\b/.test(html)) + return { items: [], page, total: 0, totalPages: 1 }; + throw new Error("Pi Catalog response contained no package cards"); + } + if (articles.length > MAX_ITEMS) + throw new Error("Pi Catalog response contained too many package cards"); + + const items = articles.map((article) => parseCard(article)); + const countText = textOfFirst(html, "packages-count"); + const totalMatch = countText.match(/\/\s*([0-9][0-9,]*)/); + const total = totalMatch + ? Number(totalMatch[1].replaceAll(",", "")) + : items.length; + const pages = [ + ...html.matchAll( + /class=(?:"[^"]*pagination-page[^"]*"|'[^']*pagination-page[^']*')[^>]*>([0-9]+) Number(match[1])); + return { + items, + page, + total: Number.isSafeInteger(total) ? total : items.length, + totalPages: Math.max(page, ...pages, 1), + }; +} + +function parseCard(article: string): ExtensionCatalogItem { + const attributes = tagAttributes(article.slice(0, article.indexOf(">") + 1)); + const name = attributes["data-package-name"]; + const downloads = Number(attributes["data-package-downloads"]); + const types = (attributes["data-package-types"] ?? "") + .split(/\s+/) + .filter(Boolean); + if ( + !name || + !isPackageName(name) || + !Number.isSafeInteger(downloads) || + downloads < 0 || + !types.includes("extension") + ) + throw new Error("Pi Catalog package card is invalid"); + const description = textOfFirst(article, "packages-desc"); + const meta = innerOfFirst(article, "packages-meta"); + const metaValues = [ + ...meta.matchAll(/]*>([\s\S]*?)<\/span>/gi), + ].map((match) => plainText(match[1])); + if (metaValues.length < 3) + throw new Error("Pi Catalog package metadata is invalid"); + const catalogPath = firstHref(innerOfFirst(article, "packages-name")); + const catalogUrl = safeCatalogUrl(catalogPath, name); + const links = innerOfFirst(article, "packages-links"); + const hrefs = [ + ...links.matchAll(/]*href=(?:"([^"]*)"|'([^']*)')[^>]*>/gi), + ].map((match) => decodeHtml(match[1] ?? match[2] ?? "")); + const npmUrl = hrefs.map(safeNpmUrl).find(Boolean); + const repositoryUrl = hrefs.map(safeRepositoryUrl).find(Boolean); + return { + name, + description, + publisher: metaValues[0], + downloads, + downloadsLabel: metaValues[1], + publishedLabel: metaValues[2], + types, + catalogUrl, + ...(npmUrl ? { npmUrl } : {}), + ...(repositoryUrl ? { repositoryUrl } : {}), + }; +} + +function validateQuery(query: ExtensionCatalogQuery): void { + if ( + query.query.trim() !== query.query || + Buffer.byteLength(query.query, "utf8") > 160 || + hasControls(query.query) + ) + throw new CommandError("INVALID_ARGUMENT", "catalog query is invalid"); + if ( + !new Set(["downloads", "recent", "name"]).has( + query.sort, + ) + ) + throw new CommandError("INVALID_ARGUMENT", "catalog sort is invalid"); + if (!Number.isSafeInteger(query.page) || query.page < 1 || query.page > 200) + throw new CommandError("INVALID_ARGUMENT", "catalog page is invalid"); +} + +function tagAttributes(tag: string): Record { + const result: Record = {}; + for (const match of tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) + result[match[1].toLowerCase()] = decodeHtml(match[2] ?? match[3] ?? ""); + return result; +} + +function innerOfFirst(html: string, className: string): string { + const pattern = new RegExp( + `<([a-z0-9]+)\\b[^>]*class=(?:"[^"]*\\b${className}\\b[^"]*"|'[^']*\\b${className}\\b[^']*')[^>]*>([\\s\\S]*?)<\\/\\1>`, + "i", + ); + return html.match(pattern)?.[2] ?? ""; +} + +function textOfFirst(html: string, className: string): string { + return plainText(innerOfFirst(html, className)); +} + +function firstHref(html: string): string { + const match = html.match(/]*href=(?:"([^"]*)"|'([^']*)')/i); + return decodeHtml(match?.[1] ?? match?.[2] ?? ""); +} + +function plainText(html: string): string { + return decodeHtml(html.replace(/<[^>]*>/g, " ")) + .replace(/\s+/g, " ") + .trim(); +} + +function decodeHtml(value: string): string { + return value.replace( + /&(#x[0-9a-f]+|#[0-9]+|amp|lt|gt|quot|apos);/gi, + (_match, entity: string) => { + const normalized = entity.toLowerCase(); + if (normalized === "amp") return "&"; + if (normalized === "lt") return "<"; + if (normalized === "gt") return ">"; + if (normalized === "quot") return '"'; + if (normalized === "apos") return "'"; + const radix = normalized.startsWith("#x") ? 16 : 10; + const digits = normalized.slice(radix === 16 ? 2 : 1); + const codePoint = Number.parseInt(digits, radix); + return Number.isSafeInteger(codePoint) && codePoint <= 0x10ffff + ? String.fromCodePoint(codePoint) + : ""; + }, + ); +} + +function safeCatalogUrl(value: string, name: string): string { + const url = new URL( + value || `/packages/${encodeURIComponent(name)}`, + CATALOG_ORIGIN, + ); + if (url.origin !== CATALOG_ORIGIN || !url.pathname.startsWith("/packages/")) + throw new Error("Pi Catalog package link is invalid"); + return url.toString(); +} + +function safeNpmUrl(value: string): string | undefined { + return safeHttpsUrl(value, new Set(["www.npmjs.com"]), "/package/"); +} + +function safeRepositoryUrl(value: string): string | undefined { + return safeHttpsUrl(value, new Set(["github.com"])); +} + +function safeHttpsUrl( + value: string, + hosts: Set, + pathPrefix = "/", +): string | undefined { + try { + const url = new URL(value); + return url.protocol === "https:" && + hosts.has(url.hostname) && + url.pathname.startsWith(pathPrefix) && + !url.username && + !url.password + ? url.toString() + : undefined; + } catch { + return undefined; + } +} + +function isPackageName(value: string): boolean { + return ( + /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value) && + value.length <= 214 + ); +} + +function hasControls(value: string): boolean { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return code <= 31 || (code >= 127 && code <= 159); + }); +} diff --git a/electron/domains/extensions/packageManagerEnvironment.test.ts b/electron/domains/extensions/packageManagerEnvironment.test.ts new file mode 100644 index 0000000..60baeed --- /dev/null +++ b/electron/domains/extensions/packageManagerEnvironment.test.ts @@ -0,0 +1,67 @@ +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { ensureNpmOnPath } from "./packageManagerEnvironment.js"; + +describe("ensureNpmOnPath", () => { + it("keeps PATH unchanged when npm and node are already available", async () => { + const env: NodeJS.ProcessEnv = { PATH: "/usr/local/bin:/usr/bin" }; + const access = vi.fn(async (candidate: string) => { + if ( + candidate === "/usr/local/bin/npm" || + candidate === "/usr/local/bin/node" + ) + return; + throw new Error("missing"); + }); + + await ensureNpmOnPath({ + env, + homeDirectory: "/Users/tester", + platform: "darwin", + access, + readDirectory: vi.fn(), + executableDirectory: "/Applications/SpireCode.app/Contents/MacOS", + }); + + expect(env.PATH).toBe("/usr/local/bin:/usr/bin"); + }); + + it("prepends the newest complete nvm Node bin for Finder-launched apps", async () => { + const env: NodeJS.ProcessEnv = { PATH: "/usr/bin:/bin" }; + const nvmBin = "/Users/tester/.nvm/versions/node/v24.1.0/bin"; + const access = vi.fn(async (candidate: string) => { + if ( + candidate === path.join(nvmBin, "npm") || + candidate === path.join(nvmBin, "node") + ) + return; + throw new Error("missing"); + }); + + await ensureNpmOnPath({ + env, + homeDirectory: "/Users/tester", + platform: "darwin", + access, + readDirectory: vi + .fn() + .mockResolvedValue(["v20.12.0", "v24.1.0", "v22.9.0"]), + executableDirectory: "/Applications/SpireCode.app/Contents/MacOS", + }); + + expect(env.PATH).toBe(`${nvmBin}:/usr/bin:/bin`); + }); + + it("fails with an actionable error when no npm runtime is available", async () => { + await expect( + ensureNpmOnPath({ + env: { PATH: "/usr/bin:/bin" }, + homeDirectory: "/Users/tester", + platform: "darwin", + access: vi.fn().mockRejectedValue(new Error("missing")), + readDirectory: vi.fn().mockRejectedValue(new Error("missing")), + executableDirectory: "/Applications/SpireCode.app/Contents/MacOS", + }), + ).rejects.toThrow("npm executable was not found"); + }); +}); diff --git a/electron/domains/extensions/packageManagerEnvironment.ts b/electron/domains/extensions/packageManagerEnvironment.ts new file mode 100644 index 0000000..d873df2 --- /dev/null +++ b/electron/domains/extensions/packageManagerEnvironment.ts @@ -0,0 +1,150 @@ +import { access, readdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; + +interface NpmPathOptions { + env?: NodeJS.ProcessEnv; + homeDirectory?: string; + platform?: NodeJS.Platform; + access?: (candidate: string) => Promise; + readDirectory?: (directory: string) => Promise; + executableDirectory?: string; +} + +export async function ensureNpmOnPath( + options: NpmPathOptions = {}, +): Promise { + const env = options.env ?? process.env; + const homeDirectory = options.homeDirectory ?? homedir(); + const platform = options.platform ?? process.platform; + const checkAccess = options.access ?? ((candidate) => access(candidate)); + const readDirectory = + options.readDirectory ?? + (async (directory) => + readdir(directory, { withFileTypes: true }).then((entries) => + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name), + )); + const executableDirectory = + options.executableDirectory ?? path.dirname(process.execPath); + const delimiter = platform === "win32" ? ";" : ":"; + const pathKey = + Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"; + const currentPath = env[pathKey] ?? ""; + const currentDirectories = currentPath.split(delimiter).filter(Boolean); + const npmExecutable = platform === "win32" ? "npm.cmd" : "npm"; + const nodeExecutable = platform === "win32" ? "node.exe" : "node"; + + if ( + (await containsExecutable( + currentDirectories, + npmExecutable, + checkAccess, + )) && + (await containsExecutable(currentDirectories, nodeExecutable, checkAccess)) + ) + return; + + const candidates = await candidateDirectories({ + env, + homeDirectory, + platform, + executableDirectory, + readDirectory, + }); + for (const directory of candidates) { + if ( + (await isAccessible(path.join(directory, npmExecutable), checkAccess)) && + (await isAccessible(path.join(directory, nodeExecutable), checkAccess)) + ) { + env[pathKey] = [directory, currentPath].filter(Boolean).join(delimiter); + return; + } + } + + throw new Error( + "npm executable was not found. Install Node.js or configure npmCommand in ~/.pi/agent/settings.json.", + ); +} + +async function candidateDirectories(options: { + env: NodeJS.ProcessEnv; + homeDirectory: string; + platform: NodeJS.Platform; + executableDirectory: string; + readDirectory: (directory: string) => Promise; +}): Promise { + if (options.platform === "win32") { + return unique([ + options.executableDirectory, + options.env.NVM_SYMLINK, + options.env.ProgramFiles + ? path.join(options.env.ProgramFiles, "nodejs") + : undefined, + options.env.LOCALAPPDATA + ? path.join(options.env.LOCALAPPDATA, "Programs", "node") + : undefined, + ]); + } + + const nvmRoot = path.join(options.homeDirectory, ".nvm", "versions", "node"); + let nvmVersions: string[] = []; + try { + nvmVersions = await options.readDirectory(nvmRoot); + } catch { + // NVM is optional. + } + nvmVersions.sort((left, right) => + right.localeCompare(left, undefined, { + numeric: true, + sensitivity: "base", + }), + ); + + return unique([ + options.executableDirectory, + ...nvmVersions.map((version) => path.join(nvmRoot, version, "bin")), + path.join(options.homeDirectory, ".volta", "bin"), + path.join( + options.homeDirectory, + ".local", + "share", + "fnm", + "current", + "bin", + ), + "/opt/homebrew/bin", + "/usr/local/bin", + ]); +} + +async function containsExecutable( + directories: string[], + executable: string, + checkAccess: (candidate: string) => Promise, +): Promise { + for (const directory of directories) { + if (await isAccessible(path.join(directory, executable), checkAccess)) + return true; + } + return false; +} + +async function isAccessible( + candidate: string, + checkAccess: (candidate: string) => Promise, +): Promise { + try { + await checkAccess(candidate); + return true; + } catch { + return false; + } +} + +function unique(values: Array): string[] { + return [ + ...new Set(values.filter((value): value is string => Boolean(value))), + ]; +} diff --git a/electron/domains/extensions/service.test.ts b/electron/domains/extensions/service.test.ts new file mode 100644 index 0000000..f7c2ef0 --- /dev/null +++ b/electron/domains/extensions/service.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; +import type { PackageManager } from "@earendil-works/pi-coding-agent"; +import { ExtensionPackageService } from "./service.js"; + +function fakeManager( + configured: ReturnType = [], +) { + return { + installAndPersist: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + removeAndPersist: vi.fn().mockResolvedValue(true), + removeSourceFromSettings: vi.fn().mockReturnValue(true), + listConfiguredPackages: vi.fn().mockReturnValue(configured), + }; +} + +describe("ExtensionPackageService", () => { + it("constructs an npm source from a validated package name", async () => { + const manager = fakeManager(); + const service = new ExtensionPackageService(() => manager); + + await service.install("@scope/pi-tools"); + + expect(manager.installAndPersist).toHaveBeenCalledWith( + "npm:@scope/pi-tools", + ); + }); + + it.each([ + "", + "pi tools", + "pi-tools@next", + "npm:pi-tools", + "../pi-tools", + "https://example.com/pi-tools", + "--ignore-scripts", + "@scope/", + "@Scope/pi-tools", + ])("rejects unsafe package name %j", async (name) => { + const service = new ExtensionPackageService(() => fakeManager()); + await expect(service.install(name)).rejects.toThrow("package name"); + }); + + it("updates and removes only exact configured user npm sources", async () => { + const configured = [ + { + source: "npm:pi-safe", + scope: "user" as const, + filtered: false, + installedPath: "/private/managed/pi-safe", + }, + { + source: "git:https://github.com/example/package", + scope: "user" as const, + filtered: false, + }, + { + source: "npm:project-only", + scope: "project" as const, + filtered: false, + }, + { + source: "npm:filtered", + scope: "user" as const, + filtered: true, + }, + ]; + const updateManager = fakeManager(configured); + const removeManager = fakeManager(configured); + const managers = [updateManager, removeManager]; + const service = new ExtensionPackageService(() => managers.shift()!); + + await service.update("npm:pi-safe"); + await service.remove("npm:pi-safe"); + + expect(updateManager.update).toHaveBeenCalledWith("npm:pi-safe"); + expect(removeManager.removeAndPersist).toHaveBeenCalledWith("npm:pi-safe"); + }); + + it("removes an exact configured user local package without updating it", async () => { + const source = "../../dev/pi-local"; + const installedPath = "/Users/tester/dev/pi-local"; + const manager = fakeManager([ + { source, scope: "user", filtered: false, installedPath }, + ]); + const service = new ExtensionPackageService(() => manager); + + await service.remove(source); + + expect(manager.removeSourceFromSettings).toHaveBeenCalledWith( + installedPath, + ); + expect(manager.removeAndPersist).not.toHaveBeenCalled(); + await expect(service.update(source)).rejects.toThrow( + "configured user npm package", + ); + }); + + it.each([ + "npm:missing", + "git:https://github.com/example/package", + "npm:project-only", + "npm:filtered", + ])("rejects unmanaged or unsafe configured source %j", async (source) => { + const manager = fakeManager([ + { + source: "git:https://github.com/example/package", + scope: "user", + filtered: false, + }, + { source: "npm:project-only", scope: "project", filtered: false }, + { source: "npm:filtered", scope: "user", filtered: true }, + ]); + const service = new ExtensionPackageService(() => manager); + + await expect(service.update(source)).rejects.toThrow( + /configured user (?:npm |)package|not found/, + ); + }); + + it("serializes package mutations", async () => { + let finishFirst!: () => void; + const first = fakeManager(); + first.installAndPersist.mockReturnValueOnce( + new Promise((resolve) => { + finishFirst = resolve; + }), + ); + const second = fakeManager(); + const service = new ExtensionPackageService( + vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(second), + ); + + const installing = service.install("pi-one"); + const next = service.install("pi-two"); + await Promise.resolve(); + expect(second.installAndPersist).not.toHaveBeenCalled(); + + finishFirst(); + await Promise.all([installing, next]); + expect(second.installAndPersist).toHaveBeenCalledWith("npm:pi-two"); + }); +}); diff --git a/electron/domains/extensions/service.ts b/electron/domains/extensions/service.ts new file mode 100644 index 0000000..65fd362 --- /dev/null +++ b/electron/domains/extensions/service.ts @@ -0,0 +1,115 @@ +import type { PackageManager } from "@earendil-works/pi-coding-agent"; +import { AsyncQueue } from "../../core/asyncQueue.js"; +import { CommandError } from "../../core/errors.js"; +import { extensionManagementForSource } from "./sourcePolicy.js"; + +type ManagedPackageManager = Pick< + PackageManager, + | "installAndPersist" + | "update" + | "removeAndPersist" + | "removeSourceFromSettings" + | "listConfiguredPackages" +>; + +type PackageManagerFactory = () => ManagedPackageManager; +type PreparePackageManager = () => Promise; + +export class ExtensionPackageService { + private readonly queue = new AsyncQueue(); + + constructor( + private readonly createPackageManager: PackageManagerFactory, + private readonly preparePackageManager: PreparePackageManager = async () => {}, + ) {} + + install(packageName: string): Promise { + return this.queue.run(async () => { + assertPackageName(packageName); + await this.preparePackageManager(); + await this.createPackageManager().installAndPersist(`npm:${packageName}`); + }); + } + + update(source: string): Promise { + return this.queue.run(async () => { + await this.preparePackageManager(); + const manager = this.createPackageManager(); + assertManagedNpmSource(manager, source); + await manager.update(source); + }); + } + + remove(source: string): Promise { + return this.queue.run(async () => { + await this.preparePackageManager(); + const manager = this.createPackageManager(); + const { management, removalSource } = assertRemovableSource( + manager, + source, + ); + const removed = + management === "npm" + ? await manager.removeAndPersist(removalSource) + : manager.removeSourceFromSettings(removalSource); + if (!removed) + throw new CommandError("NOT_FOUND", "configured package not found"); + }); + } +} + +export function assertPackageName(value: string): void { + if ( + !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value) || + value.length > 214 + ) + throw new CommandError("INVALID_ARGUMENT", "package name is invalid"); +} + +function configuredUserSource(manager: ManagedPackageManager, source: string) { + const configured = manager + .listConfiguredPackages() + .find((candidate) => candidate.source === source); + if (!configured) + throw new CommandError("NOT_FOUND", "configured package not found"); + if (configured.scope !== "user" || configured.filtered) + throw new CommandError( + "INVALID_ARGUMENT", + "source is not a removable configured user package", + ); + return configured; +} + +function assertManagedNpmSource( + manager: ManagedPackageManager, + source: string, +): void { + configuredUserSource(manager, source); + if (extensionManagementForSource(source) !== "npm") + throw new CommandError( + "INVALID_ARGUMENT", + "source is not a configured user npm package", + ); +} + +function assertRemovableSource( + manager: ManagedPackageManager, + source: string, +): { management: "npm" | "local"; removalSource: string } { + const configured = configuredUserSource(manager, source); + const management = extensionManagementForSource(source); + if (!management) + throw new CommandError( + "INVALID_ARGUMENT", + "source is not a removable configured user package", + ); + if (management === "local" && !configured.installedPath) + throw new CommandError( + "NOT_FOUND", + "configured local package path was not found", + ); + return { + management, + removalSource: management === "local" ? configured.installedPath! : source, + }; +} diff --git a/electron/domains/extensions/sourcePolicy.ts b/electron/domains/extensions/sourcePolicy.ts new file mode 100644 index 0000000..0fd54c0 --- /dev/null +++ b/electron/domains/extensions/sourcePolicy.ts @@ -0,0 +1,20 @@ +export type ExtensionManagement = "npm" | "local"; + +export function extensionManagementForSource( + source: string, +): ExtensionManagement | undefined { + if (source.startsWith("npm:")) return "npm"; + if (isLocalPackageSource(source)) return "local"; + return undefined; +} + +export function isLocalPackageSource(source: string): boolean { + return ( + source.startsWith("./") || + source.startsWith("../") || + source.startsWith("~/") || + source.startsWith("/") || + /^[A-Za-z]:[\\/]/.test(source) || + /^\\\\[^\\]/.test(source) + ); +} diff --git a/electron/domains/settings/index.ts b/electron/domains/settings/index.ts index b5dc50b..ebaab8e 100644 --- a/electron/domains/settings/index.ts +++ b/electron/domains/settings/index.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import { readFile, realpath } from "node:fs/promises"; import path from "node:path"; import { + DefaultPackageManager, DefaultResourceLoader, SettingsManager, type ResolvedResource, @@ -16,6 +17,10 @@ import { resolveSpireExtensions, resolveSpireResources, } from "../chat/spireSettings.js"; +import { + extensionManagementForSource, + type ExtensionManagement, +} from "../extensions/sourcePolicy.js"; export type ExtensionSource = "pi" | "package"; export type ExtensionScope = "global" | "project"; @@ -30,6 +35,8 @@ export interface ExtensionSetting { displayPath: string; enabled: boolean; status: "enabled" | "disabled"; + packageSource?: string; + management?: ExtensionManagement; } export interface SkillSetting { @@ -86,6 +93,7 @@ interface Candidate { source: ExtensionSource; scope: ExtensionScope; defaultEnabled: boolean; + packageSource?: string; } export class SettingsService { @@ -347,10 +355,29 @@ export class SettingsService { strict = false, ): Promise { const candidates = await this.candidates(cwd, strict); + const managedSources = new DefaultPackageManager({ + cwd, + agentDir: this.agentDir, + settingsManager: SettingsManager.create(cwd, this.agentDir, { + projectTrusted: false, + }), + }) + .listConfiguredPackages() + .filter(({ scope, filtered }) => scope === "user" && !filtered) + .flatMap((configured) => { + const management = extensionManagementForSource(configured.source); + return management ? [{ ...configured, management }] : []; + }); return candidates .map((candidate) => { const id = extensionId(candidate); const enabled = candidate.defaultEnabled; + const managedSource = managedSources.find( + ({ source, installedPath, management }) => + source === candidate.packageSource || + (management === "local" && + installedPath === candidate.packageSource), + ); return { id, name: @@ -366,6 +393,12 @@ export class SettingsService { displayPath: abbreviateHome(candidate.path), enabled, status: enabled ? "enabled" : "disabled", + ...(managedSource + ? { + packageSource: managedSource.source, + management: managedSource.management, + } + : {}), } satisfies ExtensionSetting; }) .sort((left, right) => @@ -424,6 +457,9 @@ async function fromPiResource( ...(resource.metadata.origin === "package" && resource.metadata.baseDir ? await packageMetadataAt(resource.metadata.baseDir) : {}), + ...(resource.metadata.origin === "package" + ? { packageSource: resource.metadata.source } + : {}), kind: "user", source: resource.metadata.origin === "package" ? "package" : "pi", scope: "global", diff --git a/electron/domains/settings/settings.test.ts b/electron/domains/settings/settings.test.ts index 36fe968..6b73b70 100644 --- a/electron/domains/settings/settings.test.ts +++ b/electron/domains/settings/settings.test.ts @@ -380,6 +380,60 @@ describe("SettingsService", () => { ).not.toContain("escaped-skill"); }); + it("marks configured user local packages as removable without update support", async () => { + const { cwd, agentDir, statePath } = await fixture(); + const service = await SettingsService.load(statePath, agentDir); + + await expect(service.list(cwd)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "fixture-package", + packageSource: "../package", + management: "local", + }), + ]), + ); + }); + + it("marks configured user npm extensions as package-manager managed", async () => { + const { cwd, agentDir, statePath } = await fixture(); + const packageRoot = path.join( + agentDir, + "npm", + "node_modules", + "pi-managed", + ); + await mkdir(path.join(packageRoot, "extensions"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ + name: "pi-managed", + version: "2.0.0", + pi: { extensions: ["./extensions"] }, + }), + ), + writeFile( + path.join(packageRoot, "extensions", "index.js"), + "export default () => {}", + ), + writeFile( + path.join(agentDir, "settings.json"), + JSON.stringify({ packages: ["npm:pi-managed"] }), + ), + ]); + const service = await SettingsService.load(statePath, agentDir); + + await expect(service.list(cwd)).resolves.toEqual([ + expect.objectContaining({ + name: "pi-managed", + version: "2.0.0", + packageSource: "npm:pi-managed", + management: "npm", + }), + ]); + }); + it("surfaces resource resolution failures instead of reporting an empty catalog", async () => { const { cwd, agentDir, statePath } = await fixture(); await writeFile( diff --git a/electron/ipc.test.ts b/electron/ipc.test.ts index 5e02417..a6ca892 100644 --- a/electron/ipc.test.ts +++ b/electron/ipc.test.ts @@ -131,13 +131,56 @@ describe("IPC command argument validation", () => { ).toThrow("Unexpected argument: apiKey"); }); - it("validates extension settings commands without accepting paths", () => { + it("validates extension settings commands without accepting paths or URLs", () => { expect(() => validateCommandArgs("settings_extensions_list", { worktreeId: "worktree-1", path: "/tmp/extension.ts", }), ).toThrow("Unexpected argument: path"); + expect( + validateCommandArgs("settings_extension_catalog_list", { + query: "web access", + sort: "downloads", + page: 1, + }), + ).toEqual({ query: "web access", sort: "downloads", page: 1 }); + expect( + validateCommandArgs("settings_extension_catalog_list", { + query: "", + sort: "recent", + page: 2, + }), + ).toEqual({ query: "", sort: "recent", page: 2 }); + expect(() => + validateCommandArgs("settings_extension_catalog_list", { + query: "web", + sort: "downloads", + page: 1, + url: "https://evil.example", + }), + ).toThrow("Unexpected argument: url"); + expect( + validateCommandArgs("settings_extension_package_install", { + packageName: "@scope/pi-tools", + }), + ).toEqual({ packageName: "@scope/pi-tools" }); + expect(() => + validateCommandArgs("settings_extension_package_install", { + packageName: "pi-tools", + cwd: "/tmp", + }), + ).toThrow("Unexpected argument: cwd"); + expect( + validateCommandArgs("settings_extension_package_update", { + source: "npm:pi-tools", + }), + ).toEqual({ source: "npm:pi-tools" }); + expect( + validateCommandArgs("settings_extension_package_remove", { + source: "npm:pi-tools", + }), + ).toEqual({ source: "npm:pi-tools" }); }); it("validates skill settings commands without accepting paths", () => { diff --git a/electron/ipc.ts b/electron/ipc.ts index 7c4c8fa..57f7cd1 100644 --- a/electron/ipc.ts +++ b/electron/ipc.ts @@ -370,6 +370,21 @@ async function invoke( worktreeId ? await state.projects.root(worktreeId) : homedir(), ); } + case "settings_extension_catalog_list": + return state.extensionCatalog.list({ + query: text(args, "query", true), + sort: text(args, "sort") as "downloads" | "recent" | "name", + page: number(args, "page"), + }); + case "settings_extension_package_install": + await state.extensionPackages.install(text(args, "packageName")); + return state.settings.list(homedir()); + case "settings_extension_package_update": + await state.extensionPackages.update(text(args, "source")); + return state.settings.list(homedir()); + case "settings_extension_package_remove": + await state.extensionPackages.remove(text(args, "source")); + return state.settings.list(homedir()); case "settings_skills_list": { const worktreeId = text(args, "worktreeId"); return state.settings.listSkills(await state.projects.root(worktreeId)); @@ -481,6 +496,10 @@ const ALLOWED_FIELDS: Record = { chat_session_abort: ["worktreeId", "sessionId"], chat_session_delete: ["worktreeId", "sessionId"], settings_extensions_list: ["worktreeId"], + settings_extension_catalog_list: ["query", "sort", "page"], + settings_extension_package_install: ["packageName"], + settings_extension_package_update: ["source"], + settings_extension_package_remove: ["source"], settings_skills_list: ["worktreeId"], settings_skill_set_enabled: ["worktreeId", "skillId", "enabled"], settings_language_get: [], @@ -556,7 +575,7 @@ export function validateCommandArgs(command: CommandName, args: Args): Args { !(key in args) ) continue; - if (key === "cols" || key === "rows") number(args, key); + if (key === "cols" || key === "rows" || key === "page") number(args, key); else if (key === "force" || key === "enabled" || key === "untracked") boolean(args, key); else if (key === "thinkingLevel") thinkingLevel(args); @@ -575,7 +594,8 @@ export function validateCommandArgs(command: CommandName, args: Args): Args { args, key, (command === "fs_read_dir" && key === "relativePath") || - (command === "settings_memory_models_list" && key === "worktreeId"), + (command === "settings_memory_models_list" && key === "worktreeId") || + (command === "settings_extension_catalog_list" && key === "query"), ); } return args; diff --git a/src/bindings/generated.ts b/src/bindings/generated.ts index 30a7847..d617eb2 100644 --- a/src/bindings/generated.ts +++ b/src/bindings/generated.ts @@ -221,6 +221,30 @@ export interface ExtensionSetting { displayPath: string; enabled: boolean; status: "enabled" | "disabled"; + packageSource?: string; + management?: "npm" | "local"; +} + +export type ExtensionCatalogSort = "downloads" | "recent" | "name"; + +export interface ExtensionCatalogItem { + name: string; + description: string; + publisher: string; + downloads: number; + downloadsLabel: string; + publishedLabel: string; + types: string[]; + catalogUrl: string; + npmUrl?: string; + repositoryUrl?: string; +} + +export interface ExtensionCatalogResult { + items: ExtensionCatalogItem[]; + page: number; + total: number; + totalPages: number; } export interface SkillSetting { diff --git a/src/bindings/index.ts b/src/bindings/index.ts index 58256e8..ec094fa 100644 --- a/src/bindings/index.ts +++ b/src/bindings/index.ts @@ -9,6 +9,8 @@ import type { ChatSessionSummary, ChatThinkingLevel, ChatSnapshot, + ExtensionCatalogResult, + ExtensionCatalogSort, ExtensionSetting, FileContent, FileEntry, @@ -81,6 +83,28 @@ export const commands = { command("settings_extensions_list", { ...(worktreeId ? { worktreeId } : {}), }), + settingsExtensionCatalogList: ( + query: string, + sort: ExtensionCatalogSort, + page: number, + ) => + command("settings_extension_catalog_list", { + query, + sort, + page, + }), + settingsExtensionPackageInstall: (packageName: string) => + command("settings_extension_package_install", { + packageName, + }), + settingsExtensionPackageUpdate: (source: string) => + command("settings_extension_package_update", { + source, + }), + settingsExtensionPackageRemove: (source: string) => + command("settings_extension_package_remove", { + source, + }), settingsSkillsList: (worktreeId: string) => command("settings_skills_list", { worktreeId }), settingsSkillSetEnabled: ( diff --git a/src/features/settings/ExtensionsSettings.tsx b/src/features/settings/ExtensionsSettings.tsx new file mode 100644 index 0000000..7fa475b --- /dev/null +++ b/src/features/settings/ExtensionsSettings.tsx @@ -0,0 +1,545 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import type { + ExtensionCatalogItem, + ExtensionCatalogResult, + ExtensionCatalogSort, + ExtensionSetting, +} from "../../bindings"; +import { useTranslation } from "../../i18n"; +import { commandError } from "../../lib/errors"; +import { settingsApi } from "./settingsApi"; + +type Tab = "marketplace" | "installed"; +type Confirmation = + | { action: "install"; item: ExtensionCatalogItem } + | { action: "remove"; extension: ExtensionSetting } + | null; + +export function ExtensionsSettings({ + worktreeId, +}: { + worktreeId: string | null; +}) { + const { t } = useTranslation(); + const [tab, setTab] = useState("installed"); + const [extensions, setExtensions] = useState([]); + const [installedLoading, setInstalledLoading] = useState(true); + const [installedError, setInstalledError] = useState(null); + const [catalog, setCatalog] = useState(null); + const [catalogLoading, setCatalogLoading] = useState(true); + const [catalogError, setCatalogError] = useState(null); + const [draftQuery, setDraftQuery] = useState(""); + const [query, setQuery] = useState(""); + const [sort, setSort] = useState("downloads"); + const [page, setPage] = useState(1); + const [catalogRevision, setCatalogRevision] = useState(0); + const [mutation, setMutation] = useState(null); + const [mutationError, setMutationError] = useState(null); + const [restartRequired, setRestartRequired] = useState(false); + const [confirmation, setConfirmation] = useState(null); + + useEffect(() => { + let disposed = false; + setInstalledLoading(true); + settingsApi.listExtensions(worktreeId ?? undefined).then( + (items) => { + if (!disposed) { + setExtensions(items); + setInstalledError(null); + setInstalledLoading(false); + } + }, + (failure) => { + if (!disposed) { + setInstalledError(commandError(failure).message); + setInstalledLoading(false); + } + }, + ); + return () => { + disposed = true; + }; + }, [worktreeId]); + + useEffect(() => { + if (tab !== "marketplace") return; + let disposed = false; + setCatalogLoading(true); + setCatalogError(null); + settingsApi.listExtensionCatalog(query, sort, page).then( + (result) => { + if (!disposed) { + setCatalog(result); + setCatalogLoading(false); + } + }, + (failure) => { + if (!disposed) { + setCatalogError(commandError(failure).message); + setCatalogLoading(false); + } + }, + ); + return () => { + disposed = true; + }; + }, [catalogRevision, page, query, sort, tab]); + + const installedNames = useMemo( + () => new Set(extensions.map(({ name }) => name)), + [extensions], + ); + const installedRows = useMemo(() => { + const seenSources = new Set(); + return extensions.filter((extension) => { + if (!extension.packageSource) return true; + if (seenSources.has(extension.packageSource)) return false; + seenSources.add(extension.packageSource); + return true; + }); + }, [extensions]); + + const search = (event: FormEvent) => { + event.preventDefault(); + const next = draftQuery.trim(); + setPage(1); + setQuery(next); + }; + + const completeMutation = (items: ExtensionSetting[]) => { + setExtensions(items); + setRestartRequired(true); + setMutationError(null); + }; + + const install = async (item: ExtensionCatalogItem) => { + if (mutation) return; + setMutation(`install:${item.name}`); + setMutationError(null); + try { + completeMutation(await settingsApi.installExtensionPackage(item.name)); + setConfirmation(null); + } catch (failure) { + setMutationError(commandError(failure).message); + } finally { + setMutation(null); + } + }; + + const update = async (extension: ExtensionSetting) => { + if (mutation || !extension.packageSource) return; + setMutation(`update:${extension.packageSource}`); + setMutationError(null); + try { + completeMutation( + await settingsApi.updateExtensionPackage(extension.packageSource), + ); + } catch (failure) { + setMutationError(commandError(failure).message); + } finally { + setMutation(null); + } + }; + + const remove = async (extension: ExtensionSetting) => { + if (mutation || !extension.packageSource) return; + setMutation(`remove:${extension.packageSource}`); + setMutationError(null); + try { + completeMutation( + await settingsApi.removeExtensionPackage(extension.packageSource), + ); + setConfirmation(null); + } catch (failure) { + setMutationError(commandError(failure).message); + } finally { + setMutation(null); + } + }; + + return ( +
+

{t("settings.section.extensions")}

+

+ {t("settings.extensions.description")} +

+
+ {(["installed", "marketplace"] as const).map((value) => ( + + ))} +
+ {restartRequired && ( +
+ {t("settings.extensions.restartRequired")} +
+ )} + {mutationError &&
{mutationError}
} + + {tab === "marketplace" ? ( +
+
+ {t("settings.extensions.community.title")} + {t("settings.extensions.community.description")} +
+
+ setDraftQuery(event.target.value)} + /> + + +
+ {catalogLoading && ( +
+ {t("settings.extensions.catalog.loading")} +
+ )} + {!catalogLoading && catalogError && ( +
+ + {t("settings.extensions.catalog.failed", { + error: catalogError, + })} + + + + {t("settings.extensions.openCatalog")} + +
+ )} + {!catalogLoading && !catalogError && catalog?.items.length === 0 && ( +
+ {t("settings.extensions.catalog.empty")} +
+ )} + {!catalogLoading && + !catalogError && + catalog && + catalog.items.length > 0 && ( + <> +
+ {catalog.items.map((item) => { + const installed = installedNames.has(item.name); + const installing = mutation === `install:${item.name}`; + return ( +
+
+
+ + {item.name} + + + {t("settings.extensions.community.badge")} + +
+ +
+

{item.description}

+
+ {item.publisher} + {item.downloadsLabel} + {item.publishedLabel} +
+
+ {item.npmUrl && ( + + npm + + )} + {item.repositoryUrl && ( + + {t("settings.extensions.repository")} + + )} +
+
+ ); + })} +
+
+ + + {t("settings.extensions.page", { + page: catalog.page, + total: catalog.totalPages, + })} + + +
+ + )} +
+ ) : ( +
+
+

{t("settings.extensions.user")}

+

{t("settings.extensions.user.description")}

+ {installedLoading && ( +
+ {t("settings.extensions.loading")} +
+ )} + {installedError && ( +
{installedError}
+ )} + {!installedLoading && + !installedError && + installedRows.length === 0 && ( +
+ {t("settings.extensions.user.empty")} +
+ )} + {!installedLoading && + !installedError && + installedRows.length > 0 && ( +
+ {installedRows.map((extension) => ( +
+
+
+ {extension.name} + {extension.version && ( + v{extension.version} + )} + + {extension.management === "local" + ? t("settings.extensions.local") + : extension.source} + +
+ + {extension.displayPath} + + + {t( + extension.enabled + ? "settings.extensions.status.enabled" + : "settings.extensions.status.disabled", + )} + +
+ {extension.management && extension.packageSource && ( +
+ {extension.management === "npm" && ( + + )} + +
+ )} +
+ ))} +
+ )} +
+
+ )} + + !open && !mutation && setConfirmation(null)} + > + + + + +

+ {confirmation?.action === "install" + ? t("settings.extensions.confirm.install.title", { + name: confirmation.item.name, + }) + : confirmation?.action === "remove" + ? t( + confirmation.extension.management === "local" + ? "settings.extensions.confirm.removeLocal.title" + : "settings.extensions.confirm.remove.title", + { name: confirmation.extension.name }, + ) + : ""} +

+
+ +
+ {confirmation?.action === "install" ? ( + <> +

{t("settings.extensions.confirm.install.warning")}

+
+
+
{t("settings.extensions.package")}
+
{confirmation.item.name}
+
+
+
{t("settings.extensions.publisher")}
+
{confirmation.item.publisher}
+
+
+
{t("settings.extensions.scope")}
+
{t("settings.extensions.scope.user")}
+
+
+ + ) : confirmation?.action === "remove" ? ( +

+ {t( + confirmation.extension.management === "local" + ? "settings.extensions.confirm.removeLocal.description" + : "settings.extensions.confirm.remove.description", + )} +

+ ) : null} +
+
+
+ + + + {confirmation?.action === "install" && ( + + )} + {confirmation?.action === "remove" && ( + + )} +
+
+
+
+
+ ); +} diff --git a/src/features/settings/SettingsPage.test.tsx b/src/features/settings/SettingsPage.test.tsx index 958a251..aa44426 100644 --- a/src/features/settings/SettingsPage.test.tsx +++ b/src/features/settings/SettingsPage.test.tsx @@ -29,6 +29,10 @@ vi.mock("./memoryApi", () => ({ vi.mock("./settingsApi", () => ({ settingsApi: { listExtensions: vi.fn(), + listExtensionCatalog: vi.fn(), + installExtensionPackage: vi.fn(), + updateExtensionPackage: vi.fn(), + removeExtensionPackage: vi.fn(), listSkills: vi.fn(), setSkillEnabled: vi.fn(), getLanguage: vi.fn(), @@ -89,6 +93,37 @@ beforeEach(() => { updatedAt: 1, }); vi.mocked(settingsApi.listExtensions).mockResolvedValue([extension]); + vi.mocked(settingsApi.listExtensionCatalog).mockResolvedValue({ + items: [ + { + name: "pi-safe", + description: "Safe community tools.", + publisher: "alice", + downloads: 1200, + downloadsLabel: "1.2K/mo", + publishedLabel: "2d ago", + types: ["extension"], + catalogUrl: "https://pi.dev/packages/pi-safe?type=extension", + npmUrl: "https://www.npmjs.com/package/pi-safe", + repositoryUrl: "https://github.com/example/pi-safe", + }, + ], + page: 1, + total: 1, + totalPages: 1, + }); + vi.mocked(settingsApi.installExtensionPackage).mockResolvedValue([ + { + ...extension, + id: "installed-safe", + name: "pi-safe", + source: "package", + packageSource: "npm:pi-safe", + management: "npm", + }, + ]); + vi.mocked(settingsApi.updateExtensionPackage).mockResolvedValue([extension]); + vi.mocked(settingsApi.removeExtensionPackage).mockResolvedValue([]); vi.mocked(settingsApi.listSkills).mockResolvedValue([]); vi.mocked(settingsApi.setSkillEnabled).mockResolvedValue([]); }); @@ -111,7 +146,19 @@ describe("SettingsPage", () => { ).toEqual(["System", "Light", "Dark"]); }); - it("shows only resolved user resources as a read-only list", async () => { + it("shows Installed first and selects it by default", async () => { + render( undefined} />); + + const tabs = await screen.findAllByRole("tab"); + expect(tabs.map((tab) => tab.textContent)).toEqual([ + "Installed", + "Marketplace", + ]); + expect(tabs[0]).toHaveAttribute("aria-selected", "true"); + expect(settingsApi.listExtensionCatalog).not.toHaveBeenCalled(); + }); + + it("shows resolved user resources in the Installed tab", async () => { render( undefined} />); expect( @@ -127,12 +174,135 @@ describe("SettingsPage", () => { it("shows global user resources without an open project", async () => { render( undefined} />); + fireEvent.click(await screen.findByRole("tab", { name: "Installed" })); expect( await screen.findByText("Configured user resources"), ).toBeInTheDocument(); expect(settingsApi.listExtensions).toHaveBeenCalledWith(undefined); }); + it("browses the Pi Catalog without presenting community packages as verified", async () => { + render( undefined} />); + + fireEvent.click(screen.getByRole("tab", { name: "Marketplace" })); + expect(await screen.findByText("pi-safe")).toBeInTheDocument(); + expect(screen.getByText("Safe community tools.")).toBeInTheDocument(); + expect(screen.getByText("alice")).toBeInTheDocument(); + expect(screen.getByText("Community")).toBeInTheDocument(); + expect(screen.queryByText("Verified")).not.toBeInTheDocument(); + expect(settingsApi.listExtensionCatalog).toHaveBeenCalledWith( + "", + "downloads", + 1, + ); + }); + + it("requires an explicit trust confirmation before installing", async () => { + render( undefined} />); + + fireEvent.click(screen.getByRole("tab", { name: "Marketplace" })); + fireEvent.click( + await screen.findByRole("button", { name: "Install pi-safe" }), + ); + expect( + screen.getByRole("heading", { name: "Install pi-safe?" }), + ).toBeInTheDocument(); + expect( + screen.getByText(/same operating-system permissions as the Pi agent/i), + ).toBeInTheDocument(); + expect(settingsApi.installExtensionPackage).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Install extension" })); + await waitFor(() => + expect(settingsApi.installExtensionPackage).toHaveBeenCalledWith( + "pi-safe", + ), + ); + expect( + await screen.findByText( + /Restart active Agent sessions to apply extension changes/i, + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Installed pi-safe" }), + ).toBeDisabled(); + }); + + it("keeps unmanaged resources read-only and manages configured npm packages", async () => { + const installed = [ + extension, + { + ...extension, + id: "managed", + name: "pi-safe", + source: "package" as const, + packageSource: "npm:pi-safe", + management: "npm" as const, + }, + { + ...extension, + id: "local", + name: "pi-local", + source: "package" as const, + scope: "global" as const, + packageSource: "../../dev/pi-local", + management: "local" as const, + }, + ]; + vi.mocked(settingsApi.listExtensions).mockResolvedValue(installed); + vi.mocked(settingsApi.updateExtensionPackage).mockResolvedValue(installed); + render( undefined} />); + + fireEvent.click(await screen.findByRole("tab", { name: "Installed" })); + expect(await screen.findByText("review-tools")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Update review-tools" }), + ).toBeNull(); + expect( + screen.queryByRole("button", { name: "Update pi-local" }), + ).toBeNull(); + const removeLocal = screen.getByRole("button", { + name: "Remove pi-local from Pi", + }); + expect(removeLocal).toBeInTheDocument(); + vi.mocked(settingsApi.removeExtensionPackage).mockResolvedValueOnce( + installed, + ); + fireEvent.click(removeLocal); + expect( + screen.getByRole("heading", { name: "Remove pi-local from Pi?" }), + ).toBeInTheDocument(); + expect( + screen.getByText(/does not delete or modify the source files/i), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Remove from Pi" })); + await waitFor(() => + expect(settingsApi.removeExtensionPackage).toHaveBeenCalledWith( + "../../dev/pi-local", + ), + ); + + fireEvent.click(screen.getByRole("button", { name: "Update pi-safe" })); + await waitFor(() => + expect(settingsApi.updateExtensionPackage).toHaveBeenCalledWith( + "npm:pi-safe", + ), + ); + + fireEvent.click(screen.getByRole("button", { name: "Uninstall pi-safe" })); + expect( + screen.getByRole("heading", { name: "Uninstall pi-safe?" }), + ).toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: "Uninstall extension" }), + ); + await waitFor(() => + expect(settingsApi.removeExtensionPackage).toHaveBeenCalledWith( + "npm:pi-safe", + ), + ); + }); + it("groups skills and persists a single-skill toggle", async () => { const skills = [ { diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx index e1e7799..6b78756 100644 --- a/src/features/settings/SettingsPage.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -20,7 +20,6 @@ import { import { commands, type AgentReadiness, - type ExtensionSetting, type SkillSetting, } from "../../bindings"; import { commandError } from "../../lib/errors"; @@ -29,6 +28,7 @@ import { useThemeStore, type ThemeMode } from "../theme/themeStore"; import { useSettingsStore } from "./settingsStore"; import { MemorySettings } from "./MemorySettings"; import { settingsApi } from "./settingsApi"; +import { ExtensionsSettings } from "./ExtensionsSettings"; export type SettingsSection = | "general" @@ -611,83 +611,3 @@ function SkillsSettings({ worktreeId }: { worktreeId: string | null }) { ); } - -function ExtensionsSettings({ worktreeId }: { worktreeId: string | null }) { - const { t } = useTranslation(); - const [extensions, setExtensions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let disposed = false; - setLoading(true); - settingsApi.listExtensions(worktreeId ?? undefined).then( - (items) => { - if (!disposed) { - setExtensions(items); - setError(null); - setLoading(false); - } - }, - (failure) => { - if (!disposed) { - setError(commandError(failure).message); - setLoading(false); - } - }, - ); - return () => { - disposed = true; - }; - }, [worktreeId]); - - const extensionList = (items: ExtensionSetting[]) => ( -
- {items.map((extension) => ( -
-
-
- {extension.name} - {extension.version && v{extension.version}} - {extension.source} -
- {extension.displayPath} - - {extension.enabled - ? t("settings.extensions.status.enabled") - : t("settings.extensions.status.disabled")} - -
-
- ))} -
- ); - - return ( -
-

{t("settings.section.extensions")}

-

- {t("settings.extensions.description")} -

- {loading && ( -
{t("settings.extensions.loading")}
- )} - {error &&
{error}
} - {!loading && ( -
-
-

{t("settings.extensions.user")}

-

{t("settings.extensions.user.description")}

- {extensions.length > 0 ? ( - extensionList(extensions) - ) : ( -
- {t("settings.extensions.user.empty")} -
- )} -
-
- )} -
- ); -} diff --git a/src/features/settings/settingsApi.ts b/src/features/settings/settingsApi.ts index 7b19011..537a6a4 100644 --- a/src/features/settings/settingsApi.ts +++ b/src/features/settings/settingsApi.ts @@ -2,6 +2,10 @@ import { commands } from "../../bindings"; export const settingsApi = { listExtensions: commands.settingsExtensionsList, + listExtensionCatalog: commands.settingsExtensionCatalogList, + installExtensionPackage: commands.settingsExtensionPackageInstall, + updateExtensionPackage: commands.settingsExtensionPackageUpdate, + removeExtensionPackage: commands.settingsExtensionPackageRemove, listSkills: commands.settingsSkillsList, setSkillEnabled: commands.settingsSkillSetEnabled, getLanguage: commands.settingsLanguageGet, diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 2e7ec32..0b73177 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -62,8 +62,63 @@ export const en = { "settings.terminal.scrollback": "Scrollback", "settings.terminal.scrollback.aria": "Terminal scrollback", "settings.extensions.description": - "Configured extensions run local code with your OS user permissions. This list is read-only; manage entries in your standard pi settings. Changes apply to new Agent sessions.", + "Browse Pi Catalog extensions and manage user-scoped npm packages. Extension changes apply to new Agent sessions.", + "settings.extensions.tabs": "Extension views", + "settings.extensions.tab.marketplace": "Marketplace", + "settings.extensions.tab.installed": "Installed", "settings.extensions.loading": "Loading extensions…", + "settings.extensions.restartRequired": + "Restart active Agent sessions to apply extension changes.", + "settings.extensions.community.title": "Pi Catalog community packages", + "settings.extensions.community.description": + "Catalog inclusion is not verification. Review the publisher and source before installing.", + "settings.extensions.community.badge": "Community", + "settings.extensions.search": "Search extension packages", + "settings.extensions.search.placeholder": "Search packages…", + "settings.extensions.search.action": "Search", + "settings.extensions.sort": "Sort extension packages", + "settings.extensions.sort.downloads": "Most downloaded", + "settings.extensions.sort.recent": "Recently published", + "settings.extensions.sort.name": "A–Z", + "settings.extensions.catalog.loading": "Loading Pi Catalog…", + "settings.extensions.catalog.failed": "Pi Catalog is unavailable: {error}", + "settings.extensions.catalog.empty": "No extension packages found.", + "settings.extensions.retry": "Retry", + "settings.extensions.openCatalog": "Open Pi Package Catalog", + "settings.extensions.install": "Install", + "settings.extensions.installing": "Installing…", + "settings.extensions.installed": "Installed", + "settings.extensions.install.aria": "Install {name}", + "settings.extensions.installed.aria": "Installed {name}", + "settings.extensions.update": "Update", + "settings.extensions.updating": "Updating…", + "settings.extensions.update.aria": "Update {name}", + "settings.extensions.uninstall": "Uninstall", + "settings.extensions.uninstall.aria": "Uninstall {name}", + "settings.extensions.local": "Local source", + "settings.extensions.removeLocal": "Remove from Pi", + "settings.extensions.removeLocal.aria": "Remove {name} from Pi", + "settings.extensions.repository": "Repository", + "settings.extensions.previous": "Previous", + "settings.extensions.next": "Next", + "settings.extensions.page": "Page {page} of {total}", + "settings.extensions.confirm.install.title": "Install {name}?", + "settings.extensions.confirm.install.warning": + "Extensions run with the same operating-system permissions as the Pi agent. They may access project files, prompts, tool calls, credentials, environment variables, session data, network, and subprocesses. Only install packages you trust.", + "settings.extensions.confirm.install.action": "Install extension", + "settings.extensions.confirm.remove.title": "Uninstall {name}?", + "settings.extensions.confirm.remove.description": + "This removes the user package from Pi settings and its managed installation. Active Agent sessions are not changed.", + "settings.extensions.confirm.remove.action": "Uninstall extension", + "settings.extensions.confirm.removeLocal.title": "Remove {name} from Pi?", + "settings.extensions.confirm.removeLocal.description": + "This removes only the local package reference from Pi settings. It does not delete or modify the source files. Active Agent sessions are not changed.", + "settings.extensions.confirm.removeLocal.action": "Remove from Pi", + "settings.extensions.package": "Package", + "settings.extensions.publisher": "Publisher", + "settings.extensions.scope": "Scope", + "settings.extensions.scope.user": "User", + "settings.extensions.cancel": "Cancel", "settings.extensions.user": "Configured user resources", "settings.extensions.user.description": "Packages and extensions resolved from ~/.pi/agent/settings.json.", diff --git a/src/i18n/zh-CN.ts b/src/i18n/zh-CN.ts index d2c8ea0..80a54bc 100644 --- a/src/i18n/zh-CN.ts +++ b/src/i18n/zh-CN.ts @@ -60,8 +60,63 @@ export const zhCN = { "settings.terminal.scrollback": "回滚行数", "settings.terminal.scrollback.aria": "终端回滚行数", "settings.extensions.description": - "已配置的扩展以当前 OS 用户权限运行本地代码。此列表只读,请在标准 Pi 设置中管理;更改会应用于新的 Agent 会话。", + "浏览 Pi Catalog 扩展并管理用户级 npm 包。扩展更改会应用于新的 Agent 会话。", + "settings.extensions.tabs": "扩展视图", + "settings.extensions.tab.marketplace": "市场", + "settings.extensions.tab.installed": "已安装", "settings.extensions.loading": "正在加载扩展…", + "settings.extensions.restartRequired": + "重启活动中的 Agent 会话以应用扩展更改。", + "settings.extensions.community.title": "Pi Catalog 社区包", + "settings.extensions.community.description": + "Catalog 收录不代表已验证。安装前请检查发布者和源代码。", + "settings.extensions.community.badge": "社区", + "settings.extensions.search": "搜索扩展包", + "settings.extensions.search.placeholder": "搜索包…", + "settings.extensions.search.action": "搜索", + "settings.extensions.sort": "扩展包排序", + "settings.extensions.sort.downloads": "下载量最多", + "settings.extensions.sort.recent": "最近发布", + "settings.extensions.sort.name": "A–Z", + "settings.extensions.catalog.loading": "正在加载 Pi Catalog…", + "settings.extensions.catalog.failed": "Pi Catalog 暂不可用:{error}", + "settings.extensions.catalog.empty": "未找到扩展包。", + "settings.extensions.retry": "重试", + "settings.extensions.openCatalog": "打开 Pi Package Catalog", + "settings.extensions.install": "安装", + "settings.extensions.installing": "正在安装…", + "settings.extensions.installed": "已安装", + "settings.extensions.install.aria": "安装 {name}", + "settings.extensions.installed.aria": "已安装 {name}", + "settings.extensions.update": "更新", + "settings.extensions.updating": "正在更新…", + "settings.extensions.update.aria": "更新 {name}", + "settings.extensions.uninstall": "卸载", + "settings.extensions.uninstall.aria": "卸载 {name}", + "settings.extensions.local": "本地来源", + "settings.extensions.removeLocal": "从 Pi 中移除", + "settings.extensions.removeLocal.aria": "从 Pi 中移除 {name}", + "settings.extensions.repository": "代码仓库", + "settings.extensions.previous": "上一页", + "settings.extensions.next": "下一页", + "settings.extensions.page": "第 {page} 页,共 {total} 页", + "settings.extensions.confirm.install.title": "安装 {name}?", + "settings.extensions.confirm.install.warning": + "扩展与 Pi Agent 拥有相同的操作系统权限,可能访问项目文件、提示词、工具调用、凭据、环境变量、会话数据、网络和子进程。请只安装你信任的包。", + "settings.extensions.confirm.install.action": "安装扩展", + "settings.extensions.confirm.remove.title": "卸载 {name}?", + "settings.extensions.confirm.remove.description": + "这会从 Pi 设置中移除该用户包及其托管安装,不会改变活动中的 Agent 会话。", + "settings.extensions.confirm.remove.action": "卸载扩展", + "settings.extensions.confirm.removeLocal.title": "从 Pi 中移除 {name}?", + "settings.extensions.confirm.removeLocal.description": + "这只会从 Pi 设置中移除本地包引用,不会删除或修改源文件,也不会改变活动中的 Agent 会话。", + "settings.extensions.confirm.removeLocal.action": "从 Pi 中移除", + "settings.extensions.package": "包", + "settings.extensions.publisher": "发布者", + "settings.extensions.scope": "范围", + "settings.extensions.scope.user": "用户", + "settings.extensions.cancel": "取消", "settings.extensions.user": "已配置的用户资源", "settings.extensions.user.description": "从 ~/.pi/agent/settings.json 解析的包和扩展。", diff --git a/src/styles/index.css b/src/styles/index.css index 23fceba..9a42fc5 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -1058,6 +1058,233 @@ body.panel-resizing-vertical * { text-overflow: ellipsis; white-space: nowrap; } +.extensions-tabs { + margin-bottom: 16px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-muted); + display: flex; + gap: 6px; +} +.extensions-tabs button { + min-height: 30px; + padding: 0 11px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + font-size: 11px; + cursor: pointer; +} +.extensions-tabs button:hover, +.extensions-tabs button:focus-visible { + background: var(--interactive-hover); + color: var(--text-default); +} +.extensions-tabs button.active { + border-color: var(--border-default); + background: var(--interactive-selected); + color: var(--text-default); + font-weight: 600; +} +.extension-notice, +.extension-community-note { + margin-bottom: 14px; + padding: 11px 13px; + border: 1px solid var(--border-default); + border-radius: 8px; + background: color-mix(in srgb, var(--elevated-bg) 70%, transparent); + color: var(--text-muted); + font-size: 11px; + line-height: 1.5; +} +.extension-notice { + border-color: var(--accent-muted); + color: var(--text-default); +} +.extension-community-note { + display: grid; + gap: 3px; +} +.extension-community-note b { + color: var(--text-default); + font-size: 11px; +} +.extension-marketplace-controls { + margin-bottom: 14px; + display: grid; + grid-template-columns: minmax(0, 1fr) 150px auto; + gap: 8px; +} +.extension-marketplace-controls input, +.extension-marketplace-controls select, +.extension-marketplace-controls button, +.extension-row-actions button, +.extension-pagination button, +.extension-catalog-error button { + min-height: 32px; + border: 1px solid var(--border-default); + border-radius: 6px; + background: var(--control-bg); + color: var(--text-default); + font: inherit; + font-size: 11px; +} +.extension-marketplace-controls input { + min-width: 0; + padding: 0 10px; +} +.extension-marketplace-controls select { + padding: 0 8px; +} +.extension-marketplace-controls button, +.extension-row-actions button, +.extension-pagination button, +.extension-catalog-error button { + padding: 0 11px; + cursor: pointer; +} +.extension-marketplace-controls :focus-visible, +.extension-row-actions button:focus-visible, +.extension-pagination button:focus-visible, +.extension-catalog-error button:focus-visible { + border-color: var(--accent-muted); + outline: 2px solid var(--focus-ring); +} +.extension-catalog-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.extension-card { + min-width: 0; + padding: 14px; + border: 1px solid var(--border-default); + border-radius: 9px; + display: grid; + gap: 10px; + background: color-mix(in srgb, var(--elevated-bg) 72%, transparent); +} +.extension-card-heading { + min-width: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} +.extension-card-heading > div { + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} +.extension-card-heading a { + overflow-wrap: anywhere; + color: var(--text-default); + font-size: 12px; + font-weight: 650; + text-decoration: none; +} +.extension-card-heading span { + padding: 2px 5px; + border-radius: 4px; + background: var(--control-hover); + color: var(--text-muted); + font-size: 9px; + text-transform: uppercase; +} +.extension-card-heading button { + min-height: 28px; + flex: 0 0 auto; + padding: 0 10px; + border: 1px solid var(--accent-muted); + border-radius: 6px; + background: var(--accent); + color: var(--content-bg); + font-size: 10px; + cursor: pointer; +} +.extension-card-heading button:disabled, +.extension-row-actions button:disabled, +.extension-pagination button:disabled { + cursor: default; + opacity: 0.55; +} +.extension-card > p { + min-height: 34px; + margin: 0; + display: -webkit-box; + overflow: hidden; + color: var(--text-muted); + font-size: 11px; + line-height: 1.5; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} +.extension-card-meta, +.extension-card-links { + display: flex; + flex-wrap: wrap; + gap: 8px; + color: var(--text-subtle); + font-size: 9px; +} +.extension-card-links a, +.extension-catalog-error a { + color: var(--accent); + text-decoration: none; +} +.extension-pagination { + margin-top: 14px; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-muted); + font-size: 10px; +} +.extension-row-actions { + flex: 0 0 auto; + display: flex; + gap: 6px; +} +.extension-catalog-error { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} +.extension-confirmation-dialog { + width: min(480px, calc(100vw - 32px)); +} +.extension-confirmation-dialog dl { + margin: 14px 0 0; + display: grid; + gap: 6px; +} +.extension-confirmation-dialog dl > div { + display: grid; + grid-template-columns: 80px minmax(0, 1fr); + gap: 8px; + font-size: 11px; +} +.extension-confirmation-dialog dt { + color: var(--text-subtle); +} +.extension-confirmation-dialog dd { + margin: 0; + overflow-wrap: anywhere; +} +.extension-confirmation-dialog .chat-delete-actions button.primary { + border-color: var(--accent-muted); + background: var(--accent); + color: var(--content-bg); +} +@media (max-width: 1100px) { + .extension-catalog-grid { + grid-template-columns: minmax(0, 1fr); + } +} .skill-description { overflow: hidden; color: var(--text-muted);