From c64a41166e45726821633db89bdaa0fe58244670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Tue, 28 Apr 2026 17:56:52 -0400 Subject: [PATCH 1/2] feat: add mentions command and search pagination --- README.md | 3 + skills/twist-cli/SKILL.md | 7 +- src/commands/mentions.test.ts | 116 +++++++++++++++ src/commands/mentions.ts | 53 +++++++ src/commands/search.test.ts | 37 +++++ src/commands/search.ts | 188 ++---------------------- src/index.ts | 3 + src/lib/search-command.ts | 267 ++++++++++++++++++++++++++++++++++ src/lib/skills/content.ts | 7 +- 9 files changed, 504 insertions(+), 177 deletions(-) create mode 100644 src/commands/mentions.test.ts create mode 100644 src/commands/mentions.ts create mode 100644 src/lib/search-command.ts diff --git a/README.md b/README.md index fa14588..4d2cb10 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ tw auth logout # remove saved token ```bash tw inbox # inbox threads tw inbox --unread # unread threads only +tw mentions # content mentioning you +tw mentions --since 2026-04-01 --all --json tw thread view # view thread with comments tw thread view --comment 123 # view a specific comment tw thread reply # reply to a thread @@ -109,6 +111,7 @@ tw conversation unread # list unread conversations tw conversation view # view conversation messages tw msg view # view a conversation message tw search "keyword" # search across workspace +tw search "keyword" --all # fetch all result pages tw react thread 👍 # add reaction tw away # show away status tw away set vacation 2026-03-20 # set away until date diff --git a/skills/twist-cli/SKILL.md b/skills/twist-cli/SKILL.md index 87fd90c..7e7bae6 100644 --- a/skills/twist-cli/SKILL.md +++ b/skills/twist-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: twist-cli -description: "Twist messaging CLI. View and respond to inbox threads, channel threads, direct messages, and group conversations; search, react, archive, mute, and manage workspaces. Use when the user mentions Twist, asks about their inbox, threads, DMs, channels, or wants to read or send Twist messages." +description: "Twist messaging CLI. View and respond to inbox threads, channel threads, direct messages, mentions, and group conversations; search, react, archive, mute, and manage workspaces. Use when the user mentions Twist, asks about their inbox, mentions, threads, DMs, channels, or wants to read or send Twist messages." license: MIT metadata: author: Doist @@ -168,6 +168,9 @@ Alias: `tw message` works the same as `tw msg`. ## Search ```bash +tw mentions # Show content mentioning current user +tw mentions --since 2026-04-01 --all # Fetch every mention since a date +tw mentions --type threads --json # Limit mentions to threads tw search "query" # Search content tw search "query" --type threads # Filter: threads, messages, or all tw search "query" --author # Filter by author @@ -180,6 +183,7 @@ tw search "query" --until # Content until date tw search "query" --channel # Filter by channel refs (comma-separated) tw search "query" --limit # Max results (default: 50) tw search "query" --cursor # Pagination cursor +tw search "query" --all # Fetch all result pages ``` ## Users, Channels & Groups @@ -351,6 +355,7 @@ tw thread done **Search and review:** ```bash +tw mentions --since 2026-04-01 --all --json tw search "deployment" --type threads --json tw thread view ``` diff --git a/src/commands/mentions.test.ts b/src/commands/mentions.test.ts new file mode 100644 index 0000000..31c3361 --- /dev/null +++ b/src/commands/mentions.test.ts @@ -0,0 +1,116 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const refsMocks = vi.hoisted(() => ({ + resolveWorkspaceRef: vi.fn(), + resolveUserRefs: vi.fn(), + resolveChannelId: vi.fn(), + resolveConversationId: vi.fn(), +})) + +const searchApiMocks = vi.hoisted(() => ({ + extendedSearch: vi.fn(), +})) + +vi.mock('../lib/api.js', () => ({ + getCurrentWorkspaceId: vi.fn().mockResolvedValue(1), +})) + +vi.mock('../lib/refs.js', () => refsMocks) + +vi.mock('../lib/global-args.js', async (importOriginal) => ({ + ...(await importOriginal()), + includePrivateChannels: vi.fn().mockReturnValue(true), +})) + +vi.mock('../lib/public-channels.js', () => ({ + getPublicChannelIds: vi.fn(), +})) + +vi.mock('../lib/search-api.js', () => searchApiMocks) + +vi.mock('chalk') + +import { registerMentionsCommand } from './mentions.js' + +function createProgram() { + const program = new Command() + program.exitOverride() + registerMentionsCommand(program) + return program +} + +describe('mentions', () => { + beforeEach(() => { + vi.clearAllMocks() + refsMocks.resolveWorkspaceRef.mockResolvedValue({ id: 1, name: 'Doist' }) + searchApiMocks.extendedSearch.mockResolvedValue({ + items: [], + hasMore: false, + isPlanRestricted: false, + }) + }) + + it('errors when both positional and --workspace are provided', async () => { + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tw', 'mentions', 'Doist', '--workspace', 'Other']), + ).rejects.toThrow('Cannot specify workspace both as argument and --workspace flag') + }) + + it('searches using mentionSelf without a query', async () => { + const program = createProgram() + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'mentions']) + + expect(searchApiMocks.extendedSearch).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 1, + mentionSelf: true, + query: undefined, + title: undefined, + }), + ) + + logSpy.mockRestore() + }) + + it('fetches every page when --all is set', async () => { + searchApiMocks.extendedSearch + .mockResolvedValueOnce({ + items: [], + hasMore: true, + nextCursorMark: 'cursor-1', + isPlanRestricted: false, + }) + .mockResolvedValueOnce({ + items: [], + hasMore: false, + isPlanRestricted: false, + }) + + const program = createProgram() + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'mentions', '--all']) + + expect(searchApiMocks.extendedSearch).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + mentionSelf: true, + cursor: undefined, + }), + ) + expect(searchApiMocks.extendedSearch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + mentionSelf: true, + cursor: 'cursor-1', + }), + ) + + logSpy.mockRestore() + }) +}) diff --git a/src/commands/mentions.ts b/src/commands/mentions.ts new file mode 100644 index 0000000..f8070bb --- /dev/null +++ b/src/commands/mentions.ts @@ -0,0 +1,53 @@ +import { Command, Option } from 'commander' +import { withCaseInsensitiveChoices } from '../lib/completion.js' +import { + printSearchCommandResults, + runSearchCommand, + type SearchCommandOptions, +} from '../lib/search-command.js' + +async function mentions( + workspaceRef: string | undefined, + options: SearchCommandOptions, +): Promise { + const { workspaceId, response } = await runSearchCommand(workspaceRef, { + ...options, + mentionSelf: true, + }) + + printSearchCommandResults(workspaceId, response, options) +} + +export function registerMentionsCommand(program: Command): void { + program + .command('mentions [workspace-ref]') + .description('Show content mentioning the current user') + .option('--workspace ', 'Workspace ID or name') + .option('--channel ', 'Filter by channels (comma-separated refs)') + .option('--author ', 'Filter by author (comma-separated refs)') + .option('--to ', 'Messages sent to user (comma-separated refs)') + .addOption( + withCaseInsensitiveChoices( + new Option('--type ', 'Filter: threads, messages, or all'), + ['threads', 'messages', 'all'], + ), + ) + .option('--conversation ', 'Limit to conversations (comma-separated refs)') + .option('--since ', 'Content from date') + .option('--until ', 'Content until date') + .option('--limit ', 'Max results per page (default: 50)') + .option('--cursor ', 'Pagination cursor') + .option('--all', 'Fetch all pages of results') + .option('--json', 'Output as JSON') + .option('--ndjson', 'Output as newline-delimited JSON') + .option('--full', 'Include all fields in JSON output') + .addHelpText( + 'after', + ` +Examples: + tw mentions + tw mentions --since 2026-04-01 --all + tw mentions --type threads --json`, + ) + .action(mentions) +} diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 8d06f44..3c6f472 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -93,4 +93,41 @@ describe('search --workspace conflict', () => { logSpy.mockRestore() }) + + it('fetches every page when --all is set', async () => { + searchApiMocks.extendedSearch + .mockResolvedValueOnce({ + items: [], + hasMore: true, + nextCursorMark: 'cursor-1', + isPlanRestricted: false, + }) + .mockResolvedValueOnce({ + items: [], + hasMore: false, + isPlanRestricted: false, + }) + + const program = createProgram() + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'search', 'query', '--all']) + + expect(searchApiMocks.extendedSearch).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + query: 'query', + cursor: undefined, + }), + ) + expect(searchApiMocks.extendedSearch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + query: 'query', + cursor: 'cursor-1', + }), + ) + + logSpy.mockRestore() + }) }) diff --git a/src/commands/search.ts b/src/commands/search.ts index 5eec0e3..3d9241a 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,49 +1,14 @@ -import { getFullTwistURL } from '@doist/twist-sdk' import { Command, Option } from 'commander' -import { getCurrentWorkspaceId } from '../lib/api.js' import { withCaseInsensitiveChoices } from '../lib/completion.js' -import { formatRelativeDate } from '../lib/dates.js' -import { CliError } from '../lib/errors.js' -import { includePrivateChannels } from '../lib/global-args.js' -import type { PaginatedViewOptions } from '../lib/options.js' -import { colors, formatJson } from '../lib/output.js' -import { getPublicChannelIds } from '../lib/public-channels.js' import { - resolveChannelId, - resolveConversationId, - resolveUserRefs, - resolveWorkspaceRef, -} from '../lib/refs.js' -import { extendedSearch, type SearchType } from '../lib/search-api.js' + printSearchCommandResults, + runSearchCommand, + type SearchCommandOptions, +} from '../lib/search-command.js' -function resolveNumericRefs( - refs: string | undefined, - entityType: string, - resolver: (ref: string) => number, -): number[] | undefined { - if (!refs) return undefined - return refs.split(',').map((raw) => { - const ref = raw.trim() - if (!ref) { - throw new CliError( - 'INVALID_REF', - `Invalid ${entityType} reference list: found empty value`, - ) - } - return resolver(ref) - }) -} - -type SearchOptions = PaginatedViewOptions & { - workspace?: string - channel?: string - author?: string - to?: string - type?: SearchType +type SearchOptions = SearchCommandOptions & { titleOnly?: boolean - conversation?: string mentionMe?: boolean - cursor?: string } async function search( @@ -51,142 +16,13 @@ async function search( workspaceRef: string | undefined, options: SearchOptions, ): Promise { - if (workspaceRef && options.workspace) { - throw new CliError( - 'CONFLICTING_OPTIONS', - 'Cannot specify workspace both as argument and --workspace flag', - ) - } - - let workspaceId: number - const ref = workspaceRef || options.workspace - - if (ref) { - const workspace = await resolveWorkspaceRef(ref) - workspaceId = workspace.id - } else { - workspaceId = await getCurrentWorkspaceId() - } - - const limit = options.limit ? parseInt(options.limit, 10) : 50 - - const channelIds = resolveNumericRefs(options.channel, 'channel', resolveChannelId) - - const authorIds = options.author - ? await resolveUserRefs(options.author, workspaceId) - : undefined - const toUserIds = options.to ? await resolveUserRefs(options.to, workspaceId) : undefined - - const conversationIds = resolveNumericRefs( - options.conversation, - 'conversation', - resolveConversationId, - ) - - const response = await extendedSearch({ - workspaceId, + const { workspaceId, response } = await runSearchCommand(workspaceRef, { + ...options, query: options.titleOnly ? undefined : query, title: options.titleOnly ? query : undefined, - type: options.type, - channelIds, - conversationIds, - authorIds, - toUserIds, mentionSelf: options.mentionMe, - dateFrom: options.since, - dateTo: options.until, - limit, - cursor: options.cursor, }) - - if (!includePrivateChannels()) { - const publicIds = await getPublicChannelIds(workspaceId) - response.items = response.items.filter( - (item) => !item.channelId || publicIds.has(item.channelId), - ) - } - - if (response.items.length === 0) { - if (response.hasMore && response.nextCursorMark) { - console.log('No public results on this page.') - console.log( - colors.timestamp(`More results available. Use --cursor ${response.nextCursorMark}`), - ) - } else { - console.log('No results found.') - } - return - } - - if (options.json) { - const output = { - results: response.items.map((r) => ({ - ...r, - url: buildSearchResultUrl(workspaceId, r), - })), - nextCursor: response.nextCursorMark || null, - } - console.log(formatJson(output, undefined, options.full)) - return - } - - if (options.ndjson) { - for (const r of response.items) { - console.log(JSON.stringify({ ...r, url: buildSearchResultUrl(workspaceId, r) })) - } - if (response.nextCursorMark) { - console.log(JSON.stringify({ _meta: true, nextCursor: response.nextCursorMark })) - } - return - } - - for (const result of response.items) { - const type = colors.channel(`[${result.type}]`) - const title = result.title || result.snippet.slice(0, 50) - const time = colors.timestamp(formatRelativeDate(result.snippetLastUpdated)) - - console.log(`${type} ${title}`) - console.log(` ${colors.timestamp(result.snippet.slice(0, 100))}`) - console.log(` ${time} ${colors.url(buildSearchResultUrl(workspaceId, result))}`) - console.log('') - } - - if (response.hasMore) { - console.log( - colors.timestamp(`More results available. Use --cursor ${response.nextCursorMark}`), - ) - } -} - -function buildSearchResultUrl( - workspaceId: number, - result: { - type: string - threadId?: number | null - channelId?: number | null - conversationId?: number | null - commentId?: number | null - }, -): string { - if (result.type === 'thread' && result.threadId && result.channelId) { - return getFullTwistURL({ - workspaceId, - channelId: result.channelId, - threadId: result.threadId, - }) - } - if (result.type === 'comment' && result.threadId && result.channelId && result.commentId) { - return getFullTwistURL({ - workspaceId, - channelId: result.channelId, - threadId: result.threadId, - commentId: result.commentId, - }) - } - if (result.type === 'message' && result.conversationId) { - return getFullTwistURL({ workspaceId, conversationId: result.conversationId }) - } - return `https://twist.com/a/${workspaceId}` + printSearchCommandResults(workspaceId, response, options) } export function registerSearchCommand(program: Command): void { @@ -195,8 +31,8 @@ export function registerSearchCommand(program: Command): void { .description('Search content across a workspace') .option('--workspace ', 'Workspace ID or name') .option('--channel ', 'Filter by channels (comma-separated refs)') - .option('--author ', 'Filter by author (comma-separated IDs)') - .option('--to ', 'Messages sent TO user (comma-separated IDs)') + .option('--author ', 'Filter by author (comma-separated refs)') + .option('--to ', 'Messages sent TO user (comma-separated refs)') .addOption( withCaseInsensitiveChoices( new Option('--type ', 'Filter: threads, messages, or all'), @@ -210,6 +46,7 @@ export function registerSearchCommand(program: Command): void { .option('--until ', 'Content until date') .option('--limit ', 'Max results (default: 50)') .option('--cursor ', 'Pagination cursor') + .option('--all', 'Fetch all pages of results') .option('--json', 'Output as JSON') .option('--ndjson', 'Output as newline-delimited JSON') .option('--full', 'Include all fields in JSON output') @@ -219,7 +56,8 @@ export function registerSearchCommand(program: Command): void { Examples: tw search "deployment issue" tw search "bug report" --type threads --channel id:12345 - tw search "API" --author id:5678 --since 2025-01-01 --json`, + tw search "API" --author id:5678 --since 2025-01-01 --json + tw search "incident" --all --json`, ) .action(search) } diff --git a/src/index.ts b/src/index.ts index e8cc853..7a7d718 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,8 @@ const loadMsgCommand = async () => (await import('./commands/msg/index.js')).reg const loadCommentCommand = async () => (await import('./commands/comment/index.js')).registerCommentCommand const loadSearchCommand = async () => (await import('./commands/search.js')).registerSearchCommand +const loadMentionsCommand = async () => + (await import('./commands/mentions.js')).registerMentionsCommand const loadReactCommand = async () => (await import('./commands/react.js')).registerReactCommand const loadAuthCommand = async () => (await import('./commands/auth/index.js')).registerAuthCommand const loadSkillCommand = async () => @@ -50,6 +52,7 @@ const commands: Record Promise<(p: Command) => void>]> = msg: ['Conversation message operations (view, update, delete)', loadMsgCommand], comment: ['Thread comment operations (view, update, delete)', loadCommentCommand], search: ['Search content across a workspace', loadSearchCommand], + mentions: ['Show content mentioning the current user', loadMentionsCommand], away: ['Manage away status', loadAwayCommand], react: ['Add an emoji reaction (target-type: thread, comment, message)', loadReactCommand], unreact: ['Remove an emoji reaction (target-type: thread, comment, message)', loadReactCommand], diff --git a/src/lib/search-command.ts b/src/lib/search-command.ts new file mode 100644 index 0000000..adb4f4e --- /dev/null +++ b/src/lib/search-command.ts @@ -0,0 +1,267 @@ +import { getFullTwistURL, type SearchResult } from '@doist/twist-sdk' +import { getCurrentWorkspaceId } from './api.js' +import { formatRelativeDate } from './dates.js' +import { CliError } from './errors.js' +import { includePrivateChannels } from './global-args.js' +import type { PaginatedViewOptions } from './options.js' +import { colors, formatJson } from './output.js' +import { getPublicChannelIds } from './public-channels.js' +import { + resolveChannelId, + resolveConversationId, + resolveUserRefs, + resolveWorkspaceRef, +} from './refs.js' +import { + extendedSearch, + type ExtendedSearchParams, + type ExtendedSearchResponse, + type SearchType, +} from './search-api.js' + +function resolveNumericRefs( + refs: string | undefined, + entityType: string, + resolver: (ref: string) => number, +): number[] | undefined { + if (!refs) return undefined + return refs.split(',').map((raw) => { + const ref = raw.trim() + if (!ref) { + throw new CliError( + 'INVALID_REF', + `Invalid ${entityType} reference list: found empty value`, + ) + } + return resolver(ref) + }) +} + +export type SearchCommandOptions = PaginatedViewOptions & { + workspace?: string + channel?: string + author?: string + to?: string + type?: SearchType + conversation?: string + cursor?: string + all?: boolean +} + +type SearchRequestOptions = SearchCommandOptions & { + query?: string + title?: string + mentionSelf?: boolean +} + +export interface SearchCommandResult { + workspaceId: number + response: ExtendedSearchResponse +} + +async function resolveWorkspaceId( + workspaceRef: string | undefined, + workspaceOption: string | undefined, +): Promise { + if (workspaceRef && workspaceOption) { + throw new CliError( + 'CONFLICTING_OPTIONS', + 'Cannot specify workspace both as argument and --workspace flag', + ) + } + + const ref = workspaceRef || workspaceOption + if (!ref) { + return getCurrentWorkspaceId() + } + + const workspace = await resolveWorkspaceRef(ref) + return workspace.id +} + +async function buildSearchParams( + workspaceId: number, + options: SearchRequestOptions, +): Promise { + const limit = options.limit ? parseInt(options.limit, 10) : 50 + + const channelIds = resolveNumericRefs(options.channel, 'channel', resolveChannelId) + const authorIds = options.author + ? await resolveUserRefs(options.author, workspaceId) + : undefined + const toUserIds = options.to ? await resolveUserRefs(options.to, workspaceId) : undefined + const conversationIds = resolveNumericRefs( + options.conversation, + 'conversation', + resolveConversationId, + ) + + return { + workspaceId, + query: options.query, + title: options.title, + type: options.type, + channelIds, + conversationIds, + authorIds, + toUserIds, + mentionSelf: options.mentionSelf, + dateFrom: options.since, + dateTo: options.until, + limit, + cursor: options.cursor, + } +} + +async function fetchSearchPages( + params: ExtendedSearchParams, + all = false, +): Promise { + if (!all) { + return extendedSearch(params) + } + + const items: SearchResult[] = [] + let cursor = params.cursor + let hasMore = false + let isPlanRestricted = false + + do { + const response = await extendedSearch({ ...params, cursor }) + items.push(...response.items) + cursor = response.nextCursorMark + hasMore = response.hasMore + isPlanRestricted = isPlanRestricted || response.isPlanRestricted + } while (hasMore && cursor) + + return { + items, + hasMore: false, + isPlanRestricted, + } +} + +async function filterVisibleSearchResults( + workspaceId: number, + response: ExtendedSearchResponse, +): Promise { + if (includePrivateChannels()) { + return response + } + + const publicIds = await getPublicChannelIds(workspaceId) + return { + ...response, + items: response.items.filter((item) => !item.channelId || publicIds.has(item.channelId)), + } +} + +export async function runSearchCommand( + workspaceRef: string | undefined, + options: SearchRequestOptions, +): Promise { + const workspaceId = await resolveWorkspaceId(workspaceRef, options.workspace) + const params = await buildSearchParams(workspaceId, options) + const response = await fetchSearchPages(params, options.all) + return { + workspaceId, + response: await filterVisibleSearchResults(workspaceId, response), + } +} + +function buildSearchResultUrl( + workspaceId: number, + result: { + type: string + threadId?: number | null + channelId?: number | null + conversationId?: number | null + commentId?: number | null + }, +): string { + if (result.type === 'thread' && result.threadId && result.channelId) { + return getFullTwistURL({ + workspaceId, + channelId: result.channelId, + threadId: result.threadId, + }) + } + if (result.type === 'comment' && result.threadId && result.channelId && result.commentId) { + return getFullTwistURL({ + workspaceId, + channelId: result.channelId, + threadId: result.threadId, + commentId: result.commentId, + }) + } + if (result.type === 'message' && result.conversationId) { + return getFullTwistURL({ workspaceId, conversationId: result.conversationId }) + } + return `https://twist.com/a/${workspaceId}` +} + +type SearchOutputOptions = Pick + +export function printSearchCommandResults( + workspaceId: number, + response: ExtendedSearchResponse, + options: SearchOutputOptions, +): void { + if (response.items.length === 0) { + if (!options.all && response.hasMore && response.nextCursorMark) { + console.log('No public results on this page.') + console.log( + colors.timestamp(`More results available. Use --cursor ${response.nextCursorMark}`), + ) + } else { + console.log('No results found.') + } + return + } + + const resultsWithUrls = response.items.map((result) => ({ + ...result, + url: buildSearchResultUrl(workspaceId, result), + })) + + if (options.json) { + console.log( + formatJson( + { + results: resultsWithUrls, + nextCursor: response.nextCursorMark || null, + }, + undefined, + options.full, + ), + ) + return + } + + if (options.ndjson) { + for (const result of resultsWithUrls) { + console.log(JSON.stringify(result)) + } + if (response.nextCursorMark) { + console.log(JSON.stringify({ _meta: true, nextCursor: response.nextCursorMark })) + } + return + } + + for (const result of resultsWithUrls) { + const type = colors.channel(`[${result.type}]`) + const title = result.title || result.snippet.slice(0, 50) + const time = colors.timestamp(formatRelativeDate(result.snippetLastUpdated)) + + console.log(`${type} ${title}`) + console.log(` ${colors.timestamp(result.snippet.slice(0, 100))}`) + console.log(` ${time} ${colors.url(result.url)}`) + console.log('') + } + + if (!options.all && response.hasMore) { + console.log( + colors.timestamp(`More results available. Use --cursor ${response.nextCursorMark}`), + ) + } +} diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 9d79956..0009fcb 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -3,7 +3,7 @@ import packageJson from '../../../package.json' with { type: 'json' } export const SKILL_NAME = 'twist-cli' export const SKILL_DESCRIPTION = - 'Twist messaging CLI. View and respond to inbox threads, channel threads, direct messages, and group conversations; search, react, archive, mute, and manage workspaces. Use when the user mentions Twist, asks about their inbox, threads, DMs, channels, or wants to read or send Twist messages.' + 'Twist messaging CLI. View and respond to inbox threads, channel threads, direct messages, mentions, and group conversations; search, react, archive, mute, and manage workspaces. Use when the user mentions Twist, asks about their inbox, mentions, threads, DMs, channels, or wants to read or send Twist messages.' export const SKILL_AUTHOR = 'Doist' @@ -172,6 +172,9 @@ Alias: \`tw message\` works the same as \`tw msg\`. ## Search \`\`\`bash +tw mentions # Show content mentioning current user +tw mentions --since 2026-04-01 --all # Fetch every mention since a date +tw mentions --type threads --json # Limit mentions to threads tw search "query" # Search content tw search "query" --type threads # Filter: threads, messages, or all tw search "query" --author # Filter by author @@ -184,6 +187,7 @@ tw search "query" --until # Content until date tw search "query" --channel # Filter by channel refs (comma-separated) tw search "query" --limit # Max results (default: 50) tw search "query" --cursor # Pagination cursor +tw search "query" --all # Fetch all result pages \`\`\` ## Users, Channels & Groups @@ -355,6 +359,7 @@ tw thread done **Search and review:** \`\`\`bash +tw mentions --since 2026-04-01 --all --json tw search "deployment" --type threads --json tw thread view \`\`\` From 22ffc8da387c6f21e49611399ece7de2f577d3d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Tue, 28 Apr 2026 18:34:54 -0400 Subject: [PATCH 2/2] fix: address search review feedback --- src/commands/mentions.test.ts | 30 +++++++++++++++ src/commands/mentions.ts | 36 ++++++------------ src/commands/search.ts | 42 ++++++++------------- src/lib/search-command.ts | 69 ++++++++++++++++++++++++++++------- 4 files changed, 113 insertions(+), 64 deletions(-) diff --git a/src/commands/mentions.test.ts b/src/commands/mentions.test.ts index 31c3361..329f7e7 100644 --- a/src/commands/mentions.test.ts +++ b/src/commands/mentions.test.ts @@ -113,4 +113,34 @@ describe('mentions', () => { logSpy.mockRestore() }) + + it('emits an empty JSON payload when no mentions match', async () => { + const program = createProgram() + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'mentions', '--json']) + + expect(logSpy).toHaveBeenCalledTimes(1) + expect(JSON.parse(logSpy.mock.calls[0][0])).toEqual({ + results: [], + nextCursor: null, + }) + + logSpy.mockRestore() + }) + + it('emits NDJSON metadata when no mentions match', async () => { + const program = createProgram() + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'mentions', '--ndjson']) + + expect(logSpy).toHaveBeenCalledTimes(1) + expect(JSON.parse(logSpy.mock.calls[0][0])).toEqual({ + _meta: true, + nextCursor: null, + }) + + logSpy.mockRestore() + }) }) diff --git a/src/commands/mentions.ts b/src/commands/mentions.ts index f8070bb..8c257ee 100644 --- a/src/commands/mentions.ts +++ b/src/commands/mentions.ts @@ -1,6 +1,6 @@ -import { Command, Option } from 'commander' -import { withCaseInsensitiveChoices } from '../lib/completion.js' +import { Command } from 'commander' import { + addSharedSearchOptions, printSearchCommandResults, runSearchCommand, type SearchCommandOptions, @@ -19,28 +19,16 @@ async function mentions( } export function registerMentionsCommand(program: Command): void { - program - .command('mentions [workspace-ref]') - .description('Show content mentioning the current user') - .option('--workspace ', 'Workspace ID or name') - .option('--channel ', 'Filter by channels (comma-separated refs)') - .option('--author ', 'Filter by author (comma-separated refs)') - .option('--to ', 'Messages sent to user (comma-separated refs)') - .addOption( - withCaseInsensitiveChoices( - new Option('--type ', 'Filter: threads, messages, or all'), - ['threads', 'messages', 'all'], - ), - ) - .option('--conversation ', 'Limit to conversations (comma-separated refs)') - .option('--since ', 'Content from date') - .option('--until ', 'Content until date') - .option('--limit ', 'Max results per page (default: 50)') - .option('--cursor ', 'Pagination cursor') - .option('--all', 'Fetch all pages of results') - .option('--json', 'Output as JSON') - .option('--ndjson', 'Output as newline-delimited JSON') - .option('--full', 'Include all fields in JSON output') + const command = addSharedSearchOptions( + program + .command('mentions [workspace-ref]') + .description('Show content mentioning the current user'), + { + limitDescription: 'Max results per page (default: 50)', + }, + ) + + command .addHelpText( 'after', ` diff --git a/src/commands/search.ts b/src/commands/search.ts index 3d9241a..8335afe 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,6 +1,6 @@ -import { Command, Option } from 'commander' -import { withCaseInsensitiveChoices } from '../lib/completion.js' +import { Command } from 'commander' import { + addSharedSearchOptions, printSearchCommandResults, runSearchCommand, type SearchCommandOptions, @@ -26,30 +26,20 @@ async function search( } export function registerSearchCommand(program: Command): void { - program - .command('search [workspace-ref]') - .description('Search content across a workspace') - .option('--workspace ', 'Workspace ID or name') - .option('--channel ', 'Filter by channels (comma-separated refs)') - .option('--author ', 'Filter by author (comma-separated refs)') - .option('--to ', 'Messages sent TO user (comma-separated refs)') - .addOption( - withCaseInsensitiveChoices( - new Option('--type ', 'Filter: threads, messages, or all'), - ['threads', 'messages', 'all'], - ), - ) - .option('--title-only', 'Search in thread titles only') - .option('--conversation ', 'Limit to conversations (comma-separated refs)') - .option('--mention-me', 'Only results mentioning current user') - .option('--since ', 'Content from date') - .option('--until ', 'Content until date') - .option('--limit ', 'Max results (default: 50)') - .option('--cursor ', 'Pagination cursor') - .option('--all', 'Fetch all pages of results') - .option('--json', 'Output as JSON') - .option('--ndjson', 'Output as newline-delimited JSON') - .option('--full', 'Include all fields in JSON output') + const command = addSharedSearchOptions( + program + .command('search [workspace-ref]') + .description('Search content across a workspace'), + { + addUniqueFilters: (command) => { + command + .option('--title-only', 'Search in thread titles only') + .option('--mention-me', 'Only results mentioning current user') + }, + }, + ) + + command .addHelpText( 'after', ` diff --git a/src/lib/search-command.ts b/src/lib/search-command.ts index adb4f4e..1f4dfd4 100644 --- a/src/lib/search-command.ts +++ b/src/lib/search-command.ts @@ -1,5 +1,7 @@ import { getFullTwistURL, type SearchResult } from '@doist/twist-sdk' +import { Command, Option } from 'commander' import { getCurrentWorkspaceId } from './api.js' +import { withCaseInsensitiveChoices } from './completion.js' import { formatRelativeDate } from './dates.js' import { CliError } from './errors.js' import { includePrivateChannels } from './global-args.js' @@ -59,6 +61,43 @@ export interface SearchCommandResult { response: ExtendedSearchResponse } +type SharedSearchOptionConfig = { + addUniqueFilters?: (command: Command) => void + limitDescription?: string +} + +export function addSharedSearchOptions( + command: T, + config: SharedSearchOptionConfig = {}, +): T { + const limitDescription = config.limitDescription ?? 'Max results (default: 50)' + + command + .option('--workspace ', 'Workspace ID or name') + .option('--channel ', 'Filter by channels (comma-separated refs)') + .option('--author ', 'Filter by author (comma-separated refs)') + .option('--to ', 'Messages sent to user (comma-separated refs)') + .addOption( + withCaseInsensitiveChoices( + new Option('--type ', 'Filter: threads, messages, or all'), + ['threads', 'messages', 'all'], + ), + ) + + config.addUniqueFilters?.(command) + + return command + .option('--conversation ', 'Limit to conversations (comma-separated refs)') + .option('--since ', 'Content from date') + .option('--until ', 'Content until date') + .option('--limit ', limitDescription) + .option('--cursor ', 'Pagination cursor') + .option('--all', 'Fetch all pages of results') + .option('--json', 'Output as JSON') + .option('--ndjson', 'Output as newline-delimited JSON') + .option('--full', 'Include all fields in JSON output') +} + async function resolveWorkspaceId( workspaceRef: string | undefined, workspaceOption: string | undefined, @@ -207,18 +246,6 @@ export function printSearchCommandResults( response: ExtendedSearchResponse, options: SearchOutputOptions, ): void { - if (response.items.length === 0) { - if (!options.all && response.hasMore && response.nextCursorMark) { - console.log('No public results on this page.') - console.log( - colors.timestamp(`More results available. Use --cursor ${response.nextCursorMark}`), - ) - } else { - console.log('No results found.') - } - return - } - const resultsWithUrls = response.items.map((result) => ({ ...result, url: buildSearchResultUrl(workspaceId, result), @@ -242,8 +269,22 @@ export function printSearchCommandResults( for (const result of resultsWithUrls) { console.log(JSON.stringify(result)) } - if (response.nextCursorMark) { - console.log(JSON.stringify({ _meta: true, nextCursor: response.nextCursorMark })) + if (resultsWithUrls.length === 0 || response.nextCursorMark) { + console.log( + JSON.stringify({ _meta: true, nextCursor: response.nextCursorMark || null }), + ) + } + return + } + + if (resultsWithUrls.length === 0) { + if (!options.all && response.hasMore && response.nextCursorMark) { + console.log('No public results on this page.') + console.log( + colors.timestamp(`More results available. Use --cursor ${response.nextCursorMark}`), + ) + } else { + console.log('No results found.') } return }