mirror of
https://github.com/cline/cline.git
synced 2026-09-21 05:10:09 +08:00
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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add /skills slash command to CLI for viewing and managing installed skills
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user