Skip to content
Merged
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
11 changes: 10 additions & 1 deletion skills/comms-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ 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 mark-read <ref> # Mark a thread read
tdc thread mark-read <ref> <ref> --yes # Mark multiple threads read
printf "123\n456\n" | tdc thread mark-read --dry-run # Preview bulk mark-read from 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 @@ -379,7 +382,7 @@ Commands accept flexible references:
- **Comms URLs**: Full `https://comms.todoist.com/...` URLs (parsed automatically)
- **Fuzzy names**: For workspaces/users - `"My Workspace"` or partial matches

## Piping Content
## Piping Input

Commands that accept content (`thread create`, `thread reply`, `comment update`, `conversation reply`, `msg update`) auto-detect piped stdin:

Expand All @@ -391,6 +394,12 @@ 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:

```bash
printf "123\n456\n" | tdc thread mark-read --yes
```

## Common Workflows

**View by URL (auto-routes to the right command):**
Expand Down
23 changes: 23 additions & 0 deletions src/commands/thread/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createThread } from './create.js'
import { deleteThread } from './delete.js'
import { markThreadDone } from './mutate.js'
import { muteThread, unmuteThread } from './mute.js'
import { markThreadRead } from './read.js'
import { renameThread } from './rename.js'
import { replyToThread } from './reply.js'
import { updateThread } from './update.js'
Expand Down Expand Up @@ -113,6 +114,28 @@ Examples:
)
.action(markThreadDone)

const markReadCmd = thread
.command('mark-read [thread-refs...]')
.description('Mark a thread read for the current user')
.option('--yes', 'Skip confirmation for bulk operations')
.option('--dry-run', 'Show what would happen without executing')
.option('--json', 'Output result as JSON')
.addHelpText(
'after',
`
Examples:
tdc thread mark-read 12345
tdc thread mark-read 12345 67890 --yes
printf "12345\\n67890\\n" | tdc thread mark-read --yes`,
)
.action((refs, options) => {
if (refs.length === 0 && process.stdin.isTTY) {
markReadCmd.help()
return
}
return markThreadRead(refs, options)
})

thread
.command('delete <thread-ref>')
.description('Permanently delete a thread')
Expand Down
162 changes: 162 additions & 0 deletions src/commands/thread/read.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import type { CommsApi, Thread } from '@doist/comms-sdk'
import { getCommsClient } from '../../lib/api.js'
import { CliError } from '../../lib/errors.js'
import { readStdinToEnd } from '../../lib/input.js'
import type { MutationOptions } from '../../lib/options.js'
import { formatJson, pluralize } from '../../lib/output.js'
import { assertChannelIsPublic } from '../../lib/public-channels.js'
import { resolveThreadId } from '../../lib/refs.js'

export type MarkThreadReadOptions = MutationOptions

type LoadedThread = {
thread: Thread
isUnread: boolean
}

type MarkReadStatus = {
id: string
isRead: true
}

type TextStatus = 'changed' | 'preview' | 'unchanged'

export async function markThreadRead(
refs: string[],
options: MarkThreadReadOptions,
): Promise<void> {
const rawRefs = await collectThreadRefs(refs)
if (rawRefs.length === 0) {
throw new CliError(
'INVALID_REF',
'No thread references provided. Pass refs as arguments or pipe them via stdin.',
)
}

const needsConfirmation = rawRefs.length > 1 && !options.yes && !options.dryRun
if (options.json && needsConfirmation) {
throw new CliError(
'MISSING_YES_FLAG',
'--yes is required to execute bulk mark-read in --json mode.',
)
}

const client = await getCommsClient()
const unreadCache = new Map<number, Set<string>>()
const jsonStatuses: MarkReadStatus[] = []
const textStatuses: TextStatus[] = []

for (const rawRef of rawRefs) {
const threadId = resolveThreadId(rawRef)
const loaded = await loadThread(client, unreadCache, threadId)

if (!loaded.isUnread) {
jsonStatuses.push({ id: threadId, isRead: true })
textStatuses.push('unchanged')
if (!options.json) {
console.log(`Thread ${threadLabel(loaded.thread)} is already read.`)
}
continue
}

if (needsConfirmation || options.dryRun) {
jsonStatuses.push({ id: threadId, isRead: true })
textStatuses.push('preview')
if (!options.json) {
const prefix = options.dryRun ? 'Dry run: would' : 'Would'
console.log(`${prefix} mark read thread ${threadLabel(loaded.thread)}.`)
}
continue
}

await client.threads.markRead({
id: threadId,
objIndex: getLatestObjIndex(loaded.thread),
})
unreadCache.get(loaded.thread.workspaceId)?.delete(threadId)

jsonStatuses.push({ id: threadId, isRead: true })
textStatuses.push('changed')
if (!options.json) {
console.log(`Thread ${threadLabel(loaded.thread)} marked read.`)
}
}

if (options.json && !options.dryRun) {
console.log(formatJson(jsonStatuses))
return
}

if (!options.json && rawRefs.length > 1) {
printSummary(textStatuses)
}

if (!options.json && needsConfirmation) {
console.log('Use --yes to confirm.')
}
}

async function collectThreadRefs(refs: string[]): Promise<string[]> {
const inlineRefs = refs.map((ref) => ref.trim()).filter(Boolean)

const stdinContent = await readStdinToEnd()
if (!stdinContent) return inlineRefs

const stdinRefs = stdinContent
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line !== '' && !line.startsWith('#'))

return [...inlineRefs, ...stdinRefs]
}

async function loadThread(
client: CommsApi,
unreadCache: Map<number, Set<string>>,
threadId: string,
): Promise<LoadedThread> {
const thread = await client.threads.getThread(threadId)
await assertChannelIsPublic(thread.channelId, thread.workspaceId)

let unreadIds = unreadCache.get(thread.workspaceId)
if (!unreadIds) {
const unread = await client.threads.getUnread(thread.workspaceId)
unreadIds = new Set(unread.data.map((unreadThread) => unreadThread.threadId))
unreadCache.set(thread.workspaceId, unreadIds)
}

return { thread, isUnread: unreadIds.has(thread.id) }
}

function getLatestObjIndex(thread: Thread): number {
return Math.max(
...[thread.lastComment?.objIndex, thread.lastObjIndex, thread.commentCount, 0]
.filter((value): value is number => typeof value === 'number')
.map((value) => Math.max(value, 0)),
)
}

function threadLabel(thread: Thread): string {
return `${thread.title} (${thread.id})`
}

function printSummary(statuses: TextStatus[]): void {
const summary = [
summarizeStatus(statuses, 'changed'),
summarizeStatus(statuses, 'unchanged'),
summarizeStatus(statuses, 'preview'),
].filter(Boolean)

console.log('')
console.log(`Summary: ${summary.join(', ')}`)
}

function summarizeStatus(statuses: TextStatus[], status: TextStatus): string | null {
const count = statuses.filter((value) => value === status).length
if (count === 0) {
return null
}

const noun = status === 'preview' ? pluralize(count, 'preview') : pluralize(count, 'thread')
return status === 'preview' ? `${count} ${noun}` : `${count} ${status} ${noun}`
}
Loading
Loading