feat(sdk): ESM multi-fichiers + storage plugin-owned (#134/#139)

Documente et illustre deux contrats SDK :

- Multi-fichiers ESM : le `main` du manifeste peut importer d'autres
  fichiers du package via specifiers relatifs, servis par IdeA sur
  `idea-plugin://` (build `tsc` non bundlé). Le packager embarque tout
  `dist/**/*.js` et vérifie la présence du `main`. Les bare specifiers
  (`node_modules`) restent hors contrat : à bundler ou vendorer.
- Storage plugin-owned : `ctx.storage` est la place canonique de l'état
  interne du plugin (compteurs, flags, préférences, caches), hors des
  fichiers projet. Les APIs workspace/config restent pour le contenu
  project-owned.

L'exemple hello-plugin est éclaté en modules (constants, core/layout,
core/workspace, core/storage) pour exercer l'import relatif et le storage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 12:28:30 +02:00
parent c322055edb
commit 6bca9cc4c0
9 changed files with 269 additions and 158 deletions

View File

@ -8,22 +8,37 @@ It exercises the current plugin primitives end to end:
- menu entry: `hello-plugin`;
- command: `hello-plugin`, returning `hello-world`;
- layout contribution: `hello-plugin.hello-world`, rendered as `hello-world`.
- plugin-owned storage: activation count, command run count and initialization flag;
- tooling capability: logs the focused workspace project when `ctx.services` is available.
The source is intentionally split across multiple TypeScript modules:
- `src/index.ts` is the manifest entrypoint and imports relative ESM modules;
- `src/constants.ts` owns shared command/layout identifiers;
- `src/core/layout.ts` and `src/core/workspace.ts` hold feature logic.
- `src/core/storage.ts` keeps plugin-owned counters and flags in `ctx.storage`.
The build uses plain `tsc`; it does not bundle the plugin into one file. The archive includes all
compiled `dist/**/*.js` files so IdeA can load `dist/index.js` and serve its package-relative imports
through `idea-plugin://`. Runtime imports from `node_modules` are outside this contract: vendor them
as relative files or bundle them into the plugin output before packaging.
```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.
It contains `idea-plugin.json` at the ZIP root and the compiled multi-file ESM output under `dist/`,
including `dist/index.js`, matching the manifest `main` field.
## Diagnostics
During activation the plugin logs:
- whether the command and layout runtime registries are available;
- whether plugin-owned storage is available;
- activation count and initialization state stored through `ctx.storage`;
- 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;

View File

@ -0,0 +1,8 @@
export const COMMAND_ID = "hello-plugin";
export const LAYOUT_TYPE = "hello-plugin.hello-world";
export const STORAGE_KEYS = {
activationCount: "helloPlugin.activationCount",
commandRunCount: "helloPlugin.commandRunCount",
initialized: "helloPlugin.initialized",
ownerAgentId: "helloPlugin.ownerAgentId"
} as const;

View File

@ -0,0 +1,17 @@
import type { PluginLayoutProps } from "@idea/plugin-sdk";
let hasLoggedFirstLayoutRender = false;
export 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";
}

View File

@ -0,0 +1,32 @@
import type { ActivateContext } from "@idea/plugin-sdk";
import { STORAGE_KEYS } from "../constants.js";
export async function initializePluginStorage(ctx: ActivateContext): Promise<void> {
if (!ctx.storage) {
ctx.logger.warn("plugin-owned storage unavailable");
return;
}
const activationCount = await incrementStoredNumber(ctx, STORAGE_KEYS.activationCount);
const initialized = await ctx.storage.get<boolean>(STORAGE_KEYS.initialized);
if (!initialized) {
await ctx.storage.set(STORAGE_KEYS.initialized, true);
}
ctx.logger.info("plugin-owned storage ready", {
activationCount,
initialized: initialized ?? false
});
}
export async function recordCommandRun(ctx: ActivateContext): Promise<number | undefined> {
if (!ctx.storage) return undefined;
return incrementStoredNumber(ctx, STORAGE_KEYS.commandRunCount);
}
async function incrementStoredNumber(ctx: ActivateContext, key: string): Promise<number> {
const current = await ctx.storage?.get<number>(key);
const next = typeof current === "number" && Number.isFinite(current) ? current + 1 : 1;
await ctx.storage?.set(key, next);
return next;
}

View File

@ -0,0 +1,98 @@
import type { ActivateContext } from "@idea/plugin-sdk";
import { STORAGE_KEYS } from "../constants.js";
export async function useWorkspaceSdk(ctx: ActivateContext): Promise<void> {
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 listing = await workspace.listDirectory(".ideai", project.id);
const structure = await workspace.queryStructure({
projectId: project.id,
maxDepth: 2,
maxEntries: 100
});
ctx.logger.info("workspace project inspection complete", {
projectId: project.id,
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: "idea-plugin.json", kind: "file" }]
});
ctx.logger.info("tooling diagnostic complete", {
ok: diagnostic?.ok,
echoVersion: diagnostic?.tools.find((tool) => tool.id === "echo")?.version,
messages: diagnostic?.messages
});
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<string>(STORAGE_KEYS.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
});
}
}

View File

@ -1,33 +1,20 @@
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";
}
import type { ActivateContext, IdeAPluginModule } from "@idea/plugin-sdk";
import { COMMAND_ID, LAYOUT_TYPE } from "./constants.js";
import { HelloWorldLayout } from "./core/layout.js";
import { initializePluginStorage, recordCommandRun } from "./core/storage.js";
import { useWorkspaceSdk } from "./core/workspace.js";
export function activate(ctx: ActivateContext): void {
ctx.logger.info("activating hello-plugin", {
pluginId: ctx.pluginId,
hasCommands: Boolean(ctx.commands),
hasLayouts: Boolean(ctx.layouts)
hasLayouts: Boolean(ctx.layouts),
hasStorage: Boolean(ctx.storage)
});
const commandDisposable = ctx.commands?.registerCommand(COMMAND_ID, () => {
ctx.logger.info("command executed", { commandId: COMMAND_ID });
const commandDisposable = ctx.commands?.registerCommand(COMMAND_ID, async () => {
const commandRunCount = await recordCommandRun(ctx);
ctx.logger.info("command executed", { commandId: COMMAND_ID, commandRunCount });
return "hello-world";
});
@ -53,132 +40,10 @@ export function activate(ctx: ActivateContext): void {
ctx.logger.warn("layout registry unavailable", { layoutType: LAYOUT_TYPE });
}
void initializePluginStorage(ctx);
void useWorkspaceSdk(ctx);
}
async function useWorkspaceSdk(ctx: ActivateContext): Promise<void> {
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<string>("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
};