commit c322055edb6812874fe11eea21620479a64e5850 Author: Git Agent Date: Sun Aug 2 16:24:59 2026 +0200 Initial commit: IdeaSDK TypeScript plugin SDK diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..374ce94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +examples/hello-plugin/dist/ +examples/hello-plugin/build/ +*.tsbuildinfo +.DS_Store +coverage/ +npm-debug.log* +*.tgz diff --git a/README.md b/README.md new file mode 100644 index 0000000..40276e8 --- /dev/null +++ b/README.md @@ -0,0 +1,383 @@ +# 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)`; +- 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. + +## 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", + "contributes": {} +} +``` + +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. + +## 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`. + +```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(".ideai/hello-plugin.txt", "hello\n", project.id); + + const file = await workspace.readTextFile(".ideai/hello-plugin.txt", project.id); + const listing = await workspace.listDirectory(".ideai", 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 +configuration file without reimplementing parsing and serialization. + +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: ".ideai/hello-plugin.json" + }); + + await ctx.services?.config.updateDocument({ + path: ".ideai/hello-plugin.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. diff --git a/examples/hello-plugin/README.md b/examples/hello-plugin/README.md new file mode 100644 index 0000000..0b53e1a --- /dev/null +++ b/examples/hello-plugin/README.md @@ -0,0 +1,33 @@ +# Hello Plugin + +Installable IdeA plugin example rebuilt from the public SDK types. + +It exercises the current plugin primitives end to end: + +- top-level menu: `Hello Plugin`; +- menu entry: `hello-plugin`; +- command: `hello-plugin`, returning `hello-world`; +- layout contribution: `hello-plugin.hello-world`, rendered as `hello-world`. +- tooling capability: logs the focused workspace project when `ctx.services` is available. + +```sh +npm run typecheck:examples +npm run package:hello-plugin +``` + +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 ESM entrypoint at +`dist/index.js`, matching the manifest `main` field. + +## Diagnostics + +During activation the plugin logs: + +- whether the command and layout runtime registries are available; +- successful registration of the `hello-plugin` command; +- successful registration of the `hello-plugin.hello-world` layout; +- availability of the workspace service from the `tooling` runtime capability; +- the first layout render, including project/node identifiers. + +These messages are intentionally small and stable so installation, bundle import, activation and +layout rendering failures can be separated quickly in IdeA logs/devtools. diff --git a/examples/hello-plugin/idea-plugin.json b/examples/hello-plugin/idea-plugin.json new file mode 100644 index 0000000..cfc7dd7 --- /dev/null +++ b/examples/hello-plugin/idea-plugin.json @@ -0,0 +1,44 @@ +{ + "ideaPluginManifestVersion": 1, + "id": "com.example.hello-plugin", + "displayName": "Hello Plugin", + "publisher": "IdeA Examples", + "version": "0.1.0", + "description": "SDK example plugin for validating command, menu and layout loading.", + "main": "dist/index.js", + "engines": { + "idea": ">=0.1.0" + }, + "trustLevel": "full", + "capabilities": [ + "ui", + "tooling" + ], + "contributes": { + "menus": [ + { + "id": "hello-plugin.menu", + "label": "Hello Plugin", + "topLevel": true, + "order": 100 + } + ], + "menuItems": [ + { + "id": "hello-plugin.command.item", + "targetMenuId": "hello-plugin.menu", + "label": "hello-plugin", + "command": "hello-plugin", + "order": 10 + } + ], + "layouts": [ + { + "type": "hello-plugin.hello-world", + "label": "hello-world", + "component": "hello-world", + "order": 10 + } + ] + } +} diff --git a/examples/hello-plugin/package-lock.json b/examples/hello-plugin/package-lock.json new file mode 100644 index 0000000..9935509 --- /dev/null +++ b/examples/hello-plugin/package-lock.json @@ -0,0 +1,27 @@ +{ + "name": "hello-plugin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hello-plugin", + "version": "0.1.0", + "dependencies": { + "@idea/plugin-sdk": "file:../.." + } + }, + "../..": { + "name": "@idea/plugin-sdk", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "typescript": "^5.5.0" + } + }, + "node_modules/@idea/plugin-sdk": { + "resolved": "../..", + "link": true + } + } +} diff --git a/examples/hello-plugin/package.json b/examples/hello-plugin/package.json new file mode 100644 index 0000000..937f370 --- /dev/null +++ b/examples/hello-plugin/package.json @@ -0,0 +1,14 @@ +{ + "name": "hello-plugin", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@idea/plugin-sdk": "file:../.." + } +} + diff --git a/examples/hello-plugin/src/index.ts b/examples/hello-plugin/src/index.ts new file mode 100644 index 0000000..e9ab78f --- /dev/null +++ b/examples/hello-plugin/src/index.ts @@ -0,0 +1,186 @@ +import type { ActivateContext, IdeAPluginModule, PluginLayoutProps } from "@idea/plugin-sdk"; + +const COMMAND_ID = "hello-plugin"; +const LAYOUT_TYPE = "hello-plugin.hello-world"; + +let hasLoggedFirstLayoutRender = false; + +function HelloWorldLayout(props: PluginLayoutProps): string { + if (!hasLoggedFirstLayoutRender) { + hasLoggedFirstLayoutRender = true; + console.info("[hello-plugin] layout first render", { + projectId: props.projectId, + nodeId: props.nodeId, + layoutType: props.layoutType, + hasState: props.state !== undefined + }); + } + + return "hello-world"; +} + +export function activate(ctx: ActivateContext): void { + ctx.logger.info("activating hello-plugin", { + pluginId: ctx.pluginId, + hasCommands: Boolean(ctx.commands), + hasLayouts: Boolean(ctx.layouts) + }); + + const commandDisposable = ctx.commands?.registerCommand(COMMAND_ID, () => { + ctx.logger.info("command executed", { commandId: COMMAND_ID }); + return "hello-world"; + }); + + if (commandDisposable) { + ctx.subscriptions.push(commandDisposable); + ctx.logger.info("command registered", { commandId: COMMAND_ID }); + } else { + ctx.logger.warn("command registry unavailable", { commandId: COMMAND_ID }); + } + + const layoutDisposable = ctx.layouts?.register({ + type: LAYOUT_TYPE, + component: HelloWorldLayout + }); + + if (layoutDisposable) { + ctx.subscriptions.push(layoutDisposable); + ctx.logger.info("layout registered", { + layoutType: LAYOUT_TYPE, + component: "hello-world" + }); + } else { + ctx.logger.warn("layout registry unavailable", { layoutType: LAYOUT_TYPE }); + } + + void useWorkspaceSdk(ctx); +} + +async function useWorkspaceSdk(ctx: ActivateContext): Promise { + const workspace = ctx.services?.workspace; + if (!workspace) return; + + const project = await workspace.getCurrentProject(); + if (!project) { + ctx.logger.info("workspace service available without a focused project"); + return; + } + + const fixturePath = ".ideai/hello-plugin.txt"; + await workspace.writeTextFile(fixturePath, "hello from @idea/plugin-sdk\n", project.id); + const file = await workspace.readTextFile(fixturePath, project.id); + const stat = await workspace.stat(fixturePath, project.id); + const listing = await workspace.listDirectory(".ideai", project.id); + const structure = await workspace.queryStructure({ + projectId: project.id, + maxDepth: 2, + maxEntries: 100 + }); + + ctx.logger.info("workspace file round-trip complete", { + projectId: project.id, + path: file.path, + bytes: stat.len, + ideaiEntries: listing.entries.length, + conventions: structure.conventions.map((convention) => convention.id) + }); + + const diagnostic = await ctx.services?.tooling.diagnose({ + projectId: project.id, + tools: [ + { + id: "echo", + executable: "echo", + versionArgs: ["hello-plugin-toolcheck"], + required: true + } + ], + env: [{ name: "PATH", required: true }], + files: [{ path: fixturePath, kind: "file" }] + }); + + ctx.logger.info("tooling diagnostic complete", { + ok: diagnostic?.ok, + echoVersion: diagnostic?.tools.find((tool) => tool.id === "echo")?.version, + messages: diagnostic?.messages + }); + + const configPath = ".ideai/hello-plugin.json"; + await workspace.writeTextFile( + configPath, + JSON.stringify({ enabled: true, launches: 0 }, null, 2) + "\n", + project.id + ); + const configDocument = await ctx.services?.config.readDocument({ + projectId: project.id, + path: configPath + }); + await ctx.services?.config.updateDocument({ + projectId: project.id, + path: configPath, + mode: "mergePatch", + value: { lastFormat: configDocument?.format ?? "json", launches: 1 } + }); + ctx.logger.info("config document updated", { + path: configDocument?.path, + format: configDocument?.format + }); + + const watch = await workspace.watch(".ideai", (event) => { + ctx.logger.info("workspace watch event", { + path: event.path, + kind: event.kind, + operation: event.operation + }); + }, project.id); + ctx.subscriptions.push(watch); + + const events = await ctx.services?.events.subscribe( + { + projectId: project.id, + eventTypes: ["backgroundTaskChanged"], + pollIntervalMs: 2000, + onDropped: (count) => ctx.logger.warn("plugin events dropped", { count }) + }, + (event) => { + if (event.type === "backgroundTaskChanged") { + ctx.logger.info("background task changed", { + taskId: event.taskId, + state: event.state + }); + } + } + ); + if (events) ctx.subscriptions.push(events); + + const ownerAgentId = await ctx.storage?.get("helloPlugin.ownerAgentId"); + if (!ownerAgentId) { + ctx.logger.info("command task example skipped: no owner agent configured"); + return; + } + + const task = await ctx.services?.tasks.runCommand({ + projectId: project.id, + ownerAgentId, + label: "Hello plugin command", + command: "echo", + args: ["hello from @idea/plugin-sdk"], + cwd: ".", + recordOnly: true + }); + + if (task) { + const status = await ctx.services?.tasks.getCommandStatus(task.taskId); + ctx.logger.info("command task launched", { + taskId: task.taskId, + state: status?.state ?? task.state, + exitCode: status?.exitCode ?? task.exitCode + }); + } +} + +const plugin: IdeAPluginModule = { + activate +}; + +export default plugin; diff --git a/examples/hello-plugin/tsconfig.build.json b/examples/hello-plugin/tsconfig.build.json new file mode 100644 index 0000000..9121acc --- /dev/null +++ b/examples/hello-plugin/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "sourceMap": false, + "paths": { + "@idea/plugin-sdk": [ + "../../dist/index.d.ts" + ] + } + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/examples/hello-plugin/tsconfig.json b/examples/hello-plugin/tsconfig.json new file mode 100644 index 0000000..27c3a9e --- /dev/null +++ b/examples/hello-plugin/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "noEmit": true, + "rootDir": "../..", + "paths": { + "@idea/plugin-sdk": [ + "../../src/index.ts" + ] + } + }, + "include": [ + "src/**/*.ts", + "../../src/**/*.ts" + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ffbe024 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "@idea/plugin-sdk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@idea/plugin-sdk", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "typescript": "^5.5.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b6a16b1 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "@idea/plugin-sdk", + "version": "0.1.0", + "description": "Minimal public TypeScript SDK for IdeA plugins.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.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", + "typecheck:examples": "tsc -p examples/hello-plugin/tsconfig.json --noEmit", + "check": "npm run build && npm run typecheck:examples && npm run package:hello-plugin" + }, + "keywords": [ + "idea", + "plugins", + "sdk" + ], + "license": "MIT", + "devDependencies": { + "typescript": "^5.5.0" + } +} diff --git a/scripts/package-hello-plugin.mjs b/scripts/package-hello-plugin.mjs new file mode 100644 index 0000000..3c8de90 --- /dev/null +++ b/scripts/package-hello-plugin.mjs @@ -0,0 +1,124 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +const pluginRoot = join(process.cwd(), "examples", "hello-plugin"); +const manifestPath = join(pluginRoot, "idea-plugin.json"); +const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + +const main = requireString(manifest, "main"); +const version = requireString(manifest, "version"); +const archivePath = join(pluginRoot, "build", `hello-plugin-${version}.zip`); +const archiveEntries = [ + { archivePath: "idea-plugin.json", sourcePath: manifestPath }, + { archivePath: main, sourcePath: join(pluginRoot, main) }, + { archivePath: "README.md", sourcePath: join(pluginRoot, "README.md") } +]; +const DOS_TIME_MIDNIGHT = 0; +const DOS_DATE_1980_01_01 = 33; +const CRC32_TABLE = Array.from({ length: 256 }, (_, index) => { + let value = index; + + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + + return value >>> 0; +}); + +await rm(join(pluginRoot, "build"), { recursive: true, force: true }); +await mkdir(dirname(archivePath), { recursive: true }); + +const files = []; +for (const entry of archiveEntries) { + if (entry.archivePath.startsWith("/") || entry.archivePath.includes("..")) { + throw new Error(`Refusing unsafe archive path: ${entry.archivePath}`); + } + + files.push({ + archivePath: entry.archivePath, + data: await readFile(entry.sourcePath) + }); +} + +await writeFile(archivePath, createZip(files)); +console.log(`created ${archivePath}`); + +function requireString(record, key) { + if (typeof record[key] !== "string" || record[key].trim().length === 0) { + throw new Error(`idea-plugin.json field "${key}" must be a non-empty string`); + } + + return record[key]; +} + +function createZip(files) { + const localFileHeaders = []; + const centralDirectoryHeaders = []; + let offset = 0; + + for (const file of files) { + const filename = Buffer.from(file.archivePath, "utf8"); + const checksum = crc32(file.data); + const localFileHeader = Buffer.alloc(30); + + localFileHeader.writeUInt32LE(0x04034b50, 0); + localFileHeader.writeUInt16LE(20, 4); + localFileHeader.writeUInt16LE(0x0800, 6); + localFileHeader.writeUInt16LE(0, 8); + localFileHeader.writeUInt16LE(DOS_TIME_MIDNIGHT, 10); + localFileHeader.writeUInt16LE(DOS_DATE_1980_01_01, 12); + localFileHeader.writeUInt32LE(checksum, 14); + localFileHeader.writeUInt32LE(file.data.length, 18); + localFileHeader.writeUInt32LE(file.data.length, 22); + localFileHeader.writeUInt16LE(filename.length, 26); + localFileHeader.writeUInt16LE(0, 28); + + localFileHeaders.push(localFileHeader, filename, file.data); + + const centralDirectoryHeader = Buffer.alloc(46); + centralDirectoryHeader.writeUInt32LE(0x02014b50, 0); + centralDirectoryHeader.writeUInt16LE(20, 4); + centralDirectoryHeader.writeUInt16LE(20, 6); + centralDirectoryHeader.writeUInt16LE(0x0800, 8); + centralDirectoryHeader.writeUInt16LE(0, 10); + centralDirectoryHeader.writeUInt16LE(DOS_TIME_MIDNIGHT, 12); + centralDirectoryHeader.writeUInt16LE(DOS_DATE_1980_01_01, 14); + centralDirectoryHeader.writeUInt32LE(checksum, 16); + centralDirectoryHeader.writeUInt32LE(file.data.length, 20); + centralDirectoryHeader.writeUInt32LE(file.data.length, 24); + centralDirectoryHeader.writeUInt16LE(filename.length, 28); + centralDirectoryHeader.writeUInt16LE(0, 30); + centralDirectoryHeader.writeUInt16LE(0, 32); + centralDirectoryHeader.writeUInt16LE(0, 34); + centralDirectoryHeader.writeUInt16LE(0, 36); + centralDirectoryHeader.writeUInt32LE(0, 38); + centralDirectoryHeader.writeUInt32LE(offset, 42); + + centralDirectoryHeaders.push(centralDirectoryHeader, filename); + offset += localFileHeader.length + filename.length + file.data.length; + } + + const centralDirectory = Buffer.concat(centralDirectoryHeaders); + const endOfCentralDirectory = Buffer.alloc(22); + + endOfCentralDirectory.writeUInt32LE(0x06054b50, 0); + endOfCentralDirectory.writeUInt16LE(0, 4); + endOfCentralDirectory.writeUInt16LE(0, 6); + endOfCentralDirectory.writeUInt16LE(files.length, 8); + endOfCentralDirectory.writeUInt16LE(files.length, 10); + endOfCentralDirectory.writeUInt32LE(centralDirectory.length, 12); + endOfCentralDirectory.writeUInt32LE(offset, 16); + endOfCentralDirectory.writeUInt16LE(0, 20); + + return Buffer.concat([...localFileHeaders, centralDirectory, endOfCentralDirectory]); +} + +function crc32(data) { + let value = 0xffffffff; + + for (const byte of data) { + value = (value >>> 8) ^ CRC32_TABLE[(value ^ byte) & 0xff]; + } + + return (value ^ 0xffffffff) >>> 0; +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..450bf53 --- /dev/null +++ b/src/index.js @@ -0,0 +1 @@ +export { isPluginManifest, assertPluginManifest, validatePluginManifest } from "./manifest.js"; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3defaa2 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,85 @@ +export type { + IdeAPluginCapability, + IdeAPluginManifest, + IdeAPluginEngineConstraints, + IdeAPluginLayoutContribution, + IdeAPluginMcpServerContribution, + IdeAPluginMenuItemContribution, + IdeAPluginTopLevelMenuContribution +} from "./manifest.js"; +export { + isPluginManifest, + assertPluginManifest, + validatePluginManifest +} from "./manifest.js"; +export type { + ActivateContext, + BackgroundTaskChangedEvent, + CommandDisposable, + CommandHandler, + CommandRegistry, + CommandTaskStatus, + ConfigDocument, + ConfigDocumentFormat, + ConfigDocumentReadOptions, + ConfigDocumentService, + ConfigDocumentUpdateOptions, + ConfigDocumentWriteResult, + ConfigUpdateMode, + DiagnosticMessage, + EnvDiagnostic, + EnvRequirement, + EventHandler, + EventService, + EventSubscribeOptions, + EventSubscription, + FileDiagnostic, + FileRequirement, + BackgroundTaskOutputAttachment, + BackgroundTaskRetryResult, + BackgroundTaskService, + BackgroundTaskStatus, + IdeAPluginModule, + JsonValue, + LayoutRegistry, + PluginLogger, + PluginLayoutAvailability, + PluginLayoutComponent, + PluginLayoutDefinition, + PluginLayoutProps, + PluginLayoutRenderResult, + PluginLayoutState, + PluginServices, + PluginStorage, + ProjectConvention, + ProjectModule, + ProjectStructure, + ProjectStructureEntry, + ProjectStructureEntryKind, + PublicEvent, + PublicEventType, + RunCommandTaskOptions, + TerminalOpenOptions, + TerminalReattachOptions, + TerminalReattachResult, + TerminalService, + TerminalSession, + ToolchainDiagnostic, + ToolchainDiagnosticRequest, + ToolDiagnostic, + ToolingService, + ToolRequirement, + WorkspaceBinaryFile, + WorkspaceDirEntry, + WorkspaceDirectoryListing, + WorkspaceFileChangedEvent, + WorkspaceProject, + WorkspaceResolvedPath, + WorkspaceService, + WorkspaceStat, + WorkspaceStructureQuery, + WorkspaceTextFile, + WorkspaceWatch, + WorkspaceWatchEvent, + WorkspaceWatchHandler +} from "./runtime.js"; diff --git a/src/manifest.js b/src/manifest.js new file mode 100644 index 0000000..fc8cd50 --- /dev/null +++ b/src/manifest.js @@ -0,0 +1,172 @@ +const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/; +const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; +export function validatePluginManifest(input) { + const errors = []; + if (!isRecord(input)) { + return { success: false, errors: ["manifest must be an object"] }; + } + if (input.ideaPluginManifestVersion !== 1) { + errors.push("ideaPluginManifestVersion must be 1"); + } + requireString(input, "id", errors); + requireString(input, "displayName", errors); + requireString(input, "version", errors); + requireString(input, "main", errors); + if (input.trustLevel !== "full") { + errors.push("trustLevel must be full"); + } + optionalString(input, "description", errors); + optionalString(input, "publisher", errors); + if (typeof input.id === "string" && !PLUGIN_ID_PATTERN.test(input.id)) { + errors.push("id must contain lowercase letters, digits, dots or dashes, and start/end with an alphanumeric character"); + } + if (typeof input.version === "string" && !SEMVER_PATTERN.test(input.version)) { + errors.push("version must use semver syntax, for example 0.1.0"); + } + validateEngines(input.engines, errors); + validateCapabilities(input.capabilities, errors); + validateContributes(input.contributes, errors); + if (errors.length > 0) { + return { success: false, errors }; + } + return { success: true, data: input, errors: [] }; +} +export function isPluginManifest(input) { + return validatePluginManifest(input).success; +} +export function assertPluginManifest(input) { + const result = validatePluginManifest(input); + if (!result.success) { + throw new Error(`Invalid IdeA plugin manifest: ${result.errors.join("; ")}`); + } +} +function validateEngines(value, errors) { + if (value === undefined) { + return; + } + if (!isRecord(value)) { + errors.push("engines must be an object when provided"); + return; + } + optionalString(value, "idea", errors, "engines.idea"); +} +function validateCapabilities(value, errors) { + if (value === undefined) { + return; + } + if (!Array.isArray(value)) { + errors.push("capabilities must be an array when provided"); + return; + } + value.forEach((capability, index) => { + if (capability !== "ui" && capability !== "mcp" && capability !== "tooling") { + errors.push(`capabilities[${index}] must be "ui", "mcp" or "tooling"`); + } + }); +} +function validateContributes(value, errors) { + if (value === undefined) { + return; + } + if (!isRecord(value)) { + errors.push("contributes must be an object when provided"); + return; + } + validateArray(value, "menus", errors, (menu, index) => { + requireString(menu, "id", errors, `contributes.menus[${index}].id`); + requireString(menu, "label", errors, `contributes.menus[${index}].label`); + if (menu.topLevel !== true) { + errors.push(`contributes.menus[${index}].topLevel must be true`); + } + optionalNumber(menu, "order", errors, `contributes.menus[${index}].order`); + optionalString(menu, "icon", errors, `contributes.menus[${index}].icon`); + }); + validateArray(value, "menuItems", errors, (item, index) => { + requireString(item, "id", errors, `contributes.menuItems[${index}].id`); + requireString(item, "targetMenuId", errors, `contributes.menuItems[${index}].targetMenuId`); + requireString(item, "label", errors, `contributes.menuItems[${index}].label`); + requireString(item, "command", errors, `contributes.menuItems[${index}].command`); + optionalNumber(item, "order", errors, `contributes.menuItems[${index}].order`); + optionalString(item, "icon", errors, `contributes.menuItems[${index}].icon`); + optionalString(item, "when", errors, `contributes.menuItems[${index}].when`); + }); + validateArray(value, "layouts", errors, (layout, index) => { + requireString(layout, "type", errors, `contributes.layouts[${index}].type`); + requireString(layout, "label", errors, `contributes.layouts[${index}].label`); + requireString(layout, "component", errors, `contributes.layouts[${index}].component`); + optionalNumber(layout, "order", errors, `contributes.layouts[${index}].order`); + optionalString(layout, "icon", errors, `contributes.layouts[${index}].icon`); + optionalString(layout, "when", errors, `contributes.layouts[${index}].when`); + }); + validateArray(value, "mcpServers", errors, (server, index) => { + requireString(server, "id", errors, `contributes.mcpServers[${index}].id`); + requireString(server, "displayName", errors, `contributes.mcpServers[${index}].displayName`); + requireString(server, "command", errors, `contributes.mcpServers[${index}].command`); + if (server.transport !== "stdio") { + errors.push(`contributes.mcpServers[${index}].transport must be "stdio"`); + } + optionalStringArray(server, "args", errors, `contributes.mcpServers[${index}].args`); + optionalStringRecord(server, "env", errors, `contributes.mcpServers[${index}].env`); + optionalString(server, "cwd", errors, `contributes.mcpServers[${index}].cwd`); + optionalBoolean(server, "autoStart", errors, `contributes.mcpServers[${index}].autoStart`); + optionalBoolean(server, "allowAbsoluteCommand", errors, `contributes.mcpServers[${index}].allowAbsoluteCommand`); + }); +} +function validateArray(record, key, errors, validateItem) { + const value = record[key]; + if (value === undefined) { + return; + } + if (!Array.isArray(value)) { + errors.push(`contributes.${key} must be an array when provided`); + return; + } + value.forEach((item, index) => { + if (!isRecord(item)) { + errors.push(`contributes.${key}[${index}] must be an object`); + return; + } + validateItem(item, index); + }); +} +function requireString(record, key, errors, label = key) { + if (typeof record[key] !== "string" || record[key].trim().length === 0) { + errors.push(`${label} must be a non-empty string`); + } +} +function optionalString(record, key, errors, label = key) { + if (record[key] !== undefined && typeof record[key] !== "string") { + errors.push(`${label} must be a string when provided`); + } +} +function optionalNumber(record, key, errors, label = key) { + if (record[key] !== undefined && typeof record[key] !== "number") { + errors.push(`${label} must be a number when provided`); + } +} +function optionalBoolean(record, key, errors, label = key) { + if (record[key] !== undefined && typeof record[key] !== "boolean") { + errors.push(`${label} must be a boolean when provided`); + } +} +function optionalStringArray(record, key, errors, label = key) { + const value = record[key]; + if (value === undefined) { + return; + } + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + errors.push(`${label} must be an array of strings when provided`); + } +} +function optionalStringRecord(record, key, errors, label = key) { + const value = record[key]; + if (value === undefined) { + return; + } + if (!isRecord(value) || Object.values(value).some((item) => typeof item !== "string")) { + errors.push(`${label} must be an object of strings when provided`); + } +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/manifest.ts b/src/manifest.ts new file mode 100644 index 0000000..3b33509 --- /dev/null +++ b/src/manifest.ts @@ -0,0 +1,309 @@ +export interface IdeAPluginManifest { + ideaPluginManifestVersion: 1; + id: string; + displayName: string; + version: string; + main: string; + trustLevel: "full"; + description?: string; + publisher?: string; + engines?: IdeAPluginEngineConstraints; + capabilities?: IdeAPluginCapability[]; + contributes?: { + menus?: IdeAPluginTopLevelMenuContribution[]; + menuItems?: IdeAPluginMenuItemContribution[]; + layouts?: IdeAPluginLayoutContribution[]; + mcpServers?: IdeAPluginMcpServerContribution[]; + }; +} + +export type IdeAPluginCapability = "ui" | "mcp" | "tooling"; + +export interface IdeAPluginEngineConstraints { + idea?: string; +} + +export interface IdeAPluginTopLevelMenuContribution { + id: string; + label: string; + topLevel: true; + order?: number; + icon?: string; +} + +export interface IdeAPluginMenuItemContribution { + id: string; + targetMenuId: string; + label: string; + command: string; + order?: number; + icon?: string; + when?: string; +} + +export interface IdeAPluginLayoutContribution { + type: string; + label: string; + component: string; + order?: number; + icon?: string; + when?: string; +} + +export interface IdeAPluginMcpServerContribution { + id: string; + displayName: string; + command: string; + args?: string[]; + env?: Record; + cwd?: string; + transport: "stdio"; + autoStart?: boolean; + allowAbsoluteCommand?: boolean; +} + +export type PluginManifestValidationResult = + | { success: true; data: IdeAPluginManifest; errors: [] } + | { success: false; data?: undefined; errors: string[] }; + +const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/; +const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; + +export function validatePluginManifest(input: unknown): PluginManifestValidationResult { + const errors: string[] = []; + + if (!isRecord(input)) { + return { success: false, errors: ["manifest must be an object"] }; + } + + if (input.ideaPluginManifestVersion !== 1) { + errors.push("ideaPluginManifestVersion must be 1"); + } + + requireString(input, "id", errors); + requireString(input, "displayName", errors); + requireString(input, "version", errors); + requireString(input, "main", errors); + if (input.trustLevel !== "full") { + errors.push("trustLevel must be full"); + } + optionalString(input, "description", errors); + optionalString(input, "publisher", errors); + + if (typeof input.id === "string" && !PLUGIN_ID_PATTERN.test(input.id)) { + errors.push("id must contain lowercase letters, digits, dots or dashes, and start/end with an alphanumeric character"); + } + + if (typeof input.version === "string" && !SEMVER_PATTERN.test(input.version)) { + errors.push("version must use semver syntax, for example 0.1.0"); + } + + validateEngines(input.engines, errors); + validateCapabilities(input.capabilities, errors); + validateContributes(input.contributes, errors); + + if (errors.length > 0) { + return { success: false, errors }; + } + + return { success: true, data: input as unknown as IdeAPluginManifest, errors: [] }; +} + +export function isPluginManifest(input: unknown): input is IdeAPluginManifest { + return validatePluginManifest(input).success; +} + +export function assertPluginManifest(input: unknown): asserts input is IdeAPluginManifest { + const result = validatePluginManifest(input); + + if (!result.success) { + throw new Error(`Invalid IdeA plugin manifest: ${result.errors.join("; ")}`); + } +} + +function validateEngines(value: unknown, errors: string[]): void { + if (value === undefined) { + return; + } + + if (!isRecord(value)) { + errors.push("engines must be an object when provided"); + return; + } + + optionalString(value, "idea", errors, "engines.idea"); +} + +function validateCapabilities(value: unknown, errors: string[]): void { + if (value === undefined) { + return; + } + + if (!Array.isArray(value)) { + errors.push("capabilities must be an array when provided"); + return; + } + + value.forEach((capability, index) => { + if (capability !== "ui" && capability !== "mcp" && capability !== "tooling") { + errors.push(`capabilities[${index}] must be "ui", "mcp" or "tooling"`); + } + }); +} + +function validateContributes(value: unknown, errors: string[]): void { + if (value === undefined) { + return; + } + + if (!isRecord(value)) { + errors.push("contributes must be an object when provided"); + return; + } + + validateArray(value, "menus", errors, (menu, index) => { + requireString(menu, "id", errors, `contributes.menus[${index}].id`); + requireString(menu, "label", errors, `contributes.menus[${index}].label`); + if (menu.topLevel !== true) { + errors.push(`contributes.menus[${index}].topLevel must be true`); + } + optionalNumber(menu, "order", errors, `contributes.menus[${index}].order`); + optionalString(menu, "icon", errors, `contributes.menus[${index}].icon`); + }); + validateArray(value, "menuItems", errors, (item, index) => { + requireString(item, "id", errors, `contributes.menuItems[${index}].id`); + requireString(item, "targetMenuId", errors, `contributes.menuItems[${index}].targetMenuId`); + requireString(item, "label", errors, `contributes.menuItems[${index}].label`); + requireString(item, "command", errors, `contributes.menuItems[${index}].command`); + optionalNumber(item, "order", errors, `contributes.menuItems[${index}].order`); + optionalString(item, "icon", errors, `contributes.menuItems[${index}].icon`); + optionalString(item, "when", errors, `contributes.menuItems[${index}].when`); + }); + validateArray(value, "layouts", errors, (layout, index) => { + requireString(layout, "type", errors, `contributes.layouts[${index}].type`); + requireString(layout, "label", errors, `contributes.layouts[${index}].label`); + requireString(layout, "component", errors, `contributes.layouts[${index}].component`); + optionalNumber(layout, "order", errors, `contributes.layouts[${index}].order`); + optionalString(layout, "icon", errors, `contributes.layouts[${index}].icon`); + optionalString(layout, "when", errors, `contributes.layouts[${index}].when`); + }); + validateArray(value, "mcpServers", errors, (server, index) => { + requireString(server, "id", errors, `contributes.mcpServers[${index}].id`); + requireString(server, "displayName", errors, `contributes.mcpServers[${index}].displayName`); + requireString(server, "command", errors, `contributes.mcpServers[${index}].command`); + if (server.transport !== "stdio") { + errors.push(`contributes.mcpServers[${index}].transport must be "stdio"`); + } + optionalStringArray(server, "args", errors, `contributes.mcpServers[${index}].args`); + optionalStringRecord(server, "env", errors, `contributes.mcpServers[${index}].env`); + optionalString(server, "cwd", errors, `contributes.mcpServers[${index}].cwd`); + optionalBoolean(server, "autoStart", errors, `contributes.mcpServers[${index}].autoStart`); + optionalBoolean(server, "allowAbsoluteCommand", errors, `contributes.mcpServers[${index}].allowAbsoluteCommand`); + }); +} + +function validateArray( + record: Record, + key: string, + errors: string[], + validateItem: (item: Record, index: number) => void +): void { + const value = record[key]; + if (value === undefined) { + return; + } + + if (!Array.isArray(value)) { + errors.push(`contributes.${key} must be an array when provided`); + return; + } + + value.forEach((item, index) => { + if (!isRecord(item)) { + errors.push(`contributes.${key}[${index}] must be an object`); + return; + } + + validateItem(item, index); + }); +} + +function requireString( + record: Record, + key: string, + errors: string[], + label = key +): void { + if (typeof record[key] !== "string" || record[key].trim().length === 0) { + errors.push(`${label} must be a non-empty string`); + } +} + +function optionalString( + record: Record, + key: string, + errors: string[], + label = key +): void { + if (record[key] !== undefined && typeof record[key] !== "string") { + errors.push(`${label} must be a string when provided`); + } +} + +function optionalNumber( + record: Record, + key: string, + errors: string[], + label = key +): void { + if (record[key] !== undefined && typeof record[key] !== "number") { + errors.push(`${label} must be a number when provided`); + } +} + +function optionalBoolean( + record: Record, + key: string, + errors: string[], + label = key +): void { + if (record[key] !== undefined && typeof record[key] !== "boolean") { + errors.push(`${label} must be a boolean when provided`); + } +} + +function optionalStringArray( + record: Record, + key: string, + errors: string[], + label = key +): void { + const value = record[key]; + if (value === undefined) { + return; + } + + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + errors.push(`${label} must be an array of strings when provided`); + } +} + +function optionalStringRecord( + record: Record, + key: string, + errors: string[], + label = key +): void { + const value = record[key]; + if (value === undefined) { + return; + } + + if (!isRecord(value) || Object.values(value).some((item) => typeof item !== "string")) { + errors.push(`${label} must be an object of strings when provided`); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/runtime.js b/src/runtime.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/runtime.js @@ -0,0 +1 @@ +export {}; diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..f5edfa3 --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,532 @@ +export interface ActivateContext { + pluginId: string; + logger: PluginLogger; + subscriptions: CommandDisposable[]; + commands?: CommandRegistry; + layouts?: LayoutRegistry; + storage?: PluginStorage; + /** + * Stable public service facade for plugins that need workspace, background + * task, or terminal operations. This intentionally does not expose IdeA's + * internal runtime/gateway objects. + */ + services?: PluginServices; +} + +export interface IdeAPluginModule { + activate(ctx: ActivateContext): void | Promise; + deactivate?(): void | Promise; +} + +export interface PluginLogger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; +} + +export type CommandHandler = (...args: unknown[]) => unknown | Promise; + +export interface CommandRegistry { + registerCommand(commandId: string, handler: CommandHandler): CommandDisposable; +} + +export interface CommandDisposable { + dispose(): void; +} + +export interface PluginStorage { + get(key: string): Promise; + set(key: string, value: T): Promise; + delete(key: string): Promise; +} + +export type PluginLayoutState = JsonValue | undefined; +export type PluginLayoutAvailability = "available"; +export type PluginLayoutRenderResult = unknown; + +export interface PluginLayoutProps { + /** Project currently hosting this layout cell. */ + projectId: string; + /** Stable layout node id for this cell instance. */ + nodeId: string; + /** Layout contribution type declared in `idea-plugin.json`. */ + layoutType: string; + /** Opaque JSON-serializable state persisted by the host for this cell. */ + state: TState; + /** Replaces the opaque state for this cell. Values must be JSON-serializable. */ + setState(next: TState): void; + /** Present layouts are only mounted when available; fallback UI is host-owned. */ + availability: PluginLayoutAvailability; +} + +export type PluginLayoutComponent = ( + props: PluginLayoutProps, +) => PluginLayoutRenderResult; + +export interface PluginLayoutDefinition { + /** Must match a layout `type` declared in this plugin's manifest. */ + type: string; + component: PluginLayoutComponent; +} + +export interface LayoutRegistry { + register( + definition: PluginLayoutDefinition, + ): CommandDisposable; +} + +export interface PluginServices { + workspace: WorkspaceService; + tasks: BackgroundTaskService; + tooling: ToolingService; + events: EventService; + config: ConfigDocumentService; + terminal: TerminalService; +} + +export interface WorkspaceProject { + id: string; + name: string; + root: string; +} + +export interface WorkspaceService { + /** Returns the currently focused project, or null when no project is active. */ + getCurrentProject(): Promise; + /** Returns the root path for the given project or for the current project. */ + getProjectRoot(projectId?: string): Promise; + /** Reads IdeA's shared project context for the given or current project. */ + readProjectContext(projectId?: string): Promise; + /** Updates IdeA's shared project context for the given or current project. */ + updateProjectContext(content: string, projectId?: string): Promise; + /** + * Resolves and normalizes a plugin-visible path under the project root. + * Rejects absolute paths, `..`, empty segments and other paths the host + * considers outside the workspace sandbox. + */ + resolvePath(path: string, projectId?: string): Promise; + /** Reads a UTF-8 text file under the project root. */ + readTextFile(path: string, projectId?: string): Promise; + /** Reads raw bytes from a file under the project root. */ + readBinaryFile(path: string, projectId?: string): Promise; + /** Writes UTF-8 text under the project root using the host's controlled write path. */ + writeTextFile(path: string, content: string, projectId?: string): Promise; + /** Writes raw bytes under the project root using the host's controlled write path. */ + writeBinaryFile(path: string, bytes: Uint8Array, projectId?: string): Promise; + /** Lists one directory under the project root. Defaults to the workspace root. */ + listDirectory(path?: string, projectId?: string): Promise; + /** + * Returns basic metadata. Missing paths resolve to `{ exists: false }`; invalid + * paths and permission errors reject. + */ + stat(path: string, projectId?: string): Promise; + /** + * Extension point for host file watching. The MVP SDK reserves the public + * shape; hosts may reject with a clear not-implemented error until #127 lands. + */ + watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise; + /** Queries a bounded, generic project structure read model. */ + queryStructure(query?: WorkspaceStructureQuery): Promise; +} + +export interface WorkspaceResolvedPath { + projectId: string; + root: string; + path: string; +} + +export interface WorkspaceTextFile { + path: string; + content: string; +} + +export interface WorkspaceBinaryFile { + path: string; + bytes: Uint8Array; +} + +export interface WorkspaceDirEntry { + name: string; + path: string; + isDir: boolean; +} + +export interface WorkspaceDirectoryListing { + path: string; + entries: WorkspaceDirEntry[]; +} + +export interface WorkspaceStat { + path: string; + exists: boolean; + isFile: boolean; + isDir: boolean; + len: number | null; +} + +export interface WorkspaceWatchEvent { + path: string; + kind: "created" | "modified" | "deleted" | "renamed" | "unknown"; + operation: string; + projectId: string; +} + +export type WorkspaceWatchHandler = (event: WorkspaceWatchEvent) => void; + +export interface WorkspaceWatch { + dispose(): void; +} + +export interface WorkspaceStructureQuery { + projectId?: string; + path?: string; + maxDepth?: number; + maxEntries?: number; +} + +export type ProjectStructureEntryKind = "file" | "directory"; + +export interface ProjectStructureEntry { + path: string; + name: string; + kind: ProjectStructureEntryKind; +} + +export interface ProjectConvention { + id: string; + markerPath: string; +} + +export interface ProjectModule { + path: string; + markerPath: string; + conventionId: string; +} + +export interface ProjectStructure { + projectId: string; + rootPath: string; + entries: ProjectStructureEntry[]; + conventions: ProjectConvention[]; + modules: ProjectModule[]; + truncated: boolean; +} + +export interface BackgroundTaskStatus { + taskId: string; + ownerAgentId: string; + projectId: string; + kind: string; + status: "pending" | "running" | "completed" | "failed" | "cancelled" | "delivered"; + exitCode: number | null; + summary: string | null; + stdoutTail: string | null; + stderrTail: string | null; + updatedAtMs: number; +} + +export interface BackgroundTaskOutputAttachment { + taskId: string; + scrollback: Uint8Array; + live: boolean; + detach(): void; +} + +export interface BackgroundTaskRetryResult { + /** Present when the host reports the replacement task id. */ + taskId?: string; +} + +export interface RunCommandTaskOptions { + /** Project that owns the command workspace. Defaults to the focused project. */ + projectId?: string; + /** Agent id used by IdeA Work for ownership, cancellation and completion delivery. */ + ownerAgentId: string; + /** Human-facing label shown in Work. Defaults to the command line. */ + label?: string; + /** Executable to run. Arguments are passed separately, without shell parsing. */ + command: string; + /** Arguments passed to the executable. */ + args?: string[]; + /** Relative working directory under the project root. Defaults to the root. */ + cwd?: string; + /** Extra environment variables for the command. */ + env?: Record | Array<[string, string]>; + /** When true, completion is recorded without waking the owner agent. */ + recordOnly?: boolean; + /** Optional absolute deadline, epoch milliseconds. */ + deadlineMs?: number; +} + +export interface CommandTaskStatus { + taskId: string; + ownerAgentId: string; + projectId: string; + kind: string; + state: "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "expired"; + exitCode: number | null; + summary: string | null; + stdoutTail: string | null; + stderrTail: string | null; + createdAtMs: number; + updatedAtMs: number; +} + +export interface ToolRequirement { + /** Stable id chosen by the plugin for this executable prerequisite. */ + id: string; + /** Executable name or path to probe. */ + executable: string; + /** Version/diagnostic arguments. Defaults host-side to `--version`. */ + versionArgs?: string[]; + /** Whether this tool must pass for the whole diagnostic to be ok. */ + required?: boolean; + /** Extra environment variables for this probe. */ + env?: Record | Array<[string, string]>; +} + +export interface EnvRequirement { + /** Environment variable name. */ + name: string; + /** Whether the variable must be present and match. */ + required?: boolean; + /** Optional exact expected value. */ + equals?: string; +} + +export interface FileRequirement { + /** Relative workspace path. */ + path: string; + /** Whether the path must exist and match `kind`. */ + required?: boolean; + /** Expected workspace path kind. */ + kind?: "file" | "directory" | "any"; +} + +export interface ToolchainDiagnosticRequest { + /** Project to inspect. Defaults to the focused project. */ + projectId?: string; + /** Relative working directory under the project root. Defaults to the root. */ + cwd?: string; + /** Executable probes to run. */ + tools?: ToolRequirement[]; + /** Environment variable prerequisites to inspect. */ + env?: EnvRequirement[]; + /** Workspace file prerequisites to validate. */ + files?: FileRequirement[]; +} + +export interface ToolchainDiagnostic { + projectId: string; + cwd: string; + ok: boolean; + tools: ToolDiagnostic[]; + env: EnvDiagnostic[]; + files: FileDiagnostic[]; + messages: DiagnosticMessage[]; +} + +export interface ToolDiagnostic { + id: string; + executable: string; + present: boolean; + ok: boolean; + status: "ok" | "failed" | "missing"; + required: boolean; + exitCode: number | null; + version: string | null; + stdout: string | null; + stderr: string | null; + error: string | null; +} + +export interface EnvDiagnostic { + name: string; + present: boolean; + ok: boolean; + required: boolean; + value: string | null; + status: "ok" | "missing" | "mismatch"; +} + +export interface FileDiagnostic { + path: string; + exists: boolean; + ok: boolean; + required: boolean; + kind: "file" | "directory" | "other" | "missing"; + expectedKind: "file" | "directory" | "any" | null; + len: number | null; +} + +export interface DiagnosticMessage { + level: "info" | "warning" | "error"; + message: string; +} + +export interface ToolingService { + /** Runs generic external-toolchain diagnostics for executables, env and files. */ + diagnose(request: ToolchainDiagnosticRequest): Promise; +} + +export type PublicEventType = "workspaceFileChanged" | "backgroundTaskChanged"; + +export type PublicEvent = WorkspaceFileChangedEvent | BackgroundTaskChangedEvent; + +export interface WorkspaceFileChangedEvent { + type: "workspaceFileChanged"; + sequence: number; + occurredAtMs: number; + projectId: string; + path: string; + operation: string; +} + +export interface BackgroundTaskChangedEvent { + type: "backgroundTaskChanged"; + sequence: number; + occurredAtMs: number; + projectId: string; + taskId: string; + ownerAgentId: string; + state: string; +} + +export interface EventSubscribeOptions { + /** Project to observe. Defaults to the focused project. */ + projectId?: string; + /** Public event types to retain. Empty/omitted means every supported event. */ + eventTypes?: PublicEventType[]; + /** Per-subscription retained capacity. Host clamps to its supported bounds. */ + capacity?: number; + /** Polling cadence used by the runtime facade. Defaults to 1000 ms. */ + pollIntervalMs?: number; + /** Maximum events drained per poll. Host clamps to its supported bounds. */ + maxEventsPerPoll?: number; + /** Called when the host reports dropped retained events for this subscription. */ + onDropped?: (count: number) => void; +} + +export interface EventSubscription { + readonly subscriptionId: string; + readonly projectId: string; + readonly eventTypes: PublicEventType[]; + readonly retention: string; + dispose(): void; +} + +export type EventHandler = (event: PublicEvent) => void; + +export interface EventService { + /** Subscribes to stable, best-effort bounded public host/project events. */ + subscribe(options: EventSubscribeOptions, handler: EventHandler): Promise; +} + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +export type ConfigDocumentFormat = "json"; +export type ConfigUpdateMode = "mergePatch" | "replace"; + +export interface ConfigDocumentReadOptions { + /** Project that owns the config document. Defaults to the focused project. */ + projectId?: string; + /** Relative path under the project root. */ + path: string; + /** Explicit format. Omit to infer from extension. First lot supports only `json`. */ + format?: ConfigDocumentFormat; +} + +export interface ConfigDocumentUpdateOptions extends ConfigDocumentReadOptions { + /** Update mode. Defaults host-side to `mergePatch`. */ + mode?: ConfigUpdateMode; + /** Replacement value or JSON merge patch. */ + value: JsonValue; +} + +export interface ConfigDocument { + projectId: string; + path: string; + format: ConfigDocumentFormat; + value: T; +} + +export interface ConfigDocumentWriteResult { + projectId: string; + path: string; + format: ConfigDocumentFormat; + mode: ConfigUpdateMode; + bytesWritten: number; +} + +export interface ConfigDocumentService { + /** Reads and parses a structured config document. First lot supports JSON only. */ + readDocument( + options: ConfigDocumentReadOptions, + ): Promise>; + /** Writes a full replacement or JSON merge patch. First lot supports JSON only. */ + updateDocument(options: ConfigDocumentUpdateOptions): Promise; +} + +export interface BackgroundTaskService { + /** Launches a non-interactive command as a first-class IdeA background task. */ + runCommand(options: RunCommandTaskOptions): Promise; + /** Reads one command task directly from the host task store. */ + getCommandStatus(taskId: string): Promise; + /** Lists background tasks visible in the project work-state read model. */ + list(projectId?: string): Promise; + /** Reads one task status from the project work-state read model. */ + getStatus(taskId: string, projectId?: string): Promise; + /** Attaches to retained/live output for a task. */ + attachOutput( + taskId: string, + onData: (bytes: Uint8Array) => void, + ): Promise; + /** Cancels a pending/running task. */ + cancel(taskId: string): Promise; + /** Retries a failed/cancelled task; future hosts may return the new task id. */ + retry(taskId: string): Promise; +} + +export interface TerminalOpenOptions { + cwd?: string; + rows?: number; + cols?: number; + onData?: (bytes: Uint8Array) => void; +} + +export interface TerminalReattachOptions { + onData?: (bytes: Uint8Array) => void; +} + +export interface TerminalSession { + readonly sessionId: string; + write(data: Uint8Array): Promise; + resize(rows: number, cols: number): Promise; + detach(): void; + close(): Promise; +} + +export interface TerminalReattachResult { + session: TerminalSession; + scrollback: Uint8Array; +} + +export interface TerminalService { + /** + * Opens a shell PTY in the requested/current project directory. This MVP is a + * terminal control surface, not a command runner; use tasks for build/test + * commands that should be tracked in the Work panel. + */ + open(options?: TerminalOpenOptions): Promise; + /** Reattaches to an already-running PTY and returns retained scrollback. */ + reattach(sessionId: string, options?: TerminalReattachOptions): Promise; + /** Kills a PTY by id. */ + close(sessionId: string): Promise; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..136dafe --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +} +