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

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