From 7e79ada71fe08c2e8457d9300a4c9b67a813e49c Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Thu, 28 May 2026 10:53:16 +0100 Subject: [PATCH 1/2] 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) --- docs/SPEC.md | 5 +- skills/comms-cli/SKILL.md | 22 +++++---- src/commands/comment/comment.test.ts | 33 +++++++++++-- src/commands/comment/delete.ts | 14 +++++- src/commands/comment/index.ts | 3 +- .../conversation/conversation.test.ts | 46 ++++++++++++++++++- src/commands/conversation/done.ts | 14 ++++++ src/commands/conversation/helpers.ts | 2 +- src/commands/conversation/index.ts | 3 +- src/commands/msg/delete.ts | 14 +++++- src/commands/msg/index.ts | 3 +- src/commands/msg/msg.test.ts | 33 +++++++++++-- src/commands/thread/index.ts | 5 +- src/commands/thread/mutate.ts | 15 +++++- src/commands/thread/thread.test.ts | 43 ++++++++++++++++- src/lib/skills/content.ts | 22 +++++---- 16 files changed, 237 insertions(+), 40 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 72aae9e..f76f009 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -204,6 +204,7 @@ Arguments: Options: +- `--yes` - Confirm archive (required to execute) - `--dry-run` - Show what would happen without executing --- @@ -275,6 +276,7 @@ Arguments: Options: +- `--yes` - Confirm archive (required to execute) - `--dry-run` - Show what would happen without executing --- @@ -321,6 +323,7 @@ Arguments: Options: +- `--yes` - Confirm deletion (required to execute) - `--dry-run` - Show what would happen without executing --- @@ -498,7 +501,7 @@ echo "Multiline\nreply" | tdc thread reply id:123456 tdc thread reply id:123456 # opens $EDITOR # Mark thread as done -tdc thread done id:123456 +tdc thread done id:123456 --yes # List unread conversations tdc conversation unread diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index fc76c01..c93c688 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -98,8 +98,9 @@ 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 done # Archive thread (mark done) -tdc thread done --json # Archive and return status as JSON +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 tdc thread mute # Mute thread for 60 minutes (default) tdc thread mute --minutes 480 # Mute for custom duration tdc thread mute --json # Mute and return { id, mutedUntil } as JSON @@ -135,8 +136,9 @@ tdc comment view --json --full # Include all fields in JSON out tdc comment update "new content" # Update a thread comment tdc comment update "content" --json # Update and return updated comment as JSON tdc comment update "content" --json --full # Include all comment fields -tdc comment delete # Delete a thread comment -tdc comment delete --json # Delete and return status as JSON +tdc comment delete # Preview comment deletion (requires --yes to execute) +tdc comment delete --yes # Delete a thread comment +tdc comment delete --yes --json # Delete and return status as JSON ``` ## Conversations (DMs/Groups) @@ -151,8 +153,9 @@ 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 done # Archive conversation -tdc conversation done --json # Archive and return status as JSON +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 tdc conversation mute # Mute conversation for 60 minutes (default) tdc conversation mute --minutes 480 # Mute for custom duration tdc conversation mute --json # Mute and return { id, mutedUntil } as JSON @@ -171,8 +174,9 @@ tdc msg view # View a single conversation message tdc msg update "content" # Edit a conversation message tdc msg update "content" --json # Edit and return updated message as JSON tdc msg update "content" --json --full # Include all message fields -tdc msg delete # Delete a conversation message -tdc msg delete --json # Delete and return status as JSON +tdc msg delete # Preview message deletion (requires --yes to execute) +tdc msg delete --yes # Delete a conversation message +tdc msg delete --yes --json # Delete and return status as JSON ``` 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 tdc inbox --unread --json tdc thread view --unread tdc thread reply "Thanks, I'll look into this." -tdc thread done +tdc thread done --yes ``` **Search and review:** diff --git a/src/commands/comment/comment.test.ts b/src/commands/comment/comment.test.ts index 91c2dbb..fa9abb5 100644 --- a/src/commands/comment/comment.test.ts +++ b/src/commands/comment/comment.test.ts @@ -220,18 +220,31 @@ describe('comment delete', () => { vi.clearAllMocks() }) - it('deletes a comment', async () => { + it('deletes a comment with --yes', async () => { const client = createClient() apiMocks.getCommsClient.mockResolvedValue(client) const program = createProgram() const consoleSpy = captureConsole('log') - await program.parseAsync(['node', 'tdc', 'comment', 'delete', '300']) + await program.parseAsync(['node', 'tdc', 'comment', 'delete', '300', '--yes']) expect(client.comments.deleteComment).toHaveBeenCalledWith('300') expect(consoleSpy).toHaveBeenCalledWith('Comment 300 deleted.') }) + it('prompts for confirmation without --yes', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'comment', 'delete', '300']) + + expect(consoleSpy).toHaveBeenCalledWith('Would delete comment 300') + expect(consoleSpy).toHaveBeenCalledWith('Use --yes to confirm.') + expect(client.comments.deleteComment).not.toHaveBeenCalled() + }) + it('shows dry run output', async () => { const client = createClient() apiMocks.getCommsClient.mockResolvedValue(client) @@ -271,15 +284,27 @@ describe('comment delete', () => { expect(client.comments.deleteComment).not.toHaveBeenCalled() }) - it('outputs JSON with --json', async () => { + it('outputs JSON with --json --yes', async () => { const client = createClient() apiMocks.getCommsClient.mockResolvedValue(client) const program = createProgram() const consoleSpy = captureConsole('log') - await program.parseAsync(['node', 'tdc', 'comment', 'delete', '300', '--json']) + await program.parseAsync(['node', 'tdc', 'comment', 'delete', '300', '--json', '--yes']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput).toEqual({ id: '300', deleted: true }) }) + + it('errors when --json is used without --yes', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'comment', 'delete', '300', '--json']), + ).rejects.toHaveProperty('code', 'MISSING_YES_FLAG') + + expect(client.comments.deleteComment).not.toHaveBeenCalled() + }) }) diff --git a/src/commands/comment/delete.ts b/src/commands/comment/delete.ts index 76918e8..25252eb 100644 --- a/src/commands/comment/delete.ts +++ b/src/commands/comment/delete.ts @@ -5,7 +5,7 @@ import { formatJson, printDryRun } from '../../lib/output.js' import { assertChannelIsPublic } from '../../lib/public-channels.js' import { resolveCommentId } from '../../lib/refs.js' -type DeleteOptions = MutationOptions +type DeleteOptions = MutationOptions & { yes?: boolean } export async function deleteComment(ref: string, options: DeleteOptions): Promise { const commentId = resolveCommentId(ref) @@ -33,6 +33,18 @@ export async function deleteComment(ref: string, options: DeleteOptions): Promis return } + if (!options.yes) { + if (options.json) { + throw new CliError( + 'MISSING_YES_FLAG', + '--yes is required to execute deletion in --json mode.', + ) + } + console.log(`Would delete comment ${commentId}`) + console.log('Use --yes to confirm.') + return + } + await client.comments.deleteComment(commentId) if (options.json) { diff --git a/src/commands/comment/index.ts b/src/commands/comment/index.ts index b4a053a..18b757c 100644 --- a/src/commands/comment/index.ts +++ b/src/commands/comment/index.ts @@ -49,13 +49,14 @@ Examples: comment .command('delete ') .description('Delete a thread comment') + .option('--yes', 'Confirm deletion') .option('--dry-run', 'Show what would happen without executing') .option('--json', 'Output result as JSON') .addHelpText( 'after', ` Examples: - tdc comment delete 12345 + tdc comment delete 12345 --yes tdc comment delete 12345 --dry-run`, ) .action(deleteComment) diff --git a/src/commands/conversation/conversation.test.ts b/src/commands/conversation/conversation.test.ts index 1217cb0..7122d9b 100644 --- a/src/commands/conversation/conversation.test.ts +++ b/src/commands/conversation/conversation.test.ts @@ -601,7 +601,7 @@ describe('conversation done', () => { vi.clearAllMocks() }) - it('archives a conversation', async () => { + it('archives a conversation with --yes', async () => { const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z') const client = createClient({ activeConversations: [conversation] }) apiMocks.getCommsClient.mockResolvedValue(client) @@ -609,12 +609,54 @@ describe('conversation done', () => { const program = createProgram() const consoleSpy = captureConsole('log') - await program.parseAsync(['node', 'tdc', 'conversation', 'done', '42']) + await program.parseAsync(['node', 'tdc', 'conversation', 'done', '42', '--yes']) expect(client.conversations.archiveConversation).toHaveBeenCalledWith('42') expect(consoleSpy).toHaveBeenCalledWith('Conversation 42 archived.') }) + it('prompts for confirmation without --yes', async () => { + const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z') + const client = createClient({ activeConversations: [conversation] }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'conversation', 'done', '42']) + + expect(consoleSpy).toHaveBeenCalledWith('Would archive: conversation 42') + expect(consoleSpy).toHaveBeenCalledWith('Use --yes to confirm.') + expect(client.conversations.archiveConversation).not.toHaveBeenCalled() + }) + + it('outputs JSON with --json --yes', async () => { + const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z') + const client = createClient({ activeConversations: [conversation] }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'conversation', 'done', '42', '--json', '--yes']) + + const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(jsonOutput).toEqual({ id: '42', archived: true }) + }) + + it('errors when --json is used without --yes', async () => { + const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z') + const client = createClient({ activeConversations: [conversation] }) + apiMocks.getCommsClient.mockResolvedValue(client) + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'conversation', 'done', '42', '--json']), + ).rejects.toHaveProperty('code', 'MISSING_YES_FLAG') + + expect(client.conversations.archiveConversation).not.toHaveBeenCalled() + }) + it('shows dry run output', async () => { const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z') const client = createClient({ activeConversations: [conversation] }) diff --git a/src/commands/conversation/done.ts b/src/commands/conversation/done.ts index af1cd08..d3eca39 100644 --- a/src/commands/conversation/done.ts +++ b/src/commands/conversation/done.ts @@ -1,4 +1,5 @@ import { getCommsClient } from '../../lib/api.js' +import { CliError } from '../../lib/errors.js' import { formatJson, printDryRun } from '../../lib/output.js' import { resolveConversationId } from '../../lib/refs.js' import { conversationLabel, type DoneOptions } from './helpers.js' @@ -17,6 +18,19 @@ export async function markConversationDone(ref: string, options: DoneOptions): P return } + if (!options.yes) { + if (options.json) { + throw new CliError( + 'MISSING_YES_FLAG', + '--yes is required to execute archive in --json mode.', + ) + } + const conversation = await client.conversations.getConversation(conversationId) + console.log(`Would archive: ${conversationLabel(conversation)}`) + console.log('Use --yes to confirm.') + return + } + await client.conversations.archiveConversation(conversationId) if (options.json) { diff --git a/src/commands/conversation/helpers.ts b/src/commands/conversation/helpers.ts index f050fdb..0fb59e5 100644 --- a/src/commands/conversation/helpers.ts +++ b/src/commands/conversation/helpers.ts @@ -22,7 +22,7 @@ export type ReplyOptions = MutationOptions export type MuteOptions = MutationOptions & { minutes?: string } -export type DoneOptions = MutationOptions +export type DoneOptions = MutationOptions & { yes?: boolean } export type ConversationLookupResult = { directConversation?: Conversation diff --git a/src/commands/conversation/index.ts b/src/commands/conversation/index.ts index 3dec8e3..48805f6 100644 --- a/src/commands/conversation/index.ts +++ b/src/commands/conversation/index.ts @@ -93,13 +93,14 @@ Examples: conversation .command('done ') .description('Archive a conversation') + .option('--yes', 'Confirm archive') .option('--dry-run', 'Show what would happen without executing') .option('--json', 'Output result as JSON') .addHelpText( 'after', ` Examples: - tdc conversation done 12345 + tdc conversation done 12345 --yes tdc conversation done 12345 --dry-run`, ) .action(markConversationDone) diff --git a/src/commands/msg/delete.ts b/src/commands/msg/delete.ts index ec20b8f..b8710e3 100644 --- a/src/commands/msg/delete.ts +++ b/src/commands/msg/delete.ts @@ -4,7 +4,7 @@ import type { MutationOptions } from '../../lib/options.js' import { formatJson, printDryRun } from '../../lib/output.js' import { resolveMessageId } from '../../lib/refs.js' -type DeleteOptions = MutationOptions +type DeleteOptions = MutationOptions & { yes?: boolean } export async function deleteMessage(ref: string, options: DeleteOptions): Promise { const messageId = resolveMessageId(ref) @@ -30,6 +30,18 @@ export async function deleteMessage(ref: string, options: DeleteOptions): Promis return } + if (!options.yes) { + if (options.json) { + throw new CliError( + 'MISSING_YES_FLAG', + '--yes is required to execute deletion in --json mode.', + ) + } + console.log(`Would delete message ${messageId}`) + console.log('Use --yes to confirm.') + return + } + await client.conversationMessages.deleteMessage(messageId) if (options.json) { diff --git a/src/commands/msg/index.ts b/src/commands/msg/index.ts index 9258c80..a334b11 100644 --- a/src/commands/msg/index.ts +++ b/src/commands/msg/index.ts @@ -47,13 +47,14 @@ Examples: msg.command('delete ') .description('Delete a conversation message') + .option('--yes', 'Confirm deletion') .option('--dry-run', 'Show what would happen without executing') .option('--json', 'Output result as JSON') .addHelpText( 'after', ` Examples: - tdc msg delete 12345 + tdc msg delete 12345 --yes tdc msg delete 12345 --dry-run`, ) .action(deleteMessage) diff --git a/src/commands/msg/msg.test.ts b/src/commands/msg/msg.test.ts index 2133379..6368983 100644 --- a/src/commands/msg/msg.test.ts +++ b/src/commands/msg/msg.test.ts @@ -81,18 +81,31 @@ describe('msg delete', () => { vi.clearAllMocks() }) - it('deletes a message', async () => { + it('deletes a message with --yes', async () => { const client = createClient() apiMocks.getCommsClient.mockResolvedValue(client) const program = createProgram() const consoleSpy = captureConsole('log') - await program.parseAsync(['node', 'tdc', 'msg', 'delete', '200']) + await program.parseAsync(['node', 'tdc', 'msg', 'delete', '200', '--yes']) expect(client.conversationMessages.deleteMessage).toHaveBeenCalledWith('200') expect(consoleSpy).toHaveBeenCalledWith('Message 200 deleted.') }) + it('prompts for confirmation without --yes', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'msg', 'delete', '200']) + + expect(consoleSpy).toHaveBeenCalledWith('Would delete message 200') + expect(consoleSpy).toHaveBeenCalledWith('Use --yes to confirm.') + expect(client.conversationMessages.deleteMessage).not.toHaveBeenCalled() + }) + it('shows dry run output', async () => { const client = createClient() apiMocks.getCommsClient.mockResolvedValue(client) @@ -118,15 +131,27 @@ describe('msg delete', () => { expect(client.conversationMessages.deleteMessage).not.toHaveBeenCalled() }) - it('outputs JSON with --json', async () => { + it('outputs JSON with --json --yes', async () => { const client = createClient() apiMocks.getCommsClient.mockResolvedValue(client) const program = createProgram() const consoleSpy = captureConsole('log') - await program.parseAsync(['node', 'tdc', 'msg', 'delete', '200', '--json']) + await program.parseAsync(['node', 'tdc', 'msg', 'delete', '200', '--json', '--yes']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput).toEqual({ id: '200', deleted: true }) }) + + it('errors when --json is used without --yes', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'msg', 'delete', '200', '--json']), + ).rejects.toHaveProperty('code', 'MISSING_YES_FLAG') + + expect(client.conversationMessages.deleteMessage).not.toHaveBeenCalled() + }) }) diff --git a/src/commands/thread/index.ts b/src/commands/thread/index.ts index 86f956e..16b1372 100644 --- a/src/commands/thread/index.ts +++ b/src/commands/thread/index.ts @@ -94,15 +94,16 @@ Examples: thread .command('done ') .description('Archive a thread (mark as done)') + .option('--yes', 'Confirm archive') .option('--dry-run', 'Show what would happen without executing') .option('--json', 'Output result as JSON') .addHelpText( 'after', ` Examples: - tdc thread done 12345 + tdc thread done 12345 --yes tdc thread done 12345 --dry-run - tdc thread done 12345 --json`, + tdc thread done 12345 --json --yes`, ) .action(markThreadDone) diff --git a/src/commands/thread/mutate.ts b/src/commands/thread/mutate.ts index be8b3cd..f5322d2 100644 --- a/src/commands/thread/mutate.ts +++ b/src/commands/thread/mutate.ts @@ -1,10 +1,11 @@ import { getCommsClient } from '../../lib/api.js' +import { CliError } from '../../lib/errors.js' import type { MutationOptions } from '../../lib/options.js' import { formatJson, printDryRun } from '../../lib/output.js' import { assertChannelIsPublic } from '../../lib/public-channels.js' import { resolveThreadId } from '../../lib/refs.js' -type DoneOptions = MutationOptions +type DoneOptions = MutationOptions & { yes?: boolean } export async function markThreadDone(ref: string, options: DoneOptions): Promise { const threadId = resolveThreadId(ref) @@ -20,6 +21,18 @@ export async function markThreadDone(ref: string, options: DoneOptions): Promise return } + if (!options.yes) { + if (options.json) { + throw new CliError( + 'MISSING_YES_FLAG', + '--yes is required to execute archive in --json mode.', + ) + } + console.log(`Would archive: ${thread.title}`) + console.log('Use --yes to confirm.') + return + } + await client.inbox.archiveThread(threadId) if (options.json) { diff --git a/src/commands/thread/thread.test.ts b/src/commands/thread/thread.test.ts index 268e079..4795f01 100644 --- a/src/commands/thread/thread.test.ts +++ b/src/commands/thread/thread.test.ts @@ -1226,19 +1226,58 @@ describe('thread done', () => { vi.clearAllMocks() }) - it('archives a thread', async () => { + it('archives a thread with --yes', async () => { const client = createClient() apiMocks.getCommsClient.mockResolvedValue(client) const program = createProgram() const consoleSpy = captureConsole('log') - await program.parseAsync(['node', 'tdc', 'thread', 'done', '500']) + await program.parseAsync(['node', 'tdc', 'thread', 'done', '500', '--yes']) expect(client.inbox.archiveThread).toHaveBeenCalledWith('500') expect(consoleSpy).toHaveBeenCalledWith('Thread 500 archived.') }) + it('prompts for confirmation without --yes', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'done', '500']) + + expect(consoleSpy).toHaveBeenCalledWith('Would archive: Test Thread') + expect(consoleSpy).toHaveBeenCalledWith('Use --yes to confirm.') + expect(client.inbox.archiveThread).not.toHaveBeenCalled() + }) + + it('outputs JSON with --json --yes', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'done', '500', '--json', '--yes']) + + const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(jsonOutput).toEqual({ id: '500', isArchived: true }) + }) + + it('errors when --json is used without --yes', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'thread', 'done', '500', '--json']), + ).rejects.toHaveProperty('code', 'MISSING_YES_FLAG') + + expect(client.inbox.archiveThread).not.toHaveBeenCalled() + }) + it('shows dry run output', async () => { const client = createClient() apiMocks.getCommsClient.mockResolvedValue(client) diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 11cf155..ee05343 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -102,8 +102,9 @@ 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 done # Archive thread (mark done) -tdc thread done --json # Archive and return status as JSON +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 tdc thread mute # Mute thread for 60 minutes (default) tdc thread mute --minutes 480 # Mute for custom duration tdc thread mute --json # Mute and return { id, mutedUntil } as JSON @@ -139,8 +140,9 @@ tdc comment view --json --full # Include all fields in JSON out tdc comment update "new content" # Update a thread comment tdc comment update "content" --json # Update and return updated comment as JSON tdc comment update "content" --json --full # Include all comment fields -tdc comment delete # Delete a thread comment -tdc comment delete --json # Delete and return status as JSON +tdc comment delete # Preview comment deletion (requires --yes to execute) +tdc comment delete --yes # Delete a thread comment +tdc comment delete --yes --json # Delete and return status as JSON \`\`\` ## Conversations (DMs/Groups) @@ -155,8 +157,9 @@ 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 done # Archive conversation -tdc conversation done --json # Archive and return status as JSON +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 tdc conversation mute # Mute conversation for 60 minutes (default) tdc conversation mute --minutes 480 # Mute for custom duration tdc conversation mute --json # Mute and return { id, mutedUntil } as JSON @@ -175,8 +178,9 @@ tdc msg view # View a single conversation message tdc msg update "content" # Edit a conversation message tdc msg update "content" --json # Edit and return updated message as JSON tdc msg update "content" --json --full # Include all message fields -tdc msg delete # Delete a conversation message -tdc msg delete --json # Delete and return status as JSON +tdc msg delete # Preview message deletion (requires --yes to execute) +tdc msg delete --yes # Delete a conversation message +tdc msg delete --yes --json # Delete and return status as JSON \`\`\` Alias: \`tdc message\` works the same as \`tdc msg\`. @@ -399,7 +403,7 @@ tdc view https://comms.todoist.com/a/1585/msg/400/m/500 --json # View message tdc inbox --unread --json tdc thread view --unread tdc thread reply "Thanks, I'll look into this." -tdc thread done +tdc thread done --yes \`\`\` **Search and review:** From 0fa0abb9b58d4e26e7fa65681e17a1b17f188031 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Thu, 28 May 2026 11:07:35 +0100 Subject: [PATCH 2/2] 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) --- src/commands/comment/delete.ts | 28 +++++++++++++--------------- src/commands/conversation/done.ts | 3 +-- src/commands/conversation/helpers.ts | 2 +- src/commands/msg/delete.ts | 28 +++++++++++++--------------- src/commands/thread/delete.ts | 4 +--- src/commands/thread/mutate.ts | 4 +--- src/lib/options.ts | 1 + 7 files changed, 31 insertions(+), 39 deletions(-) diff --git a/src/commands/comment/delete.ts b/src/commands/comment/delete.ts index 25252eb..2eddf55 100644 --- a/src/commands/comment/delete.ts +++ b/src/commands/comment/delete.ts @@ -5,11 +5,21 @@ import { formatJson, printDryRun } from '../../lib/output.js' import { assertChannelIsPublic } from '../../lib/public-channels.js' import { resolveCommentId } from '../../lib/refs.js' -type DeleteOptions = MutationOptions & { yes?: boolean } - -export async function deleteComment(ref: string, options: DeleteOptions): Promise { +export async function deleteComment(ref: string, options: MutationOptions): Promise { const commentId = resolveCommentId(ref) + if (!options.yes && !options.dryRun) { + if (options.json) { + throw new CliError( + 'MISSING_YES_FLAG', + '--yes is required to execute deletion in --json mode.', + ) + } + console.log(`Would delete comment ${commentId}`) + console.log('Use --yes to confirm.') + return + } + const client = await getCommsClient() const [comment, user] = await Promise.all([ client.comments.getComment(commentId), @@ -33,18 +43,6 @@ export async function deleteComment(ref: string, options: DeleteOptions): Promis return } - if (!options.yes) { - if (options.json) { - throw new CliError( - 'MISSING_YES_FLAG', - '--yes is required to execute deletion in --json mode.', - ) - } - console.log(`Would delete comment ${commentId}`) - console.log('Use --yes to confirm.') - return - } - await client.comments.deleteComment(commentId) if (options.json) { diff --git a/src/commands/conversation/done.ts b/src/commands/conversation/done.ts index d3eca39..648bafd 100644 --- a/src/commands/conversation/done.ts +++ b/src/commands/conversation/done.ts @@ -8,9 +8,9 @@ export async function markConversationDone(ref: string, options: DoneOptions): P const conversationId = resolveConversationId(ref) const client = await getCommsClient() + const conversation = await client.conversations.getConversation(conversationId) if (options.dryRun) { - const conversation = await client.conversations.getConversation(conversationId) printDryRun('archive conversation', { Conversation: conversationLabel(conversation), Status: conversation.archived ? 'already archived' : undefined, @@ -25,7 +25,6 @@ export async function markConversationDone(ref: string, options: DoneOptions): P '--yes is required to execute archive in --json mode.', ) } - const conversation = await client.conversations.getConversation(conversationId) console.log(`Would archive: ${conversationLabel(conversation)}`) console.log('Use --yes to confirm.') return diff --git a/src/commands/conversation/helpers.ts b/src/commands/conversation/helpers.ts index 0fb59e5..f050fdb 100644 --- a/src/commands/conversation/helpers.ts +++ b/src/commands/conversation/helpers.ts @@ -22,7 +22,7 @@ export type ReplyOptions = MutationOptions export type MuteOptions = MutationOptions & { minutes?: string } -export type DoneOptions = MutationOptions & { yes?: boolean } +export type DoneOptions = MutationOptions export type ConversationLookupResult = { directConversation?: Conversation diff --git a/src/commands/msg/delete.ts b/src/commands/msg/delete.ts index b8710e3..3f35ac7 100644 --- a/src/commands/msg/delete.ts +++ b/src/commands/msg/delete.ts @@ -4,11 +4,21 @@ import type { MutationOptions } from '../../lib/options.js' import { formatJson, printDryRun } from '../../lib/output.js' import { resolveMessageId } from '../../lib/refs.js' -type DeleteOptions = MutationOptions & { yes?: boolean } - -export async function deleteMessage(ref: string, options: DeleteOptions): Promise { +export async function deleteMessage(ref: string, options: MutationOptions): Promise { const messageId = resolveMessageId(ref) + if (!options.yes && !options.dryRun) { + if (options.json) { + throw new CliError( + 'MISSING_YES_FLAG', + '--yes is required to execute deletion in --json mode.', + ) + } + console.log(`Would delete message ${messageId}`) + console.log('Use --yes to confirm.') + return + } + const client = await getCommsClient() const [message, user] = await Promise.all([ client.conversationMessages.getMessage(messageId), @@ -30,18 +40,6 @@ export async function deleteMessage(ref: string, options: DeleteOptions): Promis return } - if (!options.yes) { - if (options.json) { - throw new CliError( - 'MISSING_YES_FLAG', - '--yes is required to execute deletion in --json mode.', - ) - } - console.log(`Would delete message ${messageId}`) - console.log('Use --yes to confirm.') - return - } - await client.conversationMessages.deleteMessage(messageId) if (options.json) { diff --git a/src/commands/thread/delete.ts b/src/commands/thread/delete.ts index 763ecb1..3a6d406 100644 --- a/src/commands/thread/delete.ts +++ b/src/commands/thread/delete.ts @@ -5,9 +5,7 @@ import { formatJson, printDryRun } from '../../lib/output.js' import { assertChannelIsPublic } from '../../lib/public-channels.js' import { resolveThreadId } from '../../lib/refs.js' -type DeleteOptions = MutationOptions & { yes?: boolean } - -export async function deleteThread(ref: string, options: DeleteOptions): Promise { +export async function deleteThread(ref: string, options: MutationOptions): Promise { const threadId = resolveThreadId(ref) const client = await getCommsClient() diff --git a/src/commands/thread/mutate.ts b/src/commands/thread/mutate.ts index f5322d2..3ef90e8 100644 --- a/src/commands/thread/mutate.ts +++ b/src/commands/thread/mutate.ts @@ -5,9 +5,7 @@ import { formatJson, printDryRun } from '../../lib/output.js' import { assertChannelIsPublic } from '../../lib/public-channels.js' import { resolveThreadId } from '../../lib/refs.js' -type DoneOptions = MutationOptions & { yes?: boolean } - -export async function markThreadDone(ref: string, options: DoneOptions): Promise { +export async function markThreadDone(ref: string, options: MutationOptions): Promise { const threadId = resolveThreadId(ref) const client = await getCommsClient() diff --git a/src/lib/options.ts b/src/lib/options.ts index 7bc129f..470d268 100644 --- a/src/lib/options.ts +++ b/src/lib/options.ts @@ -15,4 +15,5 @@ export type MutationOptions = { dryRun?: boolean json?: boolean full?: boolean + yes?: boolean }