diff --git a/package-lock.json b/package-lock.json index e4554ac..cf8fac2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "@doist/cli-core": "0.24.0", - "@doist/comms-sdk": "0.3.0", + "@doist/comms-sdk": "0.4.1", "@pnpm/tabtab": "0.5.4", "chalk": "5.6.2", "commander": "14.0.3", @@ -182,9 +182,9 @@ } }, "node_modules/@doist/comms-sdk": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@doist/comms-sdk/-/comms-sdk-0.3.0.tgz", - "integrity": "sha512-vfdNmTMmdKV4VTo8/uMqSY0j7e6puTjb0OWcTkC5nl11s2jGqRemrh1Jm0/SiYiAtJB7MDjvXYWk4bnsdNLSmg==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@doist/comms-sdk/-/comms-sdk-0.4.1.tgz", + "integrity": "sha512-LAEltkKlE7iD0hiybSqzsklplx9DGmfYnVXAQ59CgCANNpbb4P8xEls1WT/v9q3CLw7nKhfsXpBd/17rTI3rAw==", "license": "MIT", "dependencies": { "camelcase": "8.0.0", diff --git a/package.json b/package.json index 7d23bfb..fe7eca0 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ ], "dependencies": { "@doist/cli-core": "0.24.0", - "@doist/comms-sdk": "0.3.0", + "@doist/comms-sdk": "0.4.1", "@pnpm/tabtab": "0.5.4", "chalk": "5.6.2", "commander": "14.0.3", diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index caf6a9d..e8553e4 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -91,6 +91,7 @@ tdc thread create "Title" "content" --notify 123,456 # Notify spe tdc thread create "Title" "content" --unarchive # Land thread in author's Inbox (overrides default Comms auto-archive) tdc thread create "Title" "content" --no-unarchive # Force archive even when userSettings.unarchiveNewThreads=true tdc thread create "Title" "content" --dry-run # Preview without posting +tdc thread create "Title" --file ./a.png # Attach a file (repeatable; content optional) tdc thread reply "content" # Post a comment (notifies EVERYONE_IN_THREAD by default) tdc thread reply "content" --notify EVERYONE # Notify all workspace members tdc thread reply "content" --notify 123,id:456 # Notify specific user IDs @@ -98,6 +99,7 @@ tdc thread reply "content" --json # Post and return comment as JSON tdc thread reply "content" --json --full # Include all comment fields tdc thread reply "content" --close # Reply and close the thread tdc thread reply "content" --reopen # Reply and reopen a closed thread +tdc thread reply "content" --file ./a.png # Attach a file (repeatable; content optional) tdc thread done # Preview thread archive (requires --yes to execute) tdc thread done --yes # Archive thread (mark done) tdc thread done --yes --json # Archive and return status as JSON @@ -153,6 +155,7 @@ tdc conversation with --include-groups # List any conversations with tdc conversation reply "content" # Send a message tdc conversation reply "content" --json # Send and return message as JSON tdc conversation reply "content" --json --full # Include all message fields +tdc conversation reply "content" --file ./a.png # Attach a file (repeatable; content optional) tdc conversation done # Preview conversation archive (requires --yes to execute) tdc conversation done --yes # Archive conversation tdc conversation done --yes --json # Archive and return status as JSON diff --git a/src/commands/conversation/conversation.test.ts b/src/commands/conversation/conversation.test.ts index 7122d9b..c750f68 100644 --- a/src/commands/conversation/conversation.test.ts +++ b/src/commands/conversation/conversation.test.ts @@ -1,9 +1,12 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { captureConsole, createTestProgram, describeEmptyMachineOutput, } from '@doist/cli-core/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { clearWorkspaceUserCache } from '../../lib/api.js' import { CliError } from '../../lib/errors.js' @@ -32,6 +35,12 @@ vi.mock('../../lib/markdown.js', () => ({ vi.mock('chalk') +vi.mock('../../lib/input.js', () => ({ + readStdin: vi.fn().mockResolvedValue(''), + openEditor: vi.fn().mockResolvedValue(''), +})) + +import { openEditor } from '../../lib/input.js' import { registerConversationCommand } from './index.js' type TestConversation = { @@ -115,7 +124,25 @@ function createClient({ async ({ conversationId }: { conversationId: string; limit?: number }) => messagesByConversation[conversationId] ?? [], ), - createMessage: vi.fn(), + createMessage: vi.fn( + async (args: { + conversationId: string + content: string + attachments?: Array<{ fileName?: string | null }> + }) => ({ + id: '999', + conversationId: args.conversationId, + content: args.content, + url: 'https://comms.todoist.com/a/1/msg/999', + }), + ), + }, + attachments: { + upload: vi.fn(async (args: { file: Blob; fileName: string }) => ({ + attachmentId: `att-${args.fileName}`, + urlType: 'file', + fileName: args.fileName, + })), }, workspaceUsers: { getUserById: vi.fn( @@ -128,6 +155,30 @@ function createClient({ const createProgram = () => createTestProgram(registerConversationCommand) +// Shared setup for the --file suite: a fresh mock client wired into getCommsClient +// plus a program. Tests asserting on output call captureConsole('log') themselves. +function setupFileTest() { + const client = createClient({}) + apiMocks.getCommsClient.mockResolvedValue(client) + return { client, program: createProgram() } +} + +// Registers a temp dir with two files for a --file suite, cleaned up afterwards. +function useFileFixtures(prefix: string, png: string, pdf: string) { + const paths = { dir: '', png: '', pdf: '' } + beforeAll(async () => { + paths.dir = await mkdtemp(join(tmpdir(), prefix)) + paths.png = join(paths.dir, png) + paths.pdf = join(paths.dir, pdf) + await writeFile(paths.png, 'png-bytes') + await writeFile(paths.pdf, 'pdf-bytes') + }) + afterAll(async () => { + await rm(paths.dir, { recursive: true, force: true }) + }) + return paths +} + // Cache lives in api.ts module scope, so reset between tests. beforeEach(() => { clearWorkspaceUserCache() @@ -689,3 +740,147 @@ describe('conversation done', () => { expect(client.conversations.archiveConversation).not.toHaveBeenCalled() }) }) + +describe('conversation reply --file', () => { + const files = useFileFixtures('tdc-convo-reply-', 'photo.png', 'doc.pdf') + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uploads the file and attaches it to the message', async () => { + const { client, program } = setupFileTest() + const consoleSpy = captureConsole('log') + + await program.parseAsync([ + 'node', + 'tdc', + 'conversation', + 'reply', + '42', + 'See attached', + '--file', + files.png, + ]) + + expect(client.attachments.upload).toHaveBeenCalledTimes(1) + expect(client.attachments.upload).toHaveBeenCalledWith( + expect.objectContaining({ fileName: 'photo.png' }), + ) + expect(client.conversationMessages.createMessage).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: '42', + content: 'See attached', + attachments: [expect.objectContaining({ fileName: 'photo.png' })], + }), + ) + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Attached: photo.png')) + }) + + it('attaches multiple repeated --file values', async () => { + const { client, program } = setupFileTest() + + await program.parseAsync([ + 'node', + 'tdc', + 'conversation', + 'reply', + '42', + 'two files', + '--file', + files.png, + '--file', + files.pdf, + ]) + + expect(client.attachments.upload).toHaveBeenCalledTimes(2) + const args = client.conversationMessages.createMessage.mock.calls[0][0] as { + attachments: Array<{ fileName?: string }> + } + expect(args.attachments.map((a) => a.fileName)).toEqual(['photo.png', 'doc.pdf']) + }) + + it('allows a file-only message with no text content', async () => { + const { client, program } = setupFileTest() + + await program.parseAsync([ + 'node', + 'tdc', + 'conversation', + 'reply', + '42', + '--file', + files.png, + ]) + + expect(client.conversationMessages.createMessage).toHaveBeenCalledWith( + expect.objectContaining({ content: '', attachments: expect.any(Array) }), + ) + // A file-only message must not block on the editor. + expect(openEditor).not.toHaveBeenCalled() + }) + + it('errors with FILE_NOT_FOUND for a missing path and does not send', async () => { + const { client, program } = setupFileTest() + + await expect( + program.parseAsync([ + 'node', + 'tdc', + 'conversation', + 'reply', + '42', + 'x', + '--file', + join(files.dir, 'missing.png'), + ]), + ).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' }) + + expect(client.attachments.upload).not.toHaveBeenCalled() + expect(client.conversationMessages.createMessage).not.toHaveBeenCalled() + }) + + it('skips the upload when the conversation preflight fails (no orphaned upload)', async () => { + const { client, program } = setupFileTest() + client.conversations.getConversation.mockRejectedValueOnce( + new CliError('NOT_FOUND', 'Conversation not found'), + ) + + await expect( + program.parseAsync([ + 'node', + 'tdc', + 'conversation', + 'reply', + '42', + 'See attached', + '--file', + files.png, + ]), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }) + + expect(client.attachments.upload).not.toHaveBeenCalled() + expect(client.conversationMessages.createMessage).not.toHaveBeenCalled() + }) + + it('does not upload during --dry-run but lists the attachment', async () => { + const { client, program } = setupFileTest() + const consoleSpy = captureConsole('log') + + await program.parseAsync([ + 'node', + 'tdc', + 'conversation', + 'reply', + '42', + 'preview', + '--file', + files.png, + '--dry-run', + ]) + + expect(client.attachments.upload).not.toHaveBeenCalled() + expect(client.conversationMessages.createMessage).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining(files.png)) + }) +}) diff --git a/src/commands/conversation/helpers.ts b/src/commands/conversation/helpers.ts index f050fdb..b7cba6e 100644 --- a/src/commands/conversation/helpers.ts +++ b/src/commands/conversation/helpers.ts @@ -18,7 +18,7 @@ export type ConversationWithOptions = PaginatedViewOptions & { snippet?: boolean } -export type ReplyOptions = MutationOptions +export type ReplyOptions = MutationOptions & { file?: string[] } export type MuteOptions = MutationOptions & { minutes?: string } diff --git a/src/commands/conversation/index.ts b/src/commands/conversation/index.ts index 48805f6..28a5de9 100644 --- a/src/commands/conversation/index.ts +++ b/src/commands/conversation/index.ts @@ -1,4 +1,5 @@ import { Command } from 'commander' +import { collect } from '../../lib/options.js' import { markConversationDone } from './done.js' import { muteConversation } from './mute.js' import { replyToConversation } from './reply.js' @@ -77,6 +78,7 @@ Examples: conversation .command('reply [content]') .description('Send a message in a conversation') + .option('--file ', 'Attach a file (repeatable; content optional)', collect, []) .option('--dry-run', 'Show what would be sent without sending') .option('--json', 'Output sent message as JSON') .option('--full', 'Include all fields in JSON output') @@ -86,7 +88,8 @@ Examples: Examples: tdc conversation reply 12345 "Hello!" echo "Message body" | tdc conversation reply 12345 - tdc conversation reply 12345 "Update" --json`, + tdc conversation reply 12345 "Update" --json + tdc conversation reply 12345 "See attached" --file ./photo.jpg`, ) .action(replyToConversation) diff --git a/src/commands/conversation/reply.ts b/src/commands/conversation/reply.ts index f694077..e173be7 100644 --- a/src/commands/conversation/reply.ts +++ b/src/commands/conversation/reply.ts @@ -1,4 +1,6 @@ +import chalk from 'chalk' import { getCommsClient } from '../../lib/api.js' +import { uploadAttachments, validateAttachmentFiles } from '../../lib/attachments.js' import { CliError } from '../../lib/errors.js' import { openEditor, readStdin } from '../../lib/input.js' import { formatJson, printDryRun } from '../../lib/output.js' @@ -12,34 +14,54 @@ export async function replyToConversation( ): Promise { const conversationId = resolveConversationId(ref) + const files = options.file ?? [] + const hasFiles = files.length > 0 + let replyContent = await readStdin() if (!replyContent && content) { replyContent = content } - if (!replyContent) { + // A file-only message is allowed: skip the editor prompt and the empty-content guard. + if (!replyContent && !hasFiles) { replyContent = await openEditor() } - if (!replyContent || replyContent.trim() === '') { + if ((!replyContent || replyContent.trim() === '') && !hasFiles) { throw new CliError( 'MISSING_CONTENT', - 'No content provided. Pass content as an argument or pipe via stdin.', + 'No content provided. Pass content as an argument, pipe via stdin, or attach a file.', ) } + const messageContent = replyContent ?? '' if (options.dryRun) { + // Validate attachment paths so the preview fails on a bad path exactly + // as a real run would (no upload happens in dry-run). + if (hasFiles) { + await validateAttachmentFiles(files) + } const preview = - replyContent.length > 200 ? `${replyContent.slice(0, 200)}...` : replyContent + messageContent.length > 200 ? `${messageContent.slice(0, 200)}...` : messageContent printDryRun('send message to conversation', { Conversation: String(conversationId), - Content: preview, + Attach: hasFiles ? files.join(', ') : undefined, + Content: preview || undefined, }) return } const client = await getCommsClient() + + // Preflight the target before uploading so an invalid or forbidden + // conversation fails fast instead of leaving an orphaned upload behind. + if (hasFiles) { + await client.conversations.getConversation(conversationId) + } + + const attachments = hasFiles ? await uploadAttachments(files) : undefined const message = await client.conversationMessages.createMessage({ conversationId, - content: replyContent, + content: messageContent, + attachments, }) if (options.json) { @@ -48,4 +70,8 @@ export async function replyToConversation( } console.log(`Message sent: ${message.url}`) + if (attachments && attachments.length > 0) { + const names = attachments.map((a) => a.fileName ?? 'file').join(', ') + console.log(chalk.dim(`Attached: ${names}`)) + } } diff --git a/src/commands/thread/create.ts b/src/commands/thread/create.ts index 191f908..ff5c9d8 100644 --- a/src/commands/thread/create.ts +++ b/src/commands/thread/create.ts @@ -1,4 +1,6 @@ +import chalk from 'chalk' import { getCommsClient } from '../../lib/api.js' +import { uploadAttachments, validateAttachmentFiles } from '../../lib/attachments.js' import { getConfig } from '../../lib/config.js' import { CliError } from '../../lib/errors.js' import { openEditor, readStdin } from '../../lib/input.js' @@ -11,6 +13,7 @@ import { type ResolvedNotify, formatNotifyLabel, resolveNotifyIds } from './help type CreateOptions = MutationOptions & { notify?: string unarchive?: boolean + file?: string[] } export async function createThread( @@ -21,19 +24,24 @@ export async function createThread( ): Promise { const channelId = resolveChannelId(channelRef) + const files = options.file ?? [] + const hasFiles = files.length > 0 + let threadContent = await readStdin() if (!threadContent && content) { threadContent = content } - if (!threadContent) { + // A file-only thread is allowed: skip the editor prompt and the empty-content guard. + if (!threadContent && !hasFiles) { threadContent = await openEditor() } - if (!threadContent || threadContent.trim() === '') { + if ((!threadContent || threadContent.trim() === '') && !hasFiles) { throw new CliError( 'MISSING_CONTENT', - 'No content provided. Pass content as an argument or pipe via stdin.', + 'No content provided. Pass content as an argument, pipe via stdin, or attach a file.', ) } + const messageContent = threadContent ?? '' const allIds = options.notify ? parseNotifyIdRefs(options.notify) : undefined @@ -50,8 +58,13 @@ export async function createThread( const shouldUnarchive = options.unarchive ?? config.userSettings?.unarchiveNewThreads ?? false if (options.dryRun) { + // Validate attachment paths so the preview fails on a bad path exactly + // as a real run would (no upload happens in dry-run). + if (hasFiles) { + await validateAttachmentFiles(files) + } const preview = - threadContent.length > 200 ? `${threadContent.slice(0, 200)}...` : threadContent + messageContent.length > 200 ? `${messageContent.slice(0, 200)}...` : messageContent printDryRun('create thread', { Channel: `${channel.name} (${channelId})`, Title: title, @@ -64,17 +77,21 @@ export async function createThread( ? formatNotifyLabel(resolved.notified.groups) : undefined, Unarchive: shouldUnarchive ? 'yes' : undefined, - Content: preview, + Attach: hasFiles ? files.join(', ') : undefined, + Content: preview || undefined, }) return } + const attachments = hasFiles ? await uploadAttachments(files) : undefined + const thread = await client.threads.createThread({ channelId, title, - content: threadContent, + content: messageContent, recipients: resolved?.recipients, groups: resolved?.groups, + attachments, }) if (shouldUnarchive) { @@ -93,4 +110,8 @@ export async function createThread( } console.log(`Thread created: ${thread.url}`) + if (attachments && attachments.length > 0) { + const names = attachments.map((a) => a.fileName ?? 'file').join(', ') + console.log(chalk.dim(`Attached: ${names}`)) + } } diff --git a/src/commands/thread/index.ts b/src/commands/thread/index.ts index 16b1372..a378f4d 100644 --- a/src/commands/thread/index.ts +++ b/src/commands/thread/index.ts @@ -1,5 +1,6 @@ import { Command, Option } from 'commander' import { withUnvalidatedChoices } from '../../lib/completion.js' +import { collect } from '../../lib/options.js' import { createThread } from './create.js' import { deleteThread } from './delete.js' import { markThreadDone } from './mutate.js' @@ -55,6 +56,7 @@ Examples: ) .option('--close', 'Close the thread after replying') .option('--reopen', 'Reopen the thread after replying') + .option('--file ', 'Attach a file (repeatable; content optional)', collect, []) .option('--dry-run', 'Show what would be posted without posting') .option('--json', 'Output posted comment as JSON') .option('--full', 'Include all fields in JSON output') @@ -64,7 +66,9 @@ Examples: Examples: tdc thread reply 12345 "Sounds good!" echo "Long reply" | tdc thread reply 12345 - tdc thread reply 12345 "Done" --close --json`, + tdc thread reply 12345 "Done" --close --json + tdc thread reply 12345 "See attached" --file ./diagram.png + tdc thread reply 12345 --file ./a.png --file ./b.pdf`, ) .action(replyToThread) @@ -77,6 +81,7 @@ Examples: 'Unarchive after creation so the thread appears in your Inbox (overrides userSettings.unarchiveNewThreads when false)', ) .option('--no-unarchive', 'Skip unarchive even if userSettings.unarchiveNewThreads is true') + .option('--file ', 'Attach a file (repeatable; content optional)', collect, []) .option('--dry-run', 'Show what would be posted without posting') .option('--json', 'Output created thread as JSON') .option('--full', 'Include all fields in JSON output') @@ -87,7 +92,8 @@ Examples: tdc thread create 12345 "Weekly update" "Here's what happened..." echo "Body from stdin" | tdc thread create id:12345 "Title" tdc thread create 12345 "Title" "Body" --notify 67890,11111 --json - tdc thread create 12345 "Title" "Body" --unarchive`, + tdc thread create 12345 "Title" "Body" --unarchive + tdc thread create 12345 "Title" --file ./report.pdf`, ) .action(createThread) diff --git a/src/commands/thread/reply.ts b/src/commands/thread/reply.ts index 8d84435..cfb927f 100644 --- a/src/commands/thread/reply.ts +++ b/src/commands/thread/reply.ts @@ -1,4 +1,6 @@ +import chalk from 'chalk' import { getCommsClient } from '../../lib/api.js' +import { uploadAttachments, validateAttachmentFiles } from '../../lib/attachments.js' import { CliError } from '../../lib/errors.js' import { openEditor, readStdin } from '../../lib/input.js' import type { MutationOptions } from '../../lib/options.js' @@ -11,6 +13,7 @@ type ReplyOptions = MutationOptions & { notify?: string close?: boolean reopen?: boolean + file?: string[] } export async function replyToThread( @@ -24,19 +27,31 @@ export async function replyToThread( throw new CliError('CONFLICTING_OPTIONS', 'Cannot use --close and --reopen together.') } + const files = options.file ?? [] + const hasFiles = files.length > 0 + + if (hasFiles && (options.close || options.reopen)) { + throw new CliError( + 'CONFLICTING_OPTIONS', + 'Cannot attach files with --close or --reopen. Post the attachment separately.', + ) + } + let replyContent = await readStdin() if (!replyContent && content) { replyContent = content } - if (!replyContent) { + // A file-only reply is allowed: skip the editor prompt and the empty-content guard. + if (!replyContent && !hasFiles) { replyContent = await openEditor() } - if (!replyContent || replyContent.trim() === '') { + if ((!replyContent || replyContent.trim() === '') && !hasFiles) { throw new CliError( 'MISSING_CONTENT', - 'No content provided. Pass content as an argument or pipe via stdin.', + 'No content provided. Pass content as an argument, pipe via stdin, or attach a file.', ) } + const messageContent = replyContent ?? '' const notifyValue = options.notify ?? 'EVERYONE_IN_THREAD' const isSpecialRecipient = notifyValue === 'EVERYONE' || notifyValue === 'EVERYONE_IN_THREAD' @@ -59,9 +74,14 @@ export async function replyToThread( const actionLabel = action === 'close' ? 'close' : action === 'reopen' ? 'reopen' : undefined if (options.dryRun) { + // Validate attachment paths so the preview fails on a bad path exactly + // as a real run would (no upload happens in dry-run). + if (hasFiles) { + await validateAttachmentFiles(files) + } const actionSuffix = actionLabel ? ` and ${actionLabel} it` : '' const preview = - replyContent.length > 200 ? `${replyContent.slice(0, 200)}...` : replyContent + messageContent.length > 200 ? `${messageContent.slice(0, 200)}...` : messageContent printDryRun(`post comment to thread${actionSuffix}`, { Thread: `${thread.title} (${threadId})`, Notify: isSpecialRecipient ? notifyValue : undefined, @@ -73,33 +93,43 @@ export async function replyToThread( !isSpecialRecipient && resolved && resolved.notified.groups.length > 0 ? formatNotifyLabel(resolved.notified.groups) : undefined, - Content: preview, + Attach: hasFiles ? files.join(', ') : undefined, + Content: preview || undefined, }) return } + const attachments = hasFiles ? await uploadAttachments(files) : undefined const groupsPayload = resolved?.groups ? { groups: resolved.groups } : {} + // Type-checked against the SDK contract — notably `attachments`. Only `recipients` + // needs the assertion below: it carries the EVERYONE / EVERYONE_IN_THREAD sentinels + // the SDK type doesn't model. + const createCommentArgs = { + threadId, + content: messageContent, + ...groupsPayload, + ...(attachments ? { attachments } : {}), + } satisfies Parameters[0] + const comment = action === 'close' ? await client.threads.closeThread({ id: threadId, - content: replyContent, + content: messageContent, recipients, ...groupsPayload, } as Parameters[0]) : action === 'reopen' ? await client.threads.reopenThread({ id: threadId, - content: replyContent, + content: messageContent, recipients, ...groupsPayload, } as Parameters[0]) : await client.comments.createComment({ - threadId, - content: replyContent, + ...createCommentArgs, recipients, - ...groupsPayload, } as Parameters[0]) if (options.json) { @@ -110,4 +140,8 @@ export async function replyToThread( const suffix = actionLabel ? ` (thread ${actionLabel === 'close' ? 'closed' : 'reopened'})` : '' console.log(`Comment posted${suffix}: ${comment.url}`) + if (attachments && attachments.length > 0) { + const names = attachments.map((a) => a.fileName ?? 'file').join(', ') + console.log(chalk.dim(`Attached: ${names}`)) + } } diff --git a/src/commands/thread/thread.test.ts b/src/commands/thread/thread.test.ts index 4795f01..7d29c3c 100644 --- a/src/commands/thread/thread.test.ts +++ b/src/commands/thread/thread.test.ts @@ -1,5 +1,8 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { captureConsole, createTestProgram } from '@doist/cli-core/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const apiMocks = vi.hoisted(() => ({ getCommsClient: vi.fn(), @@ -42,7 +45,7 @@ vi.mock('../../lib/input.js', () => ({ vi.mock('chalk') import { clearWorkspaceUserCache } from '../../lib/api.js' -import { readStdin } from '../../lib/input.js' +import { openEditor, readStdin } from '../../lib/input.js' import { registerThreadCommand } from './index.js' function createThreadFixture(id: number | string) { @@ -96,8 +99,12 @@ function createClient({ getThread: vi.fn(async (_id: string) => thread), getUnread: vi.fn(async () => ({ data: unreadThreads, version: 1 })), createThread: vi.fn( - async (_args: { channelId: string; content: string; title?: string | null }) => - createThreadFixture(999), + async (_args: { + channelId: string + content: string + title?: string | null + attachments?: Array<{ fileName?: string | null }> + }) => createThreadFixture(999), ), closeThread: vi.fn(async (_args: { id: string; content: string }) => createComment(10, 10), @@ -130,10 +137,21 @@ function createClient({ getComment: vi.fn( async (id: string) => comments.find((c) => c.id === id) ?? comments[0], ), - createComment: vi.fn(async (_args: { threadId: string; content: string }) => - createComment(12, 12), + createComment: vi.fn( + async (_args: { + threadId: string + content: string + attachments?: Array<{ fileName?: string | null }> + }) => createComment(12, 12), ), }, + attachments: { + upload: vi.fn(async (args: { file: Blob; fileName: string }) => ({ + attachmentId: `att-${args.fileName}`, + urlType: 'file', + fileName: args.fileName, + })), + }, channels: { getChannel: vi.fn(async (_id: string) => channel), }, @@ -152,6 +170,30 @@ function createClient({ const createProgram = () => createTestProgram(registerThreadCommand) +// Shared setup for the --file suites: a fresh mock client wired into getCommsClient +// plus a program. Tests asserting on output call captureConsole('log') themselves. +function setupFileTest() { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + return { client, program: createProgram() } +} + +// Registers a temp dir with two files for a --file suite, cleaned up afterwards. +function useFileFixtures(prefix: string, png: string, pdf: string) { + const paths = { dir: '', png: '', pdf: '' } + beforeAll(async () => { + paths.dir = await mkdtemp(join(tmpdir(), prefix)) + paths.png = join(paths.dir, png) + paths.pdf = join(paths.dir, pdf) + await writeFile(paths.png, 'png-bytes') + await writeFile(paths.pdf, 'pdf-bytes') + }) + afterAll(async () => { + await rm(paths.dir, { recursive: true, force: true }) + }) + return paths +} + describe('thread implicit view', () => { beforeEach(() => { clearWorkspaceUserCache() @@ -1305,3 +1347,242 @@ describe('thread done', () => { expect(client.inbox.archiveThread).not.toHaveBeenCalled() }) }) + +describe('thread reply --file', () => { + const files = useFileFixtures('tdc-reply-', 'diagram.png', 'report.pdf') + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uploads the file and attaches it to the comment', async () => { + const { client, program } = setupFileTest() + const consoleSpy = captureConsole('log') + + await program.parseAsync([ + 'node', + 'tdc', + 'thread', + 'reply', + '500', + 'See attached', + '--file', + files.png, + ]) + + expect(client.attachments.upload).toHaveBeenCalledTimes(1) + expect(client.attachments.upload).toHaveBeenCalledWith( + expect.objectContaining({ fileName: 'diagram.png' }), + ) + expect(client.comments.createComment).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: '500', + content: 'See attached', + attachments: [expect.objectContaining({ fileName: 'diagram.png' })], + }), + ) + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Attached: diagram.png')) + }) + + it('attaches multiple repeated --file values', async () => { + const { client, program } = setupFileTest() + + await program.parseAsync([ + 'node', + 'tdc', + 'thread', + 'reply', + '500', + 'two files', + '--file', + files.png, + '--file', + files.pdf, + ]) + + expect(client.attachments.upload).toHaveBeenCalledTimes(2) + const args = client.comments.createComment.mock.calls[0][0] as { + attachments: Array<{ fileName?: string }> + } + expect(args.attachments.map((a) => a.fileName)).toEqual(['diagram.png', 'report.pdf']) + }) + + it('allows a file-only reply with no text content', async () => { + const { client, program } = setupFileTest() + + await program.parseAsync(['node', 'tdc', 'thread', 'reply', '500', '--file', files.png]) + + expect(client.comments.createComment).toHaveBeenCalledWith( + expect.objectContaining({ content: '', attachments: expect.any(Array) }), + ) + // A file-only reply must not block on the editor. + expect(openEditor).not.toHaveBeenCalled() + }) + + it('errors with FILE_NOT_FOUND for a missing path and does not post', async () => { + const { client, program } = setupFileTest() + + await expect( + program.parseAsync([ + 'node', + 'tdc', + 'thread', + 'reply', + '500', + 'x', + '--file', + join(files.dir, 'missing.png'), + ]), + ).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' }) + + expect(client.attachments.upload).not.toHaveBeenCalled() + expect(client.comments.createComment).not.toHaveBeenCalled() + }) + + it('rejects --file combined with --close', async () => { + const { client, program } = setupFileTest() + + await expect( + program.parseAsync([ + 'node', + 'tdc', + 'thread', + 'reply', + '500', + 'x', + '--close', + '--file', + files.png, + ]), + ).rejects.toMatchObject({ code: 'CONFLICTING_OPTIONS' }) + + expect(client.attachments.upload).not.toHaveBeenCalled() + }) + + it('does not upload during --dry-run but lists the attachment', async () => { + const { client, program } = setupFileTest() + const consoleSpy = captureConsole('log') + + await program.parseAsync([ + 'node', + 'tdc', + 'thread', + 'reply', + '500', + 'preview', + '--file', + files.png, + '--dry-run', + ]) + + expect(client.attachments.upload).not.toHaveBeenCalled() + expect(client.comments.createComment).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining(files.png)) + }) +}) + +describe('thread create --file', () => { + const files = useFileFixtures('tdc-create-', 'cover.png', 'spec.pdf') + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uploads files and attaches them to the new thread', async () => { + const { client, program } = setupFileTest() + const consoleSpy = captureConsole('log') + + await program.parseAsync([ + 'node', + 'tdc', + 'thread', + 'create', + 'CH100', + 'Release notes', + 'See attached', + '--file', + files.png, + '--file', + files.pdf, + ]) + + expect(client.attachments.upload).toHaveBeenCalledTimes(2) + const args = client.threads.createThread.mock.calls[0][0] as { + title: string + content: string + attachments: Array<{ fileName?: string }> + } + expect(args.title).toBe('Release notes') + expect(args.content).toBe('See attached') + expect(args.attachments.map((a) => a.fileName)).toEqual(['cover.png', 'spec.pdf']) + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Attached: cover.png, spec.pdf'), + ) + }) + + it('allows a file-only thread (title only, no body) without opening the editor', async () => { + const { client, program } = setupFileTest() + + await program.parseAsync([ + 'node', + 'tdc', + 'thread', + 'create', + 'CH100', + 'Title', + '--file', + files.png, + ]) + + const args = client.threads.createThread.mock.calls[0][0] as { + content: string + attachments: unknown[] + } + expect(args.content).toBe('') + expect(args.attachments).toHaveLength(1) + expect(openEditor).not.toHaveBeenCalled() + }) + + it('errors with FILE_NOT_FOUND for a missing path and does not create the thread', async () => { + const { client, program } = setupFileTest() + + await expect( + program.parseAsync([ + 'node', + 'tdc', + 'thread', + 'create', + 'CH100', + 'Title', + 'body', + '--file', + join(files.dir, 'missing.png'), + ]), + ).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' }) + + expect(client.attachments.upload).not.toHaveBeenCalled() + expect(client.threads.createThread).not.toHaveBeenCalled() + }) + + it('does not upload during --dry-run but lists the attachment', async () => { + const { client, program } = setupFileTest() + const consoleSpy = captureConsole('log') + + await program.parseAsync([ + 'node', + 'tdc', + 'thread', + 'create', + 'CH100', + 'Title', + 'body', + '--file', + files.png, + '--dry-run', + ]) + + expect(client.attachments.upload).not.toHaveBeenCalled() + expect(client.threads.createThread).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining(files.png)) + }) +}) diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 92d51a1..d0e6e7a 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -5,11 +5,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const getWorkspaceUsersMock = vi.hoisted(() => vi.fn().mockResolvedValue([])) const sdkMocks = vi.hoisted(() => ({ deleteChannel: vi.fn(), + uploadAttachment: vi.fn(), })) vi.mock('@doist/comms-sdk', () => { class CommsApi { channels = { deleteChannel: sdkMocks.deleteChannel } + attachments = { upload: sdkMocks.uploadAttachment } workspaceUsers = { getWorkspaceUsers: getWorkspaceUsersMock } constructor(_token?: string) {} } @@ -32,12 +34,16 @@ vi.mock('./auth.js', () => ({ getAuthMetadata: vi.fn().mockResolvedValue({ authMode: 'full' }), })) -// `channels.deleteChannel` is the only mutating method the 403-translation -// tests exercise; everything else (getWorkspaceUsers) stays on the read path. -vi.mock('./permissions.js', () => ({ +// Mirror production: unknown methods default to the write path. The tests +// exercise `channels.deleteChannel` and `attachments.upload` as mutating; +// reads (getWorkspaceUsers) stay off the write path. +const permMocks = vi.hoisted(() => ({ ensureWriteAllowed: vi.fn().mockResolvedValue(undefined), - isMutatingMethod: vi.fn((path: string) => path === 'channels.deleteChannel'), + isMutatingMethod: vi.fn( + (path: string) => path === 'channels.deleteChannel' || path === 'attachments.upload', + ), })) +vi.mock('./permissions.js', () => permMocks) vi.mock('./spinner.js', () => ({ withSpinner: (_label: unknown, fn: () => Promise) => fn(), @@ -98,6 +104,8 @@ describe('getWorkspaceUsers', () => { describe('wrapResult — central 403 translation', () => { beforeEach(() => { sdkMocks.deleteChannel.mockReset() + sdkMocks.uploadAttachment.mockReset() + permMocks.ensureWriteAllowed.mockReset().mockResolvedValue(undefined) }) it('translates a plain 403 into a FORBIDDEN CliError', async () => { @@ -131,6 +139,37 @@ describe('wrapResult — central 403 translation', () => { }) }) + it('translates an attachments.upload scope 403 into INSUFFICIENT_SCOPE (re-login prompt)', async () => { + permMocks.ensureWriteAllowed.mockResolvedValue(undefined) + sdkMocks.uploadAttachment.mockRejectedValueOnce( + new CommsRequestError('Request failed with status 403', 403, { + error_string: 'Insufficient scope provided: attachments:write', + }), + ) + const client = createWrappedCommsClient('test-token') + + await expect( + client.attachments.upload({ file: new Blob(['x']), fileName: 'x.png' }), + ).rejects.toMatchObject({ + code: 'INSUFFICIENT_SCOPE', + message: 'This action requires permissions your current token does not have.', + hints: ['Run `tdc auth login` to re-authenticate with the required scopes'], + }) + // Confirms upload runs through the mutating write-guard. + expect(permMocks.ensureWriteAllowed).toHaveBeenCalled() + }) + + it('routes attachments.upload through the write-guard, blocking it in read-only mode', async () => { + permMocks.ensureWriteAllowed.mockRejectedValueOnce(new Error('READ_ONLY')) + const client = createWrappedCommsClient('test-token') + + await expect( + client.attachments.upload({ file: new Blob(['x']), fileName: 'x.png' }), + ).rejects.toThrow('READ_ONLY') + // The SDK upload must never fire when the write-guard rejects. + expect(sdkMocks.uploadAttachment).not.toHaveBeenCalled() + }) + it('passes non-403 errors through untranslated', async () => { const originalError = new CommsRequestError('Request failed with status 500', 500, {}) sdkMocks.deleteChannel.mockRejectedValueOnce(originalError) diff --git a/src/lib/api.ts b/src/lib/api.ts index 7cf4275..1d9f8ab 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -96,6 +96,9 @@ const API_SPINNER_MESSAGES: Record ({ + getCommsClient: vi.fn(), +})) + +vi.mock('./api.js', async (importOriginal) => ({ + ...(await importOriginal()), + getCommsClient: apiMocks.getCommsClient, +})) + +import { uploadAttachments } from './attachments.js' + +function createClient() { + return { + attachments: { + upload: vi.fn(async (args: { file: Blob; fileName: string }) => ({ + attachmentId: `att-${args.fileName}`, + urlType: 'file', + fileName: args.fileName, + })), + }, + } +} + +describe('uploadAttachments', () => { + let tmpDir: string + let fileA: string + let fileB: string + + beforeAll(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'tdc-upload-')) + fileA = join(tmpDir, 'a.png') + fileB = join(tmpDir, 'b.pdf') + await writeFile(fileA, 'a-bytes') + await writeFile(fileB, 'b-bytes') + }) + + afterAll(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uploads every file and preserves input order', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + + const result = await uploadAttachments([fileA, fileB]) + + expect(client.attachments.upload).toHaveBeenCalledTimes(2) + expect(result.map((a) => a.fileName)).toEqual(['a.png', 'b.pdf']) + }) + + it('never uploads when any path is invalid (no partial uploads)', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + + await expect( + uploadAttachments([fileA, join(tmpDir, 'missing.png'), fileB]), + ).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' }) + + expect(client.attachments.upload).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/attachments.ts b/src/lib/attachments.ts new file mode 100644 index 0000000..08fd1f4 --- /dev/null +++ b/src/lib/attachments.ts @@ -0,0 +1,58 @@ +import type { Attachment } from '@doist/comms-sdk' +import { getCommsClient } from './api.js' +import { openLocalFileAsBlob } from './local-file.js' + +/** Max attachments uploaded at once — bounds socket/file-descriptor pressure. */ +const MAX_UPLOAD_CONCURRENCY = 4 + +/** + * Map `items` through `fn` with at most `limit` in flight at a time, preserving + * input order in the returned array. + */ +async function mapWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + const results: R[] = Array.from({ length: items.length }) + let cursor = 0 + const worker = async () => { + while (cursor < items.length) { + const index = cursor++ + results[index] = await fn(items[index], index) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) + return results +} + +/** + * Validate that every path exists and is readable, without uploading anything. + * Used by `--dry-run` so the preview fails on a bad path exactly as a real run + * would. Throws `FILE_NOT_FOUND` / `FILE_READ_ERROR` on the first bad path. + */ +export async function validateAttachmentFiles(files: string[]): Promise { + await Promise.all(files.map((file) => openLocalFileAsBlob({ file }))) +} + +/** + * Upload one or more local files and return the created {@link Attachment}s, + * ready to splice into the `attachments` array of `comments.createComment`, + * `conversationMessages.createMessage`, or `threads.createThread`. + * + * All paths are validated (existence + readability) up front, before any + * upload starts, so a bad path fails fast without leaving a partial set of + * uploaded-but-unreferenced attachments behind. Uploads then run with bounded + * concurrency while the returned array preserves the input order. + */ +export async function uploadAttachments(files: string[]): Promise { + // Validate every path first so a bad one fails before we upload anything. + const opened = await Promise.all(files.map((file) => openLocalFileAsBlob({ file }))) + + const client = await getCommsClient() + return mapWithConcurrency(opened, MAX_UPLOAD_CONCURRENCY, ({ blob, fileName }) => + client.attachments.upload({ file: blob, fileName }), + ) +} + +export type { Attachment } diff --git a/src/lib/auth-provider.ts b/src/lib/auth-provider.ts index 088e421..4cc817b 100644 --- a/src/lib/auth-provider.ts +++ b/src/lib/auth-provider.ts @@ -41,6 +41,8 @@ export const READ_WRITE_SCOPES = [ 'groups:remove', 'search:read', 'notifications:read', + 'attachments:read', + 'attachments:write', ] export const READ_ONLY_SCOPES = [ @@ -54,6 +56,7 @@ export const READ_ONLY_SCOPES = [ 'groups:read', 'search:read', 'notifications:read', + 'attachments:read', ] const AUTH_HINTS = ['Try again: tdc auth login', 'Or set COMMS_API_TOKEN environment variable'] diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 4f9bd77..071a389 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -48,6 +48,7 @@ export type ErrorCode = // State errors | 'ALREADY_INSTALLED' | 'BATCH_FAILED' + | 'FILE_NOT_FOUND' | 'FILE_READ_ERROR' | 'NOT_CREATOR' | 'NOT_INSTALLED' diff --git a/src/lib/local-file.test.ts b/src/lib/local-file.test.ts new file mode 100644 index 0000000..f4540b9 --- /dev/null +++ b/src/lib/local-file.test.ts @@ -0,0 +1,47 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { openLocalFileAsBlob } from './local-file.js' + +describe('openLocalFileAsBlob', () => { + let tmpDir: string + let filePath: string + + beforeAll(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'tdc-localfile-')) + filePath = join(tmpDir, 'diagram.png') + await writeFile(filePath, 'hello-bytes') + }) + + afterAll(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('returns a file-backed Blob and the basename as fileName', async () => { + const { blob, fileName, filePath: resolved } = await openLocalFileAsBlob({ file: filePath }) + expect(blob).toBeInstanceOf(Blob) + expect(await blob.text()).toBe('hello-bytes') + expect(fileName).toBe('diagram.png') + expect(resolved).toBe(filePath) + }) + + it('honours an explicit fileName override', async () => { + const { fileName } = await openLocalFileAsBlob({ file: filePath, fileName: 'renamed.png' }) + expect(fileName).toBe('renamed.png') + }) + + it('throws FILE_NOT_FOUND for a missing path', async () => { + await expect(openLocalFileAsBlob({ file: join(tmpDir, 'nope.png') })).rejects.toMatchObject( + { code: 'FILE_NOT_FOUND' }, + ) + }) + + it('throws FILE_READ_ERROR for a non-ENOENT fs failure', async () => { + // A path whose parent is a regular file fails with ENOTDIR (not ENOENT), + // exercising the structured FILE_READ_ERROR branch deterministically. + await expect( + openLocalFileAsBlob({ file: join(filePath, 'child.png') }), + ).rejects.toMatchObject({ code: 'FILE_READ_ERROR' }) + }) +}) diff --git a/src/lib/local-file.ts b/src/lib/local-file.ts new file mode 100644 index 0000000..9ae595b --- /dev/null +++ b/src/lib/local-file.ts @@ -0,0 +1,55 @@ +import { openAsBlob } from 'node:fs' +import { open } from 'node:fs/promises' +import { basename, resolve } from 'node:path' +import { CliError } from './errors.js' + +export type LocalFileOptions = { + /** Path to the file on disk (relative paths resolve against cwd). */ + file: string + /** Optional override for the upload's user-facing filename. Defaults to `basename(file)`. */ + fileName?: string +} + +/** + * Open a local file as a streaming `Blob` for upload, with CLI-grade + * error reporting. The returned Blob is file-backed — undici reads it + * lazily when serializing the multipart request body, so the payload + * never has to fit in memory all at once. + * + * Returns the resolved absolute path and the effective `fileName` + * (caller's override, falling back to `basename(filePath)`) so call + * sites don't have to recompute either. + * + * On the happy path this is a single `openAsBlob` — no separate readability + * probe, so no extra filesystem open and no time-of-check/time-of-use window. + * The probe only runs when `openAsBlob` fails: it rewraps fs errors as an + * opaque `ERR_INVALID_ARG_VALUE` TypeError (it does *not* preserve `ENOENT`), + * so we re-open with `fs.open` to recover the real errno and map it to a + * precise `FILE_NOT_FOUND` vs `FILE_READ_ERROR`. + */ +export async function openLocalFileAsBlob( + options: LocalFileOptions, +): Promise<{ blob: Blob; filePath: string; fileName: string }> { + const filePath = resolve(options.file) + try { + const blob = await openAsBlob(filePath) + return { blob, filePath, fileName: options.fileName || basename(filePath) } + } catch (err) { + // `openAsBlob` masks the underlying fs error; re-open to recover the errno. + try { + const handle = await open(filePath, 'r') + await handle.close() + } catch (fsErr) { + if ((fsErr as NodeJS.ErrnoException).code === 'ENOENT') { + throw new CliError('FILE_NOT_FOUND', `File not found: ${filePath}`, [ + 'Check the file path and try again.', + ]) + } + const message = fsErr instanceof Error ? fsErr.message : String(fsErr) + throw new CliError('FILE_READ_ERROR', `Cannot read file: ${filePath}`, [message]) + } + // The path is readable but `openAsBlob` still failed — surface as a read error. + const message = err instanceof Error ? err.message : String(err) + throw new CliError('FILE_READ_ERROR', `Cannot read file: ${filePath}`, [message]) + } +} diff --git a/src/lib/options.ts b/src/lib/options.ts index 470d268..78573ae 100644 --- a/src/lib/options.ts +++ b/src/lib/options.ts @@ -17,3 +17,11 @@ export type MutationOptions = { full?: boolean yes?: boolean } + +/** + * Commander collector for repeatable options (e.g. `--file a --file b`). + * Use with a `[]` default: `.option('--file ', '…', collect, [])`. + */ +export function collect(value: string, previous: string[] = []): string[] { + return [...previous, value] +} diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index d33d158..6fe2691 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -95,6 +95,7 @@ tdc thread create "Title" "content" --notify 123,456 # Notify spe tdc thread create "Title" "content" --unarchive # Land thread in author's Inbox (overrides default Comms auto-archive) tdc thread create "Title" "content" --no-unarchive # Force archive even when userSettings.unarchiveNewThreads=true tdc thread create "Title" "content" --dry-run # Preview without posting +tdc thread create "Title" --file ./a.png # Attach a file (repeatable; content optional) tdc thread reply "content" # Post a comment (notifies EVERYONE_IN_THREAD by default) tdc thread reply "content" --notify EVERYONE # Notify all workspace members tdc thread reply "content" --notify 123,id:456 # Notify specific user IDs @@ -102,6 +103,7 @@ tdc thread reply "content" --json # Post and return comment as JSON tdc thread reply "content" --json --full # Include all comment fields tdc thread reply "content" --close # Reply and close the thread tdc thread reply "content" --reopen # Reply and reopen a closed thread +tdc thread reply "content" --file ./a.png # Attach a file (repeatable; content optional) tdc thread done # Preview thread archive (requires --yes to execute) tdc thread done --yes # Archive thread (mark done) tdc thread done --yes --json # Archive and return status as JSON @@ -157,6 +159,7 @@ tdc conversation with --include-groups # List any conversations with tdc conversation reply "content" # Send a message tdc conversation reply "content" --json # Send and return message as JSON tdc conversation reply "content" --json --full # Include all message fields +tdc conversation reply "content" --file ./a.png # Attach a file (repeatable; content optional) tdc conversation done # Preview conversation archive (requires --yes to execute) tdc conversation done --yes # Archive conversation tdc conversation done --yes --json # Archive and return status as JSON