Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/agents/agent-skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ Plugin skills have the lowest precedence within their scope and are read-only. A

Global plugins can be installed from git via **Settings → Plugins** (paste a git URL or `owner/repo[@ref]`). The preview lists the full package before anything is written. Select which skills and MCP servers to import; both groups start with all current items selected and support **Select all** and **Clear**. You can install with neither group selected. Agents, workflows, slash commands, and hooks are unaffected by this choice; review their disclosures too. Imported MCP servers remain disabled until you enable them per workspace.

Installed rows show imported-of-available counts. For a managed, present plugin, choose **Add components** (or **Add Plugin Components…** in the command palette) to import more from its locked installed version without fetching the remote. Previously imported components are checked and read-only. Select additions and confirm, or cancel without changing anything. If the installed version changes during review, the inventory refreshes and you must select again. Removing imports is not supported by this flow.
Installed rows show imported-of-available counts. For a managed, present plugin, choose **Manage components** (or **Manage Plugin Components…** in the command palette) to change which skills and MCP servers are imported from its locked installed version without fetching the remote. Deselect imported components, select new ones, or use **Select all** and **Clear** for a whole group. Review the add/remove counts and choose **Save changes**, or **Cancel** to discard your draft. Saving an empty selection keeps the plugin installed with no skills or MCP servers imported; uninstall is a separate action. If the installed files or saved selection change during review, the inventory refreshes and you must review and save again.

Removing imports does not delete source files, plugin data, or workspace MCP settings (including enablement and tool allowlists). New MCP imports remain disabled until enabled per workspace; re-adding a server honors its saved workspace enablement. Agents, hooks, workflows, and slash commands are not changed by this selection. Legacy installs continue to import all components until you save a different selection. Once a selection is explicit, updates preserve it and do not automatically import newly added components.

Updates preserve explicit selections: newly available skills and MCP servers stay unimported until you add them. Updates still require consent for full-package capability changes, including unimported components. Older managed installs without a saved selection continue importing everything, including new components on update. Unmanaged and project-local plugins are unchanged.

Expand Down
240 changes: 159 additions & 81 deletions src/browser/features/Settings/Sections/PluginsSettingsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,8 @@ const ComponentChooser: React.FC<{
detail: `${server.transport} · ${server.summary}`,
}));
const imported = props.imported?.[group] ?? [];
const selectable = entries
.map((entry) => entry.name)
.filter((name) => !imported.includes(name));
const count = entries.filter(
({ name }) => imported.includes(name) || props.selected[group].includes(name)
).length;
const selectable = entries.map((entry) => entry.name);
const count = entries.filter(({ name }) => props.selected[group].includes(name)).length;
const change = (names: string[]) => props.onChange({ ...props.selected, [group]: names });
return (
<fieldset
Expand Down Expand Up @@ -131,8 +127,8 @@ const ComponentChooser: React.FC<{
<label key={name} className="flex items-start gap-2 text-xs">
<Checkbox
aria-label={name}
checked={imported.includes(name) || props.selected[group].includes(name)}
disabled={props.disabled || imported.includes(name)}
checked={props.selected[group].includes(name)}
disabled={props.disabled}
onCheckedChange={(checked) =>
change(
checked === true
Expand All @@ -154,21 +150,53 @@ const ComponentChooser: React.FC<{
);
})}
<p className="text-muted text-[11px]">
Importing MCP servers only makes them available; they stay disabled until enabled per
workspace.
{props.imported
? "New MCP imports need workspace opt-in. Re-adding a server honors its saved workspace enablement."
: "Importing MCP servers only makes them available; they stay disabled until enabled per workspace."}
</p>
<p className="text-muted hidden text-[11px] md:block">
Tab to navigate · Space to select · Enter to activate buttons
</p>
</div>
);

const AddComponentsPanel: React.FC<{
function effectiveImports(inventory: AgentPluginComponents): AgentPluginImportedComponents {
// Manage saves replace the selection; carrying unavailable names would defeat Clear/empty consent.
return {
skills: inventory.skills
.map((skill) => skill.name)
.filter(
(name) =>
!inventory.importedComponents || inventory.importedComponents.skills.includes(name)
Comment thread
ThomasK33 marked this conversation as resolved.
),
mcpServers: inventory.mcpServers
.map((server) => server.serverName)
.filter(
(name) =>
!inventory.importedComponents || inventory.importedComponents.mcpServers.includes(name)
),
};
}

function sameImports(
a: AgentPluginImportedComponents | null,
b: AgentPluginImportedComponents | null
): boolean {
if (a === null || b === null) return a === b;
return (["skills", "mcpServers"] as const).every(
(group) =>
a[group].every((name) => b[group].includes(name)) &&
b[group].every((name) => a[group].includes(name))
);
}

const ManageComponentsPanel: React.FC<{
name: string;
onAdded: () => Promise<void>;
onSaved: () => Promise<void>;
onClose: () => void;
}> = (props) => {
const { api } = useAPI();
// Keep the raw baseline (including legacy absence) separate from the visible selection.
const [inventory, setInventory] = useState<AgentPluginComponents | null>(null);
const [selected, setSelected] = useState<AgentPluginImportedComponents>({
skills: [],
Expand All @@ -177,7 +205,7 @@ const AddComponentsPanel: React.FC<{
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(true);
const [loadAttempt, setLoadAttempt] = useState(0);
const [added, setAdded] = useState(false);
const [saved, setSaved] = useState(false);
const name = props.name;

useEffect(() => {
Expand All @@ -186,8 +214,10 @@ const AddComponentsPanel: React.FC<{
api.agentPlugins.getComponents({ name }).then(
(result) => {
if (ignore) return;
if (result.success) setInventory(result.data);
else setError(result.error);
if (result.success) {
setInventory(result.data);
setSelected(effectiveImports(result.data));
} else setError(result.error);
setBusy(false);
},
(err: unknown) => {
Expand All @@ -202,101 +232,149 @@ const AddComponentsPanel: React.FC<{
};
}, [api, name, loadAttempt]);

const handleAdd = async () => {
if (!api || !inventory || busy || selected.skills.length + selected.mcpServers.length === 0)
return;
const imported = inventory ? effectiveImports(inventory) : { skills: [], mcpServers: [] };
const added = (["skills", "mcpServers"] as const).reduce(
(count, group) =>
count + selected[group].filter((name) => !imported[group].includes(name)).length,
0
);
const removed = (["skills", "mcpServers"] as const).reduce(
(count, group) =>
count + imported[group].filter((name) => !selected[group].includes(name)).length,
0
);
const handleSave = async () => {
if (!api || !inventory || busy || added + removed === 0) return;
setBusy(true);
setError(null);
setAdded(false);
setSaved(false);
let confirmed = false;
let responseLost = false;
let savedImports: AgentPluginImportedComponents | null = selected;
let warning: string | undefined;
try {
const result = await api.agentPlugins.addComponents({
name,
expectedLockedSha: inventory.lockedSha,
expectedContentHash: inventory.contentHash,
...selected,
});
const result = await api.agentPlugins
.setComponents({
name,
expectedLockedSha: inventory.lockedSha,
expectedContentHash: inventory.contentHash,
expectedImportedComponents: inventory.importedComponents ?? null,
importedComponents: selected,
})
.catch((err: unknown) => {
responseLost = true;
throw err;
});
if (!result.success) throw new Error(result.error);
setInventory({ ...inventory, importedComponents: result.data.importedComponents });
setSelected({ skills: [], mcpServers: [] });
setAdded(true);
confirmed = true;
warning = result.cleanupWarning;
savedImports = result.data.importedComponents ?? null;
publishAgentPluginsMutated();
await props.onAdded();
} catch (err) {
setError(getErrorMessage(err));
// Compare receipts rather than parsing backend error prose. Never retry choices
// against a different installed version without another explicit selection.
try {
const current = await api.agentPlugins.getComponents({ name });
if (
current.success &&
(current.data.lockedSha !== inventory.lockedSha ||
current.data.contentHash !== inventory.contentHash)
) {
setInventory(current.data);
setSelected({ skills: [], mcpServers: [] });
setError(
"The installed plugin changed. Inventory refreshed; select components again before confirming."
);
}
} catch {
/* Keep the original mutation error and choices when the inventory is unreachable. */
}
// Refetch even after a lost response: a transport failure does not mean the write failed.
try {
const current = await api.agentPlugins.getComponents({ name });
if (!current.success) throw new Error(current.error);
const sameTree =
current.data.lockedSha === inventory.lockedSha &&
current.data.contentHash === inventory.contentHash;
if (
(confirmed || responseLost) &&
sameTree &&
sameImports(current.data.importedComponents ?? null, savedImports)
) {
setInventory(current.data);
setSelected(effectiveImports(current.data));
setSaved(true);
setError(warning ?? null);
if (!confirmed) publishAgentPluginsMutated();
await props.onSaved();
} else if (
confirmed ||
!sameTree ||
!sameImports(current.data.importedComponents ?? null, inventory.importedComponents ?? null)
) {
setSaved(false);
setInventory(current.data);
setSelected(effectiveImports(current.data));
setError(
"The installed plugin or selection changed. Inventory refreshed; review your choices and save again."
);
// A rejected stale save still discovered newer counts for the surrounding card.
await props.onSaved();
}
} catch (err) {
setError(
confirmed
? `Components saved, but refreshing failed: ${getErrorMessage(err)}. Reopen to read the saved selection.`
: `Could not confirm the saved selection: ${getErrorMessage(err)}. Reopen to refresh before retrying.`
);
Comment thread
ThomasK33 marked this conversation as resolved.
if (confirmed || responseLost) {
// Refresh counts for acknowledged or possible writes even without an inventory receipt.
if (!confirmed) publishAgentPluginsMutated();
await props.onSaved();
}
} finally {
setBusy(false);
}
};
const imported = inventory?.importedComponents ?? {
skills: inventory?.skills.map((skill) => skill.name) ?? [],
mcpServers: inventory?.mcpServers.map((server) => server.serverName) ?? [],
};
const allImported =
inventory &&
inventory.skills.every((skill) => imported.skills.includes(skill.name)) &&
inventory.mcpServers.every((server) => imported.mcpServers.includes(server.serverName));
return (
<div className="border-border-medium bg-background-secondary mt-2 space-y-3 rounded-md border p-3">
<p className="text-foreground text-xs">
Add components from the installed version
Manage components from the installed version
{inventory ? ` · ${inventory.lockedSha.slice(0, 12)}` : ""}. No remote fetch.
</p>
<p className="text-muted text-xs">
Removing imports keeps source files, plugin data, and workspace MCP settings. Uninstall is
separate.
</p>
{busy && (
<p role="status" className="text-muted text-xs">
{inventory ? "Adding components…" : "Loading components…"}
{inventory ? "Saving components…" : "Loading components…"}
</p>
)}
{error && (
<p role="alert" className="text-destructive text-xs break-words">
{error}
</p>
)}
{added && (
{saved && (
<p role="status" className="text-accent text-xs">
Components imported.
Component selection saved.
</p>
)}
{inventory && (
<ComponentChooser
inventory={inventory}
imported={imported}
selected={selected}
disabled={busy}
onChange={(selection) => {
setSelected(selection);
setAdded(false);
}}
/>
)}
{allImported && (
<p className="text-muted text-xs">All available components are already imported.</p>
<>
<ComponentChooser
inventory={inventory}
imported={imported}
selected={selected}
disabled={busy}
onChange={(selection) => {
setSelected(selection);
setSaved(false);
}}
/>
<p className="text-muted counter-nums text-xs">
{added} to add · {removed} to remove
</p>
{selected.skills.length + selected.mcpServers.length === 0 && (
<p className="text-muted text-xs">
No skills or MCP servers will be imported. The plugin stays installed.
</p>
)}
</>
)}
<div className="flex flex-wrap gap-2">
{inventory ? (
<Button
size="sm"
disabled={busy || selected.skills.length + selected.mcpServers.length === 0}
onClick={() => void handleAdd()}
disabled={busy || added + removed === 0}
onClick={() => void handleSave()}
>
Import selected
Save changes
</Button>
) : (
<Button
Expand All @@ -312,7 +390,7 @@ const AddComponentsPanel: React.FC<{
</Button>
)}
<Button variant="ghost" size="sm" disabled={busy} onClick={props.onClose}>
{added ? "Done" : "Cancel"}
{saved ? "Done" : "Cancel"}
</Button>
</div>
</div>
Expand Down Expand Up @@ -752,7 +830,7 @@ export const PluginsSettingsSection: React.FC = () => {
initialIntent?.type === "confirm-uninstall" ? initialIntent.name : null
);
const [componentsTarget, setComponentsTarget] = useState<string | null>(
initialIntent?.type === "add-components" ? initialIntent.name : null
initialIntent?.type === "manage-components" ? initialIntent.name : null
);
const [installSucceeded, setInstallSucceeded] = useState(false);
/** Name of the plugin with an update/uninstall in flight. */
Expand Down Expand Up @@ -841,7 +919,7 @@ export const PluginsSettingsSection: React.FC = () => {
case "open-add-panel":
openAddPanel();
break;
case "add-components":
case "manage-components":
setComponentsTarget(intent.name);
break;
case "confirm-uninstall":
Expand Down Expand Up @@ -1113,9 +1191,9 @@ export const PluginsSettingsSection: React.FC = () => {
className="h-7 px-2 text-xs"
disabled={busyPlugin !== null}
onClick={() => setComponentsTarget(item.name)}
aria-label={`Add components to ${item.name}`}
aria-label={`Manage components for ${item.name}`}
>
Add components
Manage components
</Button>
)}
{updateAvailable && (
Expand Down Expand Up @@ -1159,10 +1237,10 @@ export const PluginsSettingsSection: React.FC = () => {
managed-registry name, so the managed row is the one
identity-correct anchor. */}
{item.managed && item.present && componentsTarget === item.name && (
<AddComponentsPanel
<ManageComponentsPanel
key={item.name}
name={item.name}
onAdded={refresh}
onSaved={refresh}
onClose={() => setComponentsTarget(null)}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type PluginsSectionIntent =
/** Expand the Add Plugin form. */
| { type: "open-add-panel" }
/** Review additional skills/MCP from a managed installed tree. */
| { type: "add-components"; name: string }
| { type: "manage-components"; name: string }
/** Open the uninstall confirmation for a managed plugin. */
| { type: "confirm-uninstall"; name: string }
/** Show the in-place update review for a capability-changing update the palette previewed. */
Expand Down
Loading
Loading