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:
claude-code-best
2026-05-09 23:04:17 +08:00
parent ee63c17697
commit 2437040b5b
47 changed files with 9309 additions and 5 deletions

View 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 &lt;name&gt; &lt;markdown&gt; 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>
);
}

View 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')
})
})

View 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')
})
})

View 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')
})
})

View 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')
}
})
})
})

View 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;

View 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 });
}
};

View 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}`,
}
}

View 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 })
})
}