Documente la règle publique menu -> command handler -> tâche de fond optionnelle -> feedback (docs/commands-and-feedback.md + README), clarifie via JSDoc les invariants de runCommand/recordOnly/ownerAgentId dans runtime.ts, et met à jour l'exemple hello-plugin (feedback structuré launched/skipped, watch non-fatal) pour qu'il illustre fidèlement le contrat documenté. Publie docs/ dans le package npm. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
478 lines
16 KiB
Markdown
478 lines
16 KiB
Markdown
# IdeA Plugin SDK
|
|
|
|
Minimal public TypeScript SDK for IdeA plugins.
|
|
|
|
This first version intentionally stays small:
|
|
|
|
- public manifest types for `idea-plugin.json`;
|
|
- public runtime types for plugin modules exposing `activate(ctx)`;
|
|
- plugin-owned persistent storage through `ctx.storage`;
|
|
- a stable `ctx.services` facade for workspace, background task and terminal operations;
|
|
- public workspace file APIs for reading, writing, listing, stat and path resolution;
|
|
- a bounded generic project-structure query API;
|
|
- public command-task APIs for launching and tracking generic tools;
|
|
- public external-toolchain diagnostics for executables, env vars and files;
|
|
- public best-effort event subscriptions and workspace watch;
|
|
- public structured config-document helpers for JSON documents;
|
|
- a lightweight manifest validator;
|
|
- a minimal `examples/hello-plugin` plugin.
|
|
|
|
## Command And Feedback Contract
|
|
|
|
Plugin menu actions follow one public contract:
|
|
|
|
```text
|
|
menu click -> registered command handler -> optional background task -> feedback surfaces
|
|
```
|
|
|
|
A menu item only names a command. The registered command handler owns all
|
|
precondition checks, task launch decisions and feedback. If a command
|
|
cannot or should not start work, return a small structured result such as
|
|
`{ status: "skipped", reason, message }` and log the same reason. That return
|
|
value is useful for programmatic callers, agents and future host surfaces; the
|
|
current human menu-click UI does not guarantee that handler return values are
|
|
shown to the user. Do not create a background task just to represent a skipped
|
|
command.
|
|
|
|
Use `ctx.services.tasks.runCommand()` only after required preconditions are
|
|
true: a focused project exists, the plugin has the `tooling` capability, required
|
|
executables/files/env are present, and a real `ownerAgentId` is available when
|
|
the result belongs to an agent workflow. `ownerAgentId` controls Work ownership,
|
|
cancellation and completion delivery; it must not be a placeholder in production
|
|
plugin code.
|
|
|
|
`recordOnly: true` records completion without waking the owner agent. It is not a
|
|
silent mode and it does not hide the task from surfaces that show Work state or
|
|
background-task events. If no task is launched, the baseline feedback surfaces
|
|
are the command result for programmatic callers and plugin logs; human-visible UI
|
|
feedback requires a host-supported surface, a real background task, or a
|
|
plugin-owned UI/file surface.
|
|
|
|
Canonical rules and examples live in
|
|
[`docs/commands-and-feedback.md`](docs/commands-and-feedback.md).
|
|
|
|
## Install
|
|
|
|
```sh
|
|
npm install
|
|
```
|
|
|
|
## Build
|
|
|
|
```sh
|
|
npm run build
|
|
```
|
|
|
|
## Typecheck the example
|
|
|
|
```sh
|
|
npm run typecheck:examples
|
|
```
|
|
|
|
## Build the installable hello plugin archive
|
|
|
|
```sh
|
|
npm run package:hello-plugin
|
|
```
|
|
|
|
The archive is written to:
|
|
|
|
```text
|
|
examples/hello-plugin/build/hello-plugin-0.1.0.zip
|
|
```
|
|
|
|
Its ZIP root contains `idea-plugin.json` directly, with no wrapping parent directory. The
|
|
compiled ESM entrypoint is emitted at `dist/index.js`, matching the manifest `main` field,
|
|
alongside the other compiled files imported by that entrypoint.
|
|
|
|
## Plugin shape
|
|
|
|
An IdeA plugin ships an `idea-plugin.json` manifest and a JavaScript entrypoint built from
|
|
TypeScript.
|
|
|
|
```json
|
|
{
|
|
"ideaPluginManifestVersion": 1,
|
|
"id": "com.example.hello",
|
|
"displayName": "Hello Plugin",
|
|
"version": "0.1.0",
|
|
"main": "dist/index.js",
|
|
"trustLevel": "full",
|
|
"activationScope": "app",
|
|
"contributes": {}
|
|
}
|
|
```
|
|
|
|
`activationScope` is optional and defaults to `"app"`, which activates the
|
|
plugin at app bootstrap without requiring a focused project. Use
|
|
`"activationScope": "project"` only when `activate(ctx)` needs project-scoped
|
|
services immediately; IdeA will keep that plugin pending until a project is
|
|
focused, then activate it once for the app session.
|
|
|
|
The `main` field is the ESM entrypoint loaded by IdeA. It may import other
|
|
JavaScript files from the same plugin package with relative specifiers:
|
|
|
|
```js
|
|
import { COMMAND_ID } from "./constants.js";
|
|
import { useWorkspaceSdk } from "./core/workspace.js";
|
|
```
|
|
|
|
Those relative imports are served by IdeA through the `idea-plugin://` protocol,
|
|
so plugins do not need to be bundled into a single JavaScript file. A plain
|
|
`tsc` build that emits multiple ESM files under `dist/` is supported, as shown
|
|
by `examples/hello-plugin`.
|
|
|
|
Only package-relative imports are resolved this way. Bare specifiers such as
|
|
`react`, `lodash` or any dependency expected from `node_modules` are not
|
|
resolved by the host at runtime. Third-party dependencies must be bundled into
|
|
the plugin output or vendored as relative files shipped inside the plugin
|
|
package.
|
|
|
|
The entrypoint exports an `activate(ctx)` function:
|
|
|
|
```ts
|
|
import type { ActivateContext } from "@idea/plugin-sdk";
|
|
|
|
export function activate(ctx: ActivateContext): void {
|
|
ctx.logger.info("hello from plugin");
|
|
}
|
|
```
|
|
|
|
## Layout Runtime
|
|
|
|
Plugins can contribute custom layout panels by declaring `contributes.layouts`
|
|
in `idea-plugin.json` and registering the matching layout type during
|
|
`activate(ctx)`.
|
|
|
|
```json
|
|
{
|
|
"contributes": {
|
|
"layouts": [
|
|
{
|
|
"type": "com.example.status",
|
|
"label": "Status",
|
|
"component": "StatusPanel"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
```ts
|
|
import type { ActivateContext, PluginLayoutProps } from "@idea/plugin-sdk";
|
|
|
|
function StatusPanel(props: PluginLayoutProps): string {
|
|
return `status for ${props.projectId}`;
|
|
}
|
|
|
|
export function activate(ctx: ActivateContext): void {
|
|
const disposable = ctx.layouts?.register({
|
|
type: "com.example.status",
|
|
component: StatusPanel
|
|
});
|
|
if (disposable) ctx.subscriptions.push(disposable);
|
|
}
|
|
```
|
|
|
|
Public layout props are:
|
|
|
|
- `projectId`: project hosting the layout cell;
|
|
- `nodeId`: stable layout node id for that cell instance;
|
|
- `layoutType`: contributed layout type from the manifest;
|
|
- `state`: opaque JSON-serializable state persisted by the host;
|
|
- `setState(next)`: replaces that state;
|
|
- `availability`: currently `"available"` when the component is mounted.
|
|
|
|
Lifecycle: register layouts during `activate(ctx)`, keep the returned disposable
|
|
in `ctx.subscriptions`, and let the host dispose it on plugin unload. Layout
|
|
components may be mounted, unmounted and remounted by the host; keep durable UI
|
|
state in `state` via `setState`, not in module globals. Call `setState` from
|
|
user actions, effects or asynchronous callbacks, not unconditionally while
|
|
rendering. Services are available from `ctx.services` to plugins declaring the
|
|
`tooling` capability; layout props do not expose private runtime gateways.
|
|
|
|
## Plugin-Owned Storage
|
|
|
|
Use `ctx.storage` for state owned by the plugin itself: internal counters, flags,
|
|
preferences, small caches and host-facing settings. This is the canonical place
|
|
for plugin-owned state because the host can scope and persist it outside the
|
|
user's project files.
|
|
|
|
```ts
|
|
import type { ActivateContext } from "@idea/plugin-sdk";
|
|
|
|
const ACTIVATION_COUNT_KEY = "helloPlugin.activationCount";
|
|
|
|
export async function activate(ctx: ActivateContext): Promise<void> {
|
|
const current = await ctx.storage?.get<number>(ACTIVATION_COUNT_KEY);
|
|
const next = typeof current === "number" ? current + 1 : 1;
|
|
await ctx.storage?.set(ACTIVATION_COUNT_KEY, next);
|
|
|
|
ctx.logger.info("activation count", { next });
|
|
}
|
|
```
|
|
|
|
Do not write plugin-internal state into `.ideai/*` or other project files by
|
|
default. Use workspace files and config-document helpers only when the file is
|
|
project-owned content or project-owned configuration that the user expects to
|
|
see, review and version with the project.
|
|
|
|
## Runtime Services
|
|
|
|
Plugins declaring the `tooling` capability receive `ctx.services`. Plugins
|
|
without that capability do not receive this facade. Prefer `ctx.services` over
|
|
IdeA's internal runtime objects when it is available:
|
|
|
|
```ts
|
|
import type { ActivateContext } from "@idea/plugin-sdk";
|
|
|
|
export async function activate(ctx: ActivateContext): Promise<void> {
|
|
const project = await ctx.services?.workspace.getCurrentProject();
|
|
ctx.logger.info("current project", project);
|
|
|
|
const task = await ctx.services?.tasks.getStatus("task-id");
|
|
ctx.logger.info("task status", task?.status);
|
|
|
|
const terminal = await ctx.services?.terminal.open({ rows: 24, cols: 80 });
|
|
await terminal?.write(new TextEncoder().encode("echo hello\\r"));
|
|
}
|
|
```
|
|
|
|
### Workspace Files
|
|
|
|
Workspace paths are always relative to the project root. Hosts reject absolute
|
|
paths, `..`, empty path segments and paths outside the sandbox. Text APIs use
|
|
UTF-8; binary APIs use `Uint8Array`. Missing files reject on reads and resolve
|
|
to `{ exists: false }` from `stat`. These APIs are for project-owned files:
|
|
source files, generated reports or user-visible artifacts. Use `ctx.storage`
|
|
instead for plugin-owned counters, flags, preferences and caches.
|
|
|
|
```ts
|
|
import type { ActivateContext } from "@idea/plugin-sdk";
|
|
|
|
export async function activate(ctx: ActivateContext): Promise<void> {
|
|
const workspace = ctx.services?.workspace;
|
|
const project = await workspace?.getCurrentProject();
|
|
if (!workspace || !project) return;
|
|
|
|
await workspace.writeTextFile("hello-plugin-report.txt", "hello\n", project.id);
|
|
|
|
const file = await workspace.readTextFile("hello-plugin-report.txt", project.id);
|
|
const listing = await workspace.listDirectory(".", project.id);
|
|
const stat = await workspace.stat(file.path, project.id);
|
|
|
|
ctx.logger.info("workspace file", {
|
|
path: file.path,
|
|
bytes: stat.len,
|
|
entries: listing.entries.length
|
|
});
|
|
}
|
|
```
|
|
|
|
`watch(path, handler, projectId?)` subscribes to public workspace file-change
|
|
events for the given relative path. It is best-effort and bounded: hosts may
|
|
reject it until workspace watching is implemented, and plugins must treat setup
|
|
failure as non-fatal. Plugins should also handle missed events by refreshing
|
|
their own derived state when needed.
|
|
|
|
### Project Structure
|
|
|
|
`queryStructure()` returns a bounded, generic read model so plugins do not each
|
|
need to rescan the whole workspace for common markers:
|
|
|
|
```ts
|
|
const structure = await ctx.services?.workspace.queryStructure({
|
|
maxDepth: 3,
|
|
maxEntries: 500
|
|
});
|
|
|
|
for (const convention of structure?.conventions ?? []) {
|
|
console.log(convention.id, convention.markerPath);
|
|
}
|
|
```
|
|
|
|
The MVP detects generic marker-file conventions such as `package.json`,
|
|
`Cargo.toml`, `pyproject.toml`, `go.mod`, `Makefile` and `.git`. It deliberately
|
|
does not expose language-specific ASTs or Android-specific concepts.
|
|
|
|
Current terminal scope is intentionally minimal: it opens or reattaches a shell
|
|
PTY, writes bytes, resizes, detaches and closes.
|
|
|
|
### Command Tasks
|
|
|
|
Use `ctx.services.tasks.runCommand()` for non-interactive tools that should be
|
|
tracked as IdeA background tasks instead of opening a raw PTY. `command` and
|
|
`args` are passed separately, `cwd` is relative to the project root, and `env`
|
|
adds process environment variables. The current host requires an `ownerAgentId`
|
|
so the task can appear in Work and completion can be correlated to an agent.
|
|
|
|
```ts
|
|
import type { ActivateContext } from "@idea/plugin-sdk";
|
|
|
|
export async function activate(ctx: ActivateContext): Promise<void> {
|
|
const project = await ctx.services?.workspace.getCurrentProject();
|
|
if (!project) return;
|
|
|
|
const task = await ctx.services?.tasks.runCommand({
|
|
projectId: project.id,
|
|
ownerAgentId: "00000000-0000-0000-0000-000000000000",
|
|
label: "Check npm",
|
|
command: "npm",
|
|
args: ["--version"],
|
|
cwd: ".",
|
|
env: { CI: "1" },
|
|
recordOnly: true
|
|
});
|
|
|
|
const status = await ctx.services?.tasks.getCommandStatus(task.taskId);
|
|
ctx.logger.info("command task", {
|
|
taskId: task.taskId,
|
|
state: status?.state,
|
|
exitCode: status?.exitCode
|
|
});
|
|
}
|
|
```
|
|
|
|
`list`, `getStatus`, `attachOutput`, `cancel` and `retry` continue to operate on
|
|
tasks visible through IdeA's Work read model. `getCommandStatus` reads a launched
|
|
command task directly from the host task store.
|
|
|
|
### Toolchain Diagnostics
|
|
|
|
Use `ctx.services.tooling.diagnose()` to check external prerequisites without
|
|
hard-coding one stack into the SDK. A request can probe executables, inspect
|
|
environment variables and validate workspace files in one structured result.
|
|
|
|
```ts
|
|
import type { ActivateContext } from "@idea/plugin-sdk";
|
|
|
|
export async function activate(ctx: ActivateContext): Promise<void> {
|
|
const diagnostic = await ctx.services?.tooling.diagnose({
|
|
tools: [
|
|
{
|
|
id: "node",
|
|
executable: "node",
|
|
versionArgs: ["--version"],
|
|
required: true
|
|
}
|
|
],
|
|
env: [{ name: "PATH", required: true }],
|
|
files: [{ path: "package.json", kind: "file" }]
|
|
});
|
|
|
|
const node = diagnostic?.tools.find((tool) => tool.id === "node");
|
|
ctx.logger.info("tooling diagnostic", {
|
|
ok: diagnostic?.ok,
|
|
nodePresent: node?.present,
|
|
nodeVersion: node?.version,
|
|
messages: diagnostic?.messages
|
|
});
|
|
}
|
|
```
|
|
|
|
The diagnostic API is intentionally generic: it does not install tools, does not
|
|
model Android devices or emulators, and does not expose language-specific ASTs.
|
|
|
|
### Events And Watch
|
|
|
|
Use `ctx.services.events.subscribe()` for stable public host/project events. The
|
|
runtime hides the host polling details and returns a disposable subscription.
|
|
|
|
```ts
|
|
import type { ActivateContext } from "@idea/plugin-sdk";
|
|
|
|
export async function activate(ctx: ActivateContext): Promise<void> {
|
|
const subscription = await ctx.services?.events.subscribe(
|
|
{
|
|
eventTypes: ["backgroundTaskChanged"],
|
|
capacity: 100,
|
|
onDropped: (count) => ctx.logger.warn("plugin events dropped", { count })
|
|
},
|
|
(event) => {
|
|
if (event.type === "backgroundTaskChanged") {
|
|
ctx.logger.info("task changed", {
|
|
taskId: event.taskId,
|
|
state: event.state
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
if (subscription) ctx.subscriptions.push(subscription);
|
|
|
|
const watch = await ctx.services?.workspace.watch("src", (event) => {
|
|
ctx.logger.info("workspace changed", {
|
|
path: event.path,
|
|
kind: event.kind,
|
|
operation: event.operation
|
|
});
|
|
});
|
|
|
|
if (watch) ctx.subscriptions.push(watch);
|
|
}
|
|
```
|
|
|
|
Public event retention is `bestEffortBounded`: events are retained per
|
|
subscription up to the requested/host-capped capacity, drained oldest-first, and
|
|
`onDropped` reports when older retained events were overwritten.
|
|
|
|
### Structured Config Documents
|
|
|
|
Use `ctx.services.config` when a plugin needs to read or update a structured
|
|
project-owned configuration file without reimplementing parsing and
|
|
serialization. Do not use project config documents as the default persistence
|
|
mechanism for plugin-internal state; use `ctx.storage` for that.
|
|
|
|
First-lot format support is deliberately narrow:
|
|
|
|
- `json` only;
|
|
- inferred from `.json` when `format` is omitted;
|
|
- serialized as pretty JSON with a trailing newline;
|
|
- update modes: `mergePatch` and `replace`;
|
|
- `mergePatch` follows JSON merge-patch semantics: object keys are merged
|
|
recursively and `null` removes a key.
|
|
|
|
```ts
|
|
import type { ActivateContext } from "@idea/plugin-sdk";
|
|
|
|
export async function activate(ctx: ActivateContext): Promise<void> {
|
|
const config = await ctx.services?.config.readDocument({
|
|
path: "tooling.config.json"
|
|
});
|
|
|
|
await ctx.services?.config.updateDocument({
|
|
path: "tooling.config.json",
|
|
mode: "mergePatch",
|
|
value: {
|
|
enabled: true,
|
|
lastReadFormat: config?.format ?? "json"
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
YAML, TOML, XML, `.properties` and stack-specific config models are not part of
|
|
this first lot.
|
|
|
|
Declare the additive `tooling` capability to receive `ctx.services` at runtime:
|
|
|
|
```json
|
|
{
|
|
"capabilities": ["ui", "tooling"]
|
|
}
|
|
```
|
|
|
|
## Manifest Validation
|
|
|
|
```ts
|
|
import { validatePluginManifest } from "@idea/plugin-sdk";
|
|
|
|
const result = validatePluginManifest(manifestJson);
|
|
if (!result.success) {
|
|
console.error(result.errors);
|
|
}
|
|
```
|
|
|
|
This validator is deliberately strict for core fields and permissive about future unknown fields.
|
|
It is not a security boundary.
|