Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ dist/
*.tsbuildinfo
.DS_Store
.claude/settings.local.json
.claude/scheduled_tasks.lock
10 changes: 10 additions & 0 deletions skills/twist-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,14 @@ tw channel threads <ref> --since 2026-01-01 # Filter by last-updated date (ISO)
tw channel threads <ref> --limit 20 # Max threads per page (default: 50)
tw channel threads <ref> --limit 20 --cursor <cursor-from-prev> # Paginate
tw channel threads <ref> --json # { results, nextCursor } with isUnread + url
tw channel members <channel-ref> # List members + groups whose membership ⊆ channel
tw channel members <channel-ref> --json # JSON with id, name, members, groupsFullyInChannel
tw channel members add <channel-ref> <ref...> # Add users and/or group:<ref> (group expansion is one-shot)
tw channel members add <channel-ref> alice group:Frontend # Mix of user refs and group: prefix
tw channel members add <channel-ref> id:123 --dry-run # Preview the diff
tw channel members remove <channel-ref> <ref...> # Remove users and/or group:<ref>
tw channel members set <channel-ref> <ref...> # Replace membership; dry-run by default
tw channel members set <channel-ref> group:Squad --apply # Mutate. Refuses to remove self unless --include-self
tw groups # List workspace groups
tw groups --search "frontend" # Filter groups by name (case-insensitive)
tw groups --json # JSON output
Expand All @@ -242,6 +250,8 @@ If a channel is not found in `tw channels`, widen with broader listings such as

`tw channel threads` returns every thread in the channel; pagination filters (`--limit`, `--cursor`, `--since`, `--until`, `--unread`) are applied client-side after fetch. `--archive-filter` is applied server-side. Results are sorted newest-first by last activity. In `--json` / `--ndjson`, the response includes a `nextCursor` string (opaque) you can pass via `--cursor` to fetch the next page; NDJSON emits the cursor as a final `{ "_meta": true, "nextCursor": "..." }` line.

`tw channel members add`, `remove`, and `set` accept user refs (id:N, email, name) and `group:<ref>` mixed in any order. Groups are expanded to their current users at call time — the group is **not** persistently linked to the channel, so users added later to the group will not auto-join. `set` replaces the channel's membership with the resolved ref set; it is dry-run by default, pass `--apply` to mutate and `--include-self` to allow the diff to remove the acting user. `tw channel members` (with no subcommand) shows the user list plus a "groups fully in channel" hint — groups whose entire membership is currently present in the channel.

## Away Status

```bash
Expand Down
202 changes: 202 additions & 0 deletions src/commands/channel/add.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { Command } from 'commander'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mockGetChannel = vi.fn()

const apiMocks = vi.hoisted(() => ({
getCurrentWorkspaceId: vi.fn().mockResolvedValue(1),
getTwistClient: vi.fn(),
getSessionUser: vi.fn(),
getWorkspaceGroups: vi.fn(),
getOptionalBatchData: vi.fn(),
addUsersToChannel: vi.fn(),
removeUsersFromChannel: vi.fn(),
}))

vi.mock('../../lib/api.js', () => apiMocks)

const refsMocks = vi.hoisted(() => ({
resolveChannelRef: vi.fn(),
resolveChannelMemberRefs: vi.fn(),
}))

vi.mock('../../lib/refs.js', () => refsMocks)

vi.mock('chalk')

import { registerChannelCommand } from './index.js'

function createProgram() {
const program = new Command()
program.exitOverride()
registerChannelCommand(program)
return program
}

const sampleChannel = {
id: 500,
name: 'general',
workspaceId: 1,
userIds: [1, 2, 3],
creator: 1,
public: true,
archived: false,
created: new Date(),
version: 1,
url: 'https://twist.com/a/1/ch/500',
}

beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks()
apiMocks.getCurrentWorkspaceId.mockResolvedValue(1)
apiMocks.getTwistClient.mockResolvedValue({
channels: { getChannel: mockGetChannel },
})
refsMocks.resolveChannelRef.mockResolvedValue(sampleChannel)
})

describe('tw channel members add', () => {
it('adds users (no group expansion)', async () => {
refsMocks.resolveChannelMemberRefs.mockResolvedValue({
userIds: [4, 5],
expandedFrom: [],
})
const program = createProgram()
vi.spyOn(console, 'log').mockImplementation(() => {})

await program.parseAsync([
'node',
'tw',
'channel',
'members',
'add',
'general',
'dave@d.com',
'eve@d.com',
])

expect(refsMocks.resolveChannelMemberRefs).toHaveBeenCalledWith(
['dave@d.com', 'eve@d.com'],
1,
)
expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4, 5])
})

it('expands group:<ref> and dedupes', async () => {
refsMocks.resolveChannelMemberRefs.mockResolvedValue({
userIds: [4, 5, 6],
expandedFrom: [{ groupId: 200, groupName: 'Backend', userIds: [4, 5] }],
})
const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await program.parseAsync([
'node',
'tw',
'channel',
'members',
'add',
'general',
'group:Backend',
'frank',
])

expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4, 5, 6])
const text = consoleSpy.mock.calls.map((c) => c[0]).join('\n')
expect(text).toContain('Backend')
})

it('skips users already in channel', async () => {
// channel has [1,2,3]; requesting [2,4] → only 4 should be added, 2 skipped
refsMocks.resolveChannelMemberRefs.mockResolvedValue({
userIds: [2, 4],
expandedFrom: [],
})
const program = createProgram()
vi.spyOn(console, 'log').mockImplementation(() => {})

await program.parseAsync([
'node',
'tw',
'channel',
'members',
'add',
'general',
'id:2',
'id:4',
])

expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4])
})

it('makes no API call when all users already members', async () => {
refsMocks.resolveChannelMemberRefs.mockResolvedValue({
userIds: [1, 2],
expandedFrom: [],
})
const program = createProgram()
vi.spyOn(console, 'log').mockImplementation(() => {})

await program.parseAsync([
'node',
'tw',
'channel',
'members',
'add',
'general',
'id:1',
'id:2',
])

expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled()
})

it('--dry-run does not mutate', async () => {
refsMocks.resolveChannelMemberRefs.mockResolvedValue({
userIds: [4, 5],
expandedFrom: [],
})
const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await program.parseAsync([
'node',
'tw',
'channel',
'members',
'add',
'general',
'id:4',
'id:5',
'--dry-run',
])

expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled()
expect(consoleSpy.mock.calls.some((c) => String(c[0]).includes('[dry-run]'))).toBe(true)
})

it('--json shape includes added/alreadyMembers', async () => {
refsMocks.resolveChannelMemberRefs.mockResolvedValue({
userIds: [2, 4],
expandedFrom: [],
})
const program = createProgram()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await program.parseAsync([
'node',
'tw',
'channel',
'members',
'add',
'general',
'id:2',
'id:4',
'--json',
])

const output = JSON.parse(consoleSpy.mock.calls[0][0])
expect(output).toMatchObject({ id: 500, added: [4], alreadyMembers: [2] })
})
})
9 changes: 9 additions & 0 deletions src/commands/channel/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { type ChannelMutationOptions, mutateChannelMembership } from './membership-helpers.js'

export async function addChannelMembers(
channelRef: string,
refs: string[],
options: ChannelMutationOptions,
): Promise<void> {
return mutateChannelMembership(channelRef, refs, 'add', options)
}
94 changes: 94 additions & 0 deletions src/commands/channel/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Command, Option } from 'commander'
import { withCaseInsensitiveChoices } from '../../lib/completion.js'
import { addChannelMembers } from './add.js'
import { listChannels } from './list.js'
import { listChannelMembers } from './members.js'
import { removeChannelMembers } from './remove.js'
import { setChannelMembers } from './set.js'
import { showChannelThreads } from './threads.js'

export function registerChannelCommand(program: Command): void {
Expand Down Expand Up @@ -86,4 +90,94 @@ Notes:
and --unread are applied client-side; --archive-filter is applied server-side.`,
)
.action(showChannelThreads)

const members = channel
.command('members')
.description('Channel membership operations (list, add, remove, set)')

members
.command('list <channel-ref>', { isDefault: true })
.description("List a channel's members and groups fully present in the channel")
.option('--json', 'Output as JSON')
.option('--ndjson', 'Output as newline-delimited JSON')
.option('--full', 'Include all fields in JSON output')
.addHelpText(
'after',
`
Examples:
tw channel members 12345
tw channel members "general" --json
tw channel members add 12345 alice group:Design
tw channel members remove 12345 alice
tw channel members set 12345 group:Squad --apply

Notes:
"Groups fully in channel" lists groups whose entire current membership is
already in the channel — a hint, not a persistent link.`,
)
.action(listChannelMembers)

members
.command('add <channel-ref> [refs...]')
.description('Add users and/or groups to a channel')
.option('--dry-run', 'Show what would change without changing')
.option('--json', 'Output result as JSON')
.option('--full', 'Include the full updated channel in JSON output')
.addHelpText(
'after',
`
Examples:
tw channel members add 12345 alice@doist.com bob@doist.com
tw channel members add "general" group:Frontend
tw channel members add 12345 alice group:Design id:789 --json

Notes:
Refs accept user identifiers (id:N, email, name) or "group:<ref>" to expand
a group to its current members. Group expansion is one-shot — users added
later to the group will not auto-join the channel.`,
)
.action(addChannelMembers)

members
.command('remove <channel-ref> [refs...]')
.description('Remove users and/or groups from a channel')
.option('--dry-run', 'Show what would change without changing')
.option('--json', 'Output result as JSON')
.option('--full', 'Include the full updated channel in JSON output')
.addHelpText(
'after',
`
Examples:
tw channel members remove 12345 alice@doist.com
tw channel members remove "general" group:Frontend

Notes:
Refs accept user identifiers (id:N, email, name) or "group:<ref>" to expand
a group to its current members.`,
)
.action(removeChannelMembers)

members
.command('set <channel-ref> [refs...]')
.description('Replace channel membership with the resolved set of refs')
.option('--apply', 'Actually mutate (otherwise dry-run)')
.option('--include-self', 'Allow set to remove the acting user')
.option('--dry-run', 'Force dry-run (default behaviour)')
.option('--json', 'Output result as JSON')
.option('--full', 'Include the full updated channel in JSON output')
.addHelpText(
'after',
`
Examples:
tw channel members set 12345 group:Frontend group:Design
tw channel members set "general" alice bob carol --apply
tw channel members set 12345 group:Squad --apply --include-self

Notes:
Dry-run by default. Pass --apply to mutate.
Refuses to remove the acting user unless --include-self is also passed.
Group expansion is one-shot — users added later to a referenced group will
not auto-join the channel.`,
)
.action(setChannelMembers)
}
Loading
Loading