Compare commits

..

13 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
14 changed files with 517 additions and 22 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.
- [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.
- [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.
- [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.
@ -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
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
```sh

View File

@ -45,12 +45,23 @@ focused project at invocation/render time.
The context never exposes internal IdeA gateways or Tauri commands. Use
`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
Push every returned disposable to `ctx.subscriptions`:

View File

@ -1,7 +1,7 @@
# Commands And Feedback
This document is the normative SDK contract for plugin commands that may launch
background work.
This document is the normative SDK contract for plugin commands, including
commands that open a view or launch background work.
## Contract
@ -25,14 +25,17 @@ manifest contribution -> command id -> registered command handler -> optional ta
not by a fake task.
- Feedback objects must be stable enough for agents and programmatic callers to
parse, and their messages must be readable by humans.
- The current human menu-click UI does not guarantee display of a command
handler return value. When a human must see feedback from a menu action, open
or focus a plugin layout/window from the handler with
- The human menu-click UI presents minimal host-owned feedback for `skipped`
and `failed` results. It intentionally does not expose the raw result payload
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 })`.
## 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
service facade.
@ -49,6 +52,12 @@ Check preconditions before calling `runCommand()`:
- 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.
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:
```ts
@ -66,21 +75,30 @@ with equivalent fields:
```ts
type CommandFeedback =
| { status: "opened"; message?: string; alreadyOpen: boolean }
| { status: "skipped"; reason: string; message: string }
| { status: "failed"; reason?: string; message?: string }
| { status: "launched"; taskId: string; state: string; message: string };
```
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.
- `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:
- Command return value: immediate feedback for programmatic callers, agents and
future host surfaces. It is not a guaranteed visible UI surface for current
human menu clicks.
- Command return value: immediate feedback for programmatic callers and agents.
The human menu host maps `skipped` and `failed` results to concise feedback;
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
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.
@ -145,6 +163,11 @@ const plugin: IdeAPluginModule = {
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:
```ts

View File

@ -115,6 +115,7 @@ development loop.
"capabilities": ["ui", "tooling"],
"activationScope": "app",
"contributes": {
"skills": [],
"menus": [],
"menuItems": [],
"layouts": [],
@ -140,6 +141,39 @@ development loop.
- `activationScope`: `"app"` by default, or `"project"` when activation needs a
focused project immediately.
- `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
@ -242,11 +276,80 @@ public id used by both `ctx.layouts.register({ type, component })` and
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`;
- `services.windows.open({ layoutType })` only accepts a layout type declared by
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
@ -255,11 +358,57 @@ 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
Use the SDK validator in tests or build tooling:

View File

@ -33,7 +33,10 @@ Then register the command id:
```ts
export function activate(ctx: ActivateContext): void {
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);
}
@ -41,9 +44,22 @@ export function activate(ctx: ActivateContext): void {
## Rules
- A command can only be registered if at least one manifest menu item declares
that exact `command` id.
- Missing command handlers are a no-op when the user clicks the menu item.
- There is no separate `contributes.commands` collection. A command can only be
registered if at least one manifest `menuItems[].command` or
`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
[Commands And Feedback](commands-and-feedback.md).
- 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
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
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
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
when IdeA reads the document. Prefer writing them explicitly for clarity and
forward-compatible review.
@ -144,6 +150,8 @@ These actions solve different problems:
- Reload the plugin after rebuilding or replacing its installed package. Reload
refreshes the plugin manifest, runtime bundle, contributions, and packaged
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
skill/tool assignments, or manually editing `.ideai/project-plugins.json`.
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,
confirm it is enabled for the project, update the assignments, then relaunch the
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?)`
- `watch(path, handler, projectId?)`
- `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
@ -42,7 +74,8 @@ 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.
as read-only. Unlike manifest-declared MCP servers, `runCommand()` does not
substitute `${pluginRoot}` or `${appDataDir}`.
```ts
const script = `${ctx.pluginRoot.replace(/[\\/]+$/, "")}/scripts/check.mjs`;
@ -56,6 +89,12 @@ await ctx.services.tasks.runCommand({
});
```
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
`services.tooling.diagnose()` checks executables, environment values and files
@ -85,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
one of the calling plugin's declared layout contributions. See
[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.
- The host validates the plugin is runtime-active and the layout exists before
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.
- `state` is JSON-serializable initial window state. The mounted component can
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
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",
state: { source: "menu" }
});
return win
? { status: "opened", message: "Opened dashboard.", alreadyOpen: win.alreadyOpen }
: { status: "skipped", reason: "ui-service-unavailable", message: "UI service unavailable." };
return { status: "opened", message: "Opened dashboard.", alreadyOpen: win.alreadyOpen };
});
```

View File

@ -18,10 +18,11 @@
],
"scripts": {
"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",
"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",
"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": [
"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

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

View File

@ -13,6 +13,7 @@ export interface IdeAPluginManifest {
* Defaults to "app". Use "project" when activate(ctx) requires a focused project.
*/
activationScope?: IdeAPluginActivationScope;
permissions?: IdeAPluginPermissions;
contributes?: {
skills?: IdeAPluginSkillContribution[];
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 IdeAPluginActivationScope = "app" | "project";
@ -95,6 +114,15 @@ export interface IdeAPluginMcpServerContribution {
transport: "stdio";
autoStart?: 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 =
@ -136,6 +164,7 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation
validateEngines(input.engines, errors);
validateCapabilities(input.capabilities, errors);
validateActivationScope(input.activationScope, errors);
validatePermissions(input.permissions, errors);
validateContributes(input.contributes, errors);
if (errors.length > 0) {
@ -145,6 +174,56 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation
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 {
return validatePluginManifest(input).success;
}
@ -269,6 +348,22 @@ function validateContributes(value: unknown, errors: string[]): void {
optionalString(server, "cwd", errors, `contributes.mcpServers[${index}].cwd`);
optionalBoolean(server, "autoStart", errors, `contributes.mcpServers[${index}].autoStart`);
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

@ -137,6 +137,31 @@ export interface WorkspaceService {
watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>;
/** Queries a bounded, generic project structure read model. */
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 {