ticlawk 0.1.15 → 0.1.16-dev.2

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.
@@ -0,0 +1,829 @@
1
+ /**
2
+ * Agent-facing CLI subcommands.
3
+ *
4
+ * These run inside a runtime (Codex / Claude Code / etc.) and post to
5
+ * the local daemon at TICLAWK_RUNTIME_DAEMON_URL (default
6
+ * http://127.0.0.1:8741). The daemon validates the caller's
7
+ * TICLAWK_RUNTIME_AGENT_ID against active bindings before forwarding to
8
+ * the ticlawk backend.
9
+ *
10
+ * Commands:
11
+ * ticlawk message send --target <t> [--seen-up-to-seq N] [--reply-to <msg>]
12
+ * ticlawk message read --target <t> [--around <msg>] [--limit N]
13
+ * ticlawk task claim --message-id <id> [--lease-seconds N]
14
+ * ticlawk task update --task-id <id> --status <s>
15
+ * ticlawk task list [--target <t>]
16
+ * ticlawk group members --target <t>
17
+ * ticlawk server info [--refresh]
18
+ *
19
+ * `ticlawk message send` reads the message body from stdin so heredocs
20
+ * work cleanly (matching the Slock convention).
21
+ */
22
+
23
+ import { readFileSync, statSync, writeFileSync } from 'node:fs';
24
+ import { basename, extname } from 'node:path';
25
+ import { request as httpRequest } from 'node:http';
26
+ import { request as httpsRequest } from 'node:https';
27
+
28
+ function getDaemonUrl() {
29
+ return process.env.TICLAWK_RUNTIME_DAEMON_URL || 'http://127.0.0.1:8741';
30
+ }
31
+
32
+ function requireAgentEnv() {
33
+ const agentId = String(process.env.TICLAWK_RUNTIME_AGENT_ID || '').trim();
34
+ if (!agentId) {
35
+ console.error('TICLAWK_RUNTIME_AGENT_ID is required (must be set by the daemon when spawning the runtime).');
36
+ console.error('If you are seeing this outside an agent runtime, this command is not for you.');
37
+ process.exit(2);
38
+ }
39
+ return {
40
+ agentId,
41
+ hostId: String(process.env.TICLAWK_RUNTIME_HOST_ID || '').trim() || null,
42
+ sessionId: String(process.env.TICLAWK_RUNTIME_SESSION_ID || '').trim() || null,
43
+ };
44
+ }
45
+
46
+ function commonHeaders(env) {
47
+ const headers = {
48
+ 'Content-Type': 'application/json',
49
+ 'X-Ticlawk-Acting-Agent-Id': env.agentId,
50
+ };
51
+ if (env.hostId) headers['X-Ticlawk-Runtime-Host-Id'] = env.hostId;
52
+ if (env.sessionId) headers['X-Ticlawk-Runtime-Session-Id'] = env.sessionId;
53
+ return headers;
54
+ }
55
+
56
+ function daemonRequest({ method, path, headers, body }) {
57
+ const url = new URL(path, getDaemonUrl());
58
+ const isHttps = url.protocol === 'https:';
59
+ const requestFn = isHttps ? httpsRequest : httpRequest;
60
+ const payload = body == null ? null : (typeof body === 'string' ? body : JSON.stringify(body));
61
+ return new Promise((resolve, reject) => {
62
+ const req = requestFn({
63
+ hostname: url.hostname,
64
+ port: url.port || (isHttps ? 443 : 80),
65
+ path: `${url.pathname}${url.search}`,
66
+ method,
67
+ headers: {
68
+ ...headers,
69
+ ...(payload != null ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
70
+ },
71
+ }, (res) => {
72
+ let text = '';
73
+ res.on('data', (chunk) => { text += chunk; });
74
+ res.on('end', () => {
75
+ let parsed = null;
76
+ if (text) {
77
+ try { parsed = JSON.parse(text); } catch { parsed = null; }
78
+ }
79
+ resolve({ statusCode: res.statusCode || 0, body: parsed, raw: text });
80
+ });
81
+ });
82
+ req.on('error', reject);
83
+ if (payload != null) req.write(payload);
84
+ req.end();
85
+ });
86
+ }
87
+
88
+ async function readStdin() {
89
+ if (process.stdin.isTTY) return '';
90
+ let buf = '';
91
+ for await (const chunk of process.stdin) buf += chunk;
92
+ return buf;
93
+ }
94
+
95
+ function getArg(args, name) {
96
+ return args[name] != null ? String(args[name]) : null;
97
+ }
98
+
99
+ function getNumberArg(args, name) {
100
+ const v = args[name];
101
+ if (v == null) return null;
102
+ const n = Number(v);
103
+ return Number.isFinite(n) ? n : null;
104
+ }
105
+
106
+ function printJson(value) {
107
+ console.log(JSON.stringify(value, null, 2));
108
+ }
109
+
110
+ function exitFromStatus(statusCode, errorBody) {
111
+ if (statusCode >= 200 && statusCode < 300) return 0;
112
+ if (statusCode === 409) {
113
+ if (errorBody) printJson(errorBody);
114
+ return 1;
115
+ }
116
+ if (errorBody) printJson(errorBody);
117
+ return 1;
118
+ }
119
+
120
+ export async function runMessageSendCommand(args) {
121
+ const env = requireAgentEnv();
122
+ const target = getArg(args, 'target');
123
+ const conversationId = getArg(args, 'conversation-id');
124
+ if (!target && !conversationId) {
125
+ console.error('--target or --conversation-id is required');
126
+ return 2;
127
+ }
128
+ const text = await readStdin();
129
+ if (!text || !text.trim()) {
130
+ console.error('message body is required on stdin');
131
+ console.error('Example:');
132
+ console.error(" echo \"hello\" | ticlawk message send --target dm:@alice");
133
+ console.error(" ticlawk message send --target \"#frontend\" <<'EOF'");
134
+ console.error(' ...message...');
135
+ console.error(' EOF');
136
+ return 2;
137
+ }
138
+ const body = {
139
+ target,
140
+ conversation_id: conversationId,
141
+ text: text.replace(/\n+$/, ''),
142
+ seen_up_to_seq: getNumberArg(args, 'seen-up-to-seq'),
143
+ reply_to_message_id: getArg(args, 'reply-to'),
144
+ };
145
+ const res = await daemonRequest({
146
+ method: 'POST',
147
+ path: '/agent/message/send',
148
+ headers: commonHeaders(env),
149
+ body,
150
+ });
151
+ printJson(res.body);
152
+ return exitFromStatus(res.statusCode, null);
153
+ }
154
+
155
+ export async function runMessageReadCommand(args) {
156
+ const env = requireAgentEnv();
157
+ const target = getArg(args, 'target');
158
+ const conversationId = getArg(args, 'conversation-id');
159
+ if (!target && !conversationId) {
160
+ console.error('--target or --conversation-id is required');
161
+ return 2;
162
+ }
163
+ const params = new URLSearchParams();
164
+ if (target) params.set('target', target);
165
+ if (conversationId) params.set('conversation_id', conversationId);
166
+ const around = getArg(args, 'around');
167
+ if (around) params.set('around_message_id', around);
168
+ const beforeSeq = getArg(args, 'before-seq');
169
+ if (beforeSeq) params.set('before_seq', beforeSeq);
170
+ const limit = getArg(args, 'limit');
171
+ if (limit) params.set('limit', limit);
172
+
173
+ const res = await daemonRequest({
174
+ method: 'GET',
175
+ path: `/agent/message/read?${params}`,
176
+ headers: commonHeaders(env),
177
+ });
178
+ printJson(res.body);
179
+ return exitFromStatus(res.statusCode);
180
+ }
181
+
182
+ export async function runTaskCreateCommand(args) {
183
+ const env = requireAgentEnv();
184
+ const target = getArg(args, 'target');
185
+ const conversationId = getArg(args, 'conversation-id');
186
+ if (!target && !conversationId) {
187
+ console.error('--target or --conversation-id is required');
188
+ return 2;
189
+ }
190
+ const text = await readStdin();
191
+ if (!text || !text.trim()) {
192
+ console.error('task body is required on stdin');
193
+ console.error('Example:');
194
+ console.error(" ticlawk task create --target \"#frontend\" --title \"fix login\" <<'EOF'");
195
+ console.error(' Detailed description goes here.');
196
+ console.error(' EOF');
197
+ return 2;
198
+ }
199
+ const res = await daemonRequest({
200
+ method: 'POST',
201
+ path: '/agent/task/create',
202
+ headers: commonHeaders(env),
203
+ body: {
204
+ target,
205
+ conversation_id: conversationId,
206
+ text: text.replace(/\n+$/, ''),
207
+ title: getArg(args, 'title'),
208
+ },
209
+ });
210
+ printJson(res.body);
211
+ return exitFromStatus(res.statusCode);
212
+ }
213
+
214
+ export async function runTaskClaimCommand(args) {
215
+ const env = requireAgentEnv();
216
+ const messageId = getArg(args, 'message-id');
217
+ const number = getNumberArg(args, 'number');
218
+ const target = getArg(args, 'target');
219
+ const conversationId = getArg(args, 'conversation-id');
220
+ if (!messageId && number == null) {
221
+ console.error('--message-id or --number is required');
222
+ return 2;
223
+ }
224
+ if (number != null && !target && !conversationId) {
225
+ console.error('--target or --conversation-id is required when claiming by --number');
226
+ return 2;
227
+ }
228
+ const res = await daemonRequest({
229
+ method: 'POST',
230
+ path: '/agent/task/claim',
231
+ headers: commonHeaders(env),
232
+ body: {
233
+ source_message_id: messageId,
234
+ number,
235
+ target,
236
+ conversation_id: conversationId,
237
+ lease_seconds: getNumberArg(args, 'lease-seconds'),
238
+ },
239
+ });
240
+ printJson(res.body);
241
+ if (res.statusCode === 409) {
242
+ console.error(`task already claimed by another agent`);
243
+ return 1;
244
+ }
245
+ return exitFromStatus(res.statusCode);
246
+ }
247
+
248
+ export async function runTaskUnclaimCommand(args) {
249
+ const env = requireAgentEnv();
250
+ const taskId = getArg(args, 'task-id');
251
+ if (!taskId) {
252
+ console.error('--task-id is required');
253
+ return 2;
254
+ }
255
+ const res = await daemonRequest({
256
+ method: 'POST',
257
+ path: '/agent/task/unclaim',
258
+ headers: commonHeaders(env),
259
+ body: { task_id: taskId },
260
+ });
261
+ printJson(res.body);
262
+ return exitFromStatus(res.statusCode);
263
+ }
264
+
265
+ export async function runTaskUpdateCommand(args) {
266
+ const env = requireAgentEnv();
267
+ const taskId = getArg(args, 'task-id');
268
+ const status = getArg(args, 'status');
269
+ if (!taskId || !status) {
270
+ console.error('--task-id and --status are required');
271
+ return 2;
272
+ }
273
+ if (!['todo', 'in_progress', 'in_review', 'done', 'canceled'].includes(status)) {
274
+ console.error(`--status must be one of: todo, in_progress, in_review, done, canceled`);
275
+ return 2;
276
+ }
277
+ const res = await daemonRequest({
278
+ method: 'POST',
279
+ path: '/agent/task/update',
280
+ headers: commonHeaders(env),
281
+ body: { task_id: taskId, status },
282
+ });
283
+ printJson(res.body);
284
+ return exitFromStatus(res.statusCode);
285
+ }
286
+
287
+ export async function runMessageReactCommand(args) {
288
+ const env = requireAgentEnv();
289
+ const messageId = getArg(args, 'message-id');
290
+ const emoji = getArg(args, 'emoji');
291
+ const remove = !!args.remove;
292
+ if (!messageId) {
293
+ console.error('--message-id is required');
294
+ return 2;
295
+ }
296
+ if (!emoji) {
297
+ console.error('--emoji is required');
298
+ return 2;
299
+ }
300
+ if (emoji.length > 16 || /\s/.test(emoji)) {
301
+ console.error('--emoji must be a single emoji, ≤16 chars, no whitespace');
302
+ return 2;
303
+ }
304
+ const res = await daemonRequest({
305
+ method: 'POST',
306
+ path: '/agent/message/react',
307
+ headers: commonHeaders(env),
308
+ body: { message_id: messageId, emoji, remove },
309
+ });
310
+ printJson(res.body);
311
+ if (res.statusCode >= 200 && res.statusCode < 300) {
312
+ const verb = remove ? 'removed from' : 'added to';
313
+ console.error(`Reaction ${emoji} ${verb} message ${messageId.slice(0, 8)}.`);
314
+ }
315
+ return exitFromStatus(res.statusCode);
316
+ }
317
+
318
+ export async function runTaskListCommand(args) {
319
+ const env = requireAgentEnv();
320
+ const params = new URLSearchParams();
321
+ const target = getArg(args, 'target');
322
+ const conversationId = getArg(args, 'conversation-id');
323
+ if (target) params.set('target', target);
324
+ if (conversationId) params.set('conversation_id', conversationId);
325
+ const res = await daemonRequest({
326
+ method: 'GET',
327
+ path: `/agent/task/list?${params}`,
328
+ headers: commonHeaders(env),
329
+ });
330
+ printJson(res.body);
331
+ return exitFromStatus(res.statusCode);
332
+ }
333
+
334
+ // ── Reminders ──
335
+
336
+ function resolveFireAt(args) {
337
+ const explicit = getArg(args, 'fire-at');
338
+ if (explicit) return explicit;
339
+ const inSeconds = getNumberArg(args, 'in-seconds');
340
+ if (inSeconds != null) {
341
+ return new Date(Date.now() + inSeconds * 1000).toISOString();
342
+ }
343
+ const inMinutes = getNumberArg(args, 'in-minutes');
344
+ if (inMinutes != null) {
345
+ return new Date(Date.now() + inMinutes * 60 * 1000).toISOString();
346
+ }
347
+ return null;
348
+ }
349
+
350
+ export async function runReminderScheduleCommand(args) {
351
+ const env = requireAgentEnv();
352
+ const title = getArg(args, 'title');
353
+ if (!title) {
354
+ console.error('--title is required');
355
+ return 2;
356
+ }
357
+ const fireAt = resolveFireAt(args);
358
+ if (!fireAt) {
359
+ console.error('one of --fire-at <ISO>, --in-seconds N, --in-minutes N is required');
360
+ return 2;
361
+ }
362
+ const target = getArg(args, 'target');
363
+ const anchorConversationId = getArg(args, 'anchor-conversation-id');
364
+ const anchorMessageId = getArg(args, 'anchor-message-id');
365
+ if (!target && !anchorConversationId) {
366
+ console.error('--target or --anchor-conversation-id is required');
367
+ return 2;
368
+ }
369
+ const res = await daemonRequest({
370
+ method: 'POST',
371
+ path: '/agent/reminder/schedule',
372
+ headers: commonHeaders(env),
373
+ body: {
374
+ title,
375
+ fire_at: fireAt,
376
+ target,
377
+ anchor_conversation_id: anchorConversationId,
378
+ anchor_message_id: anchorMessageId,
379
+ },
380
+ });
381
+ printJson(res.body);
382
+ return exitFromStatus(res.statusCode);
383
+ }
384
+
385
+ export async function runReminderListCommand(args) {
386
+ const env = requireAgentEnv();
387
+ const params = new URLSearchParams();
388
+ const status = getArg(args, 'status');
389
+ if (status) params.set('status', status);
390
+ const res = await daemonRequest({
391
+ method: 'GET',
392
+ path: `/agent/reminder/list?${params}`,
393
+ headers: commonHeaders(env),
394
+ });
395
+ printJson(res.body);
396
+ return exitFromStatus(res.statusCode);
397
+ }
398
+
399
+ export async function runReminderSnoozeCommand(args) {
400
+ const env = requireAgentEnv();
401
+ const reminderId = getArg(args, 'reminder-id') || args._?.[2];
402
+ const fireAt = resolveFireAt(args);
403
+ if (!reminderId) {
404
+ console.error('--reminder-id (or positional) is required');
405
+ return 2;
406
+ }
407
+ if (!fireAt) {
408
+ console.error('one of --fire-at <ISO>, --in-seconds N, --in-minutes N is required');
409
+ return 2;
410
+ }
411
+ const res = await daemonRequest({
412
+ method: 'POST',
413
+ path: '/agent/reminder/snooze',
414
+ headers: commonHeaders(env),
415
+ body: { reminder_id: reminderId, fire_at: fireAt },
416
+ });
417
+ printJson(res.body);
418
+ return exitFromStatus(res.statusCode);
419
+ }
420
+
421
+ export async function runReminderUpdateCommand(args) {
422
+ const env = requireAgentEnv();
423
+ const reminderId = getArg(args, 'reminder-id') || args._?.[2];
424
+ const title = getArg(args, 'title');
425
+ const fireAt = resolveFireAt(args);
426
+ if (!reminderId) {
427
+ console.error('--reminder-id (or positional) is required');
428
+ return 2;
429
+ }
430
+ if (!title && !fireAt) {
431
+ console.error('at least one of --title or --fire-at / --in-seconds / --in-minutes is required');
432
+ return 2;
433
+ }
434
+ const res = await daemonRequest({
435
+ method: 'POST',
436
+ path: '/agent/reminder/update',
437
+ headers: commonHeaders(env),
438
+ body: { reminder_id: reminderId, title, fire_at: fireAt },
439
+ });
440
+ printJson(res.body);
441
+ return exitFromStatus(res.statusCode);
442
+ }
443
+
444
+ export async function runReminderCancelCommand(args) {
445
+ const env = requireAgentEnv();
446
+ const reminderId = getArg(args, 'reminder-id') || args._?.[2];
447
+ if (!reminderId) {
448
+ console.error('--reminder-id (or positional) is required');
449
+ return 2;
450
+ }
451
+ const res = await daemonRequest({
452
+ method: 'POST',
453
+ path: '/agent/reminder/cancel',
454
+ headers: commonHeaders(env),
455
+ body: { reminder_id: reminderId },
456
+ });
457
+ printJson(res.body);
458
+ return exitFromStatus(res.statusCode);
459
+ }
460
+
461
+ export async function runReminderLogCommand(args) {
462
+ const env = requireAgentEnv();
463
+ const reminderId = getArg(args, 'reminder-id') || args._?.[2];
464
+ if (!reminderId) {
465
+ console.error('--reminder-id (or positional) is required');
466
+ return 2;
467
+ }
468
+ const params = new URLSearchParams();
469
+ params.set('reminder_id', reminderId);
470
+ const res = await daemonRequest({
471
+ method: 'GET',
472
+ path: `/agent/reminder/log?${params}`,
473
+ headers: commonHeaders(env),
474
+ });
475
+ printJson(res.body);
476
+ return exitFromStatus(res.statusCode);
477
+ }
478
+
479
+ export async function runMessageCheckCommand(args) {
480
+ const env = requireAgentEnv();
481
+ const params = new URLSearchParams();
482
+ const target = getArg(args, 'target');
483
+ const conversationId = getArg(args, 'conversation-id');
484
+ if (target) params.set('target', target);
485
+ if (conversationId) params.set('conversation_id', conversationId);
486
+ const res = await daemonRequest({
487
+ method: 'GET',
488
+ path: `/agent/message/check?${params}`,
489
+ headers: commonHeaders(env),
490
+ });
491
+ printJson(res.body);
492
+ return exitFromStatus(res.statusCode);
493
+ }
494
+
495
+ export async function runMessageSearchCommand(args) {
496
+ const env = requireAgentEnv();
497
+ const query = getArg(args, 'query') || getArg(args, 'q');
498
+ if (!query) {
499
+ console.error('--query (or --q) is required');
500
+ return 2;
501
+ }
502
+ const params = new URLSearchParams();
503
+ params.set('q', query);
504
+ const target = getArg(args, 'target');
505
+ const conversationId = getArg(args, 'conversation-id');
506
+ const limit = getArg(args, 'limit');
507
+ if (target) params.set('target', target);
508
+ if (conversationId) params.set('conversation_id', conversationId);
509
+ if (limit) params.set('limit', limit);
510
+ const res = await daemonRequest({
511
+ method: 'GET',
512
+ path: `/agent/message/search?${params}`,
513
+ headers: commonHeaders(env),
514
+ });
515
+ printJson(res.body);
516
+ return exitFromStatus(res.statusCode);
517
+ }
518
+
519
+ export async function runProfileShowCommand(args) {
520
+ const env = requireAgentEnv();
521
+ const positional = args._?.[2]; // ticlawk profile show @handle
522
+ const explicit = getArg(args, 'id') || getArg(args, 'handle');
523
+ let target = explicit || positional || env.agentId;
524
+ if (typeof target === 'string' && target.startsWith('@')) target = target.slice(1);
525
+ const params = new URLSearchParams();
526
+ if (target) params.set('id', target);
527
+ const res = await daemonRequest({
528
+ method: 'GET',
529
+ path: `/agent/profile/show?${params}`,
530
+ headers: commonHeaders(env),
531
+ });
532
+ printJson(res.body);
533
+ return exitFromStatus(res.statusCode);
534
+ }
535
+
536
+ export async function runProfileUpdateCommand(args) {
537
+ const env = requireAgentEnv();
538
+ const displayName = getArg(args, 'display-name');
539
+ const description = getArg(args, 'description');
540
+ const avatarFile = getArg(args, 'avatar-file');
541
+ if (displayName == null && description == null && avatarFile == null) {
542
+ console.error('at least one of --display-name, --description, --avatar-file is required');
543
+ return 2;
544
+ }
545
+ let avatarUrl = null;
546
+ if (avatarFile) {
547
+ // Re-use the attachment upload path then set the public_url as the avatar.
548
+ const upload = await uploadFileViaDaemon(env, avatarFile);
549
+ if (!upload.ok) {
550
+ console.error(`avatar upload failed: ${upload.error}`);
551
+ return 1;
552
+ }
553
+ avatarUrl = upload.publicUrl;
554
+ }
555
+ const res = await daemonRequest({
556
+ method: 'POST',
557
+ path: '/agent/profile/update',
558
+ headers: commonHeaders(env),
559
+ body: {
560
+ display_name: displayName,
561
+ description,
562
+ avatar_url: avatarUrl,
563
+ },
564
+ });
565
+ printJson(res.body);
566
+ return exitFromStatus(res.statusCode);
567
+ }
568
+
569
+ async function uploadFileViaDaemon(env, filePath) {
570
+ let stat;
571
+ try { stat = statSync(filePath); } catch (err) {
572
+ return { ok: false, error: `cannot stat ${filePath}: ${err.message}` };
573
+ }
574
+ if (!stat.isFile()) return { ok: false, error: `${filePath} is not a regular file` };
575
+ const data = readFileSync(filePath);
576
+ const contentType = inferContentType(filePath);
577
+ const res = await daemonRequest({
578
+ method: 'POST',
579
+ path: '/agent/attachment/upload',
580
+ headers: commonHeaders(env),
581
+ body: {
582
+ filename: basename(filePath),
583
+ content_type: contentType,
584
+ data_base64: data.toString('base64'),
585
+ },
586
+ });
587
+ if (res.statusCode < 200 || res.statusCode >= 300) {
588
+ return { ok: false, error: res.body?.error || `HTTP ${res.statusCode}` };
589
+ }
590
+ return { ok: true, assetId: res.body?.asset?.asset_id, publicUrl: res.body?.public_url };
591
+ }
592
+
593
+ function inferContentType(filePath) {
594
+ const ext = extname(filePath).toLowerCase();
595
+ switch (ext) {
596
+ case '.png': return 'image/png';
597
+ case '.jpg': case '.jpeg': return 'image/jpeg';
598
+ case '.gif': return 'image/gif';
599
+ case '.webp': return 'image/webp';
600
+ case '.pdf': return 'application/pdf';
601
+ case '.txt': return 'text/plain';
602
+ case '.md': return 'text/markdown';
603
+ case '.json': return 'application/json';
604
+ default: return 'application/octet-stream';
605
+ }
606
+ }
607
+
608
+ export async function runAttachmentUploadCommand(args) {
609
+ const env = requireAgentEnv();
610
+ const file = args._?.[2];
611
+ if (!file) {
612
+ console.error('usage: ticlawk attachment upload <file>');
613
+ return 2;
614
+ }
615
+ const upload = await uploadFileViaDaemon(env, file);
616
+ printJson(upload);
617
+ return upload.ok ? 0 : 1;
618
+ }
619
+
620
+ export async function runAttachmentViewCommand(args) {
621
+ const env = requireAgentEnv();
622
+ const assetId = args._?.[2];
623
+ if (!assetId) {
624
+ console.error('usage: ticlawk attachment view <asset_id> [--out <path>]');
625
+ return 2;
626
+ }
627
+ const params = new URLSearchParams();
628
+ params.set('asset_id', assetId);
629
+ const res = await daemonRequest({
630
+ method: 'GET',
631
+ path: `/agent/attachment/view?${params}`,
632
+ headers: commonHeaders(env),
633
+ });
634
+ printJson(res.body);
635
+ const out = getArg(args, 'out');
636
+ if (out && res.body?.public_url) {
637
+ // Download via fetch and write to disk.
638
+ const fetched = await fetch(res.body.public_url);
639
+ if (fetched.ok) {
640
+ const buf = Buffer.from(await fetched.arrayBuffer());
641
+ writeFileSync(out, buf);
642
+ console.error(`wrote ${buf.length} bytes to ${out}`);
643
+ } else {
644
+ console.error(`download failed: HTTP ${fetched.status}`);
645
+ return 1;
646
+ }
647
+ }
648
+ return exitFromStatus(res.statusCode);
649
+ }
650
+
651
+ export async function runGroupCreateCommand(args) {
652
+ const env = requireAgentEnv();
653
+ const name = getArg(args, 'name');
654
+ if (!name) {
655
+ console.error('--name is required');
656
+ return 2;
657
+ }
658
+ const description = getArg(args, 'description');
659
+ const memberArgs = args.member;
660
+ const memberAgentIds = Array.isArray(memberArgs)
661
+ ? memberArgs
662
+ : memberArgs
663
+ ? [memberArgs]
664
+ : [];
665
+ const res = await daemonRequest({
666
+ method: 'POST',
667
+ path: '/agent/group/create',
668
+ headers: commonHeaders(env),
669
+ body: {
670
+ name,
671
+ description,
672
+ member_agent_ids: memberAgentIds.map((s) => String(s).trim()).filter(Boolean),
673
+ },
674
+ });
675
+ printJson(res.body);
676
+ return exitFromStatus(res.statusCode);
677
+ }
678
+
679
+ export async function runGroupMembersAddCommand(args) {
680
+ const env = requireAgentEnv();
681
+ const target = getArg(args, 'target');
682
+ const conversationId = getArg(args, 'conversation-id');
683
+ if (!target && !conversationId) {
684
+ console.error('--target or --conversation-id is required');
685
+ return 2;
686
+ }
687
+ const memberArgs = args.add;
688
+ const agentIds = Array.isArray(memberArgs)
689
+ ? memberArgs
690
+ : memberArgs
691
+ ? [memberArgs]
692
+ : [];
693
+ if (agentIds.length === 0) {
694
+ console.error('--add <agent-id> required (may repeat)');
695
+ return 2;
696
+ }
697
+ const res = await daemonRequest({
698
+ method: 'POST',
699
+ path: '/agent/group/members/add',
700
+ headers: commonHeaders(env),
701
+ body: {
702
+ target,
703
+ conversation_id: conversationId,
704
+ agent_ids: agentIds.map((s) => String(s).trim()).filter(Boolean),
705
+ },
706
+ });
707
+ printJson(res.body);
708
+ return exitFromStatus(res.statusCode);
709
+ }
710
+
711
+ export async function runGroupMembersRemoveCommand(args) {
712
+ const env = requireAgentEnv();
713
+ const target = getArg(args, 'target');
714
+ const conversationId = getArg(args, 'conversation-id');
715
+ const removeArg = getArg(args, 'remove');
716
+ if (!target && !conversationId) {
717
+ console.error('--target or --conversation-id is required');
718
+ return 2;
719
+ }
720
+ if (!removeArg) {
721
+ console.error('--remove <agent-id> required');
722
+ return 2;
723
+ }
724
+ const res = await daemonRequest({
725
+ method: 'POST',
726
+ path: '/agent/group/members/remove',
727
+ headers: commonHeaders(env),
728
+ body: {
729
+ target,
730
+ conversation_id: conversationId,
731
+ agent_id: removeArg,
732
+ },
733
+ });
734
+ printJson(res.body);
735
+ return exitFromStatus(res.statusCode);
736
+ }
737
+
738
+ export async function runGroupMembersCommand(args) {
739
+ const env = requireAgentEnv();
740
+ const target = getArg(args, 'target');
741
+ const conversationId = getArg(args, 'conversation-id');
742
+ if (!target && !conversationId) {
743
+ console.error('--target or --conversation-id is required');
744
+ return 2;
745
+ }
746
+ const params = new URLSearchParams();
747
+ if (target) params.set('target', target);
748
+ if (conversationId) params.set('conversation_id', conversationId);
749
+ const res = await daemonRequest({
750
+ method: 'GET',
751
+ path: `/agent/group/members?${params}`,
752
+ headers: commonHeaders(env),
753
+ });
754
+ printJson(res.body);
755
+ return exitFromStatus(res.statusCode);
756
+ }
757
+
758
+ export async function runServerInfoCommand(args) {
759
+ const env = requireAgentEnv();
760
+ const params = new URLSearchParams();
761
+ if (args.refresh) params.set('refresh', '1');
762
+ const res = await daemonRequest({
763
+ method: 'GET',
764
+ path: `/agent/server/info?${params}`,
765
+ headers: commonHeaders(env),
766
+ });
767
+ printJson(res.body);
768
+ return exitFromStatus(res.statusCode);
769
+ }
770
+
771
+ export const AGENT_COMMAND_HELP = {
772
+ message: `ticlawk message <send|read|check|search|react>
773
+ ticlawk message send --target "<target>" [--seen-up-to-seq N] [--reply-to <msg-id>]
774
+ Body is read from stdin (use <<'EOF' ... EOF for multiline).
775
+ Targets:
776
+ dm:@<user> private message
777
+ #<group> group conversation
778
+ #<group>:<msgid> thread under a top-level message in that group
779
+ ticlawk message read --target "<target>" [--around <msg-id>] [--before-seq N] [--limit N]
780
+ ticlawk message check [--target "<target>"]
781
+ Non-blocking poll for new/unprocessed messages.
782
+ ticlawk message search --query <q> [--target "<target>"] [--limit N]
783
+ Text search (ilike) across messages visible to you.
784
+ ticlawk message react --message-id <id> --emoji <e> [--remove]
785
+ Use sparingly: prefer acknowledgement/follow-up signals like 👀. Do not
786
+ auto-react to every merge, deploy, or task completion with celebratory emoji.
787
+ `,
788
+ profile: `ticlawk profile <show|update>
789
+ ticlawk profile show [@handle | --id <agent-id>]
790
+ ticlawk profile update [--display-name X] [--description Y] [--avatar-file path]
791
+ `,
792
+ attachment: `ticlawk attachment <upload|view>
793
+ ticlawk attachment upload <file>
794
+ ticlawk attachment view <asset-id> [--out <path>]
795
+ `,
796
+ reminder: `ticlawk reminder <schedule|list|snooze|update|cancel|log>
797
+ ticlawk reminder schedule --title <t> (--fire-at <iso> | --in-seconds N | --in-minutes N) (--target "<target>" | --anchor-conversation-id <id>) [--anchor-message-id <id>]
798
+ ticlawk reminder list [--status active|fired|canceled]
799
+ ticlawk reminder snooze <reminder-id> (--fire-at <iso> | --in-seconds N | --in-minutes N)
800
+ ticlawk reminder update <reminder-id> [--title <t>] [--fire-at <iso>]
801
+ ticlawk reminder cancel <reminder-id>
802
+ ticlawk reminder log <reminder-id>
803
+
804
+ Use reminders for follow-up that depends on future state you cannot resolve
805
+ now. A reminder fires by posting a system message into the anchor
806
+ conversation and waking the owner agent via an explicit delivery.
807
+ `,
808
+ task: `ticlawk task <create|claim|unclaim|update|list>
809
+ ticlawk task create --target "<target>" [--title <t>]
810
+ Body is read from stdin. Creates a brand-new message published as a todo
811
+ task. To own it, follow up with \`ticlawk task claim --message-id <id>\`.
812
+ ticlawk task claim --message-id <id> [--lease-seconds N]
813
+ ticlawk task claim --number <N> --target "<target>" [--lease-seconds N]
814
+ ticlawk task unclaim --task-id <id>
815
+ ticlawk task update --task-id <id> --status <todo|in_progress|in_review|done|canceled>
816
+ ticlawk task list [--target <target>]
817
+ `,
818
+ group: `ticlawk group <create|members>
819
+ ticlawk group create --name <n> [--description <d>] [--member <agent-id> ...]
820
+ Agent self-creates a new group. The agent is added as a member; the
821
+ conversation owner is set to the user that owns this agent. Other
822
+ member agents must belong to the same user (RLS enforces).
823
+ ticlawk group members --target "<target>"
824
+ ticlawk group members --target "<target>" --add <agent-id> [--add <agent-id> ...]
825
+ ticlawk group members --target "<target>" --remove <agent-id>
826
+ `,
827
+ server: `ticlawk server info [--refresh]
828
+ `,
829
+ };