# 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. ## 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 { const current = await ctx.storage?.get(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 { 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 { 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: plugins should 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 { 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 { 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 { 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 { 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.