Skip to content

Commit 44dc3b7

Browse files
feat(attachments): support uploading files on replies and thread creation (#13)
* feat(attachments): support uploading files on replies and thread creation Ports the twist-cli attachment feature to comms-cli, backed by the `attachments` client in @doist/comms-sdk 0.4.1. - Bump @doist/comms-sdk 0.3.0 -> 0.4.1 - `src/lib/local-file.ts` (`openLocalFileAsBlob`) reads a path into a file-backed Blob with structured FILE_NOT_FOUND / FILE_READ_ERROR errors - `src/lib/attachments.ts` (`uploadAttachments`) validates every path up front, then uploads each concurrently (order preserved) - `tdc thread reply`, `tdc conversation reply`, and `tdc thread create` gain a repeatable `--file`; a file-only post (no text) is allowed - `conversation reply` preflights the conversation before uploading so an invalid/forbidden target fails before orphaning an upload - `--file` + `--close`/`--reopen` on thread reply -> CONFLICTING_OPTIONS - Add an `attachments.upload` spinner entry so uploads route through `wrapResult` for the 403 -> INSUFFICIENT_SCOPE re-login prompt - Add `attachments:read` / `attachments:write` to the OAuth scopes - Skill content + regenerated SKILL.md; tests for all of the above Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(attachments): address review feedback on #13 - thread reply: build a `satisfies`-typed createComment request so the `attachments` contract is compiler-checked again; only `recipients` (EVERYONE sentinels) keeps the assertion - local-file: try `openAsBlob` first (happy path = one open, no TOCTOU); fall back to an `fs.open` probe only on failure to recover the real errno (openAsBlob masks it as ERR_INVALID_ARG_VALUE) - attachments: cap upload concurrency (4) while preserving input order - options: `collect` defaults `previous` to `[]` so the contract holds even if a caller forgets the Commander default - dry-run now validates attachment paths (without uploading) so the preview fails on a bad path exactly as a real run would - tests: attachments.upload exercised through the mutating write-guard (incl. read-only block), local-file FILE_READ_ERROR branch, and a conversation preflight-failure-skips-upload case Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: avoid new Array() in concurrency helper (oxlint no-new-array) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6234a53 commit 44dc3b7

21 files changed

Lines changed: 899 additions & 43 deletions

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
],
5252
"dependencies": {
5353
"@doist/cli-core": "0.24.0",
54-
"@doist/comms-sdk": "0.3.0",
54+
"@doist/comms-sdk": "0.4.1",
5555
"@pnpm/tabtab": "0.5.4",
5656
"chalk": "5.6.2",
5757
"commander": "14.0.3",

skills/comms-cli/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,15 @@ tdc thread create <channel-ref> "Title" "content" --notify 123,456 # Notify spe
9191
tdc thread create <channel-ref> "Title" "content" --unarchive # Land thread in author's Inbox (overrides default Comms auto-archive)
9292
tdc thread create <channel-ref> "Title" "content" --no-unarchive # Force archive even when userSettings.unarchiveNewThreads=true
9393
tdc thread create <channel-ref> "Title" "content" --dry-run # Preview without posting
94+
tdc thread create <channel-ref> "Title" --file ./a.png # Attach a file (repeatable; content optional)
9495
tdc thread reply <ref> "content" # Post a comment (notifies EVERYONE_IN_THREAD by default)
9596
tdc thread reply <ref> "content" --notify EVERYONE # Notify all workspace members
9697
tdc thread reply <ref> "content" --notify 123,id:456 # Notify specific user IDs
9798
tdc thread reply <ref> "content" --json # Post and return comment as JSON
9899
tdc thread reply <ref> "content" --json --full # Include all comment fields
99100
tdc thread reply <ref> "content" --close # Reply and close the thread
100101
tdc thread reply <ref> "content" --reopen # Reply and reopen a closed thread
102+
tdc thread reply <ref> "content" --file ./a.png # Attach a file (repeatable; content optional)
101103
tdc thread done <ref> # Preview thread archive (requires --yes to execute)
102104
tdc thread done <ref> --yes # Archive thread (mark done)
103105
tdc thread done <ref> --yes --json # Archive and return status as JSON
@@ -153,6 +155,7 @@ tdc conversation with <user-ref> --include-groups # List any conversations with
153155
tdc conversation reply <ref> "content" # Send a message
154156
tdc conversation reply <ref> "content" --json # Send and return message as JSON
155157
tdc conversation reply <ref> "content" --json --full # Include all message fields
158+
tdc conversation reply <ref> "content" --file ./a.png # Attach a file (repeatable; content optional)
156159
tdc conversation done <ref> # Preview conversation archive (requires --yes to execute)
157160
tdc conversation done <ref> --yes # Archive conversation
158161
tdc conversation done <ref> --yes --json # Archive and return status as JSON

src/commands/conversation/conversation.test.ts

Lines changed: 197 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
14
import {
25
captureConsole,
36
createTestProgram,
47
describeEmptyMachineOutput,
58
} from '@doist/cli-core/testing'
6-
import { beforeEach, describe, expect, it, vi } from 'vitest'
9+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
710
import { clearWorkspaceUserCache } from '../../lib/api.js'
811
import { CliError } from '../../lib/errors.js'
912

@@ -32,6 +35,12 @@ vi.mock('../../lib/markdown.js', () => ({
3235

3336
vi.mock('chalk')
3437

38+
vi.mock('../../lib/input.js', () => ({
39+
readStdin: vi.fn().mockResolvedValue(''),
40+
openEditor: vi.fn().mockResolvedValue(''),
41+
}))
42+
43+
import { openEditor } from '../../lib/input.js'
3544
import { registerConversationCommand } from './index.js'
3645

3746
type TestConversation = {
@@ -115,7 +124,25 @@ function createClient({
115124
async ({ conversationId }: { conversationId: string; limit?: number }) =>
116125
messagesByConversation[conversationId] ?? [],
117126
),
118-
createMessage: vi.fn(),
127+
createMessage: vi.fn(
128+
async (args: {
129+
conversationId: string
130+
content: string
131+
attachments?: Array<{ fileName?: string | null }>
132+
}) => ({
133+
id: '999',
134+
conversationId: args.conversationId,
135+
content: args.content,
136+
url: 'https://comms.todoist.com/a/1/msg/999',
137+
}),
138+
),
139+
},
140+
attachments: {
141+
upload: vi.fn(async (args: { file: Blob; fileName: string }) => ({
142+
attachmentId: `att-${args.fileName}`,
143+
urlType: 'file',
144+
fileName: args.fileName,
145+
})),
119146
},
120147
workspaceUsers: {
121148
getUserById: vi.fn(
@@ -128,6 +155,30 @@ function createClient({
128155

129156
const createProgram = () => createTestProgram(registerConversationCommand)
130157

158+
// Shared setup for the --file suite: a fresh mock client wired into getCommsClient
159+
// plus a program. Tests asserting on output call captureConsole('log') themselves.
160+
function setupFileTest() {
161+
const client = createClient({})
162+
apiMocks.getCommsClient.mockResolvedValue(client)
163+
return { client, program: createProgram() }
164+
}
165+
166+
// Registers a temp dir with two files for a --file suite, cleaned up afterwards.
167+
function useFileFixtures(prefix: string, png: string, pdf: string) {
168+
const paths = { dir: '', png: '', pdf: '' }
169+
beforeAll(async () => {
170+
paths.dir = await mkdtemp(join(tmpdir(), prefix))
171+
paths.png = join(paths.dir, png)
172+
paths.pdf = join(paths.dir, pdf)
173+
await writeFile(paths.png, 'png-bytes')
174+
await writeFile(paths.pdf, 'pdf-bytes')
175+
})
176+
afterAll(async () => {
177+
await rm(paths.dir, { recursive: true, force: true })
178+
})
179+
return paths
180+
}
181+
131182
// Cache lives in api.ts module scope, so reset between tests.
132183
beforeEach(() => {
133184
clearWorkspaceUserCache()
@@ -689,3 +740,147 @@ describe('conversation done', () => {
689740
expect(client.conversations.archiveConversation).not.toHaveBeenCalled()
690741
})
691742
})
743+
744+
describe('conversation reply --file', () => {
745+
const files = useFileFixtures('tdc-convo-reply-', 'photo.png', 'doc.pdf')
746+
747+
beforeEach(() => {
748+
vi.clearAllMocks()
749+
})
750+
751+
it('uploads the file and attaches it to the message', async () => {
752+
const { client, program } = setupFileTest()
753+
const consoleSpy = captureConsole('log')
754+
755+
await program.parseAsync([
756+
'node',
757+
'tdc',
758+
'conversation',
759+
'reply',
760+
'42',
761+
'See attached',
762+
'--file',
763+
files.png,
764+
])
765+
766+
expect(client.attachments.upload).toHaveBeenCalledTimes(1)
767+
expect(client.attachments.upload).toHaveBeenCalledWith(
768+
expect.objectContaining({ fileName: 'photo.png' }),
769+
)
770+
expect(client.conversationMessages.createMessage).toHaveBeenCalledWith(
771+
expect.objectContaining({
772+
conversationId: '42',
773+
content: 'See attached',
774+
attachments: [expect.objectContaining({ fileName: 'photo.png' })],
775+
}),
776+
)
777+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Attached: photo.png'))
778+
})
779+
780+
it('attaches multiple repeated --file values', async () => {
781+
const { client, program } = setupFileTest()
782+
783+
await program.parseAsync([
784+
'node',
785+
'tdc',
786+
'conversation',
787+
'reply',
788+
'42',
789+
'two files',
790+
'--file',
791+
files.png,
792+
'--file',
793+
files.pdf,
794+
])
795+
796+
expect(client.attachments.upload).toHaveBeenCalledTimes(2)
797+
const args = client.conversationMessages.createMessage.mock.calls[0][0] as {
798+
attachments: Array<{ fileName?: string }>
799+
}
800+
expect(args.attachments.map((a) => a.fileName)).toEqual(['photo.png', 'doc.pdf'])
801+
})
802+
803+
it('allows a file-only message with no text content', async () => {
804+
const { client, program } = setupFileTest()
805+
806+
await program.parseAsync([
807+
'node',
808+
'tdc',
809+
'conversation',
810+
'reply',
811+
'42',
812+
'--file',
813+
files.png,
814+
])
815+
816+
expect(client.conversationMessages.createMessage).toHaveBeenCalledWith(
817+
expect.objectContaining({ content: '', attachments: expect.any(Array) }),
818+
)
819+
// A file-only message must not block on the editor.
820+
expect(openEditor).not.toHaveBeenCalled()
821+
})
822+
823+
it('errors with FILE_NOT_FOUND for a missing path and does not send', async () => {
824+
const { client, program } = setupFileTest()
825+
826+
await expect(
827+
program.parseAsync([
828+
'node',
829+
'tdc',
830+
'conversation',
831+
'reply',
832+
'42',
833+
'x',
834+
'--file',
835+
join(files.dir, 'missing.png'),
836+
]),
837+
).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' })
838+
839+
expect(client.attachments.upload).not.toHaveBeenCalled()
840+
expect(client.conversationMessages.createMessage).not.toHaveBeenCalled()
841+
})
842+
843+
it('skips the upload when the conversation preflight fails (no orphaned upload)', async () => {
844+
const { client, program } = setupFileTest()
845+
client.conversations.getConversation.mockRejectedValueOnce(
846+
new CliError('NOT_FOUND', 'Conversation not found'),
847+
)
848+
849+
await expect(
850+
program.parseAsync([
851+
'node',
852+
'tdc',
853+
'conversation',
854+
'reply',
855+
'42',
856+
'See attached',
857+
'--file',
858+
files.png,
859+
]),
860+
).rejects.toMatchObject({ code: 'NOT_FOUND' })
861+
862+
expect(client.attachments.upload).not.toHaveBeenCalled()
863+
expect(client.conversationMessages.createMessage).not.toHaveBeenCalled()
864+
})
865+
866+
it('does not upload during --dry-run but lists the attachment', async () => {
867+
const { client, program } = setupFileTest()
868+
const consoleSpy = captureConsole('log')
869+
870+
await program.parseAsync([
871+
'node',
872+
'tdc',
873+
'conversation',
874+
'reply',
875+
'42',
876+
'preview',
877+
'--file',
878+
files.png,
879+
'--dry-run',
880+
])
881+
882+
expect(client.attachments.upload).not.toHaveBeenCalled()
883+
expect(client.conversationMessages.createMessage).not.toHaveBeenCalled()
884+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining(files.png))
885+
})
886+
})

src/commands/conversation/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export type ConversationWithOptions = PaginatedViewOptions & {
1818
snippet?: boolean
1919
}
2020

21-
export type ReplyOptions = MutationOptions
21+
export type ReplyOptions = MutationOptions & { file?: string[] }
2222

2323
export type MuteOptions = MutationOptions & { minutes?: string }
2424

src/commands/conversation/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Command } from 'commander'
2+
import { collect } from '../../lib/options.js'
23
import { markConversationDone } from './done.js'
34
import { muteConversation } from './mute.js'
45
import { replyToConversation } from './reply.js'
@@ -77,6 +78,7 @@ Examples:
7778
conversation
7879
.command('reply <conversation-ref> [content]')
7980
.description('Send a message in a conversation')
81+
.option('--file <path>', 'Attach a file (repeatable; content optional)', collect, [])
8082
.option('--dry-run', 'Show what would be sent without sending')
8183
.option('--json', 'Output sent message as JSON')
8284
.option('--full', 'Include all fields in JSON output')
@@ -86,7 +88,8 @@ Examples:
8688
Examples:
8789
tdc conversation reply 12345 "Hello!"
8890
echo "Message body" | tdc conversation reply 12345
89-
tdc conversation reply 12345 "Update" --json`,
91+
tdc conversation reply 12345 "Update" --json
92+
tdc conversation reply 12345 "See attached" --file ./photo.jpg`,
9093
)
9194
.action(replyToConversation)
9295

src/commands/conversation/reply.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import chalk from 'chalk'
12
import { getCommsClient } from '../../lib/api.js'
3+
import { uploadAttachments, validateAttachmentFiles } from '../../lib/attachments.js'
24
import { CliError } from '../../lib/errors.js'
35
import { openEditor, readStdin } from '../../lib/input.js'
46
import { formatJson, printDryRun } from '../../lib/output.js'
@@ -12,34 +14,54 @@ export async function replyToConversation(
1214
): Promise<void> {
1315
const conversationId = resolveConversationId(ref)
1416

17+
const files = options.file ?? []
18+
const hasFiles = files.length > 0
19+
1520
let replyContent = await readStdin()
1621
if (!replyContent && content) {
1722
replyContent = content
1823
}
19-
if (!replyContent) {
24+
// A file-only message is allowed: skip the editor prompt and the empty-content guard.
25+
if (!replyContent && !hasFiles) {
2026
replyContent = await openEditor()
2127
}
22-
if (!replyContent || replyContent.trim() === '') {
28+
if ((!replyContent || replyContent.trim() === '') && !hasFiles) {
2329
throw new CliError(
2430
'MISSING_CONTENT',
25-
'No content provided. Pass content as an argument or pipe via stdin.',
31+
'No content provided. Pass content as an argument, pipe via stdin, or attach a file.',
2632
)
2733
}
34+
const messageContent = replyContent ?? ''
2835

2936
if (options.dryRun) {
37+
// Validate attachment paths so the preview fails on a bad path exactly
38+
// as a real run would (no upload happens in dry-run).
39+
if (hasFiles) {
40+
await validateAttachmentFiles(files)
41+
}
3042
const preview =
31-
replyContent.length > 200 ? `${replyContent.slice(0, 200)}...` : replyContent
43+
messageContent.length > 200 ? `${messageContent.slice(0, 200)}...` : messageContent
3244
printDryRun('send message to conversation', {
3345
Conversation: String(conversationId),
34-
Content: preview,
46+
Attach: hasFiles ? files.join(', ') : undefined,
47+
Content: preview || undefined,
3548
})
3649
return
3750
}
3851

3952
const client = await getCommsClient()
53+
54+
// Preflight the target before uploading so an invalid or forbidden
55+
// conversation fails fast instead of leaving an orphaned upload behind.
56+
if (hasFiles) {
57+
await client.conversations.getConversation(conversationId)
58+
}
59+
60+
const attachments = hasFiles ? await uploadAttachments(files) : undefined
4061
const message = await client.conversationMessages.createMessage({
4162
conversationId,
42-
content: replyContent,
63+
content: messageContent,
64+
attachments,
4365
})
4466

4567
if (options.json) {
@@ -48,4 +70,8 @@ export async function replyToConversation(
4870
}
4971

5072
console.log(`Message sent: ${message.url}`)
73+
if (attachments && attachments.length > 0) {
74+
const names = attachments.map((a) => a.fileName ?? 'file').join(', ')
75+
console.log(chalk.dim(`Attached: ${names}`))
76+
}
5177
}

0 commit comments

Comments
 (0)