Skip to content
Draft
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,15 @@ tdc thread reply <ref> # reply to a thread
tdc thread reply <ref> "Update" --notify NONE # reply without notifying anyone
tdc thread rename <ref> "New title" # rename a thread
tdc thread update <ref> "New body" # edit a thread's body (first post)
tdc thread done <ref> --yes # archive a thread (mark done)
tdc thread undone <ref> --yes # move it back to your inbox
tdc thread mark-read <ref> # mark a thread read
tdc thread mark-unread <ref> # mark it unread again (--from <comment-ref> for part of it)
tdc conversation unread # list unread conversations
tdc conversation list # list conversations (--kind, --participant, --name, --state)
tdc conversation view <ref> # view conversation messages
tdc conversation done <ref> --yes # archive a conversation
tdc conversation undone <ref> --yes # unarchive it
tdc msg view <ref> # view a conversation message
tdc search "keyword" # search across workspace
tdc search "keyword" --all # fetch all result pages
Expand Down
10 changes: 9 additions & 1 deletion skills/comms-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,14 @@ tdc thread reply <ref> "content" --file ./a.png # Attach a file (repeatable; co
tdc thread done <ref> # Preview thread archive (requires --yes to execute)
tdc thread done <ref> --yes # Archive thread (mark done)
tdc thread done <ref> --yes --json # Archive and return status as JSON
tdc thread undone <ref> --yes # Unarchive thread (move it back to your inbox); inverse of done
tdc thread undone <ref> --yes --json # Unarchive and return status as JSON
tdc thread mark-read <ref> # Mark a thread read
tdc thread mark-read <ref> <ref> --yes # Mark multiple threads read
printf "id:CbT8n2Kp4Qx6Rz9Lm3Va\nid:CbT9m4Qr7Vz2Nx8Lp5Sa\n" | tdc thread mark-read --dry-run # Preview bulk mark-read from stdin
tdc thread mark-unread <ref> # Mark a whole thread unread; inverse of mark-read
tdc thread mark-unread <ref> --from <comment-ref> # Mark unread from that comment onward (single thread only)
tdc thread mark-unread <ref> <ref> --yes # Mark multiple threads unread (also accepts refs on stdin)
tdc thread mute <ref> # Mute thread for 60 minutes (default)
tdc thread mute <ref> --minutes 480 # Mute for custom duration
tdc thread mute <ref> --json # Mute and return { id, mutedUntil } as JSON
Expand Down Expand Up @@ -189,6 +194,8 @@ tdc conversation reply <ref> "content" --file ./a.png # Attach a file (repeatab
tdc conversation done <ref> # Preview conversation archive (requires --yes to execute)
tdc conversation done <ref> --yes # Archive conversation
tdc conversation done <ref> --yes --json # Archive and return status as JSON
tdc conversation undone <ref> --yes # Unarchive conversation; inverse of done
tdc conversation undone <ref> --yes --json # Unarchive and return status as JSON
tdc conversation mute <ref> # Mute conversation for 60 minutes (default)
tdc conversation mute <ref> --minutes 480 # Mute for custom duration
tdc conversation mute <ref> --json # Mute and return { id, mutedUntil } as JSON
Expand Down Expand Up @@ -446,7 +453,7 @@ echo "Quick reply" | tdc conversation reply <ref>

If no content argument is provided and no stdin is piped, the CLI opens `$EDITOR` for interactive input. In non-TTY environments (e.g. when called by an agent or in a pipeline), the editor is automatically skipped and the command fails fast with an actionable error message. Use `--non-interactive` to force this behavior even in a TTY, or `--interactive` to override auto-detection.

`tdc thread mark-read` also accepts thread refs from stdin, one per line:
`tdc thread mark-read` and `tdc thread mark-unread` also accept thread refs from stdin, one per line:

```bash
printf "id:CbT8n2Kp4Qx6Rz9Lm3Va\nid:CbT9m4Qr7Vz2Nx8Lp5Sa\n" | tdc thread mark-read --yes
Expand All @@ -469,6 +476,7 @@ tdc inbox --unread --json
tdc thread view <thread-ref> --unread
tdc thread reply <thread-ref> "Thanks, I'll look into this."
tdc thread done <thread-ref> --yes
tdc thread undone <thread-ref> --yes # Changed your mind: back to the inbox
```

**Search and review:**
Expand Down
64 changes: 64 additions & 0 deletions src/commands/conversation/archive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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'

async function setConversationArchiveState(
ref: string,
options: DoneOptions,
archive: boolean,
): Promise<void> {
const action = archive ? 'archive' : 'unarchive'
const conversationId = resolveConversationId(ref)

const client = await getCommsClient()
const conversation = await client.conversations.getConversation(conversationId)

const noop = conversation.archived === archive

if (options.dryRun) {
printDryRun(`${action} conversation`, {
Conversation: conversationLabel(conversation),
Status: noop ? (archive ? 'already archived' : 'not archived') : undefined,
})
return
}

if (!options.yes) {
if (options.json) {
throw new CliError(
'MISSING_YES_FLAG',
`--yes is required to execute ${action} in --json mode.`,
)
}
console.log(`Would ${action}: ${conversationLabel(conversation)}`)
console.log('Use --yes to confirm.')
return
}

if (!noop) {
if (archive) {
await client.conversations.archiveConversation(conversationId)
} else {
await client.conversations.unarchiveConversation(conversationId)
}
}

if (options.json) {
console.log(formatJson({ id: conversationId, archived: archive }))
return
}

console.log(
`Conversation ${conversationId} ${action}d${noop ? ' (already in target state)' : ''}.`,
)
}

export async function markConversationDone(ref: string, options: DoneOptions): Promise<void> {
await setConversationArchiveState(ref, options, true)
}

export async function markConversationUndone(ref: string, options: DoneOptions): Promise<void> {
await setConversationArchiveState(ref, options, false)
}
120 changes: 120 additions & 0 deletions src/commands/conversation/conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ function createClient({
getUnread: vi.fn().mockResolvedValue({ data: [], version: 1 }),
getConversation: vi.fn(async (id: string) => conversationsById.get(id)),
archiveConversation: vi.fn(),
unarchiveConversation: vi.fn(),
muteConversation: vi.fn(async ({ id, minutes }: { id: string; minutes: number }) => ({
...conversationsById.get(id),
mutedUntil: new Date(Date.now() + minutes * 60000),
Expand Down Expand Up @@ -1225,6 +1226,125 @@ describe('conversation done', () => {
})
})

describe('conversation undone', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('unarchives a conversation with --yes', async () => {
const conversation = {
...createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z'),
archived: true,
}
const client = createClient({ archivedConversations: [conversation] })
apiMocks.getCommsClient.mockResolvedValue(client)

const program = createProgram()
const consoleSpy = captureConsole('log')

await program.parseAsync(['node', 'tdc', 'conversation', 'undone', '42', '--yes'])

expect(client.conversations.unarchiveConversation).toHaveBeenCalledWith('42')
expect(client.conversations.archiveConversation).not.toHaveBeenCalled()
expect(consoleSpy).toHaveBeenCalledWith('Conversation 42 unarchived.')
})

it('skips the write when the conversation is not archived', 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', 'undone', '42', '--yes'])

expect(client.conversations.unarchiveConversation).not.toHaveBeenCalled()
expect(consoleSpy).toHaveBeenCalledWith(
'Conversation 42 unarchived (already in target state).',
)
})

it('prompts for confirmation without --yes', async () => {
const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z')
const client = createClient({ archivedConversations: [conversation] })
apiMocks.getCommsClient.mockResolvedValue(client)

const program = createProgram()
const consoleSpy = captureConsole('log')

await program.parseAsync(['node', 'tdc', 'conversation', 'undone', '42'])

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

it('outputs JSON with --json --yes', async () => {
const conversation = {
...createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z'),
archived: true,
}
const client = createClient({ archivedConversations: [conversation] })
apiMocks.getCommsClient.mockResolvedValue(client)

const program = createProgram()
const consoleSpy = captureConsole('log')

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

expect(client.conversations.unarchiveConversation).toHaveBeenCalledWith('42')
const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0])
expect(jsonOutput).toEqual({ id: '42', archived: false })
})

it('errors when --json is used without --yes', async () => {
const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z')
const client = createClient({ archivedConversations: [conversation] })
apiMocks.getCommsClient.mockResolvedValue(client)
const program = createProgram()

await expect(
program.parseAsync(['node', 'tdc', 'conversation', 'undone', '42', '--json']),
).rejects.toHaveProperty('code', 'MISSING_YES_FLAG')

expect(client.conversations.unarchiveConversation).not.toHaveBeenCalled()
})

it('shows dry run output and flags a conversation that is not archived', 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', 'undone', '42', '--dry-run'])

expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Would unarchive conversation'),
)
expect(consoleSpy).toHaveBeenCalledWith(' Conversation: conversation 42')
expect(consoleSpy).toHaveBeenCalledWith(' Status: not archived')
expect(client.conversations.unarchiveConversation).not.toHaveBeenCalled()
})

it('runs validation in dry-run mode', async () => {
const client = createClient({ activeConversations: [] })
client.conversations.getConversation.mockRejectedValueOnce(
new Error('conversation not found'),
)
apiMocks.getCommsClient.mockResolvedValue(client)

const program = createProgram()

await expect(
program.parseAsync(['node', 'tdc', 'conversation', 'undone', '42', '--dry-run']),
).rejects.toThrow('conversation not found')
expect(client.conversations.unarchiveConversation).not.toHaveBeenCalled()
})
})

describe('conversation reply --file', () => {
const files = useFileFixtures('tdc-convo-reply-', 'photo.png', 'doc.pdf')

Expand Down
41 changes: 0 additions & 41 deletions src/commands/conversation/done.ts

This file was deleted.

17 changes: 16 additions & 1 deletion src/commands/conversation/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Command, Option } from 'commander'
import { withCaseInsensitiveChoices } from '../../lib/completion.js'
import { collect } from '../../lib/options.js'
import { markConversationDone } from './done.js'
import { markConversationDone, markConversationUndone } from './archive.js'
import { listConversations } from './list.js'
import { muteConversation } from './mute.js'
import { replyToConversation } from './reply.js'
Expand Down Expand Up @@ -160,6 +160,21 @@ Examples:
)
.action(markConversationDone)

conversation
.command('undone <conversation-ref>')
.description('Unarchive a conversation; inverse of done')
.option('--yes', 'Confirm unarchive')
.option('--dry-run', 'Show what would happen without executing')
.option('--json', 'Output result as JSON')
.addHelpText(
'after',
`
Examples:
tdc conversation undone id:CbV8n2Kp4Qx6Rz9Lm3Va --yes
tdc conversation undone id:CbV8n2Kp4Qx6Rz9Lm3Va --dry-run`,
)
.action(markConversationUndone)

conversation
.command('mute <conversation-ref>')
.description('Mute a conversation (stop notifications)')
Expand Down
Loading
Loading