diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index 06c2fd2..09aa961 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -226,6 +226,13 @@ tdc channel threads --since 2026-01-01 # Filter by last-updated date (ISO) tdc channel threads --limit 20 # Max threads per page (default: 50) tdc channel threads --limit 20 --cursor # Paginate tdc channel threads --json # { results, nextCursor } with isUnread + url +tdc channel members # List a channel's members + groups fully in the channel +tdc channel members --json # JSON with id, name, workspaceId, members +tdc channel members add alice group:Design # Add users and/or expand group: members +tdc channel members add a@d.com id:789 --json # Add refs, output result as JSON +tdc channel members remove alice group:Frontend # Remove users and/or group members +tdc channel members set group:Squad --apply # Replace membership with the resolved set +tdc channel members set alice bob # Dry-run by default; refuses to remove you (--include-self to override) tdc groups # List workspace groups tdc groups --search "frontend" # Filter groups by name (case-insensitive) tdc groups --json # JSON output @@ -251,6 +258,8 @@ If a channel is not found in `tdc channels`, widen with broader listings such as `tdc 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. +For `tdc channel members add/remove/set`, refs accept user identifiers (`id:N`, email, name) or `group:`, which expands to the group's current members. Group expansion is one-shot — it is not a persistent link, so users added to the group later will not auto-join the channel. `set` replaces membership with the resolved set and is dry-run by default (pass `--apply` to mutate); it refuses to remove the acting user unless `--include-self` is passed. + ## Reactions ```bash 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 22bc4ee..a2518a5 100644 --- a/src/commands/channel/index.ts +++ b/src/commands/channel/index.ts @@ -1,7 +1,11 @@ import { Command, Option } from 'commander' import { withCaseInsensitiveChoices } from '../../lib/completion.js' +import { addChannelMembers } from './add.js' import { createChannel } from './create.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' import { updateChannel } from './update.js' @@ -9,7 +13,7 @@ export function registerChannelCommand(program: Command): void { const channel = program .command('channel') .alias('channels') - .description('Channel operations (list, create, update, threads)') + .description('Channel operations (list, create, update, threads, members)') channel .command('list [workspace-ref]', { isDefault: true }) @@ -131,4 +135,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: + tdc channel members 12345 + tdc channel members "general" --json + tdc channel members add 12345 alice group:Design + tdc channel members remove 12345 alice + tdc 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: + tdc channel members add 12345 alice@doist.com bob@doist.com + tdc channel members add "general" group:Frontend + tdc 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: + tdc channel members remove 12345 alice@doist.com + tdc 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: + tdc channel members set 12345 group:Frontend group:Design + tdc channel members set "general" alice bob carol --apply + tdc 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..e8d98b3 --- /dev/null +++ b/src/commands/channel/members.test.ts @@ -0,0 +1,376 @@ +import { captureConsole, createTestProgram } from '@doist/cli-core/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const apiMocks = vi.hoisted(() => ({ + getCurrentWorkspaceId: vi.fn().mockResolvedValue(1), + getWorkspaceGroups: vi.fn(), + getWorkspaceUsers: vi.fn(), + getCommsClient: vi.fn(), + getSessionUser: 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' + +const createProgram = () => createTestProgram(registerChannelCommand) + +function createChannel(userIds: number[], overrides: Record = {}) { + return { + id: 'CH1', + name: 'General', + public: true, + workspaceId: 1, + archived: false, + creator: 1, + created: new Date('2026-01-01T00:00:00Z'), + version: 1, + userIds, + ...overrides, + } +} + +const sampleGroups = [ + { id: 'GR100', name: 'Frontend', workspaceId: 1, userIds: [1, 2, 3], version: 1 }, + { id: 'GR200', name: 'Backend', workspaceId: 1, userIds: [4, 5], version: 1 }, +] + +const workspaceUsers = [ + { id: 1, fullName: 'Alice', email: 'a@d.com' }, + { id: 2, fullName: 'Bob', email: 'b@d.com' }, + { id: 3, fullName: 'Carol', email: 'c@d.com' }, + { id: 4, fullName: 'Dave', email: 'd@d.com' }, + { id: 5, fullName: 'Eve', email: 'e@d.com' }, +] + +function createClient() { + return { + workspaceUsers: { + getUserById: vi.fn(async ({ userId }: { workspaceId: number; userId: number }) => { + const user = workspaceUsers.find((u) => u.id === userId) + if (!user) throw new Error(`User ${userId} not found`) + return user + }), + }, + channels: { getChannel: vi.fn() }, + } +} + +beforeEach(() => { + vi.clearAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + apiMocks.getWorkspaceGroups.mockResolvedValue(sampleGroups) + apiMocks.getCommsClient.mockResolvedValue(createClient()) + apiMocks.getSessionUser.mockResolvedValue({ id: 1, fullName: 'Alice' }) +}) + +describe('tdc channel members list (default)', () => { + it('lists members with names/emails and groups fully in channel', async () => { + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1, 2, 3])) + const consoleSpy = captureConsole('log') + const program = createProgram() + + await program.parseAsync(['node', 'tdc', 'channel', 'members', 'General']) + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') + expect(output).toContain('Alice') + expect(output).toContain('a@d.com') + expect(output).toContain('3 members') + expect(output).toContain('Groups fully in channel (1)') + expect(output).toContain('Frontend') + expect(output).not.toContain('Backend') + }) + + it('emits slim JSON with members and groupsFullyInChannel', async () => { + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1, 2, 3])) + const consoleSpy = captureConsole('log') + const program = createProgram() + + await program.parseAsync(['node', 'tdc', 'channel', 'members', 'General', '--json']) + + const payload = JSON.parse(consoleSpy.mock.calls[0][0] as string) + expect(payload.id).toBe('CH1') + expect(payload.members).toHaveLength(3) + expect(payload.members[0]).toEqual({ id: 1, name: 'Alice', email: 'a@d.com' }) + expect(payload.groupsFullyInChannel).toEqual([ + { id: 'GR100', name: 'Frontend', userIds: [1, 2, 3] }, + ]) + }) + + it('falls back to user: for unknown members', async () => { + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([99])) + const consoleSpy = captureConsole('log') + const program = createProgram() + + await program.parseAsync(['node', 'tdc', 'channel', 'members', 'General']) + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') + expect(output).toContain('user:99') + }) +}) + +describe('tdc channel members add', () => { + it('adds only users not already in the channel', async () => { + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1, 2])) + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ userIds: [1, 3], expandedFrom: [] }) + const consoleSpy = captureConsole('log') + const program = createProgram() + + await program.parseAsync([ + 'node', + 'tdc', + 'channel', + 'members', + 'add', + 'General', + 'carol', + 'alice', + ]) + + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith('CH1', [3]) + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') + expect(output).toContain('Added 1 user to "General" (now 3 members)') + expect(output).toContain('Already members: 1') + }) + + it('expands group: refs and logs the expansion', async () => { + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1])) + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [1, 2, 3], + expandedFrom: [{ groupId: 'GR100', groupName: 'Frontend', userIds: [1, 2, 3] }], + }) + const consoleSpy = captureConsole('log') + const program = createProgram() + + await program.parseAsync([ + 'node', + 'tdc', + 'channel', + 'members', + 'add', + 'General', + 'group:Frontend', + ]) + + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith('CH1', [2, 3]) + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') + expect(output).toContain('Expanded group "Frontend"') + }) + + it('does not mutate on --dry-run', async () => { + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1, 2])) + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ userIds: [3], expandedFrom: [] }) + const consoleSpy = captureConsole('log') + const program = createProgram() + + await program.parseAsync([ + 'node', + 'tdc', + 'channel', + 'members', + 'add', + 'General', + 'carol', + '--dry-run', + ]) + + expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled() + expect(consoleSpy.mock.calls.map((c) => c[0]).join('\n')).toContain('[dry-run]') + }) + + it('emits slim JSON result', async () => { + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1, 2])) + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ userIds: [1, 3], expandedFrom: [] }) + const consoleSpy = captureConsole('log') + const program = createProgram() + + await program.parseAsync([ + 'node', + 'tdc', + 'channel', + 'members', + 'add', + 'General', + 'alice', + 'carol', + '--json', + ]) + + const payload = JSON.parse(consoleSpy.mock.calls[0][0] as string) + expect(payload).toEqual({ + id: 'CH1', + memberCount: 3, + added: [3], + alreadyMembers: [1], + }) + }) +}) + +describe('tdc channel members remove', () => { + it('removes only users currently in the channel', async () => { + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1, 2, 3])) + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ userIds: [3, 9], expandedFrom: [] }) + const consoleSpy = captureConsole('log') + const program = createProgram() + + await program.parseAsync([ + 'node', + 'tdc', + 'channel', + 'members', + 'remove', + 'General', + 'carol', + 'id:9', + ]) + + expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith('CH1', [3]) + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') + expect(output).toContain('Removed 1 user from "General" (now 2 members)') + expect(output).toContain('Not members: 9') + }) +}) + +describe('tdc channel members set', () => { + it('refuses to remove the acting user without --include-self', async () => { + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1, 2])) + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ userIds: [2], expandedFrom: [] }) + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tdc', 'channel', 'members', 'set', 'General', 'bob']), + ).rejects.toThrow(/would remove you/) + expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() + }) + + it('is dry-run by default (no --apply)', async () => { + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1, 2])) + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ userIds: [1, 3], expandedFrom: [] }) + const consoleSpy = captureConsole('log') + const program = createProgram() + + await program.parseAsync([ + 'node', + 'tdc', + 'channel', + 'members', + 'set', + 'General', + 'alice', + 'carol', + ]) + + expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled() + expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') + expect(output).toContain('[dry-run]') + expect(output).toContain('dry-run by default') + }) + + it('applies add + remove with --apply', async () => { + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1, 2])) + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ userIds: [1, 3], expandedFrom: [] }) + const consoleSpy = captureConsole('log') + const program = createProgram() + + await program.parseAsync([ + 'node', + 'tdc', + 'channel', + 'members', + 'set', + 'General', + 'alice', + 'carol', + '--apply', + ]) + + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith('CH1', [3]) + expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith('CH1', [2]) + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') + expect(output).toContain('Set "General": +1 / -1 (now 2 members)') + }) + + it('emits JSON result on --apply --json with both directions changing', async () => { + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1, 2])) + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ userIds: [1, 3], expandedFrom: [] }) + const consoleSpy = captureConsole('log') + const program = createProgram() + + await program.parseAsync([ + 'node', + 'tdc', + 'channel', + 'members', + 'set', + 'General', + 'alice', + 'carol', + '--apply', + '--json', + ]) + + const payload = JSON.parse(consoleSpy.mock.calls[0][0] as string) + expect(payload).toEqual({ id: 'CH1', memberCount: 2, added: [3], removed: [2] }) + }) + + it('emits JSON (not text) on dry-run --json without --apply', async () => { + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1, 2])) + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ userIds: [1, 3], expandedFrom: [] }) + const consoleSpy = captureConsole('log') + const program = createProgram() + + await program.parseAsync([ + 'node', + 'tdc', + 'channel', + 'members', + 'set', + 'General', + 'alice', + 'carol', + '--json', + ]) + + expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled() + const payload = JSON.parse(consoleSpy.mock.calls[0][0] as string) + expect(payload).toEqual({ + id: 'CH1', + dryRun: true, + memberCount: 2, + added: [3], + removed: [2], + }) + }) + + it('removes the acting user when --include-self is passed', async () => { + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1, 2])) + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ userIds: [2], expandedFrom: [] }) + const program = createProgram() + + await program.parseAsync([ + 'node', + 'tdc', + 'channel', + 'members', + 'set', + 'General', + 'bob', + '--apply', + '--include-self', + ]) + + expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith('CH1', [1]) + }) +}) diff --git a/src/commands/channel/members.ts b/src/commands/channel/members.ts new file mode 100644 index 0000000..4afc403 --- /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?.fullName ?? 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..0285b75 --- /dev/null +++ b/src/commands/channel/membership-helpers.ts @@ -0,0 +1,163 @@ +import type { Channel, Group, WorkspaceUser } from '@doist/comms-sdk' +import { + addUsersToChannel, + getCommsClient, + getCurrentWorkspaceId, + 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: string; 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 getCommsClient() + // Per-member fetch keeps latency tied to channel size, not workspace size + // (mirrors groups/view); missing/unresolvable users are simply skipped. + const entries = await Promise.all( + userIds.map(async (userId) => { + try { + const user = await client.workspaceUsers.getUserById({ workspaceId, userId }) + return [userId, user] as const + } catch { + return null + } + }), + ) + const map = new Map() + for (const entry of entries) { + if (entry) map.set(entry[0], entry[1]) + } + 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 getCommsClient() + 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.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.ts b/src/commands/channel/set.ts new file mode 100644 index 0000000..b886640 --- /dev/null +++ b/src/commands/channel/set.ts @@ -0,0 +1,104 @@ +import type { User } from '@doist/comms-sdk' +import { + addUsersToChannel, + getCommsClient, + getCurrentWorkspaceId, + getSessionUser, + 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 toRemove = [...current].filter((id) => !desired.has(id)) + + const selfId = sessionUser.id + if (toRemove.includes(selfId) && !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 newMemberCount = current.size + toAdd.length - toRemove.length + const isDryRun = options.dryRun || !options.apply + + if (isDryRun) { + if (options.json) { + const result: Record = { + id: channel.id, + dryRun: true, + memberCount: newMemberCount, + added: toAdd, + removed: toRemove, + } + if (expandedFrom.length > 0) result.expandedFrom = expandedFrom + console.log(formatJson(result)) + return + } + 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(), + ]) + + 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 getCommsClient() + 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 db13475..2be200a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -52,6 +52,8 @@ const API_SPINNER_MESSAGES: Record { + const client = await getCommsClient() + await client.channels.addUsers({ id, userIds }) +} + +export async function removeUsersFromChannel(id: string, userIds: number[]): Promise { + const client = await getCommsClient() + 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 2ebada1..088e421 100644 --- a/src/lib/auth-provider.ts +++ b/src/lib/auth-provider.ts @@ -26,6 +26,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 954f84a..a321211 100644 --- a/src/lib/refs.test.ts +++ b/src/lib/refs.test.ts @@ -27,6 +27,7 @@ import { parseRef, partitionNotifyIds, resolveChannelId, + resolveChannelMemberRefs, resolveChannelRef, resolveCommentId, resolveConversationId, @@ -570,3 +571,81 @@ describe('resolveUserRefs', () => { }) }) }) + +describe('resolveChannelMemberRefs', () => { + const sampleGroups = [ + { + id: 'GR100', + name: 'Frontend', + workspaceId: 1, + userIds: [1, 2], + description: '', + version: 1, + }, + { + id: 'GR200', + name: 'Backend', + workspaceId: 1, + userIds: [3, 4], + description: '', + version: 1, + }, + ] + const sampleUsers = [ + { id: 1, fullName: 'Alice', email: 'alice@doist.com' }, + { id: 2, fullName: 'Bob', email: 'bob@doist.com' }, + { id: 3, fullName: 'Carol', email: 'carol@doist.com' }, + ] + + beforeEach(() => { + vi.clearAllMocks() + apiMocks.getWorkspaceUsers.mockResolvedValue(sampleUsers) + apiMocks.getWorkspaceGroups.mockResolvedValue(sampleGroups) + apiMocks.getGroup.mockImplementation(async (id: string) => { + const group = sampleGroups.find((g) => g.id === id) + if (!group) throw new Error(`Group ${id} not found`) + return group + }) + }) + + it('throws MISSING_USERS for an empty ref list', async () => { + await expect(resolveChannelMemberRefs([], 1)).rejects.toMatchObject({ + code: 'MISSING_USERS', + }) + }) + + it('preserves input order and dedupes across users and group expansion', async () => { + const { userIds, expandedFrom } = await resolveChannelMemberRefs( + ['id:3', 'group:Frontend', 'id:1'], + 1, + ) + // 3 first, then group expands to 1,2 (3 already seen stays put), 1 already seen + expect(userIds).toEqual([3, 1, 2]) + expect(expandedFrom).toEqual([{ groupId: 'GR100', groupName: 'Frontend', userIds: [1, 2] }]) + }) + + it('accepts a case-insensitive group: prefix', async () => { + const { userIds, expandedFrom } = await resolveChannelMemberRefs(['GROUP:Frontend'], 1) + expect(userIds).toEqual([1, 2]) + expect(expandedFrom).toHaveLength(1) + }) + + it('preserves order across interleaved users and multiple groups', async () => { + const { userIds, expandedFrom } = await resolveChannelMemberRefs( + ['id:5', 'group:Frontend', 'id:1', 'group:Backend'], + 1, + ) + // 5, then Frontend → 1,2 (1 dedup'd later), then Backend → 3,4 + expect(userIds).toEqual([5, 1, 2, 3, 4]) + expect(expandedFrom).toEqual([ + { groupId: 'GR100', groupName: 'Frontend', userIds: [1, 2] }, + { groupId: 'GR200', groupName: 'Backend', userIds: [3, 4] }, + ]) + }) + + it('rejects an empty group: reference', async () => { + await expect(resolveChannelMemberRefs(['group:'], 1)).rejects.toMatchObject({ + code: 'INVALID_REF', + }) + }) +}) diff --git a/src/lib/refs.ts b/src/lib/refs.ts index 1d23b6a..985294b 100644 --- a/src/lib/refs.ts +++ b/src/lib/refs.ts @@ -467,6 +467,122 @@ export async function resolveGroupRef(ref: string, workspaceId: number): Promise ]) } +export type ChannelMemberRefs = { + userIds: number[] + expandedFrom: { groupId: string; 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', + ) + // A group ref resolves by id (single fetch) or by name (matched against the + // workspace group list). Split here so name refs share one list fetch + // instead of re-fetching the whole list per ref. + const groupIdSlots = groupSlots.filter((s) => parseRef(s.ref).type === 'id') + const groupNameSlots = groupSlots.filter((s) => parseRef(s.ref).type !== 'id') + + // Resolve each user slot individually (a single ref may expand to several + // ids, e.g. a comma list or a name match), all groups by id, and the + // workspace group list (once, only when there are name refs) concurrently. + const [userIdsPerSlot, idGroups, workspaceGroups] = await Promise.all([ + Promise.all(userSlots.map((s) => resolveUserRefs(s.ref, workspaceId))), + Promise.all(groupIdSlots.map((s) => resolveGroupRef(s.ref, workspaceId))), + groupNameSlots.length > 0 + ? getWorkspaceGroups(workspaceId) + : Promise.resolve([] as Group[]), + ]) + + const userIdsByIndex = new Map() + userSlots.forEach((s, i) => { + userIdsByIndex.set(s.index, userIdsPerSlot[i]) + }) + + const groupByIndex = new Map() + groupIdSlots.forEach((s, i) => { + groupByIndex.set(s.index, idGroups[i]) + }) + for (const s of groupNameSlots) { + groupByIndex.set( + s.index, + matchByName(workspaceGroups, s.ref, { + ambiguousCode: 'AMBIGUOUS_GROUP', + notFoundCode: 'GROUP_NOT_FOUND', + ref: s.ref, + listHint: 'Run: tdc groups to list available groups', + }), + ) + } + + // 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++) { + const slotUserIds = userIdsByIndex.get(i) + if (slotUserIds) { + for (const id of slotUserIds) 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 numericIds = parseNumericIdRefs(refs, 'user') if (numericIds) return numericIds diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index a624ea5..4030097 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -230,6 +230,13 @@ tdc channel threads --since 2026-01-01 # Filter by last-updated date (ISO) tdc channel threads --limit 20 # Max threads per page (default: 50) tdc channel threads --limit 20 --cursor # Paginate tdc channel threads --json # { results, nextCursor } with isUnread + url +tdc channel members # List a channel's members + groups fully in the channel +tdc channel members --json # JSON with id, name, workspaceId, members +tdc channel members add alice group:Design # Add users and/or expand group: members +tdc channel members add a@d.com id:789 --json # Add refs, output result as JSON +tdc channel members remove alice group:Frontend # Remove users and/or group members +tdc channel members set group:Squad --apply # Replace membership with the resolved set +tdc channel members set alice bob # Dry-run by default; refuses to remove you (--include-self to override) tdc groups # List workspace groups tdc groups --search "frontend" # Filter groups by name (case-insensitive) tdc groups --json # JSON output @@ -255,6 +262,8 @@ If a channel is not found in \`tdc channels\`, widen with broader listings such \`tdc 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. +For \`tdc channel members add/remove/set\`, refs accept user identifiers (\`id:N\`, email, name) or \`group:\`, which expands to the group's current members. Group expansion is one-shot — it is not a persistent link, so users added to the group later will not auto-join the channel. \`set\` replaces membership with the resolved set and is dry-run by default (pass \`--apply\` to mutate); it refuses to remove the acting user unless \`--include-self\` is passed. + ## Reactions \`\`\`bash