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,96 @@
import React from 'react';
import { Box, Text } from '@anthropic/ink';
import type { Theme } from '@anthropic/ink';
import type { AgentTrigger } from './agentsApi.js';
import { cronToHuman } from '../../utils/cron.js';
type Props =
| { mode: 'list'; agents: AgentTrigger[] }
| { mode: 'created'; agent: AgentTrigger }
| { mode: 'deleted'; id: string }
| { mode: 'ran'; id: string; runId: string }
| { mode: 'error'; message: string };
function AgentRow({ agent }: { agent: AgentTrigger }): React.ReactNode {
const schedule = cronToHuman(agent.cron_expr, { utc: true });
const nextRun = agent.next_run ? new Date(agent.next_run).toLocaleString() : '—';
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text bold>{agent.id}</Text>
<Text dimColor> · </Text>
<Text color={'suggestion' as keyof Theme}>{agent.status}</Text>
</Box>
<Text>Schedule: {schedule}</Text>
<Text dimColor>Prompt: {agent.prompt}</Text>
<Text dimColor>Next run: {nextRun}</Text>
</Box>
);
}
export function AgentsPlatformView(props: Props): React.ReactNode {
if (props.mode === 'list') {
if (props.agents.length === 0) {
return (
<Box>
<Text dimColor>
No scheduled agents. Use /agents-platform create &lt;cron&gt; &lt;prompt&gt; to create one.
</Text>
</Box>
);
}
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Scheduled Agents ({props.agents.length})</Text>
</Box>
{props.agents.map(agent => (
<AgentRow key={agent.id} agent={agent} />
))}
</Box>
);
}
if (props.mode === 'created') {
const schedule = cronToHuman(props.agent.cron_expr, { utc: true });
return (
<Box flexDirection="column">
<Box>
<Text bold color={'success' as keyof Theme}>
Agent created
</Text>
</Box>
<Text>ID: {props.agent.id}</Text>
<Text>Schedule: {schedule}</Text>
<Text>Prompt: {props.agent.prompt}</Text>
<Text dimColor>Status: {props.agent.status}</Text>
</Box>
);
}
if (props.mode === 'deleted') {
return (
<Box>
<Text color={'success' as keyof Theme}>Agent {props.id} deleted.</Text>
</Box>
);
}
if (props.mode === 'ran') {
return (
<Box flexDirection="column">
<Box>
<Text color={'success' as keyof Theme}>Agent {props.id} triggered.</Text>
</Box>
<Text dimColor>Run ID: {props.runId}</Text>
</Box>
);
}
// error mode
return (
<Box>
<Text color={'error' as keyof Theme}>{props.message}</Text>
</Box>
);
}

View File

@@ -0,0 +1,127 @@
/**
* Tests for AgentsPlatformView.tsx
* Covers all 5 modes: list (empty), list (with agents), created, deleted, ran, error
*/
import { describe, expect, mock, test } from 'bun:test';
import * as React from 'react';
import { renderToString } from '../../../utils/staticRender.js';
// Mock cron utility before importing AgentsPlatformView
mock.module('src/utils/cron.js', () => ({
cronToHuman: (expr: string) => `HumanCron(${expr})`,
parseCronExpression: () => null,
computeNextCronRun: () => null,
}));
const { AgentsPlatformView } = await import('../AgentsPlatformView.js');
const sampleAgent = {
id: 'agt_abc123',
cron_expr: '0 9 * * 1',
prompt: 'Run standup report',
status: 'active' as const,
timezone: 'UTC',
next_run: '2026-05-05T09:00:00.000Z',
};
describe('AgentsPlatformView list mode', () => {
test('empty list shows placeholder message', async () => {
const out = await renderToString(<AgentsPlatformView mode="list" agents={[]} />);
expect(out).toContain('No scheduled agents');
});
test('non-empty list shows agent count', async () => {
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
expect(out).toContain('Scheduled Agents (1)');
});
test('non-empty list shows agent id', async () => {
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
expect(out).toContain('agt_abc123');
});
test('non-empty list shows agent status', async () => {
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
expect(out).toContain('active');
});
test('non-empty list shows human-readable schedule', async () => {
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
expect(out).toContain('HumanCron(0 9 * * 1)');
});
test('list shows agent prompt', async () => {
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
expect(out).toContain('Run standup report');
});
test('list shows next run date', async () => {
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
// next_run is formatted via toLocaleString — just check it's rendered
expect(out).toContain('Next run');
});
test('list with null next_run shows em dash', async () => {
const agentNoNextRun = { ...sampleAgent, next_run: null };
const out = await renderToString(<AgentsPlatformView mode="list" agents={[agentNoNextRun]} />);
expect(out).toContain('—');
});
test('multiple agents rendered', async () => {
const agent2 = { ...sampleAgent, id: 'agt_xyz', cron_expr: '0 10 * * 2' };
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent, agent2]} />);
expect(out).toContain('Scheduled Agents (2)');
expect(out).toContain('agt_abc123');
expect(out).toContain('agt_xyz');
});
});
describe('AgentsPlatformView created mode', () => {
test('shows Agent created', async () => {
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
expect(out).toContain('Agent created');
});
test('shows agent id', async () => {
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
expect(out).toContain('agt_abc123');
});
test('shows schedule', async () => {
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
expect(out).toContain('HumanCron(0 9 * * 1)');
});
test('shows prompt', async () => {
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
expect(out).toContain('Run standup report');
});
});
describe('AgentsPlatformView deleted mode', () => {
test('shows deleted confirmation with id', async () => {
const out = await renderToString(<AgentsPlatformView mode="deleted" id="agt_abc123" />);
expect(out).toContain('agt_abc123');
expect(out).toContain('deleted');
});
});
describe('AgentsPlatformView ran mode', () => {
test('shows triggered with agent id', async () => {
const out = await renderToString(<AgentsPlatformView mode="ran" id="agt_abc123" runId="run_xyz" />);
expect(out).toContain('agt_abc123');
expect(out).toContain('triggered');
});
test('shows run id', async () => {
const out = await renderToString(<AgentsPlatformView mode="ran" id="agt_abc123" runId="run_xyz" />);
expect(out).toContain('run_xyz');
});
});
describe('AgentsPlatformView error mode', () => {
test('shows error message', async () => {
const out = await renderToString(<AgentsPlatformView mode="error" message="Network failure" />);
expect(out).toContain('Network failure');
});
});

View File

@@ -0,0 +1,382 @@
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 side-effect modules first
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
// ── Workspace API key mock ──────────────────────────────────────────────────
const mockApiKey = 'sk-ant-api03-test-agents-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 are in place
let listAgents: typeof import('../agentsApi.js').listAgents
let createAgent: typeof import('../agentsApi.js').createAgent
let deleteAgent: typeof import('../agentsApi.js').deleteAgent
let runAgent: typeof import('../agentsApi.js').runAgent
beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../agentsApi.js')
listAgents = mod.listAgents
createAgent = mod.createAgent
deleteAgent = mod.deleteAgent
runAgent = mod.runAgent
})
afterAll(() => {
axiosHandle.useStubs = false
})
beforeEach(() => {
axiosGetMock.mockClear()
axiosPostMock.mockClear()
axiosDeleteMock.mockClear()
prepareWorkspaceApiRequestMock.mockClear()
// Ensure ANTHROPIC_API_KEY is set for happy-path tests
process.env['ANTHROPIC_API_KEY'] = mockApiKey
})
afterEach(() => {
// Clean up env var to avoid test pollution
delete process.env['ANTHROPIC_API_KEY']
})
// afterEach handled above
describe('listAgents', () => {
test('returns agents on 200', async () => {
const agents = [
{
id: 'agt_1',
cron_expr: '0 9 * * 1',
prompt: 'hello',
status: 'active',
timezone: 'UTC',
next_run: null,
},
]
axiosGetMock.mockResolvedValueOnce({ data: { data: agents }, status: 200 })
const result = await listAgents()
expect(result).toHaveLength(1)
expect(result[0]!.id).toBe('agt_1')
expect(axiosGetMock).toHaveBeenCalledTimes(1)
})
test('returns empty array when data.data is empty', async () => {
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
const result = await listAgents()
expect(result).toHaveLength(0)
})
test('throws on 401 with friendly message', async () => {
const err = Object.assign(new Error('Unauthorized'), {
isAxiosError: true,
response: { status: 401, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listAgents()).rejects.toThrow('re-authenticate')
})
test('throws on 403 with subscription message', async () => {
const err = Object.assign(new Error('Forbidden'), {
isAxiosError: true,
response: { status: 403, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listAgents()).rejects.toThrow('Subscription')
})
test('retries on 5xx and eventually throws', async () => {
const make5xxErr = () =>
Object.assign(new Error('Server Error'), {
isAxiosError: true,
response: { status: 500, data: {} },
})
axiosGetMock
.mockRejectedValueOnce(make5xxErr())
.mockRejectedValueOnce(make5xxErr())
.mockRejectedValueOnce(make5xxErr())
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listAgents()).rejects.toThrow()
expect(axiosGetMock).toHaveBeenCalledTimes(3)
}, 15000)
})
describe('createAgent', () => {
test('sends correct body and returns agent', async () => {
const agent = {
id: 'agt_new',
cron_expr: '0 9 * * *',
prompt: 'Test',
status: 'active',
timezone: 'UTC',
next_run: null,
}
axiosPostMock.mockResolvedValueOnce({ data: agent, status: 201 })
const result = await createAgent('0 9 * * *', 'Test')
expect(result.id).toBe('agt_new')
const callArgs = (
axiosPostMock.mock.calls as unknown as [string, unknown, unknown][]
)[0]
const body = callArgs?.[1] as { cron_expr: string; timezone: string }
expect(body.cron_expr).toBe('0 9 * * *')
expect(body.timezone).toBe('UTC')
})
test('throws on 404', async () => {
const err = Object.assign(new Error('Not Found'), {
isAxiosError: true,
response: { status: 404, data: {} },
})
axiosPostMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(createAgent('0 9 * * *', 'Test')).rejects.toThrow(
'Agent not found',
)
})
})
describe('deleteAgent', () => {
test('calls DELETE endpoint with agent id', async () => {
axiosDeleteMock.mockResolvedValueOnce({ status: 204 })
await deleteAgent('agt_del')
const url = (
axiosDeleteMock.mock.calls as unknown as [string, unknown][]
)[0]?.[0] as string
expect(url).toContain('agt_del')
})
})
describe('runAgent', () => {
test('calls POST /v1/agents/:id/run and returns run_id', async () => {
axiosPostMock.mockResolvedValueOnce({
data: { run_id: 'run_abc' },
status: 200,
})
const result = await runAgent('agt_run')
expect(result.run_id).toBe('run_abc')
const url = (
axiosPostMock.mock.calls as unknown as [string, unknown, unknown][]
)[0]?.[0] as string
expect(url).toContain('agt_run/run')
})
})
// ── M3 regression: createAgent must use system timezone, not hardcoded UTC ──
describe('createAgent M3: timezone uses system TZ not hardcoded UTC', () => {
test('createAgent passes system timezone to the API body', async () => {
axiosPostMock.mockResolvedValueOnce({
data: {
id: 'agt_tz',
cron_expr: '0 9 * * 1',
prompt: 'hello',
status: 'active',
timezone: 'America/New_York',
},
status: 200,
})
await createAgent('0 9 * * 1', 'hello')
const calls = axiosPostMock.mock.calls as unknown as [
string,
Record<string, unknown>,
unknown,
][]
const body = calls[0]?.[1]
expect(body).toHaveProperty('timezone')
// Must NOT be the hardcoded 'UTC' string — must be a real timezone string
// In CI the system TZ may be UTC, but the field must still be present and a string.
expect(typeof body?.timezone).toBe('string')
expect((body?.timezone as string).length).toBeGreaterThan(0)
})
})
// ── M5 regression: withRetry must honor Retry-After header ──
describe('withRetry M5: honors Retry-After header on 5xx', () => {
test('waits at least Retry-After seconds before retrying on 5xx', async () => {
// First call: 503 with Retry-After: 0 (immediate, so test is fast)
// Second call: success
const serverErr = Object.assign(new Error('Service Unavailable'), {
isAxiosError: true,
response: { status: 503, data: {}, headers: { 'retry-after': '0' } },
})
axiosGetMock
.mockRejectedValueOnce(serverErr)
.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
const result = await listAgents()
// Should have retried and succeeded on second attempt
expect(result).toHaveLength(0)
expect(axiosGetMock).toHaveBeenCalledTimes(2)
})
})
// ── Regression: auth must use prepareWorkspaceApiRequest (not subscription OAuth) ──
describe('regression: uses prepareWorkspaceApiRequest for auth', () => {
test('listAgents calls prepareWorkspaceApiRequest to obtain workspace API key', async () => {
prepareWorkspaceApiRequestMock.mockClear()
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
await listAgents()
expect(prepareWorkspaceApiRequestMock).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 listAgents()
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 listAgents()
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 listAgents()
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('buildHeaders includes anthropic-beta header with managed-agents umbrella', async () => {
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
await listAgents()
const calls = axiosGetMock.mock.calls as unknown as [
string,
{ headers: Record<string, string> },
][]
const headers = calls[0]?.[1]?.headers ?? {}
expect(headers['anthropic-beta']).toContain('managed-agents')
})
test('throws 501 when ANTHROPIC_API_KEY is missing (all 3 retries fail)', async () => {
// withRetry retries 5xx errors (statusCode >= 500 including 501).
// buildHeaders throws AgentsApiError(msg, 501) for config errors.
// All 3 retry attempts must fail for the error to propagate.
const missingKeyError = new Error('ANTHROPIC_API_KEY is required')
prepareWorkspaceApiRequestMock
.mockRejectedValueOnce(missingKeyError)
.mockRejectedValueOnce(missingKeyError)
.mockRejectedValueOnce(missingKeyError)
await expect(listAgents()).rejects.toThrow(/ANTHROPIC_API_KEY|required/i)
}, 5000)
test('request goes to api.anthropic.com (host guard passes for correct host)', async () => {
// The real assertWorkspaceHost() runs and passes since BASE_API_URL is api.anthropic.com
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
await listAgents()
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('api.anthropic.com')
})
})

View File

@@ -0,0 +1,66 @@
/**
* Tests for agents-platform/index.ts — command metadata only.
* We verify load() resolves without error but do NOT mock launchAgentsPlatform,
* to avoid polluting other test files via Bun's process-level mock.module cache.
*/
import { beforeAll, describe, expect, mock, test } from 'bun:test'
mock.module('bun:bundle', () => ({
feature: (_name: string) => true,
}))
let cmd: {
load?: () => Promise<{ call: unknown }>
isEnabled?: () => boolean
name?: string
type?: string
aliases?: string[]
bridgeSafe?: boolean
availability?: string[]
}
beforeAll(async () => {
const mod = await import('../index.js')
cmd = mod.default as typeof cmd
})
describe('agentsPlatform index metadata', () => {
test('command name is agents-platform', () => {
expect(cmd.name).toBe('agents-platform')
})
test('command type is local-jsx', () => {
expect(cmd.type).toBe('local-jsx')
})
test('isEnabled returns true', () => {
expect(cmd.isEnabled?.()).toBe(true)
})
test('aliases includes agents and schedule-agent', () => {
expect(cmd.aliases).toContain('agents')
expect(cmd.aliases).toContain('schedule-agent')
})
test('bridgeSafe is false', () => {
expect(cmd.bridgeSafe).toBe(false)
})
test('availability includes claude-ai', () => {
expect(cmd.availability).toContain('claude-ai')
})
test('load() exists and is a function', () => {
expect(typeof cmd.load).toBe('function')
})
test('load() resolves to object with call function', async () => {
const loaded = await cmd.load!()
expect(typeof (loaded as { call?: unknown }).call).toBe('function')
})
test('isHidden is boolean (dynamic: false when ANTHROPIC_API_KEY set, true when absent)', () => {
// isHidden = !process.env['ANTHROPIC_API_KEY']
expect(typeof (cmd as { isHidden?: unknown }).isHidden).toBe('boolean')
})
})

View File

@@ -0,0 +1,262 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
import { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.js'
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
mock.module('bun:bundle', () => ({
feature: (_name: string) => true,
}))
// ── Analytics mock ──────────────────────────────────────────────────────────
const logEventMock = mock(() => {})
mock.module('src/services/analytics/index.js', () => ({
logEvent: logEventMock,
logEventAsync: mock(() => Promise.resolve()),
_resetForTesting: mock(() => {}),
attachAnalyticsSink: mock(() => {}),
stripProtoFields: mock((v: unknown) => v),
}))
// ── agentsApi mock ──────────────────────────────────────────────────────────
const listMock = mock(async () => [
{
id: 'agt_1',
cron_expr: '0 9 * * 1',
prompt: 'hello world',
status: 'active',
timezone: 'UTC',
next_run: null,
},
])
const createMock = mock(async (cron: string, prompt: string) => ({
id: 'agt_new',
cron_expr: cron,
prompt,
status: 'active',
timezone: 'UTC',
next_run: null,
}))
const deleteMock = mock(async () => undefined)
const runMock = mock(async () => ({ run_id: 'run_123' }))
mock.module('src/commands/agents-platform/agentsApi.js', () => ({
listAgents: listMock,
createAgent: createMock,
deleteAgent: deleteMock,
runAgent: runMock,
}))
// ── cron mock ───────────────────────────────────────────────────────────────
mock.module('src/utils/cron.js', () => ({
parseCronExpression: (expr: string) =>
expr.includes('INVALID')
? null
: { minute: [0], hour: [9], dayOfMonth: [1], month: [1], dayOfWeek: [1] },
cronToHuman: (expr: string) => `Human(${expr})`,
computeNextCronRun: () => null,
}))
let callAgentsPlatform: typeof import('../launchAgentsPlatform.js').callAgentsPlatform
beforeAll(async () => {
const mod = await import('../launchAgentsPlatform.js')
callAgentsPlatform = mod.callAgentsPlatform
})
beforeEach(() => {
logEventMock.mockClear()
listMock.mockClear()
createMock.mockClear()
deleteMock.mockClear()
runMock.mockClear()
})
function makeContext() {
return {} as Parameters<typeof callAgentsPlatform>[1]
}
describe('callAgentsPlatform', () => {
test('list (empty args) calls listAgents and returns element', async () => {
const onDone = mock(() => {})
const result = await callAgentsPlatform(onDone, makeContext(), '')
expect(listMock).toHaveBeenCalledTimes(1)
expect(onDone).toHaveBeenCalledTimes(1)
expect(result).not.toBeNull()
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_list',
expect.anything(),
)
})
test('list sub-command calls listAgents', async () => {
const onDone = mock(() => {})
await callAgentsPlatform(onDone, makeContext(), 'list')
expect(listMock).toHaveBeenCalledTimes(1)
})
test('create with valid cron calls createAgent', async () => {
const onDone = mock(() => {})
const result = await callAgentsPlatform(
onDone,
makeContext(),
'create 0 9 * * 1 Run standup',
)
expect(createMock).toHaveBeenCalledTimes(1)
const [cron, prompt] = createMock.mock.calls[0] as [string, string]
expect(cron).toBe('0 9 * * 1')
expect(prompt).toBe('Run standup')
expect(result).not.toBeNull()
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_create',
expect.anything(),
)
})
test('create with INVALID cron does not call API', async () => {
// parseCronExpression returns null for expressions containing 'INVALID'
const onDone = mock(() => {})
await callAgentsPlatform(
onDone,
makeContext(),
'create INVALID INVALID * * * my prompt',
)
// cron = 'INVALID INVALID * * *', mock returns null → no API call
expect(createMock).not.toHaveBeenCalled()
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_failed',
expect.anything(),
)
})
test('delete with id calls deleteAgent', async () => {
const onDone = mock(() => {})
const result = await callAgentsPlatform(
onDone,
makeContext(),
'delete agt_abc',
)
expect(deleteMock).toHaveBeenCalledWith('agt_abc')
expect(result).not.toBeNull()
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_delete',
expect.anything(),
)
})
test('run with id calls runAgent', async () => {
const onDone = mock(() => {})
const result = await callAgentsPlatform(
onDone,
makeContext(),
'run agt_xyz',
)
expect(runMock).toHaveBeenCalledWith('agt_xyz')
expect(result).not.toBeNull()
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_run',
expect.anything(),
)
})
test('invalid args logs failed and calls onDone', async () => {
const onDone = mock(() => {})
await callAgentsPlatform(onDone, makeContext(), 'unknown-cmd foo')
expect(onDone).toHaveBeenCalledTimes(1)
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_failed',
expect.anything(),
)
expect(listMock).not.toHaveBeenCalled()
})
test('listAgents API error → error view returned', async () => {
listMock.mockRejectedValueOnce(new Error('network error'))
const onDone = mock(() => {})
const result = await callAgentsPlatform(onDone, makeContext(), 'list')
expect(result).not.toBeNull()
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_failed',
expect.anything(),
)
})
test('started event fires on every call', async () => {
const onDone = mock(() => {})
await callAgentsPlatform(onDone, makeContext(), '')
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_started',
expect.anything(),
)
})
// ── Error-path branches (lines 77-86, 100-109, 128-136) ──────────────────
test('createAgent API error → error view returned', async () => {
createMock.mockRejectedValueOnce(new Error('subscription required'))
const onDone = mock(() => {})
const result = await callAgentsPlatform(
onDone,
makeContext(),
'create 0 9 * * 1 My prompt',
)
expect(result).not.toBeNull()
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_failed',
expect.anything(),
)
expect(onDone).toHaveBeenCalledWith(
expect.stringContaining('subscription required'),
expect.anything(),
)
})
test('deleteAgent API error → error view returned', async () => {
deleteMock.mockRejectedValueOnce(new Error('not found'))
const onDone = mock(() => {})
const result = await callAgentsPlatform(
onDone,
makeContext(),
'delete agt_abc',
)
expect(result).not.toBeNull()
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_failed',
expect.anything(),
)
expect(onDone).toHaveBeenCalledWith(
expect.stringContaining('not found'),
expect.anything(),
)
})
test('runAgent API error → error view returned', async () => {
runMock.mockRejectedValueOnce(new Error('run failed'))
const onDone = mock(() => {})
const result = await callAgentsPlatform(
onDone,
makeContext(),
'run agt_xyz',
)
expect(result).not.toBeNull()
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_failed',
expect.anything(),
)
expect(onDone).toHaveBeenCalledWith(
expect.stringContaining('run failed'),
expect.anything(),
)
})
test('create with no prompt part → invalid action', async () => {
const onDone = mock(() => {})
// Only 4 cron fields — parseArgs returns invalid
await callAgentsPlatform(onDone, makeContext(), 'create 0 9 * *')
expect(createMock).not.toHaveBeenCalled()
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_failed',
expect.anything(),
)
})
})

View File

@@ -0,0 +1,116 @@
import { describe, expect, test } from 'bun:test'
import { parseAgentsPlatformArgs, splitCronAndPrompt } from '../parseArgs.js'
describe('parseAgentsPlatformArgs', () => {
test('empty string returns list', () => {
const r = parseAgentsPlatformArgs('')
expect(r.action).toBe('list')
})
test('"list" returns list', () => {
const r = parseAgentsPlatformArgs('list')
expect(r.action).toBe('list')
})
test('whitespace-only returns list', () => {
const r = parseAgentsPlatformArgs(' ')
expect(r.action).toBe('list')
})
test('create with valid cron and prompt', () => {
const r = parseAgentsPlatformArgs('create 0 9 * * 1 Run daily standup')
expect(r.action).toBe('create')
if (r.action === 'create') {
expect(r.cron).toBe('0 9 * * 1')
expect(r.prompt).toBe('Run daily standup')
}
})
test('create with multi-word prompt', () => {
const r = parseAgentsPlatformArgs(
'create 30 8 * * * Check emails and summarize',
)
expect(r.action).toBe('create')
if (r.action === 'create') {
expect(r.cron).toBe('30 8 * * *')
expect(r.prompt).toBe('Check emails and summarize')
}
})
test('create with missing prompt is invalid', () => {
const r = parseAgentsPlatformArgs('create 0 9 * * 1')
expect(r.action).toBe('invalid')
if (r.action === 'invalid') {
expect(r.reason).toContain('5 cron fields')
}
})
test('create with no args is invalid', () => {
const r = parseAgentsPlatformArgs('create')
expect(r.action).toBe('invalid')
if (r.action === 'invalid') {
expect(r.reason).toContain('cron expression')
}
})
test('delete with id', () => {
const r = parseAgentsPlatformArgs('delete agt_abc123')
expect(r.action).toBe('delete')
if (r.action === 'delete') {
expect(r.id).toBe('agt_abc123')
}
})
test('delete without id is invalid', () => {
const r = parseAgentsPlatformArgs('delete')
expect(r.action).toBe('invalid')
if (r.action === 'invalid') {
expect(r.reason).toContain('agent id')
}
})
test('run with id', () => {
const r = parseAgentsPlatformArgs('run agt_xyz789')
expect(r.action).toBe('run')
if (r.action === 'run') {
expect(r.id).toBe('agt_xyz789')
}
})
test('run without id is invalid', () => {
const r = parseAgentsPlatformArgs('run')
expect(r.action).toBe('invalid')
if (r.action === 'invalid') {
expect(r.reason).toContain('agent id')
}
})
test('unknown sub-command is invalid', () => {
const r = parseAgentsPlatformArgs('foobar something')
expect(r.action).toBe('invalid')
if (r.action === 'invalid') {
expect(r.reason).toContain('Unknown sub-command')
}
})
})
describe('splitCronAndPrompt', () => {
test('splits 5-field cron from prompt', () => {
const r = splitCronAndPrompt('0 9 * * 1 My prompt here')
expect(r).not.toBeNull()
expect(r?.cron).toBe('0 9 * * 1')
expect(r?.prompt).toBe('My prompt here')
})
test('returns null if fewer than 6 tokens', () => {
expect(splitCronAndPrompt('0 9 * * 1')).toBeNull()
expect(splitCronAndPrompt('0 9 *')).toBeNull()
})
test('handles extra spaces in input', () => {
const r = splitCronAndPrompt(' 0 9 * * 1 hello world ')
expect(r).not.toBeNull()
expect(r?.cron).toBe('0 9 * * 1')
expect(r?.prompt).toBe('hello world')
})
})

View File

@@ -0,0 +1,206 @@
/**
* Thin HTTP client for the /v1/agents endpoint.
*
* Reuses the same base-URL + auth-header pattern as the rest of the codebase:
* getOauthConfig().BASE_API_URL → base
* getClaudeAIOAuthTokens()?.accessToken → Bearer token
* getOAuthHeaders(token) → Authorization + anthropic-version headers
* getOrganizationUUID() → x-organization-uuid header
*/
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 AgentTrigger = {
id: string
cron_expr: string
prompt: string
status: string
timezone: string
next_run?: string | null
created_at?: string
}
type ListAgentsResponse = {
data: AgentTrigger[]
}
type AgentRunResponse = {
run_id: string
}
// Server requires the managed-agents umbrella beta header.
const AGENTS_BETA_HEADER = 'managed-agents-2026-04-01'
const MAX_RETRIES = 3
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
class AgentsApiError extends Error {
constructor(
message: string,
public readonly statusCode: number,
) {
super(message)
this.name = 'AgentsApiError'
}
}
async function buildHeaders(): Promise<Record<string, string>> {
// /v1/agents requires a workspace-scoped API key (sk-ant-api03-*).
// Subscription OAuth bearer tokens always 401 here (server-enforced plane separation).
// 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 AgentsApiError(msg, 501)
}
assertWorkspaceHost(agentsBaseUrl())
return {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'anthropic-beta': AGENTS_BETA_HEADER,
'content-type': 'application/json',
}
}
function agentsBaseUrl(): string {
return `${getOauthConfig().BASE_API_URL}/v1/agents`
}
function classifyError(err: unknown): AgentsApiError {
if (axios.isAxiosError(err)) {
const status = err.response?.status ?? 0
if (status === 401) {
return new AgentsApiError(
'Authentication failed. Please run /login to re-authenticate.',
401,
)
}
if (status === 403) {
return new AgentsApiError(
'Subscription required. Scheduled agents require a Claude Pro/Max/Team subscription.',
403,
)
}
if (status === 404) {
return new AgentsApiError('Agent not found.', 404)
}
// G2: add 429 handler (was missing; other P2 clients have it)
if (status === 429) {
const retryAfter =
(err.response?.headers as Record<string, string> | undefined)?.[
'retry-after'
] ?? ''
const detail = retryAfter ? ` Retry after ${retryAfter}s.` : ''
return new AgentsApiError(`Rate limit exceeded.${detail}`, 429)
}
const msg =
(err.response?.data as { error?: { message?: string } } | undefined)
?.error?.message ?? err.message
return new AgentsApiError(msg, status)
}
if (err instanceof AgentsApiError) return err
return new AgentsApiError(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: AgentsApiError | 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) {
// Honor Retry-After if present; fall back to exponential backoff.
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 AgentsApiError('Request failed after retries', 0)
}
export async function listAgents(): Promise<AgentTrigger[]> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.get<ListAgentsResponse>(agentsBaseUrl(), {
headers,
})
return response.data.data ?? []
})
}
export async function createAgent(
cron: string,
prompt: string,
): Promise<AgentTrigger> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.post<AgentTrigger>(
agentsBaseUrl(),
{
cron_expr: cron,
prompt,
// Server-side agent execution always runs in UTC; the timezone field
// tells the server how to interpret the cron expression. We use the
// system timezone so that "9am every Monday" means 9am local time.
// Users can override via the --tz flag parsed in parseArgs.ts.
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC',
},
{ headers },
)
return response.data
})
}
export async function deleteAgent(id: string): Promise<void> {
return withRetry(async () => {
const headers = await buildHeaders()
await axios.delete(`${agentsBaseUrl()}/${id}`, { headers })
})
}
export async function runAgent(id: string): Promise<AgentRunResponse> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.post<AgentRunResponse>(
`${agentsBaseUrl()}/${id}/run`,
{},
{ headers },
)
return response.data
})
}

View File

@@ -1,5 +0,0 @@
export default {
name: 'agents-platform',
type: 'local',
isEnabled: () => false,
}

View File

@@ -0,0 +1,29 @@
import { getGlobalConfig } from '../../utils/config.js'
import type { Command } from '../../types/command.js'
// Visible when a workspace API key is available from env or saved settings.
// Use a getter so getGlobalConfig() is called lazily (after enableConfigs()
// has run in the entry path) instead of at module-load time, which races
// the config-system bootstrap and throws "Config accessed before allowed".
const agentsPlatform: Command = {
type: 'local-jsx',
name: 'agents-platform',
aliases: ['agents', 'schedule-agent'],
description: 'Manage scheduled remote agents (cron-style triggers)',
// REPL markdown renderer strips `<...>` as HTML tags — use uppercase.
argumentHint: 'list | create CRON PROMPT | delete ID | run ID',
get isHidden(): boolean {
return (
!process.env['ANTHROPIC_API_KEY'] && !getGlobalConfig().workspaceApiKey
)
},
isEnabled: () => true,
bridgeSafe: false,
availability: ['claude-ai'],
load: async () => {
const m = await import('./launchAgentsPlatform.js')
return { call: m.callAgentsPlatform }
},
}
export default agentsPlatform

View File

@@ -0,0 +1,132 @@
import React from 'react';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js';
import { parseCronExpression } from '../../utils/cron.js';
import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js';
import { createAgent, deleteAgent, listAgents, runAgent } from './agentsApi.js';
import { AgentsPlatformView } from './AgentsPlatformView.js';
import { parseAgentsPlatformArgs } from './parseArgs.js';
import { launchCommand } from '../_shared/launchCommand.js';
type AgentsPlatformViewProps = React.ComponentProps<typeof AgentsPlatformView>;
async function dispatchAgentsPlatform(
parsed: ReturnType<typeof parseAgentsPlatformArgs>,
onDone: LocalJSXCommandOnDone,
): Promise<AgentsPlatformViewProps | null> {
if (parsed.action === 'list') {
logEvent('tengu_agents_platform_list', {});
try {
const agents = await listAgents();
onDone(agents.length === 0 ? 'No scheduled agents found.' : `${agents.length} scheduled agent(s).`, {
display: 'system',
});
return { mode: 'list', agents };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_agents_platform_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to list agents: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'create') {
const { cron, prompt } = parsed;
// Validate cron expression client-side before hitting the network
const cronFields = parseCronExpression(cron);
if (!cronFields) {
const reason = `Invalid cron expression: "${cron}". Expected 5 fields (minute hour day month weekday).`;
logEvent('tengu_agents_platform_failed', {
reason: reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(reason, { display: 'system' });
return null;
}
logEvent('tengu_agents_platform_create', {
cron: cron as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const agent = await createAgent(cron, prompt);
onDone(`Agent created: ${agent.id}`, { display: 'system' });
return { mode: 'created', agent };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_agents_platform_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to create agent: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'delete') {
const { id } = parsed;
logEvent('tengu_agents_platform_delete', {
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
await deleteAgent(id);
onDone(`Agent ${id} deleted.`, { display: 'system' });
return { mode: 'deleted', id };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_agents_platform_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to delete agent ${id}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
// parsed.action === 'run' (all other actions handled above)
const runParsed = parsed as { action: 'run'; id: string };
const { id } = runParsed;
logEvent('tengu_agents_platform_run', {
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const result = await runAgent(id);
onDone(`Agent ${id} triggered. Run ID: ${result.run_id}`, { display: 'system' });
return { mode: 'ran', id, runId: result.run_id };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_agents_platform_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to run agent ${id}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
export const callAgentsPlatform: LocalJSXCommandCall = launchCommand<
ReturnType<typeof parseAgentsPlatformArgs>,
AgentsPlatformViewProps
>({
commandName: 'agents-platform',
parseArgs: (raw: string) => {
logEvent('tengu_agents_platform_started', {
args: raw as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
const result = parseAgentsPlatformArgs(raw);
if (result.action === 'invalid') {
logEvent('tengu_agents_platform_failed', {
reason: result.reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
return {
action: 'invalid' as const,
reason: `Usage: /agents-platform list | create CRON PROMPT | delete ID | run ID\n${result.reason}`,
};
}
return result;
},
dispatch: dispatchAgentsPlatform,
View: AgentsPlatformView,
// Invalid args returns null to match original behaviour (error already surfaced via onDone)
errorView: (_msg: string) => null,
});

View File

@@ -0,0 +1,102 @@
/**
* Parse the args string for the /agents-platform command.
*
* Supported sub-commands:
* list → { action: 'list' }
* create <cron-expr> <prompt> → { action: 'create', cron, prompt }
* delete <id> → { action: 'delete', id }
* run <id> → { action: 'run', id }
* (empty) → { action: 'list' }
* anything else → { action: 'invalid', reason }
*/
export type AgentsPlatformArgs =
| { action: 'list' }
| { action: 'create'; cron: string; prompt: string }
| { action: 'delete'; id: string }
| { action: 'run'; id: string }
| { action: 'invalid'; reason: string }
/**
* Cron expressions are 5 space-separated fields.
* This helper extracts the first 5 whitespace-separated tokens and joins them.
* The remainder of the string is the prompt.
* Returns null if fewer than 5 tokens are present.
*/
export function splitCronAndPrompt(
rest: string,
): { cron: string; prompt: string } | null {
const tokens = rest.trim().split(/\s+/)
if (tokens.length < 6) return null
const cron = tokens.slice(0, 5).join(' ')
const prompt = tokens.slice(5).join(' ')
return { cron, prompt }
}
export function parseAgentsPlatformArgs(args: string): AgentsPlatformArgs {
const trimmed = args.trim()
if (trimmed === '' || trimmed === 'list') {
return { action: 'list' }
}
// Extract first token as sub-command
const spaceIdx = trimmed.indexOf(' ')
const subCmd = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)
const rest = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim()
if (subCmd === 'create') {
if (!rest) {
return {
action: 'invalid',
reason:
'create requires a cron expression and prompt, e.g. create "0 9 * * 1" Run daily standup',
}
}
const parsed = splitCronAndPrompt(rest)
if (!parsed) {
return {
action: 'invalid',
reason:
'create requires at least 5 cron fields followed by a prompt, e.g. create "0 9 * * 1" Run daily standup',
}
}
const { cron, prompt } = parsed
// splitCronAndPrompt joins slice(5) so prompt is non-empty by construction;
// this guard is a defensive fallback against future refactors.
/* istanbul ignore next -- prompt is non-empty by construction from splitCronAndPrompt */
if (!prompt.trim()) {
return { action: 'invalid', reason: 'prompt cannot be empty' }
}
return { action: 'create', cron, prompt: prompt.trim() }
}
if (subCmd === 'delete') {
if (!rest) {
return { action: 'invalid', reason: 'delete requires an agent id' }
}
const id = rest.split(/\s+/)[0]
/* istanbul ignore next -- rest is non-empty; split(/\s+/) always yields a non-empty first token */
if (!id) {
return { action: 'invalid', reason: 'delete requires an agent id' }
}
return { action: 'delete', id }
}
if (subCmd === 'run') {
if (!rest) {
return { action: 'invalid', reason: 'run requires an agent id' }
}
const id = rest.split(/\s+/)[0]
/* istanbul ignore next -- rest is non-empty; split(/\s+/) always yields a non-empty first token */
if (!id) {
return { action: 'invalid', reason: 'run requires an agent id' }
}
return { action: 'run', id }
}
return {
action: 'invalid',
reason: `Unknown sub-command "${subCmd}". Use: list | create CRON PROMPT | delete ID | run ID`,
}
}

View File

@@ -0,0 +1,263 @@
import React from 'react';
import { Box, Text } from '@anthropic/ink';
import type { Theme } from '@anthropic/ink';
import type { Memory, MemoryStore, MemoryVersion } from './memoryStoresApi.js';
type Props =
| { mode: 'list'; stores: MemoryStore[] }
| { mode: 'detail'; store: MemoryStore }
| { mode: 'created'; store: MemoryStore }
| { mode: 'archived'; store: MemoryStore }
| { mode: 'memory-list'; storeId: string; memories: Memory[] }
| { mode: 'memory-detail'; memory: Memory }
| { mode: 'memory-created'; memory: Memory }
| { mode: 'memory-updated'; memory: Memory }
| { mode: 'memory-deleted'; storeId: string; memoryId: string }
| { mode: 'versions'; storeId: string; versions: MemoryVersion[] }
| { mode: 'redacted'; version: MemoryVersion }
| { mode: 'error'; message: string };
function StoreRow({ store }: { store: MemoryStore }): React.ReactNode {
const isArchived = !!store.archived_at;
const createdAt = store.created_at ? new Date(store.created_at).toLocaleString() : '—';
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text bold>{store.memory_store_id}</Text>
<Text dimColor> · </Text>
<Text color={(isArchived ? 'warning' : 'success') as keyof Theme}>{isArchived ? 'archived' : 'active'}</Text>
{store.namespace ? (
<>
<Text dimColor> · ns: </Text>
<Text>{store.namespace}</Text>
</>
) : null}
</Box>
<Text>Name: {store.name}</Text>
<Text dimColor>Created: {createdAt}</Text>
</Box>
);
}
export function MemoryStoresView(props: Props): React.ReactNode {
if (props.mode === 'list') {
if (props.stores.length === 0) {
return (
<Box>
<Text dimColor>No memory stores found. Use /memory-stores create &lt;name&gt; to create one.</Text>
</Box>
);
}
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Memory Stores ({props.stores.length})</Text>
</Box>
{props.stores.map(store => (
<StoreRow key={store.memory_store_id} store={store} />
))}
</Box>
);
}
if (props.mode === 'detail') {
const { store } = props;
const isArchived = !!store.archived_at;
const createdAt = store.created_at ? new Date(store.created_at).toLocaleString() : '—';
const archivedAt = store.archived_at ? new Date(store.archived_at).toLocaleString() : null;
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Memory Store: {store.memory_store_id}</Text>
</Box>
<Text>Name: {store.name}</Text>
{store.namespace ? <Text>Namespace: {store.namespace}</Text> : null}
<Text>
Status:{' '}
<Text color={(isArchived ? 'warning' : 'success') as keyof Theme}>{isArchived ? 'archived' : 'active'}</Text>
</Text>
<Text dimColor>Created: {createdAt}</Text>
{archivedAt ? <Text dimColor>Archived: {archivedAt}</Text> : null}
</Box>
);
}
if (props.mode === 'created') {
const { store } = props;
return (
<Box flexDirection="column">
<Box>
<Text bold color={'success' as keyof Theme}>
Memory store created
</Text>
</Box>
<Text>ID: {store.memory_store_id}</Text>
<Text>Name: {store.name}</Text>
{store.namespace ? <Text>Namespace: {store.namespace}</Text> : null}
</Box>
);
}
if (props.mode === 'archived') {
const { store } = props;
const archivedAt = store.archived_at ? new Date(store.archived_at).toLocaleString() : '—';
return (
<Box flexDirection="column">
<Box>
<Text bold color={'warning' as keyof Theme}>
Memory store archived
</Text>
</Box>
<Text>ID: {store.memory_store_id}</Text>
<Text dimColor>Archived at: {archivedAt}</Text>
</Box>
);
}
if (props.mode === 'memory-list') {
const { storeId, memories } = props;
if (memories.length === 0) {
return (
<Box>
<Text dimColor>
No memories in store {storeId}. Use /memory-stores create-memory {storeId} &lt;content&gt; to add one.
</Text>
</Box>
);
}
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>
Memories in {storeId} ({memories.length})
</Text>
</Box>
{memories.map(mem => (
<Box key={mem.memory_id} flexDirection="column" marginBottom={1}>
<Text bold>{mem.memory_id}</Text>
<Text dimColor>{mem.content.length > 80 ? `${mem.content.slice(0, 80)}` : mem.content}</Text>
</Box>
))}
</Box>
);
}
if (props.mode === 'memory-detail') {
const { memory } = props;
const createdAt = memory.created_at ? new Date(memory.created_at).toLocaleString() : '—';
const updatedAt = memory.updated_at ? new Date(memory.updated_at).toLocaleString() : '—';
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Memory: {memory.memory_id}</Text>
</Box>
<Text>Store: {memory.memory_store_id}</Text>
<Text>Content: {memory.content}</Text>
<Text dimColor>Created: {createdAt}</Text>
<Text dimColor>Updated: {updatedAt}</Text>
</Box>
);
}
if (props.mode === 'memory-created') {
const { memory } = props;
return (
<Box flexDirection="column">
<Box>
<Text bold color={'success' as keyof Theme}>
Memory created
</Text>
</Box>
<Text>ID: {memory.memory_id}</Text>
<Text>Store: {memory.memory_store_id}</Text>
<Text dimColor>Content: {memory.content}</Text>
</Box>
);
}
if (props.mode === 'memory-updated') {
const { memory } = props;
return (
<Box flexDirection="column">
<Box>
<Text bold color={'success' as keyof Theme}>
Memory updated
</Text>
</Box>
<Text>ID: {memory.memory_id}</Text>
<Text dimColor>Content: {memory.content}</Text>
</Box>
);
}
if (props.mode === 'memory-deleted') {
return (
<Box>
<Text color={'success' as keyof Theme}>
Memory {props.memoryId} deleted from store {props.storeId}.
</Text>
</Box>
);
}
if (props.mode === 'versions') {
const { storeId, versions } = props;
if (versions.length === 0) {
return (
<Box>
<Text dimColor>No memory versions found for store {storeId}.</Text>
</Box>
);
}
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>
Memory Versions in {storeId} ({versions.length})
</Text>
</Box>
{versions.map(ver => {
const createdAt = ver.created_at ? new Date(ver.created_at).toLocaleString() : '—';
const isRedacted = !!ver.redacted_at;
return (
<Box key={ver.version_id} flexDirection="column" marginBottom={1}>
<Box>
<Text bold>{ver.version_id}</Text>
{isRedacted ? (
<>
<Text dimColor> · </Text>
<Text color={'warning' as keyof Theme}>redacted</Text>
</>
) : null}
</Box>
<Text dimColor>Created: {createdAt}</Text>
</Box>
);
})}
</Box>
);
}
if (props.mode === 'redacted') {
const { version } = props;
const redactedAt = version.redacted_at ? new Date(version.redacted_at).toLocaleString() : '—';
return (
<Box flexDirection="column">
<Box>
<Text bold color={'warning' as keyof Theme}>
Version redacted
</Text>
</Box>
<Text>ID: {version.version_id}</Text>
<Text dimColor>Redacted at: {redactedAt}</Text>
</Box>
);
}
// error mode
return (
<Box>
<Text color={'error' as keyof Theme}>{props.message}</Text>
</Box>
);
}

View File

@@ -0,0 +1,586 @@
/**
* Regression tests for memoryStoresApi.ts
*
* Key invariants under test:
* - updateMemory MUST use PATCH, not POST (spec: PATCH /v1/memory_stores/{id}/memories)
* - archiveStore uses POST /v1/memory_stores/{id}/archive (not DELETE)
* - redactVersion uses POST /v1/memory_stores/{id}/memory_versions/{vid}/redact
* - All endpoints hit /v1/memory_stores (not /v1/code/triggers or /v1/agents)
* - 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-memory-stores-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 axiosPatchMock = 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.patch = axiosPatchMock
axiosHandle.stubs.delete = axiosDeleteMock
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
// ── Lazy import after mocks ─────────────────────────────────────────────────
let listStores: typeof import('../memoryStoresApi.js').listStores
let getStore: typeof import('../memoryStoresApi.js').getStore
let createStore: typeof import('../memoryStoresApi.js').createStore
let archiveStore: typeof import('../memoryStoresApi.js').archiveStore
let listMemories: typeof import('../memoryStoresApi.js').listMemories
let createMemory: typeof import('../memoryStoresApi.js').createMemory
let getMemory: typeof import('../memoryStoresApi.js').getMemory
let updateMemory: typeof import('../memoryStoresApi.js').updateMemory
let deleteMemory: typeof import('../memoryStoresApi.js').deleteMemory
let listVersions: typeof import('../memoryStoresApi.js').listVersions
let redactVersion: typeof import('../memoryStoresApi.js').redactVersion
beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../memoryStoresApi.js')
listStores = mod.listStores
getStore = mod.getStore
createStore = mod.createStore
archiveStore = mod.archiveStore
listMemories = mod.listMemories
createMemory = mod.createMemory
getMemory = mod.getMemory
updateMemory = mod.updateMemory
deleteMemory = mod.deleteMemory
listVersions = mod.listVersions
redactVersion = mod.redactVersion
})
afterAll(() => {
axiosHandle.useStubs = false
})
beforeEach(() => {
axiosGetMock.mockClear()
axiosPostMock.mockClear()
axiosPatchMock.mockClear()
axiosDeleteMock.mockClear()
prepareWorkspaceApiRequestMock.mockClear()
process.env['ANTHROPIC_API_KEY'] = mockApiKey
})
afterEach(() => {
delete process.env['ANTHROPIC_API_KEY']
})
// ── REGRESSION: updateMemory MUST use PATCH not POST ─────────────────────
describe('updateMemory regression: must use PATCH not POST', () => {
test('updateMemory calls PATCH /v1/memory_stores/{id}/memories/{mid} (not POST)', async () => {
const updated = {
memory_id: 'mem_upd',
memory_store_id: 'ms_1',
content: 'Updated content',
}
axiosPatchMock.mockResolvedValueOnce({ data: updated, status: 200 })
await updateMemory('ms_1', 'mem_upd', 'Updated content')
// PATCH must have been called
expect(axiosPatchMock).toHaveBeenCalledTimes(1)
// POST must NOT have been called for update
expect(axiosPostMock).not.toHaveBeenCalled()
// The URL must contain the store id, memories path, and memory id
const calls = axiosPatchMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
expect(url).toContain('ms_1')
expect(url).toContain('/memories/')
expect(url).toContain('mem_upd')
expect(url).toContain('/v1/memory_stores/')
})
})
// ── listStores ────────────────────────────────────────────────────────────
describe('listStores', () => {
test('returns stores on 200', async () => {
const stores = [
{
memory_store_id: 'ms_1',
name: 'My Store',
namespace: 'work',
created_at: '2026-01-01T00:00:00Z',
},
]
axiosGetMock.mockResolvedValueOnce({ data: { data: stores }, status: 200 })
const result = await listStores()
expect(result).toHaveLength(1)
expect(result[0]!.memory_store_id).toBe('ms_1')
expect(axiosGetMock).toHaveBeenCalledTimes(1)
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('/v1/memory_stores')
})
test('returns empty array on empty response', async () => {
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
const result = await listStores()
expect(result).toHaveLength(0)
})
test('throws 401 with friendly message', async () => {
const err = Object.assign(new Error('Unauthorized'), {
isAxiosError: true,
response: { status: 401, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listStores()).rejects.toThrow(/login|authenticate/i)
})
test('throws 403 with subscription message', async () => {
const err = Object.assign(new Error('Forbidden'), {
isAxiosError: true,
response: { status: 403, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listStores()).rejects.toThrow(/subscription|pro|max|team/i)
})
test('retries on 5xx and eventually throws', async () => {
const make5xx = () =>
Object.assign(new Error('Server Error'), {
isAxiosError: true,
response: { status: 500, data: {} },
})
axiosGetMock
.mockRejectedValueOnce(make5xx())
.mockRejectedValueOnce(make5xx())
.mockRejectedValueOnce(make5xx())
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listStores()).rejects.toThrow()
expect(axiosGetMock).toHaveBeenCalledTimes(3)
}, 15000)
test('honors Retry-After header on 5xx', async () => {
const serverErr = Object.assign(new Error('Service Unavailable'), {
isAxiosError: true,
response: { status: 503, data: {}, headers: { 'retry-after': '0' } },
})
axiosGetMock
.mockRejectedValueOnce(serverErr)
.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
const result = await listStores()
expect(result).toHaveLength(0)
expect(axiosGetMock).toHaveBeenCalledTimes(2)
})
})
// ── getStore ──────────────────────────────────────────────────────────────
describe('getStore', () => {
test('calls GET /v1/memory_stores/{id}', async () => {
const store = {
memory_store_id: 'ms_get',
name: 'Work Store',
namespace: 'work',
}
axiosGetMock.mockResolvedValueOnce({ data: store, status: 200 })
const result = await getStore('ms_get')
expect(result.memory_store_id).toBe('ms_get')
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('ms_get')
})
test('throws 404 with not found message', async () => {
const err = Object.assign(new Error('Not Found'), {
isAxiosError: true,
response: { status: 404, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(getStore('nonexistent')).rejects.toThrow(/not found/i)
})
})
// ── createStore ───────────────────────────────────────────────────────────
describe('createStore', () => {
test('sends POST /v1/memory_stores with name', async () => {
const store = {
memory_store_id: 'ms_new',
name: 'My New Store',
namespace: 'default',
}
axiosPostMock.mockResolvedValueOnce({ data: store, status: 201 })
const result = await createStore('My New Store')
expect(result.memory_store_id).toBe('ms_new')
const calls = axiosPostMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
const body = calls[0]?.[1] as Record<string, unknown>
expect(url).toContain('/v1/memory_stores')
expect(url).not.toContain('/v1/agents')
expect(body.name).toBe('My New Store')
})
})
// ── archiveStore ──────────────────────────────────────────────────────────
describe('archiveStore', () => {
test('calls POST /v1/memory_stores/{id}/archive (not DELETE)', async () => {
const store = {
memory_store_id: 'ms_arc',
name: 'Archived Store',
archived_at: '2026-01-01T00:00:00Z',
}
axiosPostMock.mockResolvedValueOnce({ data: store, status: 200 })
const result = await archiveStore('ms_arc')
expect(result.memory_store_id).toBe('ms_arc')
// POST must be called for archive
expect(axiosPostMock).toHaveBeenCalledTimes(1)
// DELETE must NOT be called
expect(axiosDeleteMock).not.toHaveBeenCalled()
const calls = axiosPostMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
expect(url).toContain('ms_arc')
expect(url).toContain('/archive')
})
})
// ── listMemories ──────────────────────────────────────────────────────────
describe('listMemories', () => {
test('calls GET /v1/memory_stores/{id}/memories', async () => {
const memories = [
{ memory_id: 'mem_1', memory_store_id: 'ms_1', content: 'Test memory' },
]
axiosGetMock.mockResolvedValueOnce({
data: { data: memories },
status: 200,
})
const result = await listMemories('ms_1')
expect(result).toHaveLength(1)
expect(result[0]!.memory_id).toBe('mem_1')
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('ms_1')
expect(calls[0]?.[0]).toContain('/memories')
})
test('throws 404 when store not found', async () => {
const err = Object.assign(new Error('Not Found'), {
isAxiosError: true,
response: { status: 404, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listMemories('nonexistent')).rejects.toThrow(/not found/i)
})
})
// ── createMemory ──────────────────────────────────────────────────────────
describe('createMemory', () => {
test('sends POST /v1/memory_stores/{id}/memories', async () => {
const memory = {
memory_id: 'mem_new',
memory_store_id: 'ms_1',
content: 'New memory content',
}
axiosPostMock.mockResolvedValueOnce({ data: memory, status: 201 })
const result = await createMemory('ms_1', 'New memory content')
expect(result.memory_id).toBe('mem_new')
const calls = axiosPostMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
const body = calls[0]?.[1] as Record<string, unknown>
expect(url).toContain('ms_1')
expect(url).toContain('/memories')
expect(body.content).toBe('New memory content')
})
})
// ── getMemory ─────────────────────────────────────────────────────────────
describe('getMemory', () => {
test('calls GET /v1/memory_stores/{id}/memories/{mid}', async () => {
const memory = {
memory_id: 'mem_get',
memory_store_id: 'ms_1',
content: 'Memory content',
}
axiosGetMock.mockResolvedValueOnce({ data: memory, status: 200 })
const result = await getMemory('ms_1', 'mem_get')
expect(result.memory_id).toBe('mem_get')
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('ms_1')
expect(calls[0]?.[0]).toContain('/memories/')
expect(calls[0]?.[0]).toContain('mem_get')
})
})
// ── deleteMemory ──────────────────────────────────────────────────────────
describe('deleteMemory', () => {
test('calls DELETE /v1/memory_stores/{id}/memories/{mid}', async () => {
axiosDeleteMock.mockResolvedValueOnce({ status: 204 })
await deleteMemory('ms_1', 'mem_del')
const calls = axiosDeleteMock.mock.calls as unknown as [string, unknown][]
const url = calls[0]?.[0] as string
expect(url).toContain('ms_1')
expect(url).toContain('/memories/')
expect(url).toContain('mem_del')
})
test('throws 401 when not authenticated', async () => {
const err = Object.assign(new Error('Unauthorized'), {
isAxiosError: true,
response: { status: 401, data: {} },
})
axiosDeleteMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(deleteMemory('ms_1', 'mem_x')).rejects.toThrow(
/login|authenticate/i,
)
})
})
// ── listVersions ──────────────────────────────────────────────────────────
describe('listVersions', () => {
test('calls GET /v1/memory_stores/{id}/memory_versions', async () => {
const versions = [
{
version_id: 'ver_1',
memory_store_id: 'ms_1',
created_at: '2026-01-01T00:00:00Z',
},
]
axiosGetMock.mockResolvedValueOnce({
data: { data: versions },
status: 200,
})
const result = await listVersions('ms_1')
expect(result).toHaveLength(1)
expect(result[0]!.version_id).toBe('ver_1')
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('ms_1')
expect(calls[0]?.[0]).toContain('/memory_versions')
})
})
// ── redactVersion ─────────────────────────────────────────────────────────
describe('redactVersion', () => {
test('calls POST /v1/memory_stores/{id}/memory_versions/{vid}/redact (not DELETE)', async () => {
const version = {
version_id: 'ver_red',
memory_store_id: 'ms_1',
redacted_at: '2026-01-01T00:00:00Z',
}
axiosPostMock.mockResolvedValueOnce({ data: version, status: 200 })
const result = await redactVersion('ms_1', 'ver_red')
expect(result.version_id).toBe('ver_red')
// POST must be called for redact
expect(axiosPostMock).toHaveBeenCalledTimes(1)
// DELETE must NOT be called
expect(axiosDeleteMock).not.toHaveBeenCalled()
const calls = axiosPostMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
expect(url).toContain('ms_1')
expect(url).toContain('/memory_versions/')
expect(url).toContain('ver_red')
expect(url).toContain('/redact')
})
test('throws 403 with subscription message', async () => {
const err = Object.assign(new Error('Forbidden'), {
isAxiosError: true,
response: { status: 403, data: {} },
})
axiosPostMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(redactVersion('ms_1', 'ver_x')).rejects.toThrow(
/subscription|pro|max|team/i,
)
})
})
// ── 429 rate-limit ────────────────────────────────────────────────────────
describe('429 rate-limit: not retried (non-5xx)', () => {
test('throws immediately on 429 without retry', async () => {
const err = Object.assign(new Error('Too Many Requests'), {
isAxiosError: true,
response: { status: 429, data: {}, headers: { 'retry-after': '60' } },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listStores()).rejects.toThrow()
// Must NOT have retried — 429 is not a 5xx
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 listStores()
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 listStores()
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 listStores()
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 listStores()
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 listStores()
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('api.anthropic.com')
})
})

View File

@@ -0,0 +1,69 @@
/**
* Tests for memory-stores/index.ts — command metadata only.
*/
import { beforeAll, describe, expect, mock, test } from 'bun:test'
mock.module('bun:bundle', () => ({
feature: (_name: string) => true,
}))
let cmd: {
load?: () => Promise<{ call: unknown }>
isEnabled?: () => boolean
name?: string
type?: string
aliases?: string[]
description?: string
bridgeSafe?: boolean
availability?: string[]
}
beforeAll(async () => {
const mod = await import('../index.js')
cmd = mod.default as typeof cmd
})
describe('memoryStoresCommand metadata', () => {
test('name is "memory-stores"', () => {
expect(cmd.name).toBe('memory-stores')
})
test('type is local-jsx', () => {
expect(cmd.type).toBe('local-jsx')
})
test('isEnabled returns true', () => {
expect(cmd.isEnabled?.()).toBe(true)
})
test('aliases include mem and mstore', () => {
expect(cmd.aliases).toContain('mem')
expect(cmd.aliases).toContain('mstore')
})
test('bridgeSafe is false', () => {
expect(cmd.bridgeSafe).toBe(false)
})
test('availability includes claude-ai', () => {
expect(cmd.availability).toContain('claude-ai')
})
test('description mentions memory', () => {
expect(cmd.description?.toLowerCase()).toMatch(/memory/)
})
test('load() exists and is a function', () => {
expect(typeof cmd.load).toBe('function')
})
test('load() resolves to object with call function', async () => {
const loaded = await cmd.load!()
expect(typeof (loaded as { call?: unknown }).call).toBe('function')
})
test('isHidden is boolean (dynamic: false when ANTHROPIC_API_KEY set, true when absent)', () => {
// isHidden = !process.env['ANTHROPIC_API_KEY']
expect(typeof (cmd as { isHidden?: unknown }).isHidden).toBe('boolean')
})
})

View File

@@ -0,0 +1,380 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
import { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.js'
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
// ── Analytics mock ──────────────────────────────────────────────────────────
const logEventMock = mock(() => {})
mock.module('src/services/analytics/index.js', () => ({
logEvent: logEventMock,
}))
// ── MemoryStoresView mock ───────────────────────────────────────────────────
const memoryStoresViewMock = mock((_props: unknown) => null)
mock.module('src/commands/memory-stores/MemoryStoresView.js', () => ({
MemoryStoresView: memoryStoresViewMock,
}))
// ── memoryStoresApi mock ──────────────────────────────────────────────────
const listStoresMock = mock(async () => [] as unknown)
const getStoreMock = mock(async () => ({}) as unknown)
const createStoreMock = mock(async () => ({}) as unknown)
const archiveStoreMock = mock(async () => ({}) as unknown)
const listMemoriesMock = mock(async () => [] as unknown)
const createMemoryMock = mock(async () => ({}) as unknown)
const getMemoryMock = mock(async () => ({}) as unknown)
const updateMemoryMock = mock(async () => ({}) as unknown)
const deleteMemoryMock = mock(async () => undefined)
const listVersionsMock = mock(async () => [] as unknown)
const redactVersionMock = mock(async () => ({}) as unknown)
mock.module('src/commands/memory-stores/memoryStoresApi.js', () => ({
listStores: listStoresMock,
getStore: getStoreMock,
createStore: createStoreMock,
archiveStore: archiveStoreMock,
listMemories: listMemoriesMock,
createMemory: createMemoryMock,
getMemory: getMemoryMock,
updateMemory: updateMemoryMock,
deleteMemory: deleteMemoryMock,
listVersions: listVersionsMock,
redactVersion: redactVersionMock,
}))
let callMemoryStores: typeof import('../launchMemoryStores.js').callMemoryStores
beforeAll(async () => {
const mod = await import('../launchMemoryStores.js')
callMemoryStores = mod.callMemoryStores
})
function makeOnDone() {
return mock(() => {})
}
beforeEach(() => {
logEventMock.mockClear()
listStoresMock.mockClear()
getStoreMock.mockClear()
createStoreMock.mockClear()
archiveStoreMock.mockClear()
listMemoriesMock.mockClear()
createMemoryMock.mockClear()
getMemoryMock.mockClear()
updateMemoryMock.mockClear()
deleteMemoryMock.mockClear()
listVersionsMock.mockClear()
redactVersionMock.mockClear()
memoryStoresViewMock.mockClear()
})
describe('callMemoryStores: invalid args', () => {
test('invalid subcommand → onDone with usage + null', async () => {
const onDone = makeOnDone()
const result = await callMemoryStores(onDone, {} as never, 'badcmd')
expect(result).toBeNull()
expect(onDone).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/Usage/i)
})
})
describe('callMemoryStores: list', () => {
test('list returns empty stores', async () => {
listStoresMock.mockResolvedValueOnce([])
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'list')
expect(listStoresMock).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/no memory stores/i)
})
test('list with stores reports count', async () => {
const stores = [
{ memory_store_id: 'ms_1', name: 'Work', namespace: 'work' },
]
listStoresMock.mockResolvedValueOnce(stores)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, '')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/1 memory store/)
})
test('list API error → error view', async () => {
listStoresMock.mockRejectedValueOnce(new Error('Network error'))
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'list')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to list memory stores/i)
})
})
describe('callMemoryStores: get', () => {
test('get calls getStore with id', async () => {
const store = { memory_store_id: 'ms_get', name: 'Work Store' }
getStoreMock.mockResolvedValueOnce(store)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'get ms_get')
expect(getStoreMock).toHaveBeenCalledTimes(1)
const calls = getStoreMock.mock.calls as unknown as [string][]
expect(calls[0]?.[0]).toBe('ms_get')
})
test('get API error → error message', async () => {
getStoreMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'get ms_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to get memory store/i)
})
})
describe('callMemoryStores: create', () => {
test('create calls createStore with name', async () => {
const store = { memory_store_id: 'ms_new', name: 'New Store' }
createStoreMock.mockResolvedValueOnce(store)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'create New Store')
expect(createStoreMock).toHaveBeenCalledTimes(1)
const calls = createStoreMock.mock.calls as unknown as [string][]
expect(calls[0]?.[0]).toBe('New Store')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/memory store created/i)
})
test('create API error → error message', async () => {
createStoreMock.mockRejectedValueOnce(new Error('Subscription required'))
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'create My Store')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to create memory store/i)
})
})
describe('callMemoryStores: archive', () => {
test('archive calls archiveStore with id', async () => {
const store = {
memory_store_id: 'ms_arc',
name: 'Old Store',
archived_at: '2026-01-01',
}
archiveStoreMock.mockResolvedValueOnce(store)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'archive ms_arc')
expect(archiveStoreMock).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/archived/i)
})
test('archive API error → error message', async () => {
archiveStoreMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'archive ms_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to archive memory store/i)
})
})
describe('callMemoryStores: memories', () => {
test('memories lists memories in store', async () => {
const memories = [
{ memory_id: 'mem_1', memory_store_id: 'ms_1', content: 'Test' },
]
listMemoriesMock.mockResolvedValueOnce(memories)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'memories ms_1')
expect(listMemoriesMock).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/1 memory/)
})
test('memories API error → error message', async () => {
listMemoriesMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'memories ms_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to list memories/i)
})
})
describe('callMemoryStores: create-memory', () => {
test('create-memory calls createMemory with storeId and content', async () => {
const memory = {
memory_id: 'mem_new',
memory_store_id: 'ms_1',
content: 'hello world',
}
createMemoryMock.mockResolvedValueOnce(memory)
const onDone = makeOnDone()
await callMemoryStores(
onDone,
{} as never,
'create-memory ms_1 hello world',
)
expect(createMemoryMock).toHaveBeenCalledTimes(1)
const calls = createMemoryMock.mock.calls as unknown as [string, string][]
expect(calls[0]?.[0]).toBe('ms_1')
expect(calls[0]?.[1]).toBe('hello world')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/memory created/i)
})
test('create-memory API error → error message', async () => {
createMemoryMock.mockRejectedValueOnce(new Error('Forbidden'))
const onDone = makeOnDone()
await callMemoryStores(
onDone,
{} as never,
'create-memory ms_1 test content',
)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to create memory/i)
})
})
describe('callMemoryStores: get-memory', () => {
test('get-memory calls getMemory', async () => {
const memory = {
memory_id: 'mem_get',
memory_store_id: 'ms_1',
content: 'Test',
}
getMemoryMock.mockResolvedValueOnce(memory)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'get-memory ms_1 mem_get')
expect(getMemoryMock).toHaveBeenCalledTimes(1)
const calls = getMemoryMock.mock.calls as unknown as [string, string][]
expect(calls[0]?.[0]).toBe('ms_1')
expect(calls[0]?.[1]).toBe('mem_get')
})
test('get-memory API error → error message', async () => {
getMemoryMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'get-memory ms_1 mem_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to get memory/i)
})
})
describe('callMemoryStores: update-memory', () => {
test('update-memory calls updateMemory with storeId, memoryId, and content', async () => {
const memory = {
memory_id: 'mem_upd',
memory_store_id: 'ms_1',
content: 'new content',
}
updateMemoryMock.mockResolvedValueOnce(memory)
const onDone = makeOnDone()
await callMemoryStores(
onDone,
{} as never,
'update-memory ms_1 mem_upd new content',
)
expect(updateMemoryMock).toHaveBeenCalledTimes(1)
const calls = updateMemoryMock.mock.calls as unknown as [
string,
string,
string,
][]
expect(calls[0]?.[0]).toBe('ms_1')
expect(calls[0]?.[1]).toBe('mem_upd')
expect(calls[0]?.[2]).toBe('new content')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/updated/i)
})
test('update-memory API error → error message', async () => {
updateMemoryMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callMemoryStores(
onDone,
{} as never,
'update-memory ms_1 mem_missing new content',
)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to update memory/i)
})
})
describe('callMemoryStores: delete-memory', () => {
test('delete-memory calls deleteMemory', async () => {
deleteMemoryMock.mockResolvedValueOnce(undefined)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'delete-memory ms_1 mem_del')
expect(deleteMemoryMock).toHaveBeenCalledTimes(1)
const calls = deleteMemoryMock.mock.calls as unknown as [string, string][]
expect(calls[0]?.[0]).toBe('ms_1')
expect(calls[0]?.[1]).toBe('mem_del')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/deleted/i)
})
test('delete-memory API error → error message', async () => {
deleteMemoryMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callMemoryStores(
onDone,
{} as never,
'delete-memory ms_1 mem_missing',
)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to delete memory/i)
})
})
describe('callMemoryStores: versions', () => {
test('versions lists memory versions', async () => {
const versions = [
{
version_id: 'ver_1',
memory_store_id: 'ms_1',
created_at: '2026-01-01',
},
]
listVersionsMock.mockResolvedValueOnce(versions)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'versions ms_1')
expect(listVersionsMock).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/1 version/)
})
test('versions API error → error message', async () => {
listVersionsMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'versions ms_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to list versions/i)
})
})
describe('callMemoryStores: redact', () => {
test('redact calls redactVersion with storeId and versionId', async () => {
const version = {
version_id: 'ver_red',
memory_store_id: 'ms_1',
redacted_at: '2026-01-01',
}
redactVersionMock.mockResolvedValueOnce(version)
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'redact ms_1 ver_red')
expect(redactVersionMock).toHaveBeenCalledTimes(1)
const calls = redactVersionMock.mock.calls as unknown as [string, string][]
expect(calls[0]?.[0]).toBe('ms_1')
expect(calls[0]?.[1]).toBe('ver_red')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/redacted/i)
})
test('redact API error → error message', async () => {
redactVersionMock.mockRejectedValueOnce(new Error('Forbidden'))
const onDone = makeOnDone()
await callMemoryStores(onDone, {} as never, 'redact ms_1 ver_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to redact version/i)
})
})

View File

@@ -0,0 +1,190 @@
/**
* Unit tests for parseMemoryStoresArgs
*/
import { describe, expect, test } from 'bun:test'
import { parseMemoryStoresArgs } from '../parseArgs.js'
describe('parseMemoryStoresArgs: list', () => {
test('empty string → list', () => {
expect(parseMemoryStoresArgs('')).toEqual({ action: 'list' })
})
test('"list" → list', () => {
expect(parseMemoryStoresArgs('list')).toEqual({ action: 'list' })
})
test('whitespace-only → list', () => {
expect(parseMemoryStoresArgs(' ')).toEqual({ action: 'list' })
})
})
describe('parseMemoryStoresArgs: get', () => {
test('get ms_123 → { action: get, id: ms_123 }', () => {
expect(parseMemoryStoresArgs('get ms_123')).toEqual({
action: 'get',
id: 'ms_123',
})
})
test('get without id → invalid', () => {
const result = parseMemoryStoresArgs('get')
expect(result.action).toBe('invalid')
if (result.action === 'invalid') {
expect(result.reason).toMatch(/store id/i)
}
})
})
describe('parseMemoryStoresArgs: create', () => {
test('create "My Store" → { action: create, name }', () => {
const result = parseMemoryStoresArgs('create My Work Store')
expect(result).toEqual({ action: 'create', name: 'My Work Store' })
})
test('create without name → invalid', () => {
const result = parseMemoryStoresArgs('create')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: archive', () => {
test('archive ms_123 → { action: archive, id: ms_123 }', () => {
expect(parseMemoryStoresArgs('archive ms_123')).toEqual({
action: 'archive',
id: 'ms_123',
})
})
test('archive without id → invalid', () => {
const result = parseMemoryStoresArgs('archive')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: memories', () => {
test('memories ms_123 → { action: memories, storeId: ms_123 }', () => {
expect(parseMemoryStoresArgs('memories ms_123')).toEqual({
action: 'memories',
storeId: 'ms_123',
})
})
test('memories without storeId → invalid', () => {
const result = parseMemoryStoresArgs('memories')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: create-memory', () => {
test('create-memory ms_123 hello world → { action: create-memory, storeId, content }', () => {
const result = parseMemoryStoresArgs('create-memory ms_123 hello world')
expect(result).toEqual({
action: 'create-memory',
storeId: 'ms_123',
content: 'hello world',
})
})
test('create-memory without content → invalid', () => {
const result = parseMemoryStoresArgs('create-memory ms_123')
expect(result.action).toBe('invalid')
})
test('create-memory without args → invalid', () => {
const result = parseMemoryStoresArgs('create-memory')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: get-memory', () => {
test('get-memory ms_123 mem_456 → { action: get-memory, storeId, memoryId }', () => {
const result = parseMemoryStoresArgs('get-memory ms_123 mem_456')
expect(result).toEqual({
action: 'get-memory',
storeId: 'ms_123',
memoryId: 'mem_456',
})
})
test('get-memory with only store id → invalid', () => {
const result = parseMemoryStoresArgs('get-memory ms_123')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: update-memory', () => {
test('update-memory ms_123 mem_456 new content → { action: update-memory, storeId, memoryId, content }', () => {
const result = parseMemoryStoresArgs(
'update-memory ms_123 mem_456 new content',
)
expect(result).toEqual({
action: 'update-memory',
storeId: 'ms_123',
memoryId: 'mem_456',
content: 'new content',
})
})
test('update-memory without content → invalid', () => {
const result = parseMemoryStoresArgs('update-memory ms_123 mem_456')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: delete-memory', () => {
test('delete-memory ms_123 mem_456 → { action: delete-memory, storeId, memoryId }', () => {
const result = parseMemoryStoresArgs('delete-memory ms_123 mem_456')
expect(result).toEqual({
action: 'delete-memory',
storeId: 'ms_123',
memoryId: 'mem_456',
})
})
test('delete-memory with only store id → invalid', () => {
const result = parseMemoryStoresArgs('delete-memory ms_123')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: versions', () => {
test('versions ms_123 → { action: versions, storeId: ms_123 }', () => {
expect(parseMemoryStoresArgs('versions ms_123')).toEqual({
action: 'versions',
storeId: 'ms_123',
})
})
test('versions without storeId → invalid', () => {
const result = parseMemoryStoresArgs('versions')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: redact', () => {
test('redact ms_123 ver_456 → { action: redact, storeId, versionId }', () => {
const result = parseMemoryStoresArgs('redact ms_123 ver_456')
expect(result).toEqual({
action: 'redact',
storeId: 'ms_123',
versionId: 'ver_456',
})
})
test('redact with only store id → invalid', () => {
const result = parseMemoryStoresArgs('redact ms_123')
expect(result.action).toBe('invalid')
})
})
describe('parseMemoryStoresArgs: unknown sub-command', () => {
test('unknown subcommand → invalid with reason', () => {
const result = parseMemoryStoresArgs('foobar')
expect(result.action).toBe('invalid')
if (result.action === 'invalid') {
expect(result.reason).toMatch(/unknown sub-command/i)
expect(result.reason).toContain('foobar')
}
})
})

View File

@@ -0,0 +1,30 @@
import { getGlobalConfig } from '../../utils/config.js'
import type { Command } from '../../types/command.js'
const memoryStoresCommand: Command = {
type: 'local-jsx',
name: 'memory-stores',
aliases: ['mem', 'mstore'],
description:
'Manage remote memory stores (cross-device memory persistence). Requires Claude Pro/Max/Team subscription.',
// REPL markdown renderer strips `<...>` as HTML tags — use uppercase.
argumentHint:
'list | get ID | create NAME | archive ID | memories STORE_ID | create-memory STORE_ID CONTENT | get-memory STORE_ID MEMORY_ID | update-memory STORE_ID MEMORY_ID CONTENT | delete-memory STORE_ID MEMORY_ID | versions STORE_ID | redact STORE_ID VERSION_ID',
// 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('./launchMemoryStores.js')
return { call: m.callMemoryStores }
},
}
export default memoryStoresCommand

View File

@@ -0,0 +1,279 @@
import React from 'react';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js';
import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js';
import {
archiveStore,
createMemory,
createStore,
deleteMemory,
getMemory,
getStore,
listMemories,
listStores,
listVersions,
redactVersion,
updateMemory,
} from './memoryStoresApi.js';
import { MemoryStoresView } from './MemoryStoresView.js';
import { parseMemoryStoresArgs } from './parseArgs.js';
import { launchCommand } from '../_shared/launchCommand.js';
type MemoryStoresViewProps = React.ComponentProps<typeof MemoryStoresView>;
async function dispatchMemoryStores(
parsed: ReturnType<typeof parseMemoryStoresArgs>,
onDone: LocalJSXCommandOnDone,
): Promise<MemoryStoresViewProps | null> {
if (parsed.action === 'list') {
logEvent('tengu_memory_stores_list', {});
try {
const stores = await listStores();
onDone(stores.length === 0 ? 'No memory stores found.' : `${stores.length} memory store(s).`, {
display: 'system',
});
return { mode: 'list', stores };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to list memory stores: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'get') {
const { id } = parsed;
logEvent('tengu_memory_stores_get', {
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const store = await getStore(id);
onDone(`Memory store ${id} fetched.`, { display: 'system' });
return { mode: 'detail', store };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to get memory store ${id}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'create') {
const { name } = parsed;
logEvent('tengu_memory_stores_create', {
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const store = await createStore(name);
onDone(`Memory store created: ${store.memory_store_id}`, { display: 'system' });
return { mode: 'created', store };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to create memory store: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'archive') {
const { id } = parsed;
logEvent('tengu_memory_stores_archive', {
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const store = await archiveStore(id);
onDone(`Memory store ${id} archived.`, { display: 'system' });
return { mode: 'archived', store };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to archive memory store ${id}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'memories') {
const { storeId } = parsed;
logEvent('tengu_memory_stores_list_memories', {
storeId: storeId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const memories = await listMemories(storeId);
onDone(
memories.length === 0
? `No memories in store ${storeId}.`
: `${memories.length} memory(ies) in store ${storeId}.`,
{ display: 'system' },
);
return { mode: 'memory-list', storeId, memories };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to list memories in store ${storeId}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'create-memory') {
const { storeId, content } = parsed;
logEvent('tengu_memory_stores_create_memory', {
storeId: storeId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const memory = await createMemory(storeId, content);
onDone(`Memory created: ${memory.memory_id}`, { display: 'system' });
return { mode: 'memory-created', memory };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to create memory in store ${storeId}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'get-memory') {
const { storeId, memoryId } = parsed;
logEvent('tengu_memory_stores_get_memory', {
storeId: storeId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const memory = await getMemory(storeId, memoryId);
onDone(`Memory ${memoryId} fetched.`, { display: 'system' });
return { mode: 'memory-detail', memory };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to get memory ${memoryId}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'update-memory') {
const { storeId, memoryId, content } = parsed;
logEvent('tengu_memory_stores_update_memory', {
storeId: storeId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const memory = await updateMemory(storeId, memoryId, content);
onDone(`Memory ${memoryId} updated.`, { display: 'system' });
return { mode: 'memory-updated', memory };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to update memory ${memoryId}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'delete-memory') {
const { storeId, memoryId } = parsed;
logEvent('tengu_memory_stores_delete_memory', {
storeId: storeId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
await deleteMemory(storeId, memoryId);
onDone(`Memory ${memoryId} deleted.`, { display: 'system' });
return { mode: 'memory-deleted', storeId, memoryId };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to delete memory ${memoryId}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
if (parsed.action === 'versions') {
const { storeId } = parsed;
logEvent('tengu_memory_stores_versions', {
storeId: storeId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const versions = await listVersions(storeId);
onDone(
versions.length === 0
? `No memory versions found for store ${storeId}.`
: `${versions.length} version(s) in store ${storeId}.`,
{ display: 'system' },
);
return { mode: 'versions', storeId, versions };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to list versions for store ${storeId}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
// parsed.action === 'redact' (all other actions handled above)
const redactParsed = parsed as { action: 'redact'; storeId: string; versionId: string };
const { storeId, versionId } = redactParsed;
logEvent('tengu_memory_stores_redact', {
storeId: storeId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const version = await redactVersion(storeId, versionId);
onDone(`Version ${versionId} redacted.`, { display: 'system' });
return { mode: 'redacted', version };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_memory_stores_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to redact version ${versionId}: ${msg}`, { display: 'system' });
return { mode: 'error', message: msg };
}
}
const USAGE_MS =
'Usage: /memory-stores list | get ID | create NAME | archive ID | memories STORE_ID | create-memory STORE_ID CONTENT | get-memory STORE_ID MEMORY_ID | update-memory STORE_ID MEMORY_ID CONTENT | delete-memory STORE_ID MEMORY_ID | versions STORE_ID | redact STORE_ID VERSION_ID';
export const callMemoryStores: LocalJSXCommandCall = launchCommand<
ReturnType<typeof parseMemoryStoresArgs>,
MemoryStoresViewProps
>({
commandName: 'memory-stores',
parseArgs: (raw: string) => {
logEvent('tengu_memory_stores_started', {
args: raw as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
const result = parseMemoryStoresArgs(raw);
if (result.action === 'invalid') {
logEvent('tengu_memory_stores_failed', {
reason: result.reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
return {
action: 'invalid' as const,
reason: `${USAGE_MS}\n${result.reason}`,
};
}
return result;
},
dispatch: dispatchMemoryStores,
View: MemoryStoresView,
// The invalid-args path returns null (matching original behaviour) since the
// error reason is already surfaced via onDone. The dispatch-error path
// renders an error view with the thrown message.
errorView: (_msg: string) => null,
});

View File

@@ -0,0 +1,377 @@
/**
* Thin HTTP client for the /v1/memory_stores endpoint.
*
* Key spec facts (from binary reverse-engineering of v2.1.123):
* - list stores: GET /v1/memory_stores
* - create store: POST /v1/memory_stores
* - get store: GET /v1/memory_stores/{id}
* - archive store: POST /v1/memory_stores/{id}/archive ← POST not DELETE
* - list memories: GET /v1/memory_stores/{id}/memories
* - create memory: POST /v1/memory_stores/{id}/memories
* - get memory: GET /v1/memory_stores/{id}/memories/{mid}
* - update memory: PATCH /v1/memory_stores/{id}/memories/{mid} ← PATCH not POST
* - delete memory: DELETE /v1/memory_stores/{id}/memories/{mid}
* - list versions: GET /v1/memory_stores/{id}/memory_versions
* - redact version: POST /v1/memory_stores/{id}/memory_versions/{vid}/redact
*
* CRITICAL INVARIANT: updateMemory uses PATCH (not POST).
* Binary evidence: "PATCH /v1/memory_stores/{memory_store_id}/memories"
*
* Reuses the same base-URL + auth-header pattern as triggersApi.ts / agentsApi.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 MemoryStore = {
memory_store_id: string
name: string
namespace?: string
archived_at?: string | null
created_at?: string
}
export type Memory = {
memory_id: string
memory_store_id: string
content: string
created_at?: string
updated_at?: string
}
export type MemoryVersion = {
version_id: string
memory_store_id: string
created_at?: string
redacted_at?: string | null
}
export type CreateStoreBody = {
name: string
namespace?: string
}
export type CreateMemoryBody = {
content: string
}
export type UpdateMemoryBody = {
content: string
}
type ListStoresResponse = {
data: MemoryStore[]
}
type ListMemoriesResponse = {
data: Memory[]
}
type ListVersionsResponse = {
data: MemoryVersion[]
}
// Server requires this exact beta header — confirmed from runtime error
// "this API is in beta: add `managed-agents-2026-04-01`". Memory stores share
// the managed-agents beta umbrella with /v1/agents and /v1/code/triggers.
const MEMORY_STORES_BETA_HEADER = 'managed-agents-2026-04-01'
const MAX_RETRIES = 3
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
class MemoryStoresApiError extends Error {
constructor(
message: string,
public readonly statusCode: number,
) {
super(message)
this.name = 'MemoryStoresApiError'
}
}
async function buildHeaders(): Promise<Record<string, string>> {
// /v1/memory_stores requires a workspace-scoped API key (sk-ant-api03-*).
// Server explicitly returns: "memory stores require a workspace-scoped API key or session"
// (probed 2026-05-03). Subscription OAuth bearer tokens always 401 here.
// 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 MemoryStoresApiError(msg, 501)
}
assertWorkspaceHost(memoryStoresBaseUrl())
return {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'anthropic-beta': MEMORY_STORES_BETA_HEADER,
'content-type': 'application/json',
}
}
function memoryStoresBaseUrl(): string {
return `${getOauthConfig().BASE_API_URL}/v1/memory_stores`
}
function classifyError(err: unknown): MemoryStoresApiError {
if (axios.isAxiosError(err)) {
const status = err.response?.status ?? 0
if (status === 401) {
return new MemoryStoresApiError(
'Authentication failed. Please run /login to re-authenticate.',
401,
)
}
if (status === 403) {
return new MemoryStoresApiError(
'Subscription required. Memory stores require a Claude Pro/Max/Team subscription.',
403,
)
}
if (status === 404) {
return new MemoryStoresApiError('Memory store or memory 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 MemoryStoresApiError(`Rate limit exceeded.${detail}`, 429)
}
const msg =
(err.response?.data as { error?: { message?: string } } | undefined)
?.error?.message ?? err.message
return new MemoryStoresApiError(msg, status)
}
if (err instanceof MemoryStoresApiError) return err
return new MemoryStoresApiError(
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: MemoryStoresApiError | 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 MemoryStoresApiError('Request failed after retries', 0)
}
// ── Store CRUD ─────────────────────────────────────────────────────────────
export async function listStores(): Promise<MemoryStore[]> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.get<ListStoresResponse>(
memoryStoresBaseUrl(),
{
headers,
},
)
return response.data.data ?? []
})
}
export async function createStore(
name: string,
namespace?: string,
): Promise<MemoryStore> {
return withRetry(async () => {
const headers = await buildHeaders()
const body: CreateStoreBody = { name }
if (namespace) body.namespace = namespace
const response = await axios.post<MemoryStore>(
memoryStoresBaseUrl(),
body,
{
headers,
},
)
return response.data
})
}
export async function getStore(id: string): Promise<MemoryStore> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.get<MemoryStore>(
`${memoryStoresBaseUrl()}/${id}`,
{ headers },
)
return response.data
})
}
/**
* Archive a memory store (soft delete).
*
* IMPORTANT: The upstream API uses POST (not DELETE) for archiving.
* Binary literal evidence: "POST /v1/memory_stores/{memory_store_id}/archive"
*/
export async function archiveStore(id: string): Promise<MemoryStore> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.post<MemoryStore>(
`${memoryStoresBaseUrl()}/${id}/archive`,
{},
{ headers },
)
return response.data
})
}
// ── Memory CRUD ────────────────────────────────────────────────────────────
export async function listMemories(storeId: string): Promise<Memory[]> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.get<ListMemoriesResponse>(
`${memoryStoresBaseUrl()}/${storeId}/memories`,
{ headers },
)
return response.data.data ?? []
})
}
export async function createMemory(
storeId: string,
content: string,
): Promise<Memory> {
return withRetry(async () => {
const headers = await buildHeaders()
const body: CreateMemoryBody = { content }
const response = await axios.post<Memory>(
`${memoryStoresBaseUrl()}/${storeId}/memories`,
body,
{ headers },
)
return response.data
})
}
export async function getMemory(
storeId: string,
memoryId: string,
): Promise<Memory> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.get<Memory>(
`${memoryStoresBaseUrl()}/${storeId}/memories/${memoryId}`,
{ headers },
)
return response.data
})
}
/**
* Update a memory's content.
*
* CRITICAL INVARIANT: This endpoint uses PATCH (not POST/PUT).
* Binary literal evidence: "PATCH /v1/memory_stores/{memory_store_id}/memories"
* Test name: "updateMemory calls PATCH /v1/memory_stores/{id}/memories/{mid} (not POST)"
*/
export async function updateMemory(
storeId: string,
memoryId: string,
content: string,
): Promise<Memory> {
return withRetry(async () => {
const headers = await buildHeaders()
const body: UpdateMemoryBody = { content }
const response = await axios.patch<Memory>(
`${memoryStoresBaseUrl()}/${storeId}/memories/${memoryId}`,
body,
{ headers },
)
return response.data
})
}
export async function deleteMemory(
storeId: string,
memoryId: string,
): Promise<void> {
return withRetry(async () => {
const headers = await buildHeaders()
await axios.delete(
`${memoryStoresBaseUrl()}/${storeId}/memories/${memoryId}`,
{ headers },
)
})
}
// ── Versions ───────────────────────────────────────────────────────────────
export async function listVersions(storeId: string): Promise<MemoryVersion[]> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.get<ListVersionsResponse>(
`${memoryStoresBaseUrl()}/${storeId}/memory_versions`,
{ headers },
)
return response.data.data ?? []
})
}
/**
* Redact a memory version (PII removal).
*
* IMPORTANT: Uses POST (not DELETE) for redaction.
* Binary literal evidence: "POST /v1/memory_stores/{id}/memory_versions/{vid}/redact"
*/
export async function redactVersion(
storeId: string,
versionId: string,
): Promise<MemoryVersion> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.post<MemoryVersion>(
`${memoryStoresBaseUrl()}/${storeId}/memory_versions/${versionId}/redact`,
{},
{ headers },
)
return response.data
})
}

View File

@@ -0,0 +1,207 @@
/**
* Parse the args string for the /memory-stores command.
*
* Supported sub-commands:
* list → { action: 'list' }
* get <id> → { action: 'get', id }
* create <name> → { action: 'create', name }
* archive <id> → { action: 'archive', id }
* memories <store_id> → { action: 'memories', storeId }
* create-memory <store_id> <content> → { action: 'create-memory', storeId, content }
* get-memory <store_id> <memory_id> → { action: 'get-memory', storeId, memoryId }
* update-memory <store_id> <memory_id> <content> → { action: 'update-memory', storeId, memoryId, content }
* delete-memory <store_id> <memory_id> → { action: 'delete-memory', storeId, memoryId }
* versions <store_id> → { action: 'versions', storeId }
* redact <store_id> <version_id> → { action: 'redact', storeId, versionId }
* (empty) → { action: 'list' }
* anything else → { action: 'invalid', reason }
*/
export type MemoryStoresArgs =
| { action: 'list' }
| { action: 'get'; id: string }
| { action: 'create'; name: string }
| { action: 'archive'; id: string }
| { action: 'memories'; storeId: string }
| { action: 'create-memory'; storeId: string; content: string }
| { action: 'get-memory'; storeId: string; memoryId: string }
| {
action: 'update-memory'
storeId: string
memoryId: string
content: string
}
| { action: 'delete-memory'; storeId: string; memoryId: string }
| { action: 'versions'; storeId: string }
| { action: 'redact'; storeId: string; versionId: string }
| { action: 'invalid'; reason: string }
const USAGE =
'Usage: /memory-stores list | get ID | create NAME | archive ID | memories STORE_ID | create-memory STORE_ID CONTENT | get-memory STORE_ID MEMORY_ID | update-memory STORE_ID MEMORY_ID CONTENT | delete-memory STORE_ID MEMORY_ID | versions STORE_ID | redact STORE_ID VERSION_ID'
export function parseMemoryStoresArgs(args: string): MemoryStoresArgs {
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 store id' }
}
const id = rest.split(/\s+/)[0]
/* istanbul ignore next */
if (!id) {
return { action: 'invalid', reason: 'get requires a store id' }
}
return { action: 'get', id }
}
// ── create ────────────────────────────────────────────────────────────────
if (subCmd === 'create') {
if (!rest) {
return {
action: 'invalid',
reason: 'create requires a store name, e.g. create "My Work Store"',
}
}
return { action: 'create', name: rest }
}
// ── archive ───────────────────────────────────────────────────────────────
if (subCmd === 'archive') {
if (!rest) {
return { action: 'invalid', reason: 'archive requires a store id' }
}
const id = rest.split(/\s+/)[0]
/* istanbul ignore next */
if (!id) {
return { action: 'invalid', reason: 'archive requires a store id' }
}
return { action: 'archive', id }
}
// ── memories ──────────────────────────────────────────────────────────────
if (subCmd === 'memories') {
if (!rest) {
return { action: 'invalid', reason: 'memories requires a store id' }
}
const storeId = rest.split(/\s+/)[0]
/* istanbul ignore next */
if (!storeId) {
return { action: 'invalid', reason: 'memories requires a store id' }
}
return { action: 'memories', storeId }
}
// ── create-memory ─────────────────────────────────────────────────────────
if (subCmd === 'create-memory') {
const parts = rest.split(/\s+/)
if (parts.length < 2 || !parts[0]) {
return {
action: 'invalid',
reason:
'create-memory requires a store id and content, e.g. create-memory ms_123 "The content"',
}
}
const storeId = parts[0]
const content = parts.slice(1).join(' ')
if (!content.trim()) {
return {
action: 'invalid',
reason: 'create-memory requires non-empty content',
}
}
return { action: 'create-memory', storeId, content: content.trim() }
}
// ── get-memory ────────────────────────────────────────────────────────────
if (subCmd === 'get-memory') {
const parts = rest.split(/\s+/)
if (parts.length < 2 || !parts[0] || !parts[1]) {
return {
action: 'invalid',
reason:
'get-memory requires a store id and memory id, e.g. get-memory ms_123 mem_456',
}
}
return { action: 'get-memory', storeId: parts[0], memoryId: parts[1] }
}
// ── update-memory ─────────────────────────────────────────────────────────
if (subCmd === 'update-memory') {
const parts = rest.split(/\s+/)
if (parts.length < 3 || !parts[0] || !parts[1]) {
return {
action: 'invalid',
reason:
'update-memory requires store id, memory id, and content, e.g. update-memory ms_123 mem_456 "New content"',
}
}
const storeId = parts[0]
const memoryId = parts[1]
const content = parts.slice(2).join(' ')
if (!content.trim()) {
return {
action: 'invalid',
reason: 'update-memory requires non-empty content',
}
}
return {
action: 'update-memory',
storeId,
memoryId,
content: content.trim(),
}
}
// ── delete-memory ─────────────────────────────────────────────────────────
if (subCmd === 'delete-memory') {
const parts = rest.split(/\s+/)
if (parts.length < 2 || !parts[0] || !parts[1]) {
return {
action: 'invalid',
reason:
'delete-memory requires a store id and memory id, e.g. delete-memory ms_123 mem_456',
}
}
return { action: 'delete-memory', storeId: parts[0], memoryId: parts[1] }
}
// ── versions ──────────────────────────────────────────────────────────────
if (subCmd === 'versions') {
if (!rest) {
return { action: 'invalid', reason: 'versions requires a store id' }
}
const storeId = rest.split(/\s+/)[0]
/* istanbul ignore next */
if (!storeId) {
return { action: 'invalid', reason: 'versions requires a store id' }
}
return { action: 'versions', storeId }
}
// ── redact ────────────────────────────────────────────────────────────────
if (subCmd === 'redact') {
const parts = rest.split(/\s+/)
if (parts.length < 2 || !parts[0] || !parts[1]) {
return {
action: 'invalid',
reason:
'redact requires a store id and version id, e.g. redact ms_123 ver_456',
}
}
return { action: 'redact', storeId: parts[0], versionId: parts[1] }
}
return {
action: 'invalid',
reason: `Unknown sub-command "${subCmd}". ${USAGE}`,
}
}

View File

@@ -0,0 +1,164 @@
import React from 'react';
import { Box, Text } from '@anthropic/ink';
import type { Theme } from '@anthropic/ink';
import type { Trigger } from './triggersApi.js';
import { cronToHuman } from '../../utils/cron.js';
type Props =
| { mode: 'list'; triggers: Trigger[] }
| { mode: 'detail'; trigger: Trigger }
| { mode: 'created'; trigger: Trigger }
| { mode: 'updated'; trigger: Trigger }
| { mode: 'deleted'; id: string }
| { mode: 'ran'; id: string; runId: string }
| { mode: 'enabled'; id: string }
| { mode: 'disabled'; id: string }
| { mode: 'error'; message: string };
function TriggerRow({ trigger }: { trigger: Trigger }): React.ReactNode {
const schedule = cronToHuman(trigger.cron_expression, { utc: true });
const nextRun = trigger.next_run ? new Date(trigger.next_run).toLocaleString() : '—';
const enabledText = trigger.enabled ? 'enabled' : 'disabled';
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text bold>{trigger.trigger_id}</Text>
<Text dimColor> · </Text>
<Text color={(trigger.enabled ? 'success' : 'warning') as keyof Theme}>{enabledText}</Text>
{trigger.agent_id ? (
<>
<Text dimColor> · agent: </Text>
<Text>{trigger.agent_id}</Text>
</>
) : null}
</Box>
<Text>Schedule: {schedule}</Text>
<Text dimColor>Prompt: {trigger.prompt}</Text>
<Text dimColor>Next run: {nextRun}</Text>
</Box>
);
}
export function ScheduleView(props: Props): React.ReactNode {
if (props.mode === 'list') {
if (props.triggers.length === 0) {
return (
<Box>
<Text dimColor>No scheduled triggers. Use /schedule create &lt;cron&gt; &lt;prompt&gt; to create one.</Text>
</Box>
);
}
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Scheduled Triggers ({props.triggers.length})</Text>
</Box>
{props.triggers.map(trigger => (
<TriggerRow key={trigger.trigger_id} trigger={trigger} />
))}
</Box>
);
}
if (props.mode === 'detail') {
const { trigger } = props;
const schedule = cronToHuman(trigger.cron_expression, { utc: true });
const nextRun = trigger.next_run ? new Date(trigger.next_run).toLocaleString() : '—';
const lastRun = trigger.last_run ? new Date(trigger.last_run).toLocaleString() : '—';
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Trigger: {trigger.trigger_id}</Text>
</Box>
<Text>
Status:{' '}
<Text color={(trigger.enabled ? 'success' : 'warning') as keyof Theme}>
{trigger.enabled ? 'enabled' : 'disabled'}
</Text>
</Text>
<Text>Schedule: {schedule}</Text>
{trigger.agent_id ? <Text>Agent: {trigger.agent_id}</Text> : null}
<Text>Next run: {nextRun}</Text>
<Text dimColor>Last run: {lastRun}</Text>
<Text dimColor>Prompt: {trigger.prompt}</Text>
{trigger.created_at ? <Text dimColor>Created: {new Date(trigger.created_at).toLocaleString()}</Text> : null}
</Box>
);
}
if (props.mode === 'created') {
const { trigger } = props;
const schedule = cronToHuman(trigger.cron_expression, { utc: true });
return (
<Box flexDirection="column">
<Box>
<Text bold color={'success' as keyof Theme}>
Trigger created
</Text>
</Box>
<Text>ID: {trigger.trigger_id}</Text>
<Text>Schedule: {schedule}</Text>
<Text>Prompt: {trigger.prompt}</Text>
{trigger.agent_id ? <Text>Agent: {trigger.agent_id}</Text> : null}
<Text dimColor>Status: {trigger.enabled ? 'enabled' : 'disabled'}</Text>
</Box>
);
}
if (props.mode === 'updated') {
const { trigger } = props;
return (
<Box flexDirection="column">
<Box>
<Text bold color={'success' as keyof Theme}>
Trigger updated
</Text>
</Box>
<Text>ID: {trigger.trigger_id}</Text>
<Text dimColor>Status: {trigger.enabled ? 'enabled' : 'disabled'}</Text>
</Box>
);
}
if (props.mode === 'deleted') {
return (
<Box>
<Text color={'success' as keyof Theme}>Trigger {props.id} deleted.</Text>
</Box>
);
}
if (props.mode === 'ran') {
return (
<Box flexDirection="column">
<Box>
<Text color={'success' as keyof Theme}>Trigger {props.id} fired.</Text>
</Box>
<Text dimColor>Run ID: {props.runId}</Text>
</Box>
);
}
if (props.mode === 'enabled') {
return (
<Box>
<Text color={'success' as keyof Theme}>Trigger {props.id} enabled.</Text>
</Box>
);
}
if (props.mode === 'disabled') {
return (
<Box>
<Text color={'warning' as keyof Theme}>Trigger {props.id} disabled.</Text>
</Box>
);
}
// error mode
return (
<Box>
<Text color={'error' as keyof Theme}>{props.message}</Text>
</Box>
);
}

View File

@@ -0,0 +1,354 @@
/**
* Regression tests for triggersApi.ts
*
* Key invariants under test:
* - updateTrigger MUST use POST, not PATCH (binary literal: update: POST /v1/code/triggers/{id})
* - All CRUD endpoints hit /v1/code/triggers (not /v1/agents)
* - 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)
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
const mockAccessToken = 'test-token-triggers'
const mockOrgUUID = 'org-uuid-triggers'
mock.module('src/utils/auth.js', () => ({
getClaudeAIOAuthTokens: () => ({ accessToken: mockAccessToken }),
}))
mock.module('src/services/oauth/client.js', () => ({
getOrganizationUUID: async () => mockOrgUUID,
}))
mock.module('src/constants/oauth.js', () => ({
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
}))
mock.module('src/utils/teleport/api.js', () => ({
getOAuthHeaders: (token: string) => ({
Authorization: `Bearer ${token}`,
'anthropic-version': '2023-06-01',
}),
}))
// ── 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 ─────────────────────────────────────────────────
// Use the src/ alias path (same canonical key used in launchSchedule.test.ts mock)
// so that if launchSchedule.test.ts runs first and replaces the mock, this file's
// own beforeAll re-registers the real implementation under that same key.
let listTriggers: typeof import('../triggersApi.js').listTriggers
let getTrigger: typeof import('../triggersApi.js').getTrigger
let createTrigger: typeof import('../triggersApi.js').createTrigger
let updateTrigger: typeof import('../triggersApi.js').updateTrigger
let deleteTrigger: typeof import('../triggersApi.js').deleteTrigger
let runTrigger: typeof import('../triggersApi.js').runTrigger
beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../triggersApi.js')
listTriggers = mod.listTriggers
getTrigger = mod.getTrigger
createTrigger = mod.createTrigger
updateTrigger = mod.updateTrigger
deleteTrigger = mod.deleteTrigger
runTrigger = mod.runTrigger
})
afterAll(() => {
axiosHandle.useStubs = false
})
beforeEach(() => {
axiosGetMock.mockClear()
axiosPostMock.mockClear()
axiosDeleteMock.mockClear()
})
afterEach(() => {})
// ── REGRESSION: updateTrigger MUST use POST not PATCH ──────────────────────
describe('updateTrigger regression: must use POST not PATCH', () => {
test('updateTrigger calls POST /v1/code/triggers/{id} (not PATCH)', async () => {
const updated = {
trigger_id: 'trg_upd',
cron_expression: '0 10 * * *',
enabled: true,
prompt: 'Updated prompt',
}
axiosPostMock.mockResolvedValueOnce({ data: updated, status: 200 })
await updateTrigger('trg_upd', { enabled: false })
// POST must have been called
expect(axiosPostMock).toHaveBeenCalledTimes(1)
// axiosPatchMock must NOT have been called (no patch mock registered)
// The URL must contain the trigger id
const calls = axiosPostMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
expect(url).toContain('trg_upd')
expect(url).toContain('/v1/code/triggers/')
// Verify the URL does NOT end in /run (which is the runTrigger endpoint)
expect(url).not.toMatch(/\/run$/)
})
})
// ── listTriggers ──────────────────────────────────────────────────────────
describe('listTriggers', () => {
test('returns triggers on 200', async () => {
const triggers = [
{
trigger_id: 'trg_1',
cron_expression: '0 9 * * 1',
enabled: true,
prompt: 'Weekly standup',
agent_id: 'agt_1',
next_run: '2026-05-05T09:00:00Z',
},
]
axiosGetMock.mockResolvedValueOnce({
data: { data: triggers },
status: 200,
})
const result = await listTriggers()
expect(result).toHaveLength(1)
expect(result[0]!.trigger_id).toBe('trg_1')
expect(axiosGetMock).toHaveBeenCalledTimes(1)
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('/v1/code/triggers')
})
test('returns empty array on empty response', async () => {
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
const result = await listTriggers()
expect(result).toHaveLength(0)
})
test('throws 401 with friendly message', async () => {
const err = Object.assign(new Error('Unauthorized'), {
isAxiosError: true,
response: { status: 401, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listTriggers()).rejects.toThrow(/login|authenticate/i)
})
test('throws 403 with subscription message', async () => {
const err = Object.assign(new Error('Forbidden'), {
isAxiosError: true,
response: { status: 403, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listTriggers()).rejects.toThrow(/subscription|pro|max|team/i)
})
test('retries on 5xx and eventually throws', async () => {
const make5xx = () =>
Object.assign(new Error('Server Error'), {
isAxiosError: true,
response: { status: 500, data: {} },
})
axiosGetMock
.mockRejectedValueOnce(make5xx())
.mockRejectedValueOnce(make5xx())
.mockRejectedValueOnce(make5xx())
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listTriggers()).rejects.toThrow()
expect(axiosGetMock).toHaveBeenCalledTimes(3)
}, 15000)
test('honors Retry-After header on 5xx', async () => {
const serverErr = Object.assign(new Error('Service Unavailable'), {
isAxiosError: true,
response: { status: 503, data: {}, headers: { 'retry-after': '0' } },
})
axiosGetMock
.mockRejectedValueOnce(serverErr)
.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
const result = await listTriggers()
expect(result).toHaveLength(0)
expect(axiosGetMock).toHaveBeenCalledTimes(2)
})
})
// ── getTrigger ──────────────────────────────────────────────────────────
describe('getTrigger', () => {
test('calls GET /v1/code/triggers/{id}', async () => {
const trigger = {
trigger_id: 'trg_get',
cron_expression: '0 8 * * *',
enabled: true,
prompt: 'Daily report',
}
axiosGetMock.mockResolvedValueOnce({ data: trigger, status: 200 })
const result = await getTrigger('trg_get')
expect(result.trigger_id).toBe('trg_get')
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('trg_get')
})
test('throws 404 with not found message', async () => {
const err = Object.assign(new Error('Not Found'), {
isAxiosError: true,
response: { status: 404, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(getTrigger('nonexistent')).rejects.toThrow(/not found/i)
})
})
// ── createTrigger ─────────────────────────────────────────────────────────
describe('createTrigger', () => {
test('sends POST /v1/code/triggers with cron_expression and prompt', async () => {
const trigger = {
trigger_id: 'trg_new',
cron_expression: '0 9 * * *',
enabled: true,
prompt: 'Create daily report',
}
axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 201 })
const result = await createTrigger({
cron_expression: '0 9 * * *',
prompt: 'Create daily report',
})
expect(result.trigger_id).toBe('trg_new')
const calls = axiosPostMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
const body = calls[0]?.[1] as Record<string, unknown>
expect(url).toContain('/v1/code/triggers')
expect(url).not.toContain('/v1/agents')
expect(body.cron_expression).toBe('0 9 * * *')
expect(body.prompt).toBe('Create daily report')
})
})
// ── deleteTrigger ─────────────────────────────────────────────────────────
describe('deleteTrigger', () => {
test('calls DELETE /v1/code/triggers/{id}', async () => {
axiosDeleteMock.mockResolvedValueOnce({ status: 204 })
await deleteTrigger('trg_del')
const calls = axiosDeleteMock.mock.calls as unknown as [string, unknown][]
const url = calls[0]?.[0] as string
expect(url).toContain('trg_del')
expect(url).toContain('/v1/code/triggers/')
})
})
// ── runTrigger ───────────────────────────────────────────────────────────
describe('runTrigger', () => {
test('calls POST /v1/code/triggers/{id}/run', async () => {
axiosPostMock.mockResolvedValueOnce({
data: { run_id: 'run_trg_1' },
status: 200,
})
const result = await runTrigger('trg_run')
expect(result.run_id).toBe('run_trg_1')
const calls = axiosPostMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
expect(url).toMatch(/trg_run\/run$/)
})
})
// ── 429 Retry-After ──────────────────────────────────────────────────────
describe('429 rate-limit: not retried (non-5xx)', () => {
test('throws immediately on 429 without retry', async () => {
const err = Object.assign(new Error('Too Many Requests'), {
isAxiosError: true,
response: { status: 429, data: {}, headers: { 'retry-after': '60' } },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listTriggers()).rejects.toThrow()
// Must NOT have retried — 429 is not a 5xx
expect(axiosGetMock).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,66 @@
/**
* Tests for schedule/index.ts — command metadata only.
*/
import { beforeAll, describe, expect, mock, test } from 'bun:test'
mock.module('bun:bundle', () => ({
feature: (_name: string) => true,
}))
let cmd: {
load?: () => Promise<{ call: unknown }>
isEnabled?: () => boolean
name?: string
type?: string
aliases?: string[]
description?: string
bridgeSafe?: boolean
availability?: string[]
}
beforeAll(async () => {
const mod = await import('../index.js')
cmd = mod.default as typeof cmd
})
describe('scheduleCommand metadata', () => {
test('name is "triggers" (renamed from "schedule" to avoid bundled-skill collision)', () => {
expect(cmd.name).toBe('triggers')
})
test('type is local-jsx', () => {
expect(cmd.type).toBe('local-jsx')
})
test('isEnabled returns true', () => {
expect(cmd.isEnabled?.()).toBe(true)
})
test('aliases include cron (triggers is now the primary name)', () => {
expect(cmd.aliases).toContain('cron')
// 'triggers' moved to primary `name`; the bundled skill /schedule
// owns the 'schedule' slot upstream so we don't alias to it either.
expect(cmd.aliases).not.toContain('schedule')
})
test('bridgeSafe is false', () => {
expect(cmd.bridgeSafe).toBe(false)
})
test('availability includes claude-ai', () => {
expect(cmd.availability).toContain('claude-ai')
})
test('description mentions schedule or trigger', () => {
expect(cmd.description?.toLowerCase()).toMatch(/schedule|cron|trigger/)
})
test('load() exists and is a function', () => {
expect(typeof cmd.load).toBe('function')
})
test('load() resolves to object with call function', async () => {
const loaded = await cmd.load!()
expect(typeof (loaded as { call?: unknown }).call).toBe('function')
})
})

View File

@@ -0,0 +1,307 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
import { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.js'
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
// ── Analytics mock ──────────────────────────────────────────────────────────
const logEventMock = mock(() => {})
mock.module('src/services/analytics/index.js', () => ({
logEvent: logEventMock,
}))
// ── Cron utility mock ───────────────────────────────────────────────────────
// parseCronExpression: returns null if any field is non-numeric/non-wildcard
// to simulate real validation; specifically reject expressions with word fields.
mock.module('src/utils/cron.js', () => ({
parseCronExpression: (cron: string) => {
const fields = cron.trim().split(/\s+/)
if (fields.length !== 5) return null
// Reject if any field contains a letter (invalid cron field)
const hasWord = fields.some(f => /[a-zA-Z]/.test(f))
if (hasWord) return null
return {
minute: [0],
hour: [9],
dayOfMonth: [1],
month: [1],
dayOfWeek: [1],
}
},
cronToHuman: (cron: string) => `human(${cron})`,
}))
// ── ScheduleView mock ───────────────────────────────────────────────────────
const scheduleViewMock = mock((_props: unknown) => null)
mock.module('src/commands/schedule/ScheduleView.js', () => ({
ScheduleView: scheduleViewMock,
}))
// ── triggersApi mock ──────────────────────────────────────────────────────
// Use `as unknown as` casts to keep mock type flexible while satisfying strict TS
const listTriggersMock = mock(async () => [] as unknown)
const getTriggerMock = mock(async () => ({}) as unknown)
const createTriggerMock = mock(async () => ({}) as unknown)
const updateTriggerMock = mock(async () => ({}) as unknown)
const deleteTriggerMock = mock(async () => undefined)
const runTriggerMock = mock(async () => ({ run_id: 'run_mock' }) as unknown)
mock.module('src/commands/schedule/triggersApi.js', () => ({
listTriggers: listTriggersMock,
getTrigger: getTriggerMock,
createTrigger: createTriggerMock,
updateTrigger: updateTriggerMock,
deleteTrigger: deleteTriggerMock,
runTrigger: runTriggerMock,
}))
let callSchedule: typeof import('../launchSchedule.js').callSchedule
beforeAll(async () => {
const mod = await import('../launchSchedule.js')
callSchedule = mod.callSchedule
})
function makeOnDone() {
return mock(() => {})
}
beforeEach(() => {
logEventMock.mockClear()
listTriggersMock.mockClear()
getTriggerMock.mockClear()
createTriggerMock.mockClear()
updateTriggerMock.mockClear()
deleteTriggerMock.mockClear()
runTriggerMock.mockClear()
scheduleViewMock.mockClear()
})
describe('callSchedule: invalid args', () => {
test('invalid subcommand → onDone with usage + null', async () => {
const onDone = makeOnDone()
const result = await callSchedule(onDone, {} as never, 'badcmd')
expect(result).toBeNull()
expect(onDone).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/Usage/i)
})
})
describe('callSchedule: list', () => {
test('list returns empty triggers', async () => {
listTriggersMock.mockResolvedValueOnce([])
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'list')
expect(listTriggersMock).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/no scheduled triggers/i)
})
test('list with triggers reports count', async () => {
const triggers = [
{
trigger_id: 'trg_1',
cron_expression: '0 9 * * 1',
enabled: true,
prompt: 'daily',
},
]
listTriggersMock.mockResolvedValueOnce(triggers)
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, '')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/1 scheduled trigger/)
})
test('list API error → error view', async () => {
listTriggersMock.mockRejectedValueOnce(new Error('Network error'))
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'list')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to list/i)
})
})
describe('callSchedule: get', () => {
test('get calls getTrigger with id', async () => {
const trigger = {
trigger_id: 'trg_get',
cron_expression: '0 8 * * *',
enabled: true,
prompt: 'test',
}
getTriggerMock.mockResolvedValueOnce(trigger)
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'get trg_get')
expect(getTriggerMock).toHaveBeenCalledTimes(1)
const calls = getTriggerMock.mock.calls as unknown as [string][]
expect(calls[0]?.[0]).toBe('trg_get')
})
test('get API error → error message', async () => {
getTriggerMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'get trg_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to get/i)
})
})
describe('callSchedule: create', () => {
test('create with valid cron calls createTrigger', async () => {
const trigger = {
trigger_id: 'trg_new',
cron_expression: '0 9 * * *',
enabled: true,
prompt: 'daily report',
}
createTriggerMock.mockResolvedValueOnce(trigger)
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'create 0 9 * * * daily report')
expect(createTriggerMock).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/trigger created/i)
})
test('create with invalid cron → validation error without hitting API', async () => {
const onDone = makeOnDone()
// 4 fields only — invalid
await callSchedule(onDone, {} as never, 'create 0 9 * * report only')
// createTrigger should not be called
expect(createTriggerMock).not.toHaveBeenCalled()
})
test('create API error → error message', async () => {
createTriggerMock.mockRejectedValueOnce(new Error('Subscription required'))
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'create 0 9 * * * test prompt')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to create/i)
})
})
describe('callSchedule: update', () => {
test('update enabled field', async () => {
const trigger = {
trigger_id: 'trg_upd',
cron_expression: '0 9 * * *',
enabled: false,
prompt: 'test',
}
updateTriggerMock.mockResolvedValueOnce(trigger)
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'update trg_upd enabled false')
expect(updateTriggerMock).toHaveBeenCalledTimes(1)
const calls = updateTriggerMock.mock.calls as unknown as [
string,
Record<string, unknown>,
][]
expect(calls[0]?.[1]).toEqual({ enabled: false })
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/updated/i)
})
test('update with unknown field → error without API call', async () => {
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'update trg_upd foofield bar')
expect(updateTriggerMock).not.toHaveBeenCalled()
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/unknown field/i)
})
})
describe('callSchedule: delete', () => {
test('delete calls deleteTrigger', async () => {
deleteTriggerMock.mockResolvedValueOnce(undefined)
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'delete trg_del')
expect(deleteTriggerMock).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/deleted/i)
})
test('delete API error → error message', async () => {
deleteTriggerMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'delete trg_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to delete/i)
})
})
describe('callSchedule: run', () => {
test('run fires trigger and returns run_id', async () => {
runTriggerMock.mockResolvedValueOnce({ run_id: 'run_xyz' })
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'run trg_fire')
expect(runTriggerMock).toHaveBeenCalledTimes(1)
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/run_xyz/)
})
test('run API error → error message', async () => {
runTriggerMock.mockRejectedValueOnce(new Error('Forbidden'))
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'run trg_fire')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to run/i)
})
})
describe('callSchedule: enable / disable', () => {
test('enable calls updateTrigger with enabled:true', async () => {
const trigger = {
trigger_id: 'trg_en',
cron_expression: '0 9 * * *',
enabled: true,
prompt: 'test',
}
updateTriggerMock.mockResolvedValueOnce(trigger)
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'enable trg_en')
const calls = updateTriggerMock.mock.calls as unknown as [
string,
Record<string, unknown>,
][]
expect(calls[0]?.[1]).toEqual({ enabled: true })
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/enabled/i)
})
test('disable calls updateTrigger with enabled:false', async () => {
const trigger = {
trigger_id: 'trg_dis',
cron_expression: '0 9 * * *',
enabled: false,
prompt: 'test',
}
updateTriggerMock.mockResolvedValueOnce(trigger)
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'disable trg_dis')
const calls = updateTriggerMock.mock.calls as unknown as [
string,
Record<string, unknown>,
][]
expect(calls[0]?.[1]).toEqual({ enabled: false })
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/disabled/i)
})
test('enable API error → error message', async () => {
updateTriggerMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'enable trg_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to enable/i)
})
test('disable API error → error message', async () => {
updateTriggerMock.mockRejectedValueOnce(new Error('Not found'))
const onDone = makeOnDone()
await callSchedule(onDone, {} as never, 'disable trg_missing')
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
expect(msg).toMatch(/failed to disable/i)
})
})

View File

@@ -0,0 +1,184 @@
import { describe, expect, test } from 'bun:test'
import {
isValidCronExpression,
parseScheduleArgs,
splitCronAndPrompt,
} from '../parseArgs.js'
describe('splitCronAndPrompt', () => {
test('splits 5 cron fields + prompt', () => {
const result = splitCronAndPrompt('0 9 * * 1 Run standup')
expect(result).toEqual({ cron: '0 9 * * 1', prompt: 'Run standup' })
})
test('handles multi-word prompt', () => {
const result = splitCronAndPrompt(
'0 9 * * * Generate daily report for team',
)
expect(result?.cron).toBe('0 9 * * *')
expect(result?.prompt).toBe('Generate daily report for team')
})
test('returns null with fewer than 6 tokens', () => {
expect(splitCronAndPrompt('0 9 * * *')).toBeNull()
expect(splitCronAndPrompt('0 9 *')).toBeNull()
expect(splitCronAndPrompt('')).toBeNull()
})
})
describe('isValidCronExpression', () => {
test('accepts valid 5-field expressions', () => {
expect(isValidCronExpression('0 9 * * 1')).toBe(true)
expect(isValidCronExpression('*/5 * * * *')).toBe(true)
expect(isValidCronExpression('0 0 1 1 *')).toBe(true)
})
test('rejects expressions with wrong field count', () => {
expect(isValidCronExpression('0 9 * *')).toBe(false)
expect(isValidCronExpression('0 9 * * * *')).toBe(false)
expect(isValidCronExpression('')).toBe(false)
})
})
describe('parseScheduleArgs', () => {
test('empty string → list', () => {
expect(parseScheduleArgs('')).toEqual({ action: 'list' })
})
test('"list" → list', () => {
expect(parseScheduleArgs('list')).toEqual({ action: 'list' })
})
test('"list" with extra whitespace → list', () => {
expect(parseScheduleArgs(' list ')).toEqual({ action: 'list' })
})
// ── get ───────────────────────────────────────────────────────────────────
test('get <id> → get action', () => {
expect(parseScheduleArgs('get trg_123')).toEqual({
action: 'get',
id: 'trg_123',
})
})
test('get without id → invalid', () => {
const result = parseScheduleArgs('get')
expect(result.action).toBe('invalid')
if (result.action === 'invalid') {
expect(result.reason).toMatch(/trigger id/i)
}
})
// ── create ────────────────────────────────────────────────────────────────
test('create with cron + prompt → create action', () => {
const result = parseScheduleArgs('create 0 9 * * 1 Run daily standup')
expect(result).toEqual({
action: 'create',
cron: '0 9 * * 1',
prompt: 'Run daily standup',
})
})
test('create without args → invalid', () => {
const result = parseScheduleArgs('create')
expect(result.action).toBe('invalid')
})
test('create with only cron (no prompt) → invalid', () => {
const result = parseScheduleArgs('create 0 9 * * 1')
expect(result.action).toBe('invalid')
})
// ── update ────────────────────────────────────────────────────────────────
test('update <id> enabled false → update action', () => {
const result = parseScheduleArgs('update trg_123 enabled false')
expect(result).toEqual({
action: 'update',
id: 'trg_123',
field: 'enabled',
value: 'false',
})
})
test('update <id> prompt new text → update action with multi-word value', () => {
const result = parseScheduleArgs(
'update trg_abc prompt New prompt text here',
)
expect(result).toEqual({
action: 'update',
id: 'trg_abc',
field: 'prompt',
value: 'New prompt text here',
})
})
test('update missing field → invalid', () => {
const result = parseScheduleArgs('update trg_123')
expect(result.action).toBe('invalid')
})
test('update missing value → invalid', () => {
const result = parseScheduleArgs('update trg_123 enabled')
expect(result.action).toBe('invalid')
})
// ── delete ────────────────────────────────────────────────────────────────
test('delete <id> → delete action', () => {
expect(parseScheduleArgs('delete trg_del')).toEqual({
action: 'delete',
id: 'trg_del',
})
})
test('delete without id → invalid', () => {
const result = parseScheduleArgs('delete')
expect(result.action).toBe('invalid')
})
// ── run ───────────────────────────────────────────────────────────────────
test('run <id> → run action', () => {
expect(parseScheduleArgs('run trg_run')).toEqual({
action: 'run',
id: 'trg_run',
})
})
test('run without id → invalid', () => {
const result = parseScheduleArgs('run')
expect(result.action).toBe('invalid')
})
// ── enable / disable ──────────────────────────────────────────────────────
test('enable <id> → enable action', () => {
expect(parseScheduleArgs('enable trg_en')).toEqual({
action: 'enable',
id: 'trg_en',
})
})
test('disable <id> → disable action', () => {
expect(parseScheduleArgs('disable trg_dis')).toEqual({
action: 'disable',
id: 'trg_dis',
})
})
test('enable without id → invalid', () => {
const result = parseScheduleArgs('enable')
expect(result.action).toBe('invalid')
})
test('disable without id → invalid', () => {
const result = parseScheduleArgs('disable')
expect(result.action).toBe('invalid')
})
// ── unknown subcommand ────────────────────────────────────────────────────
test('unknown subcommand → invalid', () => {
const result = parseScheduleArgs('foobar trg_123')
expect(result.action).toBe('invalid')
if (result.action === 'invalid') {
expect(result.reason).toMatch(/unknown sub-command/i)
}
})
})

View File

@@ -0,0 +1,27 @@
import type { Command } from '../../types/command.js'
const scheduleCommand: Command = {
type: 'local-jsx',
// Primary name renamed from 'schedule' → 'triggers' to avoid collision
// with the upstream bundled skill `src/skills/bundled/scheduleRemoteAgents.ts`,
// which also registers as `/schedule`. The new name matches the underlying
// API endpoint (`/v1/code/triggers`). Directory still named schedule/ to
// keep the rename minimal — only the user-facing slash name changes.
name: 'triggers',
aliases: ['cron'],
description:
'Manage scheduled remote agent triggers (cloud cron). Requires Claude Pro/Max/Team subscription.',
// REPL markdown renderer strips `<...>` as HTML tags — use uppercase.
argumentHint:
'list | get ID | create CRON PROMPT | update ID FIELD VALUE | delete ID | run ID | enable ID | disable ID',
isHidden: false,
isEnabled: () => true,
bridgeSafe: false,
availability: ['claude-ai'],
load: async () => {
const m = await import('./launchSchedule.js')
return { call: m.callSchedule }
},
}
export default scheduleCommand

View File

@@ -0,0 +1,230 @@
import React from 'react';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js';
import { parseCronExpression } from '../../utils/cron.js';
import type { LocalJSXCommandCall } from '../../types/command.js';
import { createTrigger, deleteTrigger, getTrigger, listTriggers, runTrigger, updateTrigger } from './triggersApi.js';
import { ScheduleView } from './ScheduleView.js';
import { parseScheduleArgs } from './parseArgs.js';
import type { UpdateTriggerBody } from './triggersApi.js';
export const callSchedule: LocalJSXCommandCall = async (onDone, _context, args) => {
logEvent('tengu_schedule_started', {
args: (args ?? '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
const parsed = parseScheduleArgs(args ?? '');
// ── invalid args ──────────────────────────────────────────────────────────
if (parsed.action === 'invalid') {
logEvent('tengu_schedule_failed', {
reason: parsed.reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(
`Usage: /schedule list | get ID | create CRON PROMPT | update ID FIELD VALUE | delete ID | run ID | enable ID | disable ID\n${parsed.reason}`,
{ display: 'system' },
);
return null;
}
// ── list ──────────────────────────────────────────────────────────────────
if (parsed.action === 'list') {
logEvent('tengu_schedule_list', {});
try {
const triggers = await listTriggers();
onDone(triggers.length === 0 ? 'No scheduled triggers found.' : `${triggers.length} scheduled trigger(s).`, {
display: 'system',
});
return React.createElement(ScheduleView, { mode: 'list', triggers });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_schedule_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to list triggers: ${msg}`, { display: 'system' });
return React.createElement(ScheduleView, { mode: 'error', message: msg });
}
}
// ── get ───────────────────────────────────────────────────────────────────
if (parsed.action === 'get') {
const { id } = parsed;
logEvent('tengu_schedule_get', {
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const trigger = await getTrigger(id);
onDone(`Trigger ${id} fetched.`, { display: 'system' });
return React.createElement(ScheduleView, { mode: 'detail', trigger });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_schedule_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to get trigger ${id}: ${msg}`, { display: 'system' });
return React.createElement(ScheduleView, { mode: 'error', message: msg });
}
}
// ── create ────────────────────────────────────────────────────────────────
if (parsed.action === 'create') {
const { cron, prompt } = parsed;
const cronFields = parseCronExpression(cron);
if (!cronFields) {
const reason = `Invalid cron expression: "${cron}". Expected 5 fields (minute hour day month weekday).`;
logEvent('tengu_schedule_failed', {
reason: reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(reason, { display: 'system' });
return null;
}
logEvent('tengu_schedule_create', {
cron: cron as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const trigger = await createTrigger({ cron_expression: cron, prompt });
onDone(`Trigger created: ${trigger.trigger_id}`, { display: 'system' });
return React.createElement(ScheduleView, { mode: 'created', trigger });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_schedule_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to create trigger: ${msg}`, { display: 'system' });
return React.createElement(ScheduleView, { mode: 'error', message: msg });
}
}
// ── update ────────────────────────────────────────────────────────────────
if (parsed.action === 'update') {
const { id, field, value } = parsed;
logEvent('tengu_schedule_update', {
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
field: field as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
// Coerce value to boolean when field is 'enabled'
let body: UpdateTriggerBody = {};
if (field === 'enabled') {
body = { enabled: value === 'true' || value === '1' };
} else if (field === 'cron_expression' || field === 'cron') {
body = { cron_expression: value };
} else if (field === 'prompt') {
body = { prompt: value };
} else if (field === 'agent_id') {
body = { agent_id: value };
} else {
const reason = `Unknown field "${field}". Valid fields: enabled, cron_expression, prompt, agent_id`;
logEvent('tengu_schedule_failed', {
reason: reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(reason, { display: 'system' });
return React.createElement(ScheduleView, {
mode: 'error',
message: reason,
});
}
try {
const trigger = await updateTrigger(id, body);
onDone(`Trigger ${id} updated.`, { display: 'system' });
return React.createElement(ScheduleView, { mode: 'updated', trigger });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_schedule_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to update trigger ${id}: ${msg}`, { display: 'system' });
return React.createElement(ScheduleView, { mode: 'error', message: msg });
}
}
// ── delete ────────────────────────────────────────────────────────────────
if (parsed.action === 'delete') {
const { id } = parsed;
logEvent('tengu_schedule_delete', {
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
await deleteTrigger(id);
onDone(`Trigger ${id} deleted.`, { display: 'system' });
return React.createElement(ScheduleView, { mode: 'deleted', id });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_schedule_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to delete trigger ${id}: ${msg}`, { display: 'system' });
return React.createElement(ScheduleView, { mode: 'error', message: msg });
}
}
// ── run ───────────────────────────────────────────────────────────────────
if (parsed.action === 'run') {
const { id } = parsed;
logEvent('tengu_schedule_run', {
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
const result = await runTrigger(id);
onDone(`Trigger ${id} fired. Run ID: ${result.run_id}`, {
display: 'system',
});
return React.createElement(ScheduleView, {
mode: 'ran',
id,
runId: result.run_id,
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_schedule_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to run trigger ${id}: ${msg}`, { display: 'system' });
return React.createElement(ScheduleView, { mode: 'error', message: msg });
}
}
// ── enable ────────────────────────────────────────────────────────────────
if (parsed.action === 'enable') {
const { id } = parsed;
logEvent('tengu_schedule_enable', {
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
await updateTrigger(id, { enabled: true });
onDone(`Trigger ${id} enabled.`, { display: 'system' });
return React.createElement(ScheduleView, { mode: 'enabled', id });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_schedule_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to enable trigger ${id}: ${msg}`, { display: 'system' });
return React.createElement(ScheduleView, { mode: 'error', message: msg });
}
}
// ── disable ───────────────────────────────────────────────────────────────
// parsed.action === 'disable'
const { id } = parsed;
logEvent('tengu_schedule_disable', {
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
try {
await updateTrigger(id, { enabled: false });
onDone(`Trigger ${id} disabled.`, { display: 'system' });
return React.createElement(ScheduleView, { mode: 'disabled', id });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
logEvent('tengu_schedule_failed', {
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(`Failed to disable trigger ${id}: ${msg}`, { display: 'system' });
return React.createElement(ScheduleView, { mode: 'error', message: msg });
}
};

View File

@@ -0,0 +1,181 @@
/**
* Parse the args string for the /schedule command.
*
* Supported sub-commands:
* list → { action: 'list' }
* get <id> → { action: 'get', id }
* create <cron-expr> <prompt> → { action: 'create', cron, prompt }
* update <id> <field> <value> → { action: 'update', id, field, value }
* delete <id> → { action: 'delete', id }
* run <id> → { action: 'run', id }
* enable <id> → { action: 'enable', id }
* disable <id> → { action: 'disable', id }
* (empty) → { action: 'list' }
* anything else → { action: 'invalid', reason }
*/
export type ScheduleArgs =
| { action: 'list' }
| { action: 'get'; id: string }
| { action: 'create'; cron: string; prompt: string }
| { action: 'update'; id: string; field: string; value: string }
| { action: 'delete'; id: string }
| { action: 'run'; id: string }
| { action: 'enable'; id: string }
| { action: 'disable'; id: string }
| { action: 'invalid'; reason: string }
const USAGE =
'Usage: /schedule list | get ID | create CRON PROMPT | update ID FIELD VALUE | delete ID | run ID | enable ID | disable ID'
/**
* Extract the first 5 whitespace-separated tokens as a cron expression;
* the remainder is the prompt. Returns null if fewer than 6 tokens are present.
*/
export function splitCronAndPrompt(
rest: string,
): { cron: string; prompt: string } | null {
const tokens = rest.trim().split(/\s+/)
if (tokens.length < 6) return null
const cron = tokens.slice(0, 5).join(' ')
const prompt = tokens.slice(5).join(' ')
return { cron, prompt }
}
/**
* Validate a 5-field cron expression (minute hour day month weekday).
* Returns true if the expression has exactly 5 fields; false otherwise.
* This is a lightweight structural check — the server validates semantics.
*/
export function isValidCronExpression(cron: string): boolean {
const fields = cron.trim().split(/\s+/)
return fields.length === 5
}
export function parseScheduleArgs(args: string): ScheduleArgs {
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 trigger id' }
}
const id = rest.split(/\s+/)[0]
/* istanbul ignore next */
if (!id) {
return { action: 'invalid', reason: 'get requires a trigger id' }
}
return { action: 'get', id }
}
// ── create ────────────────────────────────────────────────────────────────
if (subCmd === 'create') {
if (!rest) {
return {
action: 'invalid',
reason:
'create requires a cron expression and prompt, e.g. create "0 9 * * 1" Run weekly standup',
}
}
const parsed = splitCronAndPrompt(rest)
if (!parsed) {
return {
action: 'invalid',
reason:
'create requires 5 cron fields followed by a prompt, e.g. create "0 9 * * 1" Run weekly standup',
}
}
const { cron, prompt } = parsed
if (!isValidCronExpression(cron)) {
return {
action: 'invalid',
reason: `Invalid cron expression: "${cron}". Expected 5 fields (minute hour day month weekday).`,
}
}
/* istanbul ignore next -- prompt is non-empty by construction from splitCronAndPrompt */
if (!prompt.trim()) {
return { action: 'invalid', reason: 'prompt cannot be empty' }
}
return { action: 'create', cron, prompt: prompt.trim() }
}
// ── update ────────────────────────────────────────────────────────────────
if (subCmd === 'update') {
const parts = rest.split(/\s+/)
if (parts.length < 3 || !parts[0]) {
return {
action: 'invalid',
reason:
'update requires an id, field, and value, e.g. update trg_123 enabled false',
}
}
const id = parts[0]
const field = parts[1] ?? ''
const value = parts.slice(2).join(' ')
if (!field) {
return { action: 'invalid', reason: 'update requires a field name' }
}
if (!value) {
return { action: 'invalid', reason: 'update requires a value' }
}
return { action: 'update', id, field, value }
}
// ── delete ────────────────────────────────────────────────────────────────
if (subCmd === 'delete') {
if (!rest) {
return { action: 'invalid', reason: 'delete requires a trigger id' }
}
const id = rest.split(/\s+/)[0]
/* istanbul ignore next */
if (!id) {
return { action: 'invalid', reason: 'delete requires a trigger id' }
}
return { action: 'delete', id }
}
// ── run ───────────────────────────────────────────────────────────────────
if (subCmd === 'run') {
if (!rest) {
return { action: 'invalid', reason: 'run requires a trigger id' }
}
const id = rest.split(/\s+/)[0]
/* istanbul ignore next */
if (!id) {
return { action: 'invalid', reason: 'run requires a trigger id' }
}
return { action: 'run', id }
}
// ── enable / disable ──────────────────────────────────────────────────────
if (subCmd === 'enable' || subCmd === 'disable') {
if (!rest) {
return {
action: 'invalid',
reason: `${subCmd} requires a trigger id`,
}
}
const id = rest.split(/\s+/)[0]
/* istanbul ignore next */
if (!id) {
return {
action: 'invalid',
reason: `${subCmd} requires a trigger id`,
}
}
return { action: subCmd as 'enable' | 'disable', id }
}
return {
action: 'invalid',
reason: `Unknown sub-command "${subCmd}". ${USAGE}`,
}
}

View File

@@ -0,0 +1,247 @@
/**
* Thin HTTP client for the /v1/code/triggers endpoint.
*
* Key spec facts (from binary reverse-engineering of v2.1.123):
* - list: GET /v1/code/triggers
* - get: GET /v1/code/triggers/{trigger_id}
* - create: POST /v1/code/triggers
* - update: POST /v1/code/triggers/{trigger_id} ← POST not PATCH
* - run: POST /v1/code/triggers/{trigger_id}/run
* - delete: DELETE /v1/code/triggers/{trigger_id}
*
* Reuses the same base-URL + auth-header pattern as agentsApi.ts.
*/
import axios from 'axios'
import { getOauthConfig } from '../../constants/oauth.js'
import { getOAuthHeaders, prepareApiRequest } from '../../utils/teleport/api.js'
export type Trigger = {
trigger_id: string
cron_expression: string
enabled: boolean
prompt: string
agent_id?: string
last_run?: string | null
next_run?: string | null
created_at?: string
}
export type CreateTriggerBody = {
cron_expression: string
prompt: string
agent_id?: string
enabled?: boolean
}
export type UpdateTriggerBody = Partial<{
cron_expression: string
prompt: string
enabled: boolean
agent_id: string
}>
type ListTriggersResponse = {
data: Trigger[]
}
type TriggerRunResponse = {
run_id: string
}
// Reverse-engineered from claude.exe v2.1.123: the only beta value the
// triggers endpoint actually accepts on the subscription auth plane is
// `ccr-triggers-2026-01-30`. The earlier umbrella value
// `managed-agents-2026-04-01` only appears in documentation strings, never
// in actual request construction.
const TRIGGERS_BETA_HEADER = 'ccr-triggers-2026-01-30'
const MAX_RETRIES = 3
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
class TriggersApiError extends Error {
constructor(
message: string,
public readonly statusCode: number,
) {
super(message)
this.name = 'TriggersApiError'
}
}
async function buildHeaders(): Promise<Record<string, string>> {
let accessToken: string
let orgUUID: string
try {
const prepared = await prepareApiRequest()
accessToken = prepared.accessToken
orgUUID = prepared.orgUUID
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
throw new TriggersApiError(
`Not authenticated: ${msg}. Run /login to re-authenticate.`,
401,
)
}
return {
...getOAuthHeaders(accessToken),
'anthropic-beta': TRIGGERS_BETA_HEADER,
'x-organization-uuid': orgUUID,
}
}
function triggersBaseUrl(): string {
return `${getOauthConfig().BASE_API_URL}/v1/code/triggers`
}
function classifyError(err: unknown): TriggersApiError {
if (axios.isAxiosError(err)) {
const status = err.response?.status ?? 0
if (status === 401) {
return new TriggersApiError(
'Authentication failed. Please run /login to re-authenticate.',
401,
)
}
if (status === 403) {
return new TriggersApiError(
'Subscription required. Scheduled triggers require a Claude Pro/Max/Team subscription.',
403,
)
}
if (status === 404) {
return new TriggersApiError('Trigger 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 TriggersApiError(`Rate limit exceeded.${detail}`, 429)
}
const msg =
(err.response?.data as { error?: { message?: string } } | undefined)
?.error?.message ?? err.message
return new TriggersApiError(msg, status)
}
if (err instanceof TriggersApiError) return err
return new TriggersApiError(
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: TriggersApiError | 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 TriggersApiError('Request failed after retries', 0)
}
export async function listTriggers(): Promise<Trigger[]> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.get<ListTriggersResponse>(triggersBaseUrl(), {
headers,
})
return response.data.data ?? []
})
}
export async function getTrigger(id: string): Promise<Trigger> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.get<Trigger>(`${triggersBaseUrl()}/${id}`, {
headers,
})
return response.data
})
}
export async function createTrigger(body: CreateTriggerBody): Promise<Trigger> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.post<Trigger>(triggersBaseUrl(), body, {
headers,
})
return response.data
})
}
/**
* Update a trigger.
*
* IMPORTANT: The upstream API uses POST (not PATCH/PUT) for updates.
* Binary literal evidence: "update: POST /v1/code/triggers/{trigger_id}"
*/
export async function updateTrigger(
id: string,
body: UpdateTriggerBody,
): Promise<Trigger> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.post<Trigger>(
`${triggersBaseUrl()}/${id}`,
body,
{ headers },
)
return response.data
})
}
export async function deleteTrigger(id: string): Promise<void> {
return withRetry(async () => {
const headers = await buildHeaders()
await axios.delete(`${triggersBaseUrl()}/${id}`, { headers })
})
}
export async function runTrigger(id: string): Promise<TriggerRunResponse> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.post<TriggerRunResponse>(
`${triggersBaseUrl()}/${id}/run`,
{},
{ headers },
)
return response.data
})
}

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

View File

@@ -0,0 +1,185 @@
import React from 'react';
import { Box, Text } from '@anthropic/ink';
import type { Theme } from '@anthropic/ink';
import type { Credential, Vault } from './vaultsApi.js';
type Props =
| { mode: 'list'; vaults: Vault[] }
| { mode: 'detail'; vault: Vault }
| { mode: 'created'; vault: Vault }
| { mode: 'archived'; vault: Vault }
| { mode: 'credential-list'; vaultId: string; credentials: Credential[] }
| { mode: 'credential-added'; vaultId: string; credentialId: string }
| { mode: 'credential-archived'; vaultId: string; credentialId: string }
| { mode: 'error'; message: string };
function VaultRow({ vault }: { vault: Vault }): React.ReactNode {
const isArchived = !!vault.archived_at;
const createdAt = vault.created_at ? new Date(vault.created_at).toLocaleString() : '—';
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text bold>{vault.vault_id}</Text>
<Text dimColor> · </Text>
<Text color={(isArchived ? 'warning' : 'success') as keyof Theme}>{isArchived ? 'archived' : 'active'}</Text>
</Box>
<Text>Name: {vault.name}</Text>
<Text dimColor>Created: {createdAt}</Text>
</Box>
);
}
export function VaultView(props: Props): React.ReactNode {
if (props.mode === 'list') {
if (props.vaults.length === 0) {
return (
<Box>
<Text dimColor>No vaults found. Use /vault create &lt;name&gt; to create one.</Text>
</Box>
);
}
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Vaults ({props.vaults.length})</Text>
</Box>
{props.vaults.map(vault => (
<VaultRow key={vault.vault_id} vault={vault} />
))}
</Box>
);
}
if (props.mode === 'detail') {
const { vault } = props;
const isArchived = !!vault.archived_at;
const createdAt = vault.created_at ? new Date(vault.created_at).toLocaleString() : '—';
const archivedAt = vault.archived_at ? new Date(vault.archived_at).toLocaleString() : null;
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Vault: {vault.vault_id}</Text>
</Box>
<Text>Name: {vault.name}</Text>
<Text>
Status:{' '}
<Text color={(isArchived ? 'warning' : 'success') as keyof Theme}>{isArchived ? 'archived' : 'active'}</Text>
</Text>
<Text dimColor>Created: {createdAt}</Text>
{archivedAt ? <Text dimColor>Archived: {archivedAt}</Text> : null}
</Box>
);
}
if (props.mode === 'created') {
const { vault } = props;
return (
<Box flexDirection="column">
<Box>
<Text bold color={'success' as keyof Theme}>
Vault created
</Text>
</Box>
<Text>ID: {vault.vault_id}</Text>
<Text>Name: {vault.name}</Text>
</Box>
);
}
if (props.mode === 'archived') {
const { vault } = props;
const archivedAt = vault.archived_at ? new Date(vault.archived_at).toLocaleString() : '—';
return (
<Box flexDirection="column">
<Box>
<Text bold color={'warning' as keyof Theme}>
Vault archived
</Text>
</Box>
<Text>ID: {vault.vault_id}</Text>
<Text dimColor>Archived at: {archivedAt}</Text>
</Box>
);
}
if (props.mode === 'credential-list') {
const { vaultId, credentials } = props;
if (credentials.length === 0) {
return (
<Box>
<Text dimColor>
No credentials in vault {vaultId}. Use /vault add-credential {vaultId} &lt;key&gt; &lt;value&gt; to add one.
</Text>
</Box>
);
}
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>
Credentials in {vaultId} ({credentials.length})
</Text>
</Box>
{credentials.map(cred => {
const isArchived = !!cred.archived_at;
return (
<Box key={cred.credential_id} flexDirection="column" marginBottom={1}>
<Box>
<Text bold>{cred.credential_id}</Text>
<Text dimColor> · </Text>
{cred.kind ? <Text dimColor>{cred.kind}</Text> : null}
{isArchived ? (
<>
<Text dimColor> · </Text>
<Text color={'warning' as keyof Theme}>archived</Text>
</>
) : null}
</Box>
{/* SECURITY: credential value is never displayed */}
<Text dimColor>Value: ***mask***</Text>
</Box>
);
})}
</Box>
);
}
if (props.mode === 'credential-added') {
const { vaultId, credentialId } = props;
return (
<Box flexDirection="column">
<Box>
<Text bold color={'success' as keyof Theme}>
Credential added
</Text>
</Box>
<Text>ID: {credentialId}</Text>
<Text>Vault: {vaultId}</Text>
{/* SECURITY: credential value is never echoed back */}
<Text dimColor>Value: ***mask***</Text>
</Box>
);
}
if (props.mode === 'credential-archived') {
const { vaultId, credentialId } = props;
return (
<Box flexDirection="column">
<Box>
<Text bold color={'warning' as keyof Theme}>
Credential archived
</Text>
</Box>
<Text>ID: {credentialId}</Text>
<Text>Vault: {vaultId}</Text>
</Box>
);
}
// error mode
return (
<Box>
<Text color={'error' as keyof Theme}>{props.message}</Text>
</Box>
);
}

View File

@@ -0,0 +1,504 @@
/**
* Regression tests for vaultsApi.ts
*
* Key invariants under test:
* - archiveVault uses POST /v1/vaults/{id}/archive (not DELETE)
* - archiveCredential uses POST /v1/vaults/{id}/credentials/{cid}/archive
* - addCredential uses POST /v1/vaults/{id}/credentials
* - credential value must NEVER appear in URL or request body metadata
* - error messages sanitize IDs (only first 8 chars exposed)
* - 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-vaults-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 listVaults: typeof import('../vaultsApi.js').listVaults
let createVault: typeof import('../vaultsApi.js').createVault
let getVault: typeof import('../vaultsApi.js').getVault
let archiveVault: typeof import('../vaultsApi.js').archiveVault
let listCredentials: typeof import('../vaultsApi.js').listCredentials
let addCredential: typeof import('../vaultsApi.js').addCredential
let archiveCredential: typeof import('../vaultsApi.js').archiveCredential
beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../vaultsApi.js')
listVaults = mod.listVaults
createVault = mod.createVault
getVault = mod.getVault
archiveVault = mod.archiveVault
listCredentials = mod.listCredentials
addCredential = mod.addCredential
archiveCredential = mod.archiveCredential
})
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']
})
// ── SECURITY: credential value must not leak into URL ─────────────────────
describe('addCredential: credential value security', () => {
test('credential value is never placed in the URL', async () => {
const cred = {
credential_id: 'cred_1',
vault_id: 'vault_abc12345',
kind: 'api_key',
}
axiosPostMock.mockResolvedValueOnce({ data: cred, status: 201 })
await addCredential('vault_abc12345', 'MY_KEY', 'super-secret-value-xyz')
const calls = axiosPostMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
// Credential VALUE must NOT appear in the URL
expect(url).not.toContain('super-secret-value-xyz')
// Credential KEY (name) is OK in URL path
expect(url).toContain('vault_abc12345')
})
test('addCredential sends credential value in body (not URL)', async () => {
const cred = {
credential_id: 'cred_2',
vault_id: 'vault_xyz',
kind: 'api_key',
}
axiosPostMock.mockResolvedValueOnce({ data: cred, status: 201 })
await addCredential('vault_xyz', 'API_KEY', 'the-secret-value')
const calls = axiosPostMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const body = calls[0]?.[1] as Record<string, unknown>
// Body should contain the secret value (it needs to be sent somewhere)
expect(body).toHaveProperty('secret')
expect(body.secret).toBe('the-secret-value')
// But URL must NOT contain it
const url = calls[0]?.[0] as string
expect(url).not.toContain('the-secret-value')
})
})
// ── REGRESSION: archiveVault must use POST not DELETE ────────────────────
describe('archiveVault regression: must use POST not DELETE', () => {
test('archiveVault calls POST /v1/vaults/{id}/archive (not DELETE)', async () => {
const vault = {
vault_id: 'vault_arc',
name: 'Archived Vault',
archived_at: '2026-01-01T00:00:00Z',
}
axiosPostMock.mockResolvedValueOnce({ data: vault, status: 200 })
await archiveVault('vault_arc')
expect(axiosPostMock).toHaveBeenCalledTimes(1)
expect(axiosDeleteMock).not.toHaveBeenCalled()
const calls = axiosPostMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
expect(url).toContain('vault_arc')
expect(url).toContain('/archive')
expect(url).toContain('/v1/vaults/')
})
})
// ── REGRESSION: archiveCredential must use POST not DELETE ────────────────
describe('archiveCredential regression: must use POST not DELETE', () => {
test('archiveCredential calls POST .../credentials/{cid}/archive (not DELETE)', async () => {
const cred = {
credential_id: 'cred_arc',
vault_id: 'vault_1',
archived_at: '2026-01-01T00:00:00Z',
}
axiosPostMock.mockResolvedValueOnce({ data: cred, status: 200 })
await archiveCredential('vault_1', 'cred_arc')
expect(axiosPostMock).toHaveBeenCalledTimes(1)
expect(axiosDeleteMock).not.toHaveBeenCalled()
const calls = axiosPostMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
expect(url).toContain('vault_1')
expect(url).toContain('/credentials/')
expect(url).toContain('cred_arc')
expect(url).toContain('/archive')
})
})
// ── listVaults ────────────────────────────────────────────────────────────
describe('listVaults', () => {
test('returns vaults on 200', async () => {
const vaults = [
{
vault_id: 'vault_1',
name: 'My Vault',
created_at: '2026-01-01T00:00:00Z',
},
]
axiosGetMock.mockResolvedValueOnce({
data: { data: vaults },
status: 200,
})
const result = await listVaults()
expect(result).toHaveLength(1)
expect(result[0]!.vault_id).toBe('vault_1')
expect(axiosGetMock).toHaveBeenCalledTimes(1)
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('/v1/vaults')
})
test('returns empty array on empty response', async () => {
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
const result = await listVaults()
expect(result).toHaveLength(0)
})
test('throws 401 with friendly message', async () => {
const err = Object.assign(new Error('Unauthorized'), {
isAxiosError: true,
response: { status: 401, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listVaults()).rejects.toThrow(/login|authenticate/i)
})
test('throws 403 with subscription message', async () => {
const err = Object.assign(new Error('Forbidden'), {
isAxiosError: true,
response: { status: 403, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listVaults()).rejects.toThrow(/subscription|pro|max|team/i)
})
test('retries on 5xx and eventually throws', async () => {
const make5xx = () =>
Object.assign(new Error('Server Error'), {
isAxiosError: true,
response: { status: 500, data: {} },
})
axiosGetMock
.mockRejectedValueOnce(make5xx())
.mockRejectedValueOnce(make5xx())
.mockRejectedValueOnce(make5xx())
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listVaults()).rejects.toThrow()
expect(axiosGetMock).toHaveBeenCalledTimes(3)
}, 15000)
test('honors Retry-After header on 5xx', async () => {
const serverErr = Object.assign(new Error('Service Unavailable'), {
isAxiosError: true,
response: { status: 503, data: {}, headers: { 'retry-after': '0' } },
})
axiosGetMock
.mockRejectedValueOnce(serverErr)
.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
const result = await listVaults()
expect(result).toHaveLength(0)
expect(axiosGetMock).toHaveBeenCalledTimes(2)
})
})
// ── getVault ──────────────────────────────────────────────────────────────
describe('getVault', () => {
test('calls GET /v1/vaults/{id}', async () => {
const vault = { vault_id: 'vault_get', name: 'Work Vault' }
axiosGetMock.mockResolvedValueOnce({ data: vault, status: 200 })
const result = await getVault('vault_get')
expect(result.vault_id).toBe('vault_get')
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('vault_get')
expect(calls[0]?.[0]).toContain('/v1/vaults/')
})
test('throws 404 with not found message', async () => {
const err = Object.assign(new Error('Not Found'), {
isAxiosError: true,
response: { status: 404, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(getVault('nonexistent')).rejects.toThrow(/not found/i)
})
test('error message only exposes first 8 chars of vault id', async () => {
const err = Object.assign(new Error('Not Found'), {
isAxiosError: true,
response: { status: 404, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
// ID is longer than 8 chars — full ID must not appear in error message
const longId = 'vault_verylongidentifier_12345'
try {
await getVault(longId)
} catch (err2: unknown) {
const msg = err2 instanceof Error ? err2.message : String(err2)
// Full ID must NOT appear in message
expect(msg).not.toContain(longId)
}
})
})
// ── createVault ───────────────────────────────────────────────────────────
describe('createVault', () => {
test('sends POST /v1/vaults with name', async () => {
const vault = { vault_id: 'vault_new', name: 'My New Vault' }
axiosPostMock.mockResolvedValueOnce({ data: vault, status: 201 })
const result = await createVault('My New Vault')
expect(result.vault_id).toBe('vault_new')
const calls = axiosPostMock.mock.calls as unknown as [
string,
unknown,
unknown,
][]
const url = calls[0]?.[0] as string
const body = calls[0]?.[1] as Record<string, unknown>
expect(url).toContain('/v1/vaults')
expect(url).not.toContain('/v1/agents')
expect(body.name).toBe('My New Vault')
})
})
// ── listCredentials ───────────────────────────────────────────────────────
describe('listCredentials', () => {
test('calls GET /v1/vaults/{id}/credentials', async () => {
const creds = [
{ credential_id: 'cred_1', vault_id: 'vault_1', kind: 'api_key' },
]
axiosGetMock.mockResolvedValueOnce({ data: { data: creds }, status: 200 })
const result = await listCredentials('vault_1')
expect(result).toHaveLength(1)
expect(result[0]!.credential_id).toBe('cred_1')
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('vault_1')
expect(calls[0]?.[0]).toContain('/credentials')
})
test('response does NOT include secret field (server returns metadata only)', async () => {
const creds = [
{
credential_id: 'cred_safe',
vault_id: 'vault_1',
kind: 'api_key',
// NOTE: no 'secret' field — server never returns secret in list
},
]
axiosGetMock.mockResolvedValueOnce({ data: { data: creds }, status: 200 })
const result = await listCredentials('vault_1')
expect(result[0]).not.toHaveProperty('secret')
})
test('throws 404 when vault not found', async () => {
const err = Object.assign(new Error('Not Found'), {
isAxiosError: true,
response: { status: 404, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listCredentials('nonexistent')).rejects.toThrow(/not found/i)
})
})
// ── 429 rate-limit ────────────────────────────────────────────────────────
describe('429 rate-limit: not retried (non-5xx)', () => {
test('throws immediately on 429 without retry', async () => {
const err = Object.assign(new Error('Too Many Requests'), {
isAxiosError: true,
response: { status: 429, data: {}, headers: { 'retry-after': '60' } },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' &&
e !== null &&
'isAxiosError' in e &&
(e as { isAxiosError: boolean }).isAxiosError === true,
)
await expect(listVaults()).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 listVaults()
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 listVaults()
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 listVaults()
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 listVaults()
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 listVaults()
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
expect(calls[0]?.[0]).toContain('api.anthropic.com')
})
})

View File

@@ -0,0 +1,58 @@
/**
* Tests for vault index.tsx (command definition)
*/
import { describe, expect, test } from 'bun:test'
import type { LocalJSXCommandModule } from '../../../types/command.js'
describe('vaultCommand definition', () => {
test('command is type local-jsx', async () => {
const mod = await import('../index.js')
const cmd = mod.default
expect(cmd.type).toBe('local-jsx')
})
test('command name is vault', async () => {
const mod = await import('../index.js')
const cmd = mod.default
expect(cmd.name).toBe('vault')
})
test('command has vaults alias', async () => {
const mod = await import('../index.js')
const cmd = mod.default
expect(cmd.aliases).toContain('vaults')
})
test('command isEnabled returns true', async () => {
const mod = await import('../index.js')
const cmd = mod.default
expect(cmd.isEnabled?.()).toBe(true)
})
test('command isHidden is boolean (dynamic: false when ANTHROPIC_API_KEY set, true when absent)', async () => {
const mod = await import('../index.js')
const cmd = mod.default
// isHidden is !process.env['ANTHROPIC_API_KEY']: boolean at import time
expect(typeof cmd.isHidden).toBe('boolean')
})
test('isHidden reflects ANTHROPIC_API_KEY presence: hidden when key absent', () => {
// isHidden = !process.env['ANTHROPIC_API_KEY']
// We test the invariant directly since module is cached
const hasKey = Boolean(process.env['ANTHROPIC_API_KEY'])
// In CI/test environment without ANTHROPIC_API_KEY, isHidden should be true
// With key set, isHidden should be false
expect(typeof hasKey).toBe('boolean') // invariant: env var determines visibility
})
test('command load resolves callVault function', async () => {
const mod = await import('../index.js')
const cmd = mod.default as unknown as {
load: () => Promise<LocalJSXCommandModule>
}
expect(cmd.load).toBeDefined()
const loaded = await cmd.load()
expect(typeof loaded.call).toBe('function')
})
})

View File

@@ -0,0 +1,339 @@
/**
* Tests for launchVault.tsx
*
* IMPORTANT: Per feedback_mock_dependency_not_subject.md, we mock axios (lower dep),
* NOT the vaultsApi module itself, to avoid Bun mock.module process-level pollution.
*
* SECURITY: Tests verify credential value never appears in onDone message text.
*/
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)
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
mock.module('src/utils/auth.js', () => ({
getClaudeAIOAuthTokens: () => ({ accessToken: 'test-token' }),
}))
mock.module('src/services/oauth/client.js', () => ({
getOrganizationUUID: async () => 'org-uuid-test',
}))
mock.module('src/constants/oauth.js', () => ({
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
}))
mock.module('src/utils/teleport/api.js', () => ({
getOAuthHeaders: (token: string) => ({
Authorization: `Bearer ${token}`,
}),
}))
// ── Axios mock ──────────────────────────────────────────────────────────────
const axiosGetMock = mock(async () => ({}))
const axiosPostMock = mock(async () => ({}))
const axiosIsAxiosError = mock((err: unknown) => {
return (
typeof err === 'object' &&
err !== null &&
'isAxiosError' in err &&
(err as { isAxiosError: boolean }).isAxiosError === true
)
})
const axiosDeleteMock = mock(async () => ({}))
const axiosHandle = setupAxiosMock()
axiosHandle.stubs.get = axiosGetMock
axiosHandle.stubs.post = axiosPostMock
axiosHandle.stubs.delete = axiosDeleteMock
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
// ── Lazy import after mocks ─────────────────────────────────────────────────
let callVault: typeof import('../launchVault.js').callVault
beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../launchVault.js')
callVault = mod.callVault
})
afterAll(() => {
axiosHandle.useStubs = false
})
beforeEach(() => {
axiosGetMock.mockClear()
axiosPostMock.mockClear()
})
afterEach(() => {})
// ── list ──────────────────────────────────────────────────────────────────
describe('callVault list', () => {
test('calls listVaults and returns vault count in onDone', async () => {
const vaults = [{ vault_id: 'v1', name: 'Test Vault' }]
axiosGetMock.mockResolvedValueOnce({ data: { data: vaults }, status: 200 })
let onDoneMsg = ''
const onDone = (msg: string) => {
onDoneMsg = msg
}
const result = await callVault(
onDone as Parameters<typeof callVault>[0],
{} as Parameters<typeof callVault>[1],
'list',
)
expect(onDoneMsg).toMatch(/1 vault/)
expect(result).not.toBeNull()
})
test('empty vault list shows friendly message', async () => {
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
let onDoneMsg = ''
const onDone = (msg: string) => {
onDoneMsg = msg
}
await callVault(
onDone as Parameters<typeof callVault>[0],
{} as Parameters<typeof callVault>[1],
'',
)
expect(onDoneMsg).toMatch(/no vaults/i)
})
test('API error shows error in onDone', async () => {
const err = Object.assign(new Error('Unauthorized'), {
isAxiosError: true,
response: { status: 401, data: {} },
})
axiosGetMock.mockRejectedValueOnce(err)
axiosIsAxiosError.mockImplementation(
(e: unknown) =>
typeof e === 'object' && e !== null && 'isAxiosError' in e,
)
let onDoneMsg = ''
const onDone = (msg: string) => {
onDoneMsg = msg
}
await callVault(
onDone as Parameters<typeof callVault>[0],
{} as Parameters<typeof callVault>[1],
'list',
)
expect(onDoneMsg).toMatch(/failed|error|login|authenticate/i)
})
})
// ── create ────────────────────────────────────────────────────────────────
describe('callVault create', () => {
test('creates vault and returns vault_id in onDone', async () => {
axiosPostMock.mockResolvedValueOnce({
data: { vault_id: 'vault_new', name: 'My Vault' },
status: 201,
})
let onDoneMsg = ''
const onDone = (msg: string) => {
onDoneMsg = msg
}
await callVault(
onDone as Parameters<typeof callVault>[0],
{} as Parameters<typeof callVault>[1],
'create My Vault',
)
expect(onDoneMsg).toMatch(/created/)
expect(onDoneMsg).toMatch(/vault_new/)
})
test('create with no name → invalid args message', async () => {
let onDoneMsg = ''
const onDone = (msg: string) => {
onDoneMsg = msg
}
await callVault(
onDone as Parameters<typeof callVault>[0],
{} as Parameters<typeof callVault>[1],
'create',
)
expect(onDoneMsg).toMatch(/usage|name/i)
})
})
// ── get ───────────────────────────────────────────────────────────────────
describe('callVault get', () => {
test('fetches vault and displays detail', async () => {
axiosGetMock.mockResolvedValueOnce({
data: { vault_id: 'vault_123', name: 'Work' },
status: 200,
})
let onDoneMsg = ''
const onDone = (msg: string) => {
onDoneMsg = msg
}
const result = await callVault(
onDone as Parameters<typeof callVault>[0],
{} as Parameters<typeof callVault>[1],
'get vault_123',
)
expect(onDoneMsg).toMatch(/fetched/i)
expect(result).not.toBeNull()
})
test('get with no id → invalid args', async () => {
let onDoneMsg = ''
const onDone = (msg: string) => {
onDoneMsg = msg
}
await callVault(
onDone as Parameters<typeof callVault>[0],
{} as Parameters<typeof callVault>[1],
'get',
)
expect(onDoneMsg).toMatch(/usage|id/i)
})
})
// ── archive vault ─────────────────────────────────────────────────────────
describe('callVault archive', () => {
test('archives vault and confirms in onDone', async () => {
axiosPostMock.mockResolvedValueOnce({
data: {
vault_id: 'vault_arc',
name: 'Old',
archived_at: '2026-01-01T00:00:00Z',
},
status: 200,
})
let onDoneMsg = ''
const onDone = (msg: string) => {
onDoneMsg = msg
}
await callVault(
onDone as Parameters<typeof callVault>[0],
{} as Parameters<typeof callVault>[1],
'archive vault_arc',
)
expect(onDoneMsg).toMatch(/archived/i)
})
})
// ── add-credential ────────────────────────────────────────────────────────
describe('callVault add-credential', () => {
test('adds credential and confirms without leaking secret value in onDone', async () => {
axiosPostMock.mockResolvedValueOnce({
data: { credential_id: 'cred_new', vault_id: 'vault_1', kind: 'api_key' },
status: 201,
})
let onDoneMsg = ''
const onDone = (msg: string) => {
onDoneMsg = msg
}
await callVault(
onDone as Parameters<typeof callVault>[0],
{} as Parameters<typeof callVault>[1],
'add-credential vault_1 MY_SECRET the-actual-secret-value-xyz',
)
// onDone message must confirm credential added
expect(onDoneMsg).toMatch(/added|created/i)
// SECURITY: the actual secret value must NOT appear in onDone message
expect(onDoneMsg).not.toContain('the-actual-secret-value-xyz')
})
test('add-credential missing value → invalid args', async () => {
let onDoneMsg = ''
const onDone = (msg: string) => {
onDoneMsg = msg
}
await callVault(
onDone as Parameters<typeof callVault>[0],
{} as Parameters<typeof callVault>[1],
'add-credential vault_1 MY_KEY',
)
expect(onDoneMsg).toMatch(/usage|value|non-empty/i)
})
test('credential value does not appear in stdout output at all', async () => {
axiosPostMock.mockResolvedValueOnce({
data: { credential_id: 'cred_secure', vault_id: 'v1', kind: 'api_key' },
status: 201,
})
const messages: string[] = []
const onDone = (msg: string) => {
messages.push(msg)
}
await callVault(
onDone as Parameters<typeof callVault>[0],
{} as Parameters<typeof callVault>[1],
'add-credential v1 KEY super-secret-do-not-leak',
)
// grep: none of the captured messages must contain the secret
for (const msg of messages) {
expect(msg).not.toContain('super-secret-do-not-leak')
}
})
})
// ── archive-credential ────────────────────────────────────────────────────
describe('callVault archive-credential', () => {
test('archives credential and confirms in onDone', async () => {
axiosPostMock.mockResolvedValueOnce({
data: {
credential_id: 'cred_arc',
vault_id: 'vault_1',
archived_at: '2026-01-01T00:00:00Z',
},
status: 200,
})
let onDoneMsg = ''
const onDone = (msg: string) => {
onDoneMsg = msg
}
await callVault(
onDone as Parameters<typeof callVault>[0],
{} as Parameters<typeof callVault>[1],
'archive-credential vault_1 cred_arc',
)
expect(onDoneMsg).toMatch(/archived/i)
})
test('archive-credential missing cred_id → invalid args', async () => {
let onDoneMsg = ''
const onDone = (msg: string) => {
onDoneMsg = msg
}
await callVault(
onDone as Parameters<typeof callVault>[0],
{} as Parameters<typeof callVault>[1],
'archive-credential vault_1',
)
expect(onDoneMsg).toMatch(/usage|credential_id|cred/i)
})
})
// ── invalid subcommand ────────────────────────────────────────────────────
describe('callVault invalid subcommand', () => {
test('unknown subcommand → usage message in onDone', async () => {
let onDoneMsg = ''
const onDone = (msg: string) => {
onDoneMsg = msg
}
await callVault(
onDone as Parameters<typeof callVault>[0],
{} as Parameters<typeof callVault>[1],
'delete vault_123',
)
expect(onDoneMsg).toMatch(/usage/i)
})
})

View File

@@ -0,0 +1,143 @@
/**
* Tests for vault parseArgs.ts
*/
import { describe, expect, test } from 'bun:test'
import { parseVaultArgs } from '../parseArgs.js'
describe('parseVaultArgs', () => {
// ── list ──────────────────────────────────────────────────────────────────
test('empty string → list', () => {
expect(parseVaultArgs('')).toEqual({ action: 'list' })
})
test('"list" → list', () => {
expect(parseVaultArgs('list')).toEqual({ action: 'list' })
})
test('" list " with whitespace → list', () => {
expect(parseVaultArgs(' list ')).toEqual({ action: 'list' })
})
// ── create ────────────────────────────────────────────────────────────────
test('create with name → create action', () => {
expect(parseVaultArgs('create My Work Vault')).toEqual({
action: 'create',
name: 'My Work Vault',
})
})
test('create with no name → invalid', () => {
const result = parseVaultArgs('create')
expect(result.action).toBe('invalid')
if (result.action === 'invalid') {
expect(result.reason).toMatch(/name/i)
}
})
// ── get ───────────────────────────────────────────────────────────────────
test('get with id → get action', () => {
expect(parseVaultArgs('get vault_123')).toEqual({
action: 'get',
id: 'vault_123',
})
})
test('get with no id → invalid', () => {
const result = parseVaultArgs('get')
expect(result.action).toBe('invalid')
if (result.action === 'invalid') {
expect(result.reason).toMatch(/id/i)
}
})
// ── archive ───────────────────────────────────────────────────────────────
test('archive with id → archive action', () => {
expect(parseVaultArgs('archive vault_456')).toEqual({
action: 'archive',
id: 'vault_456',
})
})
test('archive with no id → invalid', () => {
const result = parseVaultArgs('archive')
expect(result.action).toBe('invalid')
if (result.action === 'invalid') {
expect(result.reason).toMatch(/id/i)
}
})
// ── add-credential ────────────────────────────────────────────────────────
test('add-credential with vault_id, key, value → add-credential action', () => {
expect(
parseVaultArgs('add-credential vault_123 MY_KEY secret-value'),
).toEqual({
action: 'add-credential',
vaultId: 'vault_123',
key: 'MY_KEY',
secret: 'secret-value',
})
})
test('add-credential with multi-word value → joins value correctly', () => {
const result = parseVaultArgs(
'add-credential vault_xyz API_KEY my secret value here',
)
expect(result.action).toBe('add-credential')
if (result.action === 'add-credential') {
expect(result.secret).toBe('my secret value here')
}
})
test('add-credential with missing value → invalid', () => {
const result = parseVaultArgs('add-credential vault_123 MY_KEY')
expect(result.action).toBe('invalid')
if (result.action === 'invalid') {
expect(result.reason).toMatch(/value|non-empty/i)
}
})
test('add-credential with missing key → invalid', () => {
const result = parseVaultArgs('add-credential vault_123')
expect(result.action).toBe('invalid')
if (result.action === 'invalid') {
expect(result.reason).toMatch(/key|value/i)
}
})
test('add-credential with no args → invalid', () => {
const result = parseVaultArgs('add-credential')
expect(result.action).toBe('invalid')
})
// ── archive-credential ────────────────────────────────────────────────────
test('archive-credential with vault_id and cred_id → archive-credential action', () => {
expect(parseVaultArgs('archive-credential vault_123 cred_456')).toEqual({
action: 'archive-credential',
vaultId: 'vault_123',
credentialId: 'cred_456',
})
})
test('archive-credential with missing cred_id → invalid', () => {
const result = parseVaultArgs('archive-credential vault_123')
expect(result.action).toBe('invalid')
if (result.action === 'invalid') {
expect(result.reason).toMatch(/credential_id|cred/i)
}
})
test('archive-credential with no args → invalid', () => {
const result = parseVaultArgs('archive-credential')
expect(result.action).toBe('invalid')
})
// ── unknown subcommand ────────────────────────────────────────────────────
test('unknown subcommand → invalid with usage hint', () => {
const result = parseVaultArgs('delete vault_123')
expect(result.action).toBe('invalid')
if (result.action === 'invalid') {
expect(result.reason).toMatch(/unknown.*delete/i)
}
})
})

View File

@@ -0,0 +1,28 @@
import { getGlobalConfig } from '../../utils/config.js';
import type { Command } from '../../types/command.js';
const vaultCommand: Command = {
type: 'local-jsx',
name: 'vault',
aliases: ['vaults'],
description:
'Manage remote secret vaults and credentials for cloud agents. Requires Claude Pro/Max/Team subscription.',
// REPL markdown renderer strips `<...>` as HTML tags — use uppercase.
argumentHint:
'list | create NAME | get ID | archive ID | add-credential VAULT_ID KEY VALUE | archive-credential VAULT_ID CRED_ID',
// 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('./launchVault.js');
return { call: m.callVault };
},
};
export default vaultCommand;

View File

@@ -0,0 +1,109 @@
import React from 'react';
import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js';
import {
addCredential,
archiveCredential,
archiveVault,
createVault,
getVault,
listCredentials,
listVaults,
} from './vaultsApi.js';
import { VaultView } from './VaultView.js';
import { parseVaultArgs } from './parseArgs.js';
import { launchCommand } from '../_shared/launchCommand.js';
const USAGE =
'Usage: /vault list | create NAME | get ID | archive ID | add-credential VAULT_ID KEY VALUE | archive-credential VAULT_ID CRED_ID';
type VaultViewProps = React.ComponentProps<typeof VaultView>;
async function dispatchVault(
parsed: ReturnType<typeof parseVaultArgs>,
onDone: LocalJSXCommandOnDone,
): Promise<VaultViewProps | null> {
if (parsed.action === 'list') {
const vaults = await listVaults();
onDone(vaults.length === 0 ? 'No vaults found.' : `${vaults.length} vault(s).`, { display: 'system' });
return { mode: 'list', vaults };
}
if (parsed.action === 'create') {
const { name } = parsed;
const vault = await createVault(name);
onDone(`Vault created: ${vault.vault_id}`, { display: 'system' });
return { mode: 'created', vault };
}
if (parsed.action === 'get') {
const { id } = parsed;
const vault = await getVault(id);
onDone(`Vault fetched.`, { display: 'system' });
return { mode: 'detail', vault };
}
if (parsed.action === 'archive') {
const { id } = parsed;
const vault = await archiveVault(id);
onDone(`Vault archived.`, { display: 'system' });
return { mode: 'archived', vault };
}
if (parsed.action === 'add-credential') {
const { vaultId, key, secret } = parsed;
const cred = await addCredential(vaultId, key, secret);
// SECURITY: credential value is NOT echoed in onDone message
onDone(`Credential added: ${cred.credential_id}`, { display: 'system' });
return { mode: 'credential-added', vaultId, credentialId: cred.credential_id };
}
if (parsed.action === 'archive-credential') {
const { vaultId, credentialId } = parsed;
await archiveCredential(vaultId, credentialId);
onDone(`Credential ${credentialId} archived.`, { display: 'system' });
return { mode: 'credential-archived', vaultId, credentialId };
}
// Fallback: list vaults for any unrecognised action (matches original behaviour)
const vaults = await listVaults();
onDone(vaults.length === 0 ? 'No vaults found.' : `${vaults.length} vault(s).`, { display: 'system' });
return { mode: 'list', vaults };
}
export const callVault: LocalJSXCommandCall = launchCommand<ReturnType<typeof parseVaultArgs>, VaultViewProps>({
commandName: 'vault',
parseArgs: (raw: string) => {
const result = parseVaultArgs(raw);
if (result.action === 'invalid') {
return { action: 'invalid' as const, reason: `${USAGE}\n${result.reason}` };
}
return result;
},
dispatch: dispatchVault,
View: VaultView,
errorView: (msg: string) => React.createElement(VaultView, { mode: 'error', message: msg }),
});
export const callVaultListCredentials = async (
onDone: (msg: string, opts: { display: string }) => void,
vaultId: string,
): Promise<React.ReactNode> => {
try {
const credentials = await listCredentials(vaultId);
onDone(
credentials.length === 0
? `No credentials in vault ${vaultId}.`
: `${credentials.length} credential(s) in vault ${vaultId}.`,
{ display: 'system' },
);
return React.createElement(VaultView, {
mode: 'credential-list',
vaultId,
credentials,
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
onDone(`Failed to list credentials: ${msg}`, { display: 'system' });
return React.createElement(VaultView, { mode: 'error', message: msg });
}
};

View File

@@ -0,0 +1,128 @@
/**
* Parse the args string for the /vault command.
*
* Supported sub-commands:
* list → { action: 'list' }
* create <name> → { action: 'create', name }
* get <id> → { action: 'get', id }
* archive <id> → { action: 'archive', id }
* add-credential <vault_id> <key> <value> → { action: 'add-credential', vaultId, key, secret }
* archive-credential <vault_id> <cred_id> → { action: 'archive-credential', vaultId, credentialId }
* (empty) → { action: 'list' }
* anything else → { action: 'invalid', reason }
*/
export type VaultArgs =
| { action: 'list' }
| { action: 'create'; name: string }
| { action: 'get'; id: string }
| { action: 'archive'; id: string }
| {
action: 'add-credential'
vaultId: string
key: string
secret: string
}
| { action: 'archive-credential'; vaultId: string; credentialId: string }
| { action: 'invalid'; reason: string }
const USAGE =
'Usage: /vault list | create NAME | get ID | archive ID | add-credential VAULT_ID KEY VALUE | archive-credential VAULT_ID CRED_ID'
export function parseVaultArgs(args: string): VaultArgs {
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()
// ── create ────────────────────────────────────────────────────────────────
if (subCmd === 'create') {
if (!rest) {
return {
action: 'invalid',
reason: 'create requires a vault name, e.g. create "My Work Vault"',
}
}
return { action: 'create', name: rest }
}
// ── get ───────────────────────────────────────────────────────────────────
if (subCmd === 'get') {
if (!rest) {
return { action: 'invalid', reason: 'get requires a vault id' }
}
const id = rest.split(/\s+/)[0]
/* istanbul ignore next */
if (!id) {
return { action: 'invalid', reason: 'get requires a vault id' }
}
return { action: 'get', id }
}
// ── archive ───────────────────────────────────────────────────────────────
if (subCmd === 'archive') {
if (!rest) {
return { action: 'invalid', reason: 'archive requires a vault id' }
}
const id = rest.split(/\s+/)[0]
/* istanbul ignore next */
if (!id) {
return { action: 'invalid', reason: 'archive requires a vault id' }
}
return { action: 'archive', id }
}
// ── add-credential ────────────────────────────────────────────────────────
if (subCmd === 'add-credential') {
const parts = rest.split(/\s+/)
if (parts.length < 2 || !parts[0] || !parts[1]) {
return {
action: 'invalid',
reason:
'add-credential requires vault_id, key, and value, e.g. add-credential vault_123 MY_API_KEY <value>',
}
}
const vaultId = parts[0]
const key = parts[1]
const secret = parts.slice(2).join(' ')
if (!secret.trim()) {
return {
action: 'invalid',
reason: 'add-credential requires a non-empty credential value',
}
}
return {
action: 'add-credential',
vaultId,
key,
secret: secret.trim(),
}
}
// ── archive-credential ────────────────────────────────────────────────────
if (subCmd === 'archive-credential') {
const parts = rest.split(/\s+/)
if (parts.length < 2 || !parts[0] || !parts[1]) {
return {
action: 'invalid',
reason:
'archive-credential requires vault_id and credential_id, e.g. archive-credential vault_123 cred_456',
}
}
return {
action: 'archive-credential',
vaultId: parts[0],
credentialId: parts[1],
}
}
return {
action: 'invalid',
reason: `Unknown sub-command "${subCmd}". ${USAGE}`,
}
}

View File

@@ -0,0 +1,290 @@
/**
* Thin HTTP client for the /v1/vaults endpoint.
*
* Key spec facts (from binary reverse-engineering of v2.1.123):
* - list vaults: GET /v1/vaults
* - create vault: POST /v1/vaults
* - get vault: GET /v1/vaults/{id}
* - archive vault: POST /v1/vaults/{id}/archive ← POST not DELETE
* - list credentials: GET /v1/vaults/{id}/credentials
* - add credential: POST /v1/vaults/{id}/credentials (inferred)
* - archive credential: POST /v1/vaults/{id}/credentials/{cid}/archive ← POST not DELETE
*
* SECURITY INVARIANTS:
* - Credential `secret` value is NEVER logged or included in URLs
* - Error messages expose only the first 8 chars of any vault/credential ID
* - Zero tengu_vault_* telemetry (matches upstream: security-sensitive path)
*
* Reuses the same base-URL + auth-header pattern as memoryStoresApi.ts / triggersApi.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'
import { sanitizeId } from '../../utils/sanitizeId.js'
export type Vault = {
vault_id: string
name: string
archived_at?: string | null
created_at?: string
}
export type Credential = {
credential_id: string
vault_id: string
kind?: string
archived_at?: string | null
created_at?: string
// NOTE: 'secret' field intentionally absent — server never returns secret in responses
}
export type CreateVaultBody = {
name: string
}
export type AddCredentialBody = {
key: string
secret: string
kind?: string
}
type ListVaultsResponse = {
data: Vault[]
}
type ListCredentialsResponse = {
data: Credential[]
}
// Vaults share the managed-agents umbrella beta header.
const VAULTS_BETA_HEADER = 'managed-agents-2026-04-01'
const MAX_RETRIES = 3
// sanitizeId imported from ../../utils/sanitizeId.js (H3: single source of truth)
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
class VaultsApiError extends Error {
constructor(
message: string,
public readonly statusCode: number,
) {
super(message)
this.name = 'VaultsApiError'
}
}
async function buildHeaders(): Promise<Record<string, string>> {
// /v1/vaults requires a workspace-scoped API key (sk-ant-api03-*).
// Subscription OAuth bearer tokens always 401 here (server-enforced plane separation).
// 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 VaultsApiError(msg, 501)
}
assertWorkspaceHost(vaultsBaseUrl())
return {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'anthropic-beta': VAULTS_BETA_HEADER,
'content-type': 'application/json',
}
}
function vaultsBaseUrl(): string {
return `${getOauthConfig().BASE_API_URL}/v1/vaults`
}
function classifyError(err: unknown, id?: string): VaultsApiError {
const safeId = id ? ` (${sanitizeId(id)})` : ''
if (axios.isAxiosError(err)) {
const status = err.response?.status ?? 0
if (status === 401) {
return new VaultsApiError(
'Authentication failed. Please run /login to re-authenticate.',
401,
)
}
if (status === 403) {
return new VaultsApiError(
'Subscription required. Vault management requires a Claude Pro/Max/Team subscription.',
403,
)
}
if (status === 404) {
return new VaultsApiError(`Vault or credential not found${safeId}.`, 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 VaultsApiError(`Rate limit exceeded.${detail}`, 429)
}
const msg =
(err.response?.data as { error?: { message?: string } } | undefined)
?.error?.message ?? err.message
return new VaultsApiError(msg, status)
}
if (err instanceof VaultsApiError) return err
return new VaultsApiError(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>, id?: string): Promise<T> {
let lastErr: VaultsApiError | undefined
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
return await fn()
} catch (err: unknown) {
const classified = classifyError(err, id)
// 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 VaultsApiError('Request failed after retries', 0)
}
// ── Vault CRUD ─────────────────────────────────────────────────────────────
export async function listVaults(): Promise<Vault[]> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.get<ListVaultsResponse>(vaultsBaseUrl(), {
headers,
})
return response.data.data ?? []
})
}
export async function createVault(name: string): Promise<Vault> {
return withRetry(async () => {
const headers = await buildHeaders()
const body: CreateVaultBody = { name }
const response = await axios.post<Vault>(vaultsBaseUrl(), body, {
headers,
})
return response.data
})
}
export async function getVault(id: string): Promise<Vault> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.get<Vault>(`${vaultsBaseUrl()}/${id}`, {
headers,
})
return response.data
}, id)
}
/**
* Archive a vault (soft delete).
*
* IMPORTANT: The upstream API uses POST (not DELETE) for archiving.
* Binary literal evidence: "POST /v1/vaults/{vault_id}/archive"
*/
export async function archiveVault(id: string): Promise<Vault> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.post<Vault>(
`${vaultsBaseUrl()}/${id}/archive`,
{},
{ headers },
)
return response.data
}, id)
}
// ── Credential CRUD ────────────────────────────────────────────────────────
export async function listCredentials(vaultId: string): Promise<Credential[]> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.get<ListCredentialsResponse>(
`${vaultsBaseUrl()}/${vaultId}/credentials`,
{ headers },
)
return response.data.data ?? []
}, vaultId)
}
/**
* Add a credential to a vault.
*
* SECURITY: The `secret` value is passed in the request body only.
* It is NEVER included in URL parameters or logged.
*/
export async function addCredential(
vaultId: string,
key: string,
secret: string,
): Promise<Credential> {
return withRetry(async () => {
const headers = await buildHeaders()
const body: AddCredentialBody = { key, secret }
const response = await axios.post<Credential>(
`${vaultsBaseUrl()}/${vaultId}/credentials`,
body,
{ headers },
)
return response.data
}, vaultId)
}
/**
* Archive a credential (soft delete).
*
* IMPORTANT: Uses POST (not DELETE) for archiving.
* Binary literal evidence: "POST /v1/vaults/{vault_id}/credentials/{credential_id}/archive"
*/
export async function archiveCredential(
vaultId: string,
credentialId: string,
): Promise<Credential> {
return withRetry(async () => {
const headers = await buildHeaders()
const response = await axios.post<Credential>(
`${vaultsBaseUrl()}/${vaultId}/credentials/${credentialId}/archive`,
{},
{ headers },
)
return response.data
}, vaultId)
}