diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index 10a2c98..92f1ee0 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -106,6 +106,9 @@ tdc thread reply "content" --file ./a.png # Attach a file (repeatable; co 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 mark-read # Mark a thread read +tdc thread mark-read --yes # Mark multiple threads read +printf "123\n456\n" | tdc thread mark-read --dry-run # Preview bulk mark-read from stdin 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 @@ -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: @@ -391,6 +394,12 @@ echo "Quick reply" | tdc conversation reply 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):** diff --git a/src/commands/thread/index.ts b/src/commands/thread/index.ts index a378f4d..c5dab3c 100644 --- a/src/commands/thread/index.ts +++ b/src/commands/thread/index.ts @@ -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' @@ -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 ') .description('Permanently delete a thread') diff --git a/src/commands/thread/read.ts b/src/commands/thread/read.ts new file mode 100644 index 0000000..b1df3a4 --- /dev/null +++ b/src/commands/thread/read.ts @@ -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 { + 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>() + 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 { + 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>, + threadId: string, +): Promise { + 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}` +} diff --git a/src/commands/thread/thread.test.ts b/src/commands/thread/thread.test.ts index 7d29c3c..0190eae 100644 --- a/src/commands/thread/thread.test.ts +++ b/src/commands/thread/thread.test.ts @@ -39,13 +39,14 @@ vi.mock('../../lib/markdown.js', () => ({ vi.mock('../../lib/input.js', () => ({ readStdin: vi.fn().mockResolvedValue(''), + readStdinToEnd: vi.fn().mockResolvedValue(''), openEditor: vi.fn().mockResolvedValue(''), })) vi.mock('chalk') import { clearWorkspaceUserCache } from '../../lib/api.js' -import { openEditor, readStdin } from '../../lib/input.js' +import { openEditor, readStdin, readStdinToEnd } from '../../lib/input.js' import { registerThreadCommand } from './index.js' function createThreadFixture(id: number | string) { @@ -58,7 +59,10 @@ function createThreadFixture(id: number | string) { channelId: 'CH100', workspaceId: 10, posted: new Date('2026-03-01T00:00:00.000Z'), + lastUpdated: new Date('2026-03-02T00:00:00.000Z'), commentCount: 3, + lastObjIndex: 3 as number | null, + lastComment: null as ReturnType | null, isArchived: false, reactions: [], url: `https://comms.todoist.com/a/10/ch/CH100/t/${sid}`, @@ -94,10 +98,12 @@ function createClient({ channel = { id: 'CH100', name: 'General', workspaceId: 10 }, sessionUser = { id: 1, fullName: 'Test User' }, } = {}) { + let unreadState = [...unreadThreads] + return { threads: { getThread: vi.fn(async (_id: string) => thread), - getUnread: vi.fn(async () => ({ data: unreadThreads, version: 1 })), + getUnread: vi.fn(async () => ({ data: unreadState, version: 1 })), createThread: vi.fn( async (_args: { channelId: string @@ -112,6 +118,9 @@ function createClient({ reopenThread: vi.fn(async (_args: { id: string; content: string }) => createComment(11, 11), ), + markRead: vi.fn(async ({ id }: { id: string; objIndex: number }) => { + unreadState = unreadState.filter((unread) => unread.threadId !== id) + }), muteThread: vi.fn(async (_args: { id: string; minutes: number }) => ({ ...thread, mutedUntil: new Date(Date.now() + _args.minutes * 60000), @@ -978,6 +987,287 @@ describe('thread unmute', () => { }) }) +describe('thread read', () => { + beforeEach(() => { + clearWorkspaceUserCache() + vi.clearAllMocks() + vi.mocked(readStdinToEnd).mockResolvedValue('') + }) + + it('marks a single unread thread read without requiring --yes', async () => { + const client = createClient({ + unreadThreads: [ + { threadId: '500', channelId: 'CH100', objIndex: 1, directMention: false }, + ], + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-read', '500']) + + expect(client.threads.markRead).toHaveBeenCalledWith({ id: '500', objIndex: 3 }) + expect(consoleSpy).toHaveBeenCalledWith('Thread Test Thread (500) marked read.') + }) + + it('uses the highest available object index', async () => { + const client = createClient({ + thread: { + ...createThreadFixture(500), + lastComment: createComment(10, 9), + lastObjIndex: 12, + commentCount: 7, + }, + unreadThreads: [ + { threadId: '500', channelId: 'CH100', objIndex: 1, directMention: false }, + ], + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-read', '500']) + + expect(client.threads.markRead).toHaveBeenCalledWith({ id: '500', objIndex: 12 }) + }) + + it('leaves already-read threads unchanged', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-read', '500']) + + expect(client.threads.markRead).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalledWith('Thread Test Thread (500) is already read.') + }) + + it('shows dry run output', async () => { + const client = createClient({ + unreadThreads: [ + { threadId: '500', channelId: 'CH100', objIndex: 1, directMention: false }, + ], + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-read', '500', '--dry-run']) + + expect(consoleSpy).toHaveBeenCalledWith( + 'Dry run: would mark read thread Test Thread (500).', + ) + expect(client.threads.markRead).not.toHaveBeenCalled() + }) + + it('outputs JSON with --json', async () => { + const client = createClient({ + unreadThreads: [ + { threadId: '500', channelId: 'CH100', objIndex: 1, directMention: false }, + ], + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-read', '500', '--json']) + + const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(jsonOutput).toEqual([{ id: '500', isRead: true }]) + }) + + it('runs validation in dry-run mode', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + client.threads.getThread.mockRejectedValueOnce(new Error('thread not found')) + + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'thread', 'mark-read', '500', '--dry-run']), + ).rejects.toThrow('thread not found') + expect(client.threads.markRead).not.toHaveBeenCalled() + }) + + it('surfaces markRead failures through the shared error path', async () => { + const client = createClient({ + unreadThreads: [ + { threadId: '500', channelId: 'CH100', objIndex: 1, directMention: false }, + ], + }) + client.threads.markRead.mockRejectedValueOnce(new Error('mark failed')) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'thread', 'mark-read', '500']), + ).rejects.toThrow('mark failed') + }) + + it('previews bulk mark-read unless --yes is passed', async () => { + const client = createClient({ + unreadThreads: [ + { threadId: '500', channelId: 'CH100', objIndex: 1, directMention: false }, + ], + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-read', '500', '501']) + + expect(client.threads.markRead).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalledWith('Would mark read thread Test Thread (500).') + expect(consoleSpy).toHaveBeenCalledWith('Use --yes to confirm.') + }) + + it('marks every unread thread in bulk when --yes is passed', async () => { + const thread500 = createThreadFixture(500) + const thread501 = { ...createThreadFixture(501), title: 'Second Thread' } + const client = createClient({ + unreadThreads: [ + { threadId: '500', channelId: 'CH100', objIndex: 1, directMention: false }, + { threadId: '501', channelId: 'CH100', objIndex: 1, directMention: false }, + ], + }) + client.threads.getThread.mockImplementation(async (id: string) => + id === '501' ? thread501 : thread500, + ) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-read', '500', '501', '--yes']) + + expect(client.threads.markRead).toHaveBeenCalledWith({ id: '500', objIndex: 3 }) + expect(client.threads.markRead).toHaveBeenCalledWith({ id: '501', objIndex: 3 }) + expect(client.threads.markRead).toHaveBeenCalledTimes(2) + }) + + it('requires --yes for bulk mark-read in --json mode', async () => { + const client = createClient({ + unreadThreads: [ + { threadId: '500', channelId: 'CH100', objIndex: 1, directMention: false }, + ], + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'thread', 'mark-read', '500', '501', '--json']), + ).rejects.toHaveProperty('code', 'MISSING_YES_FLAG') + expect(client.threads.markRead).not.toHaveBeenCalled() + }) + + it('reads thread refs from stdin', async () => { + vi.mocked(readStdinToEnd).mockResolvedValueOnce('# comment\n500\n\n') + const originalIsTTY = process.stdin.isTTY + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }) + + try { + const client = createClient({ + unreadThreads: [ + { threadId: '500', channelId: 'CH100', objIndex: 1, directMention: false }, + ], + }) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-read']) + + expect(readStdinToEnd).toHaveBeenCalled() + expect(client.threads.markRead).toHaveBeenCalledWith({ id: '500', objIndex: 3 }) + } finally { + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }) + } + }) + + it('reads multiple thread refs from stdin', async () => { + vi.mocked(readStdinToEnd).mockResolvedValueOnce('# comment\n500\n501\n') + const originalIsTTY = process.stdin.isTTY + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }) + + try { + const thread500 = createThreadFixture(500) + const thread501 = { ...createThreadFixture(501), title: 'Second Thread' } + const client = createClient({ + unreadThreads: [ + { threadId: '500', channelId: 'CH100', objIndex: 1, directMention: false }, + { threadId: '501', channelId: 'CH100', objIndex: 1, directMention: false }, + ], + }) + client.threads.getThread.mockImplementation(async (id: string) => + id === '501' ? thread501 : thread500, + ) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-read', '--yes']) + + expect(client.threads.markRead).toHaveBeenCalledWith({ id: '500', objIndex: 3 }) + expect(client.threads.markRead).toHaveBeenCalledWith({ id: '501', objIndex: 3 }) + expect(client.threads.markRead).toHaveBeenCalledTimes(2) + } finally { + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }) + } + }) + + it('combines positional and stdin thread refs', async () => { + vi.mocked(readStdinToEnd).mockResolvedValueOnce('501\n') + const originalIsTTY = process.stdin.isTTY + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }) + + try { + const thread500 = createThreadFixture(500) + const thread501 = { ...createThreadFixture(501), title: 'Second Thread' } + const client = createClient({ + unreadThreads: [ + { threadId: '500', channelId: 'CH100', objIndex: 1, directMention: false }, + { threadId: '501', channelId: 'CH100', objIndex: 1, directMention: false }, + ], + }) + client.threads.getThread.mockImplementation(async (id: string) => + id === '501' ? thread501 : thread500, + ) + apiMocks.getCommsClient.mockResolvedValue(client) + + const program = createProgram() + captureConsole('log') + + await program.parseAsync(['node', 'tdc', 'thread', 'mark-read', '500', '--yes']) + + expect(client.threads.markRead).toHaveBeenCalledWith({ id: '500', objIndex: 3 }) + expect(client.threads.markRead).toHaveBeenCalledWith({ id: '501', objIndex: 3 }) + expect(client.threads.markRead).toHaveBeenCalledTimes(2) + } finally { + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }) + } + }) +}) + describe('thread delete', () => { beforeEach(() => { clearWorkspaceUserCache() diff --git a/src/lib/api.ts b/src/lib/api.ts index 97d1750..603045e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -35,6 +35,7 @@ const API_SPINNER_MESSAGES: Record { }) } +export async function readStdinToEnd(): Promise { + if (process.stdin.isTTY) { + return null + } + + return new Promise((resolve) => { + let data = '' + let settled = false + + const settle = (value: string | null) => { + if (settled) return + settled = true + process.stdin.removeListener('data', onData) + process.stdin.removeListener('end', onEnd) + process.stdin.removeListener('error', onError) + resolve(value) + } + + const onData = (chunk: string) => { + data += chunk + } + const onEnd = () => settle(data.trim() || null) + const onError = () => settle(null) + + process.stdin.setEncoding('utf8') + process.stdin.on('data', onData) + process.stdin.on('end', onEnd) + process.stdin.on('error', onError) + }) +} + export async function openEditor(): Promise { if (isNonInteractive()) { return null diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index c3823c4..0335927 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -110,6 +110,9 @@ tdc thread reply "content" --file ./a.png # Attach a file (repeatable; co 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 mark-read # Mark a thread read +tdc thread mark-read --yes # Mark multiple threads read +printf "123\\n456\\n" | tdc thread mark-read --dry-run # Preview bulk mark-read from stdin 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 @@ -383,7 +386,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: @@ -395,6 +398,12 @@ echo "Quick reply" | tdc conversation reply 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):**