mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
feat: 添加云端管理命令(memory-stores、vault、schedule、skill-store、agents-platform)
- /memory-stores: 远程记忆存储管理 - /vault: 密钥保险库管理 - /schedule: 云端定时触发器管理(cron) - /skill-store: 技能商店浏览和安装 - /agents-platform: 远程 agent 调度管理 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
164
src/commands/schedule/ScheduleView.tsx
Normal file
164
src/commands/schedule/ScheduleView.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { Theme } from '@anthropic/ink';
|
||||
import type { Trigger } from './triggersApi.js';
|
||||
import { cronToHuman } from '../../utils/cron.js';
|
||||
|
||||
type Props =
|
||||
| { mode: 'list'; triggers: Trigger[] }
|
||||
| { mode: 'detail'; trigger: Trigger }
|
||||
| { mode: 'created'; trigger: Trigger }
|
||||
| { mode: 'updated'; trigger: Trigger }
|
||||
| { mode: 'deleted'; id: string }
|
||||
| { mode: 'ran'; id: string; runId: string }
|
||||
| { mode: 'enabled'; id: string }
|
||||
| { mode: 'disabled'; id: string }
|
||||
| { mode: 'error'; message: string };
|
||||
|
||||
function TriggerRow({ trigger }: { trigger: Trigger }): React.ReactNode {
|
||||
const schedule = cronToHuman(trigger.cron_expression, { utc: true });
|
||||
const nextRun = trigger.next_run ? new Date(trigger.next_run).toLocaleString() : '—';
|
||||
const enabledText = trigger.enabled ? 'enabled' : 'disabled';
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text bold>{trigger.trigger_id}</Text>
|
||||
<Text dimColor> · </Text>
|
||||
<Text color={(trigger.enabled ? 'success' : 'warning') as keyof Theme}>{enabledText}</Text>
|
||||
{trigger.agent_id ? (
|
||||
<>
|
||||
<Text dimColor> · agent: </Text>
|
||||
<Text>{trigger.agent_id}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
<Text>Schedule: {schedule}</Text>
|
||||
<Text dimColor>Prompt: {trigger.prompt}</Text>
|
||||
<Text dimColor>Next run: {nextRun}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScheduleView(props: Props): React.ReactNode {
|
||||
if (props.mode === 'list') {
|
||||
if (props.triggers.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>No scheduled triggers. Use /schedule create <cron> <prompt> to create one.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>Scheduled Triggers ({props.triggers.length})</Text>
|
||||
</Box>
|
||||
{props.triggers.map(trigger => (
|
||||
<TriggerRow key={trigger.trigger_id} trigger={trigger} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'detail') {
|
||||
const { trigger } = props;
|
||||
const schedule = cronToHuman(trigger.cron_expression, { utc: true });
|
||||
const nextRun = trigger.next_run ? new Date(trigger.next_run).toLocaleString() : '—';
|
||||
const lastRun = trigger.last_run ? new Date(trigger.last_run).toLocaleString() : '—';
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>Trigger: {trigger.trigger_id}</Text>
|
||||
</Box>
|
||||
<Text>
|
||||
Status:{' '}
|
||||
<Text color={(trigger.enabled ? 'success' : 'warning') as keyof Theme}>
|
||||
{trigger.enabled ? 'enabled' : 'disabled'}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text>Schedule: {schedule}</Text>
|
||||
{trigger.agent_id ? <Text>Agent: {trigger.agent_id}</Text> : null}
|
||||
<Text>Next run: {nextRun}</Text>
|
||||
<Text dimColor>Last run: {lastRun}</Text>
|
||||
<Text dimColor>Prompt: {trigger.prompt}</Text>
|
||||
{trigger.created_at ? <Text dimColor>Created: {new Date(trigger.created_at).toLocaleString()}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'created') {
|
||||
const { trigger } = props;
|
||||
const schedule = cronToHuman(trigger.cron_expression, { utc: true });
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color={'success' as keyof Theme}>
|
||||
Trigger created
|
||||
</Text>
|
||||
</Box>
|
||||
<Text>ID: {trigger.trigger_id}</Text>
|
||||
<Text>Schedule: {schedule}</Text>
|
||||
<Text>Prompt: {trigger.prompt}</Text>
|
||||
{trigger.agent_id ? <Text>Agent: {trigger.agent_id}</Text> : null}
|
||||
<Text dimColor>Status: {trigger.enabled ? 'enabled' : 'disabled'}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'updated') {
|
||||
const { trigger } = props;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color={'success' as keyof Theme}>
|
||||
Trigger updated
|
||||
</Text>
|
||||
</Box>
|
||||
<Text>ID: {trigger.trigger_id}</Text>
|
||||
<Text dimColor>Status: {trigger.enabled ? 'enabled' : 'disabled'}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'deleted') {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'success' as keyof Theme}>Trigger {props.id} deleted.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'ran') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={'success' as keyof Theme}>Trigger {props.id} fired.</Text>
|
||||
</Box>
|
||||
<Text dimColor>Run ID: {props.runId}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'enabled') {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'success' as keyof Theme}>Trigger {props.id} enabled.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'disabled') {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'warning' as keyof Theme}>Trigger {props.id} disabled.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// error mode
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'error' as keyof Theme}>{props.message}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
354
src/commands/schedule/__tests__/api.test.ts
Normal file
354
src/commands/schedule/__tests__/api.test.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Regression tests for triggersApi.ts
|
||||
*
|
||||
* Key invariants under test:
|
||||
* - updateTrigger MUST use POST, not PATCH (binary literal: update: POST /v1/code/triggers/{id})
|
||||
* - All CRUD endpoints hit /v1/code/triggers (not /v1/agents)
|
||||
* - 401/403/404/429/5xx classified correctly
|
||||
* - withRetry retries only 5xx, not 4xx
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
|
||||
const mockAccessToken = 'test-token-triggers'
|
||||
const mockOrgUUID = 'org-uuid-triggers'
|
||||
|
||||
mock.module('src/utils/auth.js', () => ({
|
||||
getClaudeAIOAuthTokens: () => ({ accessToken: mockAccessToken }),
|
||||
}))
|
||||
mock.module('src/services/oauth/client.js', () => ({
|
||||
getOrganizationUUID: async () => mockOrgUUID,
|
||||
}))
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
||||
}))
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
getOAuthHeaders: (token: string) => ({
|
||||
Authorization: `Bearer ${token}`,
|
||||
'anthropic-version': '2023-06-01',
|
||||
}),
|
||||
}))
|
||||
|
||||
// ── Axios mock ──────────────────────────────────────────────────────────────
|
||||
const axiosGetMock = mock(async () => ({}))
|
||||
const axiosPostMock = mock(async () => ({}))
|
||||
const axiosDeleteMock = mock(async () => ({}))
|
||||
|
||||
const axiosIsAxiosError = mock((err: unknown) => {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'isAxiosError' in err &&
|
||||
(err as { isAxiosError: boolean }).isAxiosError === true
|
||||
)
|
||||
})
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
// ── Lazy import after mocks ─────────────────────────────────────────────────
|
||||
// Use the src/ alias path (same canonical key used in launchSchedule.test.ts mock)
|
||||
// so that if launchSchedule.test.ts runs first and replaces the mock, this file's
|
||||
// own beforeAll re-registers the real implementation under that same key.
|
||||
let listTriggers: typeof import('../triggersApi.js').listTriggers
|
||||
let getTrigger: typeof import('../triggersApi.js').getTrigger
|
||||
let createTrigger: typeof import('../triggersApi.js').createTrigger
|
||||
let updateTrigger: typeof import('../triggersApi.js').updateTrigger
|
||||
let deleteTrigger: typeof import('../triggersApi.js').deleteTrigger
|
||||
let runTrigger: typeof import('../triggersApi.js').runTrigger
|
||||
|
||||
beforeAll(async () => {
|
||||
axiosHandle.useStubs = true
|
||||
const mod = await import('../triggersApi.js')
|
||||
listTriggers = mod.listTriggers
|
||||
getTrigger = mod.getTrigger
|
||||
createTrigger = mod.createTrigger
|
||||
updateTrigger = mod.updateTrigger
|
||||
deleteTrigger = mod.deleteTrigger
|
||||
runTrigger = mod.runTrigger
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
axiosGetMock.mockClear()
|
||||
axiosPostMock.mockClear()
|
||||
axiosDeleteMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {})
|
||||
|
||||
// ── REGRESSION: updateTrigger MUST use POST not PATCH ──────────────────────
|
||||
describe('updateTrigger regression: must use POST not PATCH', () => {
|
||||
test('updateTrigger calls POST /v1/code/triggers/{id} (not PATCH)', async () => {
|
||||
const updated = {
|
||||
trigger_id: 'trg_upd',
|
||||
cron_expression: '0 10 * * *',
|
||||
enabled: true,
|
||||
prompt: 'Updated prompt',
|
||||
}
|
||||
axiosPostMock.mockResolvedValueOnce({ data: updated, status: 200 })
|
||||
|
||||
await updateTrigger('trg_upd', { enabled: false })
|
||||
|
||||
// POST must have been called
|
||||
expect(axiosPostMock).toHaveBeenCalledTimes(1)
|
||||
// axiosPatchMock must NOT have been called (no patch mock registered)
|
||||
// The URL must contain the trigger id
|
||||
const calls = axiosPostMock.mock.calls as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
][]
|
||||
const url = calls[0]?.[0] as string
|
||||
expect(url).toContain('trg_upd')
|
||||
expect(url).toContain('/v1/code/triggers/')
|
||||
// Verify the URL does NOT end in /run (which is the runTrigger endpoint)
|
||||
expect(url).not.toMatch(/\/run$/)
|
||||
})
|
||||
})
|
||||
|
||||
// ── listTriggers ──────────────────────────────────────────────────────────
|
||||
describe('listTriggers', () => {
|
||||
test('returns triggers on 200', async () => {
|
||||
const triggers = [
|
||||
{
|
||||
trigger_id: 'trg_1',
|
||||
cron_expression: '0 9 * * 1',
|
||||
enabled: true,
|
||||
prompt: 'Weekly standup',
|
||||
agent_id: 'agt_1',
|
||||
next_run: '2026-05-05T09:00:00Z',
|
||||
},
|
||||
]
|
||||
axiosGetMock.mockResolvedValueOnce({
|
||||
data: { data: triggers },
|
||||
status: 200,
|
||||
})
|
||||
|
||||
const result = await listTriggers()
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.trigger_id).toBe('trg_1')
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
|
||||
expect(calls[0]?.[0]).toContain('/v1/code/triggers')
|
||||
})
|
||||
|
||||
test('returns empty array on empty response', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
const result = await listTriggers()
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('throws 401 with friendly message', async () => {
|
||||
const err = Object.assign(new Error('Unauthorized'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 401, data: {} },
|
||||
})
|
||||
axiosGetMock.mockRejectedValueOnce(err)
|
||||
axiosIsAxiosError.mockImplementation(
|
||||
(e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'isAxiosError' in e &&
|
||||
(e as { isAxiosError: boolean }).isAxiosError === true,
|
||||
)
|
||||
await expect(listTriggers()).rejects.toThrow(/login|authenticate/i)
|
||||
})
|
||||
|
||||
test('throws 403 with subscription message', async () => {
|
||||
const err = Object.assign(new Error('Forbidden'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 403, data: {} },
|
||||
})
|
||||
axiosGetMock.mockRejectedValueOnce(err)
|
||||
axiosIsAxiosError.mockImplementation(
|
||||
(e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'isAxiosError' in e &&
|
||||
(e as { isAxiosError: boolean }).isAxiosError === true,
|
||||
)
|
||||
await expect(listTriggers()).rejects.toThrow(/subscription|pro|max|team/i)
|
||||
})
|
||||
|
||||
test('retries on 5xx and eventually throws', async () => {
|
||||
const make5xx = () =>
|
||||
Object.assign(new Error('Server Error'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: {} },
|
||||
})
|
||||
axiosGetMock
|
||||
.mockRejectedValueOnce(make5xx())
|
||||
.mockRejectedValueOnce(make5xx())
|
||||
.mockRejectedValueOnce(make5xx())
|
||||
axiosIsAxiosError.mockImplementation(
|
||||
(e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'isAxiosError' in e &&
|
||||
(e as { isAxiosError: boolean }).isAxiosError === true,
|
||||
)
|
||||
await expect(listTriggers()).rejects.toThrow()
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(3)
|
||||
}, 15000)
|
||||
|
||||
test('honors Retry-After header on 5xx', async () => {
|
||||
const serverErr = Object.assign(new Error('Service Unavailable'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 503, data: {}, headers: { 'retry-after': '0' } },
|
||||
})
|
||||
axiosGetMock
|
||||
.mockRejectedValueOnce(serverErr)
|
||||
.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
axiosIsAxiosError.mockImplementation(
|
||||
(e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'isAxiosError' in e &&
|
||||
(e as { isAxiosError: boolean }).isAxiosError === true,
|
||||
)
|
||||
const result = await listTriggers()
|
||||
expect(result).toHaveLength(0)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ── getTrigger ──────────────────────────────────────────────────────────
|
||||
describe('getTrigger', () => {
|
||||
test('calls GET /v1/code/triggers/{id}', async () => {
|
||||
const trigger = {
|
||||
trigger_id: 'trg_get',
|
||||
cron_expression: '0 8 * * *',
|
||||
enabled: true,
|
||||
prompt: 'Daily report',
|
||||
}
|
||||
axiosGetMock.mockResolvedValueOnce({ data: trigger, status: 200 })
|
||||
|
||||
const result = await getTrigger('trg_get')
|
||||
expect(result.trigger_id).toBe('trg_get')
|
||||
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
|
||||
expect(calls[0]?.[0]).toContain('trg_get')
|
||||
})
|
||||
|
||||
test('throws 404 with not found message', async () => {
|
||||
const err = Object.assign(new Error('Not Found'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 404, data: {} },
|
||||
})
|
||||
axiosGetMock.mockRejectedValueOnce(err)
|
||||
axiosIsAxiosError.mockImplementation(
|
||||
(e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'isAxiosError' in e &&
|
||||
(e as { isAxiosError: boolean }).isAxiosError === true,
|
||||
)
|
||||
await expect(getTrigger('nonexistent')).rejects.toThrow(/not found/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── createTrigger ─────────────────────────────────────────────────────────
|
||||
describe('createTrigger', () => {
|
||||
test('sends POST /v1/code/triggers with cron_expression and prompt', async () => {
|
||||
const trigger = {
|
||||
trigger_id: 'trg_new',
|
||||
cron_expression: '0 9 * * *',
|
||||
enabled: true,
|
||||
prompt: 'Create daily report',
|
||||
}
|
||||
axiosPostMock.mockResolvedValueOnce({ data: trigger, status: 201 })
|
||||
|
||||
const result = await createTrigger({
|
||||
cron_expression: '0 9 * * *',
|
||||
prompt: 'Create daily report',
|
||||
})
|
||||
expect(result.trigger_id).toBe('trg_new')
|
||||
const calls = axiosPostMock.mock.calls as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
][]
|
||||
const url = calls[0]?.[0] as string
|
||||
const body = calls[0]?.[1] as Record<string, unknown>
|
||||
expect(url).toContain('/v1/code/triggers')
|
||||
expect(url).not.toContain('/v1/agents')
|
||||
expect(body.cron_expression).toBe('0 9 * * *')
|
||||
expect(body.prompt).toBe('Create daily report')
|
||||
})
|
||||
})
|
||||
|
||||
// ── deleteTrigger ─────────────────────────────────────────────────────────
|
||||
describe('deleteTrigger', () => {
|
||||
test('calls DELETE /v1/code/triggers/{id}', async () => {
|
||||
axiosDeleteMock.mockResolvedValueOnce({ status: 204 })
|
||||
|
||||
await deleteTrigger('trg_del')
|
||||
const calls = axiosDeleteMock.mock.calls as unknown as [string, unknown][]
|
||||
const url = calls[0]?.[0] as string
|
||||
expect(url).toContain('trg_del')
|
||||
expect(url).toContain('/v1/code/triggers/')
|
||||
})
|
||||
})
|
||||
|
||||
// ── runTrigger ───────────────────────────────────────────────────────────
|
||||
describe('runTrigger', () => {
|
||||
test('calls POST /v1/code/triggers/{id}/run', async () => {
|
||||
axiosPostMock.mockResolvedValueOnce({
|
||||
data: { run_id: 'run_trg_1' },
|
||||
status: 200,
|
||||
})
|
||||
|
||||
const result = await runTrigger('trg_run')
|
||||
expect(result.run_id).toBe('run_trg_1')
|
||||
const calls = axiosPostMock.mock.calls as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
][]
|
||||
const url = calls[0]?.[0] as string
|
||||
expect(url).toMatch(/trg_run\/run$/)
|
||||
})
|
||||
})
|
||||
|
||||
// ── 429 Retry-After ──────────────────────────────────────────────────────
|
||||
describe('429 rate-limit: not retried (non-5xx)', () => {
|
||||
test('throws immediately on 429 without retry', async () => {
|
||||
const err = Object.assign(new Error('Too Many Requests'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 429, data: {}, headers: { 'retry-after': '60' } },
|
||||
})
|
||||
axiosGetMock.mockRejectedValueOnce(err)
|
||||
axiosIsAxiosError.mockImplementation(
|
||||
(e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'isAxiosError' in e &&
|
||||
(e as { isAxiosError: boolean }).isAxiosError === true,
|
||||
)
|
||||
await expect(listTriggers()).rejects.toThrow()
|
||||
// Must NOT have retried — 429 is not a 5xx
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
66
src/commands/schedule/__tests__/index.test.ts
Normal file
66
src/commands/schedule/__tests__/index.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Tests for schedule/index.ts — command metadata only.
|
||||
*/
|
||||
import { beforeAll, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
let cmd: {
|
||||
load?: () => Promise<{ call: unknown }>
|
||||
isEnabled?: () => boolean
|
||||
name?: string
|
||||
type?: string
|
||||
aliases?: string[]
|
||||
description?: string
|
||||
bridgeSafe?: boolean
|
||||
availability?: string[]
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../index.js')
|
||||
cmd = mod.default as typeof cmd
|
||||
})
|
||||
|
||||
describe('scheduleCommand metadata', () => {
|
||||
test('name is "triggers" (renamed from "schedule" to avoid bundled-skill collision)', () => {
|
||||
expect(cmd.name).toBe('triggers')
|
||||
})
|
||||
|
||||
test('type is local-jsx', () => {
|
||||
expect(cmd.type).toBe('local-jsx')
|
||||
})
|
||||
|
||||
test('isEnabled returns true', () => {
|
||||
expect(cmd.isEnabled?.()).toBe(true)
|
||||
})
|
||||
|
||||
test('aliases include cron (triggers is now the primary name)', () => {
|
||||
expect(cmd.aliases).toContain('cron')
|
||||
// 'triggers' moved to primary `name`; the bundled skill /schedule
|
||||
// owns the 'schedule' slot upstream so we don't alias to it either.
|
||||
expect(cmd.aliases).not.toContain('schedule')
|
||||
})
|
||||
|
||||
test('bridgeSafe is false', () => {
|
||||
expect(cmd.bridgeSafe).toBe(false)
|
||||
})
|
||||
|
||||
test('availability includes claude-ai', () => {
|
||||
expect(cmd.availability).toContain('claude-ai')
|
||||
})
|
||||
|
||||
test('description mentions schedule or trigger', () => {
|
||||
expect(cmd.description?.toLowerCase()).toMatch(/schedule|cron|trigger/)
|
||||
})
|
||||
|
||||
test('load() exists and is a function', () => {
|
||||
expect(typeof cmd.load).toBe('function')
|
||||
})
|
||||
|
||||
test('load() resolves to object with call function', async () => {
|
||||
const loaded = await cmd.load!()
|
||||
expect(typeof (loaded as { call?: unknown }).call).toBe('function')
|
||||
})
|
||||
})
|
||||
307
src/commands/schedule/__tests__/launchSchedule.test.ts
Normal file
307
src/commands/schedule/__tests__/launchSchedule.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
// ── Analytics mock ──────────────────────────────────────────────────────────
|
||||
const logEventMock = mock(() => {})
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: logEventMock,
|
||||
}))
|
||||
|
||||
// ── Cron utility mock ───────────────────────────────────────────────────────
|
||||
// parseCronExpression: returns null if any field is non-numeric/non-wildcard
|
||||
// to simulate real validation; specifically reject expressions with word fields.
|
||||
mock.module('src/utils/cron.js', () => ({
|
||||
parseCronExpression: (cron: string) => {
|
||||
const fields = cron.trim().split(/\s+/)
|
||||
if (fields.length !== 5) return null
|
||||
// Reject if any field contains a letter (invalid cron field)
|
||||
const hasWord = fields.some(f => /[a-zA-Z]/.test(f))
|
||||
if (hasWord) return null
|
||||
return {
|
||||
minute: [0],
|
||||
hour: [9],
|
||||
dayOfMonth: [1],
|
||||
month: [1],
|
||||
dayOfWeek: [1],
|
||||
}
|
||||
},
|
||||
cronToHuman: (cron: string) => `human(${cron})`,
|
||||
}))
|
||||
|
||||
// ── ScheduleView mock ───────────────────────────────────────────────────────
|
||||
const scheduleViewMock = mock((_props: unknown) => null)
|
||||
mock.module('src/commands/schedule/ScheduleView.js', () => ({
|
||||
ScheduleView: scheduleViewMock,
|
||||
}))
|
||||
|
||||
// ── triggersApi mock ──────────────────────────────────────────────────────
|
||||
// Use `as unknown as` casts to keep mock type flexible while satisfying strict TS
|
||||
const listTriggersMock = mock(async () => [] as unknown)
|
||||
const getTriggerMock = mock(async () => ({}) as unknown)
|
||||
const createTriggerMock = mock(async () => ({}) as unknown)
|
||||
const updateTriggerMock = mock(async () => ({}) as unknown)
|
||||
const deleteTriggerMock = mock(async () => undefined)
|
||||
const runTriggerMock = mock(async () => ({ run_id: 'run_mock' }) as unknown)
|
||||
|
||||
mock.module('src/commands/schedule/triggersApi.js', () => ({
|
||||
listTriggers: listTriggersMock,
|
||||
getTrigger: getTriggerMock,
|
||||
createTrigger: createTriggerMock,
|
||||
updateTrigger: updateTriggerMock,
|
||||
deleteTrigger: deleteTriggerMock,
|
||||
runTrigger: runTriggerMock,
|
||||
}))
|
||||
|
||||
let callSchedule: typeof import('../launchSchedule.js').callSchedule
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../launchSchedule.js')
|
||||
callSchedule = mod.callSchedule
|
||||
})
|
||||
|
||||
function makeOnDone() {
|
||||
return mock(() => {})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
logEventMock.mockClear()
|
||||
listTriggersMock.mockClear()
|
||||
getTriggerMock.mockClear()
|
||||
createTriggerMock.mockClear()
|
||||
updateTriggerMock.mockClear()
|
||||
deleteTriggerMock.mockClear()
|
||||
runTriggerMock.mockClear()
|
||||
scheduleViewMock.mockClear()
|
||||
})
|
||||
|
||||
describe('callSchedule: invalid args', () => {
|
||||
test('invalid subcommand → onDone with usage + null', async () => {
|
||||
const onDone = makeOnDone()
|
||||
const result = await callSchedule(onDone, {} as never, 'badcmd')
|
||||
expect(result).toBeNull()
|
||||
expect(onDone).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/Usage/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('callSchedule: list', () => {
|
||||
test('list returns empty triggers', async () => {
|
||||
listTriggersMock.mockResolvedValueOnce([])
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'list')
|
||||
expect(listTriggersMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/no scheduled triggers/i)
|
||||
})
|
||||
|
||||
test('list with triggers reports count', async () => {
|
||||
const triggers = [
|
||||
{
|
||||
trigger_id: 'trg_1',
|
||||
cron_expression: '0 9 * * 1',
|
||||
enabled: true,
|
||||
prompt: 'daily',
|
||||
},
|
||||
]
|
||||
listTriggersMock.mockResolvedValueOnce(triggers)
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, '')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/1 scheduled trigger/)
|
||||
})
|
||||
|
||||
test('list API error → error view', async () => {
|
||||
listTriggersMock.mockRejectedValueOnce(new Error('Network error'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'list')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to list/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('callSchedule: get', () => {
|
||||
test('get calls getTrigger with id', async () => {
|
||||
const trigger = {
|
||||
trigger_id: 'trg_get',
|
||||
cron_expression: '0 8 * * *',
|
||||
enabled: true,
|
||||
prompt: 'test',
|
||||
}
|
||||
getTriggerMock.mockResolvedValueOnce(trigger)
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'get trg_get')
|
||||
expect(getTriggerMock).toHaveBeenCalledTimes(1)
|
||||
const calls = getTriggerMock.mock.calls as unknown as [string][]
|
||||
expect(calls[0]?.[0]).toBe('trg_get')
|
||||
})
|
||||
|
||||
test('get API error → error message', async () => {
|
||||
getTriggerMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'get trg_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to get/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('callSchedule: create', () => {
|
||||
test('create with valid cron calls createTrigger', async () => {
|
||||
const trigger = {
|
||||
trigger_id: 'trg_new',
|
||||
cron_expression: '0 9 * * *',
|
||||
enabled: true,
|
||||
prompt: 'daily report',
|
||||
}
|
||||
createTriggerMock.mockResolvedValueOnce(trigger)
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'create 0 9 * * * daily report')
|
||||
expect(createTriggerMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/trigger created/i)
|
||||
})
|
||||
|
||||
test('create with invalid cron → validation error without hitting API', async () => {
|
||||
const onDone = makeOnDone()
|
||||
// 4 fields only — invalid
|
||||
await callSchedule(onDone, {} as never, 'create 0 9 * * report only')
|
||||
// createTrigger should not be called
|
||||
expect(createTriggerMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('create API error → error message', async () => {
|
||||
createTriggerMock.mockRejectedValueOnce(new Error('Subscription required'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'create 0 9 * * * test prompt')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to create/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('callSchedule: update', () => {
|
||||
test('update enabled field', async () => {
|
||||
const trigger = {
|
||||
trigger_id: 'trg_upd',
|
||||
cron_expression: '0 9 * * *',
|
||||
enabled: false,
|
||||
prompt: 'test',
|
||||
}
|
||||
updateTriggerMock.mockResolvedValueOnce(trigger)
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'update trg_upd enabled false')
|
||||
expect(updateTriggerMock).toHaveBeenCalledTimes(1)
|
||||
const calls = updateTriggerMock.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
][]
|
||||
expect(calls[0]?.[1]).toEqual({ enabled: false })
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/updated/i)
|
||||
})
|
||||
|
||||
test('update with unknown field → error without API call', async () => {
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'update trg_upd foofield bar')
|
||||
expect(updateTriggerMock).not.toHaveBeenCalled()
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/unknown field/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('callSchedule: delete', () => {
|
||||
test('delete calls deleteTrigger', async () => {
|
||||
deleteTriggerMock.mockResolvedValueOnce(undefined)
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'delete trg_del')
|
||||
expect(deleteTriggerMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/deleted/i)
|
||||
})
|
||||
|
||||
test('delete API error → error message', async () => {
|
||||
deleteTriggerMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'delete trg_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to delete/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('callSchedule: run', () => {
|
||||
test('run fires trigger and returns run_id', async () => {
|
||||
runTriggerMock.mockResolvedValueOnce({ run_id: 'run_xyz' })
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'run trg_fire')
|
||||
expect(runTriggerMock).toHaveBeenCalledTimes(1)
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/run_xyz/)
|
||||
})
|
||||
|
||||
test('run API error → error message', async () => {
|
||||
runTriggerMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'run trg_fire')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to run/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('callSchedule: enable / disable', () => {
|
||||
test('enable calls updateTrigger with enabled:true', async () => {
|
||||
const trigger = {
|
||||
trigger_id: 'trg_en',
|
||||
cron_expression: '0 9 * * *',
|
||||
enabled: true,
|
||||
prompt: 'test',
|
||||
}
|
||||
updateTriggerMock.mockResolvedValueOnce(trigger)
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'enable trg_en')
|
||||
const calls = updateTriggerMock.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
][]
|
||||
expect(calls[0]?.[1]).toEqual({ enabled: true })
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/enabled/i)
|
||||
})
|
||||
|
||||
test('disable calls updateTrigger with enabled:false', async () => {
|
||||
const trigger = {
|
||||
trigger_id: 'trg_dis',
|
||||
cron_expression: '0 9 * * *',
|
||||
enabled: false,
|
||||
prompt: 'test',
|
||||
}
|
||||
updateTriggerMock.mockResolvedValueOnce(trigger)
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'disable trg_dis')
|
||||
const calls = updateTriggerMock.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
][]
|
||||
expect(calls[0]?.[1]).toEqual({ enabled: false })
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/disabled/i)
|
||||
})
|
||||
|
||||
test('enable API error → error message', async () => {
|
||||
updateTriggerMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'enable trg_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to enable/i)
|
||||
})
|
||||
|
||||
test('disable API error → error message', async () => {
|
||||
updateTriggerMock.mockRejectedValueOnce(new Error('Not found'))
|
||||
const onDone = makeOnDone()
|
||||
await callSchedule(onDone, {} as never, 'disable trg_missing')
|
||||
const [msg] = (onDone.mock.calls as unknown as [string, unknown][])[0] ?? []
|
||||
expect(msg).toMatch(/failed to disable/i)
|
||||
})
|
||||
})
|
||||
184
src/commands/schedule/__tests__/parseArgs.test.ts
Normal file
184
src/commands/schedule/__tests__/parseArgs.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
isValidCronExpression,
|
||||
parseScheduleArgs,
|
||||
splitCronAndPrompt,
|
||||
} from '../parseArgs.js'
|
||||
|
||||
describe('splitCronAndPrompt', () => {
|
||||
test('splits 5 cron fields + prompt', () => {
|
||||
const result = splitCronAndPrompt('0 9 * * 1 Run standup')
|
||||
expect(result).toEqual({ cron: '0 9 * * 1', prompt: 'Run standup' })
|
||||
})
|
||||
|
||||
test('handles multi-word prompt', () => {
|
||||
const result = splitCronAndPrompt(
|
||||
'0 9 * * * Generate daily report for team',
|
||||
)
|
||||
expect(result?.cron).toBe('0 9 * * *')
|
||||
expect(result?.prompt).toBe('Generate daily report for team')
|
||||
})
|
||||
|
||||
test('returns null with fewer than 6 tokens', () => {
|
||||
expect(splitCronAndPrompt('0 9 * * *')).toBeNull()
|
||||
expect(splitCronAndPrompt('0 9 *')).toBeNull()
|
||||
expect(splitCronAndPrompt('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidCronExpression', () => {
|
||||
test('accepts valid 5-field expressions', () => {
|
||||
expect(isValidCronExpression('0 9 * * 1')).toBe(true)
|
||||
expect(isValidCronExpression('*/5 * * * *')).toBe(true)
|
||||
expect(isValidCronExpression('0 0 1 1 *')).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects expressions with wrong field count', () => {
|
||||
expect(isValidCronExpression('0 9 * *')).toBe(false)
|
||||
expect(isValidCronExpression('0 9 * * * *')).toBe(false)
|
||||
expect(isValidCronExpression('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseScheduleArgs', () => {
|
||||
test('empty string → list', () => {
|
||||
expect(parseScheduleArgs('')).toEqual({ action: 'list' })
|
||||
})
|
||||
|
||||
test('"list" → list', () => {
|
||||
expect(parseScheduleArgs('list')).toEqual({ action: 'list' })
|
||||
})
|
||||
|
||||
test('"list" with extra whitespace → list', () => {
|
||||
expect(parseScheduleArgs(' list ')).toEqual({ action: 'list' })
|
||||
})
|
||||
|
||||
// ── get ───────────────────────────────────────────────────────────────────
|
||||
test('get <id> → get action', () => {
|
||||
expect(parseScheduleArgs('get trg_123')).toEqual({
|
||||
action: 'get',
|
||||
id: 'trg_123',
|
||||
})
|
||||
})
|
||||
|
||||
test('get without id → invalid', () => {
|
||||
const result = parseScheduleArgs('get')
|
||||
expect(result.action).toBe('invalid')
|
||||
if (result.action === 'invalid') {
|
||||
expect(result.reason).toMatch(/trigger id/i)
|
||||
}
|
||||
})
|
||||
|
||||
// ── create ────────────────────────────────────────────────────────────────
|
||||
test('create with cron + prompt → create action', () => {
|
||||
const result = parseScheduleArgs('create 0 9 * * 1 Run daily standup')
|
||||
expect(result).toEqual({
|
||||
action: 'create',
|
||||
cron: '0 9 * * 1',
|
||||
prompt: 'Run daily standup',
|
||||
})
|
||||
})
|
||||
|
||||
test('create without args → invalid', () => {
|
||||
const result = parseScheduleArgs('create')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
|
||||
test('create with only cron (no prompt) → invalid', () => {
|
||||
const result = parseScheduleArgs('create 0 9 * * 1')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
|
||||
// ── update ────────────────────────────────────────────────────────────────
|
||||
test('update <id> enabled false → update action', () => {
|
||||
const result = parseScheduleArgs('update trg_123 enabled false')
|
||||
expect(result).toEqual({
|
||||
action: 'update',
|
||||
id: 'trg_123',
|
||||
field: 'enabled',
|
||||
value: 'false',
|
||||
})
|
||||
})
|
||||
|
||||
test('update <id> prompt new text → update action with multi-word value', () => {
|
||||
const result = parseScheduleArgs(
|
||||
'update trg_abc prompt New prompt text here',
|
||||
)
|
||||
expect(result).toEqual({
|
||||
action: 'update',
|
||||
id: 'trg_abc',
|
||||
field: 'prompt',
|
||||
value: 'New prompt text here',
|
||||
})
|
||||
})
|
||||
|
||||
test('update missing field → invalid', () => {
|
||||
const result = parseScheduleArgs('update trg_123')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
|
||||
test('update missing value → invalid', () => {
|
||||
const result = parseScheduleArgs('update trg_123 enabled')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
|
||||
// ── delete ────────────────────────────────────────────────────────────────
|
||||
test('delete <id> → delete action', () => {
|
||||
expect(parseScheduleArgs('delete trg_del')).toEqual({
|
||||
action: 'delete',
|
||||
id: 'trg_del',
|
||||
})
|
||||
})
|
||||
|
||||
test('delete without id → invalid', () => {
|
||||
const result = parseScheduleArgs('delete')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
|
||||
// ── run ───────────────────────────────────────────────────────────────────
|
||||
test('run <id> → run action', () => {
|
||||
expect(parseScheduleArgs('run trg_run')).toEqual({
|
||||
action: 'run',
|
||||
id: 'trg_run',
|
||||
})
|
||||
})
|
||||
|
||||
test('run without id → invalid', () => {
|
||||
const result = parseScheduleArgs('run')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
|
||||
// ── enable / disable ──────────────────────────────────────────────────────
|
||||
test('enable <id> → enable action', () => {
|
||||
expect(parseScheduleArgs('enable trg_en')).toEqual({
|
||||
action: 'enable',
|
||||
id: 'trg_en',
|
||||
})
|
||||
})
|
||||
|
||||
test('disable <id> → disable action', () => {
|
||||
expect(parseScheduleArgs('disable trg_dis')).toEqual({
|
||||
action: 'disable',
|
||||
id: 'trg_dis',
|
||||
})
|
||||
})
|
||||
|
||||
test('enable without id → invalid', () => {
|
||||
const result = parseScheduleArgs('enable')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
|
||||
test('disable without id → invalid', () => {
|
||||
const result = parseScheduleArgs('disable')
|
||||
expect(result.action).toBe('invalid')
|
||||
})
|
||||
|
||||
// ── unknown subcommand ────────────────────────────────────────────────────
|
||||
test('unknown subcommand → invalid', () => {
|
||||
const result = parseScheduleArgs('foobar trg_123')
|
||||
expect(result.action).toBe('invalid')
|
||||
if (result.action === 'invalid') {
|
||||
expect(result.reason).toMatch(/unknown sub-command/i)
|
||||
}
|
||||
})
|
||||
})
|
||||
27
src/commands/schedule/index.ts
Normal file
27
src/commands/schedule/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Command } from '../../types/command.js'
|
||||
|
||||
const scheduleCommand: Command = {
|
||||
type: 'local-jsx',
|
||||
// Primary name renamed from 'schedule' → 'triggers' to avoid collision
|
||||
// with the upstream bundled skill `src/skills/bundled/scheduleRemoteAgents.ts`,
|
||||
// which also registers as `/schedule`. The new name matches the underlying
|
||||
// API endpoint (`/v1/code/triggers`). Directory still named schedule/ to
|
||||
// keep the rename minimal — only the user-facing slash name changes.
|
||||
name: 'triggers',
|
||||
aliases: ['cron'],
|
||||
description:
|
||||
'Manage scheduled remote agent triggers (cloud cron). Requires Claude Pro/Max/Team subscription.',
|
||||
// REPL markdown renderer strips `<...>` as HTML tags — use uppercase.
|
||||
argumentHint:
|
||||
'list | get ID | create CRON PROMPT | update ID FIELD VALUE | delete ID | run ID | enable ID | disable ID',
|
||||
isHidden: false,
|
||||
isEnabled: () => true,
|
||||
bridgeSafe: false,
|
||||
availability: ['claude-ai'],
|
||||
load: async () => {
|
||||
const m = await import('./launchSchedule.js')
|
||||
return { call: m.callSchedule }
|
||||
},
|
||||
}
|
||||
|
||||
export default scheduleCommand
|
||||
230
src/commands/schedule/launchSchedule.tsx
Normal file
230
src/commands/schedule/launchSchedule.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js';
|
||||
import { parseCronExpression } from '../../utils/cron.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
import { createTrigger, deleteTrigger, getTrigger, listTriggers, runTrigger, updateTrigger } from './triggersApi.js';
|
||||
import { ScheduleView } from './ScheduleView.js';
|
||||
import { parseScheduleArgs } from './parseArgs.js';
|
||||
import type { UpdateTriggerBody } from './triggersApi.js';
|
||||
|
||||
export const callSchedule: LocalJSXCommandCall = async (onDone, _context, args) => {
|
||||
logEvent('tengu_schedule_started', {
|
||||
args: (args ?? '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
const parsed = parseScheduleArgs(args ?? '');
|
||||
|
||||
// ── invalid args ──────────────────────────────────────────────────────────
|
||||
if (parsed.action === 'invalid') {
|
||||
logEvent('tengu_schedule_failed', {
|
||||
reason: parsed.reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(
|
||||
`Usage: /schedule list | get ID | create CRON PROMPT | update ID FIELD VALUE | delete ID | run ID | enable ID | disable ID\n${parsed.reason}`,
|
||||
{ display: 'system' },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── list ──────────────────────────────────────────────────────────────────
|
||||
if (parsed.action === 'list') {
|
||||
logEvent('tengu_schedule_list', {});
|
||||
try {
|
||||
const triggers = await listTriggers();
|
||||
onDone(triggers.length === 0 ? 'No scheduled triggers found.' : `${triggers.length} scheduled trigger(s).`, {
|
||||
display: 'system',
|
||||
});
|
||||
return React.createElement(ScheduleView, { mode: 'list', triggers });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_schedule_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to list triggers: ${msg}`, { display: 'system' });
|
||||
return React.createElement(ScheduleView, { mode: 'error', message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// ── get ───────────────────────────────────────────────────────────────────
|
||||
if (parsed.action === 'get') {
|
||||
const { id } = parsed;
|
||||
logEvent('tengu_schedule_get', {
|
||||
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
try {
|
||||
const trigger = await getTrigger(id);
|
||||
onDone(`Trigger ${id} fetched.`, { display: 'system' });
|
||||
return React.createElement(ScheduleView, { mode: 'detail', trigger });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_schedule_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to get trigger ${id}: ${msg}`, { display: 'system' });
|
||||
return React.createElement(ScheduleView, { mode: 'error', message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// ── create ────────────────────────────────────────────────────────────────
|
||||
if (parsed.action === 'create') {
|
||||
const { cron, prompt } = parsed;
|
||||
|
||||
const cronFields = parseCronExpression(cron);
|
||||
if (!cronFields) {
|
||||
const reason = `Invalid cron expression: "${cron}". Expected 5 fields (minute hour day month weekday).`;
|
||||
logEvent('tengu_schedule_failed', {
|
||||
reason: reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(reason, { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
logEvent('tengu_schedule_create', {
|
||||
cron: cron as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
try {
|
||||
const trigger = await createTrigger({ cron_expression: cron, prompt });
|
||||
onDone(`Trigger created: ${trigger.trigger_id}`, { display: 'system' });
|
||||
return React.createElement(ScheduleView, { mode: 'created', trigger });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_schedule_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to create trigger: ${msg}`, { display: 'system' });
|
||||
return React.createElement(ScheduleView, { mode: 'error', message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// ── update ────────────────────────────────────────────────────────────────
|
||||
if (parsed.action === 'update') {
|
||||
const { id, field, value } = parsed;
|
||||
logEvent('tengu_schedule_update', {
|
||||
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
field: field as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
// Coerce value to boolean when field is 'enabled'
|
||||
let body: UpdateTriggerBody = {};
|
||||
if (field === 'enabled') {
|
||||
body = { enabled: value === 'true' || value === '1' };
|
||||
} else if (field === 'cron_expression' || field === 'cron') {
|
||||
body = { cron_expression: value };
|
||||
} else if (field === 'prompt') {
|
||||
body = { prompt: value };
|
||||
} else if (field === 'agent_id') {
|
||||
body = { agent_id: value };
|
||||
} else {
|
||||
const reason = `Unknown field "${field}". Valid fields: enabled, cron_expression, prompt, agent_id`;
|
||||
logEvent('tengu_schedule_failed', {
|
||||
reason: reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(reason, { display: 'system' });
|
||||
return React.createElement(ScheduleView, {
|
||||
mode: 'error',
|
||||
message: reason,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const trigger = await updateTrigger(id, body);
|
||||
onDone(`Trigger ${id} updated.`, { display: 'system' });
|
||||
return React.createElement(ScheduleView, { mode: 'updated', trigger });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_schedule_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to update trigger ${id}: ${msg}`, { display: 'system' });
|
||||
return React.createElement(ScheduleView, { mode: 'error', message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// ── delete ────────────────────────────────────────────────────────────────
|
||||
if (parsed.action === 'delete') {
|
||||
const { id } = parsed;
|
||||
logEvent('tengu_schedule_delete', {
|
||||
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
try {
|
||||
await deleteTrigger(id);
|
||||
onDone(`Trigger ${id} deleted.`, { display: 'system' });
|
||||
return React.createElement(ScheduleView, { mode: 'deleted', id });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_schedule_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to delete trigger ${id}: ${msg}`, { display: 'system' });
|
||||
return React.createElement(ScheduleView, { mode: 'error', message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// ── run ───────────────────────────────────────────────────────────────────
|
||||
if (parsed.action === 'run') {
|
||||
const { id } = parsed;
|
||||
logEvent('tengu_schedule_run', {
|
||||
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
try {
|
||||
const result = await runTrigger(id);
|
||||
onDone(`Trigger ${id} fired. Run ID: ${result.run_id}`, {
|
||||
display: 'system',
|
||||
});
|
||||
return React.createElement(ScheduleView, {
|
||||
mode: 'ran',
|
||||
id,
|
||||
runId: result.run_id,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_schedule_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to run trigger ${id}: ${msg}`, { display: 'system' });
|
||||
return React.createElement(ScheduleView, { mode: 'error', message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// ── enable ────────────────────────────────────────────────────────────────
|
||||
if (parsed.action === 'enable') {
|
||||
const { id } = parsed;
|
||||
logEvent('tengu_schedule_enable', {
|
||||
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
try {
|
||||
await updateTrigger(id, { enabled: true });
|
||||
onDone(`Trigger ${id} enabled.`, { display: 'system' });
|
||||
return React.createElement(ScheduleView, { mode: 'enabled', id });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_schedule_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to enable trigger ${id}: ${msg}`, { display: 'system' });
|
||||
return React.createElement(ScheduleView, { mode: 'error', message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// ── disable ───────────────────────────────────────────────────────────────
|
||||
// parsed.action === 'disable'
|
||||
const { id } = parsed;
|
||||
logEvent('tengu_schedule_disable', {
|
||||
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
try {
|
||||
await updateTrigger(id, { enabled: false });
|
||||
onDone(`Trigger ${id} disabled.`, { display: 'system' });
|
||||
return React.createElement(ScheduleView, { mode: 'disabled', id });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_schedule_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to disable trigger ${id}: ${msg}`, { display: 'system' });
|
||||
return React.createElement(ScheduleView, { mode: 'error', message: msg });
|
||||
}
|
||||
};
|
||||
181
src/commands/schedule/parseArgs.ts
Normal file
181
src/commands/schedule/parseArgs.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Parse the args string for the /schedule command.
|
||||
*
|
||||
* Supported sub-commands:
|
||||
* list → { action: 'list' }
|
||||
* get <id> → { action: 'get', id }
|
||||
* create <cron-expr> <prompt> → { action: 'create', cron, prompt }
|
||||
* update <id> <field> <value> → { action: 'update', id, field, value }
|
||||
* delete <id> → { action: 'delete', id }
|
||||
* run <id> → { action: 'run', id }
|
||||
* enable <id> → { action: 'enable', id }
|
||||
* disable <id> → { action: 'disable', id }
|
||||
* (empty) → { action: 'list' }
|
||||
* anything else → { action: 'invalid', reason }
|
||||
*/
|
||||
|
||||
export type ScheduleArgs =
|
||||
| { action: 'list' }
|
||||
| { action: 'get'; id: string }
|
||||
| { action: 'create'; cron: string; prompt: string }
|
||||
| { action: 'update'; id: string; field: string; value: string }
|
||||
| { action: 'delete'; id: string }
|
||||
| { action: 'run'; id: string }
|
||||
| { action: 'enable'; id: string }
|
||||
| { action: 'disable'; id: string }
|
||||
| { action: 'invalid'; reason: string }
|
||||
|
||||
const USAGE =
|
||||
'Usage: /schedule list | get ID | create CRON PROMPT | update ID FIELD VALUE | delete ID | run ID | enable ID | disable ID'
|
||||
|
||||
/**
|
||||
* Extract the first 5 whitespace-separated tokens as a cron expression;
|
||||
* the remainder is the prompt. Returns null if fewer than 6 tokens are present.
|
||||
*/
|
||||
export function splitCronAndPrompt(
|
||||
rest: string,
|
||||
): { cron: string; prompt: string } | null {
|
||||
const tokens = rest.trim().split(/\s+/)
|
||||
if (tokens.length < 6) return null
|
||||
const cron = tokens.slice(0, 5).join(' ')
|
||||
const prompt = tokens.slice(5).join(' ')
|
||||
return { cron, prompt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a 5-field cron expression (minute hour day month weekday).
|
||||
* Returns true if the expression has exactly 5 fields; false otherwise.
|
||||
* This is a lightweight structural check — the server validates semantics.
|
||||
*/
|
||||
export function isValidCronExpression(cron: string): boolean {
|
||||
const fields = cron.trim().split(/\s+/)
|
||||
return fields.length === 5
|
||||
}
|
||||
|
||||
export function parseScheduleArgs(args: string): ScheduleArgs {
|
||||
const trimmed = args.trim()
|
||||
|
||||
if (trimmed === '' || trimmed === 'list') {
|
||||
return { action: 'list' }
|
||||
}
|
||||
|
||||
const spaceIdx = trimmed.indexOf(' ')
|
||||
const subCmd = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)
|
||||
const rest = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim()
|
||||
|
||||
// ── get ───────────────────────────────────────────────────────────────────
|
||||
if (subCmd === 'get') {
|
||||
if (!rest) {
|
||||
return { action: 'invalid', reason: 'get requires a trigger id' }
|
||||
}
|
||||
const id = rest.split(/\s+/)[0]
|
||||
/* istanbul ignore next */
|
||||
if (!id) {
|
||||
return { action: 'invalid', reason: 'get requires a trigger id' }
|
||||
}
|
||||
return { action: 'get', id }
|
||||
}
|
||||
|
||||
// ── create ────────────────────────────────────────────────────────────────
|
||||
if (subCmd === 'create') {
|
||||
if (!rest) {
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason:
|
||||
'create requires a cron expression and prompt, e.g. create "0 9 * * 1" Run weekly standup',
|
||||
}
|
||||
}
|
||||
const parsed = splitCronAndPrompt(rest)
|
||||
if (!parsed) {
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason:
|
||||
'create requires 5 cron fields followed by a prompt, e.g. create "0 9 * * 1" Run weekly standup',
|
||||
}
|
||||
}
|
||||
const { cron, prompt } = parsed
|
||||
if (!isValidCronExpression(cron)) {
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason: `Invalid cron expression: "${cron}". Expected 5 fields (minute hour day month weekday).`,
|
||||
}
|
||||
}
|
||||
/* istanbul ignore next -- prompt is non-empty by construction from splitCronAndPrompt */
|
||||
if (!prompt.trim()) {
|
||||
return { action: 'invalid', reason: 'prompt cannot be empty' }
|
||||
}
|
||||
return { action: 'create', cron, prompt: prompt.trim() }
|
||||
}
|
||||
|
||||
// ── update ────────────────────────────────────────────────────────────────
|
||||
if (subCmd === 'update') {
|
||||
const parts = rest.split(/\s+/)
|
||||
if (parts.length < 3 || !parts[0]) {
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason:
|
||||
'update requires an id, field, and value, e.g. update trg_123 enabled false',
|
||||
}
|
||||
}
|
||||
const id = parts[0]
|
||||
const field = parts[1] ?? ''
|
||||
const value = parts.slice(2).join(' ')
|
||||
if (!field) {
|
||||
return { action: 'invalid', reason: 'update requires a field name' }
|
||||
}
|
||||
if (!value) {
|
||||
return { action: 'invalid', reason: 'update requires a value' }
|
||||
}
|
||||
return { action: 'update', id, field, value }
|
||||
}
|
||||
|
||||
// ── delete ────────────────────────────────────────────────────────────────
|
||||
if (subCmd === 'delete') {
|
||||
if (!rest) {
|
||||
return { action: 'invalid', reason: 'delete requires a trigger id' }
|
||||
}
|
||||
const id = rest.split(/\s+/)[0]
|
||||
/* istanbul ignore next */
|
||||
if (!id) {
|
||||
return { action: 'invalid', reason: 'delete requires a trigger id' }
|
||||
}
|
||||
return { action: 'delete', id }
|
||||
}
|
||||
|
||||
// ── run ───────────────────────────────────────────────────────────────────
|
||||
if (subCmd === 'run') {
|
||||
if (!rest) {
|
||||
return { action: 'invalid', reason: 'run requires a trigger id' }
|
||||
}
|
||||
const id = rest.split(/\s+/)[0]
|
||||
/* istanbul ignore next */
|
||||
if (!id) {
|
||||
return { action: 'invalid', reason: 'run requires a trigger id' }
|
||||
}
|
||||
return { action: 'run', id }
|
||||
}
|
||||
|
||||
// ── enable / disable ──────────────────────────────────────────────────────
|
||||
if (subCmd === 'enable' || subCmd === 'disable') {
|
||||
if (!rest) {
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason: `${subCmd} requires a trigger id`,
|
||||
}
|
||||
}
|
||||
const id = rest.split(/\s+/)[0]
|
||||
/* istanbul ignore next */
|
||||
if (!id) {
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason: `${subCmd} requires a trigger id`,
|
||||
}
|
||||
}
|
||||
return { action: subCmd as 'enable' | 'disable', id }
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason: `Unknown sub-command "${subCmd}". ${USAGE}`,
|
||||
}
|
||||
}
|
||||
247
src/commands/schedule/triggersApi.ts
Normal file
247
src/commands/schedule/triggersApi.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Thin HTTP client for the /v1/code/triggers endpoint.
|
||||
*
|
||||
* Key spec facts (from binary reverse-engineering of v2.1.123):
|
||||
* - list: GET /v1/code/triggers
|
||||
* - get: GET /v1/code/triggers/{trigger_id}
|
||||
* - create: POST /v1/code/triggers
|
||||
* - update: POST /v1/code/triggers/{trigger_id} ← POST not PATCH
|
||||
* - run: POST /v1/code/triggers/{trigger_id}/run
|
||||
* - delete: DELETE /v1/code/triggers/{trigger_id}
|
||||
*
|
||||
* Reuses the same base-URL + auth-header pattern as agentsApi.ts.
|
||||
*/
|
||||
|
||||
import axios from 'axios'
|
||||
import { getOauthConfig } from '../../constants/oauth.js'
|
||||
import { getOAuthHeaders, prepareApiRequest } from '../../utils/teleport/api.js'
|
||||
|
||||
export type Trigger = {
|
||||
trigger_id: string
|
||||
cron_expression: string
|
||||
enabled: boolean
|
||||
prompt: string
|
||||
agent_id?: string
|
||||
last_run?: string | null
|
||||
next_run?: string | null
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export type CreateTriggerBody = {
|
||||
cron_expression: string
|
||||
prompt: string
|
||||
agent_id?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export type UpdateTriggerBody = Partial<{
|
||||
cron_expression: string
|
||||
prompt: string
|
||||
enabled: boolean
|
||||
agent_id: string
|
||||
}>
|
||||
|
||||
type ListTriggersResponse = {
|
||||
data: Trigger[]
|
||||
}
|
||||
|
||||
type TriggerRunResponse = {
|
||||
run_id: string
|
||||
}
|
||||
|
||||
// Reverse-engineered from claude.exe v2.1.123: the only beta value the
|
||||
// triggers endpoint actually accepts on the subscription auth plane is
|
||||
// `ccr-triggers-2026-01-30`. The earlier umbrella value
|
||||
// `managed-agents-2026-04-01` only appears in documentation strings, never
|
||||
// in actual request construction.
|
||||
const TRIGGERS_BETA_HEADER = 'ccr-triggers-2026-01-30'
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
class TriggersApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'TriggersApiError'
|
||||
}
|
||||
}
|
||||
|
||||
async function buildHeaders(): Promise<Record<string, string>> {
|
||||
let accessToken: string
|
||||
let orgUUID: string
|
||||
try {
|
||||
const prepared = await prepareApiRequest()
|
||||
accessToken = prepared.accessToken
|
||||
orgUUID = prepared.orgUUID
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
throw new TriggersApiError(
|
||||
`Not authenticated: ${msg}. Run /login to re-authenticate.`,
|
||||
401,
|
||||
)
|
||||
}
|
||||
return {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': TRIGGERS_BETA_HEADER,
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
}
|
||||
|
||||
function triggersBaseUrl(): string {
|
||||
return `${getOauthConfig().BASE_API_URL}/v1/code/triggers`
|
||||
}
|
||||
|
||||
function classifyError(err: unknown): TriggersApiError {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const status = err.response?.status ?? 0
|
||||
if (status === 401) {
|
||||
return new TriggersApiError(
|
||||
'Authentication failed. Please run /login to re-authenticate.',
|
||||
401,
|
||||
)
|
||||
}
|
||||
if (status === 403) {
|
||||
return new TriggersApiError(
|
||||
'Subscription required. Scheduled triggers require a Claude Pro/Max/Team subscription.',
|
||||
403,
|
||||
)
|
||||
}
|
||||
if (status === 404) {
|
||||
return new TriggersApiError('Trigger not found.', 404)
|
||||
}
|
||||
if (status === 429) {
|
||||
const retryAfter =
|
||||
(err.response?.headers as Record<string, string> | undefined)?.[
|
||||
'retry-after'
|
||||
] ?? ''
|
||||
const detail = retryAfter ? ` Retry after ${retryAfter}s.` : ''
|
||||
return new TriggersApiError(`Rate limit exceeded.${detail}`, 429)
|
||||
}
|
||||
const msg =
|
||||
(err.response?.data as { error?: { message?: string } } | undefined)
|
||||
?.error?.message ?? err.message
|
||||
return new TriggersApiError(msg, status)
|
||||
}
|
||||
if (err instanceof TriggersApiError) return err
|
||||
return new TriggersApiError(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Retry-After header value into milliseconds.
|
||||
* Accepts both integer-seconds (e.g. "30") and HTTP-date strings.
|
||||
* Returns null when the header is absent or unparseable.
|
||||
*/
|
||||
function parseRetryAfterMs(header: string | undefined): number | null {
|
||||
if (!header) return null
|
||||
const seconds = Number(header)
|
||||
if (!Number.isNaN(seconds) && seconds >= 0) return seconds * 1000
|
||||
const date = Date.parse(header)
|
||||
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
|
||||
return null
|
||||
}
|
||||
|
||||
async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
||||
let lastErr: TriggersApiError | undefined
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err: unknown) {
|
||||
const classified = classifyError(err)
|
||||
// Only retry 5xx errors
|
||||
if (classified.statusCode >= 500) {
|
||||
lastErr = classified
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
const retryAfterHeader = axios.isAxiosError(err)
|
||||
? (err.response?.headers as Record<string, string> | undefined)?.[
|
||||
'retry-after'
|
||||
]
|
||||
: undefined
|
||||
const waitMs =
|
||||
parseRetryAfterMs(retryAfterHeader) ?? 500 * 2 ** attempt
|
||||
await sleep(waitMs)
|
||||
}
|
||||
continue
|
||||
}
|
||||
throw classified
|
||||
}
|
||||
}
|
||||
throw lastErr ?? new TriggersApiError('Request failed after retries', 0)
|
||||
}
|
||||
|
||||
export async function listTriggers(): Promise<Trigger[]> {
|
||||
return withRetry(async () => {
|
||||
const headers = await buildHeaders()
|
||||
const response = await axios.get<ListTriggersResponse>(triggersBaseUrl(), {
|
||||
headers,
|
||||
})
|
||||
return response.data.data ?? []
|
||||
})
|
||||
}
|
||||
|
||||
export async function getTrigger(id: string): Promise<Trigger> {
|
||||
return withRetry(async () => {
|
||||
const headers = await buildHeaders()
|
||||
const response = await axios.get<Trigger>(`${triggersBaseUrl()}/${id}`, {
|
||||
headers,
|
||||
})
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
export async function createTrigger(body: CreateTriggerBody): Promise<Trigger> {
|
||||
return withRetry(async () => {
|
||||
const headers = await buildHeaders()
|
||||
const response = await axios.post<Trigger>(triggersBaseUrl(), body, {
|
||||
headers,
|
||||
})
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a trigger.
|
||||
*
|
||||
* IMPORTANT: The upstream API uses POST (not PATCH/PUT) for updates.
|
||||
* Binary literal evidence: "update: POST /v1/code/triggers/{trigger_id}"
|
||||
*/
|
||||
export async function updateTrigger(
|
||||
id: string,
|
||||
body: UpdateTriggerBody,
|
||||
): Promise<Trigger> {
|
||||
return withRetry(async () => {
|
||||
const headers = await buildHeaders()
|
||||
const response = await axios.post<Trigger>(
|
||||
`${triggersBaseUrl()}/${id}`,
|
||||
body,
|
||||
{ headers },
|
||||
)
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteTrigger(id: string): Promise<void> {
|
||||
return withRetry(async () => {
|
||||
const headers = await buildHeaders()
|
||||
await axios.delete(`${triggersBaseUrl()}/${id}`, { headers })
|
||||
})
|
||||
}
|
||||
|
||||
export async function runTrigger(id: string): Promise<TriggerRunResponse> {
|
||||
return withRetry(async () => {
|
||||
const headers = await buildHeaders()
|
||||
const response = await axios.post<TriggerRunResponse>(
|
||||
`${triggersBaseUrl()}/${id}/run`,
|
||||
{},
|
||||
{ headers },
|
||||
)
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user