Compare commits
21 Commits
9829ccdf2a
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a3c582ec8 | |||
| 32d33b330f | |||
| 8ff0f475de | |||
| 720cd53924 | |||
| 9efb781636 | |||
| 116b75a024 | |||
| 047c38b7ca | |||
| 436ed59b41 | |||
| d93e302320 | |||
| fdb3a907af | |||
| b5a35563b6 | |||
| 68fd612087 | |||
| 42567a5305 | |||
| e3a0f46f9d | |||
| 9ce3a557a9 | |||
| 68019dec58 | |||
| 5f9c2f9193 | |||
| 7db819c647 | |||
| 1b5ab2fa86 | |||
| 6eb8db3791 | |||
| 1239104b0f |
17
README.md
17
README.md
@ -14,13 +14,28 @@ 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.
|
||||||
|
- [Project Plugin Assignments](docs/project-plugin-assignments.md): enable plugins per project and assign skills and MCP tools to agents.
|
||||||
|
|
||||||
The installable example lives in [`examples/hello-plugin`](examples/hello-plugin).
|
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
|
||||||
|
|||||||
@ -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`:
|
||||||
|
|||||||
53
docs/codex-skills-and-idea-plugins.md
Normal file
53
docs/codex-skills-and-idea-plugins.md
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# Codex Skills And IdeA Plugins
|
||||||
|
|
||||||
|
IdeA runtime plugins, IdeA-native plugin skills and Codex plugins are distinct
|
||||||
|
installation and discovery systems.
|
||||||
|
|
||||||
|
An IdeA runtime plugin is described by `idea-plugin.json`. Installing it through
|
||||||
|
IdeA makes its global runtime package available. Project settings then decide
|
||||||
|
which installed plugins are enabled for a project, and agent settings decide
|
||||||
|
which plugin skills and plugin MCP tools are assigned to each agent.
|
||||||
|
|
||||||
|
An IdeA-native plugin skill is declared in `idea-plugin.json` under
|
||||||
|
`contributes.skills`. Its body is a package-relative Markdown file such as
|
||||||
|
`skills/<name>/SKILL.md`. When the plugin is enabled for a project and the skill
|
||||||
|
is assigned to an agent, IdeA injects the skill affordance into that agent's
|
||||||
|
skill catalogue and serves the body through `idea_skill_read`. This is not
|
||||||
|
Codex-specific; it is projected by IdeA for every harness that supports IdeA
|
||||||
|
capabilities, including Claude Code, Codex, OpenCode local and OpenCode cloud.
|
||||||
|
|
||||||
|
A Codex plugin is described by `.codex-plugin/plugin.json`. Its `skills` entry
|
||||||
|
points Codex at skill directories such as `skills/*/SKILL.md`. This metadata is
|
||||||
|
for Codex plugin installation and skill discovery, not for IdeA runtime plugin
|
||||||
|
loading.
|
||||||
|
|
||||||
|
## Normative Behavior
|
||||||
|
|
||||||
|
- Installing or reloading an IdeA plugin makes the package available globally,
|
||||||
|
but does not automatically enable it in every project.
|
||||||
|
- Enabling a plugin for a project does not automatically assign every skill or
|
||||||
|
MCP tool to every agent.
|
||||||
|
- A plugin skill becomes visible to an agent only when the plugin is enabled for
|
||||||
|
the project and that skill is assigned to that agent.
|
||||||
|
- Installing or reloading an IdeA plugin does not automatically install a bundled
|
||||||
|
Codex plugin, and `.codex-plugin/plugin.json` remains inert for IdeA-native
|
||||||
|
plugin loading unless a separate bridge explicitly consumes it.
|
||||||
|
- Bundling `.codex-plugin/plugin.json` is still allowed as distribution content
|
||||||
|
for Codex-specific workflows, but it is not the multi-harness IdeA contract.
|
||||||
|
|
||||||
|
## Required Mechanisms
|
||||||
|
|
||||||
|
For multi-harness IdeA agent skills, declare `contributes.skills` in
|
||||||
|
`idea-plugin.json`, ship the referenced Markdown files inside the package,
|
||||||
|
install the plugin in IdeA, enable it in the target project, and assign the
|
||||||
|
skills to the target agents. See
|
||||||
|
[Project Plugin Assignments](project-plugin-assignments.md) for the project
|
||||||
|
settings schema and the complete install, enable, assign, and relaunch workflow.
|
||||||
|
|
||||||
|
For Codex-specific plugin skills, install the Codex plugin through the Codex
|
||||||
|
plugin mechanism for the intended scope, then assign or enable those skills
|
||||||
|
according to Codex/IdeA agent configuration.
|
||||||
|
|
||||||
|
Do not rely on `.codex-plugin/plugin.json` as a cross-harness skill propagation
|
||||||
|
mechanism. Cross-harness plugin skill availability is driven by
|
||||||
|
`idea-plugin.json` and IdeA project/agent assignments.
|
||||||
@ -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,13 +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. Use logs, Work/task state or plugin-owned UI/files when
|
and `failed` results. It intentionally does not expose the raw result payload
|
||||||
a human needs visible feedback today.
|
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
|
## 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.
|
||||||
@ -42,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
|
||||||
@ -61,21 +75,33 @@ 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
|
||||||
|
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.
|
||||||
- Plugin logs: diagnostics for developers and operators.
|
- Plugin logs: diagnostics for developers and operators.
|
||||||
- Work/background-task surfaces: only for tasks actually launched through
|
- Work/background-task surfaces: only for tasks actually launched through
|
||||||
`runCommand()`.
|
`runCommand()`.
|
||||||
@ -105,6 +131,45 @@ state when invoked instead of assuming a watch was installed at activation.
|
|||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
|
Visible menu feedback:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { IdeAPluginModule, PluginLayoutProps } from "@idea/plugin-sdk";
|
||||||
|
|
||||||
|
function HealthView(_props: PluginLayoutProps<{ source?: string }>) {
|
||||||
|
return <section>Unity tools are ready.</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin: IdeAPluginModule = {
|
||||||
|
activate(ctx) {
|
||||||
|
ctx.layouts?.register({
|
||||||
|
type: "unity-plugin.health",
|
||||||
|
component: HealthView
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.commands?.registerCommand("unity-plugin.health", async () => {
|
||||||
|
const win = await ctx.services?.windows.open({
|
||||||
|
layoutType: "unity-plugin.health",
|
||||||
|
state: { source: "menu" }
|
||||||
|
});
|
||||||
|
|
||||||
|
return win
|
||||||
|
? { status: "opened", message: "Opened Unity Health.", alreadyOpen: win.alreadyOpen }
|
||||||
|
: { status: "skipped", reason: "ui-service-unavailable", message: "UI service unavailable." };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
```ts
|
||||||
const project = await ctx.services?.workspace.getCurrentProject();
|
const project = await ctx.services?.workspace.getCurrentProject();
|
||||||
if (!project) {
|
if (!project) {
|
||||||
@ -158,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.
|
||||||
|
|||||||
@ -32,6 +32,26 @@ ctx.layouts?.register({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `type` must exactly match one entry in `contributes.layouts`. The host
|
||||||
|
rejects registrations for undeclared layout types. A plugin that opens this
|
||||||
|
layout for human feedback must also declare the `ui` capability so the UI
|
||||||
|
runtime and window services are part of the public contract.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"capabilities": ["ui", "tooling"],
|
||||||
|
"contributes": {
|
||||||
|
"layouts": [
|
||||||
|
{
|
||||||
|
"type": "hello-plugin.dashboard",
|
||||||
|
"label": "Dashboard",
|
||||||
|
"component": "Dashboard"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Props
|
## Props
|
||||||
|
|
||||||
- `projectId`: project hosting the layout.
|
- `projectId`: project hosting the layout.
|
||||||
|
|||||||
257
docs/manifest.md
257
docs/manifest.md
@ -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,20 +141,274 @@ 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
|
||||||
|
|
||||||
|
`contributes.skills` declares read-only agent skills shipped inside the IdeA
|
||||||
|
plugin package. These skills are IdeA-native: IdeA can expose them in project
|
||||||
|
plugin settings, assign them to agents, inject them into the agent skill
|
||||||
|
catalogue, and serve their Markdown body through `idea_skill_read` for every
|
||||||
|
supported harness that receives IdeA capabilities, including Claude Code,
|
||||||
|
Codex, OpenCode local and OpenCode cloud.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contributes": {
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"id": "unity.build-debug",
|
||||||
|
"name": "unity-build-debug",
|
||||||
|
"description": "Diagnose Unity build failures.",
|
||||||
|
"kind": "workflow",
|
||||||
|
"path": "skills/unity-build-debug/SKILL.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Skill contribution rules:
|
||||||
|
|
||||||
|
- `id` is stable within the plugin and is used by project/agent assignments.
|
||||||
|
- `name` is the agent-facing name shown in the injected skill catalogue and
|
||||||
|
accepted by `idea_skill_read`.
|
||||||
|
- `description` is the short affordance shown before the agent loads the skill.
|
||||||
|
- `kind` defaults to `"workflow"`; `"reference"` is also supported.
|
||||||
|
- `path` must be a package-relative Markdown file path ending in `.md`.
|
||||||
|
- Installing a plugin globally is not enough: a project must enable the plugin,
|
||||||
|
then assign the skill to the target agent.
|
||||||
|
|
||||||
|
See [Project Plugin Assignments](project-plugin-assignments.md) for the
|
||||||
|
`.ideai/project-plugins.json` schema, validation rules, assignment order, and
|
||||||
|
agent relaunch requirements.
|
||||||
|
|
||||||
`contributes.layouts` is the only surface for plugin layouts. The same layout
|
`contributes.layouts` is the only surface for plugin layouts. The same layout
|
||||||
contribution can be mounted inside an IdeA layout cell or opened in a detached OS
|
contribution can be mounted inside an IdeA layout cell or opened in a detached OS
|
||||||
window. Do not add a separate manifest contribution type for windows.
|
window. Do not add a separate manifest contribution type for windows.
|
||||||
|
|
||||||
|
For a command that opens a visible plugin window, declare both the menu command
|
||||||
|
and the layout type:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ideaPluginManifestVersion": 1,
|
||||||
|
"id": "com.example.unity-plugin",
|
||||||
|
"displayName": "Unity Developer Tools",
|
||||||
|
"publisher": "Example",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Unity integration for IdeA.",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"engines": {
|
||||||
|
"idea": ">=0.1.0"
|
||||||
|
},
|
||||||
|
"trustLevel": "full",
|
||||||
|
"capabilities": ["ui", "tooling"],
|
||||||
|
"activationScope": "app",
|
||||||
|
"contributes": {
|
||||||
|
"menus": [
|
||||||
|
{
|
||||||
|
"id": "unity-plugin.menu",
|
||||||
|
"label": "Unity Developer Tools",
|
||||||
|
"topLevel": true,
|
||||||
|
"order": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"menuItems": [
|
||||||
|
{
|
||||||
|
"id": "unity-plugin.health.item",
|
||||||
|
"targetMenuId": "unity-plugin.menu",
|
||||||
|
"label": "Health",
|
||||||
|
"command": "unity-plugin.health",
|
||||||
|
"order": 10,
|
||||||
|
"when": "projectOpen"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"layouts": [
|
||||||
|
{
|
||||||
|
"type": "unity-plugin.health",
|
||||||
|
"label": "Unity Health",
|
||||||
|
"component": "UnityHealth",
|
||||||
|
"order": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`capabilities: ["ui"]` is required when a plugin relies on layouts or windows as
|
||||||
|
human-facing UI. Add `"tooling"` when the same plugin uses workspace, task,
|
||||||
|
tooling or terminal services. The `contributes.layouts[].type` value is the
|
||||||
|
public id used by both `ctx.layouts.register({ type, component })` and
|
||||||
|
`ctx.services.windows.open({ layoutType })`.
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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`:
|
||||||
@ -102,8 +121,23 @@ After editing plugin source files, rebuild the plugin output first:
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Then ask an IdeA agent that has the plugin administration tool available to run
|
An IdeA agent can install a development plugin directory only when that agent has
|
||||||
`idea_plugin_reload` with the installed plugin id:
|
the plugin administration MCP tool explicitly allowed. The public tool is
|
||||||
|
`idea_plugin_install_from_directory`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "relative/path/to/hello-plugin",
|
||||||
|
"expectedPluginId": "com.example.hello-plugin"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The path must resolve under the requester's project root. This installs the
|
||||||
|
directory as the recorded development source for later reloads.
|
||||||
|
|
||||||
|
After source edits, rebuild the plugin output and ask an IdeA agent that has the
|
||||||
|
plugin administration tool available to run `idea_plugin_reload` with the
|
||||||
|
installed plugin id:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -122,7 +156,17 @@ reload. Archive installs are fixed package snapshots. They are appropriate for
|
|||||||
distribution, but the reload command is only defined for plugins installed from
|
distribution, but the reload command is only defined for plugins installed from
|
||||||
a directory source.
|
a directory source.
|
||||||
|
|
||||||
Current limitation: backend registry state and plugin MCP/tool contributions are
|
On reload, `plugin_reloaded` tells the UI to rebuild the plugin runtime registry.
|
||||||
reloaded without restarting IdeA. Frontend React contributions that are already
|
When the package content hash changes, plugin ESM URLs change and React layout
|
||||||
loaded in the current UI session may keep their existing module instance until
|
components are loaded from the new package. Existing plugin layout instances may
|
||||||
the relevant plugin surface is recreated or the app session is restarted.
|
remount and lose local React state; host-persisted layout state written through
|
||||||
|
`setState` remains the durable state channel.
|
||||||
|
|
||||||
|
JavaScript module instances cannot be forcibly removed from the browser ESM
|
||||||
|
cache. Register handlers, layouts, watches and subscriptions through the SDK
|
||||||
|
registries and push disposable side effects into `ctx.subscriptions`; global
|
||||||
|
module side effects can otherwise survive a reload. If the package hash did not
|
||||||
|
change, the reload is idempotent and may reuse the same module instance.
|
||||||
|
|
||||||
|
IdeA runtime plugin installation is separate from Codex plugin/skill discovery.
|
||||||
|
See [Codex Skills And IdeA Plugins](codex-skills-and-idea-plugins.md).
|
||||||
|
|||||||
169
docs/project-plugin-assignments.md
Normal file
169
docs/project-plugin-assignments.md
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
# Project Plugin Assignments
|
||||||
|
|
||||||
|
IdeA installs plugin packages globally, but enables their capabilities per
|
||||||
|
project and assigns agent-facing capabilities per agent. Project choices are
|
||||||
|
stored in the project-owned `.ideai/project-plugins.json` file.
|
||||||
|
|
||||||
|
The normal workflow is:
|
||||||
|
|
||||||
|
1. Install the plugin globally in IdeA.
|
||||||
|
2. Enable the installed plugin for the target project.
|
||||||
|
3. Assign selected plugin skills and MCP tools to each target agent.
|
||||||
|
4. Relaunch the agent so its capability snapshot includes the new assignments.
|
||||||
|
|
||||||
|
Installation alone does not enable a plugin in a project. Enabling a plugin does
|
||||||
|
not automatically grant all of its capabilities to every agent.
|
||||||
|
|
||||||
|
## Version 1 Schema
|
||||||
|
|
||||||
|
The current schema version is `1`. A complete example is:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"pluginId": "com.example.unity-plugin",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"agentId": "2a0e42e2-3c5f-4f30-a679-034b460cd59e",
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"pluginId": "com.example.unity-plugin",
|
||||||
|
"skillId": "unity.build-debug"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"pluginId": "com.example.unity-plugin",
|
||||||
|
"serverId": "unity-editor",
|
||||||
|
"toolName": "unity_build"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The top-level fields are:
|
||||||
|
|
||||||
|
- `version`: required schema version. It must currently be `1`; unsupported
|
||||||
|
versions are rejected.
|
||||||
|
- `plugins`: project activation entries. Each entry contains the stable global
|
||||||
|
`pluginId` and an `enabled` boolean.
|
||||||
|
- `agents`: per-agent assignments. Each entry identifies an IdeA project agent
|
||||||
|
by `agentId` and contains ordered `skills` and `tools` arrays.
|
||||||
|
|
||||||
|
An agent skill reference contains:
|
||||||
|
|
||||||
|
- `pluginId`: the id from the owning plugin's `idea-plugin.json`.
|
||||||
|
- `skillId`: the stable `contributes.skills[].id` within that plugin.
|
||||||
|
|
||||||
|
An agent MCP tool reference contains:
|
||||||
|
|
||||||
|
- `pluginId`: the id from the owning plugin's `idea-plugin.json`.
|
||||||
|
- `serverId`: the stable `contributes.mcpServers[].id` within that plugin.
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
## Assignment And Injection Order
|
||||||
|
|
||||||
|
Array order is significant. IdeA keeps plugin skill assignments in the order in
|
||||||
|
which they were added. At agent launch, IdeA resolves the agent's native skills
|
||||||
|
first, in agent-manifest order, then its plugin skills in the order stored in
|
||||||
|
`agents[].skills`. Unavailable assignments are skipped without reordering the
|
||||||
|
remaining skills.
|
||||||
|
|
||||||
|
The resolved skill catalogue is injected into the agent's orchestration context
|
||||||
|
before the agent persona. Each supported harness receives the same ordered
|
||||||
|
IdeA-native capability snapshot. The skill body remains read-only package
|
||||||
|
content and is available through `idea_skill_read` only when that skill was
|
||||||
|
resolved for the launched agent.
|
||||||
|
|
||||||
|
## Tombstones
|
||||||
|
|
||||||
|
Plugin activation entries and agent assignments are intentionally retained when
|
||||||
|
a plugin, skill, or MCP server is temporarily missing. These dangling entries
|
||||||
|
are tombstones: they preserve project intent across uninstall/reinstall or a
|
||||||
|
temporary manifest change.
|
||||||
|
|
||||||
|
A tombstone is inert. IdeA skips it when resolving capabilities, and the project
|
||||||
|
settings UI marks stale skill or server references as missing or obsolete. The
|
||||||
|
UI lets users remove those references explicitly. Reinstalling and activating a
|
||||||
|
matching plugin makes a retained assignment resolvable again.
|
||||||
|
|
||||||
|
Disabling a plugin also keeps its assignments. They become effective again only
|
||||||
|
after the plugin is enabled and runtime-active for the project.
|
||||||
|
|
||||||
|
## UI And API Validation
|
||||||
|
|
||||||
|
Prefer the project plugin settings UI or its backing API commands over manual
|
||||||
|
editing. They apply the following rules:
|
||||||
|
|
||||||
|
- Enabling requires the plugin package to be installed globally. Disabling a
|
||||||
|
missing plugin is allowed so a retained activation can be cleared.
|
||||||
|
- Assigning a skill requires an existing project agent, an enabled and
|
||||||
|
runtime-active plugin, and a matching `contributes.skills[].id`.
|
||||||
|
- Assigning a tool requires an existing project agent, an enabled and
|
||||||
|
runtime-active plugin, a matching `contributes.mcpServers[].id`, and a
|
||||||
|
non-empty `toolName`.
|
||||||
|
- Assign and unassign operations are idempotent. Assigning an existing reference
|
||||||
|
does not duplicate it; unassigning a missing reference is harmless.
|
||||||
|
- Unassign operations remain available for tombstones, so stale references can
|
||||||
|
be removed even when their plugin capability no longer resolves.
|
||||||
|
|
||||||
|
The project settings read API returns both the persisted document and the
|
||||||
|
currently installed plugin manifests. The UI uses this catalogue to offer only
|
||||||
|
skills and MCP servers from plugins enabled for the project, while still
|
||||||
|
surfacing persisted tombstones.
|
||||||
|
|
||||||
|
## Manual Editing
|
||||||
|
|
||||||
|
The file is project-owned and may be edited manually, for example in version
|
||||||
|
control or automation. Stop or relaunch affected agents around the edit, keep
|
||||||
|
the document valid JSON, preserve `version: 1`, use the real project agent UUID,
|
||||||
|
and copy plugin, skill, and server ids exactly from the plugin manifest.
|
||||||
|
|
||||||
|
Manual editing bypasses the mutation checks described above. A syntactically
|
||||||
|
valid document may therefore contain duplicates, unknown agents, disabled or
|
||||||
|
missing plugins, unknown capability ids, or an invalid tool name. Such entries
|
||||||
|
may remain inert or fail later operations. Use the UI/API whenever possible to
|
||||||
|
receive immediate validation and idempotent updates.
|
||||||
|
|
||||||
|
## Reloading Plugins And Relaunching Agents
|
||||||
|
|
||||||
|
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
|
||||||
|
running agent does not acquire assignment changes retroactively.
|
||||||
|
|
||||||
|
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.
|
||||||
@ -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.
|
||||||
|
|||||||
@ -10,11 +10,27 @@ await ctx.services?.windows.open({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`windows.open` is the public API for commands that need to make feedback visible
|
||||||
|
to a human. A command handler may still return structured data, but menu clicks
|
||||||
|
do not currently guarantee that return value is displayed.
|
||||||
|
|
||||||
## Contract
|
## Contract
|
||||||
|
|
||||||
- `layoutType` must match a layout type declared by the calling plugin.
|
- `layoutType` must match a layout type declared by the calling plugin.
|
||||||
|
- The plugin must declare `capabilities: ["ui"]` when it relies on plugin
|
||||||
|
layouts/windows as a human-facing surface. Add `"tooling"` only when the
|
||||||
|
plugin also uses tooling/workspace/task services.
|
||||||
|
- There is no `contributes.windows` manifest key. Windows always host an
|
||||||
|
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.
|
||||||
@ -38,9 +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 () => {
|
||||||
return 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 { status: "opened", message: "Opened dashboard.", alreadyOpen: win.alreadyOpen };
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
1
examples/hello-plugin/scripts/hello-task.mjs
Normal file
1
examples/hello-plugin/scripts/hello-task.mjs
Normal file
@ -0,0 +1 @@
|
|||||||
|
console.log("hello from a script packaged with @idea/plugin-sdk");
|
||||||
@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
61
scripts/check-manifest-tools.mjs
Normal file
61
scripts/check-manifest-tools.mjs
Normal 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");
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -2,10 +2,16 @@ export type {
|
|||||||
IdeAPluginActivationScope,
|
IdeAPluginActivationScope,
|
||||||
IdeAPluginCapability,
|
IdeAPluginCapability,
|
||||||
IdeAPluginManifest,
|
IdeAPluginManifest,
|
||||||
|
IdeAPluginPermissions,
|
||||||
|
IdeAPluginWorkspaceExecEffect,
|
||||||
|
IdeAPluginWorkspaceExecRule,
|
||||||
IdeAPluginEngineConstraints,
|
IdeAPluginEngineConstraints,
|
||||||
IdeAPluginLayoutContribution,
|
IdeAPluginLayoutContribution,
|
||||||
IdeAPluginMcpServerContribution,
|
IdeAPluginMcpServerContribution,
|
||||||
|
IdeAPluginMcpToolMetadata,
|
||||||
IdeAPluginMenuItemContribution,
|
IdeAPluginMenuItemContribution,
|
||||||
|
IdeAPluginSkillContribution,
|
||||||
|
IdeAPluginSkillKind,
|
||||||
IdeAPluginTopLevelMenuContribution
|
IdeAPluginTopLevelMenuContribution
|
||||||
} from "./manifest.js";
|
} from "./manifest.js";
|
||||||
export {
|
export {
|
||||||
@ -75,6 +81,8 @@ export type {
|
|||||||
WorkspaceBinaryFile,
|
WorkspaceBinaryFile,
|
||||||
WorkspaceDirEntry,
|
WorkspaceDirEntry,
|
||||||
WorkspaceDirectoryListing,
|
WorkspaceDirectoryListing,
|
||||||
|
WorkspaceExecToolRequest,
|
||||||
|
WorkspaceExecToolResult,
|
||||||
WorkspaceFileChangedEvent,
|
WorkspaceFileChangedEvent,
|
||||||
WorkspaceProject,
|
WorkspaceProject,
|
||||||
WorkspaceResolvedPath,
|
WorkspaceResolvedPath,
|
||||||
|
|||||||
124
src/manifest.ts
124
src/manifest.ts
@ -13,7 +13,9 @@ 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[];
|
||||||
menus?: IdeAPluginTopLevelMenuContribution[];
|
menus?: IdeAPluginTopLevelMenuContribution[];
|
||||||
menuItems?: IdeAPluginMenuItemContribution[];
|
menuItems?: IdeAPluginMenuItemContribution[];
|
||||||
slashCommands?: IdeAPluginSlashCommandContribution[];
|
slashCommands?: IdeAPluginSlashCommandContribution[];
|
||||||
@ -22,14 +24,46 @@ 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";
|
||||||
|
|
||||||
|
export type IdeAPluginSkillKind = "workflow" | "reference";
|
||||||
|
|
||||||
export interface IdeAPluginEngineConstraints {
|
export interface IdeAPluginEngineConstraints {
|
||||||
idea?: string;
|
idea?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IdeAPluginSkillContribution {
|
||||||
|
id: string;
|
||||||
|
/** Agent-facing name shown in the injected skill catalogue and accepted by idea_skill_read. */
|
||||||
|
name: string;
|
||||||
|
/** Short affordance shown to agents before they load the skill body. */
|
||||||
|
description: string;
|
||||||
|
/** Defaults to "workflow". */
|
||||||
|
kind?: IdeAPluginSkillKind;
|
||||||
|
/** Package-relative Markdown file, usually skills/<name>/SKILL.md. */
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IdeAPluginTopLevelMenuContribution {
|
export interface IdeAPluginTopLevelMenuContribution {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@ -80,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 =
|
||||||
@ -121,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) {
|
||||||
@ -130,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;
|
||||||
}
|
}
|
||||||
@ -192,6 +286,20 @@ function validateContributes(value: unknown, errors: string[]): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validateArray(value, "skills", errors, (skill, index) => {
|
||||||
|
requireString(skill, "id", errors, `contributes.skills[${index}].id`);
|
||||||
|
requireString(skill, "name", errors, `contributes.skills[${index}].name`);
|
||||||
|
requireString(skill, "description", errors, `contributes.skills[${index}].description`);
|
||||||
|
requireString(skill, "path", errors, `contributes.skills[${index}].path`);
|
||||||
|
if (typeof skill.kind === "string" && skill.kind !== "workflow" && skill.kind !== "reference") {
|
||||||
|
errors.push(`contributes.skills[${index}].kind must be "workflow" or "reference" when provided`);
|
||||||
|
} else if (skill.kind !== undefined && typeof skill.kind !== "string") {
|
||||||
|
errors.push(`contributes.skills[${index}].kind must be a string when provided`);
|
||||||
|
}
|
||||||
|
if (typeof skill.path === "string" && !skill.path.endsWith(".md")) {
|
||||||
|
errors.push(`contributes.skills[${index}].path must point to a .md file`);
|
||||||
|
}
|
||||||
|
});
|
||||||
validateArray(value, "menus", errors, (menu, index) => {
|
validateArray(value, "menus", errors, (menu, index) => {
|
||||||
requireString(menu, "id", errors, `contributes.menus[${index}].id`);
|
requireString(menu, "id", errors, `contributes.menus[${index}].id`);
|
||||||
requireString(menu, "label", errors, `contributes.menus[${index}].label`);
|
requireString(menu, "label", errors, `contributes.menus[${index}].label`);
|
||||||
@ -240,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);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user