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