Skip to content

Commit 2e82d30

Browse files
feat: require --yes on msg/comment delete and thread/conversation done (#9)
* feat: require --yes for destructive msg/comment delete and thread/conversation done Ports Doist/twist-cli#190. Extends the existing `tdc thread delete` `--yes` confirmation pattern to the remaining destructive/state-changing commands: `tdc msg delete`, `tdc comment delete`, `tdc thread done`, and `tdc conversation done`. Without `--yes`, each command prints a preview and exits without mutating. With `--json` and no `--yes`, it rejects with `MISSING_YES_FLAG`. `--dry-run` still short-circuits before the `--yes` gate. Motivation: agents replay/retry commands more freely than humans; idempotent archive operations produce no error signal on repeat, and deletes can silently run twice. Requiring `--yes` makes mutation intent explicit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: address review feedback on --yes gate - Move `yes?: boolean` into the shared `MutationOptions` type so each destructive command no longer redefines it locally. - Reorder `conversation done` to fetch the conversation before the gate so invalid refs surface a proper error instead of `MISSING_YES_FLAG`. - Move the `comment delete` and `msg delete` gates to the very top (before any remote calls) so previews don't trigger fetches or validation API calls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5070dff commit 2e82d30

17 files changed

Lines changed: 238 additions & 49 deletions

File tree

docs/SPEC.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ Arguments:
204204

205205
Options:
206206

207+
- `--yes` - Confirm archive (required to execute)
207208
- `--dry-run` - Show what would happen without executing
208209

209210
---
@@ -275,6 +276,7 @@ Arguments:
275276

276277
Options:
277278

279+
- `--yes` - Confirm archive (required to execute)
278280
- `--dry-run` - Show what would happen without executing
279281

280282
---
@@ -321,6 +323,7 @@ Arguments:
321323

322324
Options:
323325

326+
- `--yes` - Confirm deletion (required to execute)
324327
- `--dry-run` - Show what would happen without executing
325328

326329
---
@@ -498,7 +501,7 @@ echo "Multiline\nreply" | tdc thread reply id:123456
498501
tdc thread reply id:123456 # opens $EDITOR
499502

500503
# Mark thread as done
501-
tdc thread done id:123456
504+
tdc thread done id:123456 --yes
502505

503506
# List unread conversations
504507
tdc conversation unread

skills/comms-cli/SKILL.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,9 @@ tdc thread reply <ref> "content" --json # Post and return comment as JSON
9898
tdc thread reply <ref> "content" --json --full # Include all comment fields
9999
tdc thread reply <ref> "content" --close # Reply and close the thread
100100
tdc thread reply <ref> "content" --reopen # Reply and reopen a closed thread
101-
tdc thread done <ref> # Archive thread (mark done)
102-
tdc thread done <ref> --json # Archive and return status as JSON
101+
tdc thread done <ref> # Preview thread archive (requires --yes to execute)
102+
tdc thread done <ref> --yes # Archive thread (mark done)
103+
tdc thread done <ref> --yes --json # Archive and return status as JSON
103104
tdc thread mute <ref> # Mute thread for 60 minutes (default)
104105
tdc thread mute <ref> --minutes 480 # Mute for custom duration
105106
tdc thread mute <ref> --json # Mute and return { id, mutedUntil } as JSON
@@ -135,8 +136,9 @@ tdc comment view <comment-ref> --json --full # Include all fields in JSON out
135136
tdc comment update <comment-ref> "new content" # Update a thread comment
136137
tdc comment update <comment-ref> "content" --json # Update and return updated comment as JSON
137138
tdc comment update <comment-ref> "content" --json --full # Include all comment fields
138-
tdc comment delete <comment-ref> # Delete a thread comment
139-
tdc comment delete <comment-ref> --json # Delete and return status as JSON
139+
tdc comment delete <comment-ref> # Preview comment deletion (requires --yes to execute)
140+
tdc comment delete <comment-ref> --yes # Delete a thread comment
141+
tdc comment delete <comment-ref> --yes --json # Delete and return status as JSON
140142
```
141143

142144
## Conversations (DMs/Groups)
@@ -151,8 +153,9 @@ tdc conversation with <user-ref> --include-groups # List any conversations with
151153
tdc conversation reply <ref> "content" # Send a message
152154
tdc conversation reply <ref> "content" --json # Send and return message as JSON
153155
tdc conversation reply <ref> "content" --json --full # Include all message fields
154-
tdc conversation done <ref> # Archive conversation
155-
tdc conversation done <ref> --json # Archive and return status as JSON
156+
tdc conversation done <ref> # Preview conversation archive (requires --yes to execute)
157+
tdc conversation done <ref> --yes # Archive conversation
158+
tdc conversation done <ref> --yes --json # Archive and return status as JSON
156159
tdc conversation mute <ref> # Mute conversation for 60 minutes (default)
157160
tdc conversation mute <ref> --minutes 480 # Mute for custom duration
158161
tdc conversation mute <ref> --json # Mute and return { id, mutedUntil } as JSON
@@ -171,8 +174,9 @@ tdc msg view <message-ref> # View a single conversation message
171174
tdc msg update <ref> "content" # Edit a conversation message
172175
tdc msg update <ref> "content" --json # Edit and return updated message as JSON
173176
tdc msg update <ref> "content" --json --full # Include all message fields
174-
tdc msg delete <ref> # Delete a conversation message
175-
tdc msg delete <ref> --json # Delete and return status as JSON
177+
tdc msg delete <ref> # Preview message deletion (requires --yes to execute)
178+
tdc msg delete <ref> --yes # Delete a conversation message
179+
tdc msg delete <ref> --yes --json # Delete and return status as JSON
176180
```
177181

178182
Alias: `tdc message` works the same as `tdc msg`.
@@ -395,7 +399,7 @@ tdc view https://comms.todoist.com/a/1585/msg/400/m/500 --json # View message
395399
tdc inbox --unread --json
396400
tdc thread view <id> --unread
397401
tdc thread reply <id> "Thanks, I'll look into this."
398-
tdc thread done <id>
402+
tdc thread done <id> --yes
399403
```
400404

401405
**Search and review:**

src/commands/comment/comment.test.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,18 +220,31 @@ describe('comment delete', () => {
220220
vi.clearAllMocks()
221221
})
222222

223-
it('deletes a comment', async () => {
223+
it('deletes a comment with --yes', async () => {
224224
const client = createClient()
225225
apiMocks.getCommsClient.mockResolvedValue(client)
226226
const program = createProgram()
227227
const consoleSpy = captureConsole('log')
228228

229-
await program.parseAsync(['node', 'tdc', 'comment', 'delete', '300'])
229+
await program.parseAsync(['node', 'tdc', 'comment', 'delete', '300', '--yes'])
230230

231231
expect(client.comments.deleteComment).toHaveBeenCalledWith('300')
232232
expect(consoleSpy).toHaveBeenCalledWith('Comment 300 deleted.')
233233
})
234234

235+
it('prompts for confirmation without --yes', async () => {
236+
const client = createClient()
237+
apiMocks.getCommsClient.mockResolvedValue(client)
238+
const program = createProgram()
239+
const consoleSpy = captureConsole('log')
240+
241+
await program.parseAsync(['node', 'tdc', 'comment', 'delete', '300'])
242+
243+
expect(consoleSpy).toHaveBeenCalledWith('Would delete comment 300')
244+
expect(consoleSpy).toHaveBeenCalledWith('Use --yes to confirm.')
245+
expect(client.comments.deleteComment).not.toHaveBeenCalled()
246+
})
247+
235248
it('shows dry run output', async () => {
236249
const client = createClient()
237250
apiMocks.getCommsClient.mockResolvedValue(client)
@@ -271,15 +284,27 @@ describe('comment delete', () => {
271284
expect(client.comments.deleteComment).not.toHaveBeenCalled()
272285
})
273286

274-
it('outputs JSON with --json', async () => {
287+
it('outputs JSON with --json --yes', async () => {
275288
const client = createClient()
276289
apiMocks.getCommsClient.mockResolvedValue(client)
277290
const program = createProgram()
278291
const consoleSpy = captureConsole('log')
279292

280-
await program.parseAsync(['node', 'tdc', 'comment', 'delete', '300', '--json'])
293+
await program.parseAsync(['node', 'tdc', 'comment', 'delete', '300', '--json', '--yes'])
281294

282295
const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0])
283296
expect(jsonOutput).toEqual({ id: '300', deleted: true })
284297
})
298+
299+
it('errors when --json is used without --yes', async () => {
300+
const client = createClient()
301+
apiMocks.getCommsClient.mockResolvedValue(client)
302+
const program = createProgram()
303+
304+
await expect(
305+
program.parseAsync(['node', 'tdc', 'comment', 'delete', '300', '--json']),
306+
).rejects.toHaveProperty('code', 'MISSING_YES_FLAG')
307+
308+
expect(client.comments.deleteComment).not.toHaveBeenCalled()
309+
})
285310
})

src/commands/comment/delete.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,21 @@ import { formatJson, printDryRun } from '../../lib/output.js'
55
import { assertChannelIsPublic } from '../../lib/public-channels.js'
66
import { resolveCommentId } from '../../lib/refs.js'
77

8-
type DeleteOptions = MutationOptions
9-
10-
export async function deleteComment(ref: string, options: DeleteOptions): Promise<void> {
8+
export async function deleteComment(ref: string, options: MutationOptions): Promise<void> {
119
const commentId = resolveCommentId(ref)
1210

11+
if (!options.yes && !options.dryRun) {
12+
if (options.json) {
13+
throw new CliError(
14+
'MISSING_YES_FLAG',
15+
'--yes is required to execute deletion in --json mode.',
16+
)
17+
}
18+
console.log(`Would delete comment ${commentId}`)
19+
console.log('Use --yes to confirm.')
20+
return
21+
}
22+
1323
const client = await getCommsClient()
1424
const [comment, user] = await Promise.all([
1525
client.comments.getComment(commentId),

src/commands/comment/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,14 @@ Examples:
4949
comment
5050
.command('delete <comment-ref>')
5151
.description('Delete a thread comment')
52+
.option('--yes', 'Confirm deletion')
5253
.option('--dry-run', 'Show what would happen without executing')
5354
.option('--json', 'Output result as JSON')
5455
.addHelpText(
5556
'after',
5657
`
5758
Examples:
58-
tdc comment delete 12345
59+
tdc comment delete 12345 --yes
5960
tdc comment delete 12345 --dry-run`,
6061
)
6162
.action(deleteComment)

src/commands/conversation/conversation.test.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -601,20 +601,62 @@ describe('conversation done', () => {
601601
vi.clearAllMocks()
602602
})
603603

604-
it('archives a conversation', async () => {
604+
it('archives a conversation with --yes', async () => {
605605
const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z')
606606
const client = createClient({ activeConversations: [conversation] })
607607
apiMocks.getCommsClient.mockResolvedValue(client)
608608

609609
const program = createProgram()
610610
const consoleSpy = captureConsole('log')
611611

612-
await program.parseAsync(['node', 'tdc', 'conversation', 'done', '42'])
612+
await program.parseAsync(['node', 'tdc', 'conversation', 'done', '42', '--yes'])
613613

614614
expect(client.conversations.archiveConversation).toHaveBeenCalledWith('42')
615615
expect(consoleSpy).toHaveBeenCalledWith('Conversation 42 archived.')
616616
})
617617

618+
it('prompts for confirmation without --yes', async () => {
619+
const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z')
620+
const client = createClient({ activeConversations: [conversation] })
621+
apiMocks.getCommsClient.mockResolvedValue(client)
622+
623+
const program = createProgram()
624+
const consoleSpy = captureConsole('log')
625+
626+
await program.parseAsync(['node', 'tdc', 'conversation', 'done', '42'])
627+
628+
expect(consoleSpy).toHaveBeenCalledWith('Would archive: conversation 42')
629+
expect(consoleSpy).toHaveBeenCalledWith('Use --yes to confirm.')
630+
expect(client.conversations.archiveConversation).not.toHaveBeenCalled()
631+
})
632+
633+
it('outputs JSON with --json --yes', async () => {
634+
const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z')
635+
const client = createClient({ activeConversations: [conversation] })
636+
apiMocks.getCommsClient.mockResolvedValue(client)
637+
638+
const program = createProgram()
639+
const consoleSpy = captureConsole('log')
640+
641+
await program.parseAsync(['node', 'tdc', 'conversation', 'done', '42', '--json', '--yes'])
642+
643+
const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0])
644+
expect(jsonOutput).toEqual({ id: '42', archived: true })
645+
})
646+
647+
it('errors when --json is used without --yes', async () => {
648+
const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z')
649+
const client = createClient({ activeConversations: [conversation] })
650+
apiMocks.getCommsClient.mockResolvedValue(client)
651+
const program = createProgram()
652+
653+
await expect(
654+
program.parseAsync(['node', 'tdc', 'conversation', 'done', '42', '--json']),
655+
).rejects.toHaveProperty('code', 'MISSING_YES_FLAG')
656+
657+
expect(client.conversations.archiveConversation).not.toHaveBeenCalled()
658+
})
659+
618660
it('shows dry run output', async () => {
619661
const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z')
620662
const client = createClient({ activeConversations: [conversation] })

src/commands/conversation/done.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getCommsClient } from '../../lib/api.js'
2+
import { CliError } from '../../lib/errors.js'
23
import { formatJson, printDryRun } from '../../lib/output.js'
34
import { resolveConversationId } from '../../lib/refs.js'
45
import { conversationLabel, type DoneOptions } from './helpers.js'
@@ -7,16 +8,28 @@ export async function markConversationDone(ref: string, options: DoneOptions): P
78
const conversationId = resolveConversationId(ref)
89

910
const client = await getCommsClient()
11+
const conversation = await client.conversations.getConversation(conversationId)
1012

1113
if (options.dryRun) {
12-
const conversation = await client.conversations.getConversation(conversationId)
1314
printDryRun('archive conversation', {
1415
Conversation: conversationLabel(conversation),
1516
Status: conversation.archived ? 'already archived' : undefined,
1617
})
1718
return
1819
}
1920

21+
if (!options.yes) {
22+
if (options.json) {
23+
throw new CliError(
24+
'MISSING_YES_FLAG',
25+
'--yes is required to execute archive in --json mode.',
26+
)
27+
}
28+
console.log(`Would archive: ${conversationLabel(conversation)}`)
29+
console.log('Use --yes to confirm.')
30+
return
31+
}
32+
2033
await client.conversations.archiveConversation(conversationId)
2134

2235
if (options.json) {

src/commands/conversation/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,14 @@ Examples:
9393
conversation
9494
.command('done <conversation-ref>')
9595
.description('Archive a conversation')
96+
.option('--yes', 'Confirm archive')
9697
.option('--dry-run', 'Show what would happen without executing')
9798
.option('--json', 'Output result as JSON')
9899
.addHelpText(
99100
'after',
100101
`
101102
Examples:
102-
tdc conversation done 12345
103+
tdc conversation done 12345 --yes
103104
tdc conversation done 12345 --dry-run`,
104105
)
105106
.action(markConversationDone)

src/commands/msg/delete.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,21 @@ import type { MutationOptions } from '../../lib/options.js'
44
import { formatJson, printDryRun } from '../../lib/output.js'
55
import { resolveMessageId } from '../../lib/refs.js'
66

7-
type DeleteOptions = MutationOptions
8-
9-
export async function deleteMessage(ref: string, options: DeleteOptions): Promise<void> {
7+
export async function deleteMessage(ref: string, options: MutationOptions): Promise<void> {
108
const messageId = resolveMessageId(ref)
119

10+
if (!options.yes && !options.dryRun) {
11+
if (options.json) {
12+
throw new CliError(
13+
'MISSING_YES_FLAG',
14+
'--yes is required to execute deletion in --json mode.',
15+
)
16+
}
17+
console.log(`Would delete message ${messageId}`)
18+
console.log('Use --yes to confirm.')
19+
return
20+
}
21+
1222
const client = await getCommsClient()
1323
const [message, user] = await Promise.all([
1424
client.conversationMessages.getMessage(messageId),

src/commands/msg/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,14 @@ Examples:
4747

4848
msg.command('delete <message-ref>')
4949
.description('Delete a conversation message')
50+
.option('--yes', 'Confirm deletion')
5051
.option('--dry-run', 'Show what would happen without executing')
5152
.option('--json', 'Output result as JSON')
5253
.addHelpText(
5354
'after',
5455
`
5556
Examples:
56-
tdc msg delete 12345
57+
tdc msg delete 12345 --yes
5758
tdc msg delete 12345 --dry-run`,
5859
)
5960
.action(deleteMessage)

0 commit comments

Comments
 (0)