Compare commits

...

17 Commits

Author SHA1 Message Date
2a3c582ec8 merge(sdk): fix #329 — feedbacks menu host génériques et doc SDK alignée (QA verte: npm run check, git diff --check) — docs only, aucun push 2026-09-19 00:33:57 +02:00
32d33b330f docs(sdk): feedbacks menu host génériques — #329 ; README + docs commands-and-feedback/menus/services : failed/skipped sans message Git codé en dur ni payload brut, opened/alreadyOpen succès silencieux, logs host pluginId/commandId/status/reason/message, vue de diagnostic reste accessible quand Git/ADB/Gradle ou autre outil optionnel manque, workspace.execTool rappelé ; QA verte : npm run check, git diff --check 2026-09-19 00:33:57 +02:00
8ff0f475de feat(sdk): contrat permissions.workspaceExec pour l'exécution contrôlée via l'hôte — #346
Cadrage capacités plugin SCM (#346): le manifeste plugin gagne
permissions.workspaceExec.allow — règles d'exécution contrôlée par l'hôte
(commande bare ASCII résolue sans shell, argv exact avec jeton `*` en
suffixe, cwd project-relative sûr, effect read/write/network/destructive,
64 règles max) — au lieu de lectures ad hoc du store interne .git.
Validation manifeste + exports runtime/index + docs README/manifest/
services + auto-check script étendu (règles valides acceptées, règles
unsafe rejetées).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 12:07:56 +02:00
720cd53924 merge(sdk): fix #336 — contrat docs commandes/menus apres hot reload (erreur retryable, feedback host-owned, contrat manifest/registration, reload sans refresh auto)
# Conflicts:
#	docs/menus.md
2026-09-12 14:06:54 +02:00
9efb781636 docs(sdk): rejet de promesse windows.open remonte au handler + rejet des commandes stale d'un runtime remplace (identite plugin) — #336 (docs only, aucun push) 2026-09-12 14:06:00 +02:00
116b75a024 docs(sdk): contrat commandes/menus apres reload — handler manquant = erreur retryable jamais no-op silencieux, feedback skipped/failed host-owned, pas de contributes.commands separe, reload sans refresh auto par le plugin — #336 (docs only, aucun push) 2026-09-12 14:05:52 +02:00
047c38b7ca docs(sdk): taches planifiees projet non exposees comme service plugin V1 — #305 (idea_scheduled_task_* natifs Tauri/HTTP/MCP uniquement; interdit d'emuler via cron systeme ou fichiers auto-executes dans le repo; aucun push) 2026-09-11 10:30:59 +02:00
436ed59b41 merge(sdk): intègre feature/296-unified-agent-capabilities — metadata statiques mcpServers[].tools #296 (QA verte: npm run check) 2026-09-09 19:52:23 +02:00
d93e302320 feat(sdk): metadata statiques contributes.mcpServers[].tools — #296 (type IdeAPluginMcpToolMetadata {name exact non vide, description optionnelle non vide} exporté public; validation manifest: tools optionnel, noms uniques par serveur — metadata d'affichage pour l'UI d'attribution de capacités, ne remplace ni ne cache MCP tools/list et n'enregistre rien; scripts/check-manifest-tools.mjs + npm run test:manifest intégré à check: optionnel/valide/doublon rejeté; doc manifest.md: exemple unity_get_scene/run_tests + règles; QA verte reconfirmée par Git: npm run check) 2026-09-09 19:52:23 +02:00
fdb3a907af merge(sdk): intègre feature/294-node-mcp-launch-docs — doc lancement serveurs MCP JS packagés #294 (QA verte: npm run check; vérifié contre hôte application/plugin/mod.rs) 2026-09-09 16:24:20 +02:00
b5a35563b6 docs(sdk): lancement des serveurs MCP JavaScript packagés — #294 (manifest: aucune substitution ${node}/${runtimeNode}/${hostNode} ni champ runtime, IdeA ne fournit pas de runtime Node plugin-scoped; command n'est pas du shell — démarrage direct avec args séparés, pas de fichiers de startup/alias/PATH du shell de login; command relatif résolu sous pluginRoot — command:"node" sans allowAbsoluteCommand devient ${pluginRoot}/node (cause ENOENT UnityPlugin #12); absolu littéral refusé sans allowAbsoluteCommand=true, command devenu absolu après substitution ${pluginRoot}/${appDataDir} passé tel quel; shebang #!/usr/bin/env node dépend du node visible du process app IdeA; recommandations: exécutable packagé incluant son runtime ou runtime géré sous stockage plugin/app pointé via ${pluginRoot}/${appDataDir}, sinon dépendance hôte explicite absolue /usr/bin/node avec allowAbsoluteCommand=true et scripts dans args; même règle pour command:"bash"; packaging-distribution: section Packaged Executables And Runtimes — bit exécutable et shebang valides dans le package installé, éviter le bare command:"node" en plugins distribués/multi-harness; services: distinction runCommand() (exécutable hôte de l'environnement task, aucune substitution) vs règles manifest mcpServers avec lien croisé; affirmations vérifiées par Git contre l'hôte crates/application/src/plugin/mod.rs:3199 substitution puis looks_absolute/allow_absolute_command sinon préfixage pluginRoot, et validation :3637 absolu littéral sans flag rejeté; QA verte reconfirmée par Git: npm run check — build, typecheck:examples, package:hello-plugin) 2026-09-09 16:24:16 +02:00
68fd612087 merge(sdk): intègre feature/293-mcp-tools-exposure-docs — doc contrat tools MCP plugins #293 (QA verte: npm run check) 2026-09-09 16:02:15 +02:00
42567a5305 docs(sdk): contrat d'exposition des tools MCP par les plugins — #293 (manifest: section MCP Servers And Agent Tools — contributes.mcpServers est le seul canal IdeA-native, aucun registerMcpTool/registerServer dans activate(ctx), règles id/displayName/transport stdio/autoStart requis pour la réconciliation MCP/command relatif résolu sous pluginRoot vs absolu refusé sans allowAbsoluteCommand/cwd défaut ${pluginRoot}, assignation par triplet pluginId/serverId/toolName devant matcher tools/list du serveur, example manifest aligné contributes.skills, guidance script packagé exécutable vs lancement via binaire hôte; activation-context: activate n'expose pas d'API d'enregistrement de tools MCP — ils viennent du process serveur stdio externe et de sa réponse tools/list, ctx.pluginRoot officialisé vs import.meta.url; project-plugin-assignments: assignation résolue vers serveurs manifestés autoStart uniquement, flow skill → serveur MCP → tools assignés sans chemins relatifs depuis le Markdown servi par idea_skill_read; QA verte reconfirmée par Git: npm run check — build, typecheck:examples, package:hello-plugin) 2026-09-09 16:02:05 +02:00
e3a0f46f9d merge(sdk): intègre feature/285-plugin-root-runtime-api — pluginRoot officiel activate(ctx) #285 (QA verte: npm run check) 2026-09-08 18:35:24 +02:00
9ce3a557a9 feat(sdk): pluginRoot officiel exposé dans activate(ctx) — #285 (src/runtime: champ pluginRoot = racine du package installé; docs activation-context/commands-and-feedback/services/manifest réécrits du constat d'absence vers le contrat d'usage officiel — runCommand reste project-scoped, pluginRoot utilisable dans command/args, substitution manifest ${pluginRoot}/${appDataDir} inchangée pour mcpServers; exemple hello-plugin: script packagé scripts/hello-task.mjs consommé via ctx.pluginRoot, packaging étendu; QA verte: npm run check — build, typecheck:examples, package:hello-plugin, artefact zip vérifié) 2026-09-08 18:35:24 +02:00
68019dec58 merge(sdk): intègre feature/285-plugin-root-scope-docs — clarification périmètre pluginRoot runtime #285 (QA verte: npm run build) 2026-09-08 17:20:05 +02:00
5f9c2f9193 docs(sdk): clarify pluginRoot scope for runtime plugins — #285 (activation-context: ActivateContext n'expose ni répertoire d'installation ni racine package ni file URL; commands-and-feedback + services: runCommand project/workspace-scoped, aucune résolution package-relative des scripts packagés; manifest: substitution ${pluginRoot}/${appDataDir} étendue par l'hôte uniquement dans contributes.mcpServers command/args/env/cwd; QA verte: npm run build, cohérence champs vs types SDK vérifiée) 2026-09-08 17:19:57 +02:00
18 changed files with 594 additions and 27 deletions

View File

@ -14,7 +14,8 @@ exports `activate(ctx)`.
- [Commands And Feedback](docs/commands-and-feedback.md): command return values, skipped work and background tasks. - [Commands And Feedback](docs/commands-and-feedback.md): command return values, skipped work and background tasks.
- [Layouts With React](docs/layouts-react.md): React/JSX/hooks layout authoring with host-provided React. - [Layouts With React](docs/layouts-react.md): React/JSX/hooks layout authoring with host-provided React.
- [Windows](docs/windows.md): opening plugin layouts in detached OS windows. - [Windows](docs/windows.md): opening plugin layouts in detached OS windows.
- [Services](docs/services.md): workspace, tasks, tooling, events, config, terminal and windows facades. - [Services](docs/services.md): workspace files and bounded `workspace.execTool`, tasks,
tooling, events, config, terminal and windows facades.
- [Packaging And Distribution](docs/packaging-distribution.md): build output, archive layout, hot reload and dependency rules. - [Packaging And Distribution](docs/packaging-distribution.md): build output, archive layout, hot reload and dependency rules.
- [Codex Skills And IdeA Plugins](docs/codex-skills-and-idea-plugins.md): separate IdeA-native plugin skills from Codex plugin discovery. - [Codex Skills And IdeA Plugins](docs/codex-skills-and-idea-plugins.md): separate IdeA-native plugin skills from Codex plugin discovery.
- [Project Plugin Assignments](docs/project-plugin-assignments.md): enable plugins per project and assign skills and MCP tools to agents. - [Project Plugin Assignments](docs/project-plugin-assignments.md): enable plugins per project and assign skills and MCP tools to agents.
@ -23,6 +24,18 @@ The installable example lives in [`examples/hello-plugin`](examples/hello-plugin
It demonstrates a menu command, storage, background-task feedback, a React layout It demonstrates a menu command, storage, background-task feedback, a React layout
component and `services.windows.open(...)`. component and `services.windows.open(...)`.
For short project-scoped external tools, use
`ctx.services.workspace.execTool()` with an explicit manifest
`permissions.workspaceExec` allow rule. See [Services](docs/services.md) and
[Manifest](docs/manifest.md). The primitive is generic and does not expose a
native Git/SCM API.
Menu commands that open or focus a plugin view return `status: "opened"`
(including `alreadyOpen: true`) and remain UI-silent on success. Keep diagnostic
views accessible when optional tools are missing, and use the structured
`skipped`/`failed` feedback contract for genuinely impossible actions; see
[Commands And Feedback](docs/commands-and-feedback.md) and [Menus](docs/menus.md).
## Install ## Install
```sh ```sh

View File

@ -6,7 +6,10 @@ The plugin entrypoint exports `activate(ctx)`.
import type { ActivateContext, IdeAPluginModule } from "@idea/plugin-sdk"; import type { ActivateContext, IdeAPluginModule } from "@idea/plugin-sdk";
export function activate(ctx: ActivateContext): void { export function activate(ctx: ActivateContext): void {
ctx.logger.info("activated", { pluginId: ctx.pluginId }); ctx.logger.info("activated", {
pluginId: ctx.pluginId,
pluginRoot: ctx.pluginRoot
});
} }
export default { activate } satisfies IdeAPluginModule; export default { activate } satisfies IdeAPluginModule;
@ -27,7 +30,8 @@ focused project at invocation/render time.
## Context Fields ## Context Fields
- `pluginId`, `pluginDisplayName`, `version`: host-provided identity. - `pluginId`: host-provided plugin identity.
- `pluginRoot`: absolute host-local path to the active installed plugin package.
- `logger`: `debug`, `info`, `warn`, `error`. - `logger`: `debug`, `info`, `warn`, `error`.
- `subscriptions`: push disposables returned by command/layout/watch - `subscriptions`: push disposables returned by command/layout/watch
registrations. registrations.
@ -41,6 +45,23 @@ focused project at invocation/render time.
The context never exposes internal IdeA gateways or Tauri commands. Use The context never exposes internal IdeA gateways or Tauri commands. Use
`ctx.services` and the registration APIs instead. `ctx.services` and the registration APIs instead.
`activate(ctx)` does not expose an API for registering MCP tools directly.
Agent-callable plugin tools are provided by stdio MCP servers declared in
`idea-plugin.json` under `contributes.mcpServers`. The plugin runtime can
register menu commands and layouts in-process, but MCP tools come from the
external server process and its MCP `tools/list` response.
`pluginRoot` identifies the committed package currently loaded by IdeA. It is
not the original source directory or archive path and may change after reinstall.
Treat it as read-only, do not persist it, and resolve packaged scripts/assets
under it only while the plugin is active. Workspace services remain confined to
project-owned paths.
Use `ctx.pluginRoot` as the official way to resolve packaged scripts/assets from
runtime code. `import.meta.url` can still be useful inside a bundle for ordinary
module-relative JavaScript resolution, but it is not the host/plugin contract
for the installed package root.
## Disposal ## Disposal
Push every returned disposable to `ctx.subscriptions`: Push every returned disposable to `ctx.subscriptions`:

View File

@ -1,7 +1,7 @@
# Commands And Feedback # Commands And Feedback
This document is the normative SDK contract for plugin commands that may launch This document is the normative SDK contract for plugin commands, including
background work. commands that open a view or launch background work.
## Contract ## Contract
@ -25,14 +25,17 @@ manifest contribution -> command id -> registered command handler -> optional ta
not by a fake task. not by a fake task.
- Feedback objects must be stable enough for agents and programmatic callers to - Feedback objects must be stable enough for agents and programmatic callers to
parse, and their messages must be readable by humans. parse, and their messages must be readable by humans.
- The current human menu-click UI does not guarantee display of a command - The human menu-click UI presents minimal host-owned feedback for `skipped`
handler return value. When a human must see feedback from a menu action, open and `failed` results. It intentionally does not expose the raw result payload
or focus a plugin layout/window from the handler with or assume which tool (Git, ADB, Gradle, and so on) the plugin uses.
For successful actions and richer status, open or focus a plugin layout/window
from the handler with
`ctx.services.windows.open({ layoutType, state })`. `ctx.services.windows.open({ layoutType, state })`.
## Preconditions ## Preconditions
Check preconditions before calling `runCommand()`: Check preconditions before calling `runCommand()`. These are task-launch
preconditions, not prerequisites for opening an explanatory view:
- `ctx.services` exists. Plugins need the `ui` or `tooling` capability for the - `ctx.services` exists. Plugins need the `ui` or `tooling` capability for the
service facade. service facade.
@ -43,8 +46,18 @@ Check preconditions before calling `runCommand()`:
- `ownerAgentId` is a real agent id when the work is owned by an agent workflow. - `ownerAgentId` is a real agent id when the work is owned by an agent workflow.
Do not use all-zero placeholders in production. Do not use all-zero placeholders in production.
- `cwd` is relative to the project root. - `cwd` is relative to the project root.
- `command` and `args` do not resolve package-relative plugin resources
implicitly. Build an explicit absolute script path from `ctx.pluginRoot` when
launching read-only files shipped in the installed package.
- Do not use `ctx.pluginRoot` as `cwd`; `cwd` remains relative to the project root.
- `command` and `args` are separate values. Do not shell-join user input. - `command` and `args` are separate values. Do not shell-join user input.
A command whose purpose is to open or focus a diagnostic/configuration view
should call `services.windows.open(...)` before probing optional executables.
Missing Git, ADB, Gradle, or another tool must not block that view when the view
can explain the degraded state and the next step. Probe tools from the view (or
from its data loader) and render the degraded state there.
If any required precondition fails, return a skipped result: If any required precondition fails, return a skipped result:
```ts ```ts
@ -62,21 +75,30 @@ with equivalent fields:
```ts ```ts
type CommandFeedback = type CommandFeedback =
| { status: "opened"; message?: string; alreadyOpen: boolean }
| { status: "skipped"; reason: string; message: string } | { status: "skipped"; reason: string; message: string }
| { status: "failed"; reason?: string; message?: string }
| { status: "launched"; taskId: string; state: string; message: string }; | { status: "launched"; taskId: string; state: string; message: string };
``` ```
Use the same `status` vocabulary consistently: Use the same `status` vocabulary consistently:
- `opened`: the requested view was opened or focused. `alreadyOpen: true` is a
successful focus operation, not a failure or skip.
- `skipped`: no background task was created. - `skipped`: no background task was created.
- `launched`: a task was created; inspect the task state for later progress. - `launched`: a task was created; inspect the task state for later progress.
- `failed`: the handler itself failed before it could return normally. - `failed`: the handler caught a failure and returned structured failure
feedback. An uncaught/rejected handler is also treated as a failure by the
host.
Visible surfaces are intentionally distinct: Visible surfaces are intentionally distinct:
- Command return value: immediate feedback for programmatic callers, agents and - Command return value: immediate feedback for programmatic callers and agents.
future host surfaces. It is not a guaranteed visible UI surface for current The human menu host maps `skipped` and `failed` results to concise feedback;
human menu clicks. plugin-specific fields and messages are not displayed verbatim. `opened`
results, including `alreadyOpen: true`, succeed without an alert. The host
logs structured diagnostics with `pluginId`, `commandId`, `status`, `reason`
and `message`; raw objects, stack traces and internal ids are not user-facing.
- Plugin layout/window: the normative visible feedback surface for menu actions - Plugin layout/window: the normative visible feedback surface for menu actions
that need to show status, results or next steps to a human. The layout must be that need to show status, results or next steps to a human. The layout must be
declared in `idea-plugin.json` and registered during activation. declared in `idea-plugin.json` and registered during activation.
@ -141,6 +163,11 @@ const plugin: IdeAPluginModule = {
export default plugin; export default plugin;
``` ```
The `opened` branch above must remain successful even when an optional tool
diagnosed by `HealthView` is absent. If `windows.open(...)` itself rejects, log
the technical cause and either let the promise reject or return `failed`; the
host will show generic, plugin-neutral feedback rather than the raw message.
Background task feedback: Background task feedback:
```ts ```ts
@ -196,3 +223,20 @@ await ctx.services?.tasks.runCommand({
This creates misleading Work history, uses a placeholder owner and turns a This creates misleading Work history, uses a placeholder owner and turns a
precondition failure into a fake task. Return `status: "skipped"` instead. precondition failure into a fake task. Return `status: "skipped"` instead.
Do not try to reach packaged plugin scripts through workspace-relative paths:
```ts
await ctx.services?.tasks.runCommand({
projectId: project.id,
ownerAgentId,
label: "Run packaged tests",
command: "scripts/run-unity-tests.sh",
cwd: "."
});
```
`scripts/run-unity-tests.sh` above is resolved like any other executable visible
from the project task environment; it is not resolved relative to the plugin
package. Use a project-owned script or an executable available on `PATH` until a
dedicated packaged-resource API exists.

View File

@ -115,6 +115,7 @@ development loop.
"capabilities": ["ui", "tooling"], "capabilities": ["ui", "tooling"],
"activationScope": "app", "activationScope": "app",
"contributes": { "contributes": {
"skills": [],
"menus": [], "menus": [],
"menuItems": [], "menuItems": [],
"layouts": [], "layouts": [],
@ -140,6 +141,39 @@ development loop.
- `activationScope`: `"app"` by default, or `"project"` when activation needs a - `activationScope`: `"app"` by default, or `"project"` when activation needs a
focused project immediately. focused project immediately.
- `capabilities`: `"ui"`, `"tooling"` and/or `"mcp"`. - `capabilities`: `"ui"`, `"tooling"` and/or `"mcp"`.
- `permissions.workspaceExec`: explicit generic executable rules used by
`ctx.services.workspace.execTool()`.
## Workspace execution permissions
Short project-scoped executable access is denied unless the active plugin
manifest contains a matching rule:
```json
{
"permissions": {
"workspaceExec": {
"allow": [
{
"command": "tool",
"args": ["status", "*"],
"cwd": ".",
"effect": "read"
}
]
}
}
}
```
`command` is a bare ASCII executable name; paths and shell command lines are
rejected. `args` is an argv pattern: literal tokens match exactly, `*` matches
one token, and a final `*` matches the remaining suffix (including no token).
`cwd` is an exact normalized project-relative directory; `*` permits any cwd
that the host still proves is under the project root. `effect` is one of
`read`, `write`, `network`, or `destructive` and drives sandbox/audit policy.
The manifest declares generic process access only; it does not add native SCM
semantics to IdeA.
## Contributions ## Contributions
@ -242,11 +276,139 @@ public id used by both `ctx.layouts.register({ type, component })` and
Contribution ids are part of the runtime contract: Contribution ids are part of the runtime contract:
- commands can only register ids declared by `contributes.menuItems[*].command`; - there is no separate `contributes.commands` collection; commands can only
register ids declared by `contributes.menuItems[*].command` or
`contributes.slashCommands[*].command`;
- layouts can only register `type` values declared by `contributes.layouts`; - layouts can only register `type` values declared by `contributes.layouts`;
- `services.windows.open({ layoutType })` only accepts a layout type declared by - `services.windows.open({ layoutType })` only accepts a layout type declared by
the calling plugin. the calling plugin.
## MCP Servers And Agent Tools
`contributes.mcpServers` is the IdeA-native contract for plugin-provided
agent tools. A plugin does not register individual MCP tools from `activate(ctx)`.
Instead, the plugin ships or references a stdio MCP server process, declares it
in the manifest, and that server advertises its own tools through the MCP
`tools/list` protocol.
```json
{
"capabilities": ["mcp"],
"contributes": {
"mcpServers": [
{
"id": "unity-editor",
"displayName": "Unity Editor Tools",
"command": "scripts/unity-mcp-server.mjs",
"args": [],
"cwd": "${pluginRoot}",
"transport": "stdio",
"autoStart": true,
"tools": [
{
"name": "unity_get_scene",
"description": "Read the active Unity scene."
},
{
"name": "unity_run_tests"
}
]
}
]
}
}
```
MCP server contribution rules:
- `id` is stable within the plugin and is used by project/agent assignments.
- `displayName` is the human-readable server name shown in plugin settings.
- `transport` must currently be `"stdio"`.
- `autoStart: true` is required for IdeA to start the server during plugin MCP
reconciliation. Non-auto-start servers are manifest metadata only today.
- A relative `command` is resolved under the installed plugin package root.
- A literal absolute `command` is rejected unless `allowAbsoluteCommand: true`
is set. Use this only for an intentional dependency on a host binary.
- A `command` that becomes absolute after `${pluginRoot}` or `${appDataDir}`
substitution is allowed and is passed through as substituted.
- `cwd` defaults to `${pluginRoot}` when omitted.
- `tools` is optional static display metadata for IdeA's capability-assignment
UI. Each entry has an exact non-empty `name` and an optional non-empty
`description`; names must be unique within the server.
- This metadata does not replace or cache MCP `tools/list`, and declaring a tool
here does not register it or prove that the running server exposes it.
The tools an agent can call are assigned with the triplet
`pluginId`/`serverId`/`toolName` in project plugin settings. `toolName` must
match a tool name advertised by the MCP server. IdeA does not currently provide
a runtime API such as `ctx.mcp.registerTool()` or `ctx.mcp.registerServer()`.
For multi-harness workflows, keep `contributes.skills` as instructions and
expose executable behavior through assigned MCP tools. Do not make skill
Markdown depend on filesystem paths relative to the Markdown returned by
`idea_skill_read`; that body is served as read-only content, not mounted as a
working directory. If a skill needs packaged scripts, put the script behind a
manifest-declared MCP server and tell the agent to call the assigned tool.
## MCP Server Paths
`contributes.mcpServers` entries are resolved by the host before starting a
declared MCP server. In `command`, `args`, `env` and `cwd`, the host expands:
- `${pluginRoot}` to the installed plugin package root.
- `${appDataDir}` to the host-owned application data directory.
There is currently no `${node}`, `${runtimeNode}`, `${hostNode}` substitution
and no `runtime` field. IdeA does not provide a plugin-scoped Node.js runtime
for MCP servers.
This string substitution is limited to manifest-declared MCP server startup.
Runtime handlers receive the same installed package location separately as
`ctx.pluginRoot`; `ctx.services.tasks.runCommand()` does not perform placeholder
substitution, and workspace APIs remain project-confined.
`command` is not shell syntax. IdeA starts it directly and passes `args`
separately. Do not rely on shell startup files, shell aliases, or a user's login
shell `PATH`.
Prefer a packaged executable for plugin-owned MCP servers:
```json
{
"command": "scripts/unity-mcp-server",
"transport": "stdio",
"autoStart": true
}
```
Because the command is package-relative, IdeA resolves it to
`${pluginRoot}/scripts/unity-mcp-server`. If this file is a script, its shebang
and executable bit must be valid in the installed package. A JavaScript shebang
such as `#!/usr/bin/env node` still depends on a `node` binary visible to the
IdeA app process, not necessarily the user's interactive shell.
For JavaScript MCP servers that must not depend on the user's shell `PATH`, use
one of these approaches:
- Ship an executable server that includes its runtime, or install a managed
runtime under plugin/app-owned storage and point `command` at that executable
with `${pluginRoot}` or `${appDataDir}`.
- Declare an explicit host dependency with an absolute command such as
`/usr/bin/node`, set `allowAbsoluteCommand: true`, and keep plugin-owned
script paths in `args`.
Avoid `command: "node"` for a packaged MCP server. With
`allowAbsoluteCommand: false`, it is treated as package-relative and resolves to
`${pluginRoot}/node`. With `allowAbsoluteCommand: true`, it is passed as a host
command name and depends on the environment inherited by IdeA, which may differ
from an interactive shell.
The same rule applies to shell wrappers. `command: "bash"` resolves to
`${pluginRoot}/bash` unless absolute-command handling is explicitly enabled. Use
a package-relative wrapper executable, or use an absolute shell such as
`/bin/bash` with `allowAbsoluteCommand: true` when that host dependency is
intentional.
## Validation ## Validation
Use the SDK validator in tests or build tooling: Use the SDK validator in tests or build tooling:

View File

@ -33,7 +33,10 @@ Then register the command id:
```ts ```ts
export function activate(ctx: ActivateContext): void { export function activate(ctx: ActivateContext): void {
const disposable = ctx.commands?.registerCommand("hello-plugin.open", async () => { const disposable = ctx.commands?.registerCommand("hello-plugin.open", async () => {
await ctx.services?.windows.open({ layoutType: "hello-plugin.dashboard" }); const win = await ctx.services?.windows.open({ layoutType: "hello-plugin.dashboard" });
return win
? { status: "opened", alreadyOpen: win.alreadyOpen }
: { status: "skipped", reason: "ui-service-unavailable", message: "UI service unavailable." };
}); });
if (disposable) ctx.subscriptions.push(disposable); if (disposable) ctx.subscriptions.push(disposable);
} }
@ -41,9 +44,22 @@ export function activate(ctx: ActivateContext): void {
## Rules ## Rules
- A command can only be registered if at least one manifest menu item declares - There is no separate `contributes.commands` collection. A command can only be
that exact `command` id. registered if at least one manifest `menuItems[].command` or
- Missing command handlers are a no-op when the user clicks the menu item. `slashCommands[].command` entry declares that exact id.
- A missing command handler is surfaced by the host as a retryable reload error;
menu clicks must never fail silently.
- A registered handler's rejected promise is not swallowed: the host logs the
plugin id, command id, status, reason and message, then presents a generic
retryable error without raw payloads. In
particular, return/await `services.windows.open(...)` so native-window
failures follow this path.
- An open/focus handler returns `status: "opened"` whether `alreadyOpen` is
`false` (new window) or `true` (existing window focused). Neither success
produces an alert.
- Keep diagnostic views reachable when optional executables such as Git, ADB or
Gradle are missing. Let the view explain that degraded state instead of
skipping the menu action before opening it.
- A handler owns precondition checks and feedback. See - A handler owns precondition checks and feedback. See
[Commands And Feedback](commands-and-feedback.md). [Commands And Feedback](commands-and-feedback.md).
- Menu item `when` expressions are evaluated by the host; unsupported or false - Menu item `when` expressions are evaluated by the host; unsupported or false

View File

@ -43,6 +43,25 @@ IdeA resolves React/ReactDOM bare imports to the host instance. Other bare
dependencies are not host-resolved. Bundle or vendor third-party dependencies dependencies are not host-resolved. Bundle or vendor third-party dependencies
other than React/ReactDOM into package-relative files. other than React/ReactDOM into package-relative files.
## Packaged Executables And Runtimes
Manifest-declared MCP servers are launched from `contributes.mcpServers` by
starting the declared `command` directly. IdeA does not inject a shell and does
not provide a plugin-scoped Node.js runtime.
For a packaged MCP server, prefer a package-relative executable such as
`scripts/my-plugin-mcp-server`. The installed file must keep the executable bit
and, when it is a script, a valid shebang. A JavaScript server with
`#!/usr/bin/env node` still requires `node` in the environment inherited by the
IdeA app process; this is not guaranteed to match the user's interactive shell,
especially when Node is provided by shell startup tooling.
For reproducible JavaScript MCP servers, either ship/install a runtime under the
plugin package or app-owned storage and point the manifest at that executable,
or declare an explicit absolute host dependency with `allowAbsoluteCommand:
true`. Avoid relying on a bare `command: "node"` in distributed or multi-harness
plugins.
## TypeScript Build ## TypeScript Build
The hello plugin uses plain `tsc`: The hello plugin uses plain `tsc`:

View File

@ -69,6 +69,12 @@ An agent MCP tool reference contains:
- `toolName`: the exact tool name advertised by that MCP server. The UI accepts - `toolName`: the exact tool name advertised by that MCP server. The UI accepts
this name as text because a dynamic MCP tool catalogue is not yet available. this name as text because a dynamic MCP tool catalogue is not yet available.
IdeA resolves plugin MCP assignments to manifest-declared servers, not to
in-process handlers registered from `activate(ctx)`. A tool assignment is useful
only when the plugin is enabled, runtime-active, the referenced MCP server has
`autoStart: true`, and the server process advertises the named tool through MCP
`tools/list`.
Missing `plugins`, `agents`, `skills`, or `tools` arrays default to empty arrays Missing `plugins`, `agents`, `skills`, or `tools` arrays default to empty arrays
when IdeA reads the document. Prefer writing them explicitly for clarity and when IdeA reads the document. Prefer writing them explicitly for clarity and
forward-compatible review. forward-compatible review.
@ -144,6 +150,8 @@ These actions solve different problems:
- Reload the plugin after rebuilding or replacing its installed package. Reload - Reload the plugin after rebuilding or replacing its installed package. Reload
refreshes the plugin manifest, runtime bundle, contributions, and packaged refreshes the plugin manifest, runtime bundle, contributions, and packaged
skill Markdown. It does not change project activation or agent assignments. skill Markdown. It does not change project activation or agent assignments.
Plugins do not call a contribution-refresh API themselves: rebuild or replace
the installed package, then use the host reload action.
- Relaunch the agent after enabling or disabling a project plugin, changing its - Relaunch the agent after enabling or disabling a project plugin, changing its
skill/tool assignments, or manually editing `.ideai/project-plugins.json`. skill/tool assignments, or manually editing `.ideai/project-plugins.json`.
Agent capabilities are resolved into a launch-time snapshot, so an already Agent capabilities are resolved into a launch-time snapshot, so an already
@ -152,3 +160,10 @@ These actions solve different problems:
When both package content and assignments changed, reload the plugin first, When both package content and assignments changed, reload the plugin first,
confirm it is enabled for the project, update the assignments, then relaunch the confirm it is enabled for the project, update the assignments, then relaunch the
agent. agent.
If a plugin skill describes a workflow backed by packaged scripts, prefer this
flow: ship the scripts in the plugin package, expose them through a
manifest-declared MCP server, assign the specific MCP tools to the target agent,
and have the skill instruct the agent to call those tools. Avoid relative paths
from the skill Markdown to packaged scripts; the Markdown served by
`idea_skill_read` is content, not a stable filesystem anchor.

View File

@ -29,6 +29,38 @@ Key APIs:
- `stat(path, projectId?)` - `stat(path, projectId?)`
- `watch(path, handler, projectId?)` - `watch(path, handler, projectId?)`
- `queryStructure(query?)` - `queryStructure(query?)`
- `execTool(request)`
### Short external tools
`services.workspace.execTool()` runs a short external executable already available
to the host. It is generic: IdeA does not expose Git, SVN, formatter, or linter
semantics. The plugin owns the arguments and parses the returned output.
```ts
const result = await ctx.services!.workspace.execTool({
projectId,
command: "tool",
args: ["status", "--short"],
cwd: ".",
timeoutMs: 5_000,
maxStdoutBytes: 256_000,
maxStderrBytes: 64_000
});
```
The host passes `command` and `args` directly without a shell. The plugin must
declare a matching `permissions.workspaceExec.allow` rule, be active for the
project, and use a project-relative `cwd`. IdeA canonicalizes the cwd, bounds
time/stdin/output/concurrency, exposes only an allowlisted environment, applies
the project filesystem sandbox where supported, and audits metadata without
captured stdout/stderr contents. This API returns output directly and never
creates a visible Work/background-task entry.
Current host maxima are 30 seconds, 1,000,000 stdout bytes, 256,000 stderr bytes,
and 64,000 stdin bytes. Requested output/time values above a maximum are clamped;
zero is rejected. Output is returned as lossy UTF-8 with explicit truncation
flags.
## Tasks ## Tasks
@ -37,6 +69,32 @@ Key APIs:
Use `runCommand()` only after preconditions are satisfied. `ownerAgentId` must be Use `runCommand()` only after preconditions are satisfied. `ownerAgentId` must be
a real agent id when work belongs to an agent workflow. a real agent id when work belongs to an agent workflow.
`runCommand()` keeps its `cwd` project/workspace scoped: `cwd` must be a relative
path under the project root. The host does not implicitly resolve `command` or
`args` against the plugin package. Use the absolute `ctx.pluginRoot` supplied at
activation time to construct an explicit path to a packaged script, and pass
that path as `command` or as an argument to its interpreter. Treat package files
as read-only. Unlike manifest-declared MCP servers, `runCommand()` does not
substitute `${pluginRoot}` or `${appDataDir}`.
```ts
const script = `${ctx.pluginRoot.replace(/[\\/]+$/, "")}/scripts/check.mjs`;
await ctx.services.tasks.runCommand({
projectId,
ownerAgentId,
label: "Run packaged check",
command: "node",
args: [script],
cwd: "."
});
```
This task example uses `node` as a host executable visible to the task
environment. That is not the contract for `contributes.mcpServers`: MCP server
`command` values are resolved by the plugin manifest rules documented in
[Manifest](manifest.md), where a bare `command: "node"` is package-relative
unless external host-command handling is explicitly enabled.
## Tooling ## Tooling
`services.tooling.diagnose()` checks executables, environment values and files `services.tooling.diagnose()` checks executables, environment values and files
@ -66,3 +124,17 @@ locations. Use it for configuration the user expects to review/version.
`services.windows.open({ layoutType, state? })` opens a detached OS window for `services.windows.open({ layoutType, state? })` opens a detached OS window for
one of the calling plugin's declared layout contributions. See one of the calling plugin's declared layout contributions. See
[Windows](windows.md). [Windows](windows.md).
Resolving this promise means the window was opened or focused. Its
`alreadyOpen` flag reports which happened; both outcomes are successful and a
menu handler should return `status: "opened"`. Do not gate a diagnostic window
on optional tool availability when that window can present the unavailable
state. A rejection means the window operation itself was impossible and may be
logged or returned as `status: "failed"`; do not expose its raw error to users.
## Scheduled tasks
Project scheduled tasks are exposed through native Tauri/HTTP commands and the
native `idea_scheduled_task_*` MCP tools. They are intentionally not available as
an `activate(ctx)` plugin service in V1, and plugins must not emulate them with
system cron or repository-owned auto-executed files.

View File

@ -24,6 +24,13 @@ do not currently guarantee that return value is displayed.
existing `contributes.layouts` layout. existing `contributes.layouts` layout.
- The host validates the plugin is runtime-active and the layout exists before - The host validates the plugin is runtime-active and the layout exists before
opening or focusing the window. opening or focusing the window.
- The calling plugin package must still be installed, enabled globally and
enabled for the focused project. A stale command from a replaced runtime is
rejected instead of opening a window under another plugin's identity.
- `windows.open(...)` rejects its promise when any validation or native-window
operation fails. Let that rejection reach the command handler (or catch it,
log it and return explicit failed feedback); the IdeA menu host logs the
rejection and shows a retryable error to the user.
- Reopening the same plugin/layout pair focuses the existing window. - Reopening the same plugin/layout pair focuses the existing window.
- `state` is JSON-serializable initial window state. The mounted component can - `state` is JSON-serializable initial window state. The mounted component can
later call `setState(next)` to update its local host state. later call `setState(next)` to update its local host state.
@ -47,13 +54,14 @@ The returned `label` is a host-owned window identity. Treat it as opaque.
```ts ```ts
ctx.commands?.registerCommand("hello-plugin.open-dashboard", async () => { ctx.commands?.registerCommand("hello-plugin.open-dashboard", async () => {
const win = await ctx.services?.windows.open({ if (!ctx.services) {
return { status: "skipped", reason: "ui-service-unavailable", message: "UI service unavailable." };
}
const win = await ctx.services.windows.open({
layoutType: "hello-plugin.dashboard", layoutType: "hello-plugin.dashboard",
state: { source: "menu" } state: { source: "menu" }
}); });
return win return { status: "opened", message: "Opened dashboard.", alreadyOpen: win.alreadyOpen };
? { status: "opened", message: "Opened dashboard.", alreadyOpen: win.alreadyOpen }
: { status: "skipped", reason: "ui-service-unavailable", message: "UI service unavailable." };
}); });
``` ```

View File

@ -16,6 +16,8 @@ It exercises the current plugin primitives end to end:
`ctx.services.windows.open(...)` when services are available. `ctx.services.windows.open(...)` when services are available.
- plugin-owned storage: activation count, command run count and initialization flag; - plugin-owned storage: activation count, command run count and initialization flag;
- tooling capability: logs the focused workspace project when `ctx.services` is available. - tooling capability: logs the focused workspace project when `ctx.services` is available.
- packaged command script: resolves `scripts/hello-task.mjs` from the official
`ctx.pluginRoot` and launches it with the project kept as the task working directory.
The source is intentionally split across multiple TypeScript modules: The source is intentionally split across multiple TypeScript modules:
@ -23,6 +25,7 @@ The source is intentionally split across multiple TypeScript modules:
- `src/constants.ts` owns shared command/layout identifiers; - `src/constants.ts` owns shared command/layout identifiers;
- `src/core/layout.tsx` and `src/core/workspace.ts` hold feature logic. - `src/core/layout.tsx` and `src/core/workspace.ts` hold feature logic.
- `src/core/storage.ts` keeps plugin-owned counters and flags in `ctx.storage`. - `src/core/storage.ts` keeps plugin-owned counters and flags in `ctx.storage`.
- `scripts/hello-task.mjs` is a read-only runtime resource included in the archive.
The build uses plain `tsc`; it does not bundle the plugin into one file. The archive includes all The build uses plain `tsc`; it does not bundle the plugin into one file. The archive includes all
compiled `dist/**/*.js` files so IdeA can load `dist/index.js` and serve its package-relative imports compiled `dist/**/*.js` files so IdeA can load `dist/index.js` and serve its package-relative imports
@ -36,8 +39,8 @@ npm run package:hello-plugin
``` ```
The installable archive is emitted at `examples/hello-plugin/build/hello-plugin-0.1.0.zip`. The installable archive is emitted at `examples/hello-plugin/build/hello-plugin-0.1.0.zip`.
It contains `idea-plugin.json` at the ZIP root and the compiled multi-file ESM output under `dist/`, It contains `idea-plugin.json` at the ZIP root, the compiled multi-file ESM output under `dist/`,
including `dist/index.js`, matching the manifest `main` field. including `dist/index.js`, and the packaged handler under `scripts/hello-task.mjs`.
## Diagnostics ## Diagnostics

View File

@ -0,0 +1 @@
console.log("hello from a script packaged with @idea/plugin-sdk");

View File

@ -122,12 +122,13 @@ export async function runHelloCommandTask(ctx: ActivateContext): Promise<HelloCo
return feedback; return feedback;
} }
const pluginRoot = ctx.pluginRoot.replace(/[\\/]+$/, "");
const task = await ctx.services.tasks.runCommand({ const task = await ctx.services.tasks.runCommand({
projectId: project.id, projectId: project.id,
ownerAgentId, ownerAgentId,
label: "Hello plugin command", label: "Hello plugin command",
command: "echo", command: "node",
args: ["hello from @idea/plugin-sdk"], args: [`${pluginRoot}/scripts/hello-task.mjs`],
cwd: ".", cwd: ".",
recordOnly: true recordOnly: true
}); });

View File

@ -18,10 +18,11 @@
], ],
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"test:manifest": "npm run build && node scripts/check-manifest-tools.mjs",
"build:hello-plugin": "tsc -p examples/hello-plugin/tsconfig.build.json", "build:hello-plugin": "tsc -p examples/hello-plugin/tsconfig.build.json",
"package:hello-plugin": "npm run build && npm run build:hello-plugin && node scripts/package-hello-plugin.mjs", "package:hello-plugin": "npm run build && npm run build:hello-plugin && node scripts/package-hello-plugin.mjs",
"typecheck:examples": "tsc -p examples/hello-plugin/tsconfig.json --noEmit", "typecheck:examples": "tsc -p examples/hello-plugin/tsconfig.json --noEmit",
"check": "npm run build && npm run typecheck:examples && npm run package:hello-plugin" "check": "npm run test:manifest && npm run typecheck:examples && npm run package:hello-plugin"
}, },
"keywords": [ "keywords": [
"idea", "idea",

View File

@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { validatePluginManifest } from "../dist/index.js";
const base = {
ideaPluginManifestVersion: 1,
id: "dev.example.tools",
displayName: "Tools",
version: "1.0.0",
main: "dist/index.js",
trustLevel: "full",
capabilities: ["mcp"],
contributes: {
mcpServers: [
{
id: "server",
displayName: "Server",
command: "bin/server",
transport: "stdio"
}
]
}
};
assert.equal(validatePluginManifest(base).success, true, "tools metadata remains optional");
const withTools = structuredClone(base);
withTools.contributes.mcpServers[0].tools = [
{ name: "read_scene", description: "Read the active scene." },
{ name: "run_tests" }
];
assert.equal(validatePluginManifest(withTools).success, true, "valid tools metadata is accepted");
const duplicate = structuredClone(withTools);
duplicate.contributes.mcpServers[0].tools.push({ name: "read_scene" });
const duplicateResult = validatePluginManifest(duplicate);
assert.equal(duplicateResult.success, false, "duplicate tool metadata is rejected");
assert.ok(duplicateResult.errors.some((error) => error.includes("must be unique")));
const withWorkspaceExec = structuredClone(base);
withWorkspaceExec.permissions = {
workspaceExec: {
allow: [{ command: "tool", args: ["status", "*"], cwd: ".", effect: "read" }]
}
};
assert.equal(
validatePluginManifest(withWorkspaceExec).success,
true,
"valid generic workspace execution permissions are accepted"
);
for (const invalidRule of [
{ command: "sh -c", args: [], cwd: ".", effect: "read" },
{ command: "tool", args: ["sta*"], cwd: ".", effect: "read" },
{ command: "tool", args: [], cwd: "../outside", effect: "read" },
{ command: "tool", args: [], cwd: ".", effect: "git" }
]) {
const invalid = structuredClone(base);
invalid.permissions = { workspaceExec: { allow: [invalidRule] } };
assert.equal(validatePluginManifest(invalid).success, false, "unsafe workspace exec rule is rejected");
}

View File

@ -9,12 +9,14 @@ const main = requireString(manifest, "main");
const version = requireString(manifest, "version"); const version = requireString(manifest, "version");
const archivePath = join(pluginRoot, "build", `hello-plugin-${version}.zip`); const archivePath = join(pluginRoot, "build", `hello-plugin-${version}.zip`);
const distEntries = await collectFiles(join(pluginRoot, "dist"), "dist"); const distEntries = await collectFiles(join(pluginRoot, "dist"), "dist");
const scriptEntries = await collectFiles(join(pluginRoot, "scripts"), "scripts");
if (!distEntries.some((entry) => entry.archivePath === main)) { if (!distEntries.some((entry) => entry.archivePath === main)) {
throw new Error(`Built plugin dist does not contain manifest main: ${main}`); throw new Error(`Built plugin dist does not contain manifest main: ${main}`);
} }
const archiveEntries = [ const archiveEntries = [
{ archivePath: "idea-plugin.json", sourcePath: manifestPath }, { archivePath: "idea-plugin.json", sourcePath: manifestPath },
...distEntries, ...distEntries,
...scriptEntries,
{ archivePath: "README.md", sourcePath: join(pluginRoot, "README.md") } { archivePath: "README.md", sourcePath: join(pluginRoot, "README.md") }
]; ];
const DOS_TIME_MIDNIGHT = 0; const DOS_TIME_MIDNIGHT = 0;

View File

@ -2,9 +2,13 @@ export type {
IdeAPluginActivationScope, IdeAPluginActivationScope,
IdeAPluginCapability, IdeAPluginCapability,
IdeAPluginManifest, IdeAPluginManifest,
IdeAPluginPermissions,
IdeAPluginWorkspaceExecEffect,
IdeAPluginWorkspaceExecRule,
IdeAPluginEngineConstraints, IdeAPluginEngineConstraints,
IdeAPluginLayoutContribution, IdeAPluginLayoutContribution,
IdeAPluginMcpServerContribution, IdeAPluginMcpServerContribution,
IdeAPluginMcpToolMetadata,
IdeAPluginMenuItemContribution, IdeAPluginMenuItemContribution,
IdeAPluginSkillContribution, IdeAPluginSkillContribution,
IdeAPluginSkillKind, IdeAPluginSkillKind,
@ -77,6 +81,8 @@ export type {
WorkspaceBinaryFile, WorkspaceBinaryFile,
WorkspaceDirEntry, WorkspaceDirEntry,
WorkspaceDirectoryListing, WorkspaceDirectoryListing,
WorkspaceExecToolRequest,
WorkspaceExecToolResult,
WorkspaceFileChangedEvent, WorkspaceFileChangedEvent,
WorkspaceProject, WorkspaceProject,
WorkspaceResolvedPath, WorkspaceResolvedPath,

View File

@ -13,6 +13,7 @@ export interface IdeAPluginManifest {
* Defaults to "app". Use "project" when activate(ctx) requires a focused project. * Defaults to "app". Use "project" when activate(ctx) requires a focused project.
*/ */
activationScope?: IdeAPluginActivationScope; activationScope?: IdeAPluginActivationScope;
permissions?: IdeAPluginPermissions;
contributes?: { contributes?: {
skills?: IdeAPluginSkillContribution[]; skills?: IdeAPluginSkillContribution[];
menus?: IdeAPluginTopLevelMenuContribution[]; menus?: IdeAPluginTopLevelMenuContribution[];
@ -23,6 +24,24 @@ export interface IdeAPluginManifest {
}; };
} }
export type IdeAPluginWorkspaceExecEffect = "read" | "write" | "network" | "destructive";
export interface IdeAPluginWorkspaceExecRule {
/** Bare executable name resolved by the host without a shell. */
command: string;
/** Exact argv tokens. `*` matches one token, or the remaining suffix when last. */
args?: string[];
/** Exact project-relative cwd; `*` permits any host-confined cwd. Defaults to `.`. */
cwd?: string;
effect: IdeAPluginWorkspaceExecEffect;
}
export interface IdeAPluginPermissions {
workspaceExec?: {
allow: IdeAPluginWorkspaceExecRule[];
};
}
export type IdeAPluginCapability = "ui" | "mcp" | "tooling"; export type IdeAPluginCapability = "ui" | "mcp" | "tooling";
export type IdeAPluginActivationScope = "app" | "project"; export type IdeAPluginActivationScope = "app" | "project";
@ -95,6 +114,15 @@ export interface IdeAPluginMcpServerContribution {
transport: "stdio"; transport: "stdio";
autoStart?: boolean; autoStart?: boolean;
allowAbsoluteCommand?: boolean; allowAbsoluteCommand?: boolean;
/** Optional static UI metadata; runtime MCP tools/list remains authoritative. */
tools?: IdeAPluginMcpToolMetadata[];
}
export interface IdeAPluginMcpToolMetadata {
/** Exact tool name advertised by the MCP server. */
name: string;
/** Optional human-readable summary for capability-management UI. */
description?: string;
} }
export type PluginManifestValidationResult = export type PluginManifestValidationResult =
@ -136,6 +164,7 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation
validateEngines(input.engines, errors); validateEngines(input.engines, errors);
validateCapabilities(input.capabilities, errors); validateCapabilities(input.capabilities, errors);
validateActivationScope(input.activationScope, errors); validateActivationScope(input.activationScope, errors);
validatePermissions(input.permissions, errors);
validateContributes(input.contributes, errors); validateContributes(input.contributes, errors);
if (errors.length > 0) { if (errors.length > 0) {
@ -145,6 +174,56 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation
return { success: true, data: input as unknown as IdeAPluginManifest, errors: [] }; return { success: true, data: input as unknown as IdeAPluginManifest, errors: [] };
} }
function validatePermissions(value: unknown, errors: string[]): void {
if (value === undefined) return;
if (!isRecord(value)) {
errors.push("permissions must be an object when provided");
return;
}
const workspaceExec = value.workspaceExec;
if (workspaceExec === undefined) return;
if (!isRecord(workspaceExec) || !Array.isArray(workspaceExec.allow)) {
errors.push("permissions.workspaceExec.allow must be an array");
return;
}
if (workspaceExec.allow.length > 64) {
errors.push("permissions.workspaceExec.allow must contain at most 64 rules");
}
workspaceExec.allow.forEach((rule, index) => {
const label = `permissions.workspaceExec.allow[${index}]`;
if (!isRecord(rule)) {
errors.push(`${label} must be an object`);
return;
}
requireString(rule, "command", errors, `${label}.command`);
if (typeof rule.command === "string" && !/^[A-Za-z0-9._+-]{1,128}$/.test(rule.command)) {
errors.push(`${label}.command must be a bare ASCII executable name`);
}
optionalStringArray(rule, "args", errors, `${label}.args`);
if (Array.isArray(rule.args)) {
if (rule.args.length > 64 || rule.args.some((arg) =>
typeof arg === "string" && (arg.length === 0 || arg.length > 4096 || (arg.includes("*") && arg !== "*")))) {
errors.push(`${label}.args contains an invalid token`);
}
}
optionalString(rule, "cwd", errors, `${label}.cwd`);
if (typeof rule.cwd === "string" && rule.cwd !== "*" && !isSafeWorkspaceCwd(rule.cwd)) {
errors.push(`${label}.cwd must be project-relative without empty, dot, or parent segments`);
}
if (!["read", "write", "network", "destructive"].includes(String(rule.effect))) {
errors.push(`${label}.effect must be "read", "write", "network" or "destructive"`);
}
});
}
function isSafeWorkspaceCwd(value: string): boolean {
if (value === ".") return true;
if (value.length === 0 || value.startsWith("/") || value.startsWith("\\") || value.includes(":")) {
return false;
}
return value.replaceAll("\\", "/").split("/").every((part) => part !== "" && part !== "." && part !== "..");
}
export function isPluginManifest(input: unknown): input is IdeAPluginManifest { export function isPluginManifest(input: unknown): input is IdeAPluginManifest {
return validatePluginManifest(input).success; return validatePluginManifest(input).success;
} }
@ -269,6 +348,22 @@ function validateContributes(value: unknown, errors: string[]): void {
optionalString(server, "cwd", errors, `contributes.mcpServers[${index}].cwd`); optionalString(server, "cwd", errors, `contributes.mcpServers[${index}].cwd`);
optionalBoolean(server, "autoStart", errors, `contributes.mcpServers[${index}].autoStart`); optionalBoolean(server, "autoStart", errors, `contributes.mcpServers[${index}].autoStart`);
optionalBoolean(server, "allowAbsoluteCommand", errors, `contributes.mcpServers[${index}].allowAbsoluteCommand`); optionalBoolean(server, "allowAbsoluteCommand", errors, `contributes.mcpServers[${index}].allowAbsoluteCommand`);
validateArray(server, "tools", errors, (tool, toolIndex) => {
requireString(tool, "name", errors, `contributes.mcpServers[${index}].tools[${toolIndex}].name`);
if (tool.description !== undefined) {
requireString(tool, "description", errors, `contributes.mcpServers[${index}].tools[${toolIndex}].description`);
}
});
if (Array.isArray(server.tools)) {
const names = new Set<string>();
server.tools.forEach((tool, toolIndex) => {
if (!isRecord(tool) || typeof tool.name !== "string" || tool.name.trim() === "") return;
if (names.has(tool.name)) {
errors.push(`contributes.mcpServers[${index}].tools[${toolIndex}].name must be unique within the server`);
}
names.add(tool.name);
});
}
}); });
} }

View File

@ -2,6 +2,8 @@ import type { ComponentType, ReactNode } from "react";
export interface ActivateContext { export interface ActivateContext {
pluginId: string; pluginId: string;
/** Absolute host-local root of this active installed plugin package. */
pluginRoot: string;
logger: PluginLogger; logger: PluginLogger;
subscriptions: CommandDisposable[]; subscriptions: CommandDisposable[];
commands?: CommandRegistry; commands?: CommandRegistry;
@ -135,6 +137,31 @@ export interface WorkspaceService {
watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>; watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>;
/** Queries a bounded, generic project structure read model. */ /** Queries a bounded, generic project structure read model. */
queryStructure(query?: WorkspaceStructureQuery): Promise<ProjectStructure>; queryStructure(query?: WorkspaceStructureQuery): Promise<ProjectStructure>;
/** Runs one manifest-allowlisted binary directly, without a visible task. */
execTool(request: WorkspaceExecToolRequest): Promise<WorkspaceExecToolResult>;
}
export interface WorkspaceExecToolRequest {
projectId?: string;
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
timeoutMs?: number;
maxStdoutBytes?: number;
maxStderrBytes?: number;
stdin?: string;
}
export interface WorkspaceExecToolResult {
exitCode: number | null;
signal?: string;
timedOut: boolean;
stdout: string;
stderr: string;
stdoutTruncated: boolean;
stderrTruncated: boolean;
durationMs: number;
} }
export interface WorkspaceResolvedPath { export interface WorkspaceResolvedPath {