@myapihq/cli 1.2.8 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/commands/email/index.js +1 -0
  2. package/dist/commands/email/mailbox.js +28 -1
  3. package/dist/commands/email/verify.d.ts +2 -2
  4. package/dist/commands/email/verify.js +85 -16
  5. package/dist/commands/git.d.ts +22 -0
  6. package/dist/commands/git.js +367 -0
  7. package/dist/commands/pixel.js +6 -3
  8. package/dist/commands/queue-validation.test.d.ts +1 -0
  9. package/dist/commands/queue-validation.test.js +38 -0
  10. package/dist/commands/queue.d.ts +14 -0
  11. package/dist/commands/queue.js +215 -0
  12. package/dist/commands/task-validation.test.d.ts +1 -0
  13. package/dist/commands/task-validation.test.js +37 -0
  14. package/dist/commands/task.d.ts +18 -0
  15. package/dist/commands/task.js +288 -0
  16. package/dist/commands/workflow-validation.test.js +27 -0
  17. package/dist/commands/workflow.js +17 -1
  18. package/dist/completion.js +6 -3
  19. package/dist/exposes.test.js +3 -0
  20. package/dist/index.js +21 -0
  21. package/dist/sdk-email-forwarding.test.d.ts +1 -0
  22. package/dist/sdk-email-forwarding.test.js +48 -0
  23. package/dist/sdk-email-verify-bulk.test.d.ts +1 -0
  24. package/dist/sdk-email-verify-bulk.test.js +57 -0
  25. package/dist/sdk-git.test.d.ts +1 -0
  26. package/dist/sdk-git.test.js +115 -0
  27. package/dist/sdk-queue.test.d.ts +1 -0
  28. package/dist/sdk-queue.test.js +86 -0
  29. package/dist/sdk-task.test.d.ts +1 -0
  30. package/dist/sdk-task.test.js +110 -0
  31. package/dist/skills/my-email-api/README.md +45 -0
  32. package/dist/skills/my-email-api/SKILL.md +80 -0
  33. package/dist/skills/my-email-api/claude/.claude-plugin/plugin.json +6 -0
  34. package/dist/skills/my-email-api/openapi/.gitkeep +0 -0
  35. package/dist/skills/my-workflow-api/SKILL.md +10 -1
  36. package/package.json +2 -2
@@ -0,0 +1,38 @@
1
+ // Unit tests for the queue CLI's pure validators.
2
+ import { describe, it, expect } from 'vitest';
3
+ import { _validateConsumerUrl, _parseDependsOn } from './queue.js';
4
+ describe('_validateConsumerUrl', () => {
5
+ it.each([
6
+ 'https://example.com/consume',
7
+ 'http://example.com/consume',
8
+ 'https://fn.myapi.com/org/fn_123',
9
+ 'https://example.com:8443/path?q=v',
10
+ ])('accepts %s', (url) => {
11
+ expect(_validateConsumerUrl(url)).toBeNull();
12
+ });
13
+ it.each([
14
+ ['ftp://example.com', /http:\/\/ or https:\/\//],
15
+ ['javascript:alert(1)', /http:\/\/ or https:\/\//],
16
+ ['file:///etc/passwd', /http:\/\/ or https:\/\//],
17
+ ])('rejects non-http(s) %s', (url, re) => {
18
+ expect(_validateConsumerUrl(url)).toMatch(re);
19
+ });
20
+ it.each(['not a url', 'just-text', 'http//missing-colon'])('rejects malformed %s', (url) => {
21
+ expect(_validateConsumerUrl(url)).toMatch(/not a valid URL/);
22
+ });
23
+ });
24
+ describe('_parseDependsOn', () => {
25
+ it('returns undefined for empty / non-string input', () => {
26
+ expect(_parseDependsOn(undefined)).toBeUndefined();
27
+ expect(_parseDependsOn('')).toBeUndefined();
28
+ expect(_parseDependsOn(' ')).toBeUndefined();
29
+ expect(_parseDependsOn(true)).toBeUndefined();
30
+ });
31
+ it('splits a comma-separated list and trims', () => {
32
+ expect(_parseDependsOn('j1,j2,j3')).toEqual(['j1', 'j2', 'j3']);
33
+ expect(_parseDependsOn(' j1 , j2 ')).toEqual(['j1', 'j2']);
34
+ });
35
+ it('drops empty segments', () => {
36
+ expect(_parseDependsOn('j1,,j2,')).toEqual(['j1', 'j2']);
37
+ });
38
+ });
@@ -0,0 +1,14 @@
1
+ import type { FlagSchema } from '../flags.js';
2
+ import { type Flags } from '../helpers.js';
3
+ import type { Exposes } from '../exposes.js';
4
+ export declare const EXPOSES: Exposes;
5
+ export declare const SCHEMA: FlagSchema;
6
+ export declare function _validateConsumerUrl(url: string): string | null;
7
+ export declare function _parseDependsOn(raw: unknown): string[] | undefined;
8
+ export declare function create(name: string, flags: Flags): Promise<void>;
9
+ export declare function list(flags: Flags): Promise<void>;
10
+ export declare function get(name: string, flags: Flags): Promise<void>;
11
+ export declare function enqueue(name: string, flags: Flags): Promise<void>;
12
+ export declare function jobs(name: string, flags: Flags): Promise<void>;
13
+ export declare function job(jobId: string, flags: Flags): Promise<void>;
14
+ export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -0,0 +1,215 @@
1
+ import { queue as sdkQueue } from '@myapihq/sdk';
2
+ import { requireConfig } from '../config.js';
3
+ import { success, error, printTable, info, printJson } from '../output.js';
4
+ import { formatDate } from '../utils.js';
5
+ import { requireOrg, requireArg } from '../helpers.js';
6
+ export const EXPOSES = [
7
+ 'POST /queue/orgs/{org_id}/queues',
8
+ 'GET /queue/orgs/{org_id}/queues',
9
+ 'GET /queue/orgs/{org_id}/queues/{name}',
10
+ 'POST /queue/orgs/{org_id}/queues/{name}/jobs',
11
+ 'GET /queue/orgs/{org_id}/queues/{name}/jobs',
12
+ 'GET /queue/orgs/{org_id}/jobs/{id}',
13
+ ];
14
+ export const SCHEMA = {
15
+ name: 'string',
16
+ 'consumer-url': 'string',
17
+ 'max-attempts': 'number',
18
+ 'max-concurrency': 'number',
19
+ payload: 'string',
20
+ 'dedup-key': 'string',
21
+ delay: 'number',
22
+ 'depends-on': 'string',
23
+ status: 'string',
24
+ limit: 'number',
25
+ };
26
+ // Pure validator — returns an error message or null. Mirrors
27
+ // webhook.ts's _validateForwardUrl so it's unit-testable.
28
+ export function _validateConsumerUrl(url) {
29
+ let parsed;
30
+ try {
31
+ parsed = new URL(url);
32
+ }
33
+ catch {
34
+ return `consumer URL "${url}" is not a valid URL.`;
35
+ }
36
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
37
+ return `consumer URL must use http:// or https:// — got "${url}".`;
38
+ }
39
+ return null;
40
+ }
41
+ // Parses a comma-separated --depends-on flag into a clean id array.
42
+ export function _parseDependsOn(raw) {
43
+ if (typeof raw !== 'string' || raw.trim() === '')
44
+ return undefined;
45
+ return raw.split(',').map(s => s.trim()).filter(Boolean);
46
+ }
47
+ // ── Queues ───────────────────────────────────────────────────────────────────
48
+ export async function create(name, flags) {
49
+ const config = requireConfig();
50
+ const orgId = requireOrg(flags, config, 'myapi queue create <name> --consumer-url <url> [--org <id>]');
51
+ const queueName = name || flags.name;
52
+ requireArg(queueName, 'name', 'myapi queue create <name> --consumer-url <url>');
53
+ const consumerUrl = flags['consumer-url'];
54
+ if (typeof consumerUrl !== 'string' || consumerUrl === '') {
55
+ error('Missing --consumer-url.\nUsage: myapi queue create <name> --consumer-url <url> [--max-attempts <n>] [--max-concurrency <n>]');
56
+ }
57
+ const urlErr = _validateConsumerUrl(consumerUrl);
58
+ if (urlErr)
59
+ error(urlErr);
60
+ const q = await sdkQueue.createQueue(config.api_key, orgId, {
61
+ name: queueName,
62
+ consumerUrl,
63
+ maxAttempts: typeof flags['max-attempts'] === 'number' ? flags['max-attempts'] : undefined,
64
+ maxConcurrency: typeof flags['max-concurrency'] === 'number' ? flags['max-concurrency'] : undefined,
65
+ });
66
+ if (flags.json) {
67
+ printJson(q);
68
+ return;
69
+ }
70
+ success(`Queue created: ${q.name}`);
71
+ info(`Consumer: ${q.consumer_url}`);
72
+ info(`Max attempts: ${q.max_attempts}`);
73
+ info(`Max concurrency: ${q.max_concurrency}`);
74
+ }
75
+ export async function list(flags) {
76
+ const config = requireConfig();
77
+ const orgId = requireOrg(flags, config, 'myapi queue list [--org <id>]');
78
+ const queues = await sdkQueue.listQueues(config.api_key, orgId);
79
+ if (flags.json) {
80
+ printJson(queues);
81
+ return;
82
+ }
83
+ printTable(queues.map(q => ({ name: q.name })), {
84
+ flags,
85
+ empty: 'No queues yet. Create one with: myapi queue create <name> --consumer-url <url>',
86
+ });
87
+ }
88
+ export async function get(name, flags) {
89
+ const config = requireConfig();
90
+ const orgId = requireOrg(flags, config, 'myapi queue get <name> [--org <id>]');
91
+ requireArg(name, 'name', 'myapi queue get <name>');
92
+ const q = await sdkQueue.getQueue(config.api_key, orgId, name);
93
+ if (flags.json) {
94
+ printJson(q);
95
+ return;
96
+ }
97
+ info(`Name: ${q.name}`);
98
+ info(`Consumer: ${q.consumer_url}`);
99
+ info(`Max attempts: ${q.max_attempts}`);
100
+ info(`Max concurrency: ${q.max_concurrency}`);
101
+ }
102
+ // ── Jobs ─────────────────────────────────────────────────────────────────────
103
+ export async function enqueue(name, flags) {
104
+ const config = requireConfig();
105
+ const orgId = requireOrg(flags, config, 'myapi queue enqueue <name> --payload <json> [--org <id>]');
106
+ requireArg(name, 'name', 'myapi queue enqueue <name> --payload <json>');
107
+ let payload;
108
+ if (typeof flags.payload === 'string') {
109
+ try {
110
+ payload = JSON.parse(flags.payload);
111
+ }
112
+ catch (e) {
113
+ error(`--payload is not valid JSON: ${e?.message ?? e}`);
114
+ }
115
+ }
116
+ else if (flags.payload === true) {
117
+ error('--payload needs a JSON value, e.g. --payload \'{"order_id":42}\'');
118
+ }
119
+ const job = await sdkQueue.enqueueJob(config.api_key, orgId, name, {
120
+ payload,
121
+ dedupKey: typeof flags['dedup-key'] === 'string' ? flags['dedup-key'] : undefined,
122
+ delaySeconds: typeof flags.delay === 'number' ? flags.delay : undefined,
123
+ dependsOn: _parseDependsOn(flags['depends-on']),
124
+ });
125
+ if (flags.json) {
126
+ printJson(job);
127
+ return;
128
+ }
129
+ success(`Job enqueued: ${job.id}`);
130
+ info(`Status: ${job.status}`);
131
+ }
132
+ export async function jobs(name, flags) {
133
+ const config = requireConfig();
134
+ const orgId = requireOrg(flags, config, 'myapi queue jobs <name> [--status <s>] [--limit <n>] [--org <id>]');
135
+ requireArg(name, 'name', 'myapi queue jobs <name>');
136
+ const list = await sdkQueue.listJobs(config.api_key, orgId, name, {
137
+ status: typeof flags.status === 'string' ? flags.status : undefined,
138
+ limit: typeof flags.limit === 'number' ? flags.limit : undefined,
139
+ });
140
+ if (flags.json) {
141
+ printJson(list);
142
+ return;
143
+ }
144
+ printTable(list.map(j => ({
145
+ id: j.id,
146
+ status: j.status,
147
+ attempt: j.attempt,
148
+ created: j.created_at ? formatDate(j.created_at) : '',
149
+ })), { flags, empty: 'No jobs in this queue.' });
150
+ }
151
+ export async function job(jobId, flags) {
152
+ const config = requireConfig();
153
+ const orgId = requireOrg(flags, config, 'myapi queue job <job_id> [--org <id>]');
154
+ requireArg(jobId, 'job_id', 'myapi queue job <job_id>');
155
+ const j = await sdkQueue.getJob(config.api_key, orgId, jobId);
156
+ if (flags.json) {
157
+ printJson(j);
158
+ return;
159
+ }
160
+ info(`Job: ${j.id}`);
161
+ info(`Queue: ${j.queue_id}`);
162
+ info(`Status: ${j.status}`);
163
+ info(`Attempt: ${j.attempt} / ${j.max_attempts}`);
164
+ if (j.last_error)
165
+ info(`Error: ${j.last_error}`);
166
+ }
167
+ // ── Dispatcher ───────────────────────────────────────────────────────────────
168
+ const SUBCOMMAND_USAGE = {
169
+ 'create': 'myapi queue create <name> --consumer-url <url> [--max-attempts <n>] [--max-concurrency <n>] [--org <id>]',
170
+ 'list': 'myapi queue list [--org <id>] [--json]',
171
+ 'get': 'myapi queue get <name> [--org <id>] [--json]',
172
+ 'enqueue': 'myapi queue enqueue <name> --payload <json> [--dedup-key <k>] [--delay <seconds>] [--depends-on <id,id>] [--org <id>]',
173
+ 'jobs': 'myapi queue jobs <name> [--status <s>] [--limit <n>] [--org <id>] [--json]',
174
+ 'job': 'myapi queue job <job_id> [--org <id>] [--json]',
175
+ };
176
+ export async function run(subcommand, args, flags) {
177
+ if (!subcommand || (flags.help && !subcommand)) {
178
+ info(`Usage: myapi queue <subcommand>
179
+
180
+ A durable HTTP-consumer job queue — jobs are POSTed to the queue's
181
+ consumer_url with retry/backoff, a concurrency cap, and a dependency DAG.
182
+
183
+ Queues:
184
+ create <name> Create a queue (--consumer-url, --max-attempts, --max-concurrency)
185
+ list List queues
186
+ get <name> Show a queue's policy
187
+
188
+ Jobs:
189
+ enqueue <name> Enqueue a job (--payload, --dedup-key, --delay, --depends-on)
190
+ jobs <name> List a queue's jobs (--status, --limit)
191
+ job <job_id> Show a single job's status
192
+
193
+ For "needs a decision" work (agent/human), see: myapi task --help
194
+
195
+ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
196
+ return;
197
+ }
198
+ if (flags.help) {
199
+ const usage = SUBCOMMAND_USAGE[subcommand];
200
+ if (usage)
201
+ info(`Usage: ${usage}`);
202
+ else
203
+ info(`Unknown subcommand: ${subcommand}. Run "myapi queue --help" for the list.`);
204
+ return;
205
+ }
206
+ switch (subcommand) {
207
+ case 'create': return create(args[0], flags);
208
+ case 'list': return list(flags);
209
+ case 'get': return get(args[0], flags);
210
+ case 'enqueue': return enqueue(args[0], flags);
211
+ case 'jobs': return jobs(args[0], flags);
212
+ case 'job': return job(args[0], flags);
213
+ default: error(`Unknown subcommand: ${subcommand}. Run "myapi queue --help" for a list of valid subcommands.`);
214
+ }
215
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ // Unit tests for the task CLI's pure parsers.
2
+ import { describe, it, expect } from 'vitest';
3
+ import { _parseResolveOn, _splitList } from './task.js';
4
+ describe('_parseResolveOn', () => {
5
+ it('parses a bare event type', () => {
6
+ expect(_parseResolveOn('payment.succeeded')).toEqual({ event_type: 'payment.succeeded' });
7
+ });
8
+ it('parses event_type:field=value', () => {
9
+ expect(_parseResolveOn('payment.succeeded:order_id=o_42')).toEqual({
10
+ event_type: 'payment.succeeded', field: 'order_id', value: 'o_42',
11
+ });
12
+ });
13
+ it('keeps = signs inside the value', () => {
14
+ expect(_parseResolveOn('evt:token=a=b=c')).toEqual({
15
+ event_type: 'evt', field: 'token', value: 'a=b=c',
16
+ });
17
+ });
18
+ it('rejects an empty string', () => {
19
+ expect(_parseResolveOn(' ')).toMatch(/cannot be empty/);
20
+ });
21
+ it('rejects a leading colon (no event type)', () => {
22
+ expect(_parseResolveOn(':field=value')).toMatch(/must start with an event type/);
23
+ });
24
+ it('rejects a field match without =', () => {
25
+ expect(_parseResolveOn('evt:justfield')).toMatch(/field=value/);
26
+ });
27
+ });
28
+ describe('_splitList', () => {
29
+ it('returns undefined for empty / non-string input', () => {
30
+ expect(_splitList(undefined)).toBeUndefined();
31
+ expect(_splitList('')).toBeUndefined();
32
+ expect(_splitList(true)).toBeUndefined();
33
+ });
34
+ it('splits and trims, dropping empties', () => {
35
+ expect(_splitList('support, sales ,, billing')).toEqual(['support', 'sales', 'billing']);
36
+ });
37
+ });
@@ -0,0 +1,18 @@
1
+ import { task as sdkTask } from '@myapihq/sdk';
2
+ import type { FlagSchema } from '../flags.js';
3
+ import { type Flags } from '../helpers.js';
4
+ import type { Exposes } from '../exposes.js';
5
+ export declare const EXPOSES: Exposes;
6
+ export declare const SCHEMA: FlagSchema;
7
+ export declare const TASK_IMPORTANCE: readonly ["low", "normal", "high", "critical"];
8
+ export declare function _parseResolveOn(raw: string): sdkTask.ResolveOn | string;
9
+ export declare function _splitList(raw: unknown): string[] | undefined;
10
+ export declare function create(description: string, flags: Flags): Promise<void>;
11
+ export declare function list(flags: Flags): Promise<void>;
12
+ export declare function get(id: string, flags: Flags): Promise<void>;
13
+ export declare function claim(id: string, flags: Flags): Promise<void>;
14
+ export declare function extend(id: string, flags: Flags): Promise<void>;
15
+ export declare function resolve(id: string, flags: Flags): Promise<void>;
16
+ export declare function fail(id: string, flags: Flags): Promise<void>;
17
+ export declare function cancel(id: string, flags: Flags): Promise<void>;
18
+ export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -0,0 +1,288 @@
1
+ import { task as sdkTask } from '@myapihq/sdk';
2
+ import { requireConfig } from '../config.js';
3
+ import { success, error, printTable, info, printJson } from '../output.js';
4
+ import { formatDate } from '../utils.js';
5
+ import { requireOrg, requireArg } from '../helpers.js';
6
+ import * as fs from 'fs';
7
+ export const EXPOSES = [
8
+ 'POST /task/orgs/{org_id}/tasks',
9
+ 'GET /task/orgs/{org_id}/tasks',
10
+ 'GET /task/orgs/{org_id}/tasks/{id}',
11
+ 'DELETE /task/orgs/{org_id}/tasks/{id}',
12
+ 'GET /task/orgs/{org_id}/tasks/{id}/body',
13
+ 'POST /task/orgs/{org_id}/tasks/{id}/claim',
14
+ 'POST /task/orgs/{org_id}/tasks/{id}/extend',
15
+ 'POST /task/orgs/{org_id}/tasks/{id}/fail',
16
+ 'POST /task/orgs/{org_id}/tasks/{id}/resolve',
17
+ ];
18
+ export const SCHEMA = {
19
+ body: 'string',
20
+ importance: 'string',
21
+ due: 'string',
22
+ assignee: 'string',
23
+ tag: 'string',
24
+ 'depends-on': 'string',
25
+ 'dedup-key': 'string',
26
+ 'resolve-on': 'string',
27
+ source: 'string',
28
+ status: 'string',
29
+ limit: 'number',
30
+ lease: 'number',
31
+ worker: 'string',
32
+ reason: 'string',
33
+ };
34
+ // The backend's fixed importance enum — validated client-side so a typo
35
+ // fails before the network call.
36
+ export const TASK_IMPORTANCE = ['low', 'normal', 'high', 'critical'];
37
+ // Parses --resolve-on "<event_type>[:<field>=<value>]" into a matcher.
38
+ // Returns the parsed object, or a string error message.
39
+ export function _parseResolveOn(raw) {
40
+ const trimmed = raw.trim();
41
+ if (trimmed === '')
42
+ return '--resolve-on cannot be empty.';
43
+ const colon = trimmed.indexOf(':');
44
+ if (colon === -1)
45
+ return { event_type: trimmed };
46
+ const eventType = trimmed.slice(0, colon);
47
+ const rest = trimmed.slice(colon + 1);
48
+ if (eventType === '')
49
+ return '--resolve-on must start with an event type, e.g. payment.succeeded:amount=100';
50
+ const eq = rest.indexOf('=');
51
+ if (eq === -1) {
52
+ return `--resolve-on field match must look like field=value — got "${rest}".`;
53
+ }
54
+ return { event_type: eventType, field: rest.slice(0, eq), value: rest.slice(eq + 1) };
55
+ }
56
+ // Parses a comma-separated --tag / --depends-on flag into a clean array.
57
+ export function _splitList(raw) {
58
+ if (typeof raw !== 'string' || raw.trim() === '')
59
+ return undefined;
60
+ return raw.split(',').map(s => s.trim()).filter(Boolean);
61
+ }
62
+ // --body accepts inline text or @path to read a Markdown file.
63
+ function resolveBody(raw) {
64
+ if (raw.startsWith('@')) {
65
+ const path = raw.slice(1);
66
+ try {
67
+ return fs.readFileSync(path, 'utf-8');
68
+ }
69
+ catch (e) {
70
+ error(`Could not read --body file "${path}": ${e?.message ?? e}`);
71
+ }
72
+ }
73
+ return raw;
74
+ }
75
+ // ── Create / list ────────────────────────────────────────────────────────────
76
+ export async function create(description, flags) {
77
+ const config = requireConfig();
78
+ const orgId = requireOrg(flags, config, 'myapi task create "<description>" [--org <id>]');
79
+ requireArg(description, 'description', 'myapi task create "<description>"');
80
+ let resolveOn;
81
+ if (typeof flags['resolve-on'] === 'string') {
82
+ const parsed = _parseResolveOn(flags['resolve-on']);
83
+ if (typeof parsed === 'string')
84
+ error(parsed);
85
+ resolveOn = parsed;
86
+ }
87
+ let body;
88
+ if (typeof flags.body === 'string')
89
+ body = resolveBody(flags.body);
90
+ else if (flags.body === true)
91
+ error('--body needs a value: inline Markdown or @path-to-file.');
92
+ let importance;
93
+ if (typeof flags.importance === 'string') {
94
+ if (!TASK_IMPORTANCE.includes(flags.importance)) {
95
+ error(`Invalid --importance "${flags.importance}". Must be one of: ${TASK_IMPORTANCE.join(', ')}.`);
96
+ }
97
+ importance = flags.importance;
98
+ }
99
+ const t = await sdkTask.createTask(config.api_key, orgId, {
100
+ description,
101
+ body,
102
+ importance,
103
+ dueAt: typeof flags.due === 'string' ? flags.due : undefined,
104
+ assignee: typeof flags.assignee === 'string' ? flags.assignee : undefined,
105
+ tags: _splitList(flags.tag),
106
+ dependsOn: _splitList(flags['depends-on']),
107
+ dedupKey: typeof flags['dedup-key'] === 'string' ? flags['dedup-key'] : undefined,
108
+ resolveOn,
109
+ source: typeof flags.source === 'string' ? flags.source : undefined,
110
+ });
111
+ if (flags.json) {
112
+ printJson(t);
113
+ return;
114
+ }
115
+ success(`Task created: ${t.id}`);
116
+ info(`Status: ${t.status}`);
117
+ if (t.assignee)
118
+ info(`Assignee: ${t.assignee} (magic link emailed)`);
119
+ }
120
+ export async function list(flags) {
121
+ const config = requireConfig();
122
+ const orgId = requireOrg(flags, config, 'myapi task list [--org <id>]');
123
+ const res = await sdkTask.listTasks(config.api_key, orgId, {
124
+ status: typeof flags.status === 'string' ? flags.status : undefined,
125
+ tag: typeof flags.tag === 'string' ? flags.tag : undefined,
126
+ importance: typeof flags.importance === 'string' ? flags.importance : undefined,
127
+ assignee: typeof flags.assignee === 'string' ? flags.assignee : undefined,
128
+ source: typeof flags.source === 'string' ? flags.source : undefined,
129
+ limit: typeof flags.limit === 'number' ? flags.limit : undefined,
130
+ });
131
+ if (flags.json) {
132
+ printJson(res);
133
+ return;
134
+ }
135
+ printTable(res.tasks.map(t => ({
136
+ id: t.id,
137
+ score: t.score,
138
+ description: t.description,
139
+ })), { flags, empty: 'No open tasks.' });
140
+ info('');
141
+ info(`Showing ${res.meta.shown} of ${res.meta.total_open} open task(s).`);
142
+ }
143
+ // ── Read ─────────────────────────────────────────────────────────────────────
144
+ export async function get(id, flags) {
145
+ const config = requireConfig();
146
+ const orgId = requireOrg(flags, config, 'myapi task get <id> [--body] [--org <id>]');
147
+ requireArg(id, 'id', 'myapi task get <id>');
148
+ const t = await sdkTask.getTask(config.api_key, orgId, id);
149
+ // --body opts in to the separate body tier — never fetched implicitly.
150
+ const wantBody = flags.body === true || typeof flags.body === 'string';
151
+ const body = wantBody ? await sdkTask.getTaskBody(config.api_key, orgId, id) : undefined;
152
+ if (flags.json) {
153
+ printJson(wantBody ? { ...t, body } : t);
154
+ return;
155
+ }
156
+ info(`Task: ${t.id}`);
157
+ info(`Status: ${t.status}`);
158
+ info(`Description: ${t.description}`);
159
+ if (t.importance)
160
+ info(`Importance: ${t.importance}`);
161
+ if (t.due_at)
162
+ info(`Due: ${formatDate(t.due_at)}`);
163
+ if (t.assignee)
164
+ info(`Assignee: ${t.assignee}`);
165
+ if (t.tags?.length)
166
+ info(`Tags: ${t.tags.join(', ')}`);
167
+ if (t.depends_on?.length)
168
+ info(`Depends on: ${t.depends_on.join(', ')}`);
169
+ if (t.claimed_by)
170
+ info(`Claimed by: ${t.claimed_by}`);
171
+ if (t.lease_expires_at)
172
+ info(`Lease until: ${formatDate(t.lease_expires_at)}`);
173
+ if (wantBody) {
174
+ info('');
175
+ info('── body ──');
176
+ info(body ?? '');
177
+ }
178
+ }
179
+ // ── Lifecycle ────────────────────────────────────────────────────────────────
180
+ export async function claim(id, flags) {
181
+ const config = requireConfig();
182
+ const orgId = requireOrg(flags, config, 'myapi task claim <id> [--lease <seconds>] [--worker <name>] [--org <id>]');
183
+ requireArg(id, 'id', 'myapi task claim <id>');
184
+ const t = await sdkTask.claimTask(config.api_key, orgId, id, {
185
+ leaseSeconds: typeof flags.lease === 'number' ? flags.lease : undefined,
186
+ worker: typeof flags.worker === 'string' ? flags.worker : undefined,
187
+ });
188
+ if (flags.json) {
189
+ printJson(t);
190
+ return;
191
+ }
192
+ success(`Claimed task ${t.id}`);
193
+ if (t.lease_expires_at)
194
+ info(`Lease expires: ${formatDate(t.lease_expires_at)} — heartbeat with: myapi task extend ${t.id}`);
195
+ }
196
+ export async function extend(id, flags) {
197
+ const config = requireConfig();
198
+ const orgId = requireOrg(flags, config, 'myapi task extend <id> [--lease <seconds>] [--org <id>]');
199
+ requireArg(id, 'id', 'myapi task extend <id>');
200
+ const t = await sdkTask.extendTask(config.api_key, orgId, id, {
201
+ leaseSeconds: typeof flags.lease === 'number' ? flags.lease : undefined,
202
+ });
203
+ if (flags.json) {
204
+ printJson(t);
205
+ return;
206
+ }
207
+ success(`Extended lease on task ${t.id}`);
208
+ if (t.lease_expires_at)
209
+ info(`Lease expires: ${formatDate(t.lease_expires_at)}`);
210
+ }
211
+ export async function resolve(id, flags) {
212
+ const config = requireConfig();
213
+ const orgId = requireOrg(flags, config, 'myapi task resolve <id> [--org <id>]');
214
+ requireArg(id, 'id', 'myapi task resolve <id>');
215
+ await sdkTask.resolveTask(config.api_key, orgId, id);
216
+ success(`Resolved task ${id}`);
217
+ }
218
+ export async function fail(id, flags) {
219
+ const config = requireConfig();
220
+ const orgId = requireOrg(flags, config, 'myapi task fail <id> --reason "<why>" [--org <id>]');
221
+ requireArg(id, 'id', 'myapi task fail <id> --reason "<why>"');
222
+ const reason = flags.reason;
223
+ if (typeof reason !== 'string' || reason.trim() === '') {
224
+ error('Missing --reason.\nUsage: myapi task fail <id> --reason "<why>"');
225
+ }
226
+ await sdkTask.failTask(config.api_key, orgId, id, reason);
227
+ success(`Failed task ${id}`);
228
+ }
229
+ export async function cancel(id, flags) {
230
+ const config = requireConfig();
231
+ const orgId = requireOrg(flags, config, 'myapi task cancel <id> [--org <id>]');
232
+ requireArg(id, 'id', 'myapi task cancel <id>');
233
+ await sdkTask.cancelTask(config.api_key, orgId, id);
234
+ success(`Cancelled task ${id}`);
235
+ }
236
+ // ── Dispatcher ───────────────────────────────────────────────────────────────
237
+ const SUBCOMMAND_USAGE = {
238
+ 'create': 'myapi task create "<description>" [--body <md|@file>] [--importance <i>] [--due <rfc3339>] [--assignee <email>] [--tag <t,t>] [--depends-on <id,id>] [--dedup-key <k>] [--resolve-on <event[:field=value]>] [--source <s>] [--org <id>]',
239
+ 'list': 'myapi task list [--status <s>] [--tag <t>] [--importance <i>] [--assignee <email>] [--source <s>] [--limit <n>] [--org <id>] [--json]',
240
+ 'get': 'myapi task get <id> [--body] [--org <id>] [--json]\n\n--body additionally fetches the Markdown body tier (a separate read).',
241
+ 'claim': 'myapi task claim <id> [--lease <seconds>] [--worker <name>] [--org <id>]',
242
+ 'extend': 'myapi task extend <id> [--lease <seconds>] [--org <id>]',
243
+ 'resolve': 'myapi task resolve <id> [--org <id>]',
244
+ 'fail': 'myapi task fail <id> --reason "<why>" [--org <id>]',
245
+ 'cancel': 'myapi task cancel <id> [--org <id>]',
246
+ };
247
+ export async function run(subcommand, args, flags) {
248
+ if (!subcommand || (flags.help && !subcommand)) {
249
+ info(`Usage: myapi task <subcommand>
250
+
251
+ An agent-task queue — the agent-loop hot path. Tasks are ranked by score,
252
+ claimed under a lease, then resolved, failed, or cancelled.
253
+
254
+ create "<description>" File a task (--body, --importance, --assignee, --resolve-on, ...)
255
+ list Top open tasks, ranked (--status, --tag, --limit)
256
+ get <id> Show a task; --body also fetches the Markdown body tier
257
+ claim <id> Take an atomic lease (default 10 min)
258
+ extend <id> Heartbeat a live claim
259
+ resolve <id> Resolve a task (terminal) — unblocks dependents
260
+ fail <id> Fail a task (terminal) — requires --reason
261
+ cancel <id> Cancel a task (terminal, distinct from fail)
262
+
263
+ The agent loop is: list → get <id> --body → claim → resolve.
264
+ For durable machine work (retried HTTP jobs), see: myapi queue --help
265
+
266
+ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
267
+ return;
268
+ }
269
+ if (flags.help) {
270
+ const usage = SUBCOMMAND_USAGE[subcommand];
271
+ if (usage)
272
+ info(`Usage: ${usage}`);
273
+ else
274
+ info(`Unknown subcommand: ${subcommand}. Run "myapi task --help" for the list.`);
275
+ return;
276
+ }
277
+ switch (subcommand) {
278
+ case 'create': return create(args[0], flags);
279
+ case 'list': return list(flags);
280
+ case 'get': return get(args[0], flags);
281
+ case 'claim': return claim(args[0], flags);
282
+ case 'extend': return extend(args[0], flags);
283
+ case 'resolve': return resolve(args[0], flags);
284
+ case 'fail': return fail(args[0], flags);
285
+ case 'cancel': return cancel(args[0], flags);
286
+ default: error(`Unknown subcommand: ${subcommand}. Run "myapi task --help" for a list of valid subcommands.`);
287
+ }
288
+ }
@@ -120,6 +120,33 @@ describe('_validateSteps — http_request / http (2026-05-15)', () => {
120
120
  .toMatch(/"headers" value for "X-Token" must be a string/);
121
121
  });
122
122
  });
123
+ describe('_validateSteps — enqueue_job / enqueue (2026-05-19)', () => {
124
+ const VALID_ENQUEUE = { type: 'enqueue_job', queue: 'thumbnails' };
125
+ it('accepts the canonical type name', () => {
126
+ expect(_validateSteps([VALID_ENQUEUE])).toBeNull();
127
+ });
128
+ it('accepts the `enqueue` alias', () => {
129
+ expect(_validateSteps([{ ...VALID_ENQUEUE, type: 'enqueue' }])).toBeNull();
130
+ });
131
+ it('accepts a string payload with templating', () => {
132
+ expect(_validateSteps([{ ...VALID_ENQUEUE, payload: '{"email":"{{ payload.email }}"}' }])).toBeNull();
133
+ });
134
+ it('accepts dedup_key and delay_seconds', () => {
135
+ expect(_validateSteps([{ ...VALID_ENQUEUE, dedup_key: 'k1', delay_seconds: 30 }])).toBeNull();
136
+ });
137
+ it('rejects when queue is missing', () => {
138
+ expect(_validateSteps([{ type: 'enqueue_job' }]))
139
+ .toMatch(/missing required field "queue"/);
140
+ });
141
+ it('rejects a non-string payload', () => {
142
+ expect(_validateSteps([{ ...VALID_ENQUEUE, payload: { not: 'a string' } }]))
143
+ .toMatch(/"payload" must be a string/);
144
+ });
145
+ it('rejects a non-number delay_seconds', () => {
146
+ expect(_validateSteps([{ ...VALID_ENQUEUE, delay_seconds: '30' }]))
147
+ .toMatch(/"delay_seconds" must be a number/);
148
+ });
149
+ });
123
150
  describe('_validateSteps — mixed-type pipelines', () => {
124
151
  it('accepts an http_request after a send_email (pipeline shape)', () => {
125
152
  expect(_validateSteps([