From 0c691f72d2eb1c990f700b1cf2b55d26127c3c0a Mon Sep 17 00:00:00 2001 From: Robin Newhouse Date: Tue, 17 Feb 2026 17:13:51 -0800 Subject: [PATCH] feat(cli): add /skills slash command (#9089) * feat(cli): add /skills slash command for managing skills - Add /skills to CLI_ONLY_COMMANDS in slashCommands.ts - Create SkillsPanelContent component with: - Display global and workspace skills with toggle indicators - Enter to use skill (inserts @path into input) - Space to toggle skill enabled/disabled - Selectable marketplace link to skills.sh - Keyboard navigation with arrow keys and vim keys - Wire up panel in ChatView.tsx - Add comprehensive tests for keyboard interactions * refactor(cli): use static skill controller imports * fix(cli): add React import to skills panel test * fix(cli): suppress required React import lint in skills test * fix(cli): harden /skills panel interactions Revert optimistic skill toggle state when persistence fails, and surface a fallback URL when opening the marketplace fails. Also tighten and extend tests to verify exact marketplace URL handling and rollback behavior. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .changeset/cli-skills-command.md | 5 + cli/src/components/ChatView.tsx | 23 ++ .../components/SkillsPanelContent.test.tsx | 230 ++++++++++++++++ cli/src/components/SkillsPanelContent.tsx | 257 ++++++++++++++++++ src/shared/slashCommands.ts | 6 + 5 files changed, 521 insertions(+) create mode 100644 .changeset/cli-skills-command.md create mode 100644 cli/src/components/SkillsPanelContent.test.tsx create mode 100644 cli/src/components/SkillsPanelContent.tsx diff --git a/.changeset/cli-skills-command.md b/.changeset/cli-skills-command.md new file mode 100644 index 0000000000..0922a27cbc --- /dev/null +++ b/.changeset/cli-skills-command.md @@ -0,0 +1,5 @@ +--- +"claude-dev": patch +--- + +Add /skills slash command to CLI for viewing and managing installed skills diff --git a/cli/src/components/ChatView.tsx b/cli/src/components/ChatView.tsx index 064cfc5dc6..30e3facd5c 100644 --- a/cli/src/components/ChatView.tsx +++ b/cli/src/components/ChatView.tsx @@ -150,6 +150,7 @@ import { HighlightedInput } from "./HighlightedInput" import { HistoryPanelContent } from "./HistoryPanelContent" import { providerModels } from "./ModelPicker" import { SettingsPanelContent } from "./SettingsPanelContent" +import { SkillsPanelContent } from "./SkillsPanelContent" import { SlashCommandMenu } from "./SlashCommandMenu" import { ThinkingIndicator } from "./ThinkingIndicator" @@ -412,6 +413,7 @@ export const ChatView: React.FC = ({ | { type: "settings"; initialMode?: "model-picker" | "featured-models"; initialModelKey?: "actModelId" | "planModelId" } | { type: "history" } | { type: "help" } + | { type: "skills" } | null >(null) @@ -1156,6 +1158,14 @@ export const ChatView: React.FC = ({ setSlashMenuDismissed(true) return } + if (cmd.name === "skills") { + setActivePanel({ type: "skills" }) + setTextInput("") + setCursorPos(0) + setSelectedSlashIndex(0) + setSlashMenuDismissed(true) + return + } if (cmd.name === "clear") { clearViewAndResetTask() setSelectedSlashIndex(0) @@ -1545,6 +1555,19 @@ export const ChatView: React.FC = ({ {/* Help panel */} {activePanel?.type === "help" && setActivePanel(null)} />} + {/* Skills panel */} + {activePanel?.type === "skills" && ctrl && ( + setActivePanel(null)} + onUseSkill={(skillPath) => { + setActivePanel(null) + setTextInput(`@${skillPath} `) + setCursorPos(skillPath.length + 2) + }} + /> + )} + {/* Slash command menu - below input (takes priority over file menu) */} {showSlashMenu && !activePanel && ( diff --git a/cli/src/components/SkillsPanelContent.test.tsx b/cli/src/components/SkillsPanelContent.test.tsx new file mode 100644 index 0000000000..91de712fc5 --- /dev/null +++ b/cli/src/components/SkillsPanelContent.test.tsx @@ -0,0 +1,230 @@ +/** + * Tests for SkillsPanelContent component + * + * Tests keyboard interactions and callbacks. + * Rendering tests are limited due to ink-testing-library constraints with nested components. + */ + +import { render } from "ink-testing-library" +// biome-ignore lint/correctness/noUnusedImports: React must be in scope for JSX in this test file. +import React from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +// Mock refreshSkills +const mockRefreshSkills = vi.fn() +vi.mock("@/core/controller/file/refreshSkills", () => ({ + refreshSkills: () => mockRefreshSkills(), +})) + +// Mock toggleSkill +const mockToggleSkill = vi.fn() +vi.mock("@/core/controller/file/toggleSkill", () => ({ + toggleSkill: (...args: unknown[]) => mockToggleSkill(...args), +})) + +// Mock child_process exec +const mockExec = vi.fn() +vi.mock("node:child_process", () => ({ + exec: (...args: unknown[]) => mockExec(...args), +})) + +// Mock StdinContext +vi.mock("../context/StdinContext", () => ({ + useStdinContext: () => ({ isRawModeSupported: true }), +})) + +import { SkillsPanelContent } from "./SkillsPanelContent" + +// Helper to wait for async state updates +const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms)) + +describe("SkillsPanelContent", () => { + const mockController = {} as any + const mockOnClose = vi.fn() + const mockOnUseSkill = vi.fn() + + const defaultProps = { + controller: mockController, + onClose: mockOnClose, + onUseSkill: mockOnUseSkill, + } + + beforeEach(() => { + vi.clearAllMocks() + mockRefreshSkills.mockResolvedValue({ + globalSkills: [], + localSkills: [], + }) + }) + + describe("keyboard interactions", () => { + it("should call onClose when Escape is pressed", async () => { + mockRefreshSkills.mockResolvedValue({ + globalSkills: [], + localSkills: [], + }) + + const { stdin } = render() + await delay() + + stdin.write("\x1B") // Escape + await delay() + + expect(mockOnClose).toHaveBeenCalled() + }) + + it("should call onUseSkill with skill path when Enter is pressed on a skill", async () => { + mockRefreshSkills.mockResolvedValue({ + globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }], + localSkills: [], + }) + + const { stdin } = render() + await delay() + + stdin.write("\r") // Enter + await delay() + + expect(mockOnUseSkill).toHaveBeenCalledWith("/test/path/SKILL.md") + }) + + it("should call toggleSkill when Space is pressed on a skill", async () => { + mockRefreshSkills.mockResolvedValue({ + globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }], + localSkills: [], + }) + + const { stdin } = render() + await delay() + + stdin.write(" ") // Space + await delay() + + expect(mockToggleSkill).toHaveBeenCalledWith( + mockController, + expect.objectContaining({ + skillPath: "/test/path/SKILL.md", + isGlobal: true, + enabled: false, // toggled from true to false + }), + ) + }) + + it("should open marketplace URL when Enter is pressed on marketplace item", async () => { + mockRefreshSkills.mockResolvedValue({ + globalSkills: [{ name: "skill", description: "desc", path: "/path", enabled: true }], + localSkills: [], + }) + + const { stdin } = render() + await delay() + + // Navigate down to marketplace (past the one skill) + stdin.write("\x1B[B") // Down arrow + await delay() + + stdin.write("\r") // Enter + await delay() + + // Should have called exec with open command + expect(mockExec).toHaveBeenCalled() + const execCall = mockExec.mock.calls[0][0] + expect(execCall).toContain("https://skills.sh/") + }) + + it("should navigate through skills with arrow keys", async () => { + mockRefreshSkills.mockResolvedValue({ + globalSkills: [ + { name: "skill-1", description: "First", path: "/path1", enabled: true }, + { name: "skill-2", description: "Second", path: "/path2", enabled: true }, + ], + localSkills: [], + }) + + const { stdin } = render() + await delay() + + // Navigate down + stdin.write("\x1B[B") // Down arrow + await delay() + + // Press Enter - should use second skill + stdin.write("\r") + await delay() + + expect(mockOnUseSkill).toHaveBeenCalledWith("/path2") + }) + + it("should navigate with vim keys (j/k)", async () => { + mockRefreshSkills.mockResolvedValue({ + globalSkills: [ + { name: "skill-1", description: "First", path: "/path1", enabled: true }, + { name: "skill-2", description: "Second", path: "/path2", enabled: true }, + ], + localSkills: [], + }) + + const { stdin } = render() + await delay() + + // Navigate down with j + stdin.write("j") + await delay() + + // Press Enter - should use second skill + stdin.write("\r") + await delay() + + expect(mockOnUseSkill).toHaveBeenCalledWith("/path2") + }) + + it("should revert optimistic toggle on failure", async () => { + mockRefreshSkills.mockResolvedValue({ + globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }], + localSkills: [], + }) + mockToggleSkill.mockRejectedValueOnce(new Error("toggle failed")) + + const { stdin, lastFrame } = render() + await delay() + + stdin.write(" ") // Space to toggle + await delay(100) + + // toggleSkill was called with enabled: false (toggled from true) + expect(mockToggleSkill).toHaveBeenCalledWith(mockController, expect.objectContaining({ enabled: false })) + const frame = lastFrame() || "" + expect(frame).toContain("● test-skill") + expect(frame).not.toContain("○ test-skill") + }) + + it("should wrap navigation at list boundaries", async () => { + mockRefreshSkills.mockResolvedValue({ + globalSkills: [{ name: "only-skill", description: "Only", path: "/only", enabled: true }], + localSkills: [], + }) + + const { stdin } = render() + await delay() + + // Navigate up from first item (should wrap to last - marketplace) + stdin.write("\x1B[A") // Up arrow + await delay() + + stdin.write("\r") // Enter + await delay() + + // Should have opened marketplace (wrapped to last item) + expect(mockExec).toHaveBeenCalled() + }) + }) + + describe("skill loading", () => { + it("should call refreshSkills on mount", async () => { + render() + await delay() + + expect(mockRefreshSkills).toHaveBeenCalled() + }) + }) +}) diff --git a/cli/src/components/SkillsPanelContent.tsx b/cli/src/components/SkillsPanelContent.tsx new file mode 100644 index 0000000000..94162fb38d --- /dev/null +++ b/cli/src/components/SkillsPanelContent.tsx @@ -0,0 +1,257 @@ +/** + * Skills panel content for inline display in ChatView + * Shows installed skills with toggle and use functionality + */ + +import { exec } from "node:child_process" +import os from "node:os" +import { Box, Text, useInput } from "ink" +import React, { useCallback, useEffect, useMemo, useState } from "react" +import type { Controller } from "@/core/controller" +import { refreshSkills } from "@/core/controller/file/refreshSkills" +import { toggleSkill } from "@/core/controller/file/toggleSkill" +import { COLORS } from "../constants/colors" +import { useStdinContext } from "../context/StdinContext" +import { isMouseEscapeSequence } from "../utils/input" +import { Panel } from "./Panel" + +const SKILLS_MARKETPLACE_URL = "https://skills.sh/" + +interface SkillInfo { + name: string + description: string + path: string + enabled: boolean +} + +interface SkillsPanelContentProps { + controller: Controller + onClose: () => void + onUseSkill: (skillPath: string) => void +} + +const MAX_VISIBLE = 8 + +export const SkillsPanelContent: React.FC = ({ controller, onClose, onUseSkill }) => { + const { isRawModeSupported } = useStdinContext() + const [globalSkills, setGlobalSkills] = useState([]) + const [localSkills, setLocalSkills] = useState([]) + const [selectedIndex, setSelectedIndex] = useState(0) + const [isLoading, setIsLoading] = useState(true) + + // Load skills on mount + useEffect(() => { + const loadSkills = async () => { + try { + const skillsData = await refreshSkills(controller) + setGlobalSkills(skillsData.globalSkills || []) + setLocalSkills(skillsData.localSkills || []) + } catch (_error) { + // Skills loading failed, show empty state + } finally { + setIsLoading(false) + } + } + loadSkills() + }, [controller]) + + // Build flat list of skills with source info (global first, then local, alphabetical within each) + const skillEntries = useMemo(() => { + const entries: { skill: SkillInfo; isGlobal: boolean }[] = [] + globalSkills.forEach((skill) => entries.push({ skill, isGlobal: true })) + localSkills.forEach((skill) => entries.push({ skill, isGlobal: false })) + return entries.sort((a, b) => { + if (a.isGlobal !== b.isGlobal) return a.isGlobal ? -1 : 1 + return a.skill.name.localeCompare(b.skill.name) + }) + }, [globalSkills, localSkills]) + + // Handle toggle + const handleToggle = useCallback(async () => { + const entry = skillEntries[selectedIndex] + if (!entry) return + + const newEnabled = !entry.skill.enabled + const setter = entry.isGlobal ? setGlobalSkills : setLocalSkills + const update = (enabled: boolean) => + setter((prev) => prev.map((s) => (s.path === entry.skill.path ? { ...s, enabled } : s))) + + // Optimistic update + update(newEnabled) + + try { + await toggleSkill(controller, { + metadata: undefined, + skillPath: entry.skill.path, + isGlobal: entry.isGlobal, + enabled: newEnabled, + }) + } catch { + // Revert on failure + update(!newEnabled) + } + }, [controller, skillEntries, selectedIndex]) + + // Handle use skill (insert @ mention) + const handleUse = useCallback(() => { + const entry = skillEntries[selectedIndex] + if (!entry) return + onUseSkill(entry.skill.path) + }, [skillEntries, selectedIndex, onUseSkill]) + + // Handle opening the marketplace URL + const openMarketplace = useCallback(() => { + const platform = os.platform() + let command: string + if (platform === "darwin") { + command = `open "${SKILLS_MARKETPLACE_URL}"` + } else if (platform === "win32") { + command = `start "${SKILLS_MARKETPLACE_URL}"` + } else { + command = `xdg-open "${SKILLS_MARKETPLACE_URL}"` + } + exec(command, (err) => { + if (err) { + // Fallback: show URL in terminal if browser open fails + console.error(`Visit: ${SKILLS_MARKETPLACE_URL}`) + } + }) + }, []) + + // Total items = skills + 1 for marketplace link + const totalItems = skillEntries.length + 1 + const isMarketplaceSelected = selectedIndex === skillEntries.length + + useInput( + (input, key) => { + if (isMouseEscapeSequence(input)) { + return + } + if (key.escape) { + onClose() + return + } + + // Navigation + if (key.upArrow || input === "k") { + setSelectedIndex((i) => (i > 0 ? i - 1 : totalItems - 1)) + return + } + if (key.downArrow || input === "j") { + setSelectedIndex((i) => (i < totalItems - 1 ? i + 1 : 0)) + return + } + + // Actions + if (key.return) { + if (isMarketplaceSelected) { + openMarketplace() + } else { + handleUse() + } + return + } + if (input === " " && !isMarketplaceSelected) { + handleToggle() + return + } + }, + { isActive: isRawModeSupported }, + ) + + // Scrolling window (includes marketplace row) + const halfVisible = Math.floor(MAX_VISIBLE / 2) + const startIndex = Math.max(0, Math.min(selectedIndex - halfVisible, totalItems - MAX_VISIBLE)) + + if (isLoading) { + return ( + + Loading skills... + + ) + } + + // Check if marketplace row is in visible window + const marketplaceIndex = skillEntries.length + const showMarketplace = marketplaceIndex >= startIndex && marketplaceIndex < startIndex + MAX_VISIBLE + + return ( + + + {skillEntries.length === 0 ? ( + + No skills installed. + + Install skills with: npx skills add owner/repo + + + ) : ( + + {skillEntries + .slice(startIndex, Math.min(startIndex + MAX_VISIBLE, skillEntries.length)) + .map((entry, idx) => { + const actualIndex = startIndex + idx + const prevEntry = skillEntries[actualIndex - 1] + const showHeader = actualIndex === 0 || (prevEntry && prevEntry.isGlobal !== entry.isGlobal) + + return ( + + {showHeader && ( + 0 ? 1 : 0}> + + {entry.isGlobal ? "Global Skills:" : "Workspace Skills:"} + + + )} + + + ) + })} + + )} + + {/* Marketplace link - selectable */} + {showMarketplace && ( + + + {isMarketplaceSelected ? "❯ " : " "} + Browse more skills at https://skills.sh/ + + + )} + + {/* Help text */} + + + ↑/↓ Navigate • Enter {isMarketplaceSelected ? "Open" : "Use"} + {!isMarketplaceSelected && " • Space Toggle"} + + + + + ) +} + +const SkillRow: React.FC<{ skill: SkillInfo; isSelected: boolean }> = ({ skill, isSelected }) => { + return ( + + + + {isSelected ? "❯ " : " "} + {skill.enabled ? "●" : "○"} + + + {skill.name} + + + + {skill.description && ( + + + {skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description} + + + )} + + ) +} diff --git a/src/shared/slashCommands.ts b/src/shared/slashCommands.ts index 8041460334..1ba8e0491a 100644 --- a/src/shared/slashCommands.ts +++ b/src/shared/slashCommands.ts @@ -85,4 +85,10 @@ export const CLI_ONLY_COMMANDS: SlashCommand[] = [ section: "default", cliCompatible: true, }, + { + name: "skills", + description: "View and manage installed skills", + section: "default", + cliCompatible: true, + }, ]