Restore desktop project-skill discovery and align empty-session composer

The desktop app previously lost project-level skills in two places: the
settings browser only queried user skills, and the slash-command picker for a
fresh session depended on CLI init state that does not exist before the first
turn. This change makes the skills APIs cwd-aware, falls back to project skill
loading before CLI init, syncs sidebar session deletion with open tabs, and
aligns the empty-session composer styling so the pre-message session UI stays
consistent.

Constraint: Fresh desktop sessions need slash-command discovery before the CLI websocket emits system/init
Constraint: Only repository code should be committed; build artifacts, installs, and /tmp smoke fixtures stay out of git
Rejected: Rebuild slash-command listings from full getCommands() in the sessions API | introduced auth-gated command dependencies unrelated to local skill discovery
Rejected: Unify EmptySession and ChatInput into one full component now | higher regression risk for normal chat interactions than a visual-only hero variant
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep pre-message skill discovery keyed to session workDir so project-level .claude/skills remain visible before the first turn
Tested: bun test src/server/__tests__/sessions.test.ts; cd desktop && bun run test -- --run src/__tests__/skillsSettings.test.tsx src/components/layout/Sidebar.test.tsx src/__tests__/pages.test.tsx src/pages/ActiveSession.test.tsx; cd desktop && bun run lint
Not-tested: Manual desktop smoke test against the rebuilt .app bundle in the local GUI
This commit is contained in:
程序员阿江(Relakkes) 2026-04-20 13:36:19 +08:00
parent 76823047f4
commit 261a85ab76
14 changed files with 533 additions and 86 deletions

View File

@ -13,6 +13,7 @@ import { ToolInspection } from '../pages/ToolInspection'
import { Sidebar } from '../components/layout/Sidebar'
import { UserMessage } from '../components/chat/UserMessage'
import { useChatStore } from '../stores/chatStore'
import { useSessionStore } from '../stores/sessionStore'
import { useTabStore } from '../stores/tabStore'
/**
@ -62,13 +63,71 @@ describe('Content-only pages render without errors', () => {
// With empty messages, the hero is shown
expect(container.innerHTML).toContain('New session')
// ChatInput has a textarea
expect(container.querySelector('textarea')).toBeInTheDocument()
const textarea = container.querySelector('textarea')
expect(textarea).toBeInTheDocument()
expect(textarea).toHaveAttribute('placeholder', 'Ask anything...')
expect(textarea).toHaveAttribute('rows', '2')
expect(container.innerHTML).not.toContain('Preview')
// Cleanup
useTabStore.setState({ tabs: [], activeTabId: null })
useChatStore.setState({ sessions: {} })
})
it('ActiveSession keeps the compact composer once messages exist', () => {
const SESSION_ID = 'test-active-session-with-messages'
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
useChatStore.setState({
sessions: {
[SESSION_ID]: {
messages: [{
id: 'msg-1',
type: 'user_text',
content: 'hello',
timestamp: Date.now(),
}],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useSessionStore.setState({
sessions: [{
id: SESSION_ID,
title: 'Test',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 1,
projectPath: '',
workDir: null,
workDirExists: true,
}],
activeSessionId: SESSION_ID,
isLoading: false,
error: null,
})
render(<ActiveSession />)
const textarea = screen.getByPlaceholderText('Ask Claude to edit, debug or explain...')
expect(textarea).toHaveAttribute('rows', '1')
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useChatStore.setState({ sessions: {} })
})
it('ActiveSession shows a single primary action button while a turn is active', () => {
useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', type: 'session' as const, status: 'idle' }] })
useChatStore.setState({

View File

@ -5,6 +5,8 @@ import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
import { useSkillStore } from '../stores/skillStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useSessionStore } from '../stores/sessionStore'
import { useTabStore, SETTINGS_TAB_ID } from '../stores/tabStore'
vi.mock('../api/agents', () => ({
agentsApi: {
@ -60,6 +62,26 @@ describe('Settings > Skills tab', () => {
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
useSessionStore.setState({
sessions: [
{
id: 'session-1',
title: 'Active session',
createdAt: '2026-04-20T00:00:00.000Z',
modifiedAt: '2026-04-20T00:00:00.000Z',
messageCount: 1,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
},
],
activeSessionId: 'session-1',
isLoading: false,
error: null,
selectedProjects: [],
availableProjects: ['/workspace/project'],
})
useTabStore.setState({ tabs: [], activeTabId: null })
useSkillStore.setState({
skills: [],
selectedSkill: null,
@ -106,6 +128,29 @@ describe('Settings > Skills tab', () => {
expect(screen.getByText('Second skill description')).toBeInTheDocument()
})
it('uses the active session workDir even when settings tab is focused', () => {
const fetchSkills = vi.fn()
useSkillStore.setState({
skills: [],
selectedSkill: null,
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills,
fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL,
clearSelection: MOCK_CLEAR_SELECTION,
})
useTabStore.setState({
activeTabId: SETTINGS_TAB_ID,
tabs: [{ sessionId: SETTINGS_TAB_ID, title: 'Settings', type: 'settings', status: 'idle' }],
})
render(<Settings />)
switchToSkillsTab()
expect(fetchSkills).toHaveBeenCalledWith('/workspace/project')
})
it('opens skill detail with metadata cards and parsed markdown body', () => {
useSkillStore.setState({
selectedSkill: {

View File

@ -2,10 +2,18 @@ import { api } from './client'
import type { SkillMeta, SkillDetail } from '../types/skill'
export const skillsApi = {
list: () => api.get<{ skills: SkillMeta[] }>('/api/skills'),
list: (cwd?: string) => {
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
return api.get<{ skills: SkillMeta[] }>(`/api/skills${query}`)
},
detail: (source: string, name: string) =>
api.get<{ detail: SkillDetail }>(
`/api/skills/detail?source=${encodeURIComponent(source)}&name=${encodeURIComponent(name)}`,
),
detail: (source: string, name: string, cwd?: string) => {
const query = new URLSearchParams({
source,
name,
})
if (cwd) query.set('cwd', cwd)
return api.get<{ detail: SkillDetail }>(`/api/skills/detail?${query.toString()}`)
},
}

View File

@ -30,7 +30,11 @@ type Attachment = {
data?: string
}
export function ChatInput() {
type ChatInputProps = {
variant?: 'default' | 'hero'
}
export function ChatInput({ variant = 'default' }: ChatInputProps) {
const t = useTranslation()
const [input, setInput] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
@ -62,6 +66,7 @@ export function ChatInput() {
const isActive = chatState !== 'idle'
const isWorkspaceMissing = activeSession?.workDirExists === false
const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0))
const isHeroComposer = variant === 'hero' && !isMemberSession
useEffect(() => {
textareaRef.current?.focus()
@ -393,11 +398,25 @@ export function ChatInput() {
})
}
const composerPlaceholder =
isHeroComposer
? t('empty.placeholder')
: isWorkspaceMissing
? t('chat.placeholderMissing')
: isMemberSession
? t('teams.memberPlaceholder')
: t('chat.placeholder')
const addFilesLabel = isHeroComposer ? t('empty.addFiles') : t('chat.addFiles')
const slashCommandsLabel = isHeroComposer ? t('empty.slashCommands') : t('chat.slashCommands')
return (
<div className="bg-[var(--color-surface)] px-4 py-4">
<div className="mx-auto max-w-[860px]">
<div className={isHeroComposer ? 'bg-[var(--color-surface)] px-8 pb-4' : 'bg-[var(--color-surface)] px-4 py-4'}>
<div className={isHeroComposer ? 'mx-auto flex w-full max-w-3xl flex-col gap-2' : 'mx-auto max-w-[860px]'}>
<div
className="glass-panel relative rounded-xl p-4 transition-colors"
className={isHeroComposer
? 'glass-panel relative flex flex-col gap-3 rounded-xl p-4 transition-colors'
: 'glass-panel relative rounded-xl p-4 transition-colors'}
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
>
@ -436,18 +455,16 @@ export function ChatInput() {
ref={(el) => { slashItemRefs.current[index] = el }}
onClick={() => selectSlashCommand(command.name)}
onMouseEnter={() => setSlashSelectedIndex(index)}
className={`flex w-full items-center justify-between gap-3 px-4 py-2.5 text-left transition-colors ${
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
index === slashSelectedIndex
? 'bg-[var(--color-surface-hover)]'
: 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">
/{command.name}
</span>
</div>
<span className="truncate text-right text-xs text-[var(--color-text-tertiary)]">
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">
/{command.name}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">
{command.description}
</span>
</button>
@ -465,32 +482,50 @@ export function ChatInput() {
)}
{attachments.length > 0 && (
<div className="px-3 pt-3">
isHeroComposer ? (
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
</div>
) : (
<div className="px-3 pt-3">
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
</div>
)
)}
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onPaste={handlePaste}
placeholder={
isWorkspaceMissing
? t('chat.placeholderMissing')
: isMemberSession
? t('teams.memberPlaceholder')
: t('chat.placeholder')
}
disabled={isWorkspaceMissing}
rows={1}
className="w-full resize-none bg-transparent py-2 pb-12 text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50"
/>
{isHeroComposer ? (
<div className="flex items-start gap-3">
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onPaste={handlePaste}
placeholder={composerPlaceholder}
disabled={isWorkspaceMissing}
rows={2}
className="flex-1 resize-none border-none bg-transparent py-2 leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50"
/>
</div>
) : (
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onPaste={handlePaste}
placeholder={composerPlaceholder}
disabled={isWorkspaceMissing}
rows={1}
className="w-full resize-none bg-transparent py-2 pb-12 text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50"
/>
)}
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[var(--color-border-separator)] px-3 py-3">
<div className={isHeroComposer
? 'flex items-center justify-between border-t border-[var(--color-border-separator)] pt-3'
: 'absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[var(--color-border-separator)] px-3 py-3'}>
<div className="flex items-center gap-2">
{!isMemberSession && (
<>
@ -513,14 +548,14 @@ export function ChatInput() {
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
<span className="text-sm text-[var(--color-text-primary)]">{t('chat.addFiles')}</span>
<span className="text-sm text-[var(--color-text-primary)]">{addFilesLabel}</span>
</button>
<button
onClick={insertSlashCommand}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="w-[24px] text-center text-[18px] font-bold text-[var(--color-text-secondary)]">/</span>
<span className="text-sm text-[var(--color-text-primary)]">{t('chat.slashCommands')}</span>
<span className="text-sm text-[var(--color-text-primary)]">{slashCommandsLabel}</span>
</button>
</div>
)}

View File

@ -39,14 +39,18 @@ import { useUIStore } from '../../stores/uiStore'
describe('Sidebar', () => {
const connectToSession = vi.fn()
const disconnectSession = vi.fn()
const fetchSessions = vi.fn()
const createSession = vi.fn()
const deleteSession = vi.fn()
const addToast = vi.fn()
beforeEach(() => {
connectToSession.mockReset()
disconnectSession.mockReset()
fetchSessions.mockReset()
createSession.mockReset()
deleteSession.mockReset()
addToast.mockReset()
useTabStore.setState({ tabs: [], activeTabId: null })
@ -59,9 +63,11 @@ describe('Sidebar', () => {
availableProjects: [],
fetchSessions,
createSession,
deleteSession,
})
useChatStore.setState({
connectToSession,
disconnectSession,
} as Partial<ReturnType<typeof useChatStore.getState>>)
useUIStore.setState({
addToast,
@ -111,4 +117,42 @@ describe('Sidebar', () => {
expect(useTabStore.getState().tabs).toEqual([])
})
it('removes the matching tab when deleting a session from the sidebar', async () => {
deleteSession.mockResolvedValue(undefined)
useSessionStore.setState({
sessions: [
{
id: 'session-1',
title: 'Open Session',
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
messageCount: 1,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
},
],
})
useTabStore.setState({
tabs: [{ sessionId: 'session-1', title: 'Open Session', type: 'session', status: 'idle' }],
activeTabId: 'session-1',
})
render(<Sidebar />)
fireEvent.contextMenu(screen.getByRole('button', { name: /Open Session/ }))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
})
await waitFor(() => {
expect(deleteSession).toHaveBeenCalledWith('session-1')
expect(disconnectSession).toHaveBeenCalledWith('session-1')
})
expect(useTabStore.getState().tabs).toEqual([])
expect(useTabStore.getState().activeTabId).toBeNull()
})
})

View File

@ -23,6 +23,8 @@ export function Sidebar() {
const renameSession = useSessionStore((s) => s.renameSession)
const addToast = useUIStore((s) => s.addToast)
const activeTabId = useTabStore((s) => s.activeTabId)
const closeTab = useTabStore((s) => s.closeTab)
const disconnectSession = useChatStore((s) => s.disconnectSession)
const [searchQuery, setSearchQuery] = useState('')
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
const [renamingId, setRenamingId] = useState<string | null>(null)
@ -64,7 +66,9 @@ export function Sidebar() {
const handleDelete = useCallback(async (id: string) => {
setContextMenu(null)
await deleteSession(id)
}, [deleteSession])
disconnectSession(id)
closeTab(id)
}, [closeTab, deleteSession, disconnectSession])
const handleStartRename = useCallback((id: string, currentTitle: string) => {
setContextMenu(null)

View File

@ -1,5 +1,6 @@
import { useEffect, useMemo } from 'react'
import { useSkillStore } from '../../stores/skillStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTranslation } from '../../i18n'
import type { SkillMeta, SkillSource } from '../../types/skill'
@ -28,11 +29,15 @@ function estimateTokens(contentLength: number) {
export function SkillList() {
const { skills, isLoading, error, fetchSkills, fetchSkillDetail } =
useSkillStore()
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const t = useTranslation()
const activeSession = sessions.find((session) => session.id === activeSessionId)
const currentWorkDir = activeSession?.workDir || undefined
useEffect(() => {
fetchSkills()
}, [fetchSkills])
fetchSkills(currentWorkDir)
}, [fetchSkills, currentWorkDir])
const grouped = useMemo(() => {
const result: Partial<Record<SkillSource, SkillMeta[]>> = {}
@ -175,7 +180,7 @@ export function SkillList() {
key={`${skill.source}-${skill.name}`}
onClick={() =>
skill.hasDirectory &&
fetchSkillDetail(skill.source, skill.name)
fetchSkillDetail(skill.source, skill.name, currentWorkDir)
}
disabled={!skill.hasDirectory}
className="group rounded-xl border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] disabled:opacity-60 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:border-transparent"

View File

@ -204,7 +204,7 @@ export function ActiveSession() {
<TeamStatusBar />
<ChatInput />
<ChatInput variant={isEmpty && !isMemberSession ? 'hero' : 'default'} />
</div>
)
}

View File

@ -390,12 +390,12 @@ export function EmptySession() {
ref={(el) => { slashItemRefs.current[index] = el }}
onClick={() => selectSlashCommand(command.name)}
onMouseEnter={() => setSlashSelectedIndex(index)}
className={`flex w-full items-center justify-between gap-3 px-4 py-2.5 text-left transition-colors ${
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
index === slashSelectedIndex ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</span>
<span className="truncate text-xs text-[var(--color-text-tertiary)]">{command.description}</span>
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">{command.description}</span>
</button>
))}
</div>

View File

@ -9,8 +9,8 @@ type SkillStore = {
isDetailLoading: boolean
error: string | null
fetchSkills: () => Promise<void>
fetchSkillDetail: (source: string, name: string) => Promise<void>
fetchSkills: (cwd?: string) => Promise<void>
fetchSkillDetail: (source: string, name: string, cwd?: string) => Promise<void>
clearSelection: () => void
}
@ -21,10 +21,10 @@ export const useSkillStore = create<SkillStore>((set) => ({
isDetailLoading: false,
error: null,
fetchSkills: async () => {
fetchSkills: async (cwd) => {
set({ isLoading: true, error: null })
try {
const { skills } = await skillsApi.list()
const { skills } = await skillsApi.list(cwd)
set({ skills, isLoading: false })
} catch (err) {
set({
@ -34,10 +34,10 @@ export const useSkillStore = create<SkillStore>((set) => ({
}
},
fetchSkillDetail: async (source, name) => {
fetchSkillDetail: async (source, name, cwd) => {
set({ isDetailLoading: true, error: null })
try {
const { detail } = await skillsApi.detail(source, name)
const { detail } = await skillsApi.detail(source, name, cwd)
set({ selectedSkill: detail, isDetailLoading: false })
} catch (err) {
set({

View File

@ -7,6 +7,7 @@ import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
import { SessionService } from '../services/sessionService.js'
import { clearCommandsCache } from '../../commands.js'
import { sanitizePath } from '../../utils/sessionStoragePortable.js'
// ============================================================================
@ -45,6 +46,20 @@ async function writeSessionFile(
return filePath
}
async function writeSkill(
rootDir: string,
skillName: string,
description: string,
): Promise<void> {
const skillDir = path.join(rootDir, skillName)
await fs.mkdir(skillDir, { recursive: true })
await fs.writeFile(
path.join(skillDir, 'SKILL.md'),
['---', `description: ${description}`, '---', '', `# ${skillName}`].join('\n'),
'utf-8',
)
}
// Sample entries matching real CLI format
function makeSnapshotEntry(): Record<string, unknown> {
return {
@ -122,6 +137,7 @@ describe('SessionService', () => {
})
afterEach(async () => {
clearCommandsCache()
await cleanupTmpDir()
})
@ -692,6 +708,37 @@ describe('Sessions API', () => {
expect(detail.title).toBe('New Custom Title')
})
it('GET /api/sessions/:id/slash-commands should include user and project skills before CLI init', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const workDir = path.join(tmpDir, 'workspace', 'app')
await fs.mkdir(path.join(workDir, '.claude', 'skills'), { recursive: true })
await fs.mkdir(path.join(tmpDir, 'skills'), { recursive: true })
await writeSkill(path.join(tmpDir, 'skills'), 'user-skill', 'User skill description')
await writeSkill(path.join(workDir, '.claude', 'skills'), 'project-skill', 'Project skill description')
await writeSessionFile('-tmp-api-test', sessionId, [
makeSnapshotEntry(),
makeSessionMetaEntry(workDir),
])
clearCommandsCache()
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/slash-commands`)
expect(res.status).toBe(200)
const body = (await res.json()) as {
commands: Array<{ name: string; description: string }>
}
expect(body.commands).toContainEqual(
expect.objectContaining({ name: 'user-skill', description: 'User skill description' }),
)
expect(body.commands).toContainEqual(
expect.objectContaining({ name: 'project-skill', description: 'Project skill description' }),
)
})
// --------------------------------------------------------------------------
// Conversations API via /api/sessions/:id/chat
// --------------------------------------------------------------------------

View File

@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { getCwdState, setCwdState } from '../../bootstrap/state.js'
import { handleSkillsApi } from '../api/skills.js'
let tmpHome: string
let originalHome: string | undefined
let originalUserProfile: string | undefined
let originalClaudeConfigDir: string | undefined
let originalCwdState: string
function makeRequest(urlStr: string): { req: Request; url: URL; segments: string[] } {
const url = new URL(urlStr, 'http://localhost:3456')
const req = new Request(url.toString(), { method: 'GET' })
return {
req,
url,
segments: url.pathname.split('/').filter(Boolean),
}
}
async function writeSkill(root: string, skillName: string, content: string): Promise<void> {
const skillDir = path.join(root, skillName)
await fs.mkdir(skillDir, { recursive: true })
await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8')
}
describe('Skills API', () => {
beforeEach(async () => {
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-skills-test-'))
originalHome = process.env.HOME
originalUserProfile = process.env.USERPROFILE
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
originalCwdState = getCwdState()
process.env.HOME = tmpHome
process.env.USERPROFILE = tmpHome
process.env.CLAUDE_CONFIG_DIR = path.join(tmpHome, '.claude')
setCwdState(tmpHome)
})
afterEach(async () => {
if (originalHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = originalHome
}
if (originalUserProfile === undefined) {
delete process.env.USERPROFILE
} else {
process.env.USERPROFILE = originalUserProfile
}
if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
}
setCwdState(originalCwdState)
await fs.rm(tmpHome, { recursive: true, force: true })
})
it('lists user and project skills for the requested cwd', async () => {
const userSkillsRoot = path.join(tmpHome, '.claude', 'skills')
const projectRoot = path.join(tmpHome, 'workspace')
const cwd = path.join(projectRoot, 'packages', 'app')
await writeSkill(
userSkillsRoot,
'user-skill',
['---', 'description: User scope', '---', '', '# User skill'].join('\n'),
)
await writeSkill(
path.join(projectRoot, '.claude', 'skills'),
'project-skill',
['---', 'description: Project scope', '---', '', '# Project skill'].join('\n'),
)
const { req, url, segments } = makeRequest(`/api/skills?cwd=${encodeURIComponent(cwd)}`)
const res = await handleSkillsApi(req, url, segments)
expect(res.status).toBe(200)
const body = await res.json() as { skills: Array<{ name: string; source: string }> }
expect(body.skills).toContainEqual(expect.objectContaining({ name: 'user-skill', source: 'user' }))
expect(body.skills).toContainEqual(expect.objectContaining({ name: 'project-skill', source: 'project' }))
})
it('resolves project skill details from the nearest project skills directory', async () => {
const projectRoot = path.join(tmpHome, 'workspace')
const nestedRoot = path.join(projectRoot, 'packages', 'app')
const nestedSkillsRoot = path.join(nestedRoot, '.claude', 'skills')
const parentSkillsRoot = path.join(projectRoot, '.claude', 'skills')
await writeSkill(
parentSkillsRoot,
'shared-skill',
['---', 'description: Parent version', '---', '', 'parent body'].join('\n'),
)
await writeSkill(
nestedSkillsRoot,
'shared-skill',
['---', 'description: Child version', '---', '', 'child body'].join('\n'),
)
const { req, url, segments } = makeRequest(
`/api/skills/detail?source=project&name=shared-skill&cwd=${encodeURIComponent(nestedRoot)}`,
)
const res = await handleSkillsApi(req, url, segments)
expect(res.status).toBe(200)
const body = await res.json() as {
detail: { meta: { description: string }; skillRoot: string; files: Array<{ path: string; body?: string }> }
}
expect(body.detail.meta.description).toBe('Child version')
expect(body.detail.skillRoot).toBe(path.join(nestedSkillsRoot, 'shared-skill'))
expect(body.detail.files).toContainEqual(
expect.objectContaining({ path: 'SKILL.md', body: 'child body' }),
)
})
})

View File

@ -15,6 +15,8 @@
import { sessionService } from '../services/sessionService.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { getSlashCommands } from '../ws/handler.js'
import { getCommandName } from '../../commands.js'
import { getSkillDirCommands } from '../../skills/loadSkillsDir.js'
export async function handleSessionsApi(
req: Request,
@ -78,7 +80,7 @@ export async function handleSessionsApi(
{ status: 405 }
)
}
return Response.json({ commands: getSlashCommands(sessionId) })
return await getSessionSlashCommands(sessionId)
}
// Route to conversations handler if sub-resource is 'chat'
@ -167,6 +169,28 @@ async function deleteSession(sessionId: string): Promise<Response> {
return Response.json({ ok: true })
}
async function getSessionSlashCommands(sessionId: string): Promise<Response> {
const cachedCommands = getSlashCommands(sessionId)
if (cachedCommands.length > 0) {
return Response.json({ commands: cachedCommands })
}
const workDir = await sessionService.getSessionWorkDir(sessionId)
if (!workDir) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const commands = await getSkillDirCommands(workDir)
const slashCommands = commands
.filter((command) => command.userInvocable !== false)
.map((command) => ({
name: getCommandName(command),
description: command.description || '',
}))
return Response.json({ commands: slashCommands })
}
async function getGitInfo(sessionId: string): Promise<Response> {
const workDir = await sessionService.getSessionWorkDir(sessionId)
if (!workDir) {

View File

@ -6,10 +6,12 @@
* ?source=user&name=xxx
*/
import * as os from 'os'
import * as path from 'path'
import * as fs from 'fs/promises'
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { getProjectDirsUpToHome } from '../../utils/markdownConfigLoader.js'
import { getCwd } from '../../utils/cwd.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
// ─── Types ───────────────────────────────────────────────────────────────────
@ -25,6 +27,8 @@ type SkillMeta = {
hasDirectory: boolean
}
type SkillSource = SkillMeta['source']
type FileTreeNode = {
name: string
path: string
@ -74,7 +78,15 @@ function normalizeFrontmatter(content: string, sourcePath?: string): {
}
function getUserSkillsDir(): string {
return path.join(os.homedir(), '.claude', 'skills')
return path.join(getClaudeConfigHomeDir(), 'skills')
}
function getRequestedCwd(url: URL): string {
return url.searchParams.get('cwd') || getCwd()
}
function getProjectSkillsDirs(cwd: string): string[] {
return getProjectDirsUpToHome('skills', cwd)
}
async function loadSkillMeta(
@ -191,6 +203,60 @@ async function buildFileTree(
return { tree, files }
}
async function collectSkillsFromRoots(
skillRoots: string[],
source: SkillSource,
): Promise<SkillMeta[]> {
const skills: SkillMeta[] = []
const seenNames = new Set<string>()
for (const root of skillRoots) {
let entries: import('fs').Dirent[]
try {
entries = await fs.readdir(root, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.') || seenNames.has(entry.name)) {
continue
}
const meta = await loadSkillMeta(path.join(root, entry.name), entry.name, source)
if (!meta) continue
seenNames.add(entry.name)
skills.push(meta)
}
}
return skills
}
async function resolveSkillDir(
source: SkillSource,
name: string,
cwd: string,
): Promise<string | null> {
const skillRoots =
source === 'user' ? [getUserSkillsDir()] : getProjectSkillsDirs(cwd)
for (const root of skillRoots) {
const skillDir = path.join(root, name)
try {
const stat = await fs.stat(skillDir)
if (stat.isDirectory()) {
return skillDir
}
} catch {
// Try the next candidate root.
}
}
return null
}
// ─── Router ──────────────────────────────────────────────────────────────────
export async function handleSkillsApi(
@ -207,7 +273,7 @@ export async function handleSkillsApi(
switch (sub) {
case undefined:
return await listSkills()
return await listSkills(url)
case 'detail':
return await getSkillDetail(url)
default:
@ -220,25 +286,14 @@ export async function handleSkillsApi(
// ─── Handlers ────────────────────────────────────────────────────────────────
async function listSkills(): Promise<Response> {
const skillsDir = getUserSkillsDir()
const skills: SkillMeta[] = []
try {
const entries = await fs.readdir(skillsDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
const meta = await loadSkillMeta(
path.join(skillsDir, entry.name),
entry.name,
'user',
)
if (meta) skills.push(meta)
}
} catch {
// skills dir doesn't exist — return empty list
}
async function listSkills(url: URL): Promise<Response> {
const cwd = getRequestedCwd(url)
const [userSkills, projectSkills] = await Promise.all([
collectSkillsFromRoots([getUserSkillsDir()], 'user'),
collectSkillsFromRoots(getProjectSkillsDirs(cwd), 'project'),
])
const skills = [...userSkills, ...projectSkills]
skills.sort((a, b) => a.name.localeCompare(b.name))
return Response.json({ skills })
}
@ -256,21 +311,17 @@ async function getSkillDetail(url: URL): Promise<Response> {
throw ApiError.badRequest('Invalid skill name')
}
let skillDir: string
if (source === 'user') {
skillDir = path.join(getUserSkillsDir(), name)
} else {
if (source !== 'user' && source !== 'project') {
throw ApiError.badRequest(`Unsupported source: ${source}`)
}
try {
const stat = await fs.stat(skillDir)
if (!stat.isDirectory()) throw new Error()
} catch {
const cwd = getRequestedCwd(url)
const skillDir = await resolveSkillDir(source, name, cwd)
if (!skillDir) {
throw ApiError.notFound(`Skill not found: ${name}`)
}
const meta = await loadSkillMeta(skillDir, name, source as 'user')
const meta = await loadSkillMeta(skillDir, name, source)
if (!meta) {
throw ApiError.notFound(`Skill missing SKILL.md: ${name}`)
}