diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 5f0d82830..f2aed089c 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -83,6 +83,7 @@ function PopoverSurface(props: { readonly children: React.ReactNode; readonly st const SKILL_SOURCE_SYMBOL_BY_KIND: Record = { app: "square.grid.2x2", + plugin: "square.grid.2x2", repo: "folder", project: "folder", personal: "person.crop.circle", diff --git a/apps/server/src/provider/Drivers/ClaudePluginSkills.test.ts b/apps/server/src/provider/Drivers/ClaudePluginSkills.test.ts new file mode 100644 index 000000000..ea4fd5851 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudePluginSkills.test.ts @@ -0,0 +1,423 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; +import { discoverClaudeSkills } from "./ClaudeSkills.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const writeJson = Effect.fn(function* (filePath: string, value: unknown) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, typeof value === "string" ? value : encodeJson(value)); +}); + +const writeSkill = Effect.fn(function* (skillsDir: string, directoryName: string) { + const path = yield* Path.Path; + yield* writeJson( + path.join(skillsDir, directoryName, "SKILL.md"), + ["---", `description: The ${directoryName} skill.`, "---", "", "# Body"].join("\n"), + ); +}); + +interface PluginInstall { + readonly id: string; + readonly skills?: ReadonlyArray; + /** `null` writes no `plugin.json`; a string is written verbatim. */ + readonly manifest?: unknown; + readonly version?: string; + readonly projectPath?: string; +} + +/** + * Lay out a made-up Claude config directory the way `claude plugin install` + * does: one versioned folder per plugin under `plugins/cache`, and an + * `installed_plugins.json` pointing at each. + */ +const makeClaudeHome = Effect.fn(function* ( + plugins: ReadonlyArray, + /** Entries written into the install list as they are, whatever their shape. */ + rawInstalled: Record = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-plugin-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + yield* fs.makeDirectory(workspace, { recursive: true }); + + const installed: Record>> = {}; + const installPaths: Record = {}; + for (const plugin of plugins) { + const separator = plugin.id.lastIndexOf("@"); + const name = plugin.id.slice(0, separator); + const marketplace = plugin.id.slice(separator + 1); + const version = plugin.version ?? "1.0.0"; + const installPath = path.join(configDir, "plugins", "cache", marketplace, name, version); + yield* fs.makeDirectory(installPath, { recursive: true }); + if (plugin.manifest !== null) { + yield* writeJson( + path.join(installPath, ".claude-plugin", "plugin.json"), + plugin.manifest ?? { name, description: "A made-up plugin." }, + ); + } + for (const skill of plugin.skills ?? ["hello"]) { + yield* writeSkill(path.join(installPath, "skills"), skill); + } + (installed[plugin.id] ??= []).push({ + scope: plugin.projectPath ? "project" : "user", + installPath, + version, + ...(plugin.projectPath ? { projectPath: plugin.projectPath } : {}), + }); + installPaths[`${plugin.id}#${version}`] = installPath; + } + yield* writeJson(path.join(configDir, "plugins", "installed_plugins.json"), { + version: 2, + plugins: { ...installed, ...rawInstalled }, + }); + + return { + configDir, + workspace, + installPath: (id: string, version = "1.0.0") => installPaths[`${id}#${version}`] ?? "", + userSettings: (value: unknown) => writeJson(path.join(configDir, "settings.json"), value), + projectSettings: (value: unknown) => + writeJson(path.join(workspace, ".claude", "settings.json"), value), + localSettings: (value: unknown) => + writeJson(path.join(workspace, ".claude", "settings.local.json"), value), + }; +}); + +const names = (skills: ReadonlyArray<{ readonly name: string }>) => + skills.map((skill) => skill.name); + +it.layer(NodeServices.layer)("discoverClaudeSkills plugin skills", (it) => { + it.effect("lists the skills of an installed, enabled plugin under plugin:skill", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeClaudeHome([{ id: "alpha@market", skills: ["hello", "review"] }]); + yield* writeSkill(path.join(home.configDir, "skills"), "mine"); + yield* home.userSettings({ enabledPlugins: { "alpha@market": true } }); + + const skills = yield* discoverClaudeSkills({ homePath: home.configDir }, home.workspace); + + assert.deepEqual(skills, [ + { + name: "alpha:hello", + path: path.join(home.installPath("alpha@market"), "skills", "hello", "SKILL.md"), + enabled: true, + scope: "plugin", + description: "The hello skill.", + }, + { + name: "alpha:review", + path: path.join(home.installPath("alpha@market"), "skills", "review", "SKILL.md"), + enabled: true, + scope: "plugin", + description: "The review skill.", + }, + { + name: "mine", + path: path.join(home.configDir, "skills", "mine", "SKILL.md"), + enabled: true, + scope: "user", + description: "The mine skill.", + }, + ]); + }), + ); + + it.effect("hides a disabled plugin and one enabledPlugins does not name", () => + Effect.gen(function* () { + const home = yield* makeClaudeHome([ + { id: "alpha@market" }, + { id: "beta@market" }, + { id: "gamma@market" }, + ]); + yield* home.userSettings({ enabledPlugins: { "alpha@market": true, "beta@market": false } }); + + const skills = yield* discoverClaudeSkills({ homePath: home.configDir }, home.workspace); + + assert.deepEqual(names(skills), ["alpha:hello"]); + }), + ); + + it.effect("lets project settings outrank user settings, and local settings outrank both", () => + Effect.gen(function* () { + const home = yield* makeClaudeHome([ + { id: "alpha@market" }, + { id: "beta@market" }, + { id: "gamma@market" }, + ]); + yield* home.userSettings({ + enabledPlugins: { "alpha@market": true, "beta@market": false, "gamma@market": false }, + }); + yield* home.projectSettings({ + enabledPlugins: { "beta@market": true, "gamma@market": true }, + }); + // A value the CLI does not read as a switch leaves the earlier one alone. + yield* home.localSettings({ + enabledPlugins: { "alpha@market": false, "gamma@market": "yes" }, + }); + + const skills = yield* discoverClaudeSkills({ homePath: home.configDir }, home.workspace); + + assert.deepEqual(names(skills), ["beta:hello", "gamma:hello"]); + }), + ); + + it.effect("keeps ordinary skills when installed_plugins.json is malformed or missing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* makeClaudeHome([{ id: "alpha@market" }]); + const installedPath = path.join(home.configDir, "plugins", "installed_plugins.json"); + yield* writeSkill(path.join(home.configDir, "skills"), "mine"); + yield* home.userSettings({ enabledPlugins: { "alpha@market": true } }); + // Control: the fixture is one a healthy file would list a plugin from. + assert.deepEqual( + names(yield* discoverClaudeSkills({ homePath: home.configDir }, home.workspace)), + ["alpha:hello", "mine"], + ); + + yield* fs.writeFileString(installedPath, "{ not json"); + assert.deepEqual( + names(yield* discoverClaudeSkills({ homePath: home.configDir }, home.workspace)), + ["mine"], + ); + + yield* writeJson(installedPath, { version: 2, plugins: [] }); + assert.deepEqual( + names(yield* discoverClaudeSkills({ homePath: home.configDir }, home.workspace)), + ["mine"], + ); + + yield* fs.remove(installedPath); + assert.deepEqual( + names(yield* discoverClaudeSkills({ homePath: home.configDir }, home.workspace)), + ["mine"], + ); + }), + ); + + it.effect("skips only the broken plugin when one install entry or manifest is malformed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Entries of the wrong shape sit beside the good ones: a string, an + // entry with no path, and a plugin whose installs are not a list. + const home = yield* makeClaudeHome( + [ + { id: "alpha@market" }, + { id: "broken-manifest@market", manifest: "{ not json" }, + { id: "gone@market" }, + { id: "no-manifest@market", manifest: null }, + { id: "no-skills@market", skills: [] }, + ], + { + "bad-entry@market": ["nope", { scope: "user" }], + "bad-list@market": { installPath: 3 }, + }, + ); + yield* fs.remove(home.installPath("gone@market"), { recursive: true }); + yield* writeSkill(path.join(home.configDir, "skills"), "mine"); + yield* home.userSettings({ + enabledPlugins: { + "alpha@market": true, + "broken-manifest@market": true, + "gone@market": true, + "no-manifest@market": true, + "no-skills@market": true, + "bad-entry@market": true, + "bad-list@market": true, + "never-installed@market": true, + }, + }); + + const skills = yield* discoverClaudeSkills({ homePath: home.configDir }, home.workspace); + + // Claude Code refuses a plugin whose plugin.json does not parse, and + // names one with no plugin.json after its id. + assert.deepEqual(names(skills), ["alpha:hello", "mine", "no-manifest:hello"]); + }), + ); + + it.effect("ignores enabledPlugins in a settings file that does not parse", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeClaudeHome([{ id: "alpha@market" }, { id: "beta@market" }]); + yield* writeSkill(path.join(home.configDir, "skills"), "mine"); + yield* home.userSettings({ enabledPlugins: { "alpha@market": true } }); + yield* home.projectSettings("{ enabledPlugins: nope"); + yield* home.localSettings({ enabledPlugins: ["beta@market"] }); + + const skills = yield* discoverClaudeSkills({ homePath: home.configDir }, home.workspace); + + assert.deepEqual(names(skills), ["alpha:hello", "mine"]); + }), + ); + + it.effect("names the skills after plugin.json and reads the folders it adds", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeClaudeHome([ + { + id: "alpha@market", + manifest: { name: "renamed", skills: ["./extra", "../outside"] }, + }, + { id: "beta@market", manifest: { name: "beta", skills: "./custom" } }, + ]); + yield* writeSkill(path.join(home.installPath("alpha@market"), "extra"), "more"); + yield* writeSkill(path.join(home.installPath("alpha@market"), "..", "outside"), "escaped"); + yield* writeSkill(path.join(home.installPath("beta@market"), "custom"), "bespoke"); + yield* home.userSettings({ + enabledPlugins: { "alpha@market": true, "beta@market": true }, + }); + + const skills = yield* discoverClaudeSkills({ homePath: home.configDir }, home.workspace); + + assert.deepEqual(names(skills), [ + "beta:bespoke", + "beta:hello", + "renamed:hello", + "renamed:more", + ]); + }), + ); + + it.effect("loads a project-scoped install only inside its project", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* makeClaudeHome([]); + const elsewhere = path.join(path.dirname(home.workspace), "elsewhere"); + const nested = path.join(home.workspace, "packages", "app"); + yield* fs.makeDirectory(elsewhere, { recursive: true }); + yield* fs.makeDirectory(nested, { recursive: true }); + const install = (projectPath: string) => ({ + scope: "project", + installPath: path.join(home.configDir, "plugins", "cache", "market", "alpha", "1.0.0"), + version: "1.0.0", + projectPath, + }); + yield* writeSkill(path.join(install("").installPath, "skills"), "hello"); + const writeInstalled = (projectPath: string) => + writeJson(path.join(home.configDir, "plugins", "installed_plugins.json"), { + version: 2, + plugins: { "alpha@market": [install(projectPath)] }, + }); + yield* home.userSettings({ enabledPlugins: { "alpha@market": true } }); + + yield* writeInstalled(home.workspace); + assert.deepEqual( + names(yield* discoverClaudeSkills({ homePath: home.configDir }, home.workspace)), + ["alpha:hello"], + ); + assert.deepEqual(names(yield* discoverClaudeSkills({ homePath: home.configDir }, nested)), [ + "alpha:hello", + ]); + assert.deepEqual( + names(yield* discoverClaudeSkills({ homePath: home.configDir }, elsewhere)), + [], + ); + assert.deepEqual(names(yield* discoverClaudeSkills({ homePath: home.configDir })), []); + }), + ); + + it.effect("uses the first install entry that applies to the workspace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* makeClaudeHome([ + { id: "alpha@market", version: "2.0.0", skills: ["newer"], projectPath: "placeholder" }, + { id: "alpha@market", version: "1.0.0", skills: ["older"] }, + ]); + const installedPath = path.join(home.configDir, "plugins", "installed_plugins.json"); + const elsewhere = path.join(path.dirname(home.workspace), "elsewhere"); + yield* fs.makeDirectory(elsewhere, { recursive: true }); + yield* fs.writeFileString( + installedPath, + (yield* fs.readFileString(installedPath)).replace( + '"placeholder"', + encodeJson(home.workspace), + ), + ); + yield* home.userSettings({ enabledPlugins: { "alpha@market": true } }); + + assert.deepEqual( + names(yield* discoverClaudeSkills({ homePath: home.configDir }, home.workspace)), + ["alpha:newer"], + ); + assert.deepEqual( + names(yield* discoverClaudeSkills({ homePath: home.configDir }, elsewhere)), + ["alpha:older"], + ); + }), + ); + + it.effect("keeps the first enabled plugin when two share a name", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeClaudeHome([ + { id: "alpha@one", skills: ["hello", "only-one"] }, + { id: "alpha@two", skills: ["hello", "only-two"] }, + ]); + yield* home.userSettings({ enabledPlugins: { "alpha@two": true, "alpha@one": true } }); + + const skills = yield* discoverClaudeSkills({ homePath: home.configDir }, home.workspace); + + assert.deepEqual(names(skills), ["alpha:hello", "alpha:only-one", "alpha:only-two"]); + assert.equal( + skills[0]?.path, + path.join(home.installPath("alpha@two"), "skills", "hello", "SKILL.md"), + ); + }), + ); + + it.effect("does not apply skillOverrides to a plugin skill", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeClaudeHome([{ id: "alpha@market" }]); + yield* writeSkill(path.join(home.configDir, "skills"), "mine"); + yield* home.userSettings({ + enabledPlugins: { "alpha@market": true }, + skillOverrides: { "alpha:hello": "off", hello: "off", mine: "off" }, + }); + + const skills = yield* discoverClaudeSkills({ homePath: home.configDir }, home.workspace); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [ + ["alpha:hello", true], + ["mine", false], + ], + ); + }), + ); + + it.effect("sends a picked plugin skill as the /plugin:skill command", () => + Effect.gen(function* () { + const home = yield* makeClaudeHome([{ id: "alpha@market" }]); + yield* home.userSettings({ enabledPlugins: { "alpha@market": true } }); + + const skills = yield* discoverClaudeSkills({ homePath: home.configDir }, home.workspace); + const dispatch = planClaudeSkillDispatch( + "$alpha:hello tidy the header", + new Set(names(skills)), + ); + + assert.deepEqual(dispatch, { + leadingText: undefined, + commandText: "/alpha:hello tidy the header", + skillName: "alpha:hello", + }); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudePluginSkills.ts b/apps/server/src/provider/Drivers/ClaudePluginSkills.ts new file mode 100644 index 000000000..b82ecb395 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudePluginSkills.ts @@ -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 `:` and runs it only + * as `/:`. Verified against CLI 2.1.296 in stream-json mode: + * the bare `/` and `$:` 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 + * `@` 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 `/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, +): Effect.fn.Return, never, FileSystem.FileSystem> { + const fileSystem = yield* FileSystem.FileSystem; + const enabledById = new Map(); + + 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; + }): Effect.fn.Return< + ReadonlyArray, + 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 = []; + 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; + }, +); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index fcc55d1ac..582c5b7cb 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -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. @@ -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|$)/; @@ -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(); @@ -352,10 +372,11 @@ 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. @@ -363,7 +384,9 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* 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; diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index e1d563403..95f4dc899 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -15,6 +15,7 @@ import { BlocksIcon, FolderIcon, PackageIcon, + PlugIcon, SettingsIcon, UserRoundIcon, type LucideIcon, @@ -220,6 +221,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { const SKILL_SOURCE_ICON_BY_KIND: Record = { app: BlocksIcon, + plugin: PlugIcon, repo: FolderIcon, project: FolderIcon, personal: UserRoundIcon, @@ -229,6 +231,7 @@ const SKILL_SOURCE_ICON_BY_KIND: Record = { const SKILL_SOURCE_LABEL_BY_KIND: Record = { app: "App", + plugin: "Plugin", repo: "Repo", project: "Project", personal: "Personal", diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 30de977e6..f3cefa420 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -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 diff --git a/fork-features.json b/fork-features.json index fafee3e13..adf62611a 100644 --- a/fork-features.json +++ b/fork-features.json @@ -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 } ] } diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index ddc0f0847..5b3485a27 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -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", () => { @@ -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({ diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index 393342f07..95d9b6167 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -4,7 +4,14 @@ import type { ServerProviderSlashCommand, } from "@t3tools/contracts"; -export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; +export type ProviderSkillSourceKind = + | "app" + | "plugin" + | "repo" + | "project" + | "personal" + | "system" + | "other"; function titleCaseWords(value: string): string { const words: string[] = []; @@ -26,7 +33,14 @@ export function formatProviderSkillDisplayName( if (displayName) { return displayName; } - return titleCaseWords(skill.name); + // Claude Code names a plugin skill `plugin:skill`, and a one-skill plugin + // usually repeats itself, as in `frontend-design:frontend-design`. + const [namespace, skillName, ...rest] = skill.name.split(":"); + return titleCaseWords( + namespace !== undefined && namespace === skillName && rest.length === 0 + ? namespace + : skill.name, + ); } export function dedupeProviderSkillsByName( @@ -83,6 +97,8 @@ export function resolveProviderSkillSourceKind( const normalizedScope = skill.scope?.trim().toLowerCase(); switch (normalizedScope) { + case "plugin": + return "plugin"; case "repo": case "repository": return "repo";