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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Robin Newhouse
2026-02-17 17:13:51 -08:00
committed by GitHub
co-authored by Cursor
parent a8409137b3
commit 0c691f72d2
5 changed files with 521 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add /skills slash command to CLI for viewing and managing installed skills
+23
View File
@@ -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<ChatViewProps> = ({
| { 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<ChatViewProps> = ({
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<ChatViewProps> = ({
{/* Help panel */}
{activePanel?.type === "help" && <HelpPanelContent onClose={() => setActivePanel(null)} />}
{/* Skills panel */}
{activePanel?.type === "skills" && ctrl && (
<SkillsPanelContent
controller={ctrl}
onClose={() => setActivePanel(null)}
onUseSkill={(skillPath) => {
setActivePanel(null)
setTextInput(`@${skillPath} `)
setCursorPos(skillPath.length + 2)
}}
/>
)}
{/* Slash command menu - below input (takes priority over file menu) */}
{showSlashMenu && !activePanel && (
<Box paddingLeft={1} paddingRight={1}>
@@ -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(<SkillsPanelContent {...defaultProps} />)
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(<SkillsPanelContent {...defaultProps} />)
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(<SkillsPanelContent {...defaultProps} />)
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(<SkillsPanelContent {...defaultProps} />)
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(<SkillsPanelContent {...defaultProps} />)
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(<SkillsPanelContent {...defaultProps} />)
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(<SkillsPanelContent {...defaultProps} />)
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(<SkillsPanelContent {...defaultProps} />)
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(<SkillsPanelContent {...defaultProps} />)
await delay()
expect(mockRefreshSkills).toHaveBeenCalled()
})
})
})
+257
View File
@@ -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<SkillsPanelContentProps> = ({ controller, onClose, onUseSkill }) => {
const { isRawModeSupported } = useStdinContext()
const [globalSkills, setGlobalSkills] = useState<SkillInfo[]>([])
const [localSkills, setLocalSkills] = useState<SkillInfo[]>([])
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 (
<Panel label="Skills">
<Text color="gray">Loading skills...</Text>
</Panel>
)
}
// Check if marketplace row is in visible window
const marketplaceIndex = skillEntries.length
const showMarketplace = marketplaceIndex >= startIndex && marketplaceIndex < startIndex + MAX_VISIBLE
return (
<Panel label="Skills">
<Box flexDirection="column" gap={1}>
{skillEntries.length === 0 ? (
<Box flexDirection="column" gap={1}>
<Text color="gray">No skills installed.</Text>
<Text>
Install skills with: <Text color="white">npx skills add owner/repo</Text>
</Text>
</Box>
) : (
<Box flexDirection="column">
{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 (
<React.Fragment key={entry.skill.path}>
{showHeader && (
<Box marginTop={actualIndex > 0 ? 1 : 0}>
<Text bold color="gray">
{entry.isGlobal ? "Global Skills:" : "Workspace Skills:"}
</Text>
</Box>
)}
<SkillRow isSelected={actualIndex === selectedIndex} skill={entry.skill} />
</React.Fragment>
)
})}
</Box>
)}
{/* Marketplace link - selectable */}
{showMarketplace && (
<Box marginTop={1}>
<Text color={isMarketplaceSelected ? "cyan" : undefined}>
{isMarketplaceSelected ? " " : " "}
<Text color={COLORS.primaryBlue}>Browse more skills at https://skills.sh/</Text>
</Text>
</Box>
)}
{/* Help text */}
<Box marginTop={1}>
<Text color="gray">
/ Navigate Enter {isMarketplaceSelected ? "Open" : "Use"}
{!isMarketplaceSelected && " • Space Toggle"}
</Text>
</Box>
</Box>
</Panel>
)
}
const SkillRow: React.FC<{ skill: SkillInfo; isSelected: boolean }> = ({ skill, isSelected }) => {
return (
<Box flexDirection="column">
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
<Text color={skill.enabled ? "green" : "red"}>{skill.enabled ? "●" : "○"}</Text>
<Text> </Text>
<Text bold color="white">
{skill.name}
</Text>
</Text>
</Box>
{skill.description && (
<Box marginLeft={4}>
<Text color="gray">
{skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description}
</Text>
</Box>
)}
</Box>
)
}
+6
View File
@@ -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,
},
]