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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ref> # view thread with comments
tw thread view <ref> --comment 123 # view a specific comment
tw thread reply <ref> # reply to a thread
Expand All @@ -109,6 +111,7 @@ tw conversation unread # list unread conversations
tw conversation view <ref> # view conversation messages
tw msg view <ref> # view a conversation message
tw search "keyword" # search across workspace
tw search "keyword" --all # fetch all result pages
tw react thread <ref> 👍 # add reaction
tw away # show away status
tw away set vacation 2026-03-20 # set away until date
Expand Down
7 changes: 6 additions & 1 deletion skills/twist-cli/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <ref> # Filter by author
Expand All @@ -180,6 +183,7 @@ tw search "query" --until <date> # Content until date
tw search "query" --channel <refs> # Filter by channel refs (comma-separated)
tw search "query" --limit <n> # Max results (default: 50)
tw search "query" --cursor <cur> # Pagination cursor
tw search "query" --all # Fetch all result pages
```

## Users, Channels & Groups
Expand Down Expand Up @@ -351,6 +355,7 @@ tw thread done <id>

**Search and review:**
```bash
tw mentions --since 2026-04-01 --all --json
tw search "deployment" --type threads --json
tw thread view <thread-id>
```
Expand Down
146 changes: 146 additions & 0 deletions src/commands/mentions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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()
})

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()
})
})
41 changes: 41 additions & 0 deletions src/commands/mentions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Command } from 'commander'
import {
addSharedSearchOptions,
printSearchCommandResults,
runSearchCommand,
type SearchCommandOptions,
} from '../lib/search-command.js'

async function mentions(
workspaceRef: string | undefined,
options: SearchCommandOptions,
): Promise<void> {
const { workspaceId, response } = await runSearchCommand(workspaceRef, {
...options,
mentionSelf: true,
})

printSearchCommandResults(workspaceId, response, options)
}

export function registerMentionsCommand(program: Command): void {
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',
`
Examples:
tw mentions
tw mentions --since 2026-04-01 --all
tw mentions --type threads --json`,
)
.action(mentions)
}
37 changes: 37 additions & 0 deletions src/commands/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
Loading
Loading