Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ Arguments:

Options:

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

---
Expand Down Expand Up @@ -276,6 +277,7 @@ Arguments:

Options:

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

---
Expand Down Expand Up @@ -322,6 +324,7 @@ Arguments:

Options:

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

---
Expand Down Expand Up @@ -499,7 +502,7 @@ echo "Multiline\nreply" | tw thread reply id:123456
tw thread reply id:123456 # opens $EDITOR

# Mark thread as done
tw thread done id:123456
tw thread done id:123456 --yes

# List unread conversations
tw conversation unread
Expand Down
22 changes: 13 additions & 9 deletions skills/twist-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ tw thread reply <ref> "content" --json # Post and return comment as JSON
tw thread reply <ref> "content" --json --full # Include all comment fields
tw thread reply <ref> "content" --close # Reply and close the thread
tw thread reply <ref> "content" --reopen # Reply and reopen a closed thread
tw thread done <ref> # Archive thread (mark done)
tw thread done <ref> --json # Archive and return status as JSON
tw thread done <ref> # Preview thread archive (requires --yes to execute)
tw thread done <ref> --yes # Archive thread (mark done)
tw thread done <ref> --yes --json # Archive and return status as JSON
tw thread mute <ref> # Mute thread for 60 minutes (default)
tw thread mute <ref> --minutes 480 # Mute for custom duration
tw thread mute <ref> --json # Mute and return { id, mutedUntil } as JSON
Expand Down Expand Up @@ -119,8 +120,9 @@ tw comment view <comment-ref> --json --full # Include all fields in JSON outp
tw comment update <comment-ref> "new content" # Update a thread comment
tw comment update <comment-ref> "content" --json # Update and return updated comment as JSON
tw comment update <comment-ref> "content" --json --full # Include all comment fields
tw comment delete <comment-ref> # Delete a thread comment
tw comment delete <comment-ref> --json # Delete and return status as JSON
tw comment delete <comment-ref> # Preview comment deletion (requires --yes to execute)
tw comment delete <comment-ref> --yes # Delete a thread comment
tw comment delete <comment-ref> --yes --json # Delete and return status as JSON
```

## Conversations (DMs/Groups)
Expand All @@ -135,8 +137,9 @@ tw conversation with <user-ref> --include-groups # List any conversations with t
tw conversation reply <ref> "content" # Send a message
tw conversation reply <ref> "content" --json # Send and return message as JSON
tw conversation reply <ref> "content" --json --full # Include all message fields
tw conversation done <ref> # Archive conversation
tw conversation done <ref> --json # Archive and return status as JSON
tw conversation done <ref> # Preview conversation archive (requires --yes to execute)
tw conversation done <ref> --yes # Archive conversation
tw conversation done <ref> --yes --json # Archive and return status as JSON
tw conversation mute <ref> # Mute conversation for 60 minutes (default)
tw conversation mute <ref> --minutes 480 # Mute for custom duration
tw conversation mute <ref> --json # Mute and return { id, mutedUntil } as JSON
Expand All @@ -155,8 +158,9 @@ tw msg view <message-ref> # View a single conversation message
tw msg update <ref> "content" # Edit a conversation message
tw msg update <ref> "content" --json # Edit and return updated message as JSON
tw msg update <ref> "content" --json --full # Include all message fields
tw msg delete <ref> # Delete a conversation message
tw msg delete <ref> --json # Delete and return status as JSON
tw msg delete <ref> # Preview message deletion (requires --yes to execute)
tw msg delete <ref> --yes # Delete a conversation message
tw msg delete <ref> --yes --json # Delete and return status as JSON
```

Alias: `tw message` works the same as `tw msg`.
Expand Down Expand Up @@ -321,7 +325,7 @@ tw view https://twist.com/a/1585/msg/400/m/500 --json # View message as JSON
tw inbox --unread --json
tw thread view <id> --unread
tw thread reply <id> "Thanks, I'll look into this."
tw thread done <id>
tw thread done <id> --yes
```

**Search and review:**
Expand Down
34 changes: 30 additions & 4 deletions src/commands/comment/comment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,19 +254,33 @@ describe('comment delete', () => {
vi.clearAllMocks()
})

it('deletes a comment', async () => {
it('deletes a comment with --yes', async () => {
const client = createClient()
apiMocks.getTwistClient.mockResolvedValue(client)
const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await program.parseAsync(['node', 'tw', 'comment', 'delete', '300'])
await program.parseAsync(['node', 'tw', 'comment', 'delete', '300', '--yes'])

expect(client.comments.deleteComment).toHaveBeenCalledWith(300)
expect(consoleSpy).toHaveBeenCalledWith('Comment 300 deleted.')
consoleSpy.mockRestore()
})

it('prompts for confirmation without --yes', async () => {
const client = createClient()
apiMocks.getTwistClient.mockResolvedValue(client)
const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await program.parseAsync(['node', 'tw', 'comment', 'delete', '300'])

expect(consoleSpy).toHaveBeenCalledWith('Would delete comment 300')
expect(consoleSpy).toHaveBeenCalledWith('Use --yes to confirm.')
expect(client.comments.deleteComment).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})

it('shows dry run output', async () => {
const client = createClient()
apiMocks.getTwistClient.mockResolvedValue(client)
Expand Down Expand Up @@ -307,16 +321,28 @@ 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.getTwistClient.mockResolvedValue(client)
const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await program.parseAsync(['node', 'tw', 'comment', 'delete', '300', '--json'])
await program.parseAsync(['node', 'tw', 'comment', 'delete', '300', '--json', '--yes'])

const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0])
expect(jsonOutput).toEqual({ id: 300, deleted: true })
consoleSpy.mockRestore()
})

it('errors when --json is used without --yes', async () => {
const client = createClient()
apiMocks.getTwistClient.mockResolvedValue(client)
const program = createProgram()

await expect(
program.parseAsync(['node', 'tw', 'comment', 'delete', '300', '--json']),
).rejects.toHaveProperty('code', 'MISSING_YES_FLAG')

expect(client.comments.deleteComment).not.toHaveBeenCalled()
})
})
14 changes: 13 additions & 1 deletion src/commands/comment/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const commentId = resolveCommentId(ref)
Expand Down Expand Up @@ -36,6 +36,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) {
Expand Down
3 changes: 2 additions & 1 deletion src/commands/comment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,14 @@ Examples:
comment
.command('delete <comment-ref>')
.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:
tw comment delete 12345
tw comment delete 12345 --yes
tw comment delete 12345 --dry-run`,
)
.action(deleteComment)
Expand Down
50 changes: 48 additions & 2 deletions src/commands/conversation/conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,22 +679,68 @@ 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.getTwistClient.mockResolvedValue(client)

const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await program.parseAsync(['node', 'tw', 'conversation', 'done', '42'])
await program.parseAsync(['node', 'tw', 'conversation', 'done', '42', '--yes'])

expect(client.conversations.archiveConversation).toHaveBeenCalledWith(42)
expect(consoleSpy).toHaveBeenCalledWith('Conversation 42 archived.')

consoleSpy.mockRestore()
})

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.getTwistClient.mockResolvedValue(client)

const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await program.parseAsync(['node', 'tw', 'conversation', 'done', '42'])

expect(consoleSpy).toHaveBeenCalledWith('Would archive: conversation 42')
expect(consoleSpy).toHaveBeenCalledWith('Use --yes to confirm.')
expect(client.conversations.archiveConversation).not.toHaveBeenCalled()

consoleSpy.mockRestore()
})

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.getTwistClient.mockResolvedValue(client)

const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await program.parseAsync(['node', 'tw', 'conversation', 'done', '42', '--json', '--yes'])

const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0])
expect(jsonOutput).toEqual({ id: 42, archived: true })

consoleSpy.mockRestore()
})

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.getTwistClient.mockResolvedValue(client)
const program = createProgram()

await expect(
program.parseAsync(['node', 'tw', '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] })
Expand Down
14 changes: 14 additions & 0 deletions src/commands/conversation/done.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getTwistClient } 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'
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/conversation/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 const CONVERSATION_PAGE_LIMIT = 100

Expand Down
3 changes: 2 additions & 1 deletion src/commands/conversation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,14 @@ Examples:
conversation
.command('done <conversation-ref>')
.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:
tw conversation done 12345
tw conversation done 12345 --yes
tw conversation done 12345 --dry-run`,
)
.action(markConversationDone)
Expand Down
14 changes: 13 additions & 1 deletion src/commands/msg/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const messageId = resolveMessageId(ref)
Expand Down Expand Up @@ -33,6 +33,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) {
Expand Down
3 changes: 2 additions & 1 deletion src/commands/msg/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,14 @@ Examples:

msg.command('delete <message-ref>')
.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:
tw msg delete 12345
tw msg delete 12345 --yes
tw msg delete 12345 --dry-run`,
)
.action(deleteMessage)
Expand Down
Loading
Loading