From 2437040b5b19d54bdf7c237b123f81bfbb349db3 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 9 May 2026 23:04:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BA=91=E7=AB=AF?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=BD=E4=BB=A4=EF=BC=88memory-stores?= =?UTF-8?q?=E3=80=81vault=E3=80=81schedule=E3=80=81skill-store=E3=80=81age?= =?UTF-8?q?nts-platform=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /memory-stores: 远程记忆存储管理 - /vault: 密钥保险库管理 - /schedule: 云端定时触发器管理(cron) - /skill-store: 技能商店浏览和安装 - /agents-platform: 远程 agent 调度管理 Co-Authored-By: glm-5-turbo --- .../agents-platform/AgentsPlatformView.tsx | 96 +++ .../__tests__/AgentsPlatformView.test.tsx | 127 ++++ .../__tests__/agentsApi.test.ts | 382 ++++++++++++ .../agents-platform/__tests__/index.test.ts | 66 ++ .../__tests__/launchAgentsPlatform.test.ts | 262 ++++++++ .../__tests__/parseArgs.test.ts | 116 ++++ src/commands/agents-platform/agentsApi.ts | 206 ++++++ src/commands/agents-platform/index.js | 5 - src/commands/agents-platform/index.ts | 29 + .../agents-platform/launchAgentsPlatform.tsx | 132 ++++ src/commands/agents-platform/parseArgs.ts | 102 +++ .../memory-stores/MemoryStoresView.tsx | 263 ++++++++ .../memory-stores/__tests__/api.test.ts | 586 ++++++++++++++++++ .../memory-stores/__tests__/index.test.ts | 69 +++ .../__tests__/launchMemoryStores.test.ts | 380 ++++++++++++ .../memory-stores/__tests__/parseArgs.test.ts | 190 ++++++ src/commands/memory-stores/index.ts | 30 + .../memory-stores/launchMemoryStores.tsx | 279 +++++++++ src/commands/memory-stores/memoryStoresApi.ts | 377 +++++++++++ src/commands/memory-stores/parseArgs.ts | 207 +++++++ src/commands/schedule/ScheduleView.tsx | 164 +++++ src/commands/schedule/__tests__/api.test.ts | 354 +++++++++++ src/commands/schedule/__tests__/index.test.ts | 66 ++ .../schedule/__tests__/launchSchedule.test.ts | 307 +++++++++ .../schedule/__tests__/parseArgs.test.ts | 184 ++++++ src/commands/schedule/index.ts | 27 + src/commands/schedule/launchSchedule.tsx | 230 +++++++ src/commands/schedule/parseArgs.ts | 181 ++++++ src/commands/schedule/triggersApi.ts | 247 ++++++++ src/commands/skill-store/SkillStoreView.tsx | 180 ++++++ .../skill-store/__tests__/api.test.ts | 401 ++++++++++++ .../skill-store/__tests__/index.test.ts | 44 ++ .../__tests__/launchSkillStore.test.ts | 419 +++++++++++++ .../skill-store/__tests__/parseArgs.test.ts | 146 +++++ src/commands/skill-store/index.tsx | 28 + src/commands/skill-store/launchSkillStore.tsx | 237 +++++++ src/commands/skill-store/parseArgs.ts | 155 +++++ src/commands/skill-store/skillsApi.ts | 256 ++++++++ src/commands/vault/VaultView.tsx | 185 ++++++ src/commands/vault/__tests__/api.test.ts | 504 +++++++++++++++ src/commands/vault/__tests__/index.test.ts | 58 ++ .../vault/__tests__/launchVault.test.ts | 339 ++++++++++ .../vault/__tests__/parseArgs.test.ts | 143 +++++ src/commands/vault/index.tsx | 28 + src/commands/vault/launchVault.tsx | 109 ++++ src/commands/vault/parseArgs.ts | 128 ++++ src/commands/vault/vaultsApi.ts | 290 +++++++++ 47 files changed, 9309 insertions(+), 5 deletions(-) create mode 100644 src/commands/agents-platform/AgentsPlatformView.tsx create mode 100644 src/commands/agents-platform/__tests__/AgentsPlatformView.test.tsx create mode 100644 src/commands/agents-platform/__tests__/agentsApi.test.ts create mode 100644 src/commands/agents-platform/__tests__/index.test.ts create mode 100644 src/commands/agents-platform/__tests__/launchAgentsPlatform.test.ts create mode 100644 src/commands/agents-platform/__tests__/parseArgs.test.ts create mode 100644 src/commands/agents-platform/agentsApi.ts delete mode 100644 src/commands/agents-platform/index.js create mode 100644 src/commands/agents-platform/index.ts create mode 100644 src/commands/agents-platform/launchAgentsPlatform.tsx create mode 100644 src/commands/agents-platform/parseArgs.ts create mode 100644 src/commands/memory-stores/MemoryStoresView.tsx create mode 100644 src/commands/memory-stores/__tests__/api.test.ts create mode 100644 src/commands/memory-stores/__tests__/index.test.ts create mode 100644 src/commands/memory-stores/__tests__/launchMemoryStores.test.ts create mode 100644 src/commands/memory-stores/__tests__/parseArgs.test.ts create mode 100644 src/commands/memory-stores/index.ts create mode 100644 src/commands/memory-stores/launchMemoryStores.tsx create mode 100644 src/commands/memory-stores/memoryStoresApi.ts create mode 100644 src/commands/memory-stores/parseArgs.ts create mode 100644 src/commands/schedule/ScheduleView.tsx create mode 100644 src/commands/schedule/__tests__/api.test.ts create mode 100644 src/commands/schedule/__tests__/index.test.ts create mode 100644 src/commands/schedule/__tests__/launchSchedule.test.ts create mode 100644 src/commands/schedule/__tests__/parseArgs.test.ts create mode 100644 src/commands/schedule/index.ts create mode 100644 src/commands/schedule/launchSchedule.tsx create mode 100644 src/commands/schedule/parseArgs.ts create mode 100644 src/commands/schedule/triggersApi.ts create mode 100644 src/commands/skill-store/SkillStoreView.tsx create mode 100644 src/commands/skill-store/__tests__/api.test.ts create mode 100644 src/commands/skill-store/__tests__/index.test.ts create mode 100644 src/commands/skill-store/__tests__/launchSkillStore.test.ts create mode 100644 src/commands/skill-store/__tests__/parseArgs.test.ts create mode 100644 src/commands/skill-store/index.tsx create mode 100644 src/commands/skill-store/launchSkillStore.tsx create mode 100644 src/commands/skill-store/parseArgs.ts create mode 100644 src/commands/skill-store/skillsApi.ts create mode 100644 src/commands/vault/VaultView.tsx create mode 100644 src/commands/vault/__tests__/api.test.ts create mode 100644 src/commands/vault/__tests__/index.test.ts create mode 100644 src/commands/vault/__tests__/launchVault.test.ts create mode 100644 src/commands/vault/__tests__/parseArgs.test.ts create mode 100644 src/commands/vault/index.tsx create mode 100644 src/commands/vault/launchVault.tsx create mode 100644 src/commands/vault/parseArgs.ts create mode 100644 src/commands/vault/vaultsApi.ts diff --git a/src/commands/agents-platform/AgentsPlatformView.tsx b/src/commands/agents-platform/AgentsPlatformView.tsx new file mode 100644 index 000000000..6ecca11dd --- /dev/null +++ b/src/commands/agents-platform/AgentsPlatformView.tsx @@ -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 ( + + + {agent.id} + · + {agent.status} + + Schedule: {schedule} + Prompt: {agent.prompt} + Next run: {nextRun} + + ); +} + +export function AgentsPlatformView(props: Props): React.ReactNode { + if (props.mode === 'list') { + if (props.agents.length === 0) { + return ( + + + No scheduled agents. Use /agents-platform create <cron> <prompt> to create one. + + + ); + } + return ( + + + Scheduled Agents ({props.agents.length}) + + {props.agents.map(agent => ( + + ))} + + ); + } + + if (props.mode === 'created') { + const schedule = cronToHuman(props.agent.cron_expr, { utc: true }); + return ( + + + + Agent created + + + ID: {props.agent.id} + Schedule: {schedule} + Prompt: {props.agent.prompt} + Status: {props.agent.status} + + ); + } + + if (props.mode === 'deleted') { + return ( + + Agent {props.id} deleted. + + ); + } + + if (props.mode === 'ran') { + return ( + + + Agent {props.id} triggered. + + Run ID: {props.runId} + + ); + } + + // error mode + return ( + + {props.message} + + ); +} diff --git a/src/commands/agents-platform/__tests__/AgentsPlatformView.test.tsx b/src/commands/agents-platform/__tests__/AgentsPlatformView.test.tsx new file mode 100644 index 000000000..5dc212c99 --- /dev/null +++ b/src/commands/agents-platform/__tests__/AgentsPlatformView.test.tsx @@ -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(); + expect(out).toContain('No scheduled agents'); + }); + + test('non-empty list shows agent count', async () => { + const out = await renderToString(); + expect(out).toContain('Scheduled Agents (1)'); + }); + + test('non-empty list shows agent id', async () => { + const out = await renderToString(); + expect(out).toContain('agt_abc123'); + }); + + test('non-empty list shows agent status', async () => { + const out = await renderToString(); + expect(out).toContain('active'); + }); + + test('non-empty list shows human-readable schedule', async () => { + const out = await renderToString(); + expect(out).toContain('HumanCron(0 9 * * 1)'); + }); + + test('list shows agent prompt', async () => { + const out = await renderToString(); + expect(out).toContain('Run standup report'); + }); + + test('list shows next run date', async () => { + const out = await renderToString(); + // 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(); + expect(out).toContain('—'); + }); + + test('multiple agents rendered', async () => { + const agent2 = { ...sampleAgent, id: 'agt_xyz', cron_expr: '0 10 * * 2' }; + const out = await renderToString(); + 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(); + expect(out).toContain('Agent created'); + }); + + test('shows agent id', async () => { + const out = await renderToString(); + expect(out).toContain('agt_abc123'); + }); + + test('shows schedule', async () => { + const out = await renderToString(); + expect(out).toContain('HumanCron(0 9 * * 1)'); + }); + + test('shows prompt', async () => { + const out = await renderToString(); + expect(out).toContain('Run standup report'); + }); +}); + +describe('AgentsPlatformView deleted mode', () => { + test('shows deleted confirmation with id', async () => { + const out = await renderToString(); + expect(out).toContain('agt_abc123'); + expect(out).toContain('deleted'); + }); +}); + +describe('AgentsPlatformView ran mode', () => { + test('shows triggered with agent id', async () => { + const out = await renderToString(); + expect(out).toContain('agt_abc123'); + expect(out).toContain('triggered'); + }); + + test('shows run id', async () => { + const out = await renderToString(); + expect(out).toContain('run_xyz'); + }); +}); + +describe('AgentsPlatformView error mode', () => { + test('shows error message', async () => { + const out = await renderToString(); + expect(out).toContain('Network failure'); + }); +}); diff --git a/src/commands/agents-platform/__tests__/agentsApi.test.ts b/src/commands/agents-platform/__tests__/agentsApi.test.ts new file mode 100644 index 000000000..02ad75bca --- /dev/null +++ b/src/commands/agents-platform/__tests__/agentsApi.test.ts @@ -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, + 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 }, + ][] + 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 }, + ][] + 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 }, + ][] + 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 }, + ][] + 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') + }) +}) diff --git a/src/commands/agents-platform/__tests__/index.test.ts b/src/commands/agents-platform/__tests__/index.test.ts new file mode 100644 index 000000000..f542522d1 --- /dev/null +++ b/src/commands/agents-platform/__tests__/index.test.ts @@ -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') + }) +}) diff --git a/src/commands/agents-platform/__tests__/launchAgentsPlatform.test.ts b/src/commands/agents-platform/__tests__/launchAgentsPlatform.test.ts new file mode 100644 index 000000000..a2b9d623b --- /dev/null +++ b/src/commands/agents-platform/__tests__/launchAgentsPlatform.test.ts @@ -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[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(), + ) + }) +}) diff --git a/src/commands/agents-platform/__tests__/parseArgs.test.ts b/src/commands/agents-platform/__tests__/parseArgs.test.ts new file mode 100644 index 000000000..a5929a492 --- /dev/null +++ b/src/commands/agents-platform/__tests__/parseArgs.test.ts @@ -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') + }) +}) diff --git a/src/commands/agents-platform/agentsApi.ts b/src/commands/agents-platform/agentsApi.ts new file mode 100644 index 000000000..582756a20 --- /dev/null +++ b/src/commands/agents-platform/agentsApi.ts @@ -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 { + 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> { + // /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 | 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(fn: () => Promise): Promise { + 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 | 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 { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.get(agentsBaseUrl(), { + headers, + }) + return response.data.data ?? [] + }) +} + +export async function createAgent( + cron: string, + prompt: string, +): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.post( + 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 { + return withRetry(async () => { + const headers = await buildHeaders() + await axios.delete(`${agentsBaseUrl()}/${id}`, { headers }) + }) +} + +export async function runAgent(id: string): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.post( + `${agentsBaseUrl()}/${id}/run`, + {}, + { headers }, + ) + return response.data + }) +} diff --git a/src/commands/agents-platform/index.js b/src/commands/agents-platform/index.js deleted file mode 100644 index 502a6e13e..000000000 --- a/src/commands/agents-platform/index.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - name: 'agents-platform', - type: 'local', - isEnabled: () => false, -} diff --git a/src/commands/agents-platform/index.ts b/src/commands/agents-platform/index.ts new file mode 100644 index 000000000..516edc040 --- /dev/null +++ b/src/commands/agents-platform/index.ts @@ -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 diff --git a/src/commands/agents-platform/launchAgentsPlatform.tsx b/src/commands/agents-platform/launchAgentsPlatform.tsx new file mode 100644 index 000000000..12f21ea13 --- /dev/null +++ b/src/commands/agents-platform/launchAgentsPlatform.tsx @@ -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; + +async function dispatchAgentsPlatform( + parsed: ReturnType, + onDone: LocalJSXCommandOnDone, +): Promise { + 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, + 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, +}); diff --git a/src/commands/agents-platform/parseArgs.ts b/src/commands/agents-platform/parseArgs.ts new file mode 100644 index 000000000..cb0759666 --- /dev/null +++ b/src/commands/agents-platform/parseArgs.ts @@ -0,0 +1,102 @@ +/** + * Parse the args string for the /agents-platform command. + * + * Supported sub-commands: + * list → { action: 'list' } + * create → { action: 'create', cron, prompt } + * delete → { action: 'delete', id } + * run → { 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`, + } +} diff --git a/src/commands/memory-stores/MemoryStoresView.tsx b/src/commands/memory-stores/MemoryStoresView.tsx new file mode 100644 index 000000000..c63f7f14b --- /dev/null +++ b/src/commands/memory-stores/MemoryStoresView.tsx @@ -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 ( + + + {store.memory_store_id} + · + {isArchived ? 'archived' : 'active'} + {store.namespace ? ( + <> + · ns: + {store.namespace} + + ) : null} + + Name: {store.name} + Created: {createdAt} + + ); +} + +export function MemoryStoresView(props: Props): React.ReactNode { + if (props.mode === 'list') { + if (props.stores.length === 0) { + return ( + + No memory stores found. Use /memory-stores create <name> to create one. + + ); + } + return ( + + + Memory Stores ({props.stores.length}) + + {props.stores.map(store => ( + + ))} + + ); + } + + 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 ( + + + Memory Store: {store.memory_store_id} + + Name: {store.name} + {store.namespace ? Namespace: {store.namespace} : null} + + Status:{' '} + {isArchived ? 'archived' : 'active'} + + Created: {createdAt} + {archivedAt ? Archived: {archivedAt} : null} + + ); + } + + if (props.mode === 'created') { + const { store } = props; + return ( + + + + Memory store created + + + ID: {store.memory_store_id} + Name: {store.name} + {store.namespace ? Namespace: {store.namespace} : null} + + ); + } + + if (props.mode === 'archived') { + const { store } = props; + const archivedAt = store.archived_at ? new Date(store.archived_at).toLocaleString() : '—'; + return ( + + + + Memory store archived + + + ID: {store.memory_store_id} + Archived at: {archivedAt} + + ); + } + + if (props.mode === 'memory-list') { + const { storeId, memories } = props; + if (memories.length === 0) { + return ( + + + No memories in store {storeId}. Use /memory-stores create-memory {storeId} <content> to add one. + + + ); + } + return ( + + + + Memories in {storeId} ({memories.length}) + + + {memories.map(mem => ( + + {mem.memory_id} + {mem.content.length > 80 ? `${mem.content.slice(0, 80)}…` : mem.content} + + ))} + + ); + } + + 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 ( + + + Memory: {memory.memory_id} + + Store: {memory.memory_store_id} + Content: {memory.content} + Created: {createdAt} + Updated: {updatedAt} + + ); + } + + if (props.mode === 'memory-created') { + const { memory } = props; + return ( + + + + Memory created + + + ID: {memory.memory_id} + Store: {memory.memory_store_id} + Content: {memory.content} + + ); + } + + if (props.mode === 'memory-updated') { + const { memory } = props; + return ( + + + + Memory updated + + + ID: {memory.memory_id} + Content: {memory.content} + + ); + } + + if (props.mode === 'memory-deleted') { + return ( + + + Memory {props.memoryId} deleted from store {props.storeId}. + + + ); + } + + if (props.mode === 'versions') { + const { storeId, versions } = props; + if (versions.length === 0) { + return ( + + No memory versions found for store {storeId}. + + ); + } + return ( + + + + Memory Versions in {storeId} ({versions.length}) + + + {versions.map(ver => { + const createdAt = ver.created_at ? new Date(ver.created_at).toLocaleString() : '—'; + const isRedacted = !!ver.redacted_at; + return ( + + + {ver.version_id} + {isRedacted ? ( + <> + · + redacted + + ) : null} + + Created: {createdAt} + + ); + })} + + ); + } + + if (props.mode === 'redacted') { + const { version } = props; + const redactedAt = version.redacted_at ? new Date(version.redacted_at).toLocaleString() : '—'; + return ( + + + + Version redacted + + + ID: {version.version_id} + Redacted at: {redactedAt} + + ); + } + + // error mode + return ( + + {props.message} + + ); +} diff --git a/src/commands/memory-stores/__tests__/api.test.ts b/src/commands/memory-stores/__tests__/api.test.ts new file mode 100644 index 000000000..f036bbafb --- /dev/null +++ b/src/commands/memory-stores/__tests__/api.test.ts @@ -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 + 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 + 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 }, + ][] + 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 }, + ][] + 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 }, + ][] + 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') + }) +}) diff --git a/src/commands/memory-stores/__tests__/index.test.ts b/src/commands/memory-stores/__tests__/index.test.ts new file mode 100644 index 000000000..2e47d5817 --- /dev/null +++ b/src/commands/memory-stores/__tests__/index.test.ts @@ -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') + }) +}) diff --git a/src/commands/memory-stores/__tests__/launchMemoryStores.test.ts b/src/commands/memory-stores/__tests__/launchMemoryStores.test.ts new file mode 100644 index 000000000..7c993bed7 --- /dev/null +++ b/src/commands/memory-stores/__tests__/launchMemoryStores.test.ts @@ -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) + }) +}) diff --git a/src/commands/memory-stores/__tests__/parseArgs.test.ts b/src/commands/memory-stores/__tests__/parseArgs.test.ts new file mode 100644 index 000000000..c1319d0f9 --- /dev/null +++ b/src/commands/memory-stores/__tests__/parseArgs.test.ts @@ -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') + } + }) +}) diff --git a/src/commands/memory-stores/index.ts b/src/commands/memory-stores/index.ts new file mode 100644 index 000000000..7569f0ec6 --- /dev/null +++ b/src/commands/memory-stores/index.ts @@ -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 diff --git a/src/commands/memory-stores/launchMemoryStores.tsx b/src/commands/memory-stores/launchMemoryStores.tsx new file mode 100644 index 000000000..2d3f85dbf --- /dev/null +++ b/src/commands/memory-stores/launchMemoryStores.tsx @@ -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; + +async function dispatchMemoryStores( + parsed: ReturnType, + onDone: LocalJSXCommandOnDone, +): Promise { + 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, + 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, +}); diff --git a/src/commands/memory-stores/memoryStoresApi.ts b/src/commands/memory-stores/memoryStoresApi.ts new file mode 100644 index 000000000..09d038ee6 --- /dev/null +++ b/src/commands/memory-stores/memoryStoresApi.ts @@ -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 { + 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> { + // /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 | 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(fn: () => Promise): Promise { + 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 | 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 { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.get( + memoryStoresBaseUrl(), + { + headers, + }, + ) + return response.data.data ?? [] + }) +} + +export async function createStore( + name: string, + namespace?: string, +): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const body: CreateStoreBody = { name } + if (namespace) body.namespace = namespace + const response = await axios.post( + memoryStoresBaseUrl(), + body, + { + headers, + }, + ) + return response.data + }) +} + +export async function getStore(id: string): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.get( + `${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 { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.post( + `${memoryStoresBaseUrl()}/${id}/archive`, + {}, + { headers }, + ) + return response.data + }) +} + +// ── Memory CRUD ──────────────────────────────────────────────────────────── + +export async function listMemories(storeId: string): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.get( + `${memoryStoresBaseUrl()}/${storeId}/memories`, + { headers }, + ) + return response.data.data ?? [] + }) +} + +export async function createMemory( + storeId: string, + content: string, +): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const body: CreateMemoryBody = { content } + const response = await axios.post( + `${memoryStoresBaseUrl()}/${storeId}/memories`, + body, + { headers }, + ) + return response.data + }) +} + +export async function getMemory( + storeId: string, + memoryId: string, +): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.get( + `${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 { + return withRetry(async () => { + const headers = await buildHeaders() + const body: UpdateMemoryBody = { content } + const response = await axios.patch( + `${memoryStoresBaseUrl()}/${storeId}/memories/${memoryId}`, + body, + { headers }, + ) + return response.data + }) +} + +export async function deleteMemory( + storeId: string, + memoryId: string, +): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + await axios.delete( + `${memoryStoresBaseUrl()}/${storeId}/memories/${memoryId}`, + { headers }, + ) + }) +} + +// ── Versions ─────────────────────────────────────────────────────────────── + +export async function listVersions(storeId: string): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.get( + `${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 { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.post( + `${memoryStoresBaseUrl()}/${storeId}/memory_versions/${versionId}/redact`, + {}, + { headers }, + ) + return response.data + }) +} diff --git a/src/commands/memory-stores/parseArgs.ts b/src/commands/memory-stores/parseArgs.ts new file mode 100644 index 000000000..cd253e776 --- /dev/null +++ b/src/commands/memory-stores/parseArgs.ts @@ -0,0 +1,207 @@ +/** + * Parse the args string for the /memory-stores command. + * + * Supported sub-commands: + * list → { action: 'list' } + * get → { action: 'get', id } + * create → { action: 'create', name } + * archive → { action: 'archive', id } + * memories → { action: 'memories', storeId } + * create-memory → { action: 'create-memory', storeId, content } + * get-memory → { action: 'get-memory', storeId, memoryId } + * update-memory → { action: 'update-memory', storeId, memoryId, content } + * delete-memory → { action: 'delete-memory', storeId, memoryId } + * versions → { action: 'versions', storeId } + * redact → { 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}`, + } +} diff --git a/src/commands/schedule/ScheduleView.tsx b/src/commands/schedule/ScheduleView.tsx new file mode 100644 index 000000000..442070e01 --- /dev/null +++ b/src/commands/schedule/ScheduleView.tsx @@ -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 ( + + + {trigger.trigger_id} + · + {enabledText} + {trigger.agent_id ? ( + <> + · agent: + {trigger.agent_id} + + ) : null} + + Schedule: {schedule} + Prompt: {trigger.prompt} + Next run: {nextRun} + + ); +} + +export function ScheduleView(props: Props): React.ReactNode { + if (props.mode === 'list') { + if (props.triggers.length === 0) { + return ( + + No scheduled triggers. Use /schedule create <cron> <prompt> to create one. + + ); + } + return ( + + + Scheduled Triggers ({props.triggers.length}) + + {props.triggers.map(trigger => ( + + ))} + + ); + } + + 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 ( + + + Trigger: {trigger.trigger_id} + + + Status:{' '} + + {trigger.enabled ? 'enabled' : 'disabled'} + + + Schedule: {schedule} + {trigger.agent_id ? Agent: {trigger.agent_id} : null} + Next run: {nextRun} + Last run: {lastRun} + Prompt: {trigger.prompt} + {trigger.created_at ? Created: {new Date(trigger.created_at).toLocaleString()} : null} + + ); + } + + if (props.mode === 'created') { + const { trigger } = props; + const schedule = cronToHuman(trigger.cron_expression, { utc: true }); + return ( + + + + Trigger created + + + ID: {trigger.trigger_id} + Schedule: {schedule} + Prompt: {trigger.prompt} + {trigger.agent_id ? Agent: {trigger.agent_id} : null} + Status: {trigger.enabled ? 'enabled' : 'disabled'} + + ); + } + + if (props.mode === 'updated') { + const { trigger } = props; + return ( + + + + Trigger updated + + + ID: {trigger.trigger_id} + Status: {trigger.enabled ? 'enabled' : 'disabled'} + + ); + } + + if (props.mode === 'deleted') { + return ( + + Trigger {props.id} deleted. + + ); + } + + if (props.mode === 'ran') { + return ( + + + Trigger {props.id} fired. + + Run ID: {props.runId} + + ); + } + + if (props.mode === 'enabled') { + return ( + + Trigger {props.id} enabled. + + ); + } + + if (props.mode === 'disabled') { + return ( + + Trigger {props.id} disabled. + + ); + } + + // error mode + return ( + + {props.message} + + ); +} diff --git a/src/commands/schedule/__tests__/api.test.ts b/src/commands/schedule/__tests__/api.test.ts new file mode 100644 index 000000000..fa8d50807 --- /dev/null +++ b/src/commands/schedule/__tests__/api.test.ts @@ -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 + 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) + }) +}) diff --git a/src/commands/schedule/__tests__/index.test.ts b/src/commands/schedule/__tests__/index.test.ts new file mode 100644 index 000000000..0b8e29ef2 --- /dev/null +++ b/src/commands/schedule/__tests__/index.test.ts @@ -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') + }) +}) diff --git a/src/commands/schedule/__tests__/launchSchedule.test.ts b/src/commands/schedule/__tests__/launchSchedule.test.ts new file mode 100644 index 000000000..a0963fb47 --- /dev/null +++ b/src/commands/schedule/__tests__/launchSchedule.test.ts @@ -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, + ][] + 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, + ][] + 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, + ][] + 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) + }) +}) diff --git a/src/commands/schedule/__tests__/parseArgs.test.ts b/src/commands/schedule/__tests__/parseArgs.test.ts new file mode 100644 index 000000000..6b3ec47d8 --- /dev/null +++ b/src/commands/schedule/__tests__/parseArgs.test.ts @@ -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 → 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 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 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 → 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 → 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 → enable action', () => { + expect(parseScheduleArgs('enable trg_en')).toEqual({ + action: 'enable', + id: 'trg_en', + }) + }) + + test('disable → 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) + } + }) +}) diff --git a/src/commands/schedule/index.ts b/src/commands/schedule/index.ts new file mode 100644 index 000000000..e5fae9e54 --- /dev/null +++ b/src/commands/schedule/index.ts @@ -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 diff --git a/src/commands/schedule/launchSchedule.tsx b/src/commands/schedule/launchSchedule.tsx new file mode 100644 index 000000000..400cccb1e --- /dev/null +++ b/src/commands/schedule/launchSchedule.tsx @@ -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 }); + } +}; diff --git a/src/commands/schedule/parseArgs.ts b/src/commands/schedule/parseArgs.ts new file mode 100644 index 000000000..15298937a --- /dev/null +++ b/src/commands/schedule/parseArgs.ts @@ -0,0 +1,181 @@ +/** + * Parse the args string for the /schedule command. + * + * Supported sub-commands: + * list → { action: 'list' } + * get → { action: 'get', id } + * create → { action: 'create', cron, prompt } + * update → { action: 'update', id, field, value } + * delete → { action: 'delete', id } + * run → { action: 'run', id } + * enable → { action: 'enable', id } + * disable → { 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}`, + } +} diff --git a/src/commands/schedule/triggersApi.ts b/src/commands/schedule/triggersApi.ts new file mode 100644 index 000000000..5628921e6 --- /dev/null +++ b/src/commands/schedule/triggersApi.ts @@ -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 { + 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> { + 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 | 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(fn: () => Promise): Promise { + 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 | 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 { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.get(triggersBaseUrl(), { + headers, + }) + return response.data.data ?? [] + }) +} + +export async function getTrigger(id: string): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.get(`${triggersBaseUrl()}/${id}`, { + headers, + }) + return response.data + }) +} + +export async function createTrigger(body: CreateTriggerBody): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.post(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 { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.post( + `${triggersBaseUrl()}/${id}`, + body, + { headers }, + ) + return response.data + }) +} + +export async function deleteTrigger(id: string): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + await axios.delete(`${triggersBaseUrl()}/${id}`, { headers }) + }) +} + +export async function runTrigger(id: string): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.post( + `${triggersBaseUrl()}/${id}/run`, + {}, + { headers }, + ) + return response.data + }) +} diff --git a/src/commands/skill-store/SkillStoreView.tsx b/src/commands/skill-store/SkillStoreView.tsx new file mode 100644 index 000000000..2eb4c5e08 --- /dev/null +++ b/src/commands/skill-store/SkillStoreView.tsx @@ -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 ( + + + {skill.skill_id} + · + {skill.name} + {skill.deprecated ? ( + <> + · + deprecated + + ) : null} + + + Owner: {skill.owner} + {skill.owner_symbol ? ` (${skill.owner_symbol})` : ''} + + Created: {createdAt} + + ); +} + +export function SkillStoreView(props: Props): React.ReactNode { + if (props.mode === 'list') { + if (props.skills.length === 0) { + return ( + + No skills found. Use /skill-store create <name> <markdown> to publish one. + + ); + } + return ( + + + Skills ({props.skills.length}) + + {props.skills.map(skill => ( + + ))} + + ); + } + + if (props.mode === 'detail') { + const { skill } = props; + const createdAt = skill.created_at ? new Date(skill.created_at).toLocaleString() : '—'; + return ( + + + Skill: {skill.skill_id} + + Name: {skill.name} + + Owner: {skill.owner} + {skill.owner_symbol ? ` (${skill.owner_symbol})` : ''} + + + Status:{' '} + + {skill.deprecated ? 'deprecated' : 'active'} + + + {skill.allowed_tools && skill.allowed_tools.length > 0 ? ( + Allowed tools: {skill.allowed_tools.join(', ')} + ) : null} + Created: {createdAt} + + ); + } + + if (props.mode === 'versions') { + const { id, versions } = props; + if (versions.length === 0) { + return ( + + No versions found for skill {id}. + + ); + } + return ( + + + + Versions for {id} ({versions.length}) + + + {versions.map(ver => { + const createdAt = ver.created_at ? new Date(ver.created_at).toLocaleString() : '—'; + return ( + + {ver.version} + Created: {createdAt} + {ver.body.length > 80 ? `${ver.body.slice(0, 80)}…` : ver.body} + + ); + })} + + ); + } + + if (props.mode === 'version-detail') { + const { version } = props; + const createdAt = version.created_at ? new Date(version.created_at).toLocaleString() : '—'; + return ( + + + + Version: {version.version} (skill: {version.skill_id}) + + + Created: {createdAt} + + {version.body} + + + ); + } + + if (props.mode === 'created') { + const { skill } = props; + return ( + + + + Skill created + + + ID: {skill.skill_id} + Name: {skill.name} + + ); + } + + if (props.mode === 'deleted') { + return ( + + Skill {props.id} deleted. + + ); + } + + if (props.mode === 'installed') { + return ( + + + + Skill installed + + + Name: {props.skillName} + Path: {props.path} + Load with: /skills (bundled skills are not auto-loaded; place in {props.path}) + + ); + } + + // error mode + return ( + + {props.message} + + ); +} diff --git a/src/commands/skill-store/__tests__/api.test.ts b/src/commands/skill-store/__tests__/api.test.ts new file mode 100644 index 000000000..883d9b55d --- /dev/null +++ b/src/commands/skill-store/__tests__/api.test.ts @@ -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 }, + ][] + 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 }, + ][] + 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 }, + ][] + 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') + }) +}) diff --git a/src/commands/skill-store/__tests__/index.test.ts b/src/commands/skill-store/__tests__/index.test.ts new file mode 100644 index 000000000..8a6276af4 --- /dev/null +++ b/src/commands/skill-store/__tests__/index.test.ts @@ -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 + } + const loaded = await cmd.load() + expect(typeof loaded.call).toBe('function') + }) +}) diff --git a/src/commands/skill-store/__tests__/launchSkillStore.test.ts b/src/commands/skill-store/__tests__/launchSkillStore.test.ts new file mode 100644 index 000000000..77ead5a51 --- /dev/null +++ b/src/commands/skill-store/__tests__/launchSkillStore.test.ts @@ -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 + return { + ...real, + default: real, + mkdir: (...args: unknown[]) => + useSkillStoreFsStubs + ? mkdirMock(...args) + : (real.mkdir as (...a: unknown[]) => Promise)(...args), + writeFile: (...args: unknown[]) => + useSkillStoreFsStubs + ? writeFileMock(...args) + : (real.writeFile as (...a: unknown[]) => Promise)(...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 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 @ 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//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') + }) +}) diff --git a/src/commands/skill-store/__tests__/parseArgs.test.ts b/src/commands/skill-store/__tests__/parseArgs.test.ts new file mode 100644 index 000000000..75fb1b3ed --- /dev/null +++ b/src/commands/skill-store/__tests__/parseArgs.test.ts @@ -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 → { 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 → { 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 → { 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 → { 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 → { 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 → { action: install, id, version: undefined }', () => { + expect(parseSkillStoreArgs('install sk_123')).toEqual({ + action: 'install', + id: 'sk_123', + version: undefined, + }) + }) + + test('install @ → { 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') + } + }) + }) +}) diff --git a/src/commands/skill-store/index.tsx b/src/commands/skill-store/index.tsx new file mode 100644 index 000000000..a9858464b --- /dev/null +++ b/src/commands/skill-store/index.tsx @@ -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; diff --git a/src/commands/skill-store/launchSkillStore.tsx b/src/commands/skill-store/launchSkillStore.tsx new file mode 100644 index 000000000..db811ad85 --- /dev/null +++ b/src/commands/skill-store/launchSkillStore.tsx @@ -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 }); + } +}; diff --git a/src/commands/skill-store/parseArgs.ts b/src/commands/skill-store/parseArgs.ts new file mode 100644 index 000000000..437f55643 --- /dev/null +++ b/src/commands/skill-store/parseArgs.ts @@ -0,0 +1,155 @@ +/** + * Parse the args string for the /skill-store command. + * + * Supported sub-commands: + * list → { action: 'list' } + * get → { action: 'get', id } + * versions → { action: 'versions', id } + * version → { action: 'version', id, version } + * create → { action: 'create', name, markdown } + * delete → { action: 'delete', id } + * install → { action: 'install', id, version: undefined } + * install @ → { 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}`, + } +} diff --git a/src/commands/skill-store/skillsApi.ts b/src/commands/skill-store/skillsApi.ts new file mode 100644 index 000000000..ec16668ee --- /dev/null +++ b/src/commands/skill-store/skillsApi.ts @@ -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 { + 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> { + // /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 | 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(fn: () => Promise): Promise { + 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 | 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 { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.get(skillsBaseUrl(), { + headers, + }) + return response.data.data ?? [] + }) +} + +export async function getSkill(id: string): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.get(skillUrl(id), { headers }) + return response.data + }) +} + +export async function getSkillVersions(id: string): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.get( + skillVersionsUrl(id), + { headers }, + ) + return response.data.data ?? [] + }) +} + +export async function getSkillVersion( + id: string, + version: string, +): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.get( + skillVersionUrl(id, version), + { headers }, + ) + return response.data + }) +} + +export async function createSkill(name: string, body: string): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const requestBody: CreateSkillBody = { name, body } + const response = await axios.post(skillsBaseUrl(), requestBody, { + headers, + }) + return response.data + }) +} + +export async function deleteSkill(id: string): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + await axios.delete(skillUrl(id), { headers }) + }) +} diff --git a/src/commands/vault/VaultView.tsx b/src/commands/vault/VaultView.tsx new file mode 100644 index 000000000..40e769786 --- /dev/null +++ b/src/commands/vault/VaultView.tsx @@ -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 ( + + + {vault.vault_id} + · + {isArchived ? 'archived' : 'active'} + + Name: {vault.name} + Created: {createdAt} + + ); +} + +export function VaultView(props: Props): React.ReactNode { + if (props.mode === 'list') { + if (props.vaults.length === 0) { + return ( + + No vaults found. Use /vault create <name> to create one. + + ); + } + return ( + + + Vaults ({props.vaults.length}) + + {props.vaults.map(vault => ( + + ))} + + ); + } + + 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 ( + + + Vault: {vault.vault_id} + + Name: {vault.name} + + Status:{' '} + {isArchived ? 'archived' : 'active'} + + Created: {createdAt} + {archivedAt ? Archived: {archivedAt} : null} + + ); + } + + if (props.mode === 'created') { + const { vault } = props; + return ( + + + + Vault created + + + ID: {vault.vault_id} + Name: {vault.name} + + ); + } + + if (props.mode === 'archived') { + const { vault } = props; + const archivedAt = vault.archived_at ? new Date(vault.archived_at).toLocaleString() : '—'; + return ( + + + + Vault archived + + + ID: {vault.vault_id} + Archived at: {archivedAt} + + ); + } + + if (props.mode === 'credential-list') { + const { vaultId, credentials } = props; + if (credentials.length === 0) { + return ( + + + No credentials in vault {vaultId}. Use /vault add-credential {vaultId} <key> <value> to add one. + + + ); + } + return ( + + + + Credentials in {vaultId} ({credentials.length}) + + + {credentials.map(cred => { + const isArchived = !!cred.archived_at; + return ( + + + {cred.credential_id} + · + {cred.kind ? {cred.kind} : null} + {isArchived ? ( + <> + · + archived + + ) : null} + + {/* SECURITY: credential value is never displayed */} + Value: ***mask*** + + ); + })} + + ); + } + + if (props.mode === 'credential-added') { + const { vaultId, credentialId } = props; + return ( + + + + Credential added + + + ID: {credentialId} + Vault: {vaultId} + {/* SECURITY: credential value is never echoed back */} + Value: ***mask*** + + ); + } + + if (props.mode === 'credential-archived') { + const { vaultId, credentialId } = props; + return ( + + + + Credential archived + + + ID: {credentialId} + Vault: {vaultId} + + ); + } + + // error mode + return ( + + {props.message} + + ); +} diff --git a/src/commands/vault/__tests__/api.test.ts b/src/commands/vault/__tests__/api.test.ts new file mode 100644 index 000000000..6afa5bcb0 --- /dev/null +++ b/src/commands/vault/__tests__/api.test.ts @@ -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 + // 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 + 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 }, + ][] + 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 }, + ][] + 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 }, + ][] + 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') + }) +}) diff --git a/src/commands/vault/__tests__/index.test.ts b/src/commands/vault/__tests__/index.test.ts new file mode 100644 index 000000000..6ec2679a3 --- /dev/null +++ b/src/commands/vault/__tests__/index.test.ts @@ -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 + } + expect(cmd.load).toBeDefined() + const loaded = await cmd.load() + expect(typeof loaded.call).toBe('function') + }) +}) diff --git a/src/commands/vault/__tests__/launchVault.test.ts b/src/commands/vault/__tests__/launchVault.test.ts new file mode 100644 index 000000000..d1324e6a9 --- /dev/null +++ b/src/commands/vault/__tests__/launchVault.test.ts @@ -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[0], + {} as Parameters[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[0], + {} as Parameters[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[0], + {} as Parameters[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[0], + {} as Parameters[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[0], + {} as Parameters[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[0], + {} as Parameters[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[0], + {} as Parameters[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[0], + {} as Parameters[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[0], + {} as Parameters[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[0], + {} as Parameters[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[0], + {} as Parameters[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[0], + {} as Parameters[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[0], + {} as Parameters[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[0], + {} as Parameters[1], + 'delete vault_123', + ) + expect(onDoneMsg).toMatch(/usage/i) + }) +}) diff --git a/src/commands/vault/__tests__/parseArgs.test.ts b/src/commands/vault/__tests__/parseArgs.test.ts new file mode 100644 index 000000000..64f661ad2 --- /dev/null +++ b/src/commands/vault/__tests__/parseArgs.test.ts @@ -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) + } + }) +}) diff --git a/src/commands/vault/index.tsx b/src/commands/vault/index.tsx new file mode 100644 index 000000000..d1dee5787 --- /dev/null +++ b/src/commands/vault/index.tsx @@ -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; diff --git a/src/commands/vault/launchVault.tsx b/src/commands/vault/launchVault.tsx new file mode 100644 index 000000000..d4bea934c --- /dev/null +++ b/src/commands/vault/launchVault.tsx @@ -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; + +async function dispatchVault( + parsed: ReturnType, + onDone: LocalJSXCommandOnDone, +): Promise { + 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, 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 => { + 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 }); + } +}; diff --git a/src/commands/vault/parseArgs.ts b/src/commands/vault/parseArgs.ts new file mode 100644 index 000000000..514731fa3 --- /dev/null +++ b/src/commands/vault/parseArgs.ts @@ -0,0 +1,128 @@ +/** + * Parse the args string for the /vault command. + * + * Supported sub-commands: + * list → { action: 'list' } + * create → { action: 'create', name } + * get → { action: 'get', id } + * archive → { action: 'archive', id } + * add-credential → { action: 'add-credential', vaultId, key, secret } + * archive-credential → { 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 ', + } + } + 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}`, + } +} diff --git a/src/commands/vault/vaultsApi.ts b/src/commands/vault/vaultsApi.ts new file mode 100644 index 000000000..83efbc946 --- /dev/null +++ b/src/commands/vault/vaultsApi.ts @@ -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 { + 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> { + // /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 | 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(fn: () => Promise, id?: string): Promise { + 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 | 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 { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.get(vaultsBaseUrl(), { + headers, + }) + return response.data.data ?? [] + }) +} + +export async function createVault(name: string): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const body: CreateVaultBody = { name } + const response = await axios.post(vaultsBaseUrl(), body, { + headers, + }) + return response.data + }) +} + +export async function getVault(id: string): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.get(`${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 { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.post( + `${vaultsBaseUrl()}/${id}/archive`, + {}, + { headers }, + ) + return response.data + }, id) +} + +// ── Credential CRUD ──────────────────────────────────────────────────────── + +export async function listCredentials(vaultId: string): Promise { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.get( + `${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 { + return withRetry(async () => { + const headers = await buildHeaders() + const body: AddCredentialBody = { key, secret } + const response = await axios.post( + `${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 { + return withRetry(async () => { + const headers = await buildHeaders() + const response = await axios.post( + `${vaultsBaseUrl()}/${vaultId}/credentials/${credentialId}/archive`, + {}, + { headers }, + ) + return response.data + }, vaultId) +}