feat(plugins): paramètres projet plugins + attribution skills/tools aux agents — #282 (manifeste accepte contributes.skills IdeA-native multi-harness; store .ideai/project-plugins.json v1: enable plugin par projet + assignments skills/tools par agent; refs + use cases settings/assign/unassign; resolver skills pour idea_skill_read et injection catalogue lancement agent; permissions MCP dynamiques projetées par harness Claude Code/Codex/OpenCode; tombstones uninstall, hot reload recalcule catalogue et marque assignments cassés; frontend: Settings projet Plugins, détail capabilities, attribution agent, catalogue permissions; bump pointeur IdeaSDK 6eb8db3; QA verte: cargo test -p application, project_plugin_settings 3/3, dto_plugins 2/2, structured_launch_d3 26/26, catalogue mcp 7/7, frontend 5 tests ProjectPluginSettings/plugin, npm build frontend)

This commit is contained in:
2026-09-08 15:48:57 +02:00
parent bdc20a134a
commit 86ed0aa00b
38 changed files with 2127 additions and 89 deletions

View File

@ -24,6 +24,8 @@ import type {
PluginProjectStructure,
PluginReview,
PluginRuntimeContributionCatalog,
ProjectPluginSettings,
ProjectPluginSettingsView,
PluginUninstallResult,
PluginWorkspaceBinaryFile,
PluginWorkspaceDirectoryListing,
@ -45,6 +47,8 @@ import type {
PluginEventSubscribeInput,
PluginEventUnsubscribeInput,
PluginGateway,
MutateAgentPluginSkillInput,
MutateAgentPluginToolInput,
PluginStorageGateway,
PluginStorageGetInput,
PluginStorageSetInput,
@ -60,6 +64,7 @@ import type {
PluginWorkspaceWriteTextInput,
RemoteGateway,
ReviewPluginPackageInput,
SetProjectPluginEnabledInput,
ViewWindowClosed,
ViewWindowSnapshot,
WindowGateway,
@ -191,6 +196,24 @@ export class WebPluginGateway implements PluginGateway {
// lets the bootstrap loader run unconditionally without a transport check.
return { plugins: [] };
}
async listProjectSettings(_projectId: string): Promise<ProjectPluginSettingsView> {
return unsupportedOnWeb("Project plugin settings");
}
async setProjectEnabled(_input: SetProjectPluginEnabledInput): Promise<ProjectPluginSettings> {
return unsupportedOnWeb("Project plugin settings");
}
async assignAgentSkill(_input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings> {
return unsupportedOnWeb("Project plugin settings");
}
async unassignAgentSkill(_input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings> {
return unsupportedOnWeb("Project plugin settings");
}
async assignAgentTool(_input: MutateAgentPluginToolInput): Promise<ProjectPluginSettings> {
return unsupportedOnWeb("Project plugin settings");
}
async unassignAgentTool(_input: MutateAgentPluginToolInput): Promise<ProjectPluginSettings> {
return unsupportedOnWeb("Project plugin settings");
}
async openPluginsFolder(_pluginId?: string): Promise<void> {
return unsupportedOnWeb("Plugin management");
}

View File

@ -57,6 +57,9 @@ import type {
PluginProjectStructureEntry,
PluginReview,
PluginRuntimeContributionCatalog,
ProjectPluginManifest,
ProjectPluginSettings,
ProjectPluginSettingsView,
PluginToolchainDiagnostic,
PluginUninstallResult,
PluginPublicEvent,
@ -138,6 +141,8 @@ import type {
PluginEventSubscribeInput,
PluginEventUnsubscribeInput,
PluginGateway,
MutateAgentPluginSkillInput,
MutateAgentPluginToolInput,
PluginStorageGateway,
PluginStorageGetInput,
PluginStorageSetInput,
@ -155,6 +160,7 @@ import type {
ReattachAgentChatResult,
RemoteGateway,
ReviewPluginPackageInput,
SetProjectPluginEnabledInput,
SaveOpenCodeProviderProfileInput,
SendAgentChatOptions,
SkillGateway,
@ -4004,6 +4010,8 @@ function isBlanketRule(rule: PermissionSet["rules"][number]) {
*/
export class MockPluginGateway implements PluginGateway {
private plugins: PluginAdmin[] = [];
private projectManifests: ProjectPluginManifest[] = [];
private projectSettings = new Map<string, ProjectPluginSettings>();
private seq = 0;
private summaryFor(_pluginId: string): PluginContributionSummary {
@ -4015,6 +4023,24 @@ export class MockPluginGateway implements PluginGateway {
this.plugins.push(plugin);
}
/** Test/dev helper: seed a full manifest for project capability settings. */
_seedProjectPluginManifest(manifest: ProjectPluginManifest): void {
this.projectManifests.push(structuredClone(manifest));
}
/** Test/dev helper: seed activation/assignment tombstones. */
_seedProjectPluginSettings(projectId: string, settings: ProjectPluginSettings): void {
this.projectSettings.set(projectId, structuredClone(settings));
}
private settingsFor(projectId: string): ProjectPluginSettings {
const existing = this.projectSettings.get(projectId);
if (existing) return existing;
const created: ProjectPluginSettings = { version: 1, plugins: [], agents: [] };
this.projectSettings.set(projectId, created);
return created;
}
async listPlugins(): Promise<PluginAdmin[]> {
return structuredClone(this.plugins);
}
@ -4099,6 +4125,68 @@ export class MockPluginGateway implements PluginGateway {
return { plugins: [] };
}
async listProjectSettings(projectId: string): Promise<ProjectPluginSettingsView> {
return {
settings: structuredClone(this.settingsFor(projectId)),
installed: structuredClone(this.projectManifests),
};
}
async setProjectEnabled(input: SetProjectPluginEnabledInput): Promise<ProjectPluginSettings> {
if (input.enabled && !this.projectManifests.some((plugin) => plugin.id === input.pluginId)) {
throw { code: "NOT_FOUND", message: `plugin ${input.pluginId} not found` } satisfies GatewayError;
}
const settings = this.settingsFor(input.projectId);
const activation = settings.plugins.find((entry) => entry.pluginId === input.pluginId);
if (activation) activation.enabled = input.enabled;
else settings.plugins.push({ pluginId: input.pluginId, enabled: input.enabled });
return structuredClone(settings);
}
private assignmentsFor(projectId: string, agentId: string) {
const settings = this.settingsFor(projectId);
let assignments = settings.agents.find((entry) => entry.agentId === agentId);
if (!assignments) {
assignments = { agentId, skills: [], tools: [] };
settings.agents.push(assignments);
}
return assignments;
}
async assignAgentSkill(input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings> {
const assignments = this.assignmentsFor(input.projectId, input.agentId);
if (!assignments.skills.some((ref) => ref.pluginId === input.pluginId && ref.skillId === input.skillId)) {
assignments.skills.push({ pluginId: input.pluginId, skillId: input.skillId });
}
return structuredClone(this.settingsFor(input.projectId));
}
async unassignAgentSkill(input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings> {
const assignments = this.assignmentsFor(input.projectId, input.agentId);
assignments.skills = assignments.skills.filter(
(ref) => ref.pluginId !== input.pluginId || ref.skillId !== input.skillId,
);
return structuredClone(this.settingsFor(input.projectId));
}
async assignAgentTool(input: MutateAgentPluginToolInput): Promise<ProjectPluginSettings> {
const assignments = this.assignmentsFor(input.projectId, input.agentId);
if (!assignments.tools.some((ref) =>
ref.pluginId === input.pluginId && ref.serverId === input.serverId && ref.toolName === input.toolName
)) {
assignments.tools.push({ pluginId: input.pluginId, serverId: input.serverId, toolName: input.toolName });
}
return structuredClone(this.settingsFor(input.projectId));
}
async unassignAgentTool(input: MutateAgentPluginToolInput): Promise<ProjectPluginSettings> {
const assignments = this.assignmentsFor(input.projectId, input.agentId);
assignments.tools = assignments.tools.filter((ref) =>
ref.pluginId !== input.pluginId || ref.serverId !== input.serverId || ref.toolName !== input.toolName
);
return structuredClone(this.settingsFor(input.projectId));
}
async openPluginsFolder(_pluginId?: string): Promise<void> {
// No filesystem in the mock — no-op.
}

View File

@ -47,4 +47,39 @@ describe("TauriPluginGateway invoke payloads", () => {
expect(review.issues).toEqual([]);
expect(review.installable).toBe(true);
});
it("uses the project-plugin command envelopes", async () => {
const gateway = new TauriPluginGateway();
await gateway.listProjectSettings("project-1");
expect(invoke).toHaveBeenLastCalledWith("plugin_list_project_settings", {
projectId: "project-1",
});
const projectInput = { projectId: "project-1", pluginId: "dev.acme.unity", enabled: true };
await gateway.setProjectEnabled(projectInput);
expect(invoke).toHaveBeenLastCalledWith("plugin_set_project_enabled", { input: projectInput });
const skillInput = {
projectId: "project-1",
agentId: "agent-1",
pluginId: "dev.acme.unity",
skillId: "unity.debug",
};
await gateway.assignAgentSkill(skillInput);
expect(invoke).toHaveBeenLastCalledWith("plugin_assign_agent_skill", { input: skillInput });
await gateway.unassignAgentSkill(skillInput);
expect(invoke).toHaveBeenLastCalledWith("plugin_unassign_agent_skill", { input: skillInput });
const toolInput = {
projectId: "project-1",
agentId: "agent-1",
pluginId: "dev.acme.unity",
serverId: "unity-tools",
toolName: "unity_build",
};
await gateway.assignAgentTool(toolInput);
expect(invoke).toHaveBeenLastCalledWith("plugin_assign_agent_tool", { input: toolInput });
await gateway.unassignAgentTool(toolInput);
expect(invoke).toHaveBeenLastCalledWith("plugin_unassign_agent_tool", { input: toolInput });
});
});

View File

@ -17,9 +17,17 @@ import type {
PluginInstallResult,
PluginReview,
PluginRuntimeContributionCatalog,
ProjectPluginSettings,
ProjectPluginSettingsView,
PluginUninstallResult,
} from "@/domain";
import type { PluginGateway, ReviewPluginPackageInput } from "@/ports";
import type {
MutateAgentPluginSkillInput,
MutateAgentPluginToolInput,
PluginGateway,
ReviewPluginPackageInput,
SetProjectPluginEnabledInput,
} from "@/ports";
type TauriPluginReviewDto = Omit<PluginReview, "issues" | "installable"> & {
issues?: PluginReview["issues"];
@ -66,6 +74,30 @@ export class TauriPluginGateway implements PluginGateway {
return invoke<PluginRuntimeContributionCatalog>("plugin_list_runtime_contributions");
}
listProjectSettings(projectId: string): Promise<ProjectPluginSettingsView> {
return invoke<ProjectPluginSettingsView>("plugin_list_project_settings", { projectId });
}
setProjectEnabled(input: SetProjectPluginEnabledInput): Promise<ProjectPluginSettings> {
return invoke<ProjectPluginSettings>("plugin_set_project_enabled", { input });
}
assignAgentSkill(input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings> {
return invoke<ProjectPluginSettings>("plugin_assign_agent_skill", { input });
}
unassignAgentSkill(input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings> {
return invoke<ProjectPluginSettings>("plugin_unassign_agent_skill", { input });
}
assignAgentTool(input: MutateAgentPluginToolInput): Promise<ProjectPluginSettings> {
return invoke<ProjectPluginSettings>("plugin_assign_agent_tool", { input });
}
unassignAgentTool(input: MutateAgentPluginToolInput): Promise<ProjectPluginSettings> {
return invoke<ProjectPluginSettings>("plugin_unassign_agent_tool", { input });
}
async openPluginsFolder(pluginId?: string): Promise<void> {
await invoke("plugin_open_plugins_folder", { pluginId: pluginId ?? null });
}