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
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ function PopoverSurface(props: { readonly children: React.ReactNode; readonly st

const SKILL_SOURCE_SYMBOL_BY_KIND: Record<ProviderSkillSourceKind, AppSymbolName> = {
app: "square.grid.2x2",
plugin: "square.grid.2x2",
repo: "folder",
project: "folder",
personal: "person.crop.circle",
Expand Down
423 changes: 423 additions & 0 deletions apps/server/src/provider/Drivers/ClaudePluginSkills.test.ts

Large diffs are not rendered by default.

217 changes: 217 additions & 0 deletions apps/server/src/provider/Drivers/ClaudePluginSkills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/**
* ClaudePluginSkills — finds the skill folders of Claude Code plugins, so the
* `$` picker can offer plugin skills beside user and project ones.
*
* Claude Code publishes a plugin skill as `<plugin>:<skill>` and runs it only
* as `/<plugin>:<skill>`. Verified against CLI 2.1.296 in stream-json mode:
* the bare `/<skill>` and `$<plugin>:<skill>` forms reach the model as prose.
*
* Every rule below was read off the CLI's init message against made-up
* plugins in a temporary config directory:
*
* - A plugin loads only when `enabledPlugins` holds `true` for its
* `<plugin>@<marketplace>` id. `false`, and an installed plugin the map
* does not name, stay unloaded.
* - `enabledPlugins` merges across user settings, then the workspace's
* `.claude/settings.json`, then its `.claude/settings.local.json`, later
* files winning per id. Unlike `skillOverrides`, a repository root's
* settings are not consulted from a nested cwd.
* - `plugins/installed_plugins.json` maps each id to a list of installs. An
* install that carries a `projectPath` loads only inside that project.
* - Skills come from `<installPath>/skills`, plus any folders `plugin.json`
* names under `skills`. Only direct children holding a `SKILL.md` count.
* - The prefix is the `name` in `.claude-plugin/plugin.json`, falling back to
* the id when there is no manifest. A manifest that does not parse keeps
* the whole plugin from loading.
*
* Not modelled: the CLI also refuses a plugin whose marketplace it no longer
* knows, reads a plugin from a local directory marketplace out of that
* directory instead of the install path, and loads plugins synced from the
* user's Claude account, which `installed_plugins.json` does not list.
*
* @module provider/Drivers/ClaudePluginSkills
*/
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { fromLenientJson } from "@t3tools/shared/schemaJson";

/** One folder of skill directories, and the plugin name its skills go under. */
export interface ClaudePluginSkillRoot {
readonly directory: string;
readonly namespace: string;
}

const decodeEnabledPluginsSettings = Schema.decodeUnknownEffect(
fromLenientJson(
Schema.Struct({
enabledPlugins: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}),
),
);

const decodeInstalledPlugins = Schema.decodeUnknownEffect(
fromLenientJson(Schema.Struct({ plugins: Schema.Record(Schema.String, Schema.Unknown) })),
);

const decodePluginInstall = Schema.decodeUnknownOption(
Schema.Struct({
installPath: Schema.String,
projectPath: Schema.optional(Schema.String),
}),
);

const decodePluginManifest = Schema.decodeUnknownEffect(
fromLenientJson(
Schema.Struct({
name: Schema.optional(Schema.String),
skills: Schema.optional(Schema.Union([Schema.String, Schema.Array(Schema.String)])),
}),
),
);

function isInsideOrEqual(path: Path.Path, parent: string, child: string): boolean {
const relative = path.relative(path.resolve(parent), path.resolve(child));
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}

/**
* Ids switched on across the settings files, in the order they first appear.
* `settingsPaths` runs from lowest precedence to highest.
*/
const readEnabledPluginIds = Effect.fn("readEnabledPluginIds")(function* (
settingsPaths: ReadonlyArray<string>,
): Effect.fn.Return<ReadonlyArray<string>, never, FileSystem.FileSystem> {
const fileSystem = yield* FileSystem.FileSystem;
const enabledById = new Map<string, boolean>();

for (const settingsPath of settingsPaths) {
const contents = yield* fileSystem
.readFileString(settingsPath)
.pipe(Effect.orElseSucceed(() => undefined));
if (contents === undefined) {
continue;
}
const parsed = yield* decodeEnabledPluginsSettings(contents).pipe(
Effect.tapError((cause) =>
Effect.logDebug("claude settings file is unreadable; ignoring enabledPlugins", {
path: settingsPath,
cause,
}),
),
Effect.orElseSucceed(() => undefined),
);
for (const [id, value] of Object.entries(parsed?.enabledPlugins ?? {})) {
if (typeof value === "boolean") {
enabledById.set(id, value);
}
}
}

return [...enabledById].filter(([, enabled]) => enabled).map(([id]) => id);
});

/**
* The skill folders of every plugin Claude Code would load for `cwd`, in the
* order the plugins are switched on. Best-effort throughout: a missing or
* malformed file drops only the plugin it describes, and never fails.
*/
export const resolveClaudePluginSkillRoots = Effect.fn("resolveClaudePluginSkillRoots")(
function* (input: {
readonly configDirPath: string;
readonly cwd: string | undefined;
/** Settings files to read `enabledPlugins` from, lowest precedence first. */
readonly settingsPaths: ReadonlyArray<string>;
}): Effect.fn.Return<
ReadonlyArray<ClaudePluginSkillRoot>,
never,
FileSystem.FileSystem | Path.Path
> {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

const enabledIds = yield* readEnabledPluginIds(input.settingsPaths);
if (enabledIds.length === 0) {
return [];
}

const installedPath = path.join(input.configDirPath, "plugins", "installed_plugins.json");
const installedContents = yield* fileSystem
.readFileString(installedPath)
.pipe(Effect.orElseSucceed(() => undefined));
if (installedContents === undefined) {
return [];
}
const installed = yield* decodeInstalledPlugins(installedContents).pipe(
Effect.tapError((cause) =>
Effect.logDebug("claude installed_plugins.json is unreadable; ignoring plugin skills", {
path: installedPath,
cause,
}),
),
Effect.orElseSucceed(() => undefined),
);
if (installed === undefined) {
return [];
}

const roots: Array<ClaudePluginSkillRoot> = [];
for (const id of enabledIds) {
const installs = installed.plugins[id];
if (!Array.isArray(installs)) {
continue;
}
// The first install that applies here wins, which is what the CLI picks
// when a plugin is installed for the user and again for this project.
const install = installs
.flatMap((entry) => Option.toArray(decodePluginInstall(entry)))
.find(
(entry) =>
entry.projectPath === undefined ||
(input.cwd !== undefined && isInsideOrEqual(path, entry.projectPath, input.cwd)),
);
if (install === undefined) {
continue;
}

const manifestContents = yield* fileSystem
.readFileString(path.join(install.installPath, ".claude-plugin", "plugin.json"))
.pipe(Effect.orElseSucceed(() => undefined));
const manifest =
manifestContents === undefined
? {}
: yield* decodePluginManifest(manifestContents).pipe(
Effect.orElseSucceed(() => undefined),
);
if (manifest === undefined) {
continue;
}

const separator = id.lastIndexOf("@");
const namespace = (manifest.name ?? (separator > 0 ? id.slice(0, separator) : id)).trim();
if (!namespace) {
continue;
}

const extraFolders =
manifest.skills === undefined
? []
: typeof manifest.skills === "string"
? [manifest.skills]
: manifest.skills;
const directories = new Set([
path.join(install.installPath, "skills"),
...extraFolders
.map((folder) => path.resolve(install.installPath, folder))
.filter((directory) => isInsideOrEqual(path, install.installPath, directory)),
]);
for (const directory of directories) {
roots.push({ directory, namespace });
}
}

return roots;
},
);
33 changes: 28 additions & 5 deletions apps/server/src/provider/Drivers/ClaudeSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* matching the CLI. `.agents/skills` is a Codex location: verified against the
* CLI, a skill that lives only there is answered with `Unknown command`, so it
* is not scanned here.
* Skills of enabled plugins follow, named `plugin:skill`; `ClaudePluginSkills`
* finds their folders.
* The Agent SDK init handshake surfaces skills only as slash commands without
* their filesystem paths, so the provider snapshot scans the same locations
* directly, mirroring how the Codex app-server reports its skills.
Expand All @@ -28,8 +30,9 @@ import { fromLenientJson } from "@t3tools/shared/schemaJson";
import { parse as parseYamlDocument } from "yaml";

import { expandHomePath } from "../../pathExpansion.ts";
import { resolveClaudePluginSkillRoots } from "./ClaudePluginSkills.ts";

type ClaudeSkillScope = "user" | "project";
type ClaudeSkillScope = "user" | "project" | "plugin";

const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;

Expand Down Expand Up @@ -318,9 +321,26 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*
const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd);
const skillOverrides = yield* readSkillOverrides(configDirPath, cwd, environment ?? process.env);

const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [
const pluginRoots = yield* resolveClaudePluginSkillRoots({
configDirPath,
cwd,
settingsPaths: skillOverrideSettingsPaths(
path,
configDirPath,
cwd,
yield* HostProcessPlatform,
environment ?? process.env,
),
});

const roots: ReadonlyArray<{
directory: string;
scope: ClaudeSkillScope;
namespace?: string;
}> = [
{ directory: path.join(configDirPath, "skills"), scope: "user" },
...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []),
...pluginRoots.map((root) => ({ ...root, scope: "plugin" as const })),
];

const skillsByName = new Map<string, ServerProviderSkill>();
Expand Down Expand Up @@ -352,18 +372,21 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*
// `probe-alias`, and only `skillOverrides["probe-alias"]` switches it
// off. Keying off the frontmatter name would report a command that does
// not exist and miss the override that disables it.
const name = entry.trim();
if (!name) {
const directoryName = entry.trim();
if (!directoryName) {
continue;
}
const name = root.namespace ? `${root.namespace}:${directoryName}` : directoryName;

// First root wins, so a later root never displaces a higher-precedence
// skill of the same name.
if (skillsByName.has(name)) {
continue;
}

const override = skillOverrides.get(name);
// Verified against the CLI: `skillOverrides` leaves a plugin skill on,
// under its full name and its bare one alike.
const override = root.namespace ? undefined : skillOverrides.get(name);
const userInvocationOnly =
(frontmatter.kind === "parsed" && frontmatter.userInvocationOnly === true) ||
override?.userInvocationOnly === true;
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/ComposerCommandMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
BlocksIcon,
FolderIcon,
PackageIcon,
PlugIcon,
SettingsIcon,
UserRoundIcon,
type LucideIcon,
Expand Down Expand Up @@ -220,6 +221,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {

const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = {
app: BlocksIcon,
plugin: PlugIcon,
repo: FolderIcon,
project: FolderIcon,
personal: UserRoundIcon,
Expand All @@ -229,6 +231,7 @@ const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = {

const SKILL_SOURCE_LABEL_BY_KIND: Record<ProviderSkillSourceKind, string> = {
app: "App",
plugin: "Plugin",
repo: "Repo",
project: "Project",
personal: "Personal",
Expand Down
5 changes: 5 additions & 0 deletions docs/user/providers-claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ Claude skills come from the config directory's `skills` folder and the project's
`.claude/skills` folder. If both define the same name, the config-directory copy
wins. Skills disabled in Claude's settings do not appear in the composer.

Skills from Claude Code plugins are listed too, marked **Plugin**, when the plugin is
installed and switched on in Claude's `enabledPlugins` setting. They are named
`plugin:skill`, the same as in Claude Code. Plugins synced from your Claude account
are not listed yet.

Use `$` in the composer to select a skill. Skills marked `disable-model-invocation`
can still be started by you. Invoke those one per message: Claude directly runs
only the last named skill and may try to start earlier ones through its Skill
Expand Down
18 changes: 18 additions & 0 deletions fork-features.json
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,24 @@
],
"test": "apps/server/src/process/sessionProcessJob.test.ts",
"patchesUpstream": true
},
{
"name": "claude-plugin-skills",
"description": "The Claude skill picker also lists the skills of Claude Code plugins that are installed and enabled, named plugin:skill as the CLI names them, and a pick goes out as /plugin:skill. Web and mobile label these rows Plugin, and a plugin whose one skill repeats its name is shown once.",
"keep": "ClaudePluginSkills.ts and its test are ours whole. In ClaudeSkills.ts these survive: the resolveClaudePluginSkillRoots import and call, the plugin scope, the plugin roots and their namespace in the roots list, the plugin:skill name built from the namespace, and the skillOverrides skip for a namespaced skill. In providerSkills.ts the plugin source kind and the repeated-name rule in formatProviderSkillDisplayName survive, with their two tests in providerSkills.test.ts. The plugin entries in the source maps of ComposerCommandMenu.tsx and ComposerCommandPopover.tsx survive. The plugin paragraph under Skills in providers-claude.md survives. Everything else in those files takes upstream.",
"files": [
"apps/server/src/provider/Drivers/ClaudePluginSkills.ts",
"apps/server/src/provider/Drivers/ClaudeSkills.ts",
"packages/client-runtime/src/providerSkills.ts",
"apps/web/src/components/chat/ComposerCommandMenu.tsx",
"apps/mobile/src/features/threads/ComposerCommandPopover.tsx",
"docs/user/providers-claude.md"
],
"test": [
"apps/server/src/provider/Drivers/ClaudePluginSkills.test.ts",
"packages/client-runtime/src/providerSkills.test.ts"
],
"patchesUpstream": true
}
]
}
18 changes: 18 additions & 0 deletions packages/client-runtime/src/providerSkills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ describe("formatProviderSkillDisplayName", () => {
}),
).toBe("Review Follow Up");
});

it("names a plugin skill once when the plugin and the skill share a name", () => {
expect(formatProviderSkillDisplayName({ name: "frontend-design:frontend-design" })).toBe(
"Frontend Design",
);
expect(formatProviderSkillDisplayName({ name: "pdf-viewer:annotate" })).toBe(
"Pdf Viewer Annotate",
);
});
});

describe("dedupeProviderSkillsByName", () => {
Expand Down Expand Up @@ -225,6 +234,15 @@ describe("resolveProviderSkillSourceKind", () => {
).toBe("system");
});

it("marks Claude plugin skills as plugin installs", () => {
expect(
resolveProviderSkillSourceKind({
path: "/Users/julius/.claude/plugins/cache/market/alpha/1.0.0/skills/hello/SKILL.md",
scope: "plugin",
}),
).toBe("plugin");
});

it("keeps unknown and missing scopes usable", () => {
expect(
resolveProviderSkillSourceKind({
Expand Down
Loading
Loading