mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
feat: 添加云端管理命令(memory-stores、vault、schedule、skill-store、agents-platform)
- /memory-stores: 远程记忆存储管理 - /vault: 密钥保险库管理 - /schedule: 云端定时触发器管理(cron) - /skill-store: 技能商店浏览和安装 - /agents-platform: 远程 agent 调度管理 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
180
src/commands/skill-store/SkillStoreView.tsx
Normal file
180
src/commands/skill-store/SkillStoreView.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { Theme } from '@anthropic/ink';
|
||||
import type { Skill, SkillVersion } from './skillsApi.js';
|
||||
|
||||
type Props =
|
||||
| { mode: 'list'; skills: Skill[] }
|
||||
| { mode: 'detail'; skill: Skill }
|
||||
| { mode: 'versions'; id: string; versions: SkillVersion[] }
|
||||
| { mode: 'version-detail'; version: SkillVersion }
|
||||
| { mode: 'created'; skill: Skill }
|
||||
| { mode: 'deleted'; id: string }
|
||||
| { mode: 'installed'; skillName: string; path: string }
|
||||
| { mode: 'error'; message: string };
|
||||
|
||||
function SkillRow({ skill }: { skill: Skill }): React.ReactNode {
|
||||
const createdAt = skill.created_at ? new Date(skill.created_at).toLocaleString() : '—';
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text bold>{skill.skill_id}</Text>
|
||||
<Text dimColor> · </Text>
|
||||
<Text>{skill.name}</Text>
|
||||
{skill.deprecated ? (
|
||||
<>
|
||||
<Text dimColor> · </Text>
|
||||
<Text color={'warning' as keyof Theme}>deprecated</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
<Text dimColor>
|
||||
Owner: {skill.owner}
|
||||
{skill.owner_symbol ? ` (${skill.owner_symbol})` : ''}
|
||||
</Text>
|
||||
<Text dimColor>Created: {createdAt}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillStoreView(props: Props): React.ReactNode {
|
||||
if (props.mode === 'list') {
|
||||
if (props.skills.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>No skills found. Use /skill-store create <name> <markdown> to publish one.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>Skills ({props.skills.length})</Text>
|
||||
</Box>
|
||||
{props.skills.map(skill => (
|
||||
<SkillRow key={skill.skill_id} skill={skill} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'detail') {
|
||||
const { skill } = props;
|
||||
const createdAt = skill.created_at ? new Date(skill.created_at).toLocaleString() : '—';
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>Skill: {skill.skill_id}</Text>
|
||||
</Box>
|
||||
<Text>Name: {skill.name}</Text>
|
||||
<Text>
|
||||
Owner: {skill.owner}
|
||||
{skill.owner_symbol ? ` (${skill.owner_symbol})` : ''}
|
||||
</Text>
|
||||
<Text>
|
||||
Status:{' '}
|
||||
<Text color={(skill.deprecated ? 'warning' : 'success') as keyof Theme}>
|
||||
{skill.deprecated ? 'deprecated' : 'active'}
|
||||
</Text>
|
||||
</Text>
|
||||
{skill.allowed_tools && skill.allowed_tools.length > 0 ? (
|
||||
<Text>Allowed tools: {skill.allowed_tools.join(', ')}</Text>
|
||||
) : null}
|
||||
<Text dimColor>Created: {createdAt}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'versions') {
|
||||
const { id, versions } = props;
|
||||
if (versions.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>No versions found for skill {id}.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>
|
||||
Versions for {id} ({versions.length})
|
||||
</Text>
|
||||
</Box>
|
||||
{versions.map(ver => {
|
||||
const createdAt = ver.created_at ? new Date(ver.created_at).toLocaleString() : '—';
|
||||
return (
|
||||
<Box key={ver.version} flexDirection="column" marginBottom={1}>
|
||||
<Text bold>{ver.version}</Text>
|
||||
<Text dimColor>Created: {createdAt}</Text>
|
||||
<Text dimColor>{ver.body.length > 80 ? `${ver.body.slice(0, 80)}…` : ver.body}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'version-detail') {
|
||||
const { version } = props;
|
||||
const createdAt = version.created_at ? new Date(version.created_at).toLocaleString() : '—';
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>
|
||||
Version: {version.version} (skill: {version.skill_id})
|
||||
</Text>
|
||||
</Box>
|
||||
<Text dimColor>Created: {createdAt}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>{version.body}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'created') {
|
||||
const { skill } = props;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color={'success' as keyof Theme}>
|
||||
Skill created
|
||||
</Text>
|
||||
</Box>
|
||||
<Text>ID: {skill.skill_id}</Text>
|
||||
<Text>Name: {skill.name}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'deleted') {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'success' as keyof Theme}>Skill {props.id} deleted.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'installed') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color={'success' as keyof Theme}>
|
||||
Skill installed
|
||||
</Text>
|
||||
</Box>
|
||||
<Text>Name: {props.skillName}</Text>
|
||||
<Text dimColor>Path: {props.path}</Text>
|
||||
<Text dimColor>Load with: /skills (bundled skills are not auto-loaded; place in {props.path})</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// error mode
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'error' as keyof Theme}>{props.message}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
401
src/commands/skill-store/__tests__/api.test.ts
Normal file
401
src/commands/skill-store/__tests__/api.test.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Regression tests for skillsApi.ts
|
||||
*
|
||||
* Key invariants under test:
|
||||
* - Every request MUST include ?beta=true query parameter
|
||||
* - listSkills: GET /v1/skills?beta=true
|
||||
* - getSkill: GET /v1/skills/{id}?beta=true
|
||||
* - getSkillVersions: GET /v1/skills/{id}/versions?beta=true
|
||||
* - getSkillVersion: GET /v1/skills/{id}/versions/{v}?beta=true
|
||||
* - createSkill: POST /v1/skills?beta=true
|
||||
* - deleteSkill: DELETE /v1/skills/{id}?beta=true
|
||||
* - 401/403/404/429/5xx classified correctly
|
||||
* - withRetry retries only 5xx, not 4xx
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
// ── Workspace API key mock ──────────────────────────────────────────────────
|
||||
const mockApiKey = 'sk-ant-api03-test-skill-store-key'
|
||||
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
||||
}))
|
||||
|
||||
const prepareWorkspaceApiRequestMock = mock(async () => ({
|
||||
apiKey: mockApiKey,
|
||||
}))
|
||||
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
prepareWorkspaceApiRequest: prepareWorkspaceApiRequestMock,
|
||||
}))
|
||||
|
||||
// Note: we do NOT mock src/services/auth/hostGuard.js here.
|
||||
// The real assertWorkspaceHost() is called with the URL from getOauthConfig()
|
||||
// (mocked to https://api.anthropic.com), which passes the host guard.
|
||||
// Mocking hostGuard would pollute hostGuard's own test file via Bun process-level cache.
|
||||
|
||||
// ── Axios mock ──────────────────────────────────────────────────────────────
|
||||
const axiosGetMock = mock(async () => ({}))
|
||||
const axiosPostMock = mock(async () => ({}))
|
||||
const axiosDeleteMock = mock(async () => ({}))
|
||||
|
||||
const axiosIsAxiosError = mock((err: unknown) => {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'isAxiosError' in err &&
|
||||
(err as { isAxiosError: boolean }).isAxiosError === true
|
||||
)
|
||||
})
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
// ── Lazy import after mocks ─────────────────────────────────────────────────
|
||||
let listSkills: typeof import('../skillsApi.js').listSkills
|
||||
let getSkill: typeof import('../skillsApi.js').getSkill
|
||||
let getSkillVersions: typeof import('../skillsApi.js').getSkillVersions
|
||||
let getSkillVersion: typeof import('../skillsApi.js').getSkillVersion
|
||||
let createSkill: typeof import('../skillsApi.js').createSkill
|
||||
let deleteSkill: typeof import('../skillsApi.js').deleteSkill
|
||||
|
||||
beforeAll(async () => {
|
||||
axiosHandle.useStubs = true
|
||||
const mod = await import('../skillsApi.js')
|
||||
listSkills = mod.listSkills
|
||||
getSkill = mod.getSkill
|
||||
getSkillVersions = mod.getSkillVersions
|
||||
getSkillVersion = mod.getSkillVersion
|
||||
createSkill = mod.createSkill
|
||||
deleteSkill = mod.deleteSkill
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
axiosGetMock.mockClear()
|
||||
axiosPostMock.mockClear()
|
||||
axiosDeleteMock.mockClear()
|
||||
prepareWorkspaceApiRequestMock.mockClear()
|
||||
process.env['ANTHROPIC_API_KEY'] = mockApiKey
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['ANTHROPIC_API_KEY']
|
||||
})
|
||||
|
||||
// ── REGRESSION: All endpoints MUST include ?beta=true ─────────────────────
|
||||
describe('beta=true query invariant', () => {
|
||||
test('listSkills includes ?beta=true in URL', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await listSkills()
|
||||
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
|
||||
const url = calls[0]?.[0] as string
|
||||
expect(url).toContain('beta=true')
|
||||
expect(url).toContain('/v1/skills')
|
||||
})
|
||||
|
||||
test('getSkill includes ?beta=true in URL', async () => {
|
||||
const skill = {
|
||||
skill_id: 'sk_1',
|
||||
name: 'my-skill',
|
||||
owner: 'user',
|
||||
deprecated: false,
|
||||
}
|
||||
axiosGetMock.mockResolvedValueOnce({ data: skill, status: 200 })
|
||||
await getSkill('sk_1')
|
||||
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
|
||||
const url = calls[0]?.[0] as string
|
||||
expect(url).toContain('beta=true')
|
||||
expect(url).toContain('sk_1')
|
||||
expect(url).toContain('/v1/skills/')
|
||||
})
|
||||
|
||||
test('getSkillVersions includes ?beta=true in URL', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await getSkillVersions('sk_1')
|
||||
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
|
||||
const url = calls[0]?.[0] as string
|
||||
expect(url).toContain('beta=true')
|
||||
expect(url).toContain('sk_1')
|
||||
expect(url).toContain('/versions')
|
||||
})
|
||||
|
||||
test('getSkillVersion includes ?beta=true in URL', async () => {
|
||||
const ver = {
|
||||
version: 'v1',
|
||||
skill_id: 'sk_1',
|
||||
body: '# Skill',
|
||||
created_at: '2024-01-01',
|
||||
}
|
||||
axiosGetMock.mockResolvedValueOnce({ data: ver, status: 200 })
|
||||
await getSkillVersion('sk_1', 'v1')
|
||||
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
|
||||
const url = calls[0]?.[0] as string
|
||||
expect(url).toContain('beta=true')
|
||||
expect(url).toContain('sk_1')
|
||||
expect(url).toContain('v1')
|
||||
expect(url).toContain('/versions/')
|
||||
})
|
||||
|
||||
test('createSkill includes ?beta=true in URL', async () => {
|
||||
const skill = {
|
||||
skill_id: 'sk_new',
|
||||
name: 'new-skill',
|
||||
owner: 'user',
|
||||
deprecated: false,
|
||||
}
|
||||
axiosPostMock.mockResolvedValueOnce({ data: skill, status: 201 })
|
||||
await createSkill('new-skill', '# New Skill\nContent')
|
||||
const calls = axiosPostMock.mock.calls as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
][]
|
||||
const url = calls[0]?.[0] as string
|
||||
expect(url).toContain('beta=true')
|
||||
expect(url).toContain('/v1/skills')
|
||||
})
|
||||
|
||||
test('deleteSkill includes ?beta=true in URL', async () => {
|
||||
axiosDeleteMock.mockResolvedValueOnce({ data: {}, status: 204 })
|
||||
await deleteSkill('sk_1')
|
||||
const calls = axiosDeleteMock.mock.calls as unknown as [string, unknown][]
|
||||
const url = calls[0]?.[0] as string
|
||||
expect(url).toContain('beta=true')
|
||||
expect(url).toContain('sk_1')
|
||||
expect(url).toContain('/v1/skills/')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Happy path tests ────────────────────────────────────────────────────────
|
||||
describe('listSkills', () => {
|
||||
test('returns empty array on empty data', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
const result = await listSkills()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test('returns skills list', async () => {
|
||||
const skills = [
|
||||
{ skill_id: 'sk_1', name: 'skill-a', owner: 'alice', deprecated: false },
|
||||
{ skill_id: 'sk_2', name: 'skill-b', owner: 'bob', deprecated: true },
|
||||
]
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: skills }, status: 200 })
|
||||
const result = await listSkills()
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]?.skill_id).toBe('sk_1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSkill', () => {
|
||||
test('returns skill detail', async () => {
|
||||
const skill = {
|
||||
skill_id: 'sk_1',
|
||||
name: 'my-skill',
|
||||
owner: 'user',
|
||||
deprecated: false,
|
||||
}
|
||||
axiosGetMock.mockResolvedValueOnce({ data: skill, status: 200 })
|
||||
const result = await getSkill('sk_1')
|
||||
expect(result.skill_id).toBe('sk_1')
|
||||
expect(result.name).toBe('my-skill')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSkillVersions', () => {
|
||||
test('returns versions list', async () => {
|
||||
const versions = [
|
||||
{
|
||||
version: 'v1',
|
||||
skill_id: 'sk_1',
|
||||
body: '# v1',
|
||||
created_at: '2024-01-01',
|
||||
},
|
||||
]
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: versions },
|
||||
status: 200,
|
||||
})
|
||||
const result = await getSkillVersions('sk_1')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.version).toBe('v1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSkillVersion', () => {
|
||||
test('returns specific version', async () => {
|
||||
const ver = {
|
||||
version: 'v2',
|
||||
skill_id: 'sk_1',
|
||||
body: '# v2',
|
||||
created_at: '2024-02-01',
|
||||
}
|
||||
axiosGetMock.mockResolvedValueOnce({ data: ver, status: 200 })
|
||||
const result = await getSkillVersion('sk_1', 'v2')
|
||||
expect(result.version).toBe('v2')
|
||||
expect(result.body).toBe('# v2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSkill', () => {
|
||||
test('creates and returns skill', async () => {
|
||||
const skill = {
|
||||
skill_id: 'sk_new',
|
||||
name: 'new-skill',
|
||||
owner: 'user',
|
||||
deprecated: false,
|
||||
}
|
||||
axiosPostMock.mockResolvedValueOnce({ data: skill, status: 201 })
|
||||
const result = await createSkill('new-skill', '# New Skill\nContent')
|
||||
expect(result.skill_id).toBe('sk_new')
|
||||
// Verify body contains name and markdown
|
||||
const calls = axiosPostMock.mock.calls as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
][]
|
||||
const body = calls[0]?.[1] as { name: string; body: string }
|
||||
expect(body.name).toBe('new-skill')
|
||||
expect(body.body).toBe('# New Skill\nContent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteSkill', () => {
|
||||
test('calls DELETE on skill id', async () => {
|
||||
axiosDeleteMock.mockResolvedValueOnce({ data: {}, status: 204 })
|
||||
await deleteSkill('sk_del')
|
||||
expect(axiosDeleteMock).toHaveBeenCalledTimes(1)
|
||||
const calls = axiosDeleteMock.mock.calls as unknown as [string, unknown][]
|
||||
const url = calls[0]?.[0] as string
|
||||
expect(url).toContain('sk_del')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Error classification tests ──────────────────────────────────────────────
|
||||
describe('error classification', () => {
|
||||
function makeAxiosError(
|
||||
status: number,
|
||||
message?: string,
|
||||
retryAfter?: string,
|
||||
) {
|
||||
return {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status,
|
||||
data: message ? { error: { message } } : {},
|
||||
headers: retryAfter ? { 'retry-after': retryAfter } : {},
|
||||
},
|
||||
message: message ?? `HTTP ${status}`,
|
||||
}
|
||||
}
|
||||
|
||||
test('401 gives auth error message', async () => {
|
||||
axiosGetMock.mockRejectedValueOnce(makeAxiosError(401))
|
||||
await expect(listSkills()).rejects.toThrow(
|
||||
/[Aa]uthentication failed|Not authenticated/,
|
||||
)
|
||||
})
|
||||
|
||||
test('403 gives subscription required message', async () => {
|
||||
axiosGetMock.mockRejectedValueOnce(makeAxiosError(403))
|
||||
await expect(listSkills()).rejects.toThrow(/[Ss]ubscription/)
|
||||
})
|
||||
|
||||
test('404 gives not found message', async () => {
|
||||
axiosGetMock.mockRejectedValueOnce(makeAxiosError(404))
|
||||
await expect(getSkill('missing')).rejects.toThrow(/not found/)
|
||||
})
|
||||
|
||||
test('429 includes retry-after in message', async () => {
|
||||
axiosGetMock.mockRejectedValueOnce(makeAxiosError(429, undefined, '30'))
|
||||
await expect(listSkills()).rejects.toThrow(/[Rr]ate limit|30/)
|
||||
})
|
||||
|
||||
test('5xx retries up to 3 times before throwing', async () => {
|
||||
const err = makeAxiosError(500)
|
||||
axiosGetMock
|
||||
.mockRejectedValueOnce(err)
|
||||
.mockRejectedValueOnce(err)
|
||||
.mockRejectedValueOnce(err)
|
||||
await expect(listSkills()).rejects.toThrow()
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
test('4xx (non-401/403/404/429) does NOT retry', async () => {
|
||||
axiosGetMock.mockRejectedValueOnce(makeAxiosError(400, 'Bad request'))
|
||||
await expect(listSkills()).rejects.toThrow()
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Invariant: buildHeaders must return x-api-key, not Authorization ─────────
|
||||
describe('invariant: x-api-key present, no Authorization, no x-organization-uuid', () => {
|
||||
test('buildHeaders returns x-api-key header (workspace key)', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await listSkills()
|
||||
const calls = axiosGetMock.mock.calls as unknown as [
|
||||
string,
|
||||
{ headers: Record<string, string> },
|
||||
][]
|
||||
const headers = calls[0]?.[1]?.headers ?? {}
|
||||
expect(headers['x-api-key']).toBe(mockApiKey)
|
||||
})
|
||||
|
||||
test('buildHeaders does NOT include Authorization header', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await listSkills()
|
||||
const calls = axiosGetMock.mock.calls as unknown as [
|
||||
string,
|
||||
{ headers: Record<string, string> },
|
||||
][]
|
||||
const headers = calls[0]?.[1]?.headers ?? {}
|
||||
expect(headers['Authorization']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('buildHeaders does NOT include x-organization-uuid header', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await listSkills()
|
||||
const calls = axiosGetMock.mock.calls as unknown as [
|
||||
string,
|
||||
{ headers: Record<string, string> },
|
||||
][]
|
||||
const headers = calls[0]?.[1]?.headers ?? {}
|
||||
expect(headers['x-organization-uuid']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('uses prepareWorkspaceApiRequest to obtain API key', async () => {
|
||||
prepareWorkspaceApiRequestMock.mockClear()
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await listSkills()
|
||||
expect(prepareWorkspaceApiRequestMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('request goes to api.anthropic.com (host guard passes for correct host)', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await listSkills()
|
||||
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
|
||||
expect(calls[0]?.[0]).toContain('api.anthropic.com')
|
||||
})
|
||||
})
|
||||
44
src/commands/skill-store/__tests__/index.test.ts
Normal file
44
src/commands/skill-store/__tests__/index.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Unit tests for the skill-store command definition (index.tsx)
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { LocalJSXCommandModule } from '../../../types/command.js'
|
||||
import skillStoreCommand from '../index.js'
|
||||
|
||||
describe('skillStoreCommand definition', () => {
|
||||
test('name is skill-store', () => {
|
||||
expect(skillStoreCommand.name).toBe('skill-store')
|
||||
})
|
||||
|
||||
test('aliases include ss and cloud-skills', () => {
|
||||
expect(skillStoreCommand.aliases).toContain('ss')
|
||||
expect(skillStoreCommand.aliases).toContain('cloud-skills')
|
||||
})
|
||||
|
||||
test('type is local-jsx', () => {
|
||||
expect(skillStoreCommand.type).toBe('local-jsx')
|
||||
})
|
||||
|
||||
test('isHidden is boolean (dynamic: false when ANTHROPIC_API_KEY set, true when absent)', () => {
|
||||
// isHidden = !process.env['ANTHROPIC_API_KEY']
|
||||
expect(typeof skillStoreCommand.isHidden).toBe('boolean')
|
||||
})
|
||||
|
||||
test('isEnabled returns true', () => {
|
||||
const cmd = skillStoreCommand as unknown as { isEnabled: () => boolean }
|
||||
expect(cmd.isEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
test('availability includes claude-ai', () => {
|
||||
expect(skillStoreCommand.availability).toContain('claude-ai')
|
||||
})
|
||||
|
||||
test('load resolves a call function', async () => {
|
||||
const cmd = skillStoreCommand as unknown as {
|
||||
load: () => Promise<LocalJSXCommandModule>
|
||||
}
|
||||
const loaded = await cmd.load()
|
||||
expect(typeof loaded.call).toBe('function')
|
||||
})
|
||||
})
|
||||
419
src/commands/skill-store/__tests__/launchSkillStore.test.ts
Normal file
419
src/commands/skill-store/__tests__/launchSkillStore.test.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Tests for launchSkillStore.tsx
|
||||
*
|
||||
* Strategy per feedback_mock_dependency_not_subject:
|
||||
* - DO NOT mock skillsApi.ts itself (would pollute api.test.ts)
|
||||
* - Mock axios (the underlying HTTP layer) to control API responses
|
||||
* - Mock fs/promises for install filesystem operations
|
||||
* - Let real skillsApi functions run real code paths
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
// ── Analytics mock ──────────────────────────────────────────────────────────
|
||||
const realAnalytics = await import('src/services/analytics/index.js')
|
||||
const logEventMock = mock(() => {})
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
...realAnalytics,
|
||||
logEvent: logEventMock,
|
||||
}))
|
||||
|
||||
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
|
||||
const realAuth = await import('src/utils/auth.js')
|
||||
mock.module('src/utils/auth.js', () => ({
|
||||
...realAuth,
|
||||
getClaudeAIOAuthTokens: () => ({ accessToken: 'test-token' }),
|
||||
}))
|
||||
mock.module('src/services/oauth/client.js', () => ({
|
||||
getOrganizationUUID: async () => 'org-uuid',
|
||||
}))
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
||||
}))
|
||||
// Spread real teleport/api so any export not explicitly stubbed (like
|
||||
// prepareWorkspaceApiRequest, axiosGetWithRetry, type guards, schemas)
|
||||
// remains available to transitive importers.
|
||||
const realTeleportApi = await import('src/utils/teleport/api.js')
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
...realTeleportApi,
|
||||
getOAuthHeaders: (token: string) => ({ Authorization: `Bearer ${token}` }),
|
||||
}))
|
||||
|
||||
// ── envUtils config dir injection ────────────────────────────────────────────
|
||||
// Don't mock the envUtils module — that's process-level and leaks to other
|
||||
// tests' getClaudeConfigHomeDir consumers (see feedback_mock_dependency_not_subject).
|
||||
// Instead inject CLAUDE_CONFIG_DIR via process.env and clear the lodash memoize
|
||||
// cache around each test so the real getClaudeConfigHomeDir reads our value.
|
||||
const mockConfigDir = '/tmp/test-claude-config'
|
||||
|
||||
// ── Axios mock ──────────────────────────────────────────────────────────────
|
||||
const axiosGetMock = mock(async () => ({}))
|
||||
const axiosPostMock = mock(async () => ({}))
|
||||
const axiosDeleteMock = mock(async () => ({}))
|
||||
const axiosIsAxiosError = mock((err: unknown) => {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'isAxiosError' in err &&
|
||||
(err as { isAxiosError: boolean }).isAxiosError === true
|
||||
)
|
||||
})
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
// ── fs/promises mock ─────────────────────────────────────────────────────────
|
||||
// Bun's mock.module is global per-process and last-write-wins. Replacing
|
||||
// node:fs/promises with only mkdir + writeFile breaks every other test in
|
||||
// the same `bun test` run that imports readFile / readdir / unlink / chmod /
|
||||
// etc. (notably src/services/localVault/__tests__/store.test.ts).
|
||||
//
|
||||
// Use require() INSIDE the factory (same trick as SessionMemory/prompts.test)
|
||||
// so we get the truly-real module bypassing the mock registry. Gate our two
|
||||
// stubs behind useSkillStoreFsStubs (default off; beforeAll flips on; afterAll
|
||||
// flips off).
|
||||
const mkdirMock = mock(async (..._args: unknown[]) => undefined)
|
||||
const writeFileMock = mock(async (..._args: unknown[]) => undefined)
|
||||
let useSkillStoreFsStubs = false
|
||||
mock.module('node:fs/promises', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('node:fs/promises') as Record<string, unknown>
|
||||
return {
|
||||
...real,
|
||||
default: real,
|
||||
mkdir: (...args: unknown[]) =>
|
||||
useSkillStoreFsStubs
|
||||
? mkdirMock(...args)
|
||||
: (real.mkdir as (...a: unknown[]) => Promise<unknown>)(...args),
|
||||
writeFile: (...args: unknown[]) =>
|
||||
useSkillStoreFsStubs
|
||||
? writeFileMock(...args)
|
||||
: (real.writeFile as (...a: unknown[]) => Promise<unknown>)(...args),
|
||||
}
|
||||
})
|
||||
|
||||
// ── Lazy imports ─────────────────────────────────────────────────────────────
|
||||
let callSkillStore: typeof import('../launchSkillStore.js').callSkillStore
|
||||
let getClaudeConfigHomeDir: typeof import('../../../utils/envUtils.js').getClaudeConfigHomeDir
|
||||
let origConfigDir: string | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
axiosHandle.useStubs = true
|
||||
const mod = await import('../launchSkillStore.js')
|
||||
callSkillStore = mod.callSkillStore
|
||||
const envMod = await import('../../../utils/envUtils.js')
|
||||
getClaudeConfigHomeDir = envMod.getClaudeConfigHomeDir
|
||||
origConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
useSkillStoreFsStubs = true
|
||||
})
|
||||
|
||||
// Flip the stub flag off after this suite so localVault/store and other
|
||||
// fs-dependent tests in the same process see real readFile/readdir/etc.
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
useSkillStoreFsStubs = false
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
axiosGetMock.mockClear()
|
||||
axiosPostMock.mockClear()
|
||||
axiosDeleteMock.mockClear()
|
||||
mkdirMock.mockClear()
|
||||
writeFileMock.mockClear()
|
||||
logEventMock.mockClear()
|
||||
// Inject our mock config dir + bust lodash memoize so real
|
||||
// getClaudeConfigHomeDir reads the freshly-set env var.
|
||||
process.env.CLAUDE_CONFIG_DIR = mockConfigDir
|
||||
getClaudeConfigHomeDir.cache?.clear?.()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore env so we don't leak mockConfigDir into other test files.
|
||||
if (origConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = origConfigDir
|
||||
}
|
||||
getClaudeConfigHomeDir.cache?.clear?.()
|
||||
})
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||
function makeOnDone() {
|
||||
const calls: [string | undefined, unknown][] = []
|
||||
const onDone = (msg?: string, opts?: unknown) => calls.push([msg, opts])
|
||||
return { onDone, calls }
|
||||
}
|
||||
|
||||
// ── list ──────────────────────────────────────────────────────────────────────
|
||||
describe('list action', () => {
|
||||
test('calls listSkills and returns element on success', async () => {
|
||||
const skills = [
|
||||
{ skill_id: 'sk_1', name: 'skill-a', owner: 'alice', deprecated: false },
|
||||
]
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: skills }, status: 200 })
|
||||
const { onDone } = makeOnDone()
|
||||
const result = await callSkillStore(onDone, {} as never, 'list')
|
||||
expect(result).not.toBeNull()
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('empty list returns element', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callSkillStore(onDone, {} as never, 'list')
|
||||
expect(calls[0]?.[0]).toContain('No skills')
|
||||
})
|
||||
|
||||
test('API error reports failure', async () => {
|
||||
axiosGetMock.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 401 },
|
||||
message: 'Unauthorized',
|
||||
})
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callSkillStore(onDone, {} as never, 'list')
|
||||
expect(calls[0]?.[0]).toContain('Failed')
|
||||
})
|
||||
})
|
||||
|
||||
// ── get ───────────────────────────────────────────────────────────────────────
|
||||
describe('get action', () => {
|
||||
test('fetches and returns skill detail', async () => {
|
||||
const skill = {
|
||||
skill_id: 'sk_1',
|
||||
name: 'my-skill',
|
||||
owner: 'user',
|
||||
deprecated: false,
|
||||
}
|
||||
axiosGetMock.mockResolvedValueOnce({ data: skill, status: 200 })
|
||||
const { onDone } = makeOnDone()
|
||||
const result = await callSkillStore(onDone, {} as never, 'get sk_1')
|
||||
expect(result).not.toBeNull()
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('API 404 reports failure', async () => {
|
||||
axiosGetMock.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 404 },
|
||||
message: 'Not found',
|
||||
})
|
||||
const { onDone, calls } = makeOnDone()
|
||||
await callSkillStore(onDone, {} as never, 'get missing_id')
|
||||
expect(calls[0]?.[0]).toContain('Failed')
|
||||
})
|
||||
})
|
||||
|
||||
// ── versions ──────────────────────────────────────────────────────────────────
|
||||
describe('versions action', () => {
|
||||
test('fetches and returns versions', async () => {
|
||||
const versions = [
|
||||
{
|
||||
version: 'v1',
|
||||
skill_id: 'sk_1',
|
||||
body: '# v1',
|
||||
created_at: '2024-01-01',
|
||||
},
|
||||
]
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: versions },
|
||||
status: 200,
|
||||
})
|
||||
const { onDone } = makeOnDone()
|
||||
const result = await callSkillStore(onDone, {} as never, 'versions sk_1')
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── version ───────────────────────────────────────────────────────────────────
|
||||
describe('version action', () => {
|
||||
test('fetches specific version', async () => {
|
||||
const ver = {
|
||||
version: 'v2',
|
||||
skill_id: 'sk_1',
|
||||
body: '# v2',
|
||||
created_at: '2024-02-01',
|
||||
}
|
||||
axiosGetMock.mockResolvedValueOnce({ data: ver, status: 200 })
|
||||
const { onDone } = makeOnDone()
|
||||
const result = await callSkillStore(onDone, {} as never, 'version sk_1 v2')
|
||||
expect(result).not.toBeNull()
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ── create ────────────────────────────────────────────────────────────────────
|
||||
describe('create action', () => {
|
||||
test('creates skill and returns result', async () => {
|
||||
const skill = {
|
||||
skill_id: 'sk_new',
|
||||
name: 'new-skill',
|
||||
owner: 'user',
|
||||
deprecated: false,
|
||||
}
|
||||
axiosPostMock.mockResolvedValueOnce({ data: skill, status: 201 })
|
||||
const { onDone } = makeOnDone()
|
||||
const result = await callSkillStore(
|
||||
onDone,
|
||||
{} as never,
|
||||
'create new-skill # Skill Content',
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ── delete ────────────────────────────────────────────────────────────────────
|
||||
describe('delete action', () => {
|
||||
test('deletes skill and confirms', async () => {
|
||||
axiosDeleteMock.mockResolvedValueOnce({ data: {}, status: 204 })
|
||||
const { onDone, calls } = makeOnDone()
|
||||
const result = await callSkillStore(onDone, {} as never, 'delete sk_del')
|
||||
expect(result).not.toBeNull()
|
||||
expect(calls[0]?.[0]).toContain('deleted')
|
||||
})
|
||||
})
|
||||
|
||||
// ── install ───────────────────────────────────────────────────────────────────
|
||||
describe('install action', () => {
|
||||
test('install <id> fetches skill + versions, writes SKILL.md', async () => {
|
||||
const skill = {
|
||||
skill_id: 'sk_1',
|
||||
name: 'my-skill',
|
||||
owner: 'user',
|
||||
deprecated: false,
|
||||
}
|
||||
const versions = [
|
||||
{
|
||||
version: 'v1',
|
||||
skill_id: 'sk_1',
|
||||
body: '# My Skill Content',
|
||||
created_at: '2024-01-01',
|
||||
},
|
||||
]
|
||||
// First call: getSkill, Second call: getSkillVersions
|
||||
axiosGetMock
|
||||
.mockResolvedValueOnce({ data: skill, status: 200 })
|
||||
.mockResolvedValueOnce({ data: { data: versions }, status: 200 })
|
||||
|
||||
const { onDone, calls } = makeOnDone()
|
||||
const result = await callSkillStore(onDone, {} as never, 'install sk_1')
|
||||
expect(result).not.toBeNull()
|
||||
expect(mkdirMock).toHaveBeenCalledTimes(1)
|
||||
expect(writeFileMock).toHaveBeenCalledTimes(1)
|
||||
const writeCall = writeFileMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
]
|
||||
expect(writeCall[0]).toContain('SKILL.md')
|
||||
expect(writeCall[0]).toContain('my-skill')
|
||||
expect(writeCall[1]).toBe('# My Skill Content')
|
||||
expect(calls[0]?.[0]).toContain('installed')
|
||||
})
|
||||
|
||||
test('install <id>@<version> fetches specific version and writes SKILL.md', async () => {
|
||||
const ver = {
|
||||
version: 'v2',
|
||||
skill_id: 'sk_1',
|
||||
body: '# v2 Content',
|
||||
created_at: '2024-02-01',
|
||||
}
|
||||
axiosGetMock.mockResolvedValueOnce({ data: ver, status: 200 })
|
||||
|
||||
const { onDone, calls } = makeOnDone()
|
||||
const result = await callSkillStore(onDone, {} as never, 'install sk_1@v2')
|
||||
expect(result).not.toBeNull()
|
||||
expect(writeFileMock).toHaveBeenCalledTimes(1)
|
||||
const writeCall = writeFileMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
]
|
||||
expect(writeCall[1]).toBe('# v2 Content')
|
||||
expect(calls[0]?.[0]).toContain('installed')
|
||||
})
|
||||
|
||||
test('install skill with no versions shows error', async () => {
|
||||
const skill = {
|
||||
skill_id: 'sk_nover',
|
||||
name: 'no-ver-skill',
|
||||
owner: 'user',
|
||||
deprecated: false,
|
||||
}
|
||||
axiosGetMock
|
||||
.mockResolvedValueOnce({ data: skill, status: 200 })
|
||||
.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
|
||||
const { onDone, calls } = makeOnDone()
|
||||
const result = await callSkillStore(onDone, {} as never, 'install sk_nover')
|
||||
expect(result).not.toBeNull()
|
||||
expect(calls[0]?.[0]).toContain('no published versions')
|
||||
expect(writeFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('install writes to ~/.claude/skills/<name>/SKILL.md path', async () => {
|
||||
const skill = {
|
||||
skill_id: 'sk_path',
|
||||
name: 'path-test',
|
||||
owner: 'user',
|
||||
deprecated: false,
|
||||
}
|
||||
const versions = [
|
||||
{
|
||||
version: 'v1',
|
||||
skill_id: 'sk_path',
|
||||
body: '# Path Test',
|
||||
created_at: '2024-01-01',
|
||||
},
|
||||
]
|
||||
axiosGetMock
|
||||
.mockResolvedValueOnce({ data: skill, status: 200 })
|
||||
.mockResolvedValueOnce({ data: { data: versions }, status: 200 })
|
||||
|
||||
const { onDone } = makeOnDone()
|
||||
await callSkillStore(onDone, {} as never, 'install sk_path')
|
||||
|
||||
const mkdirCall = mkdirMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
{ recursive: boolean },
|
||||
]
|
||||
expect(mkdirCall[0]).toContain('skills')
|
||||
expect(mkdirCall[0]).toContain('path-test')
|
||||
|
||||
const writeCall = writeFileMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
]
|
||||
expect(writeCall[0]).toContain('SKILL.md')
|
||||
})
|
||||
})
|
||||
|
||||
// ── invalid args ──────────────────────────────────────────────────────────────
|
||||
describe('invalid args', () => {
|
||||
test('invalid subcommand returns null and calls onDone with usage', async () => {
|
||||
const { onDone, calls } = makeOnDone()
|
||||
const result = await callSkillStore(onDone, {} as never, 'unknowncmd')
|
||||
expect(result).toBeNull()
|
||||
expect(calls[0]?.[0]).toContain('Usage')
|
||||
})
|
||||
})
|
||||
146
src/commands/skill-store/__tests__/parseArgs.test.ts
Normal file
146
src/commands/skill-store/__tests__/parseArgs.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Unit tests for parseSkillStoreArgs
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { parseSkillStoreArgs } from '../parseArgs.js'
|
||||
|
||||
describe('parseSkillStoreArgs', () => {
|
||||
test('empty string → list', () => {
|
||||
expect(parseSkillStoreArgs('')).toEqual({ action: 'list' })
|
||||
})
|
||||
|
||||
test('"list" → list', () => {
|
||||
expect(parseSkillStoreArgs('list')).toEqual({ action: 'list' })
|
||||
})
|
||||
|
||||
test('"list" with whitespace → list', () => {
|
||||
expect(parseSkillStoreArgs(' list ')).toEqual({ action: 'list' })
|
||||
})
|
||||
|
||||
describe('get', () => {
|
||||
test('get <id> → { action: get, id }', () => {
|
||||
expect(parseSkillStoreArgs('get sk_123')).toEqual({
|
||||
action: 'get',
|
||||
id: 'sk_123',
|
||||
})
|
||||
})
|
||||
|
||||
test('get without id → invalid', () => {
|
||||
const result = parseSkillStoreArgs('get')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
})
|
||||
|
||||
describe('versions', () => {
|
||||
test('versions <id> → { action: versions, id }', () => {
|
||||
expect(parseSkillStoreArgs('versions sk_abc')).toEqual({
|
||||
action: 'versions',
|
||||
id: 'sk_abc',
|
||||
})
|
||||
})
|
||||
|
||||
test('versions without id → invalid', () => {
|
||||
const result = parseSkillStoreArgs('versions')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
})
|
||||
|
||||
describe('version', () => {
|
||||
test('version <id> <ver> → { action: version, id, version }', () => {
|
||||
expect(parseSkillStoreArgs('version sk_1 v2')).toEqual({
|
||||
action: 'version',
|
||||
id: 'sk_1',
|
||||
version: 'v2',
|
||||
})
|
||||
})
|
||||
|
||||
test('version without version string → invalid', () => {
|
||||
const result = parseSkillStoreArgs('version sk_1')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
|
||||
test('version without any args → invalid', () => {
|
||||
const result = parseSkillStoreArgs('version')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
test('create <name> <markdown> → { action: create, name, markdown }', () => {
|
||||
const result = parseSkillStoreArgs('create my-skill # Skill Content')
|
||||
expect(result).toEqual({
|
||||
action: 'create',
|
||||
name: 'my-skill',
|
||||
markdown: '# Skill Content',
|
||||
})
|
||||
})
|
||||
|
||||
test('create without markdown → invalid', () => {
|
||||
const result = parseSkillStoreArgs('create my-skill')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
|
||||
test('create without name → invalid', () => {
|
||||
const result = parseSkillStoreArgs('create')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
test('delete <id> → { action: delete, id }', () => {
|
||||
expect(parseSkillStoreArgs('delete sk_del')).toEqual({
|
||||
action: 'delete',
|
||||
id: 'sk_del',
|
||||
})
|
||||
})
|
||||
|
||||
test('delete without id → invalid', () => {
|
||||
const result = parseSkillStoreArgs('delete')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
})
|
||||
|
||||
describe('install', () => {
|
||||
test('install <id> → { action: install, id, version: undefined }', () => {
|
||||
expect(parseSkillStoreArgs('install sk_123')).toEqual({
|
||||
action: 'install',
|
||||
id: 'sk_123',
|
||||
version: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test('install <id>@<version> → { action: install, id, version }', () => {
|
||||
expect(parseSkillStoreArgs('install sk_123@v2')).toEqual({
|
||||
action: 'install',
|
||||
id: 'sk_123',
|
||||
version: 'v2',
|
||||
})
|
||||
})
|
||||
|
||||
test('install without id → invalid', () => {
|
||||
const result = parseSkillStoreArgs('install')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
|
||||
test('install @version without id → invalid', () => {
|
||||
const result = parseSkillStoreArgs('install @v1')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
|
||||
test('install id@ without version → invalid', () => {
|
||||
const result = parseSkillStoreArgs('install sk_1@')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unknown subcommand', () => {
|
||||
test('unknown subcommand → invalid with reason', () => {
|
||||
const result = parseSkillStoreArgs('foobar')
|
||||
expect(result.action).toBe('invalid')
|
||||
if (result.action === 'invalid') {
|
||||
expect(result.reason).toContain('foobar')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
28
src/commands/skill-store/index.tsx
Normal file
28
src/commands/skill-store/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getGlobalConfig } from '../../utils/config.js';
|
||||
import type { Command } from '../../types/command.js';
|
||||
|
||||
const skillStoreCommand: Command = {
|
||||
type: 'local-jsx',
|
||||
name: 'skill-store',
|
||||
aliases: ['ss', 'cloud-skills'],
|
||||
description:
|
||||
'Browse and install remote skills from the Anthropic skill marketplace. Requires Claude Pro/Max/Team subscription.',
|
||||
// REPL markdown renderer strips `<...>` as HTML tags — use uppercase.
|
||||
argumentHint:
|
||||
'list | get ID | versions ID | version ID VER | create NAME MARKDOWN | delete ID | install ID[@VERSION]',
|
||||
// Visible when a workspace API key is available from env or saved settings.
|
||||
// Use a getter so getGlobalConfig() runs lazily (after enableConfigs())
|
||||
// instead of at module-load time, which races bootstrap and throws.
|
||||
get isHidden(): boolean {
|
||||
return !process.env['ANTHROPIC_API_KEY'] && !getGlobalConfig().workspaceApiKey;
|
||||
},
|
||||
isEnabled: () => true,
|
||||
bridgeSafe: false,
|
||||
availability: ['claude-ai'],
|
||||
load: async () => {
|
||||
const m = await import('./launchSkillStore.js');
|
||||
return { call: m.callSkillStore };
|
||||
},
|
||||
};
|
||||
|
||||
export default skillStoreCommand;
|
||||
237
src/commands/skill-store/launchSkillStore.tsx
Normal file
237
src/commands/skill-store/launchSkillStore.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React from 'react';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js';
|
||||
import { createSkill, deleteSkill, getSkill, getSkillVersion, getSkillVersions, listSkills } from './skillsApi.js';
|
||||
import { SkillStoreView } from './SkillStoreView.js';
|
||||
import { parseSkillStoreArgs } from './parseArgs.js';
|
||||
|
||||
const USAGE =
|
||||
'Usage: /skill-store list | get ID | versions ID | version ID VER | create NAME MARKDOWN | delete ID | install ID[@VERSION]';
|
||||
|
||||
export const callSkillStore: LocalJSXCommandCall = async (onDone, _context, args) => {
|
||||
logEvent('tengu_skill_store_started', {
|
||||
args: (args ?? '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
const parsed = parseSkillStoreArgs(args ?? '');
|
||||
|
||||
// ── invalid args ──────────────────────────────────────────────────────────
|
||||
if (parsed.action === 'invalid') {
|
||||
logEvent('tengu_skill_store_failed', {
|
||||
reason: parsed.reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`${USAGE}\n${parsed.reason}`, { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── list skills ───────────────────────────────────────────────────────────
|
||||
if (parsed.action === 'list') {
|
||||
logEvent('tengu_skill_store_list', {});
|
||||
try {
|
||||
const skills = await listSkills();
|
||||
onDone(skills.length === 0 ? 'No skills found in the marketplace.' : `${skills.length} skill(s) available.`, {
|
||||
display: 'system',
|
||||
});
|
||||
return React.createElement(SkillStoreView, { mode: 'list', skills });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_skill_store_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to list skills: ${msg}`, { display: 'system' });
|
||||
return React.createElement(SkillStoreView, { mode: 'error', message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// ── get skill ─────────────────────────────────────────────────────────────
|
||||
if (parsed.action === 'get') {
|
||||
const { id } = parsed;
|
||||
logEvent('tengu_skill_store_get', {
|
||||
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
try {
|
||||
const skill = await getSkill(id);
|
||||
onDone(`Skill ${id} fetched.`, { display: 'system' });
|
||||
return React.createElement(SkillStoreView, { mode: 'detail', skill });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_skill_store_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to get skill ${id}: ${msg}`, { display: 'system' });
|
||||
return React.createElement(SkillStoreView, { mode: 'error', message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// ── list versions ─────────────────────────────────────────────────────────
|
||||
if (parsed.action === 'versions') {
|
||||
const { id } = parsed;
|
||||
logEvent('tengu_skill_store_versions', {
|
||||
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
try {
|
||||
const versions = await getSkillVersions(id);
|
||||
onDone(
|
||||
versions.length === 0 ? `No versions found for skill ${id}.` : `${versions.length} version(s) for skill ${id}.`,
|
||||
{ display: 'system' },
|
||||
);
|
||||
return React.createElement(SkillStoreView, {
|
||||
mode: 'versions',
|
||||
id,
|
||||
versions,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_skill_store_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to list versions for skill ${id}: ${msg}`, {
|
||||
display: 'system',
|
||||
});
|
||||
return React.createElement(SkillStoreView, { mode: 'error', message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// ── get specific version ──────────────────────────────────────────────────
|
||||
if (parsed.action === 'version') {
|
||||
const { id, version } = parsed;
|
||||
logEvent('tengu_skill_store_version', {
|
||||
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
try {
|
||||
const ver = await getSkillVersion(id, version);
|
||||
onDone(`Skill ${id}@${version} fetched.`, { display: 'system' });
|
||||
return React.createElement(SkillStoreView, {
|
||||
mode: 'version-detail',
|
||||
version: ver,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_skill_store_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to get version ${version} for skill ${id}: ${msg}`, {
|
||||
display: 'system',
|
||||
});
|
||||
return React.createElement(SkillStoreView, { mode: 'error', message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// ── create skill ──────────────────────────────────────────────────────────
|
||||
if (parsed.action === 'create') {
|
||||
const { name, markdown } = parsed;
|
||||
logEvent('tengu_skill_store_create', {
|
||||
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
try {
|
||||
const skill = await createSkill(name, markdown);
|
||||
onDone(`Skill created: ${skill.skill_id}`, { display: 'system' });
|
||||
return React.createElement(SkillStoreView, { mode: 'created', skill });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_skill_store_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to create skill: ${msg}`, { display: 'system' });
|
||||
return React.createElement(SkillStoreView, { mode: 'error', message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// ── delete skill ──────────────────────────────────────────────────────────
|
||||
if (parsed.action === 'delete') {
|
||||
const { id } = parsed;
|
||||
logEvent('tengu_skill_store_delete', {
|
||||
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
try {
|
||||
await deleteSkill(id);
|
||||
onDone(`Skill ${id} deleted.`, { display: 'system' });
|
||||
return React.createElement(SkillStoreView, { mode: 'deleted', id });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_skill_store_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to delete skill ${id}: ${msg}`, { display: 'system' });
|
||||
return React.createElement(SkillStoreView, { mode: 'error', message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// ── install skill ─────────────────────────────────────────────────────────
|
||||
// parsed.action === 'install'
|
||||
const { id, version } = parsed;
|
||||
logEvent('tengu_skill_store_install', {
|
||||
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
try {
|
||||
// Fetch the skill markdown body
|
||||
let skillName: string;
|
||||
let body: string;
|
||||
if (version !== undefined) {
|
||||
const ver = await getSkillVersion(id, version);
|
||||
body = ver.body;
|
||||
// Derive a safe name from the version's skill_id or id
|
||||
skillName = ver.skill_id;
|
||||
} else {
|
||||
const skill = await getSkill(id);
|
||||
// To get the body we need to fetch the latest version
|
||||
const versions = await getSkillVersions(id);
|
||||
if (versions.length === 0) {
|
||||
onDone(`Skill ${id} has no published versions to install.`, {
|
||||
display: 'system',
|
||||
});
|
||||
return React.createElement(SkillStoreView, {
|
||||
mode: 'error',
|
||||
message: `Skill ${id} has no published versions to install.`,
|
||||
});
|
||||
}
|
||||
// Sort by created_at descending and pick latest
|
||||
const sorted = [...versions].sort((a, b) => {
|
||||
const dateA = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||
const dateB = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
const latest = sorted[0];
|
||||
if (!latest) {
|
||||
onDone(`Skill ${id} has no published versions to install.`, {
|
||||
display: 'system',
|
||||
});
|
||||
return React.createElement(SkillStoreView, {
|
||||
mode: 'error',
|
||||
message: `Skill ${id} has no published versions to install.`,
|
||||
});
|
||||
}
|
||||
body = latest.body;
|
||||
skillName = skill.name;
|
||||
}
|
||||
|
||||
// Sanitize skill name to a safe directory name
|
||||
const safeName = skillName.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/^-+|-+$/g, '') || id;
|
||||
|
||||
const skillDir = join(getClaudeConfigHomeDir(), 'skills', safeName);
|
||||
const skillPath = join(skillDir, 'SKILL.md');
|
||||
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(skillPath, body, 'utf-8');
|
||||
|
||||
onDone(`Skill installed to ${skillPath}`, { display: 'system' });
|
||||
return React.createElement(SkillStoreView, {
|
||||
mode: 'installed',
|
||||
skillName: safeName,
|
||||
path: skillPath,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_skill_store_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to install skill ${id}: ${msg}`, { display: 'system' });
|
||||
return React.createElement(SkillStoreView, { mode: 'error', message: msg });
|
||||
}
|
||||
};
|
||||
155
src/commands/skill-store/parseArgs.ts
Normal file
155
src/commands/skill-store/parseArgs.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Parse the args string for the /skill-store command.
|
||||
*
|
||||
* Supported sub-commands:
|
||||
* list → { action: 'list' }
|
||||
* get <id> → { action: 'get', id }
|
||||
* versions <id> → { action: 'versions', id }
|
||||
* version <id> <version> → { action: 'version', id, version }
|
||||
* create <name> <markdown> → { action: 'create', name, markdown }
|
||||
* delete <id> → { action: 'delete', id }
|
||||
* install <id> → { action: 'install', id, version: undefined }
|
||||
* install <id>@<version> → { action: 'install', id, version }
|
||||
* (empty) → { action: 'list' }
|
||||
* anything else → { action: 'invalid', reason }
|
||||
*/
|
||||
|
||||
export type SkillStoreArgs =
|
||||
| { action: 'list' }
|
||||
| { action: 'get'; id: string }
|
||||
| { action: 'versions'; id: string }
|
||||
| { action: 'version'; id: string; version: string }
|
||||
| { action: 'create'; name: string; markdown: string }
|
||||
| { action: 'delete'; id: string }
|
||||
| { action: 'install'; id: string; version: string | undefined }
|
||||
| { action: 'invalid'; reason: string }
|
||||
|
||||
const USAGE =
|
||||
'Usage: /skill-store list | get ID | versions ID | version ID VER | create NAME MARKDOWN | delete ID | install ID[@VERSION]'
|
||||
|
||||
export function parseSkillStoreArgs(args: string): SkillStoreArgs {
|
||||
const trimmed = args.trim()
|
||||
|
||||
if (trimmed === '' || trimmed === 'list') {
|
||||
return { action: 'list' }
|
||||
}
|
||||
|
||||
const spaceIdx = trimmed.indexOf(' ')
|
||||
const subCmd = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)
|
||||
const rest = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim()
|
||||
|
||||
// ── get ───────────────────────────────────────────────────────────────────
|
||||
if (subCmd === 'get') {
|
||||
if (!rest) {
|
||||
return { action: 'invalid', reason: 'get requires a skill id' }
|
||||
}
|
||||
const id = rest.split(/\s+/)[0]
|
||||
if (!id) {
|
||||
return { action: 'invalid', reason: 'get requires a skill id' }
|
||||
}
|
||||
return { action: 'get', id }
|
||||
}
|
||||
|
||||
// ── versions ──────────────────────────────────────────────────────────────
|
||||
if (subCmd === 'versions') {
|
||||
if (!rest) {
|
||||
return { action: 'invalid', reason: 'versions requires a skill id' }
|
||||
}
|
||||
const id = rest.split(/\s+/)[0]
|
||||
if (!id) {
|
||||
return { action: 'invalid', reason: 'versions requires a skill id' }
|
||||
}
|
||||
return { action: 'versions', id }
|
||||
}
|
||||
|
||||
// ── version ───────────────────────────────────────────────────────────────
|
||||
if (subCmd === 'version') {
|
||||
const parts = rest.split(/\s+/)
|
||||
if (parts.length < 2 || !parts[0] || !parts[1]) {
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason:
|
||||
'version requires a skill id and version, e.g. version sk_123 v1',
|
||||
}
|
||||
}
|
||||
return { action: 'version', id: parts[0], version: parts[1] }
|
||||
}
|
||||
|
||||
// ── create ────────────────────────────────────────────────────────────────
|
||||
if (subCmd === 'create') {
|
||||
const spaceInRest = rest.indexOf(' ')
|
||||
if (!rest || spaceInRest === -1) {
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason:
|
||||
'create requires a skill name and markdown body, e.g. create my-skill "# My Skill\\nContent"',
|
||||
}
|
||||
}
|
||||
const name = rest.slice(0, spaceInRest).trim()
|
||||
const markdown = rest.slice(spaceInRest + 1).trim()
|
||||
if (!name) {
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason: 'create requires a non-empty skill name',
|
||||
}
|
||||
}
|
||||
if (!markdown) {
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason: 'create requires a non-empty markdown body',
|
||||
}
|
||||
}
|
||||
return { action: 'create', name, markdown }
|
||||
}
|
||||
|
||||
// ── delete ────────────────────────────────────────────────────────────────
|
||||
if (subCmd === 'delete') {
|
||||
if (!rest) {
|
||||
return { action: 'invalid', reason: 'delete requires a skill id' }
|
||||
}
|
||||
const id = rest.split(/\s+/)[0]
|
||||
if (!id) {
|
||||
return { action: 'invalid', reason: 'delete requires a skill id' }
|
||||
}
|
||||
return { action: 'delete', id }
|
||||
}
|
||||
|
||||
// ── install ───────────────────────────────────────────────────────────────
|
||||
if (subCmd === 'install') {
|
||||
if (!rest) {
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason:
|
||||
'install requires a skill id (optionally with @version), e.g. install sk_123 or install sk_123@v2',
|
||||
}
|
||||
}
|
||||
const token = rest.split(/\s+/)[0]
|
||||
if (!token) {
|
||||
return { action: 'invalid', reason: 'install requires a skill id' }
|
||||
}
|
||||
const atIdx = token.indexOf('@')
|
||||
if (atIdx === -1) {
|
||||
return { action: 'install', id: token, version: undefined }
|
||||
}
|
||||
const id = token.slice(0, atIdx)
|
||||
const version = token.slice(atIdx + 1)
|
||||
if (!id) {
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason: 'install requires a non-empty skill id before @',
|
||||
}
|
||||
}
|
||||
if (!version) {
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason: 'install requires a non-empty version after @',
|
||||
}
|
||||
}
|
||||
return { action: 'install', id, version }
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason: `Unknown sub-command "${subCmd}". ${USAGE}`,
|
||||
}
|
||||
}
|
||||
256
src/commands/skill-store/skillsApi.ts
Normal file
256
src/commands/skill-store/skillsApi.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Thin HTTP client for the /v1/skills endpoint.
|
||||
*
|
||||
* Key spec facts (from binary reverse-engineering of v2.1.123):
|
||||
* - list skills: GET /v1/skills?beta=true
|
||||
* - get skill: GET /v1/skills/{id}?beta=true
|
||||
* - list versions: GET /v1/skills/{id}/versions?beta=true
|
||||
* - get version: GET /v1/skills/{id}/versions/{v}?beta=true
|
||||
* - create skill: POST /v1/skills?beta=true
|
||||
* - delete skill: DELETE /v1/skills/{id}?beta=true
|
||||
*
|
||||
* CRITICAL INVARIANT: Every request MUST include ?beta=true query parameter.
|
||||
* Binary evidence: `?beta=true` gate on all /v1/skills paths.
|
||||
*
|
||||
* Reuses the same base-URL + auth-header pattern as memoryStoresApi.ts.
|
||||
*/
|
||||
|
||||
import axios from 'axios'
|
||||
import { getOauthConfig } from '../../constants/oauth.js'
|
||||
import { assertWorkspaceHost } from '../../services/auth/hostGuard.js'
|
||||
import { prepareWorkspaceApiRequest } from '../../utils/teleport/api.js'
|
||||
|
||||
export type Skill = {
|
||||
skill_id: string
|
||||
name: string
|
||||
owner: string
|
||||
owner_symbol?: string
|
||||
deprecated: boolean
|
||||
allowed_tools?: string[]
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export type SkillVersion = {
|
||||
version: string
|
||||
skill_id: string
|
||||
body: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export type CreateSkillBody = {
|
||||
name: string
|
||||
body: string
|
||||
}
|
||||
|
||||
type ListSkillsResponse = {
|
||||
data: Skill[]
|
||||
}
|
||||
|
||||
type ListVersionsResponse = {
|
||||
data: SkillVersion[]
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
class SkillsApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'SkillsApiError'
|
||||
}
|
||||
}
|
||||
|
||||
async function buildHeaders(): Promise<Record<string, string>> {
|
||||
// /v1/skills requires a workspace-scoped API key (sk-ant-api03-*).
|
||||
// Subscription OAuth bearer tokens 404 here (endpoint not on subscription plane).
|
||||
// Guard the host before sending the key to prevent credential leakage.
|
||||
let apiKey: string
|
||||
try {
|
||||
const prepared = await prepareWorkspaceApiRequest()
|
||||
apiKey = prepared.apiKey
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
throw new SkillsApiError(msg, 501)
|
||||
}
|
||||
assertWorkspaceHost(skillsBaseUrl())
|
||||
return {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base URL for /v1/skills with mandatory ?beta=true query.
|
||||
* CRITICAL INVARIANT: always append beta=true.
|
||||
*/
|
||||
function skillsBaseUrl(): string {
|
||||
return `${getOauthConfig().BASE_API_URL}/v1/skills?beta=true`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL for a specific skill with mandatory ?beta=true query.
|
||||
*/
|
||||
function skillUrl(id: string): string {
|
||||
return `${getOauthConfig().BASE_API_URL}/v1/skills/${id}?beta=true`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL for skill versions with mandatory ?beta=true query.
|
||||
*/
|
||||
function skillVersionsUrl(id: string): string {
|
||||
return `${getOauthConfig().BASE_API_URL}/v1/skills/${id}/versions?beta=true`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL for a specific skill version with mandatory ?beta=true query.
|
||||
*/
|
||||
function skillVersionUrl(id: string, version: string): string {
|
||||
return `${getOauthConfig().BASE_API_URL}/v1/skills/${id}/versions/${version}?beta=true`
|
||||
}
|
||||
|
||||
function classifyError(err: unknown): SkillsApiError {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const status = err.response?.status ?? 0
|
||||
if (status === 401) {
|
||||
return new SkillsApiError(
|
||||
'Authentication failed. Please run /login to re-authenticate.',
|
||||
401,
|
||||
)
|
||||
}
|
||||
if (status === 403) {
|
||||
return new SkillsApiError(
|
||||
'Subscription required. Skill store requires a Claude Pro/Max/Team subscription.',
|
||||
403,
|
||||
)
|
||||
}
|
||||
if (status === 404) {
|
||||
return new SkillsApiError('Skill or version not found.', 404)
|
||||
}
|
||||
if (status === 429) {
|
||||
const retryAfter =
|
||||
(err.response?.headers as Record<string, string> | undefined)?.[
|
||||
'retry-after'
|
||||
] ?? ''
|
||||
const detail = retryAfter ? ` Retry after ${retryAfter}s.` : ''
|
||||
return new SkillsApiError(`Rate limit exceeded.${detail}`, 429)
|
||||
}
|
||||
const msg =
|
||||
(err.response?.data as { error?: { message?: string } } | undefined)
|
||||
?.error?.message ?? err.message
|
||||
return new SkillsApiError(msg, status)
|
||||
}
|
||||
if (err instanceof SkillsApiError) return err
|
||||
return new SkillsApiError(err instanceof Error ? err.message : String(err), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Retry-After header value into milliseconds.
|
||||
* Accepts both integer-seconds (e.g. "30") and HTTP-date strings.
|
||||
* Returns null when the header is absent or unparseable.
|
||||
*/
|
||||
function parseRetryAfterMs(header: string | undefined): number | null {
|
||||
if (!header) return null
|
||||
const seconds = Number(header)
|
||||
if (!Number.isNaN(seconds) && seconds >= 0) return seconds * 1000
|
||||
const date = Date.parse(header)
|
||||
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
|
||||
return null
|
||||
}
|
||||
|
||||
async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
||||
let lastErr: SkillsApiError | undefined
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err: unknown) {
|
||||
const classified = classifyError(err)
|
||||
// Only retry 5xx errors
|
||||
if (classified.statusCode >= 500) {
|
||||
lastErr = classified
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
const retryAfterHeader = axios.isAxiosError(err)
|
||||
? (err.response?.headers as Record<string, string> | undefined)?.[
|
||||
'retry-after'
|
||||
]
|
||||
: undefined
|
||||
const waitMs =
|
||||
parseRetryAfterMs(retryAfterHeader) ?? 500 * 2 ** attempt
|
||||
await sleep(waitMs)
|
||||
}
|
||||
continue
|
||||
}
|
||||
throw classified
|
||||
}
|
||||
}
|
||||
throw lastErr ?? new SkillsApiError('Request failed after retries', 0)
|
||||
}
|
||||
|
||||
// ── Skills CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listSkills(): Promise<Skill[]> {
|
||||
return withRetry(async () => {
|
||||
const headers = await buildHeaders()
|
||||
const response = await axios.get<ListSkillsResponse>(skillsBaseUrl(), {
|
||||
headers,
|
||||
})
|
||||
return response.data.data ?? []
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSkill(id: string): Promise<Skill> {
|
||||
return withRetry(async () => {
|
||||
const headers = await buildHeaders()
|
||||
const response = await axios.get<Skill>(skillUrl(id), { headers })
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSkillVersions(id: string): Promise<SkillVersion[]> {
|
||||
return withRetry(async () => {
|
||||
const headers = await buildHeaders()
|
||||
const response = await axios.get<ListVersionsResponse>(
|
||||
skillVersionsUrl(id),
|
||||
{ headers },
|
||||
)
|
||||
return response.data.data ?? []
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSkillVersion(
|
||||
id: string,
|
||||
version: string,
|
||||
): Promise<SkillVersion> {
|
||||
return withRetry(async () => {
|
||||
const headers = await buildHeaders()
|
||||
const response = await axios.get<SkillVersion>(
|
||||
skillVersionUrl(id, version),
|
||||
{ headers },
|
||||
)
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
export async function createSkill(name: string, body: string): Promise<Skill> {
|
||||
return withRetry(async () => {
|
||||
const headers = await buildHeaders()
|
||||
const requestBody: CreateSkillBody = { name, body }
|
||||
const response = await axios.post<Skill>(skillsBaseUrl(), requestBody, {
|
||||
headers,
|
||||
})
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteSkill(id: string): Promise<void> {
|
||||
return withRetry(async () => {
|
||||
const headers = await buildHeaders()
|
||||
await axios.delete(skillUrl(id), { headers })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user