diff --git a/.agents/skills/interactive-testing/scripts/poracode-integration-smoke.mjs b/.agents/skills/interactive-testing/scripts/poracode-integration-smoke.mjs index 3eabb8c28..1872efa65 100644 --- a/.agents/skills/interactive-testing/scripts/poracode-integration-smoke.mjs +++ b/.agents/skills/interactive-testing/scripts/poracode-integration-smoke.mjs @@ -412,6 +412,7 @@ async function settingsScenario(client) { "agentsGeneral", "skills", "mcpServers", + "plugins", "browser", "usage", "archived", @@ -421,6 +422,7 @@ async function settingsScenario(client) { let mcpListScreenshotPath; let mcpScreenshotPath; let mcpImportScreenshotPath; + let pluginsScreenshotPath; let skillsScreenshotPath; let skillsImportScreenshotPath; let skillsImportDestinationsScreenshotPath; @@ -477,6 +479,9 @@ async function settingsScenario(client) { await mcpServersSectionDeepDive(client, mcpFixture)); } } + if (section === "plugins") { + ({ pluginsScreenshotPath } = await pluginsSectionDeepDive(client)); + } } const screenshotPath = join(outDir, "smoke-02-settings.png"); await screenshot(client, screenshotPath); @@ -489,6 +494,7 @@ async function settingsScenario(client) { ...(mcpListScreenshotPath ? { mcpListScreenshotPath } : {}), ...(mcpScreenshotPath ? { mcpScreenshotPath } : {}), ...(mcpImportScreenshotPath ? { mcpImportScreenshotPath } : {}), + ...(pluginsScreenshotPath ? { pluginsScreenshotPath } : {}), ...(skillsScreenshotPath ? { skillsScreenshotPath } : {}), ...(skillsImportScreenshotPath ? { skillsImportScreenshotPath } : {}), ...(skillsImportDestinationsScreenshotPath ? { skillsImportDestinationsScreenshotPath } : {}), @@ -497,6 +503,156 @@ async function settingsScenario(client) { }; } +async function pluginsSectionDeepDive(client) { + const pluginId = "browser-tools"; + const marketplaceState = await waitForValue( + () => + evaluate( + client, + `(() => { + const search = document.querySelector('[aria-label="Search plugins"]'); + const action = document.querySelector("#plugin-browser-tools-action"); + return { + visible: Boolean(search && !search.closest("[hidden]")), + pluginCount: document.querySelectorAll("[data-plugin-id]").length, + action: action?.textContent?.trim(), + initialInstalled: window.__poracodeDev.stores.sharedSettings.getState().installedPlugins["browser-tools"] !== undefined, + }; + })()`, + ), + (state) => state.visible && state.pluginCount > 0 && Boolean(state.action), + "plugins marketplace", + ); + assert( + marketplaceState.action === (marketplaceState.initialInstalled ? "Manage" : "Install"), + `Browser Tools marketplace action did not match install state: ${JSON.stringify(marketplaceState)}`, + ); + + let detailOpened = false; + try { + const opened = await evaluate( + client, + `(() => { + const action = document.querySelector("#plugin-browser-tools-action")?.closest("button"); + if (!(action instanceof HTMLButtonElement)) return false; + action.click(); + return true; + })()`, + ); + assert(opened, "Browser Tools marketplace action was unavailable"); + detailOpened = true; + + const detailState = await waitForValue( + () => + evaluate( + client, + `(() => { + const buttonText = [...document.querySelectorAll("button")].map((button) => button.textContent?.trim()); + const headings = [...document.querySelectorAll("h2")].map((heading) => heading.textContent?.trim()); + const switchNames = [...document.querySelectorAll('[role="switch"]')].map((control) => + (control.getAttribute("aria-labelledby") ?? "") + .split(/\\s+/u) + .map((id) => document.getElementById(id)?.textContent?.trim() ?? "") + .filter(Boolean) + .join(" "), + ); + return { + installed: window.__poracodeDev.stores.sharedSettings.getState().installedPlugins["browser-tools"] !== undefined, + back: buttonText.includes("Back to plugins"), + uninstall: buttonText.includes("Uninstall"), + mcpServers: headings.includes("MCP servers") && document.body.innerText.includes("Browser"), + skills: headings.includes("Skills") && document.body.innerText.includes("Browser Control"), + bundledMcpHasNoSeparateSwitch: !switchNames.includes("Browser MCP"), + skillSwitch: switchNames.includes("Browser Control Skill"), + }; + })()`, + ), + (state) => + state.installed && + state.back && + state.uninstall && + state.mcpServers && + state.skills && + state.bundledMcpHasNoSeparateSwitch && + state.skillSwitch, + "Browser Tools plugin detail", + ); + assert( + detailState.mcpServers && detailState.skills, + "Browser Tools contributions did not render", + ); + assert( + detailState.bundledMcpHasNoSeparateSwitch && detailState.skillSwitch, + "Browser Tools contribution controls did not match the combined plugin contract", + ); + + const pluginsScreenshotPath = join(outDir, "smoke-02-plugins.png"); + await screenshot(client, pluginsScreenshotPath); + + if (!marketplaceState.initialInstalled) { + const uninstalled = await evaluate( + client, + `(() => { + const button = [...document.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === "Uninstall", + ); + if (!(button instanceof HTMLButtonElement)) return false; + button.click(); + return true; + })()`, + ); + assert(uninstalled, "Browser Tools uninstall action was unavailable"); + await waitForValue( + () => + evaluate( + client, + `window.__poracodeDev.stores.sharedSettings.getState().installedPlugins["browser-tools"] === undefined`, + ), + Boolean, + "Browser Tools install-state restoration", + ); + } + + const restored = await evaluate( + client, + `window.__poracodeDev.stores.sharedSettings.getState().installedPlugins[${JSON.stringify(pluginId)}] !== undefined`, + ); + assert( + restored === marketplaceState.initialInstalled, + "Browser Tools install state was not restored", + ); + return { pluginsScreenshotPath }; + } finally { + await evaluate( + client, + `(() => { + const store = window.__poracodeDev.stores.sharedSettings.getState(); + const plugin = window.__poracodeDev.stores.plugins + .getState() + .plugins.find((candidate) => candidate.name === ${JSON.stringify(pluginId)}); + if (!plugin) return; + const installed = store.installedPlugins[${JSON.stringify(pluginId)}] !== undefined; + if (${JSON.stringify(marketplaceState.initialInstalled)} && !installed) { + store.installPlugin(plugin); + } else if (!${JSON.stringify(marketplaceState.initialInstalled)} && installed) { + store.uninstallPlugin(plugin); + } + })()`, + ); + if (detailOpened) { + await evaluate( + client, + `(() => { + const button = [...document.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === "Back to plugins", + ); + if (button instanceof HTMLButtonElement) button.click(); + })()`, + ); + } + } +} + async function skillsSectionDeepDive(client) { const toolbarState = await evaluate( client, diff --git a/.agents/skills/interactive-testing/scripts/smoke-scenarios.mjs b/.agents/skills/interactive-testing/scripts/smoke-scenarios.mjs index 2e02708ad..93531da54 100644 --- a/.agents/skills/interactive-testing/scripts/smoke-scenarios.mjs +++ b/.agents/skills/interactive-testing/scripts/smoke-scenarios.mjs @@ -94,6 +94,17 @@ export const functionalAreas = [ automated: ["baseline", "settings"], manual: [], }, + { + id: "plugins-marketplace", + title: "Plugin marketplace, installation, and bundled MCP and skill contributions", + patterns: [ + /components\/plugins\//i, + /shared\/(?:contracts\/plugin|plugins\/)/i, + /PluginsSettings/i, + ], + automated: ["settings"], + manual: [], + }, { id: "updates-auth-usage", title: "Updates, authentication, usage, notifications, and diagnostics", diff --git a/resources/plugins/browser-tools/plugin.json b/resources/plugins/browser-tools/plugin.json new file mode 100644 index 000000000..62665663d --- /dev/null +++ b/resources/plugins/browser-tools/plugin.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "browser-tools", + "version": "1.1.0", + "description": "Browse, inspect, and test websites in Poracode's isolated in-app browser.", + "author": { + "name": "Poracode", + "url": "https://poracode.com" + }, + "homepage": "https://poracode.com", + "license": "Apache-2.0", + "keywords": ["browser", "testing", "automation"], + "extensions": { + "com.poracode.client": { + "title": "Browser Tools", + "category": "developer-tools", + "featured": true, + "examplePrompt": "Open my local app in Poracode's browser, test the requested flow, and report visual, console, and network evidence", + "coreSkill": "browser-control", + "nativePluginNames": ["browser"], + "nativeCoreSkill": "control-in-app-browser", + "builtInMcpServerIds": ["browser"], + "skills": { + "browser-control": { + "name": "Browser Control", + "description": "Navigate, inspect, and test pages with the in-app Browser MCP.", + "nativePluginName": "browser", + "nativeSkill": "control-in-app-browser" + } + } + } + } +} diff --git a/resources/plugins/browser-tools/skills/browser-control/SKILL.md b/resources/plugins/browser-tools/skills/browser-control/SKILL.md new file mode 100644 index 000000000..7cd4fb07d --- /dev/null +++ b/resources/plugins/browser-tools/skills/browser-control/SKILL.md @@ -0,0 +1,29 @@ +--- +name: browser-control +description: Open, inspect, interact with, and verify websites or local web apps in Poracode's isolated browser. Use for visible page state, navigation, screenshots, console or network evidence, and end-to-end UI testing; do not use it for semantic service operations when a purpose-built connector is available. +--- + +# Browser Control + +Use Poracode's `browser` MCP when the task depends on a rendered page, visible interaction, or local web app. If the request is really about structured data or a service operation and a purpose-built connector is available, use that connector instead. An explicit request for Poracode's browser wins. + +## Workflow + +1. Call `browser.api` when you need the current API map, then call `browser.enable` once before the first browser action. +2. Reuse a relevant tab from `browser.list_tabs`; otherwise open the exact URL the user supplied or the known local target. Do not guess a remote site or substitute web search when authentication blocks the requested page. +3. Establish the baseline with the current URL plus `browser.snapshot` or `browser.find`. Prefer accessible roles, names, and returned element refs over brittle selectors or coordinates. +4. Perform the smallest meaningful action. Use `fill` when replacing a field and `type` only when appending is intended. +5. After every navigation or state-changing action, wait for the expected URL, text, or element and inspect the resulting state. For web-app verification, also check relevant console errors and failed network requests. +6. Capture a screenshot when visual layout or appearance is part of the requirement. +7. Call `browser.disable` before asking the user for input, waiting on an external event, or finishing. + +## Boundaries + +- The in-app browser is isolated from the user's personal Chrome profile. Do not assume it contains existing logins, cookies, or extensions. +- Never inspect cookies or storage unless the task requires it and the user authorized that data access. +- A successful click is not proof of success. Verify the user-visible or application state it was meant to produce. +- Pause before purchases, submissions, messages, deletions, or other irreversible external actions unless the user explicitly authorized that exact action. + +## Output + +Report the tested URL and flow, the final observed state, and the evidence used. Separate visual, console, and network findings, and state any step that could not be verified. diff --git a/resources/plugins/chrome-tools/plugin.json b/resources/plugins/chrome-tools/plugin.json new file mode 100644 index 000000000..b87255045 --- /dev/null +++ b/resources/plugins/chrome-tools/plugin.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "chrome-tools", + "version": "1.1.0", + "description": "Work with the pages and signed-in sessions already open in Chrome.", + "author": { + "name": "Poracode", + "url": "https://poracode.com" + }, + "homepage": "https://poracode.com", + "license": "Apache-2.0", + "keywords": ["chrome", "browser", "automation"], + "extensions": { + "com.poracode.client": { + "title": "Chrome Tools", + "category": "automation", + "featured": true, + "examplePrompt": "Use my existing Chrome session to complete this browser task and verify the final visible state", + "projectKinds": ["windows", "posix"], + "coreSkill": "chrome-control", + "nativePluginNames": ["chrome"], + "nativeCoreSkill": "control-chrome", + "builtInMcpServerIds": ["chrome"], + "skills": { + "chrome-control": { + "name": "Chrome Control", + "description": "Use Chrome safely when a task needs an existing browser session.", + "nativePluginName": "chrome", + "nativeSkill": "control-chrome" + } + } + } + } +} diff --git a/resources/plugins/chrome-tools/skills/chrome-control/SKILL.md b/resources/plugins/chrome-tools/skills/chrome-control/SKILL.md new file mode 100644 index 000000000..8b267f760 --- /dev/null +++ b/resources/plugins/chrome-tools/skills/chrome-control/SKILL.md @@ -0,0 +1,28 @@ +--- +name: chrome-control +description: Use the user's real Chrome tabs and signed-in sessions for visible browser workflows. Use when existing authentication, open tabs, cookies, or extensions matter; prefer a purpose-built connector for semantic service operations unless the user explicitly asks for Chrome. +--- + +# Chrome Control + +Use Poracode's `chrome` MCP when a task depends on the user's current Chrome tabs, authenticated sessions, or installed extensions. If a purpose-built connector can complete a semantic service operation, prefer it unless the user explicitly requested Chrome or visual interaction is part of the task. + +## Workflow + +1. Call `chrome.chrome_status` first. If the extension is disconnected, ask the user to connect it rather than switching surfaces silently. +2. Call `chrome.enable` once before browser actions. Use the background Poracode workspace by default; call `chrome.chrome_attach` only when the user asked to operate an existing tab. +3. Inspect with `chrome.chrome_snapshot` or `chrome.chrome_find` before clicking or typing. Prefer returned element refs and use `chrome_fill` for replacement versus `chrome_type` for appending. +4. Keep every action scoped to the requested site and task. Do not explore other tabs or signed-in content for extra context. +5. After every meaningful action, wait for and verify the resulting URL, text, control state, or screenshot. +6. Call `chrome.disable` before asking the user for input, waiting on an external event, or finishing. + +## Boundaries + +- Treat tabs, cookies, storage, and signed-in content as sensitive user data. Do not read cookies unless the task requires it and Chrome data access is enabled. +- Never attach to an unrelated existing tab merely because it is already authenticated. +- A successful tool call is not proof that the website accepted the action; verify the visible result. +- Confirm the exact target and payload before purchases, submissions, messages, deletions, account changes, or other irreversible actions unless already authorized. + +## Output + +Report the target site or tab, what changed, and the final visible evidence. State whether the background workspace or an existing user tab was used, and identify any action left pending for confirmation. diff --git a/resources/plugins/computer-use/plugin.json b/resources/plugins/computer-use/plugin.json new file mode 100644 index 000000000..2f942ce16 --- /dev/null +++ b/resources/plugins/computer-use/plugin.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "computer-use", + "version": "1.1.0", + "description": "Control desktop apps and complete visual workflows.", + "author": { + "name": "Poracode", + "url": "https://poracode.com" + }, + "homepage": "https://poracode.com", + "license": "Apache-2.0", + "keywords": ["desktop", "automation", "computer-use"], + "extensions": { + "com.poracode.client": { + "title": "Computer Use", + "category": "automation", + "featured": true, + "examplePrompt": "Operate the requested desktop app in small verified steps and report the final window state", + "platforms": ["win32", "darwin"], + "projectKinds": ["windows", "posix"], + "coreSkill": "computer-use", + "nativePluginNames": ["computer-use"], + "nativeCoreSkill": "computer-use", + "builtInMcpServerIds": ["computer-use"], + "skills": { + "computer-use": { + "name": "Computer Use", + "description": "Operate desktop apps through Poracode's desktop-control tools.", + "nativePluginName": "computer-use", + "nativeSkill": "computer-use" + } + } + } + } +} diff --git a/resources/plugins/computer-use/skills/computer-use/SKILL.md b/resources/plugins/computer-use/skills/computer-use/SKILL.md new file mode 100644 index 000000000..db2fab714 --- /dev/null +++ b/resources/plugins/computer-use/skills/computer-use/SKILL.md @@ -0,0 +1,28 @@ +--- +name: computer-use +description: Inspect and operate native Windows or macOS applications through Poracode's desktop-control tools. Use for visual workflows that require real windows; prefer Browser for web pages and a purpose-built connector or API when one can complete the task directly. +--- + +# Computer Use + +Use Poracode's `computer_use` MCP for tasks that require interacting with desktop applications or native windows. Do not use it for a web page when Browser or Chrome is the intended surface, or for a semantic operation that a safer purpose-built connector can perform. + +## Workflow + +1. Call `computer_use.api` when you need the API map, then list applications and windows and select the exact target. +2. Capture `computer_use.get_window_state` before coordinate input. Use its returned window object and screenshot coordinates; refresh the window if it moved, resized, or became stale. +3. Call `computer_use.enable` immediately before the first interactive action. Keep the session enabled across uninterrupted related steps. +4. Prefer accessibility text, named controls, and reliable keyboard shortcuts. When coordinates are necessary, derive them from the latest screenshot rather than guessing. +5. Use small actions and inspect the window again after each meaningful change. Re-resolve the window after application navigation that may recreate it. +6. Call `computer_use.disable` before asking the user for input, waiting on an external event, or finishing. + +## Boundaries + +- Interactive actions take control of the real mouse and keyboard and bring the target window to the foreground. Avoid unnecessary actions and do not operate a different application. +- Locked desktops, secure prompts, operating-system permission dialogs, passwords, and authentication surfaces require the user. +- Do not type or expose secrets unless the user supplied them for that exact purpose. +- Confirm before destructive changes or external communication unless the user already authorized the exact action. + +## Output + +Report the application and window used, the verified final state, and any step requiring user interaction. Do not claim completion from input dispatch alone. diff --git a/resources/plugins/github/mcp.json b/resources/plugins/github/mcp.json new file mode 100644 index 000000000..86e6bd64a --- /dev/null +++ b/resources/plugins/github/mcp.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "github": { + "type": "streamable-http", + "url": "https://api.githubcopilot.com/mcp/" + } + } +} diff --git a/resources/plugins/github/plugin.json b/resources/plugins/github/plugin.json new file mode 100644 index 000000000..8828963ad --- /dev/null +++ b/resources/plugins/github/plugin.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "github", + "version": "1.1.0", + "description": "Triage PRs, issues, CI, and publish flows.", + "author": { "name": "GitHub", "url": "https://github.com" }, + "homepage": "https://github.com/github/github-mcp-server", + "repository": "https://github.com/github/github-mcp-server", + "license": "MIT", + "keywords": ["github", "pull requests", "issues", "ci", "code review"], + "extensions": { + "com.poracode.client": { + "title": "GitHub", + "category": "developer-tools", + "featured": true, + "coreSkill": "github", + "nativeCoreSkill": "github", + "examplePrompt": "Inspect PRs, triage issues, debug failing checks, and prepare code changes for review", + "skills": { + "github": { + "name": "GitHub", + "description": "Inspect PRs, issues, CI, and publish flows.", + "nativePluginName": "github", + "nativeSkill": "github" + }, + "review-follow-up": { + "name": "Review Follow-up", + "description": "Address actionable PR feedback.", + "nativePluginName": "github", + "nativeSkill": "gh-address-comments" + }, + "ci-debug": { + "name": "CI Debug", + "description": "Debug failing GitHub Actions checks.", + "nativePluginName": "github", + "nativeSkill": "gh-fix-ci" + }, + "publish-changes": { + "name": "Publish Changes", + "description": "Commit, push, and open a PR.", + "nativePluginName": "github", + "nativeSkill": "yeet" + } + } + } + } +} diff --git a/resources/plugins/github/skills/ci-debug/SKILL.md b/resources/plugins/github/skills/ci-debug/SKILL.md new file mode 100644 index 000000000..410caa8ed --- /dev/null +++ b/resources/plugins/github/skills/ci-debug/SKILL.md @@ -0,0 +1,52 @@ +--- +name: ci-debug +description: "Diagnose a failing GitHub Actions check by reading the real logs and finding the first true failure." +--- + +# CI Debug + +Find out why a check is failing, from evidence rather than from the check's name. + +## Find the real failure + +Start from the check run, get its job, and read the **log**. A job summary tells you something failed; the log tells +you what. + +Scan for the _first_ failure, not the loudest one. A long red log is usually one root cause followed by cascading +noise — later errors are often just the same failure surfacing again downstream. + +Note the step, the command, and the exact error text. Copy the error verbatim; do not paraphrase it into something +that sounds cleaner than it was. + +## Separate the failure from the change + +Before blaming the diff, check whether the same job fails on the base branch or on unrelated recent runs. Flaky and +pre-existing failures look identical to real ones in a single run. + +Say explicitly which of these you concluded: + +- the change caused it, +- it was already broken, +- it is flaky (and what evidence supports that), +- you could not tell from the available logs. + +"I could not tell" is a legitimate answer. An invented root cause is not. + +## Environment differences + +When something passes locally and fails in CI, compare the things that actually differ: OS and runner image, tool and +dependency versions, environment variables and secrets, working directory, and whether the job runs against a merge +commit rather than the branch head. + +## Fix and verify + +Propose the narrowest fix that addresses the root cause. Re-run only the failed jobs when you can. If a re-run is +needed to confirm a flake, say that is what you are doing and why. + +Do not re-run, cancel, or dispatch a workflow merely to gather more evidence unless the user authorized that GitHub +action. When a fix is local, run the closest equivalent check before asking CI to confirm it. + +## Report + +Lead with the root cause and the evidence line from the log. Then the fix. If the fix is unverified because CI has not +re-run yet, say so. diff --git a/resources/plugins/github/skills/github/SKILL.md b/resources/plugins/github/skills/github/SKILL.md new file mode 100644 index 000000000..311e9a3ad --- /dev/null +++ b/resources/plugins/github/skills/github/SKILL.md @@ -0,0 +1,51 @@ +--- +name: github +description: "Inspect repositories, review pull requests, triage issues, and follow CI through the GitHub MCP server." +--- + +# GitHub + +Work with GitHub through the connected `github` MCP server. Prefer its tools over shelling out to `gh` or `git` — +they return structured data and work without a local checkout. + +Use this core skill to orient the repository and route the request. Use `review-follow-up` for review threads, +`ci-debug` for failing Actions checks, and `publish-changes` for commit/push/PR work. Do not blend a read-only +inspection request into a publishing workflow. + +## Before you start + +Confirm the server is connected. If its tools are unavailable, say so and stop rather than silently falling back to +guesswork; the user connects it from **Settings → Plugins → GitHub**. + +Establish the target repository once, from the user's words or the current project's `origin` remote, and reuse it. +Do not guess an owner or repo name. + +## Reading + +- Read the pull request or issue body **and** its comments before forming an opinion. Review threads carry the + decisions; the description is often stale. +- For a PR, read the diff before the discussion. Someone's summary of a change is not the change. +- Quote file and line when you reference code, so the user can jump to it. +- Resolve repository, pull request, issue, check, and ref identifiers once and reuse the exact identifiers. If more + than one target plausibly matches, ask rather than acting on the first search result. + +## Writing + +Anything that other people will see — a comment, a review, a label change, a merge — is outward-facing. Confirm the +exact text and target with the user before posting, unless they have already told you to go ahead. Approval to post one +comment is not approval to post the next one. + +Never merge, close, or force-push on the user's behalf without them asking for that specific action. + +## Reporting + +Give the answer, not a transcript of your API calls. When you list PRs or issues, include number, title, author, and +current state, and lead with whatever the user actually asked about. + +For any mutation, read back the resulting GitHub state and link the exact repository object. A successful tool call +without the expected state is not verification. + +## Related skills + +`review-follow-up` for working through review feedback, `ci-debug` for failing checks, `publish-changes` for +committing and opening a PR. diff --git a/resources/plugins/github/skills/publish-changes/SKILL.md b/resources/plugins/github/skills/publish-changes/SKILL.md new file mode 100644 index 000000000..274e013b1 --- /dev/null +++ b/resources/plugins/github/skills/publish-changes/SKILL.md @@ -0,0 +1,40 @@ +--- +name: publish-changes +description: "Commit work, push a branch, and open a pull request with an accurate description." +--- + +# Publish Changes + +Get finished work onto a branch and into a pull request. + +## Before committing + +Committing and pushing are the user's call. Do them when asked, not because the work looks done. + +Check the current branch first. If it is the default branch, create a new one instead of committing to it. + +Review what is actually staged. Never `git add -A` over a tree you have not looked at — stray artifacts, local config, +and secrets get committed that way. Stage the files you changed on purpose. + +## The commit + +Write a message that says what changed and why, in the style already used in the repository's history. Match its +existing conventions rather than importing your own. + +Do not skip hooks or bypass signing. If a hook fails, fix what it caught. + +## The pull request + +The description should let a reviewer understand the change without reading every line of the diff: what it does, why, +and anything that needs a decision. Note what you did not do — deliberate omissions, follow-ups, known gaps. + +Do not describe tests as passing unless you ran them and saw them pass. If something is unverified, say which part and +why. + +When the user asked you to publish the finished work, that authorizes the commit, push, and draft PR described by this +workflow. Ask only when the branch, included changes, target repository/base, or PR content is materially ambiguous. +Read the created PR back after opening it and verify its head, base, title, and URL. + +## After + +Report the branch name and the PR URL. If CI starts and fails, `ci-debug` covers the diagnosis. diff --git a/resources/plugins/github/skills/review-follow-up/SKILL.md b/resources/plugins/github/skills/review-follow-up/SKILL.md new file mode 100644 index 000000000..76c71b3cd --- /dev/null +++ b/resources/plugins/github/skills/review-follow-up/SKILL.md @@ -0,0 +1,44 @@ +--- +name: review-follow-up +description: "Work through pull request review feedback: sort what is actionable, fix it, and reply accurately." +--- + +# Review Follow-up + +Turn review comments on a pull request into landed changes. + +## Collect the feedback + +Read every review thread, not just the top-level review summaries. Include threads marked resolved only if the user +asks — a resolved thread usually means it is already handled. + +Sort each comment into one of three buckets and say which is which: + +- **Actionable** — a concrete change is being requested. +- **Question** — the reviewer wants an explanation, not a diff. +- **Note** — an observation with no change implied. + +## Work the actionable ones + +Fix them in the code, not in the reply. Group related comments so you make one coherent change rather than several +overlapping ones. + +If you disagree with a comment, say so to the user with your reasoning and let them decide. Do not silently skip it, +and do not implement something you believe is wrong without flagging it. + +If a comment is ambiguous enough that two readings lead to different code, ask rather than guess. + +## Reply + +Reply once per thread, after the change exists. State what you changed and where. Do not claim a comment is addressed +until the code is actually written. + +Replies are outward-facing: show the user the text before posting. + +Before posting, re-read the thread and the current diff so the reply describes the change that actually exists. After +posting, verify the reply is attached to the intended thread; do not resolve a thread unless the user requested it. + +## Report + +List what you changed, what you answered without changing, and what you deliberately left — with the reason. If some +feedback is still open, say that plainly instead of implying the review is fully handled. diff --git a/resources/plugins/outlook/mcp.json b/resources/plugins/outlook/mcp.json new file mode 100644 index 000000000..24a91547a --- /dev/null +++ b/resources/plugins/outlook/mcp.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "outlook": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@softeria/ms-365-mcp-server"], + "env": { + "MS365_MCP_CACHE_DIR": "${PLUGIN_DATA}" + } + } + } +} diff --git a/resources/plugins/outlook/plugin.json b/resources/plugins/outlook/plugin.json new file mode 100644 index 000000000..83769f019 --- /dev/null +++ b/resources/plugins/outlook/plugin.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "outlook", + "version": "1.1.0", + "description": "Triage Microsoft Outlook mail and manage your calendar.", + "author": { + "name": "Softeria", + "url": "https://github.com/softeria" + }, + "homepage": "https://github.com/softeria/ms-365-mcp-server", + "repository": "https://github.com/softeria/ms-365-mcp-server", + "license": "MIT", + "keywords": ["outlook", "email", "calendar", "microsoft 365"], + "extensions": { + "com.poracode.client": { + "title": "Outlook", + "category": "communication", + "featured": true, + "projectKinds": ["windows", "posix"], + "communityMaintained": true, + "coreSkill": "outlook-email", + "nativePluginNames": ["outlook-email", "outlook-calendar"], + "nativeCoreSkill": "outlook-email", + "examplePrompt": "Triage my inbox, summarize the important threads, and show what's on my calendar today", + "skills": { + "outlook-email": { + "name": "Outlook Email", + "description": "Triage inboxes, summarize threads, and draft replies.", + "nativePluginName": "outlook-email", + "nativeSkill": "outlook-email" + }, + "outlook-calendar": { + "name": "Outlook Calendar", + "description": "Read your schedule, find times, and manage events.", + "nativePluginName": "outlook-calendar", + "nativeSkill": "outlook-calendar" + } + } + } + } +} diff --git a/resources/plugins/outlook/skills/outlook-calendar/SKILL.md b/resources/plugins/outlook/skills/outlook-calendar/SKILL.md new file mode 100644 index 000000000..09725e6d1 --- /dev/null +++ b/resources/plugins/outlook/skills/outlook-calendar/SKILL.md @@ -0,0 +1,48 @@ +--- +name: outlook-calendar +description: "Read an Outlook calendar, find meeting times, and create or move events through the Microsoft 365 MCP server." +--- + +# Outlook Calendar + +Work with the user's Outlook calendar through the connected `outlook` MCP server. + +## Time zones + +Get this right or everything else is wrong. Establish the user's time zone before reading or writing anything, and +state times in it. When a meeting involves other people, say the time in each relevant zone rather than assuming +everyone shares the user's. + +Watch for all-day events and multi-day events — they do not behave like timed blocks when you are looking for a gap. + +## Reading + +Report the schedule as blocks of committed time and gaps between them, not as a list of API records. Include what the +user asked for and skip the rest. + +Declined and tentative events are not the same as accepted ones. Say which is which when it affects availability. + +## Finding times + +A gap on the calendar is not automatically a good time. Respect working hours, leave room around back-to-back +meetings, and flag when the only options are early, late, or over lunch. + +When you propose slots, give a few concrete options with dates and times, not a description of your search. + +## Writing + +Creating, moving, or cancelling an event notifies other people. If the user explicitly asked for the action and gave +an exact time, duration, title, and attendee list, perform it. Otherwise confirm only the missing or ambiguous details +before writing. + +Moving a meeting the user does not organize can be disruptive — say so before doing it rather than after. + +Never decline or accept an invitation on the user's behalf unless they asked for that specific response. + +## Report + +State what you found or what you changed, with times in the user's zone. If you could not find a workable slot, say +that plainly and show the constraint that blocked it. + +After a write, read back the event and verify its organizer, attendees, start, end, recurrence, and time zone. A +successful API response without the expected calendar state is not completion. diff --git a/resources/plugins/outlook/skills/outlook-email/SKILL.md b/resources/plugins/outlook/skills/outlook-email/SKILL.md new file mode 100644 index 000000000..5d4f41ef5 --- /dev/null +++ b/resources/plugins/outlook/skills/outlook-email/SKILL.md @@ -0,0 +1,55 @@ +--- +name: outlook-email +description: "Triage an Outlook inbox, summarize threads, and draft replies through the Microsoft 365 MCP server." +--- + +# Outlook Email + +Work with the user's Outlook mail through the connected `outlook` MCP server. + +## Before you start + +The server signs in on first use with a device code — the user completes that in a browser. If its tools are +unavailable or unauthenticated, say so and stop. Do not attempt to reach mail any other way. + +## Reading is not free + +This is the user's real mailbox. Read what the task needs and no more. Do not open unrelated threads to "get context", +and do not summarize messages the user did not ask about. + +Treat message contents as private. Do not repeat addresses, attachments, or body text into anything outward-facing — +a commit message, an issue, a file — unless the user asked you to put it there. + +## Triage + +When asked to triage, group by what the user has to _do_, not by folder: + +- needs a reply from them, +- needs a decision, +- informational, +- can be ignored or archived. + +Say who each thread is from and what it actually wants. "Follow-up on the proposal" is not a summary; "Dana is asking +whether you can commit to the March date" is. + +For a thread, read it in order and report the current state — the last message often reverses the first. + +## Drafting and sending + +**Never send mail without the user explicitly asking you to send that specific message.** Draft, show them the full +text and the recipient list when approval is still needed. If the user explicitly supplied or approved the exact +recipients and message and asked you to send it, do not ask for duplicate confirmation. + +Check the recipients yourself before showing a draft: reply versus reply-all is a real mistake with real consequences, +and so is an autocompleted wrong address. Say which one you chose. + +Match the tone of the thread. Do not add pleasantries the user would not write. + +Deleting, moving, or marking mail read changes state the user can see. Confirm first. + +## Report + +Answer the question. If you triaged, lead with what needs them today. + +After a mailbox mutation, verify the resulting draft, sent item, folder, category, or read state. Keep private mailbox +evidence concise and do not reproduce more message content than the user needs. diff --git a/resources/plugins/subagent-delegation/plugin.json b/resources/plugins/subagent-delegation/plugin.json new file mode 100644 index 000000000..8649439a4 --- /dev/null +++ b/resources/plugins/subagent-delegation/plugin.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "subagent-delegation", + "version": "1.1.0", + "description": "Delegate focused work to other installed agents and coordinate the results.", + "author": { + "name": "Poracode", + "url": "https://poracode.com" + }, + "homepage": "https://poracode.com", + "license": "Apache-2.0", + "keywords": ["subagents", "delegation", "orchestration"], + "extensions": { + "com.poracode.client": { + "title": "Subagent Delegation", + "category": "productivity", + "featured": true, + "examplePrompt": "Delegate independent parts of this task to the best available agents, then validate and consolidate their results", + "coreSkill": "subagent-delegation", + "builtInMcpServerIds": ["crossagents"], + "skills": { + "subagent-delegation": { + "name": "Subagent Delegation", + "description": "Choose, brief, and coordinate subagents for parallel work." + } + } + } + } +} diff --git a/resources/plugins/subagent-delegation/skills/subagent-delegation/SKILL.md b/resources/plugins/subagent-delegation/skills/subagent-delegation/SKILL.md new file mode 100644 index 000000000..f04905458 --- /dev/null +++ b/resources/plugins/subagent-delegation/skills/subagent-delegation/SKILL.md @@ -0,0 +1,39 @@ +--- +name: subagent-delegation +description: Delegate independent, bounded work to the best available Poracode agents and consolidate verified results. Use for parallel research, independent reviews, specialist work, or non-overlapping implementation; do not delegate trivial, sequential, tightly coupled, or context-heavy work. +--- + +# Subagent Delegation + +Use Poracode's `crossagents` MCP when independent, bounded work can run in parallel or a specialist or independent second opinion will materially improve the result. The coordinator remains responsible for understanding the problem, protecting shared state, and validating the final answer. + +## Decide whether to delegate + +Delegate when at least one of these is true: + +- two or more subtasks can run independently; +- a distinct provider or specialist perspective is valuable; +- an independent review reduces correctness or security risk; +- a bounded search, test, or implementation lane can return a concrete artifact. + +Do not delegate a trivial task, a sequence whose next step depends on the previous result, overlapping edits, or work that would require copying most of the conversation. Do not delegate merely to avoid understanding the task. + +## Workflow + +1. Classify the work with one to five concise task tags. Use `list_agents` when selection matters; call `get_agent` only when you need a provider's detailed models, reasoning choices, Fast support, or permission information. +2. Omit provider, model, reasoning, and Fast unless the user chose them or the task requires a deliberate override. Let Crossagents apply learned and configured routing. +3. Split the work into concrete subtasks with clear deliverables and non-overlapping edit scope. Every prompt must be self-contained and include relevant context, constraints, authority, expected output, and verification. +4. For one short task, call `spawn_agent` in the foreground. Set `background=true` only when the coordinator has useful independent work to do before synchronization. Submit independent tasks together through one `tasks` call for actual parallelism. +5. At the next real synchronization point, wait once for every required background result. Do not repeatedly poll. Cancel or continue without a stalled optional run. +6. Inspect returned evidence and changes, resolve disagreements or shared-worktree conflicts, and verify the combined result against the original request. + +## Safety and retries + +- Child agents have powerful permissions. Their prompt must not authorize actions beyond the user's request. +- Use startup-only fallback retries by default. `any-failure` can repeat writes or external side effects and requires explicit justification and authority. +- Do not allow multiple agents to edit the same files concurrently. Assign exact ownership or make review lanes read-only. +- Treat a confident child response as a claim, not proof. Check the relevant files, commands, tests, sources, or runtime state yourself. + +## Output + +Lead with the consolidated result. Mention delegated lanes only when it helps explain evidence, disagreement, limitations, or provider diversity. State what was verified and what remains uncertain. diff --git a/scripts/build-desktop-artifact.mjs b/scripts/build-desktop-artifact.mjs index 776553fee..67a8aa846 100644 --- a/scripts/build-desktop-artifact.mjs +++ b/scripts/build-desktop-artifact.mjs @@ -546,6 +546,10 @@ extraResources: to: skills filter: - "**/*" + - from: resources/plugins + to: plugins + filter: + - "**/*" - from: build/icon${runtimeIconSuffix}.png to: app-icon.png - from: build/tray-icon${iconSuffix}.ico diff --git a/src/main/browser/external/ChromeMcpIngress.test.ts b/src/main/browser/external/ChromeMcpIngress.test.ts index 9ba9ef5e3..95cf4f757 100644 --- a/src/main/browser/external/ChromeMcpIngress.test.ts +++ b/src/main/browser/external/ChromeMcpIngress.test.ts @@ -45,11 +45,17 @@ describe("ChromeMcpIngress", () => { const list = await postMcp(info, { jsonrpc: "2.0", id: 2, method: "tools/list" }); const listBody = (await list.json()) as { - result: { tools: Array<{ name: string }> }; + result: { tools: Array<{ name: string; annotations?: Record }> }; }; expect(listBody.result.tools.map((tool) => tool.name)).toContain("chrome_status"); expect(listBody.result.tools.map((tool) => tool.name)).toContain("enable"); expect(listBody.result.tools.map((tool) => tool.name)).toContain("disable"); + expect( + listBody.result.tools.find((tool) => tool.name === "chrome_snapshot")?.annotations, + ).toMatchObject({ readOnlyHint: true, destructiveHint: false }); + expect( + listBody.result.tools.find((tool) => tool.name === "chrome_click")?.annotations, + ).toMatchObject({ readOnlyHint: false, destructiveHint: true, openWorldHint: true }); }); it("routes Chrome tool calls and formats their result", async () => { diff --git a/src/main/browser/external/chromeTools.ts b/src/main/browser/external/chromeTools.ts index d46063258..6c7474f66 100644 --- a/src/main/browser/external/chromeTools.ts +++ b/src/main/browser/external/chromeTools.ts @@ -345,7 +345,7 @@ export function formatChromeToolResult(raw: unknown): McpToolResult { return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) }; } -export const CHROME_TOOLS: ToolSpec[] = [ +const RAW_CHROME_TOOLS: ToolSpec[] = [ { name: "chrome_status", description: @@ -556,4 +556,39 @@ export const CHROME_TOOLS: ToolSpec[] = [ }, ]; +const READ_ONLY_CHROME_TOOL_NAMES = new Set([ + "chrome_status", + "chrome_list_tabs", + "chrome_get_url", + "chrome_get_title", + "chrome_snapshot", + "chrome_find", + "chrome_get", + "chrome_is", + "chrome_wait", + "chrome_screenshot", + "chrome_cookies", +]); +const SESSION_CHROME_TOOL_NAMES = new Set(["enable", "disable"]); +const DESTRUCTIVE_CHROME_TOOL_NAMES = new Set([ + "chrome_click", + "chrome_fill", + "chrome_type", + "chrome_press", + "chrome_eval", +]); + +export const CHROME_TOOLS: ToolSpec[] = RAW_CHROME_TOOLS.map((tool) => ({ + ...tool, + annotations: READ_ONLY_CHROME_TOOL_NAMES.has(tool.name) + ? { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } + : SESSION_CHROME_TOOL_NAMES.has(tool.name) + ? { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false } + : { + readOnlyHint: false, + destructiveHint: DESTRUCTIVE_CHROME_TOOL_NAMES.has(tool.name), + openWorldHint: true, + }, +})); + export const CHROME_TOOL_NAMES = new Set(CHROME_TOOLS.map((t) => t.name)); diff --git a/src/main/browser/mcp/toolRegistry.test.ts b/src/main/browser/mcp/toolRegistry.test.ts index 64191b540..9ddec07dd 100644 --- a/src/main/browser/mcp/toolRegistry.test.ts +++ b/src/main/browser/mcp/toolRegistry.test.ts @@ -239,6 +239,18 @@ describe("browser MCP tool registry", () => { expect(formatted.content[0]?.text?.length).toBeLessThan(20_000); }); + it("advertises passive and state-changing tool annotations", () => { + expect(TOOLS.find((tool) => tool.name === "snapshot")?.annotations).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + }); + expect(TOOLS.find((tool) => tool.name === "click")?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + openWorldHint: true, + }); + }); + it("recognizes agent-browser-style aliases", () => { expect(isKnownToolName("goto")).toBe(true); expect(isKnownToolName("key")).toBe(true); diff --git a/src/main/browser/mcp/tools/specs.ts b/src/main/browser/mcp/tools/specs.ts index da43fe2d7..b97dd77b7 100644 --- a/src/main/browser/mcp/tools/specs.ts +++ b/src/main/browser/mcp/tools/specs.ts @@ -3,7 +3,7 @@ import type { ToolSpec } from "./types"; export const BROWSER_MCP_INSTRUCTIONS = "Use the browser MCP server for browsing, inspecting, clicking, typing, screenshots, network/console checks, and local web app verification inside Poracode. Before the first browsing action, call browser.enable once and keep it enabled across the whole uninterrupted browser session so agent presence stays consistent between calls. Always call browser.disable before pausing to ask for user input, waiting for an external event, or finishing, and enable again when you resume. Prefer browser.snapshot or browser.find before browser.click/fill/type, use @e refs from snapshots when possible, and call browser.api when you need the complete API map."; -export const TOOLS: ToolSpec[] = [ +const RAW_TOOLS: ToolSpec[] = [ { name: "api", description: @@ -597,6 +597,57 @@ export const TOOLS: ToolSpec[] = [ }, ]; +const READ_ONLY_TOOL_NAMES = new Set([ + "api", + "list_tabs", + "get_url", + "get_title", + "screenshot", + "query", + "wait_for", + "snapshot", + "inspect", + "get", + "is", + "find", + "wait", + "wait_for_url", + "wait_for_text", + "wait_for_js", + "frames", +]); +const SESSION_TOOL_NAMES = new Set(["enable", "disable"]); +const DESTRUCTIVE_TOOL_NAMES = new Set([ + "close_tab", + "click", + "dblclick", + "type", + "fill", + "check", + "uncheck", + "select", + "eval", + "press", + "cookies", + "storage", + "dialog", + "addscript", + "addstyle", +]); + +export const TOOLS: ToolSpec[] = RAW_TOOLS.map((tool) => ({ + ...tool, + annotations: READ_ONLY_TOOL_NAMES.has(tool.name) + ? { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } + : SESSION_TOOL_NAMES.has(tool.name) + ? { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false } + : { + readOnlyHint: false, + destructiveHint: DESTRUCTIVE_TOOL_NAMES.has(tool.name), + openWorldHint: true, + }, +})); + export const TOOL_NAMES = new Set(TOOLS.map((t) => t.name)); const TOOL_ALIASES = new Map([ diff --git a/src/main/browser/mcp/tools/types.ts b/src/main/browser/mcp/tools/types.ts index 47620bde6..ccff8c98d 100644 --- a/src/main/browser/mcp/tools/types.ts +++ b/src/main/browser/mcp/tools/types.ts @@ -1,4 +1,5 @@ import type { BrowserPanelManager } from "../../BrowserPanelManager"; +import type { McpToolAnnotations } from "@/shared/contracts"; export interface ToolContext { manager: BrowserPanelManager; @@ -14,6 +15,7 @@ export interface ToolSpec { name: string; description: string; inputSchema: Record; + annotations?: McpToolAnnotations; } export interface McpContent { diff --git a/src/main/computer-use/mcp/toolRegistry.test.ts b/src/main/computer-use/mcp/toolRegistry.test.ts index 9ceb9b27b..03ffa09ba 100644 --- a/src/main/computer-use/mcp/toolRegistry.test.ts +++ b/src/main/computer-use/mcp/toolRegistry.test.ts @@ -32,6 +32,15 @@ describe("computer-use toolRegistry", () => { expect(isInteractiveToolName("type")).toBe(true); expect(isInteractiveToolName("get_window_state")).toBe(false); expect(isInteractiveToolName("list_windows")).toBe(false); + expect(TOOLS.find((tool) => tool.name === "get_window_state")?.annotations).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + }); + expect(TOOLS.find((tool) => tool.name === "click")?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + openWorldHint: true, + }); }); it("preserves the refreshed window returned by interactive driver actions", async () => { diff --git a/src/main/computer-use/mcp/toolRegistry.ts b/src/main/computer-use/mcp/toolRegistry.ts index 8cbba40dc..000a761d4 100644 --- a/src/main/computer-use/mcp/toolRegistry.ts +++ b/src/main/computer-use/mcp/toolRegistry.ts @@ -1,4 +1,5 @@ import type { ComputerUseDriver, ComputerUseScreenshot, ComputerUseWindowState } from "./types"; +import type { McpToolAnnotations } from "@/shared/contracts"; import { readNumber, readString, readWindow } from "../drivers/common"; export interface ToolContext { @@ -11,6 +12,7 @@ export interface ToolSpec { name: string; description: string; inputSchema: Record; + annotations?: McpToolAnnotations; } const WINDOW_SCHEMA = { @@ -30,7 +32,7 @@ const WINDOW_SCHEMA = { export const COMPUTER_USE_MCP_INSTRUCTIONS = "Use the computer_use MCP server to inspect and control native macOS or Windows apps on the host desktop (including when the user is driving from a paired phone/remote client — agents still run on that desktop). Start with computer_use.api or computer_use.list_apps, choose a returned window, then call computer_use.get_window_state before coordinate input. Immediately before the first interactive action, call computer_use.enable once; it keeps the Computer Use overlay visible across the whole uninterrupted control session. Keep it enabled between related actions, including passive inspection calls. Always call computer_use.disable before you pause to ask for user input, wait for an external event, or finish; call enable again when you resume. Prefer ordinary Win32 desktop apps when you have a choice — some Store/WinUI apps recreate window handles during activation, so always prefer the `window` object returned by interactive tools (or re-call list_windows/get_window) before the next click/type. list/get/screenshot operations are passive and do not steal focus; click, drag, scroll, type_text, press_key, activate_window, and launch_app switch to interactive mode, bring the target app to the FOREGROUND, and take exclusive control of the real mouse/keyboard — nobody should use the host machine while interactive computer-use is running. Coordinates (x/y) are window-relative with the origin at the TOP-LEFT of the window frame (including the title bar), matching the top-left pixel of the most recent get_window_state screenshot for that window; if the window may have moved or resized, call get_window_state again before sending coordinates. If a tool reports that the window is no longer available (windows are re-identified after they move/resize), call computer_use.list_windows or computer_use.get_window to obtain a fresh window id and retry. Prefer the browser MCP server for web pages. Locked desktops, secure prompts, OS permission prompts, and password/authentication surfaces require the user."; -export const TOOLS: ToolSpec[] = [ +const RAW_TOOLS: ToolSpec[] = [ { name: "api", description: @@ -173,6 +175,24 @@ export const TOOLS: ToolSpec[] = [ }, ]; +const READ_ONLY_TOOL_NAMES = new Set([ + "api", + "list_apps", + "list_windows", + "get_window", + "get_window_state", +]); +const SESSION_TOOL_NAMES = new Set(["enable", "disable"]); + +export const TOOLS: ToolSpec[] = RAW_TOOLS.map((tool) => ({ + ...tool, + annotations: READ_ONLY_TOOL_NAMES.has(tool.name) + ? { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } + : SESSION_TOOL_NAMES.has(tool.name) + ? { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false } + : { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, +})); + export const TOOL_NAMES = new Set(TOOLS.map((tool) => tool.name)); const INTERACTIVE_TOOL_NAMES = new Set([ diff --git a/src/main/ipc/localHandlers.ts b/src/main/ipc/localHandlers.ts index feedb98b2..cc0f0c0c9 100644 --- a/src/main/ipc/localHandlers.ts +++ b/src/main/ipc/localHandlers.ts @@ -1,3 +1,4 @@ +import { mkdir } from "node:fs/promises"; import { dirname } from "node:path"; import { app, clipboard, dialog, nativeImage, shell, type BrowserWindow } from "electron"; import type { BrowserPanelManager } from "../browser"; @@ -364,6 +365,13 @@ export function createLocalIpcHandlers( revealProjectEntry: async (payload) => { shell.showItemInFolder(resolveProjectFsPath(payload)); }, + openPluginsFolder: async () => { + // Created on demand so the folder is always there to drop a package into, + // even on a fresh install that has never loaded a user plugin. + const pluginsDir = options.requirePoracodePaths().pluginsDir; + await mkdir(pluginsDir, { recursive: true }); + await shell.openPath(pluginsDir); + }, publishRemoteGitSummaries: (payload) => { options.onRemoteGitSummaries?.(payload.summaries); }, diff --git a/src/main/main.ts b/src/main/main.ts index 59a66cfbd..b7b0a5499 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -715,6 +715,9 @@ if (!hasSingleInstanceLock) { const bundledSkillsDir = app.isPackaged ? join(process.resourcesPath, "skills") : join(__dirname, "..", "..", "resources", "skills"); + const bundledPluginsDir = app.isPackaged + ? join(process.resourcesPath, "plugins") + : join(__dirname, "..", "..", "resources", "plugins"); const sshConnectionManager = new SshConnectionManager({ mainBundleDir: __dirname, agentPluginsDir: app.isPackaged @@ -722,6 +725,7 @@ if (!hasSingleInstanceLock) { : join(__dirname, "..", "..", "resources", "agent-plugins"), wslHelpersDir, bundledSkillsDir, + bundledPluginsDir, cacheDir: join(paths.baseDir, "ssh-runtime-bundles"), }); @@ -739,6 +743,7 @@ if (!hasSingleInstanceLock) { supervisorPath, wslHelpersDir, bundledSkillsDir, + bundledPluginsDir, secretStorageKey, resolveExtraEnv: () => { const env: Record = {}; diff --git a/src/main/sharedSettingsFile.test.ts b/src/main/sharedSettingsFile.test.ts index 3662f7e5a..a84912a24 100644 --- a/src/main/sharedSettingsFile.test.ts +++ b/src/main/sharedSettingsFile.test.ts @@ -180,6 +180,7 @@ describe("sharedSettingsFile", () => { mcpServers: [], disabledBuiltInMcpServers: {}, disabledBuiltInMcpTools: {}, + installedPlugins: {}, browser: { allowEval: false, allowDataAccess: false, @@ -310,6 +311,7 @@ describe("sharedSettingsFile", () => { mcpServers: [], disabledBuiltInMcpServers: {}, disabledBuiltInMcpTools: {}, + installedPlugins: {}, browser: { allowEval: false, allowDataAccess: false, diff --git a/src/main/ssh/runtimeBundle.ts b/src/main/ssh/runtimeBundle.ts index bb740b496..c90dc84c2 100644 --- a/src/main/ssh/runtimeBundle.ts +++ b/src/main/ssh/runtimeBundle.ts @@ -26,6 +26,7 @@ export interface SshRuntimeBundleOptions { readonly agentPluginsDir: string; readonly wslHelpersDir: string; readonly bundledSkillsDir?: string; + readonly bundledPluginsDir?: string; readonly cacheDir: string; readonly tarCommand?: string; } @@ -198,6 +199,7 @@ export function ensureSshRuntimeBundle(options: SshRuntimeBundleOptions): SshRun options.agentPluginsDir, options.wslHelpersDir, options.bundledSkillsDir ?? null, + options.bundledPluginsDir ?? null, options.cacheDir, options.tarCommand ?? null, ]); @@ -219,6 +221,7 @@ export function ensureSshRuntimeBundle(options: SshRuntimeBundleOptions): SshRun options.agentPluginsDir, options.wslHelpersDir, ...(options.bundledSkillsDir ? [options.bundledSkillsDir] : []), + ...(options.bundledPluginsDir ? [options.bundledPluginsDir] : []), ], runtimePackage, ); @@ -261,6 +264,11 @@ export function ensureSshRuntimeBundle(options: SshRuntimeBundleOptions): SshRun } else { mkdirSync(join(stage, "skills")); } + if (options.bundledPluginsDir && existsSync(options.bundledPluginsDir)) { + cpSync(options.bundledPluginsDir, join(stage, "plugins"), { recursive: true }); + } else { + mkdirSync(join(stage, "plugins")); + } writeFileSync(join(stage, "package.json"), runtimePackage, "utf8"); const hash = hashDirectory(stage); diff --git a/src/main/supervisor/SupervisorClient.test.ts b/src/main/supervisor/SupervisorClient.test.ts index 6a6ac20c5..ada061225 100644 --- a/src/main/supervisor/SupervisorClient.test.ts +++ b/src/main/supervisor/SupervisorClient.test.ts @@ -13,7 +13,7 @@ vi.mock("@/shared/processTree", () => ({ terminateChildProcessTree: vi.fn<() => void>(), })); -import { SupervisorClient } from "./SupervisorClient"; +import { SupervisorClient, type SupervisorClientOptions } from "./SupervisorClient"; type SendCallback = (error?: Error | null) => void; @@ -36,7 +36,7 @@ function makeFakeChild(): FakeChild { return child; } -function makeClient() { +function makeClient(options: Pick = {}) { const child = makeFakeChild(); forkMock.mockReturnValue(child); const client = new SupervisorClient({ @@ -47,6 +47,7 @@ function makeClient() { secretStorageKey: "key", onEvent: vi.fn<(event: SupervisorEvent) => void>(), onReset: vi.fn<() => void>(), + ...options, }); client.start("/base"); return { client, child }; @@ -109,6 +110,35 @@ describe("SupervisorClient.call", () => { await expect(promise).resolves.toBe("result-value"); }); + it("applies main-process start invariants before sending the request", async () => { + const { client, child } = makeClient({ + prepareStartThread: (payload) => ({ + ...payload, + invariantDisabledBuiltInMcpServerIds: ["crossagents"], + }), + }); + let request: { id: string; payload: unknown } | undefined; + child.send.mockImplementation((message, callback) => { + request = message as { id: string; payload: unknown }; + callback?.(); + return true; + }); + const promise = client.call("startThread", { + threadId: "child-thread", + projectLocation: { kind: "windows", path: "C:\\repo" }, + agentKind: "codex", + config: { model: "test" }, + prompt: "Inspect this.", + initialSize: { cols: 120, rows: 40 }, + }); + await vi.waitFor(() => expect(request).toBeDefined()); + expect(request?.payload).toMatchObject({ + invariantDisabledBuiltInMcpServerIds: ["crossagents"], + }); + child.emit("message", { replyTo: request!.id, ok: true, data: { threadId: "child-thread" } }); + await expect(promise).resolves.toEqual({ threadId: "child-thread" }); + }); + it("rejects when the reply reports failure", async () => { const { client, child } = makeClient(); const getId = captureSentId(child); diff --git a/src/main/supervisor/SupervisorClient.ts b/src/main/supervisor/SupervisorClient.ts index 002078c14..dd22999cb 100644 --- a/src/main/supervisor/SupervisorClient.ts +++ b/src/main/supervisor/SupervisorClient.ts @@ -3,6 +3,7 @@ import type { Readable } from "node:stream"; import { randomUUID } from "node:crypto"; import type { PoracodeDiagnosticTags } from "@/shared/diagnostics/sentryPrivacy"; import { terminateChildProcessTree } from "@/shared/processTree"; +import type { StartThreadPayload } from "@/shared/contracts"; import type { IpcProcedurePayload, IpcProcedureResult, @@ -60,6 +61,12 @@ export interface SupervisorClientOptions { * `PORACODE_BUNDLED_SKILLS_DIR` so the skills service can surface them. */ bundledSkillsDir?: string; + /** + * Directory containing the Agent Plugins packages shipped with the app. + * Forwarded as `PORACODE_BUNDLED_PLUGINS_DIR` so the plugin registry can + * discover them. + */ + bundledPluginsDir?: string; secretStorageKey: string; /** * Optional resolver invoked at every supervisor spawn, returning extra env @@ -67,6 +74,8 @@ export interface SupervisorClientOptions { * to inject `PORACODE_BROWSER_MCP_*` per-launch. */ resolveExtraEnv?: () => Record; + /** Apply main-process launch invariants before any start reaches the supervisor. */ + prepareStartThread?(payload: StartThreadPayload): StartThreadPayload; assignPid?(pid: number): Promise; reportError?(error: unknown, tags?: PoracodeDiagnosticTags): void; onEvent(event: SupervisorEvent): void; @@ -134,6 +143,9 @@ export class SupervisorClient { ...(this.options.bundledSkillsDir ? { PORACODE_BUNDLED_SKILLS_DIR: this.options.bundledSkillsDir } : {}), + ...(this.options.bundledPluginsDir + ? { PORACODE_BUNDLED_PLUGINS_DIR: this.options.bundledPluginsDir } + : {}), ...extraEnv, }, }); @@ -217,10 +229,14 @@ export class SupervisorClient { } const id = randomUUID(); + const requestPayload = + type === "startThread" && this.options.prepareStartThread + ? this.options.prepareStartThread(payload as StartThreadPayload) + : payload; const request: SupervisorRequest = { id, type, - payload, + payload: requestPayload, } as SupervisorRequest; return new Promise>((resolve, reject) => { diff --git a/src/renderer/components/common/ToggleSwitch.tsx b/src/renderer/components/common/ToggleSwitch.tsx index 70c159a2d..7dbc290e6 100644 --- a/src/renderer/components/common/ToggleSwitch.tsx +++ b/src/renderer/components/common/ToggleSwitch.tsx @@ -7,7 +7,11 @@ type ToggleSwitchBaseProps = Omit< >; export type ToggleSwitchProps = ToggleSwitchBaseProps & - ({ "aria-label": string; children?: never } | { "aria-label"?: string; children: ReactNode }); + ( + | { "aria-label": string; "aria-labelledby"?: never; children?: never } + | { "aria-label"?: never; "aria-labelledby": string; children?: never } + | { "aria-label"?: string; "aria-labelledby"?: string; children: ReactNode } + ); /** * App switch with the interactive HeroUI anatomy included. diff --git a/src/renderer/components/composer/MentionInput.test.ts b/src/renderer/components/composer/MentionInput.test.ts index 7f06ce6cc..63cc655e7 100644 --- a/src/renderer/components/composer/MentionInput.test.ts +++ b/src/renderer/components/composer/MentionInput.test.ts @@ -8,6 +8,7 @@ import { MentionInput, type McpMentionItem, type MentionInputHandle, + type PluginMentionItem, } from "./MentionInput"; vi.mock("./MentionPopover", () => ({ MentionPopover: () => null })); @@ -50,6 +51,35 @@ describe("buildMentionResults", () => { detail: "Computer Use", enabled: true, }; + const github: PluginMentionItem = { + id: "github", + name: "GitHub", + detail: "Plugin", + command: { + id: "github", + label: "GitHub", + skillName: "github", + skillPath: "C:\\plugins\\github\\skills\\github\\SKILL.md", + skillInvocation: "$github", + skillProvider: "GitHub", + skillScope: "global", + pluginId: "github", + pluginName: "GitHub", + }, + }; + + it("shows a plugin as one result before its underlying MCP and files", () => { + expect(buildMentionResults(fileResults, "git", [browser], [github])).toEqual([ + { + type: "plugin", + path: "github", + name: "GitHub", + detail: "Plugin", + command: github.command, + }, + ...fileResults, + ]); + }); it("shows Browser when typing an empty @ mention", () => { expect(buildMentionResults(fileResults, "", [browser])).toEqual([ @@ -248,6 +278,57 @@ describe("MCP mention selection", () => { }); }); +describe("plugin mention selection", () => { + it("inserts one plugin badge that preserves the core skill and plugin identity", () => { + const ref = createRef(); + render( + createElement(MentionInput, { + ref, + placeholder: "Send a message...", + projectLocation: undefined, + onTextChange: vi.fn<(hasText: boolean) => void>(), + onSubmit: vi.fn<(segments: PromptSegment[]) => void>(), + pluginMentions: [ + { + id: "github", + name: "GitHub", + detail: "Plugin", + command: { + id: "github", + label: "GitHub", + skillName: "github", + skillPath: "C:\\plugins\\github\\skills\\github\\SKILL.md", + skillInvocation: "$github", + skillProvider: "GitHub", + skillScope: "global", + pluginId: "github", + pluginName: "GitHub", + }, + }, + ], + }), + ); + + const editor = typeMention("git"); + fireEvent.keyDown(editor, { key: "Enter" }); + + expect(editor.querySelector('[data-plugin-id="github"]')).toHaveTextContent("GitHub"); + expect(ref.current?.serializeSegments()).toEqual([ + { + kind: "skill", + name: "github", + path: "C:\\plugins\\github\\skills\\github\\SKILL.md", + invocation: "$github", + provider: "GitHub", + scope: "global", + pluginId: "github", + pluginName: "GitHub", + }, + { kind: "text", content: " " }, + ]); + }); +}); + describe("Enter handling", () => { const baseProps = { placeholder: "Send a message...", diff --git a/src/renderer/components/composer/MentionInput.tsx b/src/renderer/components/composer/MentionInput.tsx index 6a86dc538..7ef81f827 100644 --- a/src/renderer/components/composer/MentionInput.tsx +++ b/src/renderer/components/composer/MentionInput.tsx @@ -32,13 +32,23 @@ export interface McpMentionItem { enabled: boolean; } +/** An installed Agent Plugin surfaced as one `@`-mention. */ +export interface PluginMentionItem { + id: string; + name: string; + detail: string; + command: AgentSlashCommand; +} + /** Stable empty list so an omitted `mcpMentions` prop doesn't churn renders. */ const EMPTY_MCP_MENTIONS: readonly McpMentionItem[] = []; +const EMPTY_PLUGIN_MENTIONS: readonly PluginMentionItem[] = []; export function buildMentionResults( fileResults: FileEntry[], query: string, mcpMentions: readonly McpMentionItem[] = EMPTY_MCP_MENTIONS, + pluginMentions: readonly PluginMentionItem[] = EMPTY_PLUGIN_MENTIONS, ): MentionEntry[] { const q = query.trim().toLowerCase(); // Case-insensitive prefix match on the display name or a stable alias. @@ -54,7 +64,16 @@ export function buildMentionResults( detail: item.detail, enabled: item.enabled, })); - return [...mcpResults, ...fileResults]; + const pluginResults: MentionEntry[] = pluginMentions + .filter((item) => item.name.toLowerCase().startsWith(q)) + .map((item) => ({ + type: "plugin", + path: item.id, + name: item.name, + detail: item.detail, + command: item.command, + })); + return [...pluginResults, ...mcpResults, ...fileResults]; } export interface MentionInputHandle { @@ -199,6 +218,8 @@ function skillChipDataset(segment: Extract) { skillInvocation: segment.invocation, skillProvider: segment.provider, skillScope: segment.scope, + ...(segment.pluginId ? { pluginId: segment.pluginId } : {}), + ...(segment.pluginName ? { pluginName: segment.pluginName } : {}), }; } @@ -249,6 +270,7 @@ export const MentionInput = forwardRef< * call `onMcpMentionSelect` so the composer can enable them first. */ mcpMentions?: readonly McpMentionItem[]; + pluginMentions?: readonly PluginMentionItem[]; onMcpMentionSelect?: (id: string) => void; onSlashCommandChange?: (query: string | null) => void; commandListId?: string; @@ -280,9 +302,11 @@ export const MentionInput = forwardRef< onInterceptKey, } = props; const mcpMentions = props.mcpMentions ?? EMPTY_MCP_MENTIONS; + const pluginMentions = props.pluginMentions ?? EMPTY_PLUGIN_MENTIONS; // Stable dependency key: which MCP mentions are offered, independent of the // array's per-render identity (mirrors the old boolean flags in the effect). const mcpMentionKey = mcpMentions.map((item) => `${item.id}:${item.enabled}`).join(","); + const pluginMentionKey = pluginMentions.map((item) => item.id).join(","); const editorRef = useRef(null); const lastSlashQueryRef = useRef(null); const voicePreviewRef = useRef(null); @@ -295,11 +319,16 @@ export const MentionInput = forwardRef< mention !== null, projectId, ); - const results = buildMentionResults(fileResults, mention?.query ?? "", mcpMentions); + const results = buildMentionResults( + fileResults, + mention?.query ?? "", + mcpMentions, + pluginMentions, + ); useEffect(() => { setActiveIndex(0); - }, [mention?.query, fileResults, mcpMentionKey]); + }, [mention?.query, fileResults, mcpMentionKey, pluginMentionKey]); function insertPlainText(text: string) { const editor = editorRef.current; @@ -584,14 +613,31 @@ export const MentionInput = forwardRef< const range = detectTriggerRange("@"); if (!range) return; - if (entry.type === "mcp") { + if (entry.type === "mcp" || entry.type === "plugin") { + const pluginSegment = + entry.type === "plugin" ? skillSegmentFromSlashCommand(entry.command) : undefined; + if (entry.type === "plugin" && !pluginSegment) return; const sel = window.getSelection(); if (!sel) return; sel.removeAllRanges(); sel.addRange(range); range.deleteContents(); - if (entry.enabled) { + if (entry.type === "plugin") { + if (!pluginSegment) return; + const chip = createSlashCommandChipElement({ + id: entry.command.id, + ...skillChipDataset(pluginSegment), + }); + range.insertNode(chip); + const space = document.createTextNode(" "); + chip.after(space); + const nextRange = document.createRange(); + nextRange.setStartAfter(space); + nextRange.collapse(true); + sel.removeAllRanges(); + sel.addRange(nextRange); + } else if (entry.enabled) { const chip = createMcpMentionChipElement({ id: entry.path, name: entry.name }); range.insertNode(chip); // Trailing nbsp keeps the caret visually separate from the chip, matching diff --git a/src/renderer/components/composer/MentionPopover.tsx b/src/renderer/components/composer/MentionPopover.tsx index 71d7731ee..0990d8c79 100644 --- a/src/renderer/components/composer/MentionPopover.tsx +++ b/src/renderer/components/composer/MentionPopover.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import type { LucideIcon } from "lucide-react"; -import type { FileEntry } from "@/shared/contracts"; +import type { AgentSlashCommand, FileEntry } from "@/shared/contracts"; import { getEntryIconUrl } from "@/renderer/components/common/fileIcons"; +import { PluginIcon } from "@/renderer/components/plugins/PluginIcon"; /** * A composer MCP server (Browser, Crossagents, Computer Use, …) surfaced as an @@ -19,7 +20,15 @@ export type McpMentionEntry = { enabled: boolean; }; -export type MentionEntry = FileEntry | McpMentionEntry; +export type PluginMentionEntry = { + type: "plugin"; + path: string; + name: string; + detail: string; + command: AgentSlashCommand; +}; + +export type MentionEntry = FileEntry | McpMentionEntry | PluginMentionEntry; function getParentDir(path: string): string { const lastSlash = path.lastIndexOf("/"); @@ -70,8 +79,9 @@ export function MentionPopover(props: { {results.map((entry, index) => { const isActive = index === activeIndex; const isMcp = entry.type === "mcp"; + const isPlugin = entry.type === "plugin"; const McpIcon = isMcp ? entry.icon : null; - const dir = isMcp ? "" : getParentDir(entry.path); + const dir = isMcp || isPlugin ? "" : getParentDir(entry.path); return (
- {McpIcon ? ( + {isPlugin ? ( + + ) : McpIcon ? (

{props.server.description}

diff --git a/src/renderer/components/plugins/PluginDetail.test.tsx b/src/renderer/components/plugins/PluginDetail.test.tsx new file mode 100644 index 000000000..b9e1ffdce --- /dev/null +++ b/src/renderer/components/plugins/PluginDetail.test.tsx @@ -0,0 +1,118 @@ +import { act, fireEvent, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { pluginFixture, seedBuiltInPlugins } from "@/renderer/testUtils/plugins"; +import { PluginDetail } from "./PluginDetail"; +import { useLocalizedPluginCatalog } from "./pluginCopy"; + +const actionMocks = vi.hoisted(() => ({ + newThreadFromText: + vi.fn<(projectId: string, text: string, options?: { bindLeadingSkill?: boolean }) => void>(), + ensureHomeScopeProject: vi.fn<() => Promise<{ id: string }>>(async () => ({ + id: "home-project", + })), +})); + +vi.mock("@/renderer/actions/notesActions", () => ({ + newThreadFromText: actionMocks.newThreadFromText, +})); + +vi.mock("@/renderer/actions/projectActions", () => ({ + ensureHomeScopeProject: actionMocks.ensureHomeScopeProject, +})); + +function BrowserPluginDetail(props: { onBack?: () => void }) { + const plugin = useLocalizedPluginCatalog().find( + (candidate) => candidate.plugin.name === "browser-tools", + )!; + return ( + undefined)} /> + ); +} + +function ComputerUsePluginDetail() { + const plugin = useLocalizedPluginCatalog().find( + (candidate) => candidate.plugin.name === "computer-use", + )!; + return undefined} />; +} + +function TryNowPluginDetail() { + const base = useLocalizedPluginCatalog().find( + (candidate) => candidate.plugin.name === "browser-tools", + )!; + const plugin = { + ...base, + plugin: { + ...base.plugin, + poracode: { ...base.plugin.poracode, examplePrompt: "Inspect this page" }, + }, + }; + return undefined} />; +} + +describe("PluginDetail", () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + seedBuiltInPlugins(); + useSharedSettings.setState({ installedPlugins: {} }); + }); + + it("updates plugin and skill toggles and uninstalls the bundle", () => { + useSharedSettings.getState().installPlugin(pluginFixture("browser-tools")); + render(); + + expect(screen.getByRole("heading", { name: "MCP servers" })).toBeInTheDocument(); + expect(screen.getByText("Browser")).toBeInTheDocument(); + expect(screen.queryByRole("switch", { name: "Browser MCP" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("switch", { name: "Browser Control Skill" })); + expect( + useSharedSettings.getState().installedPlugins["browser-tools"]?.disabledSkillIds, + ).toEqual(["browser-control"]); + expect(screen.getByRole("switch", { name: "Browser Control Skill" })).not.toBeChecked(); + + fireEvent.click(screen.getByRole("switch", { name: "Browser Tools Enable plugin" })); + expect(useSharedSettings.getState().installedPlugins["browser-tools"]?.enabled).toBe(false); + expect(screen.getByRole("switch", { name: "Browser Control Skill" })).toBeDisabled(); + + fireEvent.click(screen.getByRole("button", { name: "Uninstall" })); + expect(useSharedSettings.getState().installedPlugins["browser-tools"]).toBeUndefined(); + expect(screen.getByRole("button", { name: "Install" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Enable plugin" })).not.toBeInTheDocument(); + }); + + it("returns to the marketplace", () => { + const onBack = vi.fn<() => void>(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Back to plugins" })); + expect(onBack).toHaveBeenCalledOnce(); + }); + + it("offers Try now only while the plugin is installed and enabled", async () => { + const plugin = pluginFixture("browser-tools"); + render(); + + expect(screen.getByRole("button", { name: "Try now" })).toBeDisabled(); + act(() => useSharedSettings.getState().installPlugin(plugin)); + expect(screen.getByRole("button", { name: "Try now" })).toBeEnabled(); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Try now" }))); + expect(actionMocks.newThreadFromText).toHaveBeenCalledWith( + "home-project", + "/browser-control Inspect this page", + { bindLeadingSkill: true }, + ); + act(() => useSharedSettings.getState().setPluginEnabled(plugin, false)); + expect(screen.getByRole("button", { name: "Try now" })).toBeDisabled(); + }); + + it("disables installation when the plugin is unavailable on this device", () => { + render(); + + expect(screen.getByRole("button", { name: "Unavailable on this device" })).toBeDisabled(); + expect(screen.getByText("Unavailable on this device", { selector: "p" })).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/plugins/PluginDetail.tsx b/src/renderer/components/plugins/PluginDetail.tsx new file mode 100644 index 000000000..48ad8ee39 --- /dev/null +++ b/src/renderer/components/plugins/PluginDetail.tsx @@ -0,0 +1,433 @@ +import { ArrowLeft, ArrowRight, Box, Plug, Sparkles, TriangleAlert } from "lucide-react"; +import { Trans, useLingui } from "@lingui/react/macro"; +import { useEffect, useId, useRef, type ReactNode } from "react"; +import { Button, ToggleSwitch } from "@/renderer/components/common"; +import { ensureHomeScopeProject } from "@/renderer/actions/projectActions"; +import { newThreadFromText } from "@/renderer/actions/notesActions"; +import { usePanelStore } from "@/renderer/state/panelStore"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { + isPluginMcpServerEnabled, + isPluginSkillEnabled, + isPluginSupportedOnHost, + getPluginCoreSkill, +} from "@/shared/plugins/catalog"; +import { PluginIcon } from "./PluginIcon"; +import { PluginTag } from "./PluginTag"; +import { usePluginOauth } from "./usePluginOauth"; +import { useLocalizedPluginDiagnostic, type LocalizedPlugin } from "./pluginCopy"; + +export function PluginDetail(props: { + plugin: LocalizedPlugin; + hostPlatform: NodeJS.Platform; + onBack: () => void; +}) { + const { t } = useLingui(); + const plugin = props.plugin.plugin; + const state = useSharedSettings((settings) => settings.installedPlugins[plugin.name]); + const installPlugin = useSharedSettings((settings) => settings.installPlugin); + const uninstallPlugin = useSharedSettings((settings) => settings.uninstallPlugin); + const setPluginEnabled = useSharedSettings((settings) => settings.setPluginEnabled); + const setPluginSkillEnabled = useSharedSettings((settings) => settings.setPluginSkillEnabled); + const setPluginMcpServerEnabled = useSharedSettings( + (settings) => settings.setPluginMcpServerEnabled, + ); + const supported = isPluginSupportedOnHost(plugin, props.hostPlatform); + const backButtonRef = useRef(null); + const titleId = useId(); + const pluginToggleLabelId = useId(); + const author = plugin.manifest.author?.name; + const examplePrompt = plugin.poracode.examplePrompt; + const coreSkill = getPluginCoreSkill(plugin); + const closeSettings = usePanelStore((panel) => panel.closeSettings); + const oauth = usePluginOauth(plugin); + const describeDiagnostic = useLocalizedPluginDiagnostic(); + // Warnings are tolerated by the loader; errors mean something was dropped. + const problems = plugin.diagnostics.filter((diagnostic) => diagnostic.severity === "error"); + + // Seeds a draft composer rather than sending anything, so the user still + // reviews the prompt and picks a model before the thread starts. + const tryNow = async () => { + if (!examplePrompt || !coreSkill) return; + const project = await ensureHomeScopeProject(); + newThreadFromText(project.id, `/${coreSkill.folder} ${examplePrompt}`, { + bindLeadingSkill: true, + }); + closeSettings(); + }; + + useEffect(() => { + backButtonRef.current?.focus(); + }, []); + + return ( +
+ + +
+
+ +
+
+
+
+

+ {props.plugin.name} +

+

{props.plugin.description}

+
+
+ {examplePrompt && coreSkill ? ( + + ) : null} + {state ? ( + + ) : ( + + )} +
+
+
+ {author ?? plugin.name} + {plugin.poracode.communityMaintained ? ( + + Community + + ) : null} + + {props.plugin.category} + {plugin.manifest.version ? ( + <> + + v{plugin.manifest.version} + + ) : null} +
+ {!supported ? ( +

+ Unavailable on this device +

+ ) : null} +
+
+ + {examplePrompt && coreSkill ? ( + + ) : null} + + {problems.length > 0 ? ( +
+
+ +

+ Some contributions could not be loaded +

+
+
    + {problems.map((diagnostic, index) => ( +
  • + {describeDiagnostic(diagnostic)} +
  • + ))} +
+
+ ) : null} + + {plugin.poracode.communityMaintained ? ( +

+ + The server this plugin launches is maintained by a third party, not by the service it + connects to. Review the source before enabling it. + +

+ ) : null} + + {state ? ( +
+
+

+ Enable plugin +

+

+ Enable this plugin's skills and servers for new threads. +

+
+ setPluginEnabled(plugin, enabled)} + /> +
+ ) : null} + + {props.plugin.mcpServers.length > 0 ? ( + } + title={t`MCP servers`} + {...(plugin.mcpServers.length > 0 + ? { + description: t`Servers this plugin declares in mcp.json. Poracode passes them to every supported agent.`, + } + : {})} + > + {props.plugin.mcpServers.map((server, index) => { + const declared = plugin.mcpServers.some((candidate) => candidate.name === server.id); + const enabled = + state && declared ? isPluginMcpServerEnabled(plugin, state, server.id) : true; + const labelId = `${titleId}-server-${server.id}`; + const badgeId = `${labelId}-kind`; + return ( + + {oauth.isRemoteServer(server.id) ? ( + void oauth.connect(server.id)} + onDisconnect={() => void oauth.disconnect(server.id)} + /> + ) : null} + setPluginMcpServerEnabled(plugin.name, server.id, next)} + /> +
+ ) : undefined + } + /> + ); + })} + + ) : null} + + {oauth.error ? ( +

+ {oauth.error} +

+ ) : null} + + {props.plugin.skills.length > 0 ? ( + } + title={t`Skills`} + description={t`Reusable guidance delivered across supported agents.`} + > + {props.plugin.skills.map((skill, index) => { + const enabled = state ? isPluginSkillEnabled(plugin, state, skill.id) : true; + const labelId = `${titleId}-skill-${skill.id}`; + const badgeId = `${labelId}-kind`; + return ( + setPluginSkillEnabled(plugin.name, skill.id, next)} + /> + ) : undefined + } + /> + ); + })} + + ) : null} + +
+

+ Information +

+
+
+ Identifier +
+
{plugin.name}
+ {author ? ( + <> +
+ Author +
+
{author}
+ + ) : null} +
+ Category +
+
{props.plugin.category}
+ {plugin.manifest.version ? ( + <> +
+ Version +
+
{plugin.manifest.version}
+ + ) : null} + {plugin.manifest.license ? ( + <> +
+ License +
+
{plugin.manifest.license}
+ + ) : null} + {plugin.manifest.homepage ? ( + <> +
+ Homepage +
+
{plugin.manifest.homepage}
+ + ) : null} + {plugin.manifest.repository ? ( + <> +
+ Repository +
+
{plugin.manifest.repository}
+ + ) : null} +
+ Location +
+
{plugin.root}
+
+
+
+ ); +} + +function ConnectControl(props: { + state: "unknown" | "connected" | "disconnected" | "connecting"; + ariaLabelledBy: string; + onConnect: () => void; + onDisconnect: () => void; +}) { + const connected = props.state === "connected"; + const connecting = props.state === "connecting"; + return ( + + ); +} + +function ContributionSection(props: { + icon: ReactNode; + title: string; + description?: string; + children: ReactNode; +}) { + return ( +
+
+ {props.icon} +
+

{props.title}

+ {props.description ?

{props.description}

: null} +
+
+
+ {props.children} +
+
+ ); +} + +function ContributionRow(props: { + labelId: string; + badgeId: string; + name: string; + description?: string; + badge: string; + control?: ReactNode; + last: boolean; +}) { + return ( +
+
+
+ + {props.name} + + + {props.badge} + +
+ {props.description ? ( +

{props.description}

+ ) : null} +
+ {props.control} +
+ ); +} diff --git a/src/renderer/components/plugins/PluginIcon.tsx b/src/renderer/components/plugins/PluginIcon.tsx new file mode 100644 index 000000000..b3bad264a --- /dev/null +++ b/src/renderer/components/plugins/PluginIcon.tsx @@ -0,0 +1,21 @@ +import { AppWindow, GitPullRequest, Globe, Mail, Monitor, Network, Puzzle } from "lucide-react"; + +export function PluginIcon(props: { pluginId: string; className?: string }) { + const className = props.className ?? "size-5"; + switch (props.pluginId) { + case "browser-tools": + return ; + case "chrome-tools": + return ; + case "computer-use": + return ; + case "subagent-delegation": + return ; + case "github": + return ; + case "outlook": + return ; + default: + return ; + } +} diff --git a/src/renderer/components/plugins/PluginMarketplace.test.tsx b/src/renderer/components/plugins/PluginMarketplace.test.tsx new file mode 100644 index 000000000..31f2b0fd9 --- /dev/null +++ b/src/renderer/components/plugins/PluginMarketplace.test.tsx @@ -0,0 +1,108 @@ +import { fireEvent, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { pluginFixture, seedBuiltInPlugins } from "@/renderer/testUtils/plugins"; +import { useLocalizedPluginCatalog } from "./pluginCopy"; +import { PluginMarketplace } from "./PluginMarketplace"; + +function Marketplace(props: { onOpen: (pluginId: string) => void }) { + const plugins = useLocalizedPluginCatalog(); + return ; +} + +describe("PluginMarketplace", () => { + beforeEach(() => { + localStorage.clear(); + seedBuiltInPlugins(); + useSharedSettings.setState({ installedPlugins: {} }); + }); + + it("browses plugins by contribution text, installs one, and exposes management", () => { + const onOpen = vi.fn<(pluginId: string) => void>(); + render(); + + expect(screen.getByRole("heading", { name: "Featured" })).toBeInTheDocument(); + expect(screen.getByText("Browser Tools")).toBeInTheDocument(); + expect(screen.getByText("Chrome Tools")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Browser Tools Install" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Browser Tools" })); + expect(onOpen).toHaveBeenCalledWith("browser-tools"); + onOpen.mockClear(); + + fireEvent.change(screen.getByRole("textbox", { name: "Search plugins" }), { + target: { value: "chrome" }, + }); + + expect(screen.getByText("Chrome Tools")).toBeInTheDocument(); + expect(screen.queryByText("Browser Tools")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Chrome Tools Install" })); + + expect(useSharedSettings.getState().installedPlugins["chrome-tools"]).toMatchObject({ + version: "1.1.0", + enabled: true, + }); + expect(onOpen).toHaveBeenLastCalledWith("chrome-tools"); + + fireEvent.click(screen.getByRole("button", { name: "Chrome Tools Manage" })); + expect(onOpen).toHaveBeenCalledTimes(2); + expect(onOpen).toHaveBeenLastCalledWith("chrome-tools"); + }); + + it("surfaces installed plugins in the installed strip", () => { + useSharedSettings.getState().installPlugin(pluginFixture("chrome-tools")); + const onOpen = vi.fn<(pluginId: string) => void>(); + render(); + + // The shortcut is named distinctly from the card title so a screen reader + // does not read two identically-named controls for the same plugin. + const strip = screen.getByRole("heading", { name: "Installed" }).closest("section")!; + expect(within(strip).getByRole("button", { name: "Open Chrome Tools" })).toBeInTheDocument(); + expect( + within(strip).queryByRole("button", { name: "Open Browser Tools" }), + ).not.toBeInTheDocument(); + + fireEvent.click(within(strip).getByRole("button", { name: "Open Chrome Tools" })); + expect(onOpen).toHaveBeenCalledWith("chrome-tools"); + }); + + it("groups non-featured plugins under their category", () => { + render( void>()} />); + + // Every shipped package is featured, so a category heading only appears for + // one that is not — the section list is derived, never hardcoded. + expect(screen.queryByRole("heading", { name: "Communication" })).not.toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Featured" })).toBeInTheDocument(); + }); + + it("reports when nothing matches the search", () => { + render( void>()} />); + + fireEvent.change(screen.getByRole("textbox", { name: "Search plugins" }), { + target: { value: "nothing matches this" }, + }); + + expect(screen.getByText("No plugins match your search.")).toBeInTheDocument(); + }); + + it("does not install a plugin that is unavailable on this host", () => { + const onOpen = vi.fn<(pluginId: string) => void>(); + + function LinuxMarketplace() { + const plugins = useLocalizedPluginCatalog(); + return ; + } + + render(); + + const computerUseCard = screen + .getByText("Computer Use") + .closest("[class*='min-h-40']")!; + expect( + within(computerUseCard).getByRole("button", { + name: "Computer Use Unavailable on this device", + }), + ).toBeDisabled(); + expect(useSharedSettings.getState().installedPlugins["computer-use"]).toBeUndefined(); + }); +}); diff --git a/src/renderer/components/plugins/PluginMarketplace.tsx b/src/renderer/components/plugins/PluginMarketplace.tsx new file mode 100644 index 000000000..05f4c8c8d --- /dev/null +++ b/src/renderer/components/plugins/PluginMarketplace.tsx @@ -0,0 +1,247 @@ +import { Card, Input } from "@heroui/react"; +import { Plural, Trans, useLingui } from "@lingui/react/macro"; +import { FolderOpen, Search } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/renderer/components/common"; +import { readBridge } from "@/renderer/bridge"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { isPluginSupportedOnHost } from "@/shared/plugins/catalog"; +import type { PluginCategory } from "@/shared/contracts"; +import { PluginIcon } from "./PluginIcon"; +import { PluginTag } from "./PluginTag"; +import type { LocalizedPlugin } from "./pluginCopy"; + +/** Section order for the browse view. Featured is derived, not a category. */ +const CATEGORY_ORDER: PluginCategory[] = [ + "developer-tools", + "communication", + "automation", + "productivity", +]; + +export function PluginMarketplace(props: { + plugins: readonly LocalizedPlugin[]; + hostPlatform: NodeJS.Platform; + onOpen: (pluginId: string) => void; +}) { + const { t } = useLingui(); + const [query, setQuery] = useState(""); + const installedPlugins = useSharedSettings((state) => state.installedPlugins); + const normalizedQuery = query.trim().toLowerCase(); + + const matches = props.plugins.filter((entry) => + [ + entry.name, + entry.description, + entry.category, + ...(entry.plugin.manifest.keywords ?? []), + ...entry.skills.map((skill) => skill.name), + ...entry.mcpServers.map((server) => server.name), + ] + .join(" ") + .toLowerCase() + .includes(normalizedQuery), + ); + + const installed = props.plugins.filter((entry) => installedPlugins[entry.plugin.name]); + const featured = matches.filter((entry) => entry.plugin.poracode.featured); + const sections = CATEGORY_ORDER.flatMap((category) => { + const entries = matches.filter( + (entry) => entry.plugin.poracode.category === category && !entry.plugin.poracode.featured, + ); + return entries.length > 0 ? [{ category, entries }] : []; + }); + + return ( +
+

+ Plugins +

+

+ + Bundles of skills and MCP servers that work across every supported agent. Poracode loads + any package built for the Agent Plugins specification. + +

+ +
+
+ + setQuery(event.target.value)} + /> +
+ +
+ + {installed.length > 0 && !normalizedQuery ? ( +
+

+ Installed +

+
+ {installed.map((entry) => ( + + ))} +
+
+ ) : null} + + {matches.length === 0 ? ( +
+

+ No plugins match your search. +

+
+ ) : null} + + {featured.length > 0 ? ( + + {featured.map((entry) => ( + + ))} + + ) : null} + + {sections.map(({ category, entries }) => ( + + {entries.map((entry) => ( + + ))} + + ))} +
+ ); +} + +function PluginSection(props: { title: string; count: number; children: React.ReactNode }) { + return ( +
+
+

{props.title}

+ {props.count} +
+
{props.children}
+
+ ); +} + +function PluginCard(props: { + entry: LocalizedPlugin; + hostPlatform: NodeJS.Platform; + onOpen: (pluginId: string) => void; +}) { + const installedPlugins = useSharedSettings((state) => state.installedPlugins); + const installPlugin = useSharedSettings((state) => state.installPlugin); + const plugin = props.entry.plugin; + const installed = installedPlugins[plugin.name] !== undefined; + const supported = isPluginSupportedOnHost(plugin, props.hostPlatform); + const titleId = `plugin-${plugin.name}-title`; + const actionLabelId = `plugin-${plugin.name}-action`; + const serverCount = props.entry.mcpServers.length; + + return ( + +
+
+ +
+ + + + {plugin.source === "user" ? ( + + External + + ) : null} + {plugin.poracode.communityMaintained ? ( + + Community + + ) : null} + + + {props.entry.description} + + +
+ + + + {" · "} + + + {installed ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/src/renderer/components/plugins/PluginTag.tsx b/src/renderer/components/plugins/PluginTag.tsx new file mode 100644 index 000000000..8eb068051 --- /dev/null +++ b/src/renderer/components/plugins/PluginTag.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; + +/** + * Inline label chip. Matches the local `Badge` treatment used in + * `components/mcp/McpServersManager.tsx` — HeroUI's `Badge` is an overlay badge + * and renders outside its container here. + */ +export function PluginTag(props: { children: ReactNode }) { + return ( + + {props.children} + + ); +} diff --git a/src/renderer/components/plugins/pluginCopy.ts b/src/renderer/components/plugins/pluginCopy.ts new file mode 100644 index 000000000..6443d2f94 --- /dev/null +++ b/src/renderer/components/plugins/pluginCopy.ts @@ -0,0 +1,234 @@ +import { useLingui } from "@lingui/react/macro"; +import type { LoadedPlugin, PluginDiagnostic, SkillEntry } from "@/shared/contracts"; +import { usePlugins } from "@/renderer/state/pluginsStore"; + +/** + * Display copy for loaded Agent Plugins packages. + * + * Poracode's own packages ship English text in `plugin.json`, so their names and + * descriptions are overridden here with translated strings. Third-party packages + * carry author-written metadata that cannot live in our catalogs, so their + * manifest text is shown as authored — that is the correct behavior for a + * general plugin client, not a missing translation. + */ + +export interface LocalizedPluginContribution { + id: string; + name: string; + /** + * Absent when we have no copy of our own for this contribution. Callers that + * have the scanned SKILL.md fall back to its description; an empty string here + * would shadow it, because `??` does not treat "" as missing. + */ + description?: string; +} + +export interface LocalizedPlugin { + plugin: LoadedPlugin; + name: string; + description: string; + category: string; + skills: LocalizedPluginContribution[]; + mcpServers: LocalizedPluginContribution[]; +} + +export function useLocalizedPluginCatalog(): LocalizedPlugin[] { + const { t } = useLingui(); + const plugins = usePlugins((state) => state.plugins); + + return plugins.map((plugin): LocalizedPlugin => { + const fallbackName = plugin.poracode.title ?? plugin.name; + let name: string; + let description: string; + switch (plugin.name) { + case "browser-tools": + name = t`Browser Tools`; + description = t`Browse, inspect, and test websites in Poracode's isolated in-app browser.`; + break; + case "chrome-tools": + name = t`Chrome Tools`; + description = t`Work with the pages and signed-in sessions already open in Chrome.`; + break; + case "computer-use": + name = t`Computer Use`; + description = t`Control desktop apps and complete visual workflows.`; + break; + case "subagent-delegation": + name = t`Subagent Delegation`; + description = t`Delegate focused work to other installed agents and coordinate the results.`; + break; + case "github": + name = t`GitHub`; + description = t`Triage PRs, issues, CI, and publish flows.`; + break; + case "outlook": + name = t`Outlook`; + description = t`Triage Microsoft Outlook mail and manage your calendar.`; + break; + default: + name = fallbackName; + description = plugin.manifest.description ?? ""; + } + + const skills = plugin.skills.map((skill): LocalizedPluginContribution => { + const policy = plugin.poracode.skills[skill.folder]; + switch (`${plugin.name}:${skill.folder}`) { + case "browser-tools:browser-control": + return { + id: skill.folder, + name: t`Browser Control`, + description: t`Navigate, inspect, and test pages with the in-app Browser MCP.`, + }; + case "chrome-tools:chrome-control": + return { + id: skill.folder, + name: t`Chrome Control`, + description: t`Use Chrome safely when a task needs an existing browser session.`, + }; + case "computer-use:computer-use": + return { + id: skill.folder, + name: t`Computer Use`, + description: t`Operate desktop apps through Poracode's desktop-control tools.`, + }; + case "subagent-delegation:subagent-delegation": + return { + id: skill.folder, + name: t`Subagent Delegation`, + description: t`Choose, brief, and coordinate subagents for parallel work.`, + }; + default: + return { + id: skill.folder, + name: policy?.name ?? skill.folder, + ...(policy?.description ? { description: policy.description } : {}), + }; + } + }); + + // Server transport detail is author-supplied and identifies the endpoint, so + // it is shown verbatim rather than translated. + const builtInMcpServers = plugin.poracode.builtInMcpServerIds.map( + (id): LocalizedPluginContribution => ({ + id, + name: + id === "browser" + ? t`Browser` + : id === "chrome" + ? t`Chrome` + : id === "crossagents" + ? t`Crossagents` + : id === "computer-use" + ? t`Computer Use` + : id, + }), + ); + const declaredMcpServers = plugin.mcpServers.map((server): LocalizedPluginContribution => { + const entry = server.entry; + return { + id: server.name, + name: server.name, + description: entry.type === "stdio" ? entry.command : entry.url, + }; + }); + const mcpServers = [...builtInMcpServers, ...declaredMcpServers]; + + const category = + plugin.poracode.category === "developer-tools" + ? t`Developer tools` + : plugin.poracode.category === "automation" + ? t`Automation` + : plugin.poracode.category === "communication" + ? t`Communication` + : t`Productivity`; + + return { plugin, name, description, category, skills, mcpServers }; + }); +} + +export function resolveLocalizedPluginSkill( + catalog: readonly LocalizedPlugin[], + skill: Pick, +) { + const localizedPlugin = skill.pluginId + ? catalog.find((entry) => entry.plugin.name === skill.pluginId) + : undefined; + const pluginSkill = localizedPlugin?.plugin.skills.find( + (contribution) => contribution.folder === skill.folderName, + ); + const localizedSkill = localizedPlugin?.skills.find( + (contribution) => contribution.id === pluginSkill?.folder, + ); + return { localizedPlugin, pluginSkill, localizedSkill }; +} + +/** + * User-facing text for a loader diagnostic. + * + * `PluginDiagnostic.message` is written in the supervisor, which carries no + * catalogs, so it is English developer prose ("skills/ exists but is not a + * directory"). The `code` is the stable part — translate that and keep the raw + * message only as the technical detail for a code we do not recognize. + */ +export function useLocalizedPluginDiagnostic(): (diagnostic: PluginDiagnostic) => string { + const { t } = useLingui(); + + return (diagnostic) => { + const target = diagnostic.target; + switch (diagnostic.code) { + case "root-unresolvable": + return t`This plugin's folder could not be read.`; + case "manifest-missing": + return t`plugin.json is missing.`; + case "manifest-unreadable": + case "manifest-not-object": + return t`plugin.json could not be read as JSON.`; + case "manifest-invalid": + return t`plugin.json is not valid for the Agent Plugins specification.`; + case "manifest-schema-unsupported": + return t`plugin.json targets an Agent Plugins version this build does not support.`; + case "manifest-unknown-field": + return t`Ignored an unrecognized field in plugin.json.`; + case "manifest-extensions-not-object": + case "extension-invalid": + return t`This plugin's Poracode settings were ignored because they are not valid.`; + case "extension-unknown-skill": + return t`This plugin describes a skill it does not actually ship.`; + case "path-escapes-root": + return target + ? t`Skipped ${target} because it points outside the plugin folder.` + : t`Skipped a file because it points outside the plugin folder.`; + case "skills-location-wrong-kind": + case "skills-unreadable": + return t`This plugin's skills could not be read.`; + case "mcp-location-wrong-kind": + case "mcp-unreadable": + case "mcp-document-not-object": + return t`mcp.json could not be read, so this plugin's servers were skipped.`; + case "mcp-schema-unsupported": + case "mcp-schema-version-mismatch": + return t`mcp.json targets an Agent Plugins version this build does not support.`; + case "mcp-servers-not-object": + case "mcp-entry-invalid": + return target + ? t`Server ${target} is not configured correctly and was skipped.` + : t`A server is not configured correctly and was skipped.`; + case "mcp-entry-unresolvable": + return target + ? t`Server ${target} could not be started and was skipped.` + : t`A server could not be started and was skipped.`; + case "mcp-entry-host-only": + return target + ? t`Server ${target} runs on this computer and is unavailable for WSL projects.` + : t`This server runs on this computer and is unavailable for WSL projects.`; + case "mcp-name-unusable": + return target + ? t`Server ${target} has a name Poracode cannot use.` + : t`A server has a name Poracode cannot use.`; + case "plugin-data-unavailable": + return t`Poracode could not create this plugin's data folder.`; + default: + return diagnostic.message; + } + }; +} diff --git a/src/renderer/components/plugins/usePluginOauth.ts b/src/renderer/components/plugins/usePluginOauth.ts new file mode 100644 index 000000000..1a579c570 --- /dev/null +++ b/src/renderer/components/plugins/usePluginOauth.ts @@ -0,0 +1,137 @@ +import { useEffect, useState } from "react"; +import { useLingui } from "@lingui/react/macro"; +import type { LoadedPlugin, McpServer } from "@/shared/contracts"; +import { DEFAULT_MCP_SERVER_TIMEOUT_MS } from "@/shared/contracts"; +import { readBridge } from "@/renderer/bridge"; +import { pluginMcpServerId, pluginMcpServerName } from "@/shared/plugins/catalog"; + +/** + * Connection state for a plugin's remote MCP servers. + * + * Remote servers a package declares in `mcp.json` are authorized through the + * same OAuth 2.1 flow already used for user-configured MCP servers + * (`src/supervisor/mcp/McpOAuthService.ts`). The supervisor owns the loopback + * redirect listener and the sealed token store; the renderer only ever sees the + * authorization URL and a connected flag. + */ + +type ConnectionState = "unknown" | "connected" | "disconnected" | "connecting"; + +function remoteServerUrl(entry: LoadedPlugin["mcpServers"][number]["entry"]): string | undefined { + // Must match what `pluginMcpRuntime.buildTransport` launches: the token store + // is keyed on the exact URL string. + return entry.type === "stdio" ? undefined : entry.url.trim(); +} + +/** + * Mirrors the record `pluginMcpRuntime` builds so the supervisor authorizes the + * same server. `McpOAuthService.begin` only reads `transport.url`, but the + * transport kind and headers are carried through so the two sides cannot drift. + */ +function toMcpServer( + plugin: LoadedPlugin, + declaration: LoadedPlugin["mcpServers"][number], + url: string, +): McpServer { + const entry = declaration.entry; + return { + id: pluginMcpServerId(plugin.name, declaration.name), + name: pluginMcpServerName(plugin.name, declaration.name), + description: plugin.manifest.description ?? "", + enabled: true, + timeoutMs: DEFAULT_MCP_SERVER_TIMEOUT_MS, + transport: { + type: entry.type === "streamable-http" ? "http" : "sse", + url, + headers: entry.type === "stdio" ? {} : { ...entry.headers }, + }, + }; +} + +export function usePluginOauth(plugin: LoadedPlugin) { + const { t } = useLingui(); + const [authorizedUrls, setAuthorizedUrls] = useState(); + const [pending, setPending] = useState(); + const [error, setError] = useState(); + + const refresh = async () => { + try { + const status = await readBridge().getMcpOauthStatus({}); + setAuthorizedUrls(status.authenticatedUrls); + } catch { + // Leave the state unknown rather than claiming a server is disconnected. + setAuthorizedUrls(undefined); + } + }; + + const hasRemoteServer = plugin.mcpServers.some((server) => remoteServerUrl(server.entry)); + + useEffect(() => { + if (hasRemoteServer) void refresh(); + // `refresh` only closes over setState, which is stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasRemoteServer]); + + const stateFor = (serverName: string): ConnectionState => { + const server = plugin.mcpServers.find((candidate) => candidate.name === serverName); + const url = server ? remoteServerUrl(server.entry) : undefined; + if (!url) return "unknown"; + if (pending === serverName) return "connecting"; + if (!authorizedUrls) return "unknown"; + return authorizedUrls.includes(url) ? "connected" : "disconnected"; + }; + + const connect = async (serverName: string) => { + const server = plugin.mcpServers.find((candidate) => candidate.name === serverName); + const url = server ? remoteServerUrl(server.entry) : undefined; + if (!server || !url) return; + setPending(serverName); + setError(undefined); + try { + const bridge = readBridge(); + const begin = await bridge.beginMcpServerOauth({ + server: toMcpServer(plugin, server, url), + }); + if (begin.status === "error") { + setError(t`Could not sign in to ${serverName}.`); + return; + } + if (begin.status === "redirect") { + await bridge.openExternalNative(begin.authorizationUrl); + const result = await bridge.waitMcpServerOauth({ flowId: begin.flowId }); + if (result.status === "error") { + setError(t`Could not sign in to ${serverName}.`); + return; + } + } + await refresh(); + } catch { + setError(t`Could not sign in to ${serverName}.`); + } finally { + setPending(undefined); + } + }; + + const disconnect = async (serverName: string) => { + const server = plugin.mcpServers.find((candidate) => candidate.name === serverName); + const url = server ? remoteServerUrl(server.entry) : undefined; + if (!url) return; + setError(undefined); + try { + await readBridge().clearMcpServerOauth({ url }); + await refresh(); + } catch { + setError(t`Could not sign out of ${serverName}.`); + } + }; + + /** True for servers reached over the network, which are the ones that can be authorized. */ + const isRemoteServer = (serverName: string): boolean => { + const server = plugin.mcpServers.find((candidate) => candidate.name === serverName); + return server ? remoteServerUrl(server.entry) !== undefined : false; + }; + + return { stateFor, isRemoteServer, connect, disconnect, error }; +} + +export type PluginOauthConnectionState = ConnectionState; diff --git a/src/renderer/components/skills/SkillViewModal.tsx b/src/renderer/components/skills/SkillViewModal.tsx index 03e5103fa..15071cec0 100644 --- a/src/renderer/components/skills/SkillViewModal.tsx +++ b/src/renderer/components/skills/SkillViewModal.tsx @@ -13,6 +13,7 @@ function skillMarkdownBody(content: string): string { export function SkillViewModal(props: { skill: SkillEntry; + displayName: string; projectLocation?: ProjectLocation; wslDistro?: string; onClose: () => void; @@ -69,7 +70,7 @@ export function SkillViewModal(props: { - {props.skill.name} + {props.displayName}

{props.skill.skillFilePath}

diff --git a/src/renderer/components/skills/SkillsManager.test.tsx b/src/renderer/components/skills/SkillsManager.test.tsx index e81e3492f..b7d8ec39b 100644 --- a/src/renderer/components/skills/SkillsManager.test.tsx +++ b/src/renderer/components/skills/SkillsManager.test.tsx @@ -7,6 +7,7 @@ import type { SkillScanResult, } from "@/shared/contracts"; import { AppProvider } from "@/renderer/components/ui/provider"; +import { seedBuiltInPlugins } from "@/renderer/testUtils/plugins"; import { SkillsManager } from "./SkillsManager"; const { @@ -109,6 +110,7 @@ function renderManager( describe("SkillsManager", () => { beforeEach(() => { + seedBuiltInPlugins(); vi.clearAllMocks(); bridge.listWslDistros.mockReturnValue(new Promise(() => undefined)); ensureHomeScopeProjectMock.mockResolvedValue({ id: "home" }); @@ -235,6 +237,58 @@ describe("SkillsManager", () => { ).not.toHaveTextContent("Enabled"); }); + it("identifies plugin-managed skills without exposing lifecycle controls", async () => { + useSkillsMock.mockReturnValue({ + scan: scan([ + skill({ + id: "global:plugin:browser-control:on", + name: "browser-control", + description: "Navigate, inspect, and test pages", + folderName: "browser-control", + absolutePath: "C:\\Users\\me\\.poracode\\plugins\\browser-tools\\browser-control", + skillFilePath: + "C:\\Users\\me\\.poracode\\plugins\\browser-tools\\browser-control\\SKILL.md", + rootPath: "C:\\Users\\me\\.poracode\\plugins\\browser-tools", + providerId: "plugin:browser-tools", + providerLabel: "Browser Tools", + providerGroupId: "plugin:browser-tools", + pluginId: "browser-tools", + pluginName: "Browser Tools", + origin: "plugin", + mutable: false, + enabled: false, + }), + ]), + loading: false, + error: undefined, + reload, + }); + + renderManager(); + + expect(screen.getByRole("heading", { name: "Browser Tools" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "View Browser Control" })).toBeInTheDocument(); + expect( + screen.getByText("Navigate, inspect, and test pages with the in-app Browser MCP."), + ).toBeInTheDocument(); + expect(screen.getByText("Plugin")).toBeInTheDocument(); + expect(screen.getByText("Managed by Browser Tools")).toBeInTheDocument(); + expect(screen.getByText("Disabled", { selector: "span" })).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Delete browser-control" }), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("switch", { name: /browser-control/iu })).not.toBeInTheDocument(); + + fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), { + target: { value: "in-app Browser MCP" }, + }); + expect(screen.getByRole("button", { name: "View Browser Control" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "View Browser Control" })); + expect(screen.getByRole("heading", { name: "Browser Control" })).toBeInTheDocument(); + await waitFor(() => expect(bridge.readExternalFile).toHaveBeenCalled()); + }); + it("does not label linked skills as already imported", () => { useSkillsMock.mockReturnValue({ scan: scan([ diff --git a/src/renderer/components/skills/SkillsManager.tsx b/src/renderer/components/skills/SkillsManager.tsx index d076946cb..abbd4bd66 100644 --- a/src/renderer/components/skills/SkillsManager.tsx +++ b/src/renderer/components/skills/SkillsManager.tsx @@ -26,6 +26,11 @@ import { SkillViewModal } from "./SkillViewModal"; import { groupSkills } from "./skillGrouping"; import { hostGlobalScopeLabel, resolveSkillTarget, skillTargetRequest } from "./skillTargets"; import { useSkills } from "./useSkills"; +import { + resolveLocalizedPluginSkill, + useLocalizedPluginCatalog, + type LocalizedPlugin, +} from "@/renderer/components/plugins/pluginCopy"; type StatusFilter = "all" | "enabled" | "disabled"; @@ -34,6 +39,7 @@ export function SkillsManager(props: { defaultDestinationId?: string; }) { const { t } = useLingui(); + const localizedPlugins = useLocalizedPluginCatalog(); const [destinationId, setDestinationId] = useState( props.defaultDestinationId ?? GLOBAL_MCP_DESTINATION_ID, ); @@ -79,12 +85,19 @@ export function SkillsManager(props: { const visibleSkills = targetSkills.filter((skill) => { if (statusFilter === "enabled" && !skill.enabled) return false; if (statusFilter === "disabled" && skill.enabled) return false; + const { localizedPlugin, localizedSkill } = resolveLocalizedPluginSkill( + localizedPlugins, + skill, + ); return [ skill.name, skill.description, skill.providerLabel, skill.scopeLabel, skill.absolutePath, + localizedPlugin?.name, + localizedSkill?.name, + localizedSkill?.description, ] .join(" ") .toLowerCase() @@ -107,9 +120,12 @@ export function SkillsManager(props: { const roots = new Set(skills.map((skill) => skill.rootPath)); return { key, - title: - skills.find((skill) => skill.providerGroupLabel)?.providerGroupLabel ?? - first.providerLabel, + title: first.pluginId + ? (localizedPlugins.find((entry) => entry.plugin.name === first.pluginId)?.name ?? + first.pluginName ?? + first.providerLabel) + : (skills.find((skill) => skill.providerGroupLabel)?.providerGroupLabel ?? + first.providerLabel), ...(roots.size === 1 ? { subtitle: first.rootPath } : {}), skills, order: Math.min(...skills.map((skill) => skill.providerGroupOrder ?? 0)), @@ -124,6 +140,10 @@ export function SkillsManager(props: { const externalCount = targetSkills.filter( (skill) => skill.origin === "external" && skill.valid && skill.portable !== false, ).length; + const viewingSkillDisplayName = viewingSkill + ? (resolveLocalizedPluginSkill(localizedPlugins, viewingSkill).localizedSkill?.name ?? + viewingSkill.name) + : undefined; const runMutation = async (skill: SkillEntry, action: () => Promise) => { setPending((current) => new Set(current).add(skill.id)); @@ -223,6 +243,7 @@ export function SkillsManager(props: { {viewingSkill ? ( setViewingSkill(undefined)} @@ -420,6 +441,7 @@ export function SkillsManager(props: { title={section.title} {...("subtitle" in section ? { subtitle: section.subtitle } : {})} skills={section.skills} + localizedPlugins={localizedPlugins} pending={pending} onEnabledChange={setEnabled} onView={setViewingSkill} @@ -455,6 +477,7 @@ function SkillSection(props: { title: string; subtitle?: string; skills: SkillEntry[]; + localizedPlugins: readonly LocalizedPlugin[]; pending: ReadonlySet; onEnabledChange: (skill: SkillEntry, enabled: boolean) => Promise; onView: (skill: SkillEntry) => void; @@ -478,6 +501,7 @@ function SkillSection(props: { Promise; onView: (skill: SkillEntry) => void; @@ -498,6 +523,13 @@ function SkillRow(props: { }) { const { t } = useLingui(); const skill = props.skill; + const { localizedPlugin, localizedSkill: pluginSkillCopy } = resolveLocalizedPluginSkill( + props.localizedPlugins, + skill, + ); + const pluginName = localizedPlugin?.name ?? skill.pluginName; + const displayName = pluginSkillCopy?.name ?? skill.name; + const displayDescription = pluginSkillCopy?.description ?? skill.description; const providerOwnedLabel = skill.origin === "built-in" ? ( Built-in @@ -535,10 +567,10 @@ function SkillRow(props: { size="sm" variant="ghost" className="!h-auto min-w-0 max-w-full justify-start !p-0 text-sm font-medium text-foreground hover:underline" - aria-label={t`View ${skill.name}`} + aria-label={t`View ${displayName}`} onPress={() => props.onView(skill)} > - {skill.name} + {displayName} {providerOwnedLabel ? ( @@ -558,9 +590,14 @@ function SkillRow(props: { {importLabel ? ( {importLabel} ) : null} + {!skill.mutable && !skill.enabled ? ( + + Disabled + + ) : null}

- {(invalidReason ?? skill.description) || t`No description`} + {(invalidReason ?? displayDescription) || t`No description`}

{skill.absolutePath}

@@ -593,7 +630,7 @@ function SkillRow(props: { ) : ( - Managed by provider + {pluginName ? Managed by {pluginName} : Managed by provider} )} diff --git a/src/renderer/components/skills/useSkills.test.ts b/src/renderer/components/skills/useSkills.test.ts index 35ba31f53..7d593bf27 100644 --- a/src/renderer/components/skills/useSkills.test.ts +++ b/src/renderer/components/skills/useSkills.test.ts @@ -1,14 +1,25 @@ +import { createElement, type PropsWithChildren } from "react"; import { act, renderHook, waitFor } from "@testing-library/react"; +import { I18nProvider } from "@lingui/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SkillScanResult } from "@/shared/contracts"; -import { buildSkillSlashCommands, useSkills } from "./useSkills"; +import { dynamicActivate, i18n } from "@/renderer/i18n/i18n"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { usePlugins } from "@/renderer/state/pluginsStore"; +import { pluginFixture, seedBuiltInPlugins } from "@/renderer/testUtils/plugins"; +import { + buildSkillSlashCommands, + usePluginMentionItems, + useSkills, + useSkillSlashCommandState, +} from "./useSkills"; const { scanSkillsMock } = vi.hoisted(() => ({ scanSkillsMock: vi.fn<() => Promise>(), })); vi.mock("@/renderer/bridge", () => ({ - readBridge: () => ({ scanSkills: scanSkillsMock }), + readBridge: () => ({ platform: "win32", scanSkills: scanSkillsMock }), })); const invocationByProvider = { @@ -34,9 +45,47 @@ function emptyScan(): SkillScanResult { }; } +function pluginSkillScan(): SkillScanResult { + const id = "project:plugin:browser-tools:browser-control"; + return { + skills: [ + { + id, + name: "browser-control", + description: "Navigate, inspect, and test pages with the in-app Browser MCP.", + folderName: "browser-control", + absolutePath: "C:\\project\\.poracode\\skills\\browser-control", + skillFilePath: "C:\\project\\.poracode\\skills\\browser-control\\SKILL.md", + rootPath: "C:\\project\\.poracode\\skills", + providerId: "plugin:browser-tools", + providerLabel: "Browser Tools", + scope: "project", + scopeLabel: "Project", + origin: "plugin", + pluginId: "browser-tools", + pluginName: "Browser Tools", + enabled: true, + mutable: false, + valid: true, + linked: false, + }, + ], + effectiveSkillIds: [id], + invocation: "dollar", + issues: [], + canLinkToGlobal: true, + }; +} + +function I18nWrapper(props: PropsWithChildren) { + return createElement(I18nProvider, { i18n }, props.children); +} + describe("useSkills", () => { beforeEach(() => { scanSkillsMock.mockReset(); + seedBuiltInPlugins(); + useSharedSettings.setState({ installedPlugins: {} }); }); it("shows the cached result immediately while refreshing a remounted scope", async () => { @@ -60,6 +109,109 @@ describe("useSkills", () => { act(() => resolveRefresh(refreshed)); await waitFor(() => expect(second.result.current.scan).toBe(refreshed)); }); + + it("invalidates mounted composer skills immediately when plugin state changes", async () => { + useSharedSettings.getState().installPlugin(pluginFixture("browser-tools")); + const initial = pluginSkillScan(); + scanSkillsMock.mockResolvedValueOnce(initial); + const hook = renderHook( + () => + useSkillSlashCommandState({ kind: "windows", path: "C:\\PluginStateCacheTest" }, "codex"), + { wrapper: I18nWrapper }, + ); + await waitFor(() => expect(hook.result.current.commands).toHaveLength(1)); + + let resolveRefresh!: (result: SkillScanResult) => void; + scanSkillsMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + act(() => useSharedSettings.getState().setPluginEnabled(pluginFixture("browser-tools"), false)); + + expect(hook.result.current.commands).toEqual([]); + await waitFor(() => expect(scanSkillsMock).toHaveBeenCalledTimes(2)); + act(() => resolveRefresh(emptyScan())); + await waitFor(() => expect(hook.result.current.resolved).toBe(true)); + }); + + it("rescans mounted composer skills when the plugin catalog refreshes", async () => { + scanSkillsMock.mockResolvedValueOnce(emptyScan()).mockResolvedValueOnce(emptyScan()); + const hook = renderHook(() => useSkills(undefined, "codex", "CatalogRefreshTest")); + await waitFor(() => expect(scanSkillsMock).toHaveBeenCalledTimes(1)); + + act(() => usePlugins.setState((state) => ({ revision: state.revision + 1 }))); + await waitFor(() => expect(scanSkillsMock).toHaveBeenCalledTimes(2)); + hook.unmount(); + }); + + it("represents an installed plugin as one mention backed by its core skill", async () => { + useSharedSettings.getState().installPlugin(pluginFixture("browser-tools")); + scanSkillsMock.mockResolvedValueOnce(pluginSkillScan()); + const hook = renderHook( + () => usePluginMentionItems({ kind: "windows", path: "C:\\PluginMentionTest" }, "codex"), + { wrapper: I18nWrapper }, + ); + + await waitFor(() => expect(hook.result.current).toHaveLength(1)); + expect(hook.result.current[0]).toMatchObject({ + id: "browser-tools", + name: "Browser Tools", + detail: "Plugin", + command: { + skillName: "browser-control", + skillInvocation: "$browser-control", + pluginId: "browser-tools", + pluginName: "Browser Tools", + }, + }); + }); + + it("scopes composer scans to the active presentation", async () => { + scanSkillsMock.mockResolvedValueOnce(emptyScan()); + const projectLocation = { kind: "windows" as const, path: "C:\\PresentationSkillTest" }; + const hook = renderHook( + () => useSkillSlashCommandState(projectLocation, "claude", "terminal"), + { wrapper: I18nWrapper }, + ); + + await waitFor(() => expect(hook.result.current.resolved).toBe(true)); + expect(scanSkillsMock).toHaveBeenCalledWith({ + projectLocation, + agentKind: "claude", + presentationMode: "terminal", + }); + }); + + it("localizes plugin command display metadata without changing its invocation identity", async () => { + await dynamicActivate("es"); + useSharedSettings.getState().installPlugin(pluginFixture("browser-tools")); + scanSkillsMock.mockResolvedValueOnce(pluginSkillScan()); + const hook = renderHook( + () => + useSkillSlashCommandState( + { kind: "windows", path: "C:\\LocalizedPluginCommandTest" }, + "codex", + ), + { wrapper: I18nWrapper }, + ); + + try { + await waitFor(() => expect(hook.result.current.commands).toHaveLength(1)); + expect(hook.result.current.commands[0]).toMatchObject({ + id: "browser-control", + label: + "Control del navegador — Navega, inspecciona y prueba páginas con el MCP del navegador integrado.", + description: "Navega, inspecciona y prueba páginas con el MCP del navegador integrado.", + skillName: "browser-control", + skillInvocation: "$browser-control", + skillProvider: "Herramientas del navegador", + }); + } finally { + hook.unmount(); + await dynamicActivate("en"); + } + }); }); describe("buildSkillSlashCommands", () => { diff --git a/src/renderer/components/skills/useSkills.ts b/src/renderer/components/skills/useSkills.ts index cd0424461..afa7e8476 100644 --- a/src/renderer/components/skills/useSkills.ts +++ b/src/renderer/components/skills/useSkills.ts @@ -1,16 +1,40 @@ import { useEffect, useRef, useState } from "react"; +import { useLingui } from "@lingui/react/macro"; import type { AgentSlashCommand, + InstalledPlugins, ProjectLocation, ScanSkillsPayload, SkillScanResult, + ThreadPresentationMode, } from "@/shared/contracts"; import { readBridge } from "@/renderer/bridge"; +import { + resolveLocalizedPluginSkill, + useLocalizedPluginCatalog, + type LocalizedPlugin, +} from "@/renderer/components/plugins/pluginCopy"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { usePlugins } from "@/renderer/state/pluginsStore"; +import type { PluginMentionItem } from "@/renderer/components/composer/MentionInput"; +import { + getPluginCoreSkill, + isPluginSkillEnabled, + isPluginSupportedForProject, +} from "@/shared/plugins/catalog"; const scanCache = new Map(); const pendingScans = new Map>(); const scanVersions = new Map(); +function pluginSkillScanKey(installedPlugins: InstalledPlugins): string { + return JSON.stringify( + Object.entries(installedPlugins) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([id, state]) => [id, state.version, state.enabled, state.disabledSkillIds.toSorted()]), + ); +} + function requestSkillScan( requestKey: string, payload: ScanSkillsPayload, @@ -39,8 +63,11 @@ export function useSkills( projectLocation?: ProjectLocation, agentKind?: string, wslDistro?: string, + presentationMode?: ThreadPresentationMode, ) { - const requestKey = `${agentKind ?? ""}\0${wslDistro ?? ""}\0${projectLocation ? JSON.stringify(projectLocation) : ""}`; + const installedPlugins = useSharedSettings((state) => state.installedPlugins); + const pluginRevision = usePlugins((state) => state.revision); + const requestKey = `${agentKind ?? ""}\0${wslDistro ?? ""}\0${presentationMode ?? ""}\0${projectLocation ? JSON.stringify(projectLocation) : ""}\0${pluginSkillScanKey(installedPlugins)}\0${pluginRevision}`; const cachedScan = scanCache.get(requestKey); const [scanState, setScanState] = useState< | { @@ -64,6 +91,7 @@ export function useSkills( ...(projectLocation ? { projectLocation } : {}), ...(wslDistro ? { wslDistro } : {}), ...(agentKind ? { agentKind } : {}), + ...(presentationMode ? { presentationMode } : {}), }, reusePending, ); @@ -96,42 +124,115 @@ export function useSkills( export function useSkillSlashCommands( projectLocation: ProjectLocation, agentKind: string, + presentationMode?: ThreadPresentationMode, ): AgentSlashCommand[] { - return useSkillSlashCommandState(projectLocation, agentKind).commands; + return useSkillSlashCommandState(projectLocation, agentKind, presentationMode).commands; } -export function useSkillSlashCommandState(projectLocation: ProjectLocation, agentKind: string) { - const { scan, loading, error } = useSkills(projectLocation, agentKind); +export function useSkillSlashCommandState( + projectLocation: ProjectLocation, + agentKind: string, + presentationMode?: ThreadPresentationMode, +) { + const { scan, loading, error } = useSkills( + projectLocation, + agentKind, + undefined, + presentationMode, + ); + const localizedPlugins = useLocalizedPluginCatalog(); return { - commands: buildSkillSlashCommands(scan), + commands: buildSkillSlashCommands(scan, localizedPlugins), resolved: !loading && (scan !== null || error !== undefined), }; } -export function buildSkillSlashCommands(scan: SkillScanResult | null): AgentSlashCommand[] { - if (!scan?.invocation) return []; +export function buildSkillSlashCommands( + scan: SkillScanResult | null, + localizedPlugins: readonly LocalizedPlugin[] = [], +): AgentSlashCommand[] { + const invocation = scan?.invocation; + if (!invocation) return []; const effective = new Set(scan.effectiveSkillIds); return scan.skills.flatMap((skill) => { if (!effective.has(skill.id)) return []; - const invocation = - scan.invocation === "dollar" - ? `$${skill.name}` - : scan.invocation === "skill" - ? `/skill:${skill.name}` - : scan.invocation === "prompt" - ? `Use the ${skill.name} skill.` - : `/${skill.name}`; + return [buildSkillSlashCommand(skill, invocation, localizedPlugins)]; + }); +} + +function buildSkillSlashCommand( + skill: SkillScanResult["skills"][number], + invocationKind: NonNullable, + localizedPlugins: readonly LocalizedPlugin[], +): AgentSlashCommand { + const { localizedPlugin, localizedSkill } = resolveLocalizedPluginSkill(localizedPlugins, skill); + const displayName = localizedSkill?.name ?? skill.name; + const description = localizedSkill?.description ?? skill.description; + const invocation = + invocationKind === "dollar" + ? `$${skill.name}` + : invocationKind === "skill" + ? `/skill:${skill.name}` + : invocationKind === "prompt" + ? `Use the ${skill.name} skill.` + : `/${skill.name}`; + return { + id: skill.name, + label: description ? `${displayName} — ${description}` : displayName, + ...(description ? { description } : {}), + section: "skills", + skillName: skill.name, + skillPath: skill.skillFilePath, + skillInvocation: invocation, + skillProvider: localizedPlugin?.name ?? skill.providerLabel, + skillScope: skill.scope, + ...(skill.pluginId ? { pluginId: skill.pluginId } : {}), + ...(skill.pluginName ? { pluginName: localizedPlugin?.name ?? skill.pluginName } : {}), + }; +} + +/** Installed plugins represented as one composer mention backed by their core skill. */ +export function usePluginMentionItems( + projectLocation: ProjectLocation, + agentKind: string, + presentationMode?: ThreadPresentationMode, +): PluginMentionItem[] { + const { t } = useLingui(); + const { scan } = useSkills(projectLocation, agentKind, undefined, presentationMode); + const localizedPlugins = useLocalizedPluginCatalog(); + const installedPlugins = useSharedSettings((state) => state.installedPlugins); + const disabledBuiltIns = useSharedSettings((state) => state.disabledBuiltInMcpServers); + const invocation = scan?.invocation; + if (!invocation) return []; + + return localizedPlugins.flatMap((localized): PluginMentionItem[] => { + const plugin = localized.plugin; + const state = installedPlugins[plugin.name]; + const core = getPluginCoreSkill(plugin); + if ( + !state?.enabled || + !core || + !isPluginSkillEnabled(plugin, state, core.folder) || + !isPluginSupportedForProject(plugin, readBridge().platform, projectLocation) || + plugin.poracode.builtInMcpServerIds.some((id) => disabledBuiltIns[id] === true) + ) { + return []; + } + const skill = scan.skills.find( + (candidate) => + candidate.pluginId === plugin.name && + candidate.folderName === core.folder && + candidate.enabled && + candidate.valid, + ); + if (!skill) return []; + const command = buildSkillSlashCommand(skill, invocation, localizedPlugins); return [ { - id: skill.name, - label: skill.description ? `${skill.name} — ${skill.description}` : skill.name, - ...(skill.description ? { description: skill.description } : {}), - section: "skills" as const, - skillName: skill.name, - skillPath: skill.skillFilePath, - skillInvocation: invocation, - skillProvider: skill.providerLabel, - skillScope: skill.scope, + id: plugin.name, + name: localized.name, + detail: t`Plugin`, + command: { ...command, pluginId: plugin.name, pluginName: localized.name }, }, ]; }); diff --git a/src/renderer/components/thread/ChatPane/parts/items/UserMessage.tsx b/src/renderer/components/thread/ChatPane/parts/items/UserMessage.tsx index 8667d36dd..4da58b547 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/UserMessage.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/UserMessage.tsx @@ -29,6 +29,7 @@ import { CheckpointRevertButton, type CheckpointRevertRequest } from "../Checkpo import { chatPromptSurfaceClass } from "./chatMessageSurface"; import { CopyTextButton } from "./CopyTextButton"; import { InlineFilePathChip } from "./InlineFilePathChip"; +import { PluginIcon } from "@/renderer/components/plugins/PluginIcon"; import { ItemMarkdown } from "./ItemMarkdown"; import { extractSelectorPayloads } from "./SelectorBadge"; import { @@ -322,7 +323,8 @@ function buildUserPromptText(content: CanonicalContentBlock[]): string { return content .map((block) => { if (block.kind === "text") return block.text; - if (block.kind === "skill") return block.invocation; + if (block.kind === "skill") + return block.pluginName ? `@${block.pluginName}` : block.invocation; if (block.kind === "diff_comment") return formatDiffCommentPrompt(block); if (block.kind === "mcp") return `@${block.name}`; if (block.kind === "file" && block.source !== "attachment") return block.path; @@ -360,9 +362,16 @@ function renderUserMessageInlineContent( nodes.push(