From 5d01e8d2c24c3c296c5c9d0bd97bc9c00ad0430a Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:17:51 -0400 Subject: [PATCH 01/13] feat(server): manage provider skills from Threadlines Skills could only be toggled on Codex, and there was no way to create or delete one. The server now reports per-skill canToggle/canDelete, routes the Claude toggle through its skills-dir plugin ids, and adds guarded create and delete actions for user and project skills roots. --- .../server/src/provider/providerExtensions.ts | 460 +++++++++++++++++- apps/server/src/ws.ts | 20 + apps/web/src/environmentApi.ts | 2 + apps/web/src/localApi.ts | 4 + apps/web/src/rpc/wsRpcClient.ts | 18 + packages/contracts/src/ipc.ts | 16 + packages/contracts/src/providerExtensions.ts | 45 ++ packages/contracts/src/rpc.ts | 26 + 8 files changed, 574 insertions(+), 17 deletions(-) diff --git a/apps/server/src/provider/providerExtensions.ts b/apps/server/src/provider/providerExtensions.ts index a671814ad..f56d77d55 100644 --- a/apps/server/src/provider/providerExtensions.ts +++ b/apps/server/src/provider/providerExtensions.ts @@ -61,6 +61,10 @@ import { type ProviderExtensionsInventoryResult, type ProviderExtensionProviderInventory, type ProviderExtensionSkill, + type ProviderExtensionSkillCreateInput, + type ProviderExtensionSkillCreateResult, + type ProviderExtensionSkillDeleteInput, + type ProviderExtensionSkillDeleteResult, type ProviderExtensionSkillReadInput, type ProviderExtensionSkillReadResult, type ProviderExtensionSkillToggleInput, @@ -79,8 +83,12 @@ import { } from "@threadlines/contracts"; import { codexAppServerCommandOptions } from "./codexAppServerArgs.ts"; -import { makeClaudeEnvironment } from "./Drivers/ClaudeHome.ts"; -import { materializeCodexShadowHome, resolveCodexHomeLayout } from "./Drivers/CodexHomeLayout.ts"; +import { makeClaudeEnvironment, resolveClaudeHomePath } from "./Drivers/ClaudeHome.ts"; +import { + materializeCodexShadowHome, + resolveCodexHomeLayout, + type CodexHomeLayout, +} from "./Drivers/CodexHomeLayout.ts"; import { buildCodexInitializeParams } from "./Layers/CodexProvider.ts"; import { deriveProviderInstanceConfigMap } from "./Layers/ProviderInstanceRegistryHydration.ts"; import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; @@ -785,6 +793,19 @@ export function codexMarketplaceLoadErrorMessage( ); } +/** + * User skills live in `/skills`. With a shadow home in play the app-server reports + * paths through the shadow, which symlinks that directory back to the shared home, so both spellings + * count as the same root. + */ +export function codexUserSkillsRoots(path: Path.Path, layout: CodexHomeLayout): string[] { + const roots = [path.join(layout.sharedHomePath, "skills")]; + if (layout.effectiveHomePath && layout.effectiveHomePath !== layout.sharedHomePath) { + roots.push(path.join(layout.effectiveHomePath, "skills")); + } + return roots; +} + function mapCodexSkills( response: CodexSchema.V2SkillsListResponse, cwd: string, @@ -1366,6 +1387,7 @@ const readCodexAppServerInventory = Effect.fn("providerExtensions.readCodexAppSe never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { + const path = yield* Path.Path; const layout = yield* resolveCodexHomeLayout(input.config); const materialized = yield* materializeCodexShadowHome(layout).pipe(Effect.result); if (Result.isFailure(materialized)) { @@ -1598,7 +1620,10 @@ const readCodexAppServerInventory = Effect.fn("providerExtensions.readCodexAppSe const plugins = Result.isSuccess(data.plugins) ? data.plugins.success.plugins : []; const marketplaces = Result.isSuccess(data.plugins) ? data.plugins.success.marketplaces : []; const skills = Result.isSuccess(data.skills) - ? annotatePluginBackedSkills(data.skills.success, plugins) + ? annotateCodexSkillCapabilities(annotatePluginBackedSkills(data.skills.success, plugins), { + path, + userSkillsRoots: codexUserSkillsRoots(path, layout), + }) : []; const mcpServersStatus = data.includeMcpServers ? Result.isSuccess(data.mcpServers) @@ -2500,6 +2525,109 @@ export function derivePluginBackedSkillBundle(skillPath: string): PluginBackedSk return null; } +/** + * Claude auto-loads each folder in the user skills dir as a plugin named `@skills-dir`, + * which is what `claude plugin enable|disable` takes and what lands in settings.json. + */ +export const CLAUDE_SKILLS_DIR_PLUGIN_SUFFIX = "@skills-dir"; + +/** + * A skill is only managed when its folder sits directly inside a root, `//SKILL.md`. + * Namespaced skills nested deeper belong to a layout Threadlines did not create, so they are left + * alone. Returns the skill's own directory when it matches. + */ +export function skillDirectoryUnderRoots( + path: Path.Path, + roots: ReadonlyArray, + skillPath: string, +): string | null { + const directory = path.dirname(path.resolve(skillPath)); + const parent = normalizedPathKey(path.dirname(directory)); + return roots.some((root) => normalizedPathKey(path.resolve(root)) === parent) ? directory : null; +} + +/** `enabledPlugins` in a Claude settings.json, ignoring anything that is not a boolean. */ +export function parseClaudeEnabledPlugins(contents: string): Map { + const enabled = new Map(); + if (contents.trim().length === 0) return enabled; + let parsed: unknown; + try { + parsed = JSON.parse(contents); + } catch { + return enabled; + } + if (typeof parsed !== "object" || parsed === null) return enabled; + const map = (parsed as { readonly enabledPlugins?: unknown }).enabledPlugins; + if (typeof map !== "object" || map === null) return enabled; + for (const [key, value] of Object.entries(map as Record)) { + if (typeof value === "boolean") enabled.set(key, value); + } + return enabled; +} + +/** + * `claude plugin list --json` does not report skills-dir entries (checked on 2.1.239), so the + * enabled state for a user skill comes from the settings.json map the CLI writes. A missing entry + * means the skill is enabled. + */ +const readClaudeEnabledPlugins = Effect.fn("providerExtensions.readClaudeEnabledPlugins")( + function* ( + claudeHome: string, + ): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const contents = yield* fileSystem + .readFileString(path.join(claudeHome, ".claude", "settings.json")) + .pipe(Effect.catch(() => Effect.succeed(""))); + return parseClaudeEnabledPlugins(contents); + }, +); + +export function annotateClaudeSkillCapabilities( + skills: ReadonlyArray, + input: { + readonly path: Path.Path; + readonly userSkillsRoot: string; + readonly writableRoots: ReadonlyArray; + readonly enabledPlugins: ReadonlyMap; + }, +): ProviderExtensionSkill[] { + return skills.map((skill) => { + // A bundled skill follows the plugin that ships it; it is neither toggled nor deleted here. + if (skill.bundleId !== undefined) return skill; + const skillsDirDirectory = skillDirectoryUnderRoots( + input.path, + [input.userSkillsRoot], + skill.path, + ); + const pluginId = skillsDirDirectory + ? `${input.path.basename(skillsDirDirectory)}${CLAUDE_SKILLS_DIR_PLUGIN_SUFFIX}` + : null; + const canDelete = + skillDirectoryUnderRoots(input.path, input.writableRoots, skill.path) !== null; + return { + ...skill, + ...(pluginId ? { enabled: input.enabledPlugins.get(pluginId) ?? true, canToggle: true } : {}), + ...(canDelete ? { canDelete: true } : {}), + }; + }); +} + +export function annotateCodexSkillCapabilities( + skills: ReadonlyArray, + input: { readonly path: Path.Path; readonly userSkillsRoots: ReadonlyArray }, +): ProviderExtensionSkill[] { + return skills.map((skill) => { + if (skill.bundleId !== undefined) return skill; + // Codex toggles every skill it reports through `skills/config/write`. Its own bundled skills + // live a level deeper (`skills/.system//`), so the direct-child rule excludes them from + // deletion on its own. + const canDelete = + skillDirectoryUnderRoots(input.path, input.userSkillsRoots, skill.path) !== null; + return { ...skill, canToggle: true, ...(canDelete ? { canDelete: true } : {}) }; + }); +} + function annotatePluginBackedSkills( skills: ReadonlyArray, plugins: ReadonlyArray, @@ -2805,25 +2933,48 @@ export function claudePluginSkillRoots( }); } +/** The user skills root Claude auto-loads as `@skills-dir` plugins. */ +export function claudeUserSkillsRoot(path: Path.Path, claudeHome: string): string { + return path.join(claudeHome, ".claude", "skills"); +} + +/** + * Every skills root Threadlines is allowed to write to or delete from: the user root plus the + * project roots discovered around the cwd. Plugin-bundled roots are deliberately excluded — those + * belong to the plugin that installed them. + */ +const claudeWritableSkillRoots = Effect.fn("providerExtensions.claudeWritableSkillRoots")( + function* ( + claudeHome: string, + cwd: string, + ): Effect.fn.Return { + const path = yield* Path.Path; + const nestedProjectRoots = yield* discoverNestedClaudeSkillRoots(cwd); + return uniqueClaudeSkillRoots( + [ + ...nestedProjectRoots, + ...claudeAncestorSkillRoots(path, cwd), + { + root: claudeUserSkillsRoot(path, claudeHome), + scope: "user", + source: "Claude user", + priority: 0, + }, + ], + path, + ); + }, +); + const readClaudeSkills = Effect.fn("providerExtensions.readClaudeSkills")(function* ( claudeHome: string, cwd: string, plugins: ReadonlyArray = [], ) { const path = yield* Path.Path; - const nestedProjectRoots = yield* discoverNestedClaudeSkillRoots(cwd); + const writableRoots = yield* claudeWritableSkillRoots(claudeHome, cwd); const skillRoots = uniqueClaudeSkillRoots( - [ - ...nestedProjectRoots, - ...claudeAncestorSkillRoots(path, cwd), - { - root: path.join(claudeHome, ".claude", "skills"), - scope: "user", - source: "Claude user", - priority: 0, - }, - ...claudePluginSkillRoots(path, plugins), - ], + [...writableRoots, ...claudePluginSkillRoots(path, plugins)], path, ); const discovered = yield* Effect.forEach(skillRoots, readSkillsFromRoot, { @@ -3198,7 +3349,8 @@ const readClaudeInventory = Effect.fn("providerExtensions.readClaudeInventory")( marketplaces, marketplaceManifests, ); - const skillsResult = yield* readClaudeSkills(path.resolve(claudeHome), input.cwd, plugins).pipe( + const resolvedClaudeHome = path.resolve(claudeHome); + const skillsResult = yield* readClaudeSkills(resolvedClaudeHome, input.cwd, plugins).pipe( Effect.result, ); const messages = [ @@ -3206,8 +3358,17 @@ const readClaudeInventory = Effect.fn("providerExtensions.readClaudeInventory")( resultMessage(mcpResult), resultMessage(skillsResult), ].filter((message): message is string => Boolean(message)); + const [enabledPlugins, writableRoots] = yield* Effect.all([ + readClaudeEnabledPlugins(resolvedClaudeHome), + claudeWritableSkillRoots(resolvedClaudeHome, input.cwd), + ]); const skills = Result.isSuccess(skillsResult) - ? annotatePluginBackedSkills(skillsResult.success, plugins) + ? annotateClaudeSkillCapabilities(annotatePluginBackedSkills(skillsResult.success, plugins), { + path, + userSkillsRoot: claudeUserSkillsRoot(path, resolvedClaudeHome), + writableRoots: writableRoots.map((root) => root.root), + enabledPlugins, + }) : []; return { status: messages.length > 0 ? "partial" : "ready", @@ -3601,6 +3762,241 @@ export const reloadProviderExtensionMcpServers = Effect.fn( return { reloaded: true }; }); +/** + * Which skills roots a provider lets Threadlines write to and delete from, and where a newly + * created skill goes. Resolved from settings alone — no provider process is started, because + * create and delete are plain filesystem work on both drivers. + */ +interface ProviderSkillRoots { + readonly driver: string; + /** Where `createProviderExtensionSkill` scaffolds. User scope only in v1. */ + readonly userSkillsRoot: string; + /** Every root a skill may be deleted from, user and project. */ + readonly writableRoots: ReadonlyArray; +} + +const resolveProviderSkillRoots = Effect.fn("providerExtensions.resolveProviderSkillRoots")( + function* (input: { + readonly cwd?: string | undefined; + readonly providerInstanceId: ProviderInstanceId; + readonly settings: ServerSettings; + }): Effect.fn.Return< + ProviderSkillRoots, + ProviderExtensionsError, + FileSystem.FileSystem | Path.Path + > { + const path = yield* Path.Path; + const cwd = input.cwd ?? machineScopeCwd(); + const providerConfig = yield* resolveProviderActionConfig(input); + + if (providerConfig.driver === CLAUDE_DRIVER) { + const decoded = yield* Effect.try({ + try: () => decodeClaudeSettings(providerConfig.config ?? {}), + catch: (cause) => + new ProviderExtensionsError({ + message: `Could not decode Claude settings for ${input.providerInstanceId}.`, + cause, + }), + }); + if (!(providerConfig.enabled ?? decoded.enabled)) { + return yield* new ProviderExtensionsError({ message: "Provider is disabled." }); + } + const claudeHome = yield* resolveClaudeHomePath(decoded); + const roots = yield* claudeWritableSkillRoots(claudeHome, cwd); + return { + driver: providerConfig.driver, + userSkillsRoot: claudeUserSkillsRoot(path, claudeHome), + writableRoots: roots.map((root) => root.root), + }; + } + + if (providerConfig.driver === CODEX_DRIVER) { + const decoded = yield* Effect.try({ + try: () => decodeCodexSettings(providerConfig.config ?? {}), + catch: (cause) => + new ProviderExtensionsError({ + message: `Could not decode Codex settings for ${input.providerInstanceId}.`, + cause, + }), + }); + if (!(providerConfig.enabled ?? decoded.enabled)) { + return yield* new ProviderExtensionsError({ message: "Provider is disabled." }); + } + const layout = yield* resolveCodexHomeLayout(decoded); + const roots = codexUserSkillsRoots(path, layout); + return { + driver: providerConfig.driver, + userSkillsRoot: roots[0]!, + writableRoots: roots, + }; + } + + return yield* new ProviderExtensionsError({ + message: "Managing skills is only available for Codex and Claude providers.", + }); + }, +); + +function skillMarkdownTemplate(input: { + readonly name: string; + readonly description: string; +}): string { + // JSON quoting doubles as a valid YAML double-quoted scalar, so a description with a colon or a + // quote in it cannot break the front matter. + return `--- +name: ${input.name} +description: ${JSON.stringify(input.description)} +--- + +# ${input.name} + +${input.description} + +Replace this with the steps the agent should follow. +`; +} + +export const createProviderExtensionSkill = Effect.fn( + "providerExtensions.createProviderExtensionSkill", +)(function* (input: { + readonly request: ProviderExtensionSkillCreateInput; + readonly settings: ServerSettings; +}): Effect.fn.Return< + ProviderExtensionSkillCreateResult, + ProviderExtensionsError, + FileSystem.FileSystem | Path.Path +> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const name = input.request.name; + // The schema already enforces this, but the check is cheap and keeps a malformed name from ever + // reaching a path join. + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) || name.length > 64) { + return yield* new ProviderExtensionsError({ + message: + "Skill names use lower-case letters, digits, and single hyphens, up to 64 characters.", + }); + } + + const roots = yield* resolveProviderSkillRoots({ + cwd: input.request.cwd, + providerInstanceId: input.request.providerInstanceId, + settings: input.settings, + }); + const directory = path.join(roots.userSkillsRoot, name); + const exists = yield* fileSystem + .exists(directory) + .pipe(Effect.catch(() => Effect.succeed(false))); + if (exists) { + return yield* new ProviderExtensionsError({ + message: `A skill named ${name} already exists at ${directory}.`, + }); + } + + const skillPath = path.join(directory, "SKILL.md"); + const description = + optionalText(input.request.description) ?? `Describe when ${name} should be used.`; + yield* fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.andThen( + fileSystem.writeFileString(skillPath, skillMarkdownTemplate({ name, description })), + ), + Effect.mapError( + (cause) => + new ProviderExtensionsError({ + message: `Could not create the skill at ${skillPath}.`, + cause, + }), + ), + ); + return { path: skillPath }; +}); + +export const deleteProviderExtensionSkill = Effect.fn( + "providerExtensions.deleteProviderExtensionSkill", +)(function* (input: { + readonly request: ProviderExtensionSkillDeleteInput; + readonly settings: ServerSettings; +}): Effect.fn.Return< + ProviderExtensionSkillDeleteResult, + ProviderExtensionsError, + FileSystem.FileSystem | Path.Path +> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillPath = input.request.path; + if (!isCurrentProviderExtensionSkillPath(skillPath)) { + return yield* new ProviderExtensionsError({ + message: "Skill is not in the current provider extensions inventory.", + }); + } + if (derivePluginBackedSkillBundle(skillPath)) { + return yield* new ProviderExtensionsError({ + message: "This skill is installed by a plugin. Uninstall the plugin to remove it.", + }); + } + + const roots = yield* resolveProviderSkillRoots({ + cwd: input.request.cwd, + providerInstanceId: input.request.providerInstanceId, + settings: input.settings, + }); + + const linkTarget = yield* fileSystem + .readLink(skillPath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (linkTarget !== null) { + return yield* new ProviderExtensionsError({ + message: "Skill could not be deleted because the inventory entry is not a regular file.", + }); + } + const stat = yield* fileSystem + .stat(skillPath) + .pipe( + Effect.mapError(() => new ProviderExtensionsError({ message: "Skill could not be read." })), + ); + if (stat.type !== "File") { + return yield* new ProviderExtensionsError({ + message: "Skill could not be deleted because the inventory entry is not a regular file.", + }); + } + + // Compare real paths so a symlinked skills root (the Codex shadow home) resolves to the same + // place as the shared one, and so no `..` segment can walk out of a root. + const realDirectory = yield* fileSystem + .realPath(path.dirname(path.resolve(skillPath))) + .pipe( + Effect.mapError( + () => new ProviderExtensionsError({ message: "Skill directory could not be resolved." }), + ), + ); + const realRoots = yield* Effect.forEach(roots.writableRoots, (root) => + fileSystem.realPath(root).pipe(Effect.catch(() => Effect.succeed(null))), + ); + const parent = normalizedPathKey(path.dirname(realDirectory)); + const insideRoot = realRoots.some((root) => root !== null && normalizedPathKey(root) === parent); + if (!insideRoot) { + return yield* new ProviderExtensionsError({ + message: "Skill is not in a skills folder Threadlines manages.", + }); + } + + yield* fileSystem.remove(realDirectory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new ProviderExtensionsError({ + message: `Could not delete the skill folder ${realDirectory}.`, + cause, + }), + ), + ); + return { deleted: true }; +}); + +/** + * Codex has a first-class per-skill toggle. Claude has no skill toggle, but it auto-loads each + * folder in the user skills dir as a `@skills-dir` plugin, so the normal plugin + * enable/disable path reaches it. + */ export const setProviderExtensionSkillEnabled = Effect.fn( "providerExtensions.setProviderExtensionSkillEnabled", )(function* (input: { @@ -3611,6 +4007,36 @@ export const setProviderExtensionSkillEnabled = Effect.fn( ProviderExtensionsError, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { + const providerConfig = yield* resolveProviderActionConfig({ + providerInstanceId: input.request.providerInstanceId, + settings: input.settings, + }); + + if (providerConfig.driver === CLAUDE_DRIVER) { + const path = yield* Path.Path; + const context = yield* resolveClaudeActionContext({ + cwd: input.request.cwd, + providerInstanceId: input.request.providerInstanceId, + settings: input.settings, + }); + const skillPath = optionalText(input.request.path); + const claudeHome = yield* resolveClaudeHomePath(context.config); + const directory = skillPath + ? skillDirectoryUnderRoots(path, [claudeUserSkillsRoot(path, claudeHome)], skillPath) + : null; + if (!directory) { + return yield* new ProviderExtensionsError({ + message: "Claude can only enable or disable skills in your personal skills folder.", + }); + } + yield* runClaudePluginAction(context, [ + "plugin", + input.request.enabled ? "enable" : "disable", + `${path.basename(directory)}${CLAUDE_SKILLS_DIR_PLUGIN_SUFFIX}`, + ]); + return { effectiveEnabled: input.request.enabled }; + } + const params = codexSkillConfigWriteParams(input.request); if (!params) { return yield* new ProviderExtensionsError({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 393131b60..0d5a95153 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -88,6 +88,8 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner import { addProviderExtensionMarketplace, callProviderExtensionMcpTool, + createProviderExtensionSkill, + deleteProviderExtensionSkill, getProviderExtensionOperationStatus, installProviderExtensionPlugin, readProviderInstructionFiles, @@ -1368,6 +1370,24 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => }), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverCreateProviderExtensionSkill]: (input) => + observeRpcEffect( + WS_METHODS.serverCreateProviderExtensionSkill, + Effect.gen(function* () { + const settings = yield* loadProviderExtensionSettings; + return yield* createProviderExtensionSkill({ request: input, settings }); + }), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.serverDeleteProviderExtensionSkill]: (input) => + observeRpcEffect( + WS_METHODS.serverDeleteProviderExtensionSkill, + Effect.gen(function* () { + const settings = yield* loadProviderExtensionSettings; + return yield* deleteProviderExtensionSkill({ request: input, settings }); + }), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverReadProviderExtensionPlugin]: (input) => observeRpcEffect( WS_METHODS.serverReadProviderExtensionPlugin, diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 10765d57d..3edf1062e 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -39,6 +39,8 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { getExtensionOperationStatus: rpcClient.server.getProviderExtensionOperationStatus, reloadExtensionMcpServers: rpcClient.server.reloadProviderExtensionMcpServers, setExtensionSkillEnabled: rpcClient.server.setProviderExtensionSkillEnabled, + createExtensionSkill: rpcClient.server.createProviderExtensionSkill, + deleteExtensionSkill: rpcClient.server.deleteProviderExtensionSkill, readExtensionSkill: rpcClient.server.readProviderExtensionSkill, readExtensionPlugin: rpcClient.server.readProviderExtensionPlugin, installExtensionPlugin: rpcClient.server.installProviderExtensionPlugin, diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 3d078cf75..60f9d6368 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -159,6 +159,10 @@ function createBrowserLocalApi(resolveRpcClient?: () => WsRpcClient | null): Loc withServer((server) => server.reloadProviderExtensionMcpServers(input)), setProviderExtensionSkillEnabled: (input) => withServer((server) => server.setProviderExtensionSkillEnabled(input)), + createProviderExtensionSkill: (input) => + withServer((server) => server.createProviderExtensionSkill(input)), + deleteProviderExtensionSkill: (input) => + withServer((server) => server.deleteProviderExtensionSkill(input)), readProviderExtensionSkill: (input) => withServer((server) => server.readProviderExtensionSkill(input)), readProviderExtensionPlugin: (input) => diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index d81442f1e..135d35188 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -241,6 +241,12 @@ export interface WsRpcClient { readonly readProviderExtensionSkill: RpcUnaryMethod< typeof WS_METHODS.serverReadProviderExtensionSkill >; + readonly createProviderExtensionSkill: RpcUnaryMethod< + typeof WS_METHODS.serverCreateProviderExtensionSkill + >; + readonly deleteProviderExtensionSkill: RpcUnaryMethod< + typeof WS_METHODS.serverDeleteProviderExtensionSkill + >; readonly readProviderExtensionPlugin: RpcUnaryMethod< typeof WS_METHODS.serverReadProviderExtensionPlugin >; @@ -650,6 +656,18 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { Effect.withTracerEnabled(false), ), ), + createProviderExtensionSkill: (input) => + transport.request((client) => + client[WS_METHODS.serverCreateProviderExtensionSkill](input).pipe( + Effect.withTracerEnabled(false), + ), + ), + deleteProviderExtensionSkill: (input) => + transport.request((client) => + client[WS_METHODS.serverDeleteProviderExtensionSkill](input).pipe( + Effect.withTracerEnabled(false), + ), + ), readProviderExtensionPlugin: (input) => transport.request((client) => client[WS_METHODS.serverReadProviderExtensionPlugin](input).pipe( diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b2cb137a5..9c5134e56 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -98,6 +98,10 @@ import type { ProviderExtensionPluginUninstallResult, ProviderExtensionPluginUpdateInput, ProviderExtensionPluginUpdateResult, + ProviderExtensionSkillCreateInput, + ProviderExtensionSkillCreateResult, + ProviderExtensionSkillDeleteInput, + ProviderExtensionSkillDeleteResult, ProviderExtensionSkillReadInput, ProviderExtensionSkillReadResult, ProviderExtensionSkillToggleInput, @@ -1099,6 +1103,12 @@ export interface LocalApi { readProviderExtensionSkill: ( input: ProviderExtensionSkillReadInput, ) => Promise; + createProviderExtensionSkill: ( + input: ProviderExtensionSkillCreateInput, + ) => Promise; + deleteProviderExtensionSkill: ( + input: ProviderExtensionSkillDeleteInput, + ) => Promise; readProviderExtensionPlugin: ( input: ProviderExtensionPluginReadInput, ) => Promise; @@ -1200,6 +1210,12 @@ export interface EnvironmentApi { readExtensionSkill: ( input: ProviderExtensionSkillReadInput, ) => Promise; + createExtensionSkill: ( + input: ProviderExtensionSkillCreateInput, + ) => Promise; + deleteExtensionSkill: ( + input: ProviderExtensionSkillDeleteInput, + ) => Promise; readExtensionPlugin: ( input: ProviderExtensionPluginReadInput, ) => Promise; diff --git a/packages/contracts/src/providerExtensions.ts b/packages/contracts/src/providerExtensions.ts index aa680c2d7..6283cb4d3 100644 --- a/packages/contracts/src/providerExtensions.ts +++ b/packages/contracts/src/providerExtensions.ts @@ -148,6 +148,17 @@ export const ProviderExtensionSkill = Schema.Struct({ bundleId: Schema.optional(TrimmedNonEmptyString), bundleName: Schema.optional(TrimmedNonEmptyString), bundleDisplayName: Schema.optional(TrimmedNonEmptyString), + /** + * The server can flip this skill's enabled state on its own. Absent means it cannot, so the + * client renders status text instead of a toggle. Plugin-bundled skills follow their plugin + * and are never toggled directly. + */ + canToggle: Schema.optional(Schema.Boolean), + /** + * The skill directory sits directly inside a user or project skills root the server is allowed + * to delete from. Never set for plugin-bundled or provider-shipped skills. + */ + canDelete: Schema.optional(Schema.Boolean), }); export type ProviderExtensionSkill = typeof ProviderExtensionSkill.Type; @@ -410,6 +421,40 @@ export const ProviderExtensionSkillReadResult = Schema.Struct({ }); export type ProviderExtensionSkillReadResult = typeof ProviderExtensionSkillReadResult.Type; +/** + * Skill names double as directory names and as the identifier providers match on, so they are + * restricted to kebab-case. The server rejects anything else rather than sanitizing it. + */ +export const ProviderExtensionSkillName = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), +); + +export const ProviderExtensionSkillCreateInput = Schema.Struct({ + ...ProviderExtensionActionBaseInput, + name: ProviderExtensionSkillName, + description: Schema.optional(TrimmedString), +}); +export type ProviderExtensionSkillCreateInput = typeof ProviderExtensionSkillCreateInput.Type; + +export const ProviderExtensionSkillCreateResult = Schema.Struct({ + /** Absolute path of the SKILL.md that was written. */ + path: TrimmedNonEmptyString, +}); +export type ProviderExtensionSkillCreateResult = typeof ProviderExtensionSkillCreateResult.Type; + +export const ProviderExtensionSkillDeleteInput = Schema.Struct({ + ...ProviderExtensionActionBaseInput, + /** Absolute path of the skill file, as reported by the inventory. */ + path: TrimmedNonEmptyString, +}); +export type ProviderExtensionSkillDeleteInput = typeof ProviderExtensionSkillDeleteInput.Type; + +export const ProviderExtensionSkillDeleteResult = Schema.Struct({ + deleted: Schema.Boolean, +}); +export type ProviderExtensionSkillDeleteResult = typeof ProviderExtensionSkillDeleteResult.Type; + export const ProviderExtensionPluginReadInput = Schema.Struct({ ...ProviderExtensionActionBaseInput, pluginName: TrimmedNonEmptyString, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index bf4644d65..823f66a05 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -143,6 +143,10 @@ import { ProviderExtensionPluginUninstallResult, ProviderExtensionPluginUpdateInput, ProviderExtensionPluginUpdateResult, + ProviderExtensionSkillCreateInput, + ProviderExtensionSkillCreateResult, + ProviderExtensionSkillDeleteInput, + ProviderExtensionSkillDeleteResult, ProviderExtensionSkillReadInput, ProviderExtensionSkillReadResult, ProviderExtensionSkillToggleInput, @@ -337,6 +341,8 @@ export const WS_METHODS = { serverReloadProviderExtensionMcpServers: "server.reloadProviderExtensionMcpServers", serverSetProviderExtensionSkillEnabled: "server.setProviderExtensionSkillEnabled", serverReadProviderExtensionSkill: "server.readProviderExtensionSkill", + serverCreateProviderExtensionSkill: "server.createProviderExtensionSkill", + serverDeleteProviderExtensionSkill: "server.deleteProviderExtensionSkill", serverReadProviderExtensionPlugin: "server.readProviderExtensionPlugin", serverInstallProviderExtensionPlugin: "server.installProviderExtensionPlugin", serverUninstallProviderExtensionPlugin: "server.uninstallProviderExtensionPlugin", @@ -565,6 +571,24 @@ export const WsServerReadProviderExtensionSkillRpc = Rpc.make( }, ); +export const WsServerCreateProviderExtensionSkillRpc = Rpc.make( + WS_METHODS.serverCreateProviderExtensionSkill, + { + payload: ProviderExtensionSkillCreateInput, + success: ProviderExtensionSkillCreateResult, + error: ProviderExtensionsError, + }, +); + +export const WsServerDeleteProviderExtensionSkillRpc = Rpc.make( + WS_METHODS.serverDeleteProviderExtensionSkill, + { + payload: ProviderExtensionSkillDeleteInput, + success: ProviderExtensionSkillDeleteResult, + error: ProviderExtensionsError, + }, +); + export const WsServerReadProviderExtensionPluginRpc = Rpc.make( WS_METHODS.serverReadProviderExtensionPlugin, { @@ -1159,6 +1183,8 @@ export const WsRpcGroup = RpcGroup.make( WsServerReloadProviderExtensionMcpServersRpc, WsServerSetProviderExtensionSkillEnabledRpc, WsServerReadProviderExtensionSkillRpc, + WsServerCreateProviderExtensionSkillRpc, + WsServerDeleteProviderExtensionSkillRpc, WsServerReadProviderExtensionPluginRpc, WsServerInstallProviderExtensionPluginRpc, WsServerUninstallProviderExtensionPluginRpc, From 42fbf8eff4dc50e5113c0a34315536170b7de5f6 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:27:54 -0400 Subject: [PATCH 02/13] feat(web): split the plugins page into Plugins and Skills tabs The page mixed plugins, skills, apps, and connections in one scroll, and skills were buried inside per-provider rows. A page-level tab bar now splits it in two. Skills get a flat cross-provider list grouped by origin, with inline toggles where the provider supports them, a New skill button, and Delete in the skill detail dialog. Apps move out of the per-provider rows into their own section on the Plugins tab. --- .../settings/ExtensionsSettings.logic.ts | 85 ++ .../settings/ExtensionsSettings.tsx | 1195 ++++++++++------- apps/web/src/routes/settings.plugins.tsx | 7 + 3 files changed, 774 insertions(+), 513 deletions(-) diff --git a/apps/web/src/components/settings/ExtensionsSettings.logic.ts b/apps/web/src/components/settings/ExtensionsSettings.logic.ts index c979a07bc..ab84c820b 100644 --- a/apps/web/src/components/settings/ExtensionsSettings.logic.ts +++ b/apps/web/src/components/settings/ExtensionsSettings.logic.ts @@ -22,6 +22,91 @@ export { type ExtensionMcpOAuthActionIntent, } from "../../mcpAuthStatus"; +// ── Page tabs ──────────────────────────────────────────────────────── +// +// The page holds two unrelated jobs — installing plugins and managing skills — so +// it splits into tabs. The tab lives in the URL so a link can point at either one, +// and is remembered so returning to settings lands where you left. + +export const EXTENSIONS_SETTINGS_TABS = ["plugins", "skills"] as const; +export type ExtensionsSettingsTab = (typeof EXTENSIONS_SETTINGS_TABS)[number]; + +/** Route search validation. Returns undefined for a missing or unknown tab. */ +export function parseExtensionsSettingsTab(value: unknown): ExtensionsSettingsTab | undefined { + return EXTENSIONS_SETTINGS_TABS.find((tab) => tab === value); +} + +/** The URL is explicit intent, so it beats the remembered tab. */ +export function resolveExtensionsSettingsTab( + searchTab: ExtensionsSettingsTab | undefined, + rememberedTab: ExtensionsSettingsTab | undefined, +): ExtensionsSettingsTab { + return searchTab ?? rememberedTab ?? "plugins"; +} + +// ── Skill grouping ─────────────────────────────────────────────────── + +export type ExtensionSkillGroupKey = "project" | "personal" | "plugin" | "builtin"; + +/** + * Where a skill came from, which is what a reader wants to know first: one they wrote for this + * project, one they wrote for themselves, one a plugin brought, or one the provider ships. + */ +export function extensionSkillGroupKey(skill: { + readonly scope?: string | undefined; + readonly bundleId?: string | undefined; +}): ExtensionSkillGroupKey { + if (skill.bundleId?.trim()) return "plugin"; + const scope = skill.scope?.trim().toLowerCase(); + if (scope === "project") return "project"; + if (scope === "system") return "builtin"; + return "personal"; +} + +const EXTENSION_SKILL_GROUP_ORDER = [ + "project", + "personal", + "plugin", + "builtin", +] as const satisfies ReadonlyArray; + +export const EXTENSION_SKILL_GROUP_LABELS: Record = { + project: "Project skills", + personal: "Personal skills", + plugin: "From plugins", + builtin: "Built in", +}; + +export interface ExtensionSkillGroup { + readonly key: ExtensionSkillGroupKey; + readonly label: string; + readonly items: ReadonlyArray; +} + +/** Groups in a fixed origin order, each sorted by the name shown on the row. Empty groups drop. */ +export function groupExtensionSkills( + items: ReadonlyArray, + read: (item: T) => { + readonly scope?: string | undefined; + readonly bundleId?: string | undefined; + readonly sortKey: string; + }, +): ReadonlyArray> { + return EXTENSION_SKILL_GROUP_ORDER.flatMap((key) => { + const matching = items.filter((item) => extensionSkillGroupKey(read(item)) === key); + if (matching.length === 0) return []; + return [ + { + key, + label: EXTENSION_SKILL_GROUP_LABELS[key], + items: matching.toSorted((left, right) => + read(left).sortKey.localeCompare(read(right).sortKey), + ), + }, + ]; + }); +} + export function extensionProviderDriverSortRank(driverKind: string): number { if (driverKind === "codex") return 0; if (driverKind === "claudeAgent") return 1; diff --git a/apps/web/src/components/settings/ExtensionsSettings.tsx b/apps/web/src/components/settings/ExtensionsSettings.tsx index f8508e7cd..c88f556b1 100644 --- a/apps/web/src/components/settings/ExtensionsSettings.tsx +++ b/apps/web/src/components/settings/ExtensionsSettings.tsx @@ -21,8 +21,10 @@ import { RefreshCwIcon, SearchIcon, TerminalIcon, + Trash2Icon, WrenchIcon, } from "lucide-react"; +import { useNavigate, useSearch } from "@tanstack/react-router"; import { scopedThreadKey, scopeThreadRef } from "@threadlines/client-runtime"; import type { EnvironmentApi, @@ -80,6 +82,10 @@ import { extensionProviderDriverSortRank, formatSkillDisplayName, formatTokenCount, + groupExtensionSkills, + parseExtensionsSettingsTab, + resolveExtensionsSettingsTab, + type ExtensionsSettingsTab, rankPluginsAcrossProviders, resolveExtensionScope, selectCuratedPlugins, @@ -143,8 +149,6 @@ import { copyTextToClipboard } from "../../lib/clipboard"; import { cn } from "../../lib/utils"; const EXTENSION_SECTION_PREVIEW_LIMIT = 10; -/** Installed plugin lists are short now that the catalog lives behind Browse, so show them whole. */ -const EXTENSION_INSTALLED_PLUGIN_PREVIEW_LIMIT = 25; const EXTENSION_BROWSER_PAGE_SIZE = 80; const EXTENSION_INVENTORY_CACHE_MAX_ENTRIES = 5; const EXTENSION_INVENTORY_CACHE_TTL_MS = 10 * 60 * 1_000; @@ -243,6 +247,8 @@ interface ExtensionsSettingsPanelMemoryState { | undefined; manualThreadOverride?: { readonly scopeKey: string; readonly value: string } | undefined; showAdvancedContext?: boolean | undefined; + /** Overridden by a `tab` search param, so a shared link still opens where it points. */ + tab?: ExtensionsSettingsTab | undefined; } type ExtensionProvidersApi = NonNullable; @@ -358,35 +364,6 @@ function extensionItemActionKey(item: ExtensionItem): string { return `${item.provider.instanceId}:${item.kind}:${item.id}`; } -function statusVariant(status: ProviderExtensionProviderInventory["status"]) { - switch (status) { - case "ready": - return "success"; - case "partial": - return "warning"; - case "error": - return "error"; - case "disabled": - case "unsupported": - return "outline"; - } -} - -function providerStatusLabel(status: ProviderExtensionProviderInventory["status"]): string { - switch (status) { - case "ready": - return "Ready"; - case "partial": - return "Loaded with issues"; - case "error": - return "Error"; - case "disabled": - return "Disabled"; - case "unsupported": - return "Unsupported"; - } -} - function providerTitle(provider: ProviderExtensionProviderInventory): string { return provider.displayName ?? (provider.driver === "claudeAgent" ? "Claude" : provider.driver); } @@ -638,6 +615,12 @@ function skillBundleKey(skill: ProviderExtensionSkill): string { }); } +/** `//SKILL.md` -> `/`: the folder a delete removes. */ +function skillFolderPath(skillPath: string): string { + const separatorIndex = skillPath.replaceAll("\\", "/").lastIndexOf("/"); + return separatorIndex > 0 ? skillPath.slice(0, separatorIndex) : skillPath; +} + function findRefreshedExtensionItem( current: ExtensionItem, inventory: ProviderExtensionsInventoryResult, @@ -1542,6 +1525,7 @@ function ExtensionDetailDialog({ onSelectItem, environmentId, cwd, + machineLabel, providerThreadId, onInventoryMutated, lastAction, @@ -1552,6 +1536,8 @@ function ExtensionDetailDialog({ onSelectItem: (item: ExtensionItem) => void; environmentId: EnvironmentId | null; cwd: string; + /** Named in the delete confirmation so it is clear which machine loses the folder. */ + machineLabel: string; providerThreadId: string; onInventoryMutated: () => Promise; lastAction?: ExtensionActionHistoryEntry | undefined; @@ -1806,6 +1792,25 @@ function ExtensionDetailDialog({ }); }, [cwd, item, onInventoryMutated, providersApi, runDialogAction]); + const deleteSkill = useCallback(() => { + if (!item || item.kind !== "skill") return; + const folder = skillFolderPath(item.skill.path); + void runDialogAction("Delete skill", async () => { + const confirmed = await ensureLocalApi().dialogs.confirm( + `Delete ${item.title}? Deletes the folder ${folder} on ${machineLabel}.`, + ); + if (!confirmed) return "Delete cancelled."; + await providersApi().deleteExtensionSkill({ + ...actionBaseInput(item, cwd), + path: item.skill.path, + }); + await onInventoryMutated(); + // The item this dialog is bound to no longer exists, so there is nothing left to show. + onClose(); + return "Skill deleted."; + }); + }, [cwd, item, machineLabel, onClose, onInventoryMutated, providersApi, runDialogAction]); + const installPlugin = useCallback(() => { if (!item || item.kind !== "plugin") return; void runDialogAction("Install plugin", async () => { @@ -2414,7 +2419,7 @@ function ExtensionDetailDialog({ ) : null} ) : null} - {codexActionsAvailable && item.kind === "skill" ? ( + {item.kind === "skill" && item.skill.canToggle === true ? ( + ) : null} {codexActionsAvailable && item.kind === "plugin" ? ( <> {item.plugin.installed === true ? ( @@ -3005,43 +3025,21 @@ function InstalledStripSkeleton() { ); } -function ProviderInventorySkeleton() { +function SkillListSkeleton() { return ( -