Skip to content

Commit 2a1e473

Browse files
feat(channel): channel membership control (members list/add/remove/set) (#7)
* feat(channel): channel membership control (members list/add/remove/set) Port Doist/twist-cli#244 to comms-cli. Adds `tdc channel members`: - list: members + groups fully present in the channel - add/remove: users and/or `group:<ref>` (one-shot expansion at call time) - set: replace membership with the resolved set, dry-run by default (--apply to mutate), refuses to remove the acting user unless --include-self Also adds resolveChannelMemberRefs (mixed user/group: ref parsing with dedup + input-order preservation), addUsersToChannel/removeUsersFromChannel API wrappers, and channels:write/channels:remove to READ_WRITE_SCOPES. Existing logged-in users must re-run `tdc auth login` to pick up the new scopes, otherwise channel mutations fail with INSUFFICIENT_SCOPE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: address doistbot feedback on PR #7 - refs.ts: resolve each user slot individually so a comma/multi-match ref can't shift index mapping and silently drop users (P1); fetch the workspace group list once for name refs instead of per-ref (P2). - set.ts: emit a JSON object in dry-run when --json (was printing text, breaking parsers) (P1); drop the unreachable self-sparing ternary (the earlier guard already throws) (P2). - membership-helpers.ts: fetchUsersByIds resolves members via per-id getUserById (mirrors groups/view) instead of downloading the whole workspace directory (P2). - tests: rename the misleading "sparing self" case; add set --apply --json and dry-run --json coverage; add an interleaved multi-group refs test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fae8923 commit 2a1e473

13 files changed

Lines changed: 1053 additions & 1 deletion

File tree

skills/comms-cli/SKILL.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,13 @@ tdc channel threads <ref> --since 2026-01-01 # Filter by last-updated date (ISO)
226226
tdc channel threads <ref> --limit 20 # Max threads per page (default: 50)
227227
tdc channel threads <ref> --limit 20 --cursor <cursor-from-prev> # Paginate
228228
tdc channel threads <ref> --json # { results, nextCursor } with isUnread + url
229+
tdc channel members <channel-ref> # List a channel's members + groups fully in the channel
230+
tdc channel members <ref> --json # JSON with id, name, workspaceId, members
231+
tdc channel members add <ref> alice group:Design # Add users and/or expand group:<ref> members
232+
tdc channel members add <ref> a@d.com id:789 --json # Add refs, output result as JSON
233+
tdc channel members remove <ref> alice group:Frontend # Remove users and/or group members
234+
tdc channel members set <ref> group:Squad --apply # Replace membership with the resolved set
235+
tdc channel members set <ref> alice bob # Dry-run by default; refuses to remove you (--include-self to override)
229236
tdc groups # List workspace groups
230237
tdc groups --search "frontend" # Filter groups by name (case-insensitive)
231238
tdc groups --json # JSON output
@@ -251,6 +258,8 @@ If a channel is not found in `tdc channels`, widen with broader listings such as
251258

252259
`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.
253260

261+
For `tdc channel members add/remove/set`, refs accept user identifiers (`id:N`, email, name) or `group:<ref>`, 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.
262+
254263
## Reactions
255264

256265
```bash

src/commands/channel/add.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { type ChannelMutationOptions, mutateChannelMembership } from './membership-helpers.js'
2+
3+
export async function addChannelMembers(
4+
channelRef: string,
5+
refs: string[],
6+
options: ChannelMutationOptions,
7+
): Promise<void> {
8+
return mutateChannelMembership(channelRef, refs, 'add', options)
9+
}

src/commands/channel/index.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { Command, Option } from 'commander'
22
import { withCaseInsensitiveChoices } from '../../lib/completion.js'
3+
import { addChannelMembers } from './add.js'
34
import { createChannel } from './create.js'
45
import { listChannels } from './list.js'
6+
import { listChannelMembers } from './members.js'
7+
import { removeChannelMembers } from './remove.js'
8+
import { setChannelMembers } from './set.js'
59
import { showChannelThreads } from './threads.js'
610
import { updateChannel } from './update.js'
711

812
export function registerChannelCommand(program: Command): void {
913
const channel = program
1014
.command('channel')
1115
.alias('channels')
12-
.description('Channel operations (list, create, update, threads)')
16+
.description('Channel operations (list, create, update, threads, members)')
1317

1418
channel
1519
.command('list [workspace-ref]', { isDefault: true })
@@ -131,4 +135,94 @@ Notes:
131135
and --unread are applied client-side; --archive-filter is applied server-side.`,
132136
)
133137
.action(showChannelThreads)
138+
139+
const members = channel
140+
.command('members')
141+
.description('Channel membership operations (list, add, remove, set)')
142+
143+
members
144+
.command('list <channel-ref>', { isDefault: true })
145+
.description("List a channel's members and groups fully present in the channel")
146+
.option('--json', 'Output as JSON')
147+
.option('--ndjson', 'Output as newline-delimited JSON')
148+
.option('--full', 'Include all fields in JSON output')
149+
.addHelpText(
150+
'after',
151+
`
152+
Examples:
153+
tdc channel members 12345
154+
tdc channel members "general" --json
155+
tdc channel members add 12345 alice group:Design
156+
tdc channel members remove 12345 alice
157+
tdc channel members set 12345 group:Squad --apply
158+
159+
Notes:
160+
"Groups fully in channel" lists groups whose entire current membership is
161+
already in the channel — a hint, not a persistent link.`,
162+
)
163+
.action(listChannelMembers)
164+
165+
members
166+
.command('add <channel-ref> [refs...]')
167+
.description('Add users and/or groups to a channel')
168+
.option('--dry-run', 'Show what would change without changing')
169+
.option('--json', 'Output result as JSON')
170+
.option('--full', 'Include the full updated channel in JSON output')
171+
.addHelpText(
172+
'after',
173+
`
174+
Examples:
175+
tdc channel members add 12345 alice@doist.com bob@doist.com
176+
tdc channel members add "general" group:Frontend
177+
tdc channel members add 12345 alice group:Design id:789 --json
178+
179+
Notes:
180+
Refs accept user identifiers (id:N, email, name) or "group:<ref>" to expand
181+
a group to its current members. Group expansion is one-shot — users added
182+
later to the group will not auto-join the channel.`,
183+
)
184+
.action(addChannelMembers)
185+
186+
members
187+
.command('remove <channel-ref> [refs...]')
188+
.description('Remove users and/or groups from a channel')
189+
.option('--dry-run', 'Show what would change without changing')
190+
.option('--json', 'Output result as JSON')
191+
.option('--full', 'Include the full updated channel in JSON output')
192+
.addHelpText(
193+
'after',
194+
`
195+
Examples:
196+
tdc channel members remove 12345 alice@doist.com
197+
tdc channel members remove "general" group:Frontend
198+
199+
Notes:
200+
Refs accept user identifiers (id:N, email, name) or "group:<ref>" to expand
201+
a group to its current members.`,
202+
)
203+
.action(removeChannelMembers)
204+
205+
members
206+
.command('set <channel-ref> [refs...]')
207+
.description('Replace channel membership with the resolved set of refs')
208+
.option('--apply', 'Actually mutate (otherwise dry-run)')
209+
.option('--include-self', 'Allow set to remove the acting user')
210+
.option('--dry-run', 'Force dry-run (default behaviour)')
211+
.option('--json', 'Output result as JSON')
212+
.option('--full', 'Include the full updated channel in JSON output')
213+
.addHelpText(
214+
'after',
215+
`
216+
Examples:
217+
tdc channel members set 12345 group:Frontend group:Design
218+
tdc channel members set "general" alice bob carol --apply
219+
tdc channel members set 12345 group:Squad --apply --include-self
220+
221+
Notes:
222+
Dry-run by default. Pass --apply to mutate.
223+
Refuses to remove the acting user unless --include-self is also passed.
224+
Group expansion is one-shot — users added later to a referenced group will
225+
not auto-join the channel.`,
226+
)
227+
.action(setChannelMembers)
134228
}

0 commit comments

Comments
 (0)