feat: 添加云端管理命令(memory-stores、vault、schedule、skill-store、agents-platform)

- /memory-stores: 远程记忆存储管理
- /vault: 密钥保险库管理
- /schedule: 云端定时触发器管理(cron)
- /skill-store: 技能商店浏览和安装
- /agents-platform: 远程 agent 调度管理

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-05-09 23:04:17 +08:00
parent ee63c17697
commit 2437040b5b
47 changed files with 9309 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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