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

View File

@ -1856,6 +1856,8 @@ export type PluginLifecycleState =
/** Counts of what a plugin declares, shown in the admin list (carnet §5). */
export interface PluginContributionSummary {
/** Agent skills shipped by the plugin. Absent on older backend DTOs. */
skills?: number;
topLevelMenus: number;
menuItems: number;
layouts: number;
@ -1917,6 +1919,8 @@ export interface PluginUninstallResult {
/** Contribution declarations for one plugin, as consumed by the runtime loader. */
export interface PluginContributionDto {
/** Read-only, package-relative skills exposed to project agents. */
skills?: PluginSkillContribution[];
menus: PluginTopLevelMenuContribution[];
menuItems: PluginMenuItemContribution[];
slashCommands?: PluginSlashCommandContribution[];
@ -1924,6 +1928,66 @@ export interface PluginContributionDto {
mcpServers: PluginMcpServerSummary[];
}
/** One agent skill declared by an IdeA plugin manifest. */
export interface PluginSkillContribution {
id: string;
name: string;
description: string;
kind: SkillKind;
path: string;
}
/** Validated plugin manifest returned by the project-plugin settings catalogue. */
export interface ProjectPluginManifest {
ideaPluginManifestVersion: number;
id: string;
displayName: string;
publisher?: string;
version: string;
description?: string;
engineIdea?: string;
main: string;
icon?: string;
trustLevel: "full";
capabilities: Array<"ui" | "mcp" | "tooling">;
activationScope: "app" | "project";
contributes: PluginContributionDto;
}
export interface ProjectPluginActivation {
pluginId: string;
enabled: boolean;
}
export interface PluginSkillRef {
pluginId: string;
skillId: string;
}
export interface PluginToolRef {
pluginId: string;
serverId: string;
toolName: string;
}
export interface AgentPluginAssignments {
agentId: string;
skills: PluginSkillRef[];
tools: PluginToolRef[];
}
export interface ProjectPluginSettings {
version: number;
plugins: ProjectPluginActivation[];
agents: AgentPluginAssignments[];
}
/** Project settings plus the currently installed global plugin manifests. */
export interface ProjectPluginSettingsView {
settings: ProjectPluginSettings;
installed: ProjectPluginManifest[];
}
/** One entry of `plugin_list_runtime_contributions` (mirror of `PluginRuntimePluginDto`). */
export interface PluginRuntimePlugin {
id: string;
@ -2266,5 +2330,11 @@ export interface CustomPluginLayoutCell {
export interface PluginMcpServerSummary {
id: string;
displayName: string;
command?: string;
args?: string[];
env?: Array<[string, string]>;
cwd?: string;
transport?: "stdio";
autoStart?: boolean;
allowAbsoluteCommand?: boolean;
}

View File

@ -19,6 +19,7 @@ import { useEffect, useState } from "react";
import { Button, Input, Panel, SmallDropdown, Spinner, cn } from "@/shared";
import { TerminalView } from "@/features/terminals/TerminalView";
import { AnnouncementsPreview } from "@/features/announcements";
import { AgentPluginCapabilities } from "@/features/plugins/AgentPluginCapabilities";
import { useDrift } from "@/features/templates/useDrift";
import { templateProfileLabel } from "@/features/templates/templatePresentation";
import { useGateways } from "@/app/di";
@ -737,6 +738,16 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
</div>
)}
{selectedAgent && (
<div className="border-t border-border p-4">
<AgentPluginCapabilities
projectId={projectId}
agentId={selectedAgent.id}
agentName={selectedAgent.name}
/>
</div>
)}
{/* ── Context editor ── */}
{selectedAgent && (
<div className="flex flex-col gap-2 border-t border-border p-4">

View File

@ -0,0 +1,148 @@
import { useMemo, useState } from "react";
import { Button, Input, Panel, SmallDropdown, Spinner } from "@/shared";
import type { PluginSkillRef, PluginToolRef } from "@/domain";
import { useProjectPlugins } from "./useProjectPlugins";
interface AgentPluginCapabilitiesProps {
projectId: string;
agentId: string;
agentName: string;
}
function skillKey(ref: PluginSkillRef): string {
return `${ref.pluginId}:${ref.skillId}`;
}
function toolKey(ref: PluginToolRef): string {
return `${ref.pluginId}:${ref.serverId}:${ref.toolName}`;
}
export function AgentPluginCapabilities({
projectId,
agentId,
agentName,
}: AgentPluginCapabilitiesProps) {
const vm = useProjectPlugins(projectId);
const [serverChoice, setServerChoice] = useState("");
const [toolName, setToolName] = useState("");
const assignments = vm.settings?.agents.find((entry) => entry.agentId === agentId);
const assignedSkills = new Set((assignments?.skills ?? []).map(skillKey));
const assignedTools = assignments?.tools ?? [];
const enabledIds = new Set(
(vm.settings?.plugins ?? []).filter((entry) => entry.enabled).map((entry) => entry.pluginId),
);
const enabledPlugins = vm.installed.filter((plugin) => enabledIds.has(plugin.id));
const serverOptions = useMemo(
() => enabledPlugins.flatMap((plugin) =>
(plugin.contributes.mcpServers ?? []).map((server) => ({
value: `${plugin.id}\u0000${server.id}`,
label: `${plugin.displayName} — ${server.displayName}`,
})),
),
[enabledPlugins],
);
const knownSkillKeys = new Set(vm.installed.flatMap((plugin) =>
(plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`),
));
const knownServerKeys = new Set(vm.installed.flatMap((plugin) =>
(plugin.contributes.mcpServers ?? []).map((server) => `${plugin.id}:${server.id}`),
));
const staleSkills = (assignments?.skills ?? []).filter((ref) => !knownSkillKeys.has(skillKey(ref)));
const staleTools = assignedTools.filter(
(ref) => !knownServerKeys.has(`${ref.pluginId}:${ref.serverId}`),
);
async function assignTool() {
const [pluginId, serverId] = serverChoice.split("\u0000");
const name = toolName.trim();
if (!pluginId || !serverId || !name) return;
await vm.setToolAssigned({ agentId, pluginId, serverId, toolName: name }, true);
setToolName("");
}
if (vm.loading && !vm.settings) {
return <div className="flex items-center gap-2 text-xs text-muted"><Spinner size={12} /> Chargement des capabilities de plugins…</div>;
}
return (
<div className="flex flex-col gap-4">
{vm.error && <p role="alert" className="text-xs text-danger">{vm.error}</p>}
<section aria-label={`Skills de plugins — ${agentName}`} className="flex flex-col gap-2">
<h4 className="text-sm font-semibold text-content">Skills de plugins</h4>
{enabledPlugins.every((plugin) => (plugin.contributes.skills ?? []).length === 0) ? (
<p className="text-xs text-muted">Aucun skill fourni par les plugins activés pour ce projet.</p>
) : (
enabledPlugins.map((plugin) => {
const skills = plugin.contributes.skills ?? [];
if (skills.length === 0) return null;
return (
<Panel key={plugin.id} className="flex flex-col gap-2">
<h5 className="text-xs font-semibold text-content">{plugin.displayName}</h5>
{skills.map((skill) => {
const assigned = assignedSkills.has(`${plugin.id}:${skill.id}`);
return (
<label key={skill.id} className="flex items-start gap-2 text-xs text-content">
<input
type="checkbox"
checked={assigned}
disabled={vm.busy}
onChange={(event) => void vm.setSkillAssigned(
agentId, plugin.id, skill.id, event.target.checked,
)}
/>
<span><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}</span>
</label>
);
})}
</Panel>
);
})
)}
{staleSkills.map((ref) => (
<div key={skillKey(ref)} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
<span>{ref.pluginId} · {ref.skillId} <span className="text-warning">— Obsolète ou manquant</span></span>
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={() => void vm.setSkillAssigned(agentId, ref.pluginId, ref.skillId, false)}>Retirer</Button>
</div>
))}
</section>
<section aria-label={`Tools MCP de plugins — ${agentName}`} className="flex flex-col gap-2">
<h4 className="text-sm font-semibold text-content">Tools MCP de plugins</h4>
{assignedTools.length === 0 ? (
<p className="text-xs text-muted">Aucun tool MCP de plugin attribué à l’agent.</p>
) : (
<ul className="flex flex-col gap-1">
{assignedTools.map((ref) => {
const stale = staleTools.some((candidate) => toolKey(candidate) === toolKey(ref));
return (
<li key={toolKey(ref)} className="flex items-center justify-between gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<span>{ref.pluginId} · {ref.serverId} · <span className="font-medium">{ref.toolName}</span>{stale && <span className="text-warning"> — Obsolète ou manquant</span>}</span>
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={() => void vm.setToolAssigned({ agentId, ...ref }, false)}>Retirer</Button>
</li>
);
})}
</ul>
)}
{serverOptions.length > 0 && (
<div className="flex items-end gap-2">
<SmallDropdown
aria-label="serveur MCP de plugin"
value={serverChoice}
onChange={setServerChoice}
disabled={vm.busy}
className="min-w-0 flex-1"
size="md"
options={[{ value: "", label: "— choisir un serveur MCP —" }, ...serverOptions]}
/>
<Input aria-label="nom du tool MCP" value={toolName} onChange={(event) => setToolName(event.target.value)} placeholder="Nom du tool" disabled={vm.busy} />
<Button variant="primary" disabled={vm.busy || !serverChoice || !toolName.trim()} onClick={() => void assignTool()}>Attribuer</Button>
</div>
)}
</section>
</div>
);
}

View File

@ -15,6 +15,7 @@ import { Button, Panel } from "@/shared";
import { PluginConfirmDialog as ConfirmDialog } from "./PluginConfirmDialog";
import { usePluginRuntime } from "./PluginRuntimeProvider";
import { usePlugins } from "./usePlugins";
import { ProjectPluginsPanel } from "./ProjectPluginsPanel";
const STATE_LABEL: Record<PluginLifecycleState, string> = {
enabled: "Activé",
@ -35,7 +36,11 @@ interface InstallFlow {
review: PluginReview;
}
export function PluginsPanel() {
export interface PluginsPanelProps {
projectId?: string;
}
export function PluginsPanel({ projectId }: PluginsPanelProps = {}) {
const vm = usePlugins();
const pluginRuntime = usePluginRuntime();
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
@ -87,8 +92,19 @@ export function PluginsPanel() {
return (
<div className="flex flex-col gap-4">
{projectId ? (
<ProjectPluginsPanel projectId={projectId} />
) : (
<Panel>
<p className="text-sm text-muted">
Ouvrez un projet pour configurer les plugins utilisés par ce projet.
</p>
</Panel>
)}
<div className="border-t border-border pt-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-content">Plugins</h2>
<h2 className="text-sm font-semibold text-content">Installation globale</h2>
<div className="flex gap-2">
<Button size="sm" onClick={() => void startInstallFromArchive()} disabled={vm.busy}>
Installer depuis une archive…
@ -103,6 +119,7 @@ export function PluginsPanel() {
</Button>
</div>
</div>
</div>
{(vm.error || installFlowError) && (
<p role="alert" className="text-sm text-danger">

View File

@ -0,0 +1,105 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DIProvider } from "@/app/di";
import { createMockGateways, MockPluginGateway } from "@/adapters/mock";
import type { ProjectPluginManifest } from "@/domain";
import { AgentPluginCapabilities } from "./AgentPluginCapabilities";
import { ProjectPluginsPanel } from "./ProjectPluginsPanel";
function manifest(): ProjectPluginManifest {
return {
ideaPluginManifestVersion: 1,
id: "dev.acme.unity",
displayName: "Unity Plugin",
publisher: "Acme",
version: "1.2.3",
main: "dist/index.js",
trustLevel: "full",
capabilities: ["ui", "mcp"],
activationScope: "app",
contributes: {
skills: [{
id: "unity.debug",
name: "unity-debug",
description: "Diagnostiquer les builds Unity.",
kind: "workflow",
path: "skills/unity-debug/SKILL.md",
}],
menus: [],
menuItems: [],
layouts: [],
mcpServers: [{ id: "unity-tools", displayName: "Unity Tools", transport: "stdio" }],
},
};
}
function gatewaysWithPlugin() {
const plugin = new MockPluginGateway();
plugin._seedProjectPluginManifest(manifest());
return { ...createMockGateways(), plugin };
}
describe("project plugin settings #282", () => {
it("lists globally installed plugins, details their capabilities and toggles project activation", async () => {
const gateways = gatewaysWithPlugin();
render(
<DIProvider gateways={gateways}>
<ProjectPluginsPanel projectId="project-1" />
</DIProvider>,
);
expect(await screen.findByText("Unity Plugin")).toBeTruthy();
expect(screen.getByText("Installé globalement")).toBeTruthy();
fireEvent.click(screen.getByText("Voir les capabilities"));
expect(screen.getByText("unity-debug")).toBeTruthy();
expect(screen.getByText("Unity Tools")).toBeTruthy();
const toggle = screen.getByRole("checkbox", { name: "Activé pour ce projet" });
fireEvent.click(toggle);
await waitFor(() => expect((toggle as HTMLInputElement).checked).toBe(true));
expect((await gateways.plugin.listProjectSettings("project-1")).settings.plugins).toEqual([
{ pluginId: "dev.acme.unity", enabled: true },
]);
});
it("assigns plugin skills and MCP tools to an agent and keeps stale references removable", async () => {
const gateways = gatewaysWithPlugin();
gateways.plugin._seedProjectPluginSettings("project-1", {
version: 1,
plugins: [{ pluginId: "dev.acme.unity", enabled: true }],
agents: [{
agentId: "agent-1",
skills: [{ pluginId: "missing.plugin", skillId: "removed-skill" }],
tools: [],
}],
});
render(
<DIProvider gateways={gateways}>
<AgentPluginCapabilities projectId="project-1" agentId="agent-1" agentName="Dev" />
</DIProvider>,
);
const skill = await screen.findByRole("checkbox", { name: /unity-debug/ });
fireEvent.click(skill);
await waitFor(() => expect((skill as HTMLInputElement).checked).toBe(true));
expect(screen.getByText(/removed-skill/).textContent).toContain("Obsolète ou manquant");
fireEvent.click(screen.getByLabelText("serveur MCP de plugin"));
fireEvent.click(screen.getByRole("option", { name: "Unity Plugin — Unity Tools" }));
fireEvent.change(screen.getByLabelText("nom du tool MCP"), {
target: { value: "unity_build" },
});
fireEvent.click(screen.getByRole("button", { name: "Attribuer" }));
expect(await screen.findByText("unity_build")).toBeTruthy();
const settings = (await gateways.plugin.listProjectSettings("project-1")).settings;
const agent = settings.agents.find((entry) => entry.agentId === "agent-1");
expect(agent?.skills).toContainEqual({ pluginId: "dev.acme.unity", skillId: "unity.debug" });
expect(agent?.tools).toContainEqual({
pluginId: "dev.acme.unity",
serverId: "unity-tools",
toolName: "unity_build",
});
});
});

View File

@ -0,0 +1,143 @@
import { Panel, Spinner } from "@/shared";
import { useProjectPlugins } from "./useProjectPlugins";
export interface ProjectPluginsPanelProps {
projectId: string;
}
function countLabel(count: number, singular: string, plural: string): string {
return `${count} ${count === 1 ? singular : plural}`;
}
export function ProjectPluginsPanel({ projectId }: ProjectPluginsPanelProps) {
const vm = useProjectPlugins(projectId);
if (vm.loading && !vm.settings) {
return (
<Panel aria-label="plugins du projet" className="flex items-center gap-2">
<Spinner size={14} />
<span className="text-sm text-muted">Chargement des plugins du projet…</span>
</Panel>
);
}
const installedIds = new Set(vm.installed.map((plugin) => plugin.id));
const missing = (vm.settings?.plugins ?? []).filter(
(activation) => !installedIds.has(activation.pluginId),
);
return (
<section aria-label="plugins du projet" className="flex flex-col gap-3">
<div>
<h2 className="text-sm font-semibold text-content">Plugins du projet</h2>
<p className="mt-1 text-xs text-muted">
Un plugin reste installé globalement. Activez-le ici avant d’attribuer ses
capabilities aux agents de ce projet.
</p>
</div>
{vm.error && <p role="alert" className="text-sm text-danger">{vm.error}</p>}
{vm.installed.length === 0 && missing.length === 0 ? (
<Panel><p className="text-sm text-muted">Aucun plugin installé globalement.</p></Panel>
) : (
<ul className="flex flex-col gap-2">
{vm.installed.map((plugin) => {
const enabled = vm.settings?.plugins.some(
(entry) => entry.pluginId === plugin.id && entry.enabled,
) ?? false;
const skills = plugin.contributes.skills ?? [];
const servers = plugin.contributes.mcpServers ?? [];
return (
<li key={plugin.id}>
<Panel className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-content">{plugin.displayName}</span>
<span className="text-xs text-faint">v{plugin.version}</span>
<span className="rounded bg-raised px-1.5 py-0.5 text-[0.65rem] text-muted">
Installé globalement
</span>
</div>
<p className="mt-1 text-xs text-muted">
{countLabel(skills.length, "skill", "skills")} · {countLabel(servers.length, "serveur MCP", "serveurs MCP")}
</p>
</div>
<label className="flex shrink-0 items-center gap-2 text-xs text-content">
<input
type="checkbox"
checked={enabled}
disabled={vm.busy}
onChange={(event) => void vm.setEnabled(plugin.id, event.target.checked)}
/>
Activé pour ce projet
</label>
</div>
<details>
<summary className="cursor-pointer text-xs font-medium text-primary">
Voir les capabilities
</summary>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div>
<h3 className="text-xs font-semibold text-content">Skills fournis</h3>
{skills.length === 0 ? (
<p className="mt-1 text-xs text-muted">Aucun skill fourni.</p>
) : (
<ul className="mt-1 flex flex-col gap-1">
{skills.map((skill) => (
<li key={skill.id} className="text-xs text-muted">
<span className="font-medium text-content">{skill.name}</span>
{skill.description ? ` — ${skill.description}` : ""}
</li>
))}
</ul>
)}
</div>
<div>
<h3 className="text-xs font-semibold text-content">Tools MCP fournis</h3>
{servers.length === 0 ? (
<p className="mt-1 text-xs text-muted">Aucun serveur MCP fourni.</p>
) : (
<ul className="mt-1 flex flex-col gap-1">
{servers.map((server) => (
<li key={server.id} className="text-xs text-muted">
<span className="font-medium text-content">{server.displayName}</span>
<span className="text-faint"> · {server.id}</span>
</li>
))}
</ul>
)}
</div>
</div>
</details>
</Panel>
</li>
);
})}
{missing.map((activation) => (
<li key={activation.pluginId}>
<Panel className="flex items-center justify-between gap-4 border-warning/40">
<div>
<span className="text-sm font-semibold text-content">{activation.pluginId}</span>
<p className="text-xs text-warning">Plugin manquant · configuration conservée</p>
</div>
<label className="flex items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={activation.enabled}
disabled={vm.busy || !activation.enabled}
onChange={(event) => void vm.setEnabled(activation.pluginId, event.target.checked)}
/>
Activé pour ce projet
</label>
</Panel>
</li>
))}
</ul>
)}
</section>
);
}

View File

@ -1,5 +1,7 @@
export { PluginRuntimeProvider, usePluginRuntime, type PluginRuntimeContextValue } from "./PluginRuntimeProvider";
export { PluginsPanel } from "./PluginsPanel";
export { ProjectPluginsPanel } from "./ProjectPluginsPanel";
export { AgentPluginCapabilities } from "./AgentPluginCapabilities";
export { usePlugins, type PluginsViewModel } from "./usePlugins";
export { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus";
export { usePluginMenus, type UsePluginMenusResult } from "./usePluginMenus";

View File

@ -0,0 +1,105 @@
import { useCallback, useEffect, useState } from "react";
import { useGateways } from "@/app/di";
import type {
GatewayError,
ProjectPluginManifest,
ProjectPluginSettings,
} from "@/domain";
import type { MutateAgentPluginToolInput } from "@/ports";
function describe(error: unknown): string {
if (error && typeof error === "object" && "message" in error) {
return String((error as GatewayError).message);
}
return String(error);
}
export interface ProjectPluginsViewModel {
installed: ProjectPluginManifest[];
settings: ProjectPluginSettings | null;
loading: boolean;
busy: boolean;
error: string | null;
refresh(): Promise<void>;
setEnabled(pluginId: string, enabled: boolean): Promise<void>;
setSkillAssigned(agentId: string, pluginId: string, skillId: string, assigned: boolean): Promise<void>;
setToolAssigned(input: Omit<MutateAgentPluginToolInput, "projectId">, assigned: boolean): Promise<void>;
}
export function useProjectPlugins(projectId: string): ProjectPluginsViewModel {
const { plugin } = useGateways();
const [installed, setInstalled] = useState<ProjectPluginManifest[]>([]);
const [settings, setSettings] = useState<ProjectPluginSettings | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const view = await plugin.listProjectSettings(projectId);
setInstalled(view.installed);
setSettings(view.settings);
} catch (cause) {
setError(describe(cause));
} finally {
setLoading(false);
}
}, [plugin, projectId]);
useEffect(() => {
void refresh();
}, [refresh]);
const mutate = useCallback(async (operation: () => Promise<ProjectPluginSettings>) => {
setBusy(true);
setError(null);
try {
setSettings(await operation());
} catch (cause) {
setError(describe(cause));
} finally {
setBusy(false);
}
}, []);
const setEnabled = useCallback(
(pluginId: string, enabled: boolean) =>
mutate(() => plugin.setProjectEnabled({ projectId, pluginId, enabled })),
[mutate, plugin, projectId],
);
const setSkillAssigned = useCallback(
(agentId: string, pluginId: string, skillId: string, assigned: boolean) => {
const input = { projectId, agentId, pluginId, skillId };
return mutate(() =>
assigned ? plugin.assignAgentSkill(input) : plugin.unassignAgentSkill(input),
);
},
[mutate, plugin, projectId],
);
const setToolAssigned = useCallback(
(input: Omit<MutateAgentPluginToolInput, "projectId">, assigned: boolean) => {
const scoped = { projectId, ...input };
return mutate(() =>
assigned ? plugin.assignAgentTool(scoped) : plugin.unassignAgentTool(scoped),
);
},
[mutate, plugin, projectId],
);
return {
installed,
settings,
loading,
busy,
error,
refresh,
setEnabled,
setSkillAssigned,
setToolAssigned,
};
}

View File

@ -908,6 +908,7 @@ export function ProjectsView() {
// while the menu bar above stays visible.
<SettingsView
section={settingsSection}
projectId={active?.id}
onSectionChange={setSettingsSection}
onClose={() => setSettingsSection(null)}
/>

View File

@ -48,12 +48,14 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
interface SettingsViewProps {
section: SettingsSection;
projectId?: string;
onSectionChange: (section: SettingsSection) => void;
onClose: () => void;
}
export function SettingsView({
section,
projectId,
onSectionChange,
onClose,
}: SettingsViewProps) {
@ -97,7 +99,7 @@ export function SettingsView({
) : section === "deployment" ? (
<DeploymentSettings />
) : section === "plugins" ? (
<PluginsPanel />
<PluginsPanel projectId={projectId} />
) : (
// No `onSessionEnded`: the desktop app hosts the server and is never
// itself a paired device, so it cannot revoke its own session.

View File

@ -218,6 +218,7 @@ function layoutTypesFromContributes(contributes: PluginContributionDto): Set<str
function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto {
const contributes = objectOrEmpty(entry.contributes) as Partial<PluginContributionDto>;
return {
skills: arrayOrEmpty(contributes?.skills),
menus: arrayOrEmpty(contributes?.menus),
menuItems: arrayOrEmpty(contributes?.menuItems),
slashCommands: arrayOrEmpty(contributes?.slashCommands),

View File

@ -59,6 +59,8 @@ import type {
PluginProjectStructure,
PluginReview,
PluginRuntimeContributionCatalog,
ProjectPluginSettings,
ProjectPluginSettingsView,
PluginSourceKind,
PluginUninstallResult,
PluginWorkspaceBinaryFile,
@ -1549,6 +1551,27 @@ export interface ReviewPluginPackageInput {
path: string;
}
export interface SetProjectPluginEnabledInput {
projectId: string;
pluginId: string;
enabled: boolean;
}
export interface MutateAgentPluginSkillInput {
projectId: string;
agentId: string;
pluginId: string;
skillId: string;
}
export interface MutateAgentPluginToolInput {
projectId: string;
agentId: string;
pluginId: string;
serverId: string;
toolName: string;
}
/**
* Plugin system gateway (ticket #43, F1) — admin CRUD + the bootstrap catalog
* the runtime loader consumes. Mirrors the Tauri commands in carnet §5
@ -1562,6 +1585,12 @@ export interface PluginGateway {
setEnabled(pluginId: string, enabled: boolean): Promise<PluginAdmin>;
uninstall(pluginId: string): Promise<PluginUninstallResult>;
listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog>;
listProjectSettings(projectId: string): Promise<ProjectPluginSettingsView>;
setProjectEnabled(input: SetProjectPluginEnabledInput): Promise<ProjectPluginSettings>;
assignAgentSkill(input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings>;
unassignAgentSkill(input: MutateAgentPluginSkillInput): Promise<ProjectPluginSettings>;
assignAgentTool(input: MutateAgentPluginToolInput): Promise<ProjectPluginSettings>;
unassignAgentTool(input: MutateAgentPluginToolInput): Promise<ProjectPluginSettings>;
openPluginsFolder(pluginId?: string): Promise<void>;
}