merge feature/hello-plugin-manifest-fix-and-fixture-tests dans develop (fix manifeste hello-plugin + base de tests fonctionnels plugins)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 10:01:04 +02:00
22 changed files with 1113 additions and 0 deletions

View File

@ -0,0 +1,3 @@
export function activate(ctx) {
ctx.commands?.registerCommand("dev.idea.fixtures.reference-minimal.hello", () => "hello");
}

View File

@ -0,0 +1,33 @@
{
"ideaPluginManifestVersion": 1,
"id": "dev.idea.fixtures.reference-minimal",
"displayName": "Reference Minimal Plugin",
"publisher": "IdeA QA",
"version": "0.1.0",
"description": "Minimal fixture for the real plugin install/load loop.",
"engines": {
"idea": ">=0.1.0 <1.0.0"
},
"main": "dist/index.js",
"trustLevel": "full",
"capabilities": [
"ui"
],
"contributes": {
"menus": [
{
"id": "dev.idea.fixtures.reference-minimal.menu",
"label": "Reference",
"topLevel": true
}
],
"menuItems": [
{
"id": "dev.idea.fixtures.reference-minimal.hello.item",
"targetMenuId": "dev.idea.fixtures.reference-minimal.menu",
"label": "Say Hello",
"command": "dev.idea.fixtures.reference-minimal.hello"
}
]
}
}

View File

@ -0,0 +1,92 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use application::{
InstallPluginFromDirectory, JsonPluginManifestValidator, ListPluginRuntimeContributions,
ListPlugins,
};
use infrastructure::{
ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore,
TokioBroadcastEventBus,
};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_dir(label: &str) -> PathBuf {
let n = TEMP_COUNTER.fetch_add(1, Ordering::SeqCst);
let path = std::env::temp_dir().join(format!(
"idea-plugin-functional-{label}-{}-{n}",
std::process::id()
));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).unwrap();
path
}
fn fixture_path(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("plugins")
.join(name)
}
#[tokio::test]
async fn installs_reference_fixture_and_loads_runtime_catalog() {
let app_data = temp_dir("app-data");
let fixture = fixture_path("reference-minimal");
let packages = Arc::new(FsPluginPackageStore::new(&app_data));
let registry = Arc::new(FsPluginRegistryStore::new(&app_data));
let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0"));
let events = Arc::new(TokioBroadcastEventBus::new());
let mcp = Arc::new(ExternalMcpPluginSupervisor::new());
let install = InstallPluginFromDirectory::new(
packages.clone(),
registry.clone(),
validator.clone(),
events,
mcp.clone(),
);
let result = install
.execute(fixture.to_string_lossy().into_owned())
.await
.unwrap();
assert_eq!(result.plugin.id, "dev.idea.fixtures.reference-minimal");
assert_eq!(result.plugin.display_name, "Reference Minimal Plugin");
assert_eq!(result.review.contribution_summary.top_level_menus, 1);
assert_eq!(result.review.contribution_summary.menu_items, 1);
assert!(result.restart_required);
assert!(app_data
.join("plugins/installed/dev.idea.fixtures.reference-minimal/idea-plugin.json")
.is_file());
let admin = ListPlugins::new(packages.clone(), registry.clone(), validator.clone())
.execute()
.await
.unwrap();
assert_eq!(admin.len(), 1);
assert_eq!(admin[0].id, "dev.idea.fixtures.reference-minimal");
assert_eq!(
admin[0].lifecycle_state,
domain::PluginLifecycleState::Enabled
);
let catalog = ListPluginRuntimeContributions::new(packages, registry, validator)
.execute()
.await
.unwrap();
assert_eq!(catalog.plugins.len(), 1);
let plugin = &catalog.plugins[0];
assert_eq!(plugin.id, "dev.idea.fixtures.reference-minimal");
assert!(plugin
.bundle_url
.starts_with("idea-plugin://dev.idea.fixtures.reference-minimal/0.1.0/"));
assert_eq!(plugin.contributes.menus.len(), 1);
assert_eq!(plugin.contributes.menu_items.len(), 1);
let _ = fs::remove_dir_all(app_data);
}

9
sdk/IdeaSDK/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules/
dist/
examples/hello-plugin/dist/
examples/hello-plugin/build/
*.tsbuildinfo
.DS_Store
coverage/
npm-debug.log*
*.tgz

84
sdk/IdeaSDK/README.md Normal file
View File

@ -0,0 +1,84 @@
# 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 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");
}
```
## 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.

View File

@ -0,0 +1,12 @@
# Hello Plugin
Minimal IdeA plugin example using the public SDK types.
```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.

View File

@ -0,0 +1,33 @@
{
"ideaPluginManifestVersion": 1,
"id": "com.example.hello-plugin",
"displayName": "Hello Plugin",
"publisher": "IdeA Examples",
"version": "0.1.0",
"description": "Minimal IdeA plugin example.",
"main": "dist/index.js",
"engines": {
"idea": ">=0.1.0"
},
"trustLevel": "full",
"capabilities": [
"ui"
],
"contributes": {
"menus": [
{
"id": "hello-plugin.menu",
"label": "Hello",
"topLevel": true
}
],
"menuItems": [
{
"id": "hello-plugin.sayHello.item",
"targetMenuId": "hello-plugin.menu",
"label": "Say Hello",
"command": "hello-plugin.sayHello"
}
]
}
}

View File

@ -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
}
}
}

View File

@ -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:../.."
}
}

View File

@ -0,0 +1,21 @@
import type { ActivateContext, IdeAPluginModule } from "@idea/plugin-sdk";
export function activate(ctx: ActivateContext): void {
ctx.logger.info("Hello from the IdeA hello plugin.");
const disposable = ctx.commands?.registerCommand("hello-plugin.sayHello", () => {
ctx.logger.info("Hello command executed.");
return "Hello from IdeA";
});
if (disposable) {
ctx.subscriptions.push(disposable);
}
}
const plugin: IdeAPluginModule = {
activate
};
export default plugin;

View File

@ -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"
]
}

View File

@ -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"
]
}

30
sdk/IdeaSDK/package-lock.json generated Normal file
View File

@ -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"
}
}
}
}

34
sdk/IdeaSDK/package.json Normal file
View File

@ -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"
}
}

View File

@ -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;
}

1
sdk/IdeaSDK/src/index.js Normal file
View File

@ -0,0 +1 @@
export { isPluginManifest, assertPluginManifest, validatePluginManifest } from "./manifest.js";

23
sdk/IdeaSDK/src/index.ts Normal file
View File

@ -0,0 +1,23 @@
export type {
IdeAPluginCapability,
IdeAPluginManifest,
IdeAPluginEngineConstraints,
IdeAPluginLayoutContribution,
IdeAPluginMcpServerContribution,
IdeAPluginMenuItemContribution,
IdeAPluginTopLevelMenuContribution
} from "./manifest.js";
export {
isPluginManifest,
assertPluginManifest,
validatePluginManifest
} from "./manifest.js";
export type {
ActivateContext,
CommandDisposable,
CommandHandler,
CommandRegistry,
IdeAPluginModule,
PluginLogger,
PluginStorage
} from "./runtime.js";

172
sdk/IdeaSDK/src/manifest.js Normal file
View File

@ -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") {
errors.push(`capabilities[${index}] must be "ui" or "mcp"`);
}
});
}
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);
}

309
sdk/IdeaSDK/src/manifest.ts Normal file
View File

@ -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";
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<string, string>;
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") {
errors.push(`capabilities[${index}] must be "ui" or "mcp"`);
}
});
}
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<string, unknown>,
key: string,
errors: string[],
validateItem: (item: Record<string, unknown>, 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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,36 @@
export interface ActivateContext {
pluginId: string;
logger: PluginLogger;
subscriptions: CommandDisposable[];
commands?: CommandRegistry;
storage?: PluginStorage;
}
export interface IdeAPluginModule {
activate(ctx: ActivateContext): void | Promise<void>;
deactivate?(): void | Promise<void>;
}
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<unknown>;
export interface CommandRegistry {
registerCommand(commandId: string, handler: CommandHandler): CommandDisposable;
}
export interface CommandDisposable {
dispose(): void;
}
export interface PluginStorage {
get<T = unknown>(key: string): Promise<T | undefined>;
set<T = unknown>(key: string, value: T): Promise<void>;
delete(key: string): Promise<void>;
}

18
sdk/IdeaSDK/tsconfig.json Normal file
View File

@ -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"
]
}