diff --git a/.gitignore b/.gitignore index fc5abe2..ab26531 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ *.tsbuildinfo .DS_Store .claude/settings.local.json +.claude/scheduled_tasks.lock diff --git a/skills/twist-cli/SKILL.md b/skills/twist-cli/SKILL.md index 1572e7c..1ee9edb 100644 --- a/skills/twist-cli/SKILL.md +++ b/skills/twist-cli/SKILL.md @@ -217,6 +217,14 @@ tw channel threads --since 2026-01-01 # Filter by last-updated date (ISO) tw channel threads --limit 20 # Max threads per page (default: 50) tw channel threads --limit 20 --cursor # Paginate tw channel threads --json # { results, nextCursor } with isUnread + url +tw channel members # List members + groups whose membership ⊆ channel +tw channel members --json # JSON with id, name, members, groupsFullyInChannel +tw channel members add # Add users and/or group: (group expansion is one-shot) +tw channel members add alice group:Frontend # Mix of user refs and group: prefix +tw channel members add id:123 --dry-run # Preview the diff +tw channel members remove # Remove users and/or group: +tw channel members set # Replace membership; dry-run by default +tw channel members set 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 @@ -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:` 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 diff --git a/src/commands/channel/add.test.ts b/src/commands/channel/add.test.ts new file mode 100644 index 0000000..915109e --- /dev/null +++ b/src/commands/channel/add.test.ts @@ -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: 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] }) + }) +}) diff --git a/src/commands/channel/add.ts b/src/commands/channel/add.ts new file mode 100644 index 0000000..ce5a44d --- /dev/null +++ b/src/commands/channel/add.ts @@ -0,0 +1,9 @@ +import { type ChannelMutationOptions, mutateChannelMembership } from './membership-helpers.js' + +export async function addChannelMembers( + channelRef: string, + refs: string[], + options: ChannelMutationOptions, +): Promise { + return mutateChannelMembership(channelRef, refs, 'add', options) +} diff --git a/src/commands/channel/index.ts b/src/commands/channel/index.ts index fa15046..03cfad5 100644 --- a/src/commands/channel/index.ts +++ b/src/commands/channel/index.ts @@ -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 { @@ -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 ', { 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 [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:" 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 [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:" to expand + a group to its current members.`, + ) + .action(removeChannelMembers) + + members + .command('set [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) } diff --git a/src/commands/channel/members.test.ts b/src/commands/channel/members.test.ts new file mode 100644 index 0000000..d9dbae0 --- /dev/null +++ b/src/commands/channel/members.test.ts @@ -0,0 +1,174 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockBatch = vi.fn() +const mockGetUserById = vi.fn() +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', +} + +const frontendGroup = { id: 100, name: 'Frontend', workspaceId: 1, userIds: [1, 2], version: 1 } +const backendGroup = { id: 200, name: 'Backend', workspaceId: 1, userIds: [4, 5], version: 1 } +// Group whose membership is a subset of channel's userIds [1,2,3] +const allInChannel = { id: 300, name: 'Core', workspaceId: 1, userIds: [1, 2], version: 1 } + +beforeEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + // Return a tagged sentinel per user id so we can assert that batch() received the + // exact requests built by the SDK call. + mockGetUserById.mockImplementation((args: { userId: number }) => ({ + __req: 'getUserById', + userId: args.userId, + })) + apiMocks.getTwistClient.mockResolvedValue({ + workspaceUsers: { getUserById: mockGetUserById }, + channels: { getChannel: mockGetChannel }, + batch: mockBatch, + }) + apiMocks.getOptionalBatchData.mockImplementation((response: { code: number; data: unknown }) => + response && response.code < 400 ? (response.data ?? null) : null, + ) + apiMocks.getWorkspaceGroups.mockResolvedValue([frontendGroup, backendGroup, allInChannel]) + refsMocks.resolveChannelRef.mockResolvedValue(sampleChannel) +}) + +describe('tw channel members (list)', () => { + beforeEach(() => { + mockBatch.mockResolvedValue([ + { code: 200, data: { id: 1, name: 'Alice', email: 'a@d.com' } }, + { code: 200, data: { id: 2, name: 'Bob', email: 'b@d.com' } }, + { code: 200, data: { id: 3, name: 'Carol', email: 'c@d.com' } }, + ]) + }) + + it('lists users and groups fully in channel', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'members', 'general']) + + const text = consoleSpy.mock.calls.map((c) => c[0]).join('\n') + expect(text).toContain('Alice') + expect(text).toContain('Bob') + expect(text).toContain('Carol') + // "Core" group's userIds [1,2] are all in channel userIds [1,2,3] + expect(text).toContain('Core') + expect(text).toContain('Frontend') + // Backend's [4,5] are not in channel → should not appear in groups section + expect(text).not.toMatch(/Backend\s+\(/) + }) + + it('outputs JSON with default shape', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'members', 'general', '--json']) + + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(output.id).toBe(500) + expect(output.members).toHaveLength(3) + expect(output.members[0]).toMatchObject({ id: 1, name: 'Alice' }) + expect(output.groupsFullyInChannel.map((g: { id: number }) => g.id).sort()).toEqual([ + 100, 300, + ]) + }) + + it('batches one getUserById request per channel member', async () => { + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'members', 'general', '--json']) + + // Verify the batch wiring: getUserById is built for each userId, then those + // requests are passed verbatim into batch(). + expect(mockGetUserById).toHaveBeenCalledTimes(3) + expect(mockGetUserById).toHaveBeenNthCalledWith( + 1, + { workspaceId: 1, userId: 1 }, + { batch: true }, + ) + expect(mockBatch).toHaveBeenCalledWith( + { __req: 'getUserById', userId: 1 }, + { __req: 'getUserById', userId: 2 }, + { __req: 'getUserById', userId: 3 }, + ) + }) + + it('ndjson default shape matches json default shape', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'members', 'general', '--ndjson']) + + const line = consoleSpy.mock.calls[0][0].split('\n').filter(Boolean)[0] + const output = JSON.parse(line) + expect(output.id).toBe(500) + expect(output.members).toHaveLength(3) + // Slim shape — no raw SDK fields like `creator`, `version` unless --full was passed. + expect(output).not.toHaveProperty('creator') + expect(output).not.toHaveProperty('version') + }) + + it('--full ndjson includes raw channel fields', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'members', + 'general', + '--ndjson', + '--full', + ]) + + const line = consoleSpy.mock.calls[0][0].split('\n').filter(Boolean)[0] + const output = JSON.parse(line) + expect(output).toHaveProperty('creator') + expect(output).toHaveProperty('version') + }) +}) diff --git a/src/commands/channel/members.ts b/src/commands/channel/members.ts new file mode 100644 index 0000000..1226911 --- /dev/null +++ b/src/commands/channel/members.ts @@ -0,0 +1,70 @@ +import { getCurrentWorkspaceId, getWorkspaceGroups } from '../../lib/api.js' +import type { ViewOptions } from '../../lib/options.js' +import { colors, formatJson, formatNdjson, pluralize } from '../../lib/output.js' +import { resolveChannelRef } from '../../lib/refs.js' +import { channelUserIds, fetchUsersByIds, groupsFullyInChannel } from './membership-helpers.js' + +export async function listChannelMembers( + channelRef: string, + options: ViewOptions & { full?: boolean }, +): Promise { + const workspaceId = await getCurrentWorkspaceId() + const [channel, groups] = await Promise.all([ + resolveChannelRef(channelRef, workspaceId), + getWorkspaceGroups(workspaceId), + ]) + const userIds = channelUserIds(channel) + const userMap = await fetchUsersByIds(workspaceId, userIds) + + const userIdSet = new Set(userIds) + const fullyInChannel = groupsFullyInChannel(groups, userIdSet) + + const members = userIds.map((id) => { + const user = userMap.get(id) + return { id, name: user?.name ?? null, email: user?.email ?? null } + }) + + const slimPayload = { + id: channel.id, + name: channel.name, + workspaceId: channel.workspaceId, + members, + groupsFullyInChannel: fullyInChannel.map((g) => ({ + id: g.id, + name: g.name, + userIds: g.userIds, + })), + } + const fullPayload = { ...channel, members, groupsFullyInChannel: fullyInChannel } + + if (options.json) { + console.log(formatJson(options.full ? fullPayload : slimPayload)) + return + } + + if (options.ndjson) { + console.log(formatNdjson([options.full ? fullPayload : slimPayload])) + return + } + + console.log(colors.channel(channel.name)) + console.log(colors.timestamp(`id:${channel.id}`)) + console.log('') + console.log(`${members.length} ${pluralize(members.length, 'member')}`) + for (const m of members) { + const name = m.name ?? `user:${m.id}` + const email = m.email ? colors.timestamp(`<${m.email}>`) : '' + const id = colors.timestamp(`id:${m.id}`) + console.log(` ${id} ${colors.author(name)} ${email}`.trimEnd()) + } + + if (fullyInChannel.length > 0) { + console.log('') + console.log(`Groups fully in channel (${fullyInChannel.length}):`) + for (const g of fullyInChannel) { + console.log( + ` ${colors.timestamp(`id:${g.id}`)} ${g.name} ${colors.timestamp(`(${g.userIds.length} ${pluralize(g.userIds.length, 'member')})`)}`, + ) + } + } +} diff --git a/src/commands/channel/membership-helpers.ts b/src/commands/channel/membership-helpers.ts new file mode 100644 index 0000000..6746bb8 --- /dev/null +++ b/src/commands/channel/membership-helpers.ts @@ -0,0 +1,157 @@ +import type { Channel, Group, WorkspaceUser } from '@doist/twist-sdk' +import { + addUsersToChannel, + getCurrentWorkspaceId, + getOptionalBatchData, + getTwistClient, + removeUsersFromChannel, +} from '../../lib/api.js' +import type { MutationOptions } from '../../lib/options.js' +import { colors, formatJson, pluralize, printDryRun } from '../../lib/output.js' +import { resolveChannelMemberRefs, resolveChannelRef } from '../../lib/refs.js' + +export type ChannelMutationOptions = MutationOptions + +export type ExpandedGroup = { groupId: number; groupName: string; userIds: number[] } + +export function channelUserIds(channel: Channel): number[] { + return channel.userIds ?? [] +} + +export async function fetchUsersByIds( + workspaceId: number, + userIds: number[], +): Promise> { + if (userIds.length === 0) return new Map() + const client = await getTwistClient() + const calls = userIds.map((userId) => + client.workspaceUsers.getUserById({ workspaceId, userId }, { batch: true }), + ) + const responses = await client.batch(...calls) + const map = new Map() + userIds.forEach((id, i) => { + const user = getOptionalBatchData(responses[i], `user ${id}`) + if (user) map.set(id, user) + }) + return map +} + +export function logExpansion(expandedFrom: ExpandedGroup[]): void { + for (const g of expandedFrom) { + console.log( + colors.timestamp( + `Expanded group "${g.groupName}" → ${g.userIds.length} ${pluralize(g.userIds.length, 'user')}`, + ), + ) + } +} + +export function describeExpansion(expandedFrom: ExpandedGroup[]): string | undefined { + if (expandedFrom.length === 0) return undefined + return expandedFrom + .map((g) => `${g.groupName} (id:${g.groupId}, ${g.userIds.length} users)`) + .join('\n') +} + +export function groupsFullyInChannel(groups: Group[], channelUserIdSet: Set): Group[] { + return groups.filter( + (g) => g.userIds.length > 0 && g.userIds.every((id) => channelUserIdSet.has(id)), + ) +} + +/** + * Shared add/remove mutation flow. Resolves the channel and the requested refs + * concurrently, diffs against current membership, then either previews + * (`--dry-run`) or applies the mutation and prints / emits JSON. + */ +export async function mutateChannelMembership( + channelRef: string, + refs: string[], + action: 'add' | 'remove', + options: ChannelMutationOptions, +): Promise { + const workspaceId = await getCurrentWorkspaceId() + const [channel, { userIds: requested, expandedFrom }] = await Promise.all([ + resolveChannelRef(channelRef, workspaceId), + resolveChannelMemberRefs(refs, workspaceId), + ]) + + const current = new Set(channelUserIds(channel)) + const actionable = + action === 'add' + ? requested.filter((id) => !current.has(id)) + : requested.filter((id) => current.has(id)) + const skipped = + action === 'add' + ? requested.filter((id) => current.has(id)) + : requested.filter((id) => !current.has(id)) + + const actionLabel = action === 'add' ? 'add users to' : 'remove users from' + const skippedLabel = action === 'add' ? 'Already members' : 'Not members' + + if (options.dryRun) { + printDryRun(`${actionLabel} channel`, { + Channel: `${channel.name} (id:${channel.id})`, + 'Expanded from groups': describeExpansion(expandedFrom), + [`Users to ${action}`]: actionable.length > 0 ? actionable.join(', ') : '(none)', + [skippedLabel]: skipped.length > 0 ? skipped.join(', ') : undefined, + }) + return + } + + if (actionable.length > 0) { + if (action === 'add') { + await addUsersToChannel(channel.id, actionable) + } else { + await removeUsersFromChannel(channel.id, actionable) + } + } + + const newMemberCount = + action === 'add' + ? channelUserIds(channel).length + actionable.length + : channelUserIds(channel).length - actionable.length + + if (options.json) { + if (options.full) { + const client = await getTwistClient() + const updated = await client.channels.getChannel(channel.id) + console.log(formatJson(updated, 'channel', true)) + } else { + const result: Record = { + id: channel.id, + memberCount: newMemberCount, + } + if (expandedFrom.length > 0) result.expandedFrom = expandedFrom + if (action === 'add') { + result.added = actionable + result.alreadyMembers = skipped + } else { + result.removed = actionable + result.notMembers = skipped + } + console.log(formatJson(result)) + } + return + } + + const pastVerb = action === 'add' ? 'Added' : 'Removed' + const preposition = action === 'add' ? 'to' : 'from' + const noneMsg = + action === 'add' + ? `No new members added to "${channel.name}" (already in channel).` + : `No members removed from "${channel.name}" (none of the users were in channel).` + + logExpansion(expandedFrom) + + if (actionable.length === 0) { + console.log(noneMsg) + } else { + console.log( + `${pastVerb} ${actionable.length} ${pluralize(actionable.length, 'user')} ${preposition} "${channel.name}" (now ${newMemberCount} ${pluralize(newMemberCount, 'member')}).`, + ) + } + if (skipped.length > 0) { + console.log(`${skippedLabel}: ${skipped.join(', ')}`) + } +} diff --git a/src/commands/channel/remove.test.ts b/src/commands/channel/remove.test.ts new file mode 100644 index 0000000..2a011b2 --- /dev/null +++ b/src/commands/channel/remove.test.ts @@ -0,0 +1,181 @@ +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 remove', () => { + it('only removes users that are members', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 3, 99], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'members', + 'remove', + 'general', + 'id:2', + 'id:3', + 'id:99', + ]) + + expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith(500, [2, 3]) + }) + + it('makes no API call when none of the users are members', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [99, 100], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'members', + 'remove', + 'general', + 'id:99', + 'id:100', + ]) + + expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() + }) + + it('--dry-run does not mutate', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 3], + expandedFrom: [], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'members', + 'remove', + 'general', + 'id:2', + 'id:3', + '--dry-run', + ]) + + expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() + expect(consoleSpy.mock.calls.some((c) => String(c[0]).includes('[dry-run]'))).toBe(true) + }) + + it('--json shape includes removed/notMembers', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 99], + expandedFrom: [], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'members', + 'remove', + 'general', + 'id:2', + 'id:99', + '--json', + ]) + + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(output).toMatchObject({ id: 500, removed: [2], notMembers: [99] }) + }) + + it('--full fetches and prints the updated channel', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2], + expandedFrom: [], + }) + mockGetChannel.mockResolvedValue({ ...sampleChannel, userIds: [1, 3] }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'members', + 'remove', + 'general', + 'id:2', + '--json', + '--full', + ]) + + expect(mockGetChannel).toHaveBeenCalledWith(500) + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + // --full returns the full SDK channel object + expect(output.id).toBe(500) + expect(output).toHaveProperty('creator') + expect(output).toHaveProperty('version') + }) +}) diff --git a/src/commands/channel/remove.ts b/src/commands/channel/remove.ts new file mode 100644 index 0000000..ed46029 --- /dev/null +++ b/src/commands/channel/remove.ts @@ -0,0 +1,9 @@ +import { type ChannelMutationOptions, mutateChannelMembership } from './membership-helpers.js' + +export async function removeChannelMembers( + channelRef: string, + refs: string[], + options: ChannelMutationOptions, +): Promise { + return mutateChannelMembership(channelRef, refs, 'remove', options) +} diff --git a/src/commands/channel/set.test.ts b/src/commands/channel/set.test.ts new file mode 100644 index 0000000..2483846 --- /dev/null +++ b/src/commands/channel/set.test.ts @@ -0,0 +1,187 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +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.getSessionUser.mockResolvedValue({ id: 1, name: 'Me', email: 'me@d.com' }) + refsMocks.resolveChannelRef.mockResolvedValue(sampleChannel) +}) + +describe('tw channel members set', () => { + it('dry-run by default — does not mutate', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [1, 4, 5], + expandedFrom: [], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'members', + 'set', + 'general', + 'id:1', + 'id:4', + 'id:5', + ]) + + expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled() + expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() + const text = consoleSpy.mock.calls.map((c) => c[0]).join('\n') + expect(text).toContain('[dry-run]') + }) + + it('--apply computes add/remove diff', async () => { + // channel currently [1,2,3]; desired [1,4,5] → add [4,5], remove [2,3] + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [1, 4, 5], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'members', + 'set', + 'general', + 'id:1', + 'id:4', + 'id:5', + '--apply', + ]) + + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4, 5]) + expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith(500, [2, 3]) + }) + + it('refuses to remove acting user without --include-self', async () => { + // session user id:1 is in current channel; desired set omits id:1 → would remove self + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 3, 4], + expandedFrom: [], + }) + const program = createProgram() + + await expect( + program.parseAsync([ + 'node', + 'tw', + 'channel', + 'members', + 'set', + 'general', + 'id:2', + 'id:3', + 'id:4', + '--apply', + ]), + ).rejects.toMatchObject({ code: 'INVALID_VALUE' }) + }) + + it('--include-self allows removing acting user', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 3, 4], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'members', + 'set', + 'general', + 'id:2', + 'id:3', + 'id:4', + '--apply', + '--include-self', + ]) + + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4]) + expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith(500, [1]) + }) + + it('--json output shape', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [1, 4], + expandedFrom: [{ groupId: 200, groupName: 'Backend', userIds: [4] }], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'members', + 'set', + 'general', + 'id:1', + 'group:Backend', + '--apply', + '--json', + ]) + + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(output).toMatchObject({ + id: 500, + added: [4], + removed: [2, 3], + expandedFrom: [{ groupId: 200, groupName: 'Backend', userIds: [4] }], + }) + }) +}) diff --git a/src/commands/channel/set.ts b/src/commands/channel/set.ts new file mode 100644 index 0000000..344b5cd --- /dev/null +++ b/src/commands/channel/set.ts @@ -0,0 +1,98 @@ +import type { User } from '@doist/twist-sdk' +import { + addUsersToChannel, + getCurrentWorkspaceId, + getSessionUser, + getTwistClient, + removeUsersFromChannel, +} from '../../lib/api.js' +import { CliError } from '../../lib/errors.js' +import type { MutationOptions } from '../../lib/options.js' +import { formatJson, pluralize, printDryRun } from '../../lib/output.js' +import { resolveChannelMemberRefs, resolveChannelRef } from '../../lib/refs.js' +import { channelUserIds, describeExpansion, logExpansion } from './membership-helpers.js' + +export type SetOptions = MutationOptions & { + apply?: boolean + includeSelf?: boolean +} + +export async function setChannelMembers( + channelRef: string, + refs: string[], + options: SetOptions, +): Promise { + const workspaceId = await getCurrentWorkspaceId() + const [channel, sessionUser, memberRefs] = await Promise.all([ + resolveChannelRef(channelRef, workspaceId), + getSessionUser() as Promise, + resolveChannelMemberRefs(refs, workspaceId), + ]) + const { userIds: targetIds, expandedFrom } = memberRefs + const desired = new Set(targetIds) + const current = new Set(channelUserIds(channel)) + + const toAdd = [...desired].filter((id) => !current.has(id)) + const toRemoveAll = [...current].filter((id) => !desired.has(id)) + + const selfId = sessionUser.id + const wouldRemoveSelf = toRemoveAll.includes(selfId) + if (wouldRemoveSelf && !options.includeSelf) { + throw new CliError( + 'INVALID_VALUE', + `Set would remove you (id:${selfId}) from "${channel.name}".`, + [ + 'Pass --include-self to allow removing yourself, or include yourself in the ref list.', + ], + ) + } + const toRemove = + wouldRemoveSelf && !options.includeSelf + ? toRemoveAll.filter((id) => id !== selfId) + : toRemoveAll + + const isDryRun = options.dryRun || !options.apply + + if (isDryRun) { + printDryRun(`set channel membership`, { + Channel: `${channel.name} (id:${channel.id})`, + 'Expanded from groups': describeExpansion(expandedFrom), + 'To add': toAdd.length > 0 ? toAdd.join(', ') : '(none)', + 'To remove': toRemove.length > 0 ? toRemove.join(', ') : '(none)', + Note: options.apply ? undefined : 'set is dry-run by default; pass --apply to mutate.', + }) + return + } + + await Promise.all([ + toAdd.length > 0 ? addUsersToChannel(channel.id, toAdd) : Promise.resolve(), + toRemove.length > 0 ? removeUsersFromChannel(channel.id, toRemove) : Promise.resolve(), + ]) + + const newMemberCount = current.size + toAdd.length - toRemove.length + + if (options.json) { + const result: Record = { + id: channel.id, + memberCount: newMemberCount, + added: toAdd, + removed: toRemove, + } + if (expandedFrom.length > 0) result.expandedFrom = expandedFrom + if (options.full) { + const client = await getTwistClient() + const updated = await client.channels.getChannel(channel.id) + console.log(formatJson({ ...updated, ...result }, 'channel', true)) + } else { + console.log(formatJson(result)) + } + return + } + + logExpansion(expandedFrom) + console.log( + `Set "${channel.name}": +${toAdd.length} / -${toRemove.length} (now ${newMemberCount} ${pluralize(newMemberCount, 'member')}).`, + ) + if (toAdd.length > 0) console.log(` Added: ${toAdd.join(', ')}`) + if (toRemove.length > 0) console.log(` Removed: ${toRemove.join(', ')}`) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 590c6bb..3bb8a05 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -52,6 +52,8 @@ const API_SPINNER_MESSAGES: Record { + const client = await getTwistClient() + await client.channels.addUsers({ id, userIds }) +} + +export async function removeUsersFromChannel(id: number, userIds: number[]): Promise { + const client = await getTwistClient() + await client.channels.removeUsers({ id, userIds }) +} + export function clearUserCache(): void { sessionUserCache = null } diff --git a/src/lib/auth-provider.ts b/src/lib/auth-provider.ts index a4ac917..0502c45 100644 --- a/src/lib/auth-provider.ts +++ b/src/lib/auth-provider.ts @@ -29,6 +29,8 @@ export const READ_WRITE_SCOPES = [ 'user:write', 'workspaces:read', 'channels:read', + 'channels:write', + 'channels:remove', 'threads:read', 'threads:write', 'comments:read', diff --git a/src/lib/refs.test.ts b/src/lib/refs.test.ts index 6023dca..7c19798 100644 --- a/src/lib/refs.test.ts +++ b/src/lib/refs.test.ts @@ -25,6 +25,7 @@ import { parseTwistUrl, partitionNotifyIds, resolveChannelId, + resolveChannelMemberRefs, resolveChannelRef, resolveCommentId, resolveConversationId, @@ -511,3 +512,74 @@ describe('resolveUserRefs', () => { }) }) }) + +describe('resolveChannelMemberRefs', () => { + const sampleUsers = [ + { id: 10, name: 'Alice', email: 'a@d.com' }, + { id: 20, name: 'Bob', email: 'b@d.com' }, + { id: 30, name: 'Carol', email: 'c@d.com' }, + ] + const designGroup = { id: 100, name: 'Design', workspaceId: 1, userIds: [10, 20], version: 1 } + const engGroup = { id: 200, name: 'Eng', workspaceId: 1, userIds: [20, 30], version: 1 } + + beforeEach(() => { + apiMocks.getWorkspaceUsers.mockResolvedValue(sampleUsers) + apiMocks.getGroup.mockImplementation(async (id: number) => { + if (id === 100) return designGroup + if (id === 200) return engGroup + throw new Error(`unexpected group id ${id}`) + }) + }) + + it('rejects empty input', async () => { + await expect(resolveChannelMemberRefs([], 1)).rejects.toMatchObject({ + code: 'MISSING_USERS', + }) + }) + + it('rejects empty group ref', async () => { + await expect(resolveChannelMemberRefs(['group:'], 1)).rejects.toMatchObject({ + code: 'INVALID_REF', + }) + }) + + it('resolves user-only refs in input order', async () => { + const { userIds, expandedFrom } = await resolveChannelMemberRefs(['id:30', 'id:10'], 1) + expect(userIds).toEqual([30, 10]) + expect(expandedFrom).toEqual([]) + }) + + it('expands group: refs', async () => { + const { userIds, expandedFrom } = await resolveChannelMemberRefs(['group:100'], 1) + expect(userIds).toEqual([10, 20]) + expect(expandedFrom).toEqual([{ groupId: 100, groupName: 'Design', userIds: [10, 20] }]) + }) + + it('preserves input order across mixed user and group: refs', async () => { + // 'group:100' (id:200 in mock) expands to [10, 20]; then 'id:30' adds 30 last. + const { userIds } = await resolveChannelMemberRefs(['group:100', 'id:30'], 1) + expect(userIds).toEqual([10, 20, 30]) + + // Reversed input → user id:30 first, then the group's [10, 20]. + const reversed = await resolveChannelMemberRefs(['id:30', 'group:100'], 1) + expect(reversed.userIds).toEqual([30, 10, 20]) + }) + + it('dedupes across users and group expansion (input-order kept)', async () => { + // group:100 → [10, 20]; group:200 → [20, 30]. With 'id:10' first, that wins + // the order slot; 20 appears once from group:100; 30 only from group:200. + const { userIds, expandedFrom } = await resolveChannelMemberRefs( + ['id:10', 'group:100', 'group:200'], + 1, + ) + expect(userIds).toEqual([10, 20, 30]) + expect(expandedFrom.map((g) => g.groupId)).toEqual([100, 200]) + // expandedFrom records pre-dedup ids per group + expect(expandedFrom[1].userIds).toEqual([20, 30]) + }) + + it('is case-insensitive on the group: prefix', async () => { + const { expandedFrom } = await resolveChannelMemberRefs(['GROUP:100'], 1) + expect(expandedFrom[0]).toMatchObject({ groupId: 100 }) + }) +}) diff --git a/src/lib/refs.ts b/src/lib/refs.ts index 0786d5b..c08e7a4 100644 --- a/src/lib/refs.ts +++ b/src/lib/refs.ts @@ -398,6 +398,103 @@ export async function resolveGroupRef(ref: string, workspaceId: number): Promise ]) } +export type ChannelMemberRefs = { + userIds: number[] + expandedFrom: { groupId: number; groupName: string; userIds: number[] }[] +} + +const GROUP_REF_PREFIX = 'group:' + +/** + * Resolve a mixed list of user and `group:` references for channel membership. + * + * Groups are expanded to their current `userIds` at call time. The group itself + * is not persistently linked to the channel — callers should surface that + * caveat in user-facing help text. + * + * Returns deduped userIds in input order, with a parallel `expandedFrom` list + * recording which groups contributed (and which users each group brought in, + * pre-dedup) for reporting purposes. + */ +export async function resolveChannelMemberRefs( + refs: string[], + workspaceId: number, +): Promise { + if (refs.length === 0) { + throw new CliError('MISSING_USERS', 'Provide at least one user or group: reference.') + } + + type Slot = + | { kind: 'user'; ref: string; index: number } + | { kind: 'group'; ref: string; index: number } + const slots: Slot[] = refs.map((ref, index) => { + const trimmed = normalizeRef(ref) + if (trimmed.toLowerCase().startsWith(GROUP_REF_PREFIX)) { + const inner = trimmed.slice(GROUP_REF_PREFIX.length).trim() + if (!inner) { + throw new CliError( + 'INVALID_REF', + `Empty group reference: "${ref}". Use group:.`, + ) + } + return { kind: 'group', ref: inner, index } + } + return { kind: 'user', ref: trimmed, index } + }) + + const userSlots = slots.filter((s): s is Extract => s.kind === 'user') + const groupSlots = slots.filter( + (s): s is Extract => s.kind === 'group', + ) + + // Resolve users (one batched API call) and all groups concurrently. + const [userIdsResolved, groupsResolved] = await Promise.all([ + userSlots.length > 0 + ? resolveUserRefs(userSlots.map((s) => s.ref).join(','), workspaceId) + : Promise.resolve([] as number[]), + Promise.all(groupSlots.map((s) => resolveGroupRef(s.ref, workspaceId))), + ]) + + const userIdByIndex = new Map() + userSlots.forEach((s, i) => { + const id = userIdsResolved[i] + if (typeof id === 'number') userIdByIndex.set(s.index, id) + }) + const groupByIndex = new Map() + groupSlots.forEach((s, i) => { + groupByIndex.set(s.index, groupsResolved[i]) + }) + + // Walk the original input order to assemble dedup'd userIds and expandedFrom. + const expandedFrom: ChannelMemberRefs['expandedFrom'] = [] + const seen = new Set() + const userIds: number[] = [] + const pushId = (id: number) => { + if (!seen.has(id)) { + seen.add(id) + userIds.push(id) + } + } + + for (let i = 0; i < refs.length; i++) { + if (userIdByIndex.has(i)) { + const id = userIdByIndex.get(i) + if (typeof id === 'number') pushId(id) + continue + } + const group = groupByIndex.get(i) + if (!group) continue + expandedFrom.push({ + groupId: group.id, + groupName: group.name, + userIds: [...group.userIds], + }) + for (const id of group.userIds) pushId(id) + } + + return { userIds, expandedFrom } +} + export async function resolveUserRefs(refs: string, workspaceId: number): Promise { const { getWorkspaceUsers } = await import('./api.js') const users = await getWorkspaceUsers(workspaceId) diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 83be5a5..b7cfa36 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -221,6 +221,14 @@ tw channel threads --since 2026-01-01 # Filter by last-updated date (ISO) tw channel threads --limit 20 # Max threads per page (default: 50) tw channel threads --limit 20 --cursor # Paginate tw channel threads --json # { results, nextCursor } with isUnread + url +tw channel members # List members + groups whose membership ⊆ channel +tw channel members --json # JSON with id, name, members, groupsFullyInChannel +tw channel members add # Add users and/or group: (group expansion is one-shot) +tw channel members add alice group:Frontend # Mix of user refs and group: prefix +tw channel members add id:123 --dry-run # Preview the diff +tw channel members remove # Remove users and/or group: +tw channel members set # Replace membership; dry-run by default +tw channel members set 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 @@ -246,6 +254,8 @@ If a channel is not found in \`tw channels\`, widen with broader listings such a \`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:\` 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