) : null}
- {warning?.kind === "conflict" ? (
+ {conflict !== null ? (
}>
@@ -89,7 +89,7 @@ const SkillListItem = memo(function SkillListItem({
- {warning.detail}
+ {conflict}
) : null}
diff --git a/apps/web/src/components/settings/ToolsSettings.tsx b/apps/web/src/components/settings/ToolsSettings.tsx
index 0b8d203e0523..ac466840e817 100644
--- a/apps/web/src/components/settings/ToolsSettings.tsx
+++ b/apps/web/src/components/settings/ToolsSettings.tsx
@@ -61,7 +61,7 @@ import {
SKILL_GROUP_HINTS,
SKILL_GROUP_LABELS,
skillAgents,
- skillAttention,
+ skillConflict,
type SkillGroupKind,
type SkillRow,
skillRowDetails,
@@ -352,11 +352,11 @@ function SkillsPanel({
rows
.filter((row) => {
const details = detailsByName.get(row.name);
- return details !== undefined && skillAttention(row, details, agents) !== null;
+ return details !== undefined && skillConflict(row, details) !== null;
})
.map((row) => row.name),
),
- [agents, detailsByName, rows],
+ [detailsByName, rows],
);
const visibleRows = useMemo(
() =>
@@ -465,7 +465,7 @@ function SkillsPanel({
size="sm"
variant={onlyAttention ? "secondary" : "outline"}
aria-pressed={onlyAttention}
- aria-label={`Show only skills that need a look (${attentionNames.size})`}
+ aria-label={`Show only skills whose name is used more than once (${attentionNames.size})`}
onClick={() => setOnlyAttention((value) => !value)}
/>
}
@@ -476,7 +476,7 @@ function SkillsPanel({
{onlyAttention
? "Show all skills."
- : "Show only skills that need a look: one name used by different skills, or a skill some of your agents don't load."}
+ : "Show only skills whose name is used by different skills. One switch turns all of them on or off."}
) : null}
@@ -520,7 +520,7 @@ function SkillsPanel({
diff --git a/apps/web/src/components/settings/toolsSettings.logic.test.ts b/apps/web/src/components/settings/toolsSettings.logic.test.ts
index 8143e52cdf73..f0ca84016c62 100644
--- a/apps/web/src/components/settings/toolsSettings.logic.test.ts
+++ b/apps/web/src/components/settings/toolsSettings.logic.test.ts
@@ -15,8 +15,8 @@ import {
parseMcpServerJson,
formatArgs,
skillAgents,
- skillAttention,
skillAvailability,
+ skillConflict,
skillBody,
skillRowDetails,
splitRowsBySource,
@@ -256,13 +256,15 @@ describe("skill availability and attention", () => {
]);
});
- it("flags a skill some agents miss, but not a plugin or built-in one", () => {
- expect(skillAttention(row("personal", ["codex"]), noDetails, agents)?.detail).toBe(
- "Not available to Claude.",
- );
- expect(skillAttention(row("plugin", ["codex"]), noDetails, agents)).toBeNull();
- expect(skillAttention(row("system", ["codex"]), noDetails, agents)).toBeNull();
- expect(skillAttention(row("project", ["claudeAgent", "codex"]), noDetails, agents)).toBeNull();
+ it("flags only a name used by different skills, not one some agents don't load", () => {
+ expect(skillConflict(row("personal", ["codex"]), noDetails)).toBeNull();
+ expect(
+ skillConflict(row("personal", ["codex"]), {
+ ...noDetails,
+ copies: [noDetails.folder, noDetails.folder].filter((copy) => copy !== null),
+ conflict: true,
+ }),
+ ).toMatch(/^Different skills are named “review”/);
});
it("strips SKILL.md's header for the rendered view", () => {
diff --git a/apps/web/src/components/settings/toolsSettings.logic.ts b/apps/web/src/components/settings/toolsSettings.logic.ts
index fdcd5c29aef9..d8b23a26135a 100644
--- a/apps/web/src/components/settings/toolsSettings.logic.ts
+++ b/apps/web/src/components/settings/toolsSettings.logic.ts
@@ -308,34 +308,14 @@ export function availabilityNote(value: SkillAvailability): string {
}
/**
- * Why a skill needs a look, in one plain sentence, or null. Only a conflict
- * marks the row itself; the rest show in the list's attention filter and on
- * the skill's page.
+ * What to do about a skill that needs a look, or null. Only a name used by
+ * different skills does: its one switch can't tell them apart, and removing
+ * or renaming a copy fixes it. Which agents load a skill is shown, not flagged.
*/
-export interface SkillAttention {
- readonly kind: "conflict" | "missing";
- readonly detail: string;
-}
-
-export function skillAttention(
- row: SkillRow,
- details: SkillRowDetails,
- agents: ReadonlyArray,
-): SkillAttention | null {
- if (details.conflict) {
- return {
- kind: "conflict",
- detail: `Different skills are named “${row.name}”. Its switch turns all of them on or off.`,
- };
- }
- // A plugin or built-in skill belongs to its agent; others never load it.
- if (row.group !== "project" && row.group !== "personal") return null;
- const availability = skillAvailability(row, agents);
- if (availability.missing.length === 0) return null;
- return {
- kind: "missing",
- detail: `Not available to ${joinNames(availability.missing.map((agent) => agent.displayName))}.`,
- };
+export function skillConflict(row: SkillRow, details: SkillRowDetails): string | null {
+ return details.conflict
+ ? `Different skills are named “${row.name}”, and its switch turns them all on or off. Remove or rename the copy you don't want.`
+ : null;
}
/** Rows with no recorded source show first; a pack collapses past this many rows. */
diff --git a/docs/user/tools.md b/docs/user/tools.md
index 83384521a7aa..e1f22bdfff30 100644
--- a/docs/user/tools.md
+++ b/docs/user/tools.md
@@ -20,7 +20,8 @@ With a project selected, a switch overrides the environment for that project onl
skill off for one project, or back on where the environment has it off.
A **Conflict** mark means two different skills share a name. The switch turns both of them on or
-off. **View files** in a skill's menu shows what is in its folder; a skill marked **Includes
+off, so remove or rename the copy you don't want; the warning button beside the search lists every
+conflict. **View files** in a skill's menu shows what is in its folder; a skill marked **Includes
scripts** has files an agent could run.
### Adding skills
From 88b1ea3305da6be4892f9fbc4f8f543ab99ddf53 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sat, 10 Oct 2026 21:53:04 -0700
Subject: [PATCH 16/26] fix(web): pick skills to add from dense rows, with
select all and a filter
Each skill in the Add skills picker took three lines, so a big source showed
three at a time. Rows now match the Skills list: name and one-line
description, a script mark, and the file count on the right. A header box
selects or clears every skill shown, and a source with many skills gets a
filter.
Co-Authored-By: Claude Opus 5.5
---
.../components/settings/AddSkillsDialog.tsx | 152 +++++++++++++-----
1 file changed, 113 insertions(+), 39 deletions(-)
diff --git a/apps/web/src/components/settings/AddSkillsDialog.tsx b/apps/web/src/components/settings/AddSkillsDialog.tsx
index f51e3b3f1f33..9b41e1159534 100644
--- a/apps/web/src/components/settings/AddSkillsDialog.tsx
+++ b/apps/web/src/components/settings/AddSkillsDialog.tsx
@@ -4,12 +4,12 @@ import {
type SkillInstallTarget,
type SkillPreviewItem,
} from "@t3tools/contracts";
-import { useId, useState } from "react";
+import { AlertTriangleIcon, SearchIcon } from "lucide-react";
+import { useId, useMemo, useState } from "react";
import { serverEnvironment } from "../../state/server";
import { formatEnvironmentQueryError } from "../../state/query";
import { useAtomCommand } from "../../state/use-atom-command";
-import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Checkbox } from "../ui/checkbox";
import {
@@ -22,12 +22,18 @@ import {
DialogTitle,
} from "../ui/dialog";
import { Input } from "../ui/input";
+import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
import { Label } from "../ui/label";
import { Spinner } from "../ui/spinner";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+
+/** A source this big gets a filter above its list. */
+const FILTER_FROM = 8;
/**
* Install skills from a source, as `npx skills add` does: paste the source,
* pick from the skills it holds, install for every agent at the current scope.
+ * The picker uses the Skills list's dense one-line rows.
*/
export function AddSkillsDialog({
open,
@@ -54,6 +60,7 @@ export function AddSkillsDialog({
readonly skills: ReadonlyArray;
} | null>(null);
const [selected, setSelected] = useState>(new Set());
+ const [filter, setFilter] = useState("");
const [busy, setBusy] = useState<"finding" | "installing" | null>(null);
const [error, setError] = useState(null);
@@ -70,6 +77,7 @@ export function AddSkillsDialog({
return;
}
setFound({ source: trimmed, skills: result.value.skills });
+ setFilter("");
// Everything is ticked, up to what one install takes.
setSelected(
new Set(result.value.skills.slice(0, MAX_SKILLS_PER_INSTALL).map((skill) => skill.name)),
@@ -111,6 +119,28 @@ export function AddSkillsDialog({
return next;
});
const full = selected.size >= MAX_SKILLS_PER_INSTALL;
+ const shown = useMemo(() => {
+ const needle = filter.trim().toLowerCase();
+ if (found === null || needle.length === 0) return found?.skills ?? [];
+ return found.skills.filter(
+ (skill) =>
+ skill.folder.toLowerCase().includes(needle) ||
+ skill.name.toLowerCase().includes(needle) ||
+ (skill.description ?? "").toLowerCase().includes(needle),
+ );
+ }, [filter, found]);
+ // The header box acts on the rows shown, so a filter can pick a subset.
+ const shownSelected = shown.filter((skill) => selected.has(skill.name)).length;
+ const allShown = shown.length > 0 && shownSelected === shown.length;
+ const selectShown = (checked: boolean) =>
+ setSelected((current) => {
+ const next = new Set(current);
+ for (const skill of shown) {
+ if (!checked) next.delete(skill.name);
+ else if (next.size < MAX_SKILLS_PER_INSTALL) next.add(skill.name);
+ }
+ return next;
+ });
return (
-
+
Add skills
@@ -166,43 +196,82 @@ export function AddSkillsDialog({
{found !== null ? (
-
-
- {found.skills.length === 1 ? "1 skill" : `${found.skills.length} skills`}
-
-
- {found.skills.map((skill) => (
-
- toggle(skill.name, checked === true)}
- />
-
-
- {skill.folder}
+
+ Skills in {found.source}
+ {found.skills.length >= FILTER_FROM ? (
+
+
+
+
+ setFilter(event.target.value)}
+ aria-label="Filter skills"
+ />
+
+ ) : null}
+
+
+ 0 && !allShown}
+ disabled={shown.length === 0}
+ onCheckedChange={(checked) => selectShown(checked === true)}
+ />
+
+ {filter.trim() === "" ? "Select all" : "Select all shown"}
+
+
+ {selected.size} of {found.skills.length} selected
+
+
+
+ {shown.length === 0 ? (
+
+ No skills match “{filter.trim()}”.
+
+ ) : null}
+ {shown.map((skill) => (
+
+
+ toggle(skill.name, checked === true)}
+ />
+
+
+ {skill.folder}
+
+
+ {skill.description || "No description."}
+
+
{skill.scripts ? (
-
- Includes scripts
-
+
+
+ }
+ >
+
+
+ Includes scripts
+
) : null}
-
- {skill.description ? (
-
- {skill.description}
+
+ {skill.files.length === 1 ? "1 file" : `${skill.files.length} files`}
+ {skill.filesTruncated ? "+" : ""}
- ) : null}
-
- {skill.files.length === 1 ? "1 file" : `${skill.files.length} files`}
- {skill.filesTruncated ? " or more" : ""}
-
-
-
- ))}
+
+
+ ))}
+
{found.skills.length > MAX_SKILLS_PER_INSTALL ? (
@@ -210,8 +279,13 @@ export function AddSkillsDialog({
afterwards.
) : null}
-
- Skills can tell agents to run their scripts. Install only sources you trust.
+
+
+ marks skills with scripts agents can be told to run. Install only sources you
+ trust.
) : null}
From 4ae47262708e8602f544bad013ed4c73195bf65a Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sat, 10 Oct 2026 21:57:12 -0700
Subject: [PATCH 17/26] fix(web): show a skill's whole description when its
line is cut off
Hovering a description that ends in an ellipsis, in the Skills list or the
Add skills picker, shows the full text. The fit is checked as the tooltip
opens, so a line that fits shows none and the lists measure nothing until
hovered.
Co-Authored-By: Claude Opus 5.5
---
.../components/settings/AddSkillsDialog.tsx | 8 +++--
.../web/src/components/settings/SkillList.tsx | 14 ++++++---
.../src/components/settings/TruncatedText.tsx | 31 +++++++++++++++++++
3 files changed, 45 insertions(+), 8 deletions(-)
create mode 100644 apps/web/src/components/settings/TruncatedText.tsx
diff --git a/apps/web/src/components/settings/AddSkillsDialog.tsx b/apps/web/src/components/settings/AddSkillsDialog.tsx
index 9b41e1159534..4614c4cb4339 100644
--- a/apps/web/src/components/settings/AddSkillsDialog.tsx
+++ b/apps/web/src/components/settings/AddSkillsDialog.tsx
@@ -26,6 +26,7 @@ import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"
import { Label } from "../ui/label";
import { Spinner } from "../ui/spinner";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+import { TruncatedText } from "./TruncatedText";
/** A source this big gets a filter above its list. */
const FILTER_FROM = 8;
@@ -245,9 +246,10 @@ export function AddSkillsDialog({
{skill.folder}
-
- {skill.description || "No description."}
-
+
{skill.scripts ? (
diff --git a/apps/web/src/components/settings/SkillList.tsx b/apps/web/src/components/settings/SkillList.tsx
index 9f71d1b8e30d..69c900075a98 100644
--- a/apps/web/src/components/settings/SkillList.tsx
+++ b/apps/web/src/components/settings/SkillList.tsx
@@ -8,6 +8,7 @@ import { Switch } from "../ui/switch";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { SettingsGroup } from "./SettingsGroup";
import { SkillAgents } from "./SkillAgents";
+import { TruncatedText } from "./TruncatedText";
import {
SOURCE_GROUP_PREVIEW,
skillAvailability,
@@ -69,11 +70,14 @@ const SkillListItem = memo(function SkillListItem({
>
{row.name}
-
- {lockedOff
- ? "Turned off in the agent's own settings."
- : row.description || "No description yet."}
-
+
{overridden ? (
diff --git a/apps/web/src/components/settings/TruncatedText.tsx b/apps/web/src/components/settings/TruncatedText.tsx
new file mode 100644
index 000000000000..ae0172484dbd
--- /dev/null
+++ b/apps/web/src/components/settings/TruncatedText.tsx
@@ -0,0 +1,31 @@
+import { useRef } from "react";
+
+import { cn } from "../../lib/utils";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+
+/**
+ * One line of text that ends in an ellipsis when it doesn't fit, with the
+ * whole text in a tooltip on hover. The fit is checked as the tooltip opens,
+ * so a line that fits never shows one and long lists measure nothing until
+ * hovered.
+ */
+export function TruncatedText({ text, className }: { text: string; className?: string }) {
+ const ref = useRef(null);
+ return (
+ {
+ const element = ref.current;
+ if (open && element !== null && element.scrollWidth <= element.clientWidth) {
+ details.cancel();
+ }
+ }}
+ >
+ }>
+ {text}
+
+
+ {text}
+
+
+ );
+}
From 57758db86de64d1e43005b693c63d6445006fc3e Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sat, 10 Oct 2026 22:34:59 -0700
Subject: [PATCH 18/26] feat: remove any skill in the agents' own skill folders
Remove only reached skills the skills CLI had installed, so most of a
skill folder could be switched off but not deleted. A skill's menu now has
Remove for any copy an agent finds directly in its own skill folders, at
home or in the project. One the CLI installed still goes through the CLI;
another is deleted from each folder an agent reports for it, links first,
along with the folder a link points to when that is one of the user's own.
Plugin and built-in skills stay switch-only.
Co-Authored-By: Claude Opus 5.5
---
apps/server/src/mcp/toolkits/tools/tools.ts | 2 +-
apps/server/src/skills/SkillLibrary.test.ts | 61 ++++++++++++
apps/server/src/skills/SkillLibrary.ts | 97 +++++++++++++++++--
.../src/components/settings/ToolsSettings.tsx | 28 ++++--
.../settings/toolsSettings.logic.test.ts | 1 +
.../settings/toolsSettings.logic.ts | 13 ++-
docs/user/tools.md | 4 +-
packages/contracts/src/skills.ts | 16 ++-
.../src/server/AgentSkillFolders.ts | 13 +++
9 files changed, 217 insertions(+), 18 deletions(-)
diff --git a/apps/server/src/mcp/toolkits/tools/tools.ts b/apps/server/src/mcp/toolkits/tools/tools.ts
index ffcef1d376bd..0a8c8a608fd9 100644
--- a/apps/server/src/mcp/toolkits/tools/tools.ts
+++ b/apps/server/src/mcp/toolkits/tools/tools.ts
@@ -89,7 +89,7 @@ const SkillsInstallTool = Tool.make("t3_skills_install", {
const SkillsRemoveTool = Tool.make("t3_skills_remove", {
...shared,
description:
- "Remove a skill installed from a source (with t3_skills_install, the Tools page, or `npx skills`), with every agent's link to it. Requires a live full-access/default calling thread.",
+ "Remove a skill from the agents' own skill folders for this environment or one project, with every agent's link to it: one installed from a source (with t3_skills_install, the Tools page, or `npx skills`) or put there by hand. Plugin and built-in skills can only be switched off with t3_tools_update. Requires a live full-access/default calling thread.",
parameters: Schema.Struct({ ...Scope, name: InstalledSkillName }),
success: Schema.Struct({}),
}).annotate(Tool.Destructive, true);
diff --git a/apps/server/src/skills/SkillLibrary.test.ts b/apps/server/src/skills/SkillLibrary.test.ts
index ec5736236cd6..5294f3ccd703 100644
--- a/apps/server/src/skills/SkillLibrary.test.ts
+++ b/apps/server/src/skills/SkillLibrary.test.ts
@@ -267,6 +267,67 @@ it.effect(
60_000,
);
+it.effect(
+ "removes a skill put in an agent folder by hand, and only from the agents' own folders",
+ () =>
+ withLibrary(({ library, home, providers }) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ // A hand-made skill in ~/.agents/skills, linked into ~/.claude/skills
+ // the way other installers do, plus one only in a plugin's folder.
+ const own = path.join(home, ".agents", "skills", "handmade");
+ const link = path.join(home, ".claude", "skills", "handmade");
+ const plugin = path.join(home, ".claude", "plugins", "acme", "skills", "handmade");
+ yield* fileSystem.makeDirectory(own, { recursive: true });
+ yield* fileSystem.writeFileString(path.join(own, "SKILL.md"), SKILL("handmade", "Mine."));
+ yield* fileSystem.makeDirectory(path.dirname(link), { recursive: true });
+ yield* fileSystem.symlink(own, link);
+ yield* fileSystem.makeDirectory(plugin, { recursive: true });
+ yield* fileSystem.writeFileString(
+ path.join(plugin, "SKILL.md"),
+ SKILL("handmade", "A plugin's."),
+ );
+ const reported = (skillPath: string) => ({
+ name: "handmade",
+ path: skillPath,
+ scope: "user",
+ enabled: true,
+ });
+ yield* Ref.set(providers, [
+ {
+ ...claude,
+ skills: [
+ reported(path.join(link, "SKILL.md")),
+ reported(path.join(plugin, "SKILL.md")),
+ ],
+ },
+ ]);
+
+ const inspected = yield* library.inspect({});
+ assert.deepEqual(
+ inspected.folders.map((folder) => [folder.path, folder.location?.kind, folder.installed]),
+ [
+ [path.join(link, "SKILL.md"), "environment", undefined],
+ [path.join(plugin, "SKILL.md"), undefined, undefined],
+ ],
+ );
+
+ yield* library.remove({ name: "handmade", target: { kind: "environment" } });
+ assert.isFalse(yield* fileSystem.exists(own));
+ assert.isFalse(
+ yield* fileSystem.readLink(link).pipe(
+ Effect.as(true),
+ Effect.orElseSucceed(() => false),
+ ),
+ );
+ // The plugin's copy isn't the user's to remove.
+ assert.isTrue(yield* fileSystem.exists(path.join(plugin, "SKILL.md")));
+ }).pipe(Effect.provide(NodeServices.layer)),
+ ),
+ 60_000,
+);
+
it.effect(
"fails a removal the CLI couldn't finish, which it reports with a zero exit",
() =>
diff --git a/apps/server/src/skills/SkillLibrary.ts b/apps/server/src/skills/SkillLibrary.ts
index 5bc5ad581d16..088f9c7cad9b 100644
--- a/apps/server/src/skills/SkillLibrary.ts
+++ b/apps/server/src/skills/SkillLibrary.ts
@@ -7,8 +7,9 @@
* their own folder. The CLI's lock files (`~/.agents/.skill-lock.json`, or
* `$XDG_STATE_HOME/skills/.skill-lock.json`, and a project's
* `skills-lock.json`) record where each install came from; this module reads
- * them to group skills by source and to update or remove only what the CLI
- * installed. Turning skills on or off stays in settings.
+ * them to group skills by source and to update only what the CLI installed.
+ * Remove takes any skill in the agents' own skill folders, through the CLI
+ * when it installed the skill. Turning skills on or off stays in settings.
*
* @module SkillLibrary
*/
@@ -29,6 +30,7 @@ import {
} from "@t3tools/contracts";
import {
STANDARD_SKILL_FOLDER,
+ agentSkillFolderRoots,
skillsCliInstallAgents,
} from "@t3tools/provider-core/server/AgentSkillFolders";
import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
@@ -65,7 +67,10 @@ export class SkillLibrary extends Context.Service<
readonly update: (
input: InstalledSkillInput,
) => Effect.Effect;
- /** Remove a skill the CLI installed, with every agent's link to it. */
+ /**
+ * Remove a skill from the agents' skill folders at one level: through the
+ * CLI when it installed the skill, else each folder's copy of that name.
+ */
readonly remove: (input: InstalledSkillInput) => Effect.Effect;
}
>()("t3/skills/SkillLibrary") {}
@@ -314,6 +319,15 @@ const make = Effect.gen(function* () {
const hasScripts = (files: ReadonlyArray) => files.some((file) => file.script);
+ /** The agents' skill folders at a level, as written and after links. */
+ const skillRoots = (target: SkillInstallTarget) =>
+ Effect.forEach(
+ agentSkillFolderRoots(target.kind === "environment" ? "home" : "project").map((folder) =>
+ path.join(targetPaths(target).root, folder),
+ ),
+ (folder) => realPath(folder).pipe(Effect.map((real) => [folder, real])),
+ ).pipe(Effect.map((pairs) => [...new Set(pairs.flat())]));
+
const sha256 = (text: string) =>
crypto.digest("SHA-256", new TextEncoder().encode(text)).pipe(
Effect.map(Hex.encode),
@@ -347,6 +361,7 @@ const make = Effect.gen(function* () {
target,
folder: yield* realPath(paths.folder),
lock: yield* readLock(paths.lock),
+ roots: yield* skillRoots(target),
};
}),
);
@@ -359,8 +374,16 @@ const make = Effect.gen(function* () {
.readFileString(skillPath)
.pipe(Effect.orElseSucceed(() => undefined));
if (contents === undefined) return undefined;
- const folder = yield* realPath(path.dirname(skillPath));
+ const reportedFolder = path.dirname(skillPath);
+ const folder = yield* realPath(reportedFolder);
const name = path.basename(folder);
+ // Where the agent found it, before links: `~/.claude/skills/x` can
+ // link to `~/.agents/skills/x`, and either makes it the user's own.
+ const parent = path.dirname(reportedFolder);
+ const location = installs.find(
+ (candidate) =>
+ candidate.roots.includes(parent) || candidate.roots.includes(path.dirname(folder)),
+ )?.target;
const install = installs.find(
(candidate) =>
path.dirname(folder) === candidate.folder &&
@@ -376,6 +399,7 @@ const make = Effect.gen(function* () {
files,
filesTruncated: truncated,
scripts: hasScripts(files),
+ ...(location === undefined ? {} : { location }),
...(install === undefined || entry === undefined
? {}
: { installed: { target: install.target, source: entry.source } }),
@@ -513,7 +537,7 @@ const make = Effect.gen(function* () {
? Effect.fail(
new SkillLibraryError({
reason: "notInstalled",
- message: "Only skills installed from a source can be updated or removed here.",
+ message: "Only skills installed from a source can be updated here.",
}),
)
: Effect.succeed(found);
@@ -551,7 +575,9 @@ const make = Effect.gen(function* () {
const removeLocked = (target: SkillInstallTarget, name: string) =>
Effect.gen(function* () {
- const { key } = yield* lockedEntry(target, name);
+ const locked = lockEntryFor(yield* readLock(targetPaths(target).lock), name);
+ if (locked === undefined) return yield* removeUnlocked(target, name);
+ const { key } = locked;
const paths = targetPaths(target);
yield* cli
.remove({
@@ -577,6 +603,65 @@ const make = Effect.gen(function* () {
}
});
+ /**
+ * A skill no lock records, put in place by hand or by another tool: delete
+ * each folder an enabled agent reports for that name directly inside one of
+ * the level's agent skill folders, links first. Only reported folders count,
+ * so a name can't reach anything else.
+ */
+ const removeUnlocked = (target: SkillInstallTarget, name: string) =>
+ Effect.gen(function* () {
+ const roots = yield* skillRoots(target);
+ const wanted = name.trim().toLowerCase();
+ const reported = new Set();
+ for (const provider of yield* enabledProviders) {
+ const lists = [
+ ...(target.kind === "environment" ? [provider.skills] : []),
+ ...(provider.workspaceSnapshots ?? [])
+ .filter((snapshot) => target.kind === "environment" || snapshot.cwd === target.cwd)
+ .map((snapshot) => snapshot.skills),
+ ];
+ for (const skill of lists.flat()) {
+ if (skill.name.trim().toLowerCase() !== wanted || !path.isAbsolute(skill.path)) continue;
+ const folder = path.dirname(skill.path);
+ if (!roots.includes(path.dirname(folder))) continue;
+ reported.add(folder);
+ // A link's folder goes too when it is one of the user's own.
+ const real = yield* realPath(folder);
+ if (roots.includes(path.dirname(real))) reported.add(real);
+ }
+ }
+ if (reported.size === 0) {
+ return yield* new SkillLibraryError({
+ reason: "notInstalled",
+ message: `No skill named ${name} is in the agents' skill folders here.`,
+ });
+ }
+ // Links go first, so none is left pointing at a deleted folder.
+ const isLink = (folder: string) =>
+ fileSystem.readLink(folder).pipe(
+ Effect.as(true),
+ Effect.orElseSucceed(() => false),
+ );
+ const links = yield* Effect.filter([...reported], isLink);
+ const folders = [...links, ...[...reported].filter((folder) => !links.includes(folder))];
+ yield* Effect.forEach(
+ folders,
+ (folder) =>
+ fileSystem
+ .remove(folder, { recursive: true })
+ .pipe(
+ Effect.mapError(() =>
+ cliFailed(
+ `Couldn't remove ${name}. Check the permissions of ${path.dirname(folder)}.`,
+ ),
+ ),
+ ),
+ { discard: true },
+ );
+ yield* refreshAgents(target);
+ });
+
return SkillLibrary.of({ inspect, preview, install, update, remove });
});
diff --git a/apps/web/src/components/settings/ToolsSettings.tsx b/apps/web/src/components/settings/ToolsSettings.tsx
index ac466840e817..687b762d1070 100644
--- a/apps/web/src/components/settings/ToolsSettings.tsx
+++ b/apps/web/src/components/settings/ToolsSettings.tsx
@@ -256,6 +256,8 @@ function SkillsPanel({
const [removingSkill, setRemovingSkill] = useState<{
readonly name: string;
readonly target: SkillInstallTarget;
+ /** The `skills` CLI installed it, so its lock entry goes too. */
+ readonly installed: boolean;
} | null>(null);
const [skillActionError, setSkillActionError] = useState(null);
const inspected = useEnvironmentQuery(
@@ -402,10 +404,11 @@ function SkillsPanel({
: () => void runSkillAction("update", { name: row.name, target: installed.target });
};
const removeOf = (row: SkillRow) => {
- const installed = detailsOf(row).installed;
- return installed === null || !canInstall
+ const { installed, removable } = detailsOf(row);
+ return removable === null || !canInstall
? null
- : () => setRemovingSkill({ name: row.name, target: installed.target });
+ : () =>
+ setRemovingSkill({ name: row.name, target: removable, installed: installed !== null });
};
const opened = openName === null ? null : (rows.find((row) => row.name === openName) ?? null);
@@ -568,8 +571,18 @@ function SkillsPanel({
Remove {removingSkill?.name}?
- Deletes the skill's folder and every agent's link to it, as{" "}
- npx skills remove does.
+ {removingSkill?.installed ? (
+ <>
+ Deletes the skill's folder and every agent's link to it, as{" "}
+ npx skills remove does.
+ >
+ ) : (
+ `Deletes ${removingSkill?.name ?? "the skill"} from every agent's skill folder ${
+ removingSkill?.target.kind === "project"
+ ? "in this project"
+ : "on this environment"
+ }. It wasn't installed from a source, so it can't be added back from here.`
+ )}
@@ -578,7 +591,10 @@ function SkillsPanel({
variant="destructive"
onClick={() => {
if (removingSkill) {
- void runSkillAction("remove", removingSkill);
+ void runSkillAction("remove", {
+ name: removingSkill.name,
+ target: removingSkill.target,
+ });
if (removingSkill.name === openName) onOpen(null);
}
setRemovingSkill(null);
diff --git a/apps/web/src/components/settings/toolsSettings.logic.test.ts b/apps/web/src/components/settings/toolsSettings.logic.test.ts
index f0ca84016c62..03644a2557f0 100644
--- a/apps/web/src/components/settings/toolsSettings.logic.test.ts
+++ b/apps/web/src/components/settings/toolsSettings.logic.test.ts
@@ -200,6 +200,7 @@ describe("skill row details", () => {
scripts: false,
conflict: false,
installed: null,
+ removable: null,
});
});
diff --git a/apps/web/src/components/settings/toolsSettings.logic.ts b/apps/web/src/components/settings/toolsSettings.logic.ts
index d8b23a26135a..8b302f3a7875 100644
--- a/apps/web/src/components/settings/toolsSettings.logic.ts
+++ b/apps/web/src/components/settings/toolsSettings.logic.ts
@@ -199,8 +199,14 @@ export interface SkillRowDetails {
readonly scripts: boolean;
/** Two copies of the name have different SKILL.md text. */
readonly conflict: boolean;
- /** Set when the `skills` CLI installed the row's skill, so it can be updated or removed. */
+ /** Set when the `skills` CLI installed the row's skill, so it can be updated. */
readonly installed: NonNullable | null;
+ /**
+ * The level to remove the skill from: where the CLI installed it, else
+ * where a copy sits in the agents' own skill folders. Null for a plugin's
+ * or an agent's built-in skill.
+ */
+ readonly removable: NonNullable | null;
}
const NO_DETAILS: SkillRowDetails = {
@@ -209,6 +215,7 @@ const NO_DETAILS: SkillRowDetails = {
scripts: false,
conflict: false,
installed: null,
+ removable: null,
};
export function skillRowDetails(
@@ -225,12 +232,14 @@ export function skillRowDetails(
const copies = [...byFolder.values()].toSorted(
(left, right) => Number(right.installed !== undefined) - Number(left.installed !== undefined),
);
+ const installed = copies.find((info) => info.installed !== undefined)?.installed ?? null;
return {
folder: copies[0] ?? null,
copies,
scripts: copies.some((info) => info.scripts),
conflict: new Set(copies.map((info) => info.hash)).size > 1,
- installed: copies.find((info) => info.installed !== undefined)?.installed ?? null,
+ installed,
+ removable: installed?.target ?? copies.find((info) => info.location)?.location ?? null,
};
}
diff --git a/docs/user/tools.md b/docs/user/tools.md
index e1f22bdfff30..d82673ee7a82 100644
--- a/docs/user/tools.md
+++ b/docs/user/tools.md
@@ -36,7 +36,9 @@ for every project. Agents that only read their own folder, such as Claude, get a
Skills installed this way, here or with `npx skills` in a terminal, are grouped by where they came
from, with one switch for the group. Their menu has **Update**, which installs the latest version
-from the same source, and **Remove**. Installing runs the open-source
+from the same source, and **Remove**. Other skills in your own skill folders, such as
+`~/.claude/skills` or a project's `.agents/skills`, can be removed from a skill's menu too; skills
+from plugins and the ones agents ship with can only be switched off. Installing runs the open-source
[`skills`](https://github.com/vercel-labs/skills) installer built into T3 Code, with its telemetry
off. Installing, updating and removing skills needs permission to manage providers.
diff --git a/packages/contracts/src/skills.ts b/packages/contracts/src/skills.ts
index 64e62bee2327..d7b8f4db8bde 100644
--- a/packages/contracts/src/skills.ts
+++ b/packages/contracts/src/skills.ts
@@ -84,7 +84,10 @@ export const SkillInstallInput = Schema.Struct({
});
export type SkillInstallInput = typeof SkillInstallInput.Type;
-/** One skill an `installed` lock entry names, to update or remove. */
+/**
+ * One skill at a level, by the name agents know it by: to update one the CLI
+ * installed, or to remove one from the agents' skill folders there.
+ */
export const InstalledSkillInput = Schema.Struct({
name: InstalledSkillName,
target: SkillInstallTarget,
@@ -122,6 +125,12 @@ export const SkillFolderInfo = Schema.Struct({
files: Schema.Array(SkillFileEntry),
filesTruncated: Schema.Boolean,
scripts: Schema.Boolean,
+ /**
+ * The level this copy belongs to when the agent found it directly in one of
+ * the agents' skill folders, at home or in the project, so it can be
+ * removed. Absent for a plugin's or an agent's built-in skills.
+ */
+ location: Schema.optional(SkillInstallTarget),
/** Set when the `skills` CLI installed this folder: its lock names the source. */
installed: Schema.optional(
Schema.Struct({
@@ -146,7 +155,10 @@ export class SkillLibraryError extends Schema.TaggedError()(
"noSkills",
/** The project isn't one of this environment's projects. */
"projectNotRegistered",
- /** Only skills the CLI installed (with a lock entry) can be updated or removed. */
+ /**
+ * Nothing to change: update needs a skill the CLI installed (with a lock
+ * entry), and remove a skill in the agents' skill folders at that level.
+ */
"notInstalled",
]),
message: Schema.String,
diff --git a/packages/provider-core/src/server/AgentSkillFolders.ts b/packages/provider-core/src/server/AgentSkillFolders.ts
index 3635267470e5..d315ea26ad63 100644
--- a/packages/provider-core/src/server/AgentSkillFolders.ts
+++ b/packages/provider-core/src/server/AgentSkillFolders.ts
@@ -75,6 +75,19 @@ const AGENT_SKILL_FOLDERS: Readonly> = {
const agentSkillFolders = (driver: ProviderDriverKind): AgentSkillFolders | undefined =>
AGENT_SKILL_FOLDERS[driver];
+/**
+ * Every folder some agent reads skills from at a level. A skill directly in
+ * one is the user's own, as opposed to a plugin's or an agent's built-in one.
+ */
+export function agentSkillFolderRoots(level: "home" | "project"): ReadonlyArray {
+ return [
+ ...new Set([
+ STANDARD_SKILL_FOLDER,
+ ...Object.values(AGENT_SKILL_FOLDERS).flatMap((folders) => folders[level]),
+ ]),
+ ];
+}
+
/**
* The `skills` CLI agents an install names so every listed driver can use the
* skill: `universal` writes `.agents/skills`, and each driver that doesn't read
From 8ddae4a09f0c77556661bb42c02d22d12eec1f2d Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sat, 10 Oct 2026 22:40:04 -0700
Subject: [PATCH 19/26] fix(web): copy a skill's path from a button beside it
Copy path sat in the skill's menu, away from the path it copies. It is now
an icon button after the folder path, for whichever copy is shown, and turns
into a check once copied. The menu keeps Update and Remove.
Co-Authored-By: Claude Opus 5.5
---
.../src/components/settings/SkillDetail.tsx | 42 +++++++++----------
1 file changed, 21 insertions(+), 21 deletions(-)
diff --git a/apps/web/src/components/settings/SkillDetail.tsx b/apps/web/src/components/settings/SkillDetail.tsx
index 9cfcf83e5226..92b9b586f37b 100644
--- a/apps/web/src/components/settings/SkillDetail.tsx
+++ b/apps/web/src/components/settings/SkillDetail.tsx
@@ -1,13 +1,14 @@
import type { EnvironmentId } from "@t3tools/contracts";
+import { Check, Copy } from "lucide";
import { AlertTriangleIcon, MoreHorizontalIcon } from "lucide-react";
import { lazy, Suspense, useEffect, useEffectEvent, useState } from "react";
-import { writeTextToClipboard } from "../../hooks/useCopyToClipboard";
+import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
+import { MorphIcon } from "../MorphIcon";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
import { Skeleton } from "../ui/skeleton";
-import { toastManager } from "../ui/toast";
import { Toggle, ToggleGroup } from "../ui/toggle-group";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { SkillAgentIcon } from "./SkillAgents";
@@ -79,20 +80,7 @@ export function SkillDetail({
details.copies.find((copy) => copy.folder === folderPath) ?? details.folder ?? null;
const scripts = folder?.files.filter((file) => file.script).map((file) => file.path) ?? [];
- const copyPath = (path: string) => {
- void writeTextToClipboard(path, "skill path").then(
- (copied) => {
- if (copied) toastManager.add({ type: "success", title: "Path copied", description: path });
- },
- (error: unknown) => {
- toastManager.add({
- type: "error",
- title: "Failed to copy path",
- description: error instanceof Error ? error.message : "An error occurred.",
- });
- },
- );
- };
+ const { copyToClipboard, isCopied } = useCopyToClipboard({ timeout: 1500, target: "skill path" });
return (
@@ -129,7 +117,7 @@ export function SkillDetail({
);
})}
- {folder !== null || onUpdate !== null || onRemove !== null ? (
+ {onUpdate !== null || onRemove !== null ? (
}
@@ -137,9 +125,6 @@ export function SkillDetail({
- {folder !== null ? (
- copyPath(folder.folder)}>Copy path
- ) : null}
{onUpdate !== null ? Update : null}
{onRemove !== null ? (
@@ -200,9 +185,24 @@ export function SkillDetail({
))}
) : null}
-
+
{folder.folder}
+
+ copyToClipboard(folder.folder, undefined)}
+ />
+ }
+ >
+
+
+ {isCopied ? "Copied" : "Copy path"}
+
) : null}
{folder === null ? (
From 93548e8821a39fc416fc9dbd94c07d5c67b83ac5 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Sat, 10 Oct 2026 22:43:24 -0700
Subject: [PATCH 20/26] fix(web): show a skill's Update and Remove as buttons,
not a menu
With Copy path beside the path, the skill's menu held one or two items. They
are now buttons on the skill's page: Update for a skill installed from a
source, and Remove.
Co-Authored-By: Claude Opus 5.5
---
.../src/components/settings/SkillDetail.tsx | 28 +++++++------------
docs/user/tools.md | 4 +--
2 files changed, 12 insertions(+), 20 deletions(-)
diff --git a/apps/web/src/components/settings/SkillDetail.tsx b/apps/web/src/components/settings/SkillDetail.tsx
index 92b9b586f37b..7847d89c7cc8 100644
--- a/apps/web/src/components/settings/SkillDetail.tsx
+++ b/apps/web/src/components/settings/SkillDetail.tsx
@@ -1,13 +1,12 @@
import type { EnvironmentId } from "@t3tools/contracts";
import { Check, Copy } from "lucide";
-import { AlertTriangleIcon, MoreHorizontalIcon } from "lucide-react";
+import { AlertTriangleIcon } from "lucide-react";
import { lazy, Suspense, useEffect, useEffectEvent, useState } from "react";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import { MorphIcon } from "../MorphIcon";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
-import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
import { Skeleton } from "../ui/skeleton";
import { Toggle, ToggleGroup } from "../ui/toggle-group";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
@@ -117,22 +116,15 @@ export function SkillDetail({
);
})}
- {onUpdate !== null || onRemove !== null ? (
-
- }
- >
-
-
-
- {onUpdate !== null ? Update : null}
- {onRemove !== null ? (
-
- Remove
-
- ) : null}
-
-
+ {onUpdate !== null ? (
+
+ Update
+
+ ) : null}
+ {onRemove !== null ? (
+
+ Remove
+
) : null}