diff --git a/apps/server/src/provider/providerCommandsDiscovery.ts b/apps/server/src/provider/providerCommandsDiscovery.ts new file mode 100644 index 000000000000..f751427c23d3 --- /dev/null +++ b/apps/server/src/provider/providerCommandsDiscovery.ts @@ -0,0 +1,207 @@ +/** + * providerCommandsDiscovery - Filesystem discovery for CLI slash commands and skills. + * + * Both Codex and Claude expose user/project scoped commands and skills via + * markdown files under well-known directories. We scan those directories and + * project them into a uniform shape for the composer autocomplete menus. + * + * Discovery layout (per provider): + * ~/./commands/.md → user commands + * /./commands/.md → project commands + * ~/./skills//SKILL.md → user skills + * /./skills//SKILL.md → project skills + * + * Codex's CLI also supports `~/.codex/prompts` for slash commands; we treat + * that as an additional source so users with existing Codex prompts get them + * surfaced automatically. + */ +import { promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; + +import { + type ProviderCommandEntry, + type ProviderCommandsListInput, + type ProviderCommandsListResult, + type ProviderCommandSource, + type ProviderKind, +} from "@t3tools/contracts"; +import { Data, Effect } from "effect"; + +export class ProviderCommandsDiscoveryError extends Data.TaggedError( + "ProviderCommandsDiscoveryError", +)<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +interface ProviderLayout { + readonly directoryNames: ReadonlyArray; + readonly commandSubpaths: ReadonlyArray; + readonly skillSubpaths: ReadonlyArray; +} + +const PROVIDER_LAYOUTS: Record = { + codex: { + directoryNames: [".codex"], + commandSubpaths: ["commands", "prompts"], + skillSubpaths: ["skills"], + }, + claudeAgent: { + directoryNames: [".claude"], + commandSubpaths: ["commands"], + skillSubpaths: ["skills"], + }, +}; + +const DESCRIPTION_PATTERN = /^description\s*[:=]\s*(.+)$/im; +const FRONTMATTER_PATTERN = /^---\s*\n([\s\S]*?)\n---\s*/; + +function stripExtension(name: string): string { + const dot = name.lastIndexOf("."); + return dot > 0 ? name.slice(0, dot) : name; +} + +function extractDescription(content: string): string { + const frontmatter = FRONTMATTER_PATTERN.exec(content); + if (frontmatter?.[1]) { + const match = DESCRIPTION_PATTERN.exec(frontmatter[1]); + if (match?.[1]) { + return match[1].trim().replace(/^["']|["']$/g, ""); + } + } + const body = frontmatter ? content.slice(frontmatter[0].length) : content; + for (const line of body.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + return trimmed.length > 160 ? `${trimmed.slice(0, 157)}...` : trimmed; + } + return ""; +} + +async function safeReadFile(path: string): Promise { + try { + return await fs.readFile(path, "utf8"); + } catch { + return null; + } +} + +async function readDirEntries(path: string) { + try { + return await fs.readdir(path, { withFileTypes: true }); + } catch { + return null; + } +} + +async function collectCommandsFromDir( + dir: string, + source: ProviderCommandSource, +): Promise { + const entries = await readDirEntries(dir); + if (!entries) return []; + const out: ProviderCommandEntry[] = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.toLowerCase().endsWith(".md")) continue; + const name = stripExtension(entry.name); + if (!name) continue; + const content = (await safeReadFile(join(dir, entry.name))) ?? ""; + out.push({ name, description: extractDescription(content), source }); + } + return out; +} + +async function collectSkillsFromDir( + dir: string, + source: ProviderCommandSource, +): Promise { + const entries = await readDirEntries(dir); + if (!entries) return []; + const out: ProviderCommandEntry[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillDir = join(dir, entry.name); + const skillFile = + (await safeReadFile(join(skillDir, "SKILL.md"))) ?? + (await safeReadFile(join(skillDir, "skill.md"))); + if (skillFile === null) continue; + out.push({ + name: basename(entry.name), + description: extractDescription(skillFile), + source, + }); + } + return out; +} + +function dedupeByName(entries: ReadonlyArray): ProviderCommandEntry[] { + // Project entries take precedence over user entries with the same name. + const sourceRank: Record = { + project: 0, + user: 1, + builtin: 2, + }; + const byName = new Map(); + for (const entry of entries) { + const existing = byName.get(entry.name); + if (!existing || sourceRank[entry.source] < sourceRank[existing.source]) { + byName.set(entry.name, entry); + } + } + return [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name)); +} + +async function discoverInternal( + input: ProviderCommandsListInput, +): Promise { + const layout = PROVIDER_LAYOUTS[input.provider]; + const home = homedir(); + const roots: ReadonlyArray<{ root: string; source: ProviderCommandSource }> = [ + ...layout.directoryNames.map((dir) => ({ + root: join(home, dir), + source: "user" as const, + })), + ...(input.cwd + ? layout.directoryNames.map((dir) => ({ + root: join(input.cwd!, dir), + source: "project" as const, + })) + : []), + ]; + + const commandResults: ProviderCommandEntry[] = []; + const skillResults: ProviderCommandEntry[] = []; + + await Promise.all( + roots.map(async ({ root, source }) => { + await Promise.all([ + ...layout.commandSubpaths.map(async (sub) => { + commandResults.push(...(await collectCommandsFromDir(join(root, sub), source))); + }), + ...layout.skillSubpaths.map(async (sub) => { + skillResults.push(...(await collectSkillsFromDir(join(root, sub), source))); + }), + ]); + }), + ); + + return { + provider: input.provider, + commands: dedupeByName(commandResults), + skills: dedupeByName(skillResults), + }; +} + +export const discoverProviderCommands = ( + input: ProviderCommandsListInput, +): Effect.Effect => + Effect.tryPromise({ + try: () => discoverInternal(input), + catch: (cause) => + new ProviderCommandsDiscoveryError({ + message: cause instanceof Error ? cause.message : "Failed to discover provider commands", + cause, + }), + }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ca096bff33ed..2deffe2b9c53 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -13,6 +13,7 @@ import { ORCHESTRATION_WS_METHODS, ProjectSearchEntriesError, ProjectWriteFileError, + ProviderCommandsListError, OrchestrationReplayEventsError, ThreadId, type TerminalEvent, @@ -47,6 +48,7 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; +import { discoverProviderCommands } from "./provider/providerCommandsDiscovery"; const WsRpcLayer = WsRpcGroup.toLayer( Effect.gen(function* () { @@ -561,6 +563,20 @@ const WsRpcLayer = WsRpcGroup.toLayer( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.providersListCommands]: (input) => + observeRpcEffect( + WS_METHODS.providersListCommands, + discoverProviderCommands(input).pipe( + Effect.mapError( + (cause) => + new ProviderCommandsListError({ + message: `Failed to list provider commands: ${cause.message}`, + cause: cause.cause, + }), + ), + ), + { "rpc.aggregate": "provider" }, + ), [WS_METHODS.shellOpenInEditor]: (input) => observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { "rpc.aggregate": "workspace", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7c649b5003df..49abde32a19f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8,6 +8,7 @@ import { type ProviderKind, type ProjectEntry, type ProjectId, + type ProviderCommandEntry, type ProviderApprovalDecision, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, @@ -29,6 +30,7 @@ import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useGitStatus } from "~/lib/gitStatusState"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { providerCommandsQueryOptions } from "~/lib/providerCommandsReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { @@ -206,6 +208,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; +const EMPTY_PROVIDER_COMMAND_ENTRIES: ReadonlyArray = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; @@ -1443,6 +1446,17 @@ export default function ChatView({ threadId }: ChatViewProps) { }), ); const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; + const isProviderCommandsTrigger = + composerTriggerKind === "slash-command" || composerTriggerKind === "cli-skill"; + const providerCommandsQuery = useQuery( + providerCommandsQueryOptions({ + provider: selectedProvider, + cwd: gitCwd, + enabled: isProviderCommandsTrigger, + }), + ); + const providerCommands = providerCommandsQuery.data?.commands ?? EMPTY_PROVIDER_COMMAND_ENTRIES; + const providerSkills = providerCommandsQuery.data?.skills ?? EMPTY_PROVIDER_COMMAND_ENTRIES; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { @@ -1457,7 +1471,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (composerTrigger.kind === "slash-command") { - const slashCommandItems = [ + const builtInItems: ReadonlyArray> = [ { id: "slash:model", type: "slash-command", @@ -1479,14 +1493,48 @@ export default function ChatView({ threadId }: ChatViewProps) { label: "/default", description: "Switch this thread back to normal build mode", }, - ] satisfies ReadonlyArray>; + ]; + const cliCommandItems: ReadonlyArray> = + providerCommands.map((entry) => ({ + id: `cli:${selectedProvider}:${entry.source}:${entry.name}`, + type: "cli-command", + command: entry.name, + provider: selectedProvider, + source: entry.source, + label: `/${entry.name}`, + description: + entry.description || (entry.source === "project" ? "Project command" : "User command"), + })); + const allItems: ComposerCommandItem[] = [...builtInItems, ...cliCommandItems]; const query = composerTrigger.query.trim().toLowerCase(); if (!query) { - return [...slashCommandItems]; + return allItems; } - return slashCommandItems.filter( - (item) => item.command.includes(query) || item.label.slice(1).includes(query), - ); + return allItems.filter((item) => { + const haystack = (item.label + " " + item.description).toLowerCase(); + return haystack.includes(query); + }); + } + + if (composerTrigger.kind === "cli-skill") { + const skillItems: ComposerCommandItem[] = providerSkills.map((entry) => ({ + id: `skill:${selectedProvider}:${entry.source}:${entry.name}`, + type: "cli-skill", + skill: entry.name, + provider: selectedProvider, + source: entry.source, + label: `$${entry.name}`, + description: + entry.description || (entry.source === "project" ? "Project skill" : "User skill"), + })); + const query = composerTrigger.query.trim().toLowerCase(); + if (!query) { + return skillItems; + } + return skillItems.filter((item) => { + const haystack = (item.label + " " + item.description).toLowerCase(); + return haystack.includes(query); + }); } return searchableModelOptions @@ -1505,7 +1553,14 @@ export default function ChatView({ threadId }: ChatViewProps) { label: name, description: `${providerLabel} · ${slug}`, })); - }, [composerTrigger, searchableModelOptions, workspaceEntries]); + }, [ + composerTrigger, + searchableModelOptions, + workspaceEntries, + providerCommands, + providerSkills, + selectedProvider, + ]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => @@ -3716,6 +3771,42 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } + if (item.type === "cli-command") { + const replacement = `/${item.command} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + if (item.type === "cli-skill") { + const replacement = `$${item.skill} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } if (item.type === "slash-command") { if (item.command === "model") { const replacement = "/model "; diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index fc7ea27c29f4..ea55ac3d8828 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,7 +1,11 @@ -import { type ProjectEntry, type ProviderKind } from "@t3tools/contracts"; +import { + type ProjectEntry, + type ProviderCommandSource, + type ProviderKind, +} from "@t3tools/contracts"; import { memo, useLayoutEffect, useRef } from "react"; import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; -import { BotIcon } from "lucide-react"; +import { BotIcon, SparklesIcon, TerminalIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; import { Command, CommandItem, CommandList } from "../ui/command"; @@ -23,6 +27,24 @@ export type ComposerCommandItem = label: string; description: string; } + | { + id: string; + type: "cli-command"; + command: string; + provider: ProviderKind; + source: ProviderCommandSource; + label: string; + description: string; + } + | { + id: string; + type: "cli-skill"; + skill: string; + provider: ProviderKind; + source: ProviderCommandSource; + label: string; + description: string; + } | { id: string; type: "model"; @@ -80,10 +102,12 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { {props.items.length === 0 && (

{props.isLoading - ? "Searching workspace files..." + ? "Searching..." : props.triggerKind === "path" ? "No matching files or folders." - : "No matching command."} + : props.triggerKind === "cli-skill" + ? "No matching skill." + : "No matching command."}

)} @@ -126,6 +150,12 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { {props.item.type === "slash-command" ? ( ) : null} + {props.item.type === "cli-command" ? ( + + ) : null} + {props.item.type === "cli-skill" ? ( + + ) : null} {props.item.type === "model" ? ( model diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 44f32bef9a42..4643ba44d38c 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -60,6 +60,30 @@ describe("detectComposerTrigger", () => { }); }); + it("detects unknown slash commands as cli commands while typing", () => { + const text = "/commit"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "slash-command", + query: "commit", + rangeStart: 0, + rangeEnd: text.length, + }); + }); + + it("detects $ skill trigger while typing", () => { + const text = "$pdf"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "cli-skill", + query: "pdf", + rangeStart: 0, + rangeEnd: text.length, + }); + }); + it("detects @path trigger in the middle of existing text", () => { // User typed @ between "inspect " and "in this sentence" const text = "Please inspect @in this sentence"; diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index c8e62ebdcc4b..033439ff2d77 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,7 +1,7 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; -export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; +export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "cli-skill"; export type ComposerSlashCommand = "model" | "plan" | "default"; export interface ComposerTrigger { @@ -11,7 +11,6 @@ export interface ComposerTrigger { rangeEnd: number; } -const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; const isInlineTokenSegment = ( segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" }, ): boolean => segment.type !== "text"; @@ -190,7 +189,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos const linePrefix = text.slice(lineStart, cursor); if (linePrefix.startsWith("/")) { - const commandMatch = /^\/(\S*)$/.exec(linePrefix); + const commandMatch = /^\/([\w.-]*)$/.exec(linePrefix); if (commandMatch) { const commandQuery = commandMatch[1] ?? ""; if (commandQuery.toLowerCase() === "model") { @@ -201,15 +200,12 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } - if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) { - return { - kind: "slash-command", - query: commandQuery, - rangeStart: lineStart, - rangeEnd: cursor, - }; - } - return null; + return { + kind: "slash-command", + query: commandQuery, + rangeStart: lineStart, + rangeEnd: cursor, + }; } const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); @@ -223,6 +219,18 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos } } + if (linePrefix.startsWith("$")) { + const skillMatch = /^\$([\w.-]*)$/.exec(linePrefix); + if (skillMatch) { + return { + kind: "cli-skill", + query: skillMatch[1] ?? "", + rangeStart: lineStart, + rangeEnd: cursor, + }; + } + } + const tokenStart = tokenStartForCursor(text, cursor); const token = text.slice(tokenStart, cursor); if (!token.startsWith("@")) { diff --git a/apps/web/src/lib/providerCommandsReactQuery.ts b/apps/web/src/lib/providerCommandsReactQuery.ts new file mode 100644 index 000000000000..999577039878 --- /dev/null +++ b/apps/web/src/lib/providerCommandsReactQuery.ts @@ -0,0 +1,39 @@ +import type { ProviderCommandsListResult, ProviderKind } from "@t3tools/contracts"; +import { queryOptions } from "@tanstack/react-query"; +import { ensureNativeApi } from "~/nativeApi"; + +export const providerCommandsQueryKeys = { + all: ["provider-commands"] as const, + list: (provider: ProviderKind, cwd: string | null) => + ["provider-commands", "list", provider, cwd] as const, +}; + +const EMPTY_PROVIDER_COMMANDS_RESULT: ProviderCommandsListResult = { + provider: "codex", + commands: [], + skills: [], +}; + +const DEFAULT_STALE_TIME = 30_000; + +export function providerCommandsQueryOptions(input: { + provider: ProviderKind; + cwd: string | null; + enabled?: boolean; + staleTime?: number; +}) { + return queryOptions({ + queryKey: providerCommandsQueryKeys.list(input.provider, input.cwd), + queryFn: async () => { + const api = ensureNativeApi(); + return api.providers.listCommands({ + provider: input.provider, + ...(input.cwd ? { cwd: input.cwd } : {}), + }); + }, + enabled: input.enabled ?? true, + staleTime: input.staleTime ?? DEFAULT_STALE_TIME, + placeholderData: (previous) => + previous ?? { ...EMPTY_PROVIDER_COMMANDS_RESULT, provider: input.provider }, + }); +} diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index ae56f859918a..4fc693356fdc 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -51,6 +51,9 @@ const rpcClientMock = { searchEntries: vi.fn(), writeFile: vi.fn(), }, + providers: { + listCommands: vi.fn(), + }, shell: { openInEditor: vi.fn(), }, diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 3cfb976e0957..d5d96b2fa02a 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -53,6 +53,9 @@ export function createWsNativeApi(): NativeApi { searchEntries: rpcClient.projects.searchEntries, writeFile: rpcClient.projects.writeFile, }, + providers: { + listCommands: rpcClient.providers.listCommands, + }, shell: { openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), openExternal: async (url) => { diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index 997b83d2d76c..98e58f39cf0b 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -59,6 +59,9 @@ export interface WsRpcClient { readonly searchEntries: RpcUnaryMethod; readonly writeFile: RpcUnaryMethod; }; + readonly providers: { + readonly listCommands: RpcUnaryMethod; + }; readonly shell: { readonly openInEditor: (input: { readonly cwd: Parameters[0]; @@ -151,6 +154,10 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { writeFile: (input) => transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), }, + providers: { + listCommands: (input) => + transport.request((client) => client[WS_METHODS.providersListCommands](input)), + }, shell: { openInEditor: (input) => transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c60856bbe574..779c01adca6e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -11,4 +11,5 @@ export * from "./git"; export * from "./orchestration"; export * from "./editor"; export * from "./project"; +export * from "./providerCommands"; export * from "./rpc"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 630ccd824979..e43955cfe51b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -24,6 +24,7 @@ import type { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; +import type { ProviderCommandsListInput, ProviderCommandsListResult } from "./providerCommands"; import type { ServerConfig, ServerProviderUpdatedPayload, @@ -141,6 +142,9 @@ export interface NativeApi { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; }; + providers: { + listCommands: (input: ProviderCommandsListInput) => Promise; + }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; openExternal: (url: string) => Promise; diff --git a/packages/contracts/src/providerCommands.ts b/packages/contracts/src/providerCommands.ts new file mode 100644 index 000000000000..4f8467ed9d2f --- /dev/null +++ b/packages/contracts/src/providerCommands.ts @@ -0,0 +1,42 @@ +/** + * providerCommands - Schemas for discovering provider CLI slash commands and skills. + * + * The web composer uses these to populate the `/` and `$` autocomplete menus + * with commands/skills shipped by the underlying CLI (Codex/Claude) plus any + * project- or user-scoped overrides. + */ +import { Schema } from "effect"; + +import { TrimmedNonEmptyString } from "./baseSchemas"; +import { ProviderKind } from "./orchestration"; + +export const ProviderCommandSource = Schema.Literals(["builtin", "user", "project"]); +export type ProviderCommandSource = typeof ProviderCommandSource.Type; + +export const ProviderCommandEntry = Schema.Struct({ + name: TrimmedNonEmptyString, + description: Schema.String, + source: ProviderCommandSource, +}); +export type ProviderCommandEntry = typeof ProviderCommandEntry.Type; + +export const ProviderCommandsListInput = Schema.Struct({ + provider: ProviderKind, + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type ProviderCommandsListInput = typeof ProviderCommandsListInput.Type; + +export const ProviderCommandsListResult = Schema.Struct({ + provider: ProviderKind, + commands: Schema.Array(ProviderCommandEntry), + skills: Schema.Array(ProviderCommandEntry), +}); +export type ProviderCommandsListResult = typeof ProviderCommandsListResult.Type; + +export class ProviderCommandsListError extends Schema.TaggedErrorClass()( + "ProviderCommandsListError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index a3d10299dfbe..f38f891aab6e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -52,6 +52,11 @@ import { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; +import { + ProviderCommandsListError, + ProviderCommandsListInput, + ProviderCommandsListResult, +} from "./providerCommands"; import { TerminalClearInput, TerminalCloseInput, @@ -80,6 +85,7 @@ export const WS_METHODS = { projectsRemove: "projects.remove", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + providersListCommands: "providers.listCommands", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -161,6 +167,12 @@ export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { error: ProjectWriteFileError, }); +export const WsProvidersListCommandsRpc = Rpc.make(WS_METHODS.providersListCommands, { + payload: ProviderCommandsListInput, + success: ProviderCommandsListResult, + error: ProviderCommandsListError, +}); + export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: OpenInEditorInput, error: OpenError, @@ -342,6 +354,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerUpdateSettingsRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, + WsProvidersListCommandsRpc, WsShellOpenInEditorRpc, WsSubscribeGitStatusRpc, WsGitPullRpc,