mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
126 lines
4.4 KiB
TypeScript
126 lines
4.4 KiB
TypeScript
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' }),
|
|
)
|
|
})
|
|
})
|