Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
],
"dependencies": {
"@doist/cli-core": "1.6.0",
"@doist/comms-sdk": "3.8.1",
"@doist/comms-sdk": "3.9.0",
"@pnpm/tabtab": "0.5.4",
"chalk": "5.6.2",
"commander": "14.0.3",
Expand Down
12 changes: 11 additions & 1 deletion skills/comms-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,10 @@ tdc channels --ids-only # Output only channel IDs
tdc channel create "Engineering" # Create a channel in the current workspace
tdc channel create "Leadership Team" --private --users id:10,id:20 # Create private channel with initial members
tdc channel create "Product" --workspace "Doist" --description "Product discussions" --json # Create and return channel as JSON
tdc channel create "Squad" --default-groups "Design" --default-users alice@doist.com # Set default thread audience
tdc channel update <ref> --default-groups "Design,Backend" --dry-run # Preview resolved group IDs
tdc channel update <ref> --default-users id:10,alice@doist.com # Replace default user recipients
tdc channel update <ref> --clear-default-audience # Disable defaults and empty both lists
tdc channel update <channel-ref> "New name" # Rename a channel
tdc channel update <ref> --name "New name" # Rename with an explicit flag
tdc channel update <ref> --description "Team discussions" # Update channel description
Expand All @@ -289,7 +293,7 @@ tdc channel threads <ref> --limit 20 --cursor <cursor-from-prev> # Paginate
tdc channel threads <ref> --json # { results, nextCursor } with isUnread + url
tdc channel threads <ref> --ids-only # Output only thread IDs; pagination notice goes to stderr
tdc channel members <channel-ref> # List a channel's members + groups fully in the channel
tdc channel members <ref> --json # JSON with id, name, workspaceId, members
tdc channel members <ref> --json # JSON with members, groupsFullyInChannel, and defaultAudience
tdc channel members <ref> --ids-only # Output only member user IDs
tdc channel members add <ref> alice group:Design # Add users and/or expand group:<ref> members
tdc channel members add <ref> a@d.com id:789 --json # Add refs, output result as JSON
Expand Down Expand Up @@ -328,6 +332,12 @@ 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.

On `channel create` and `channel update`, `--default-groups` and `--default-users` set who new threads are addressed to. They do not add channel members.

On update, each supplied list replaces that list. Omitted lists stay unchanged. Use `--clear-default-audience` to disable defaults and empty both lists.

Use `channel members list <ref> --json` to inspect the default audience.

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.

## Reactions
Expand Down
258 changes: 258 additions & 0 deletions src/commands/channel/channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import {
createTestProgram,
describeEmptyMachineOutput,
} from '@doist/cli-core/testing'
import { CommsApi, type CustomFetch } from '@doist/comms-sdk'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const apiMocks = vi.hoisted(() => ({
getCommsClient: vi.fn(),
getCurrentWorkspaceId: vi.fn().mockResolvedValue(1),
getWorkspaceGroups: vi.fn().mockResolvedValue([]),
}))

const refsMocks = vi.hoisted(() => ({
Expand All @@ -16,6 +18,7 @@ const refsMocks = vi.hoisted(() => ({
parseRef: vi.fn(),
getDirectChannelId: vi.fn(),
resolveUserRefs: vi.fn(),
resolveGroupRef: vi.fn(),
}))

const globalArgsMocks = vi.hoisted(() => ({
Expand All @@ -26,6 +29,7 @@ const globalArgsMocks = vi.hoisted(() => ({
vi.mock('../../lib/api.js', () => ({
getCommsClient: apiMocks.getCommsClient,
getCurrentWorkspaceId: apiMocks.getCurrentWorkspaceId,
getWorkspaceGroups: apiMocks.getWorkspaceGroups,
}))

vi.mock('../../lib/refs.js', () => ({
Expand All @@ -34,6 +38,7 @@ vi.mock('../../lib/refs.js', () => ({
parseRef: refsMocks.parseRef,
getDirectChannelId: refsMocks.getDirectChannelId,
resolveUserRefs: refsMocks.resolveUserRefs,
resolveGroupRef: refsMocks.resolveGroupRef,
}))

vi.mock('../../lib/global-args.js', () => ({
Expand Down Expand Up @@ -130,6 +135,22 @@ describe('channels list', () => {
expect(consoleSpy.mock.calls[0][0]).not.toContain('Leadership')
})

it('preserves all default audience fields in full JSON', async () => {
const audience = {
useDefaultRecipients: true,
defaultGroups: ['GR1'],
defaultRecipients: [10],
}
apiMocks.getCommsClient.mockResolvedValue(
createClient({
joinedChannels: [createChannel(10, 'General', audience)],
}),
)
const log = captureConsole('log')
await runChannelCommand(['list', '--json', '--full'])
expect(JSON.parse(log.mock.calls[0][0])[0]).toMatchObject(audience)
})

it('also works via the singular channel command name', async () => {
const client = createClient({
joinedChannels: [createChannel(10, 'General')],
Expand Down Expand Up @@ -902,3 +923,240 @@ describe('channels lifecycle direct refs', () => {
})
}
})

describe.each(['create', 'update'])('channel %s default audience', (command) => {
const channel = createChannel(10, 'Engineering', {
id: 'CH10',
workspaceId: 69,
useDefaultRecipients: true,
defaultGroups: ['OLD'],
defaultRecipients: [99],
})
beforeEach(() => {
vi.clearAllMocks()
apiMocks.getCurrentWorkspaceId.mockResolvedValue(1)
refsMocks.parseRef.mockImplementation((ref: string) => ({ type: 'name', name: ref }))
refsMocks.getDirectChannelId.mockReturnValue(command === 'update' ? 'CH10' : null)
refsMocks.resolveWorkspaceRef.mockResolvedValue({ id: 69, name: 'Doist' })
refsMocks.resolveChannelRef.mockResolvedValue(channel)
refsMocks.resolveGroupRef.mockImplementation(async (ref: string) => ({
id: ref === '26Q4|KLM' ? 'GR1' : 'GR2',
}))
refsMocks.resolveUserRefs.mockResolvedValue([10, 20, 30])
})

function setup() {
const client = createClient({ createdChannel: channel, updatedChannel: channel })
client.channels.getChannel.mockResolvedValue(channel)
apiMocks.getCommsClient.mockResolvedValue(client)
const log = captureConsole('log')
const args =
command === 'create'
? ['create', 'Engineering', '--workspace', 'Doist']
: ['update', 'id:CH10']
const mutation =
command === 'create' ? client.channels.createChannel : client.channels.updateChannel
return { client, log, args, mutation }
}

it.each([
{
flags: ['--default-groups', '26Q4|KLM, id:GR2'],
fields: { useDefaultRecipients: true, defaultGroups: ['GR1', 'GR2'] },
},
{
flags: ['--default-users', 'id:10,alice@doist.com,Bob'],
fields: { useDefaultRecipients: true, defaultRecipients: [10, 20, 30] },
},
{
flags: ['--default-groups', '26Q4|KLM', '--default-users', 'id:10,alice@doist.com,Bob'],
fields: {
useDefaultRecipients: true,
defaultGroups: ['GR1'],
defaultRecipients: [10, 20, 30],
},
},
{
flags: ['--clear-default-audience'],
fields: { useDefaultRecipients: false, defaultGroups: [], defaultRecipients: [] },
},
])('sends exactly the requested fields for $flags', async ({ flags, fields }) => {
const { args, mutation } = setup()
await runChannelCommand([...args, ...flags, '--json'])
expect(mutation).toHaveBeenCalledWith({
...(command === 'create' ? { workspaceId: 69 } : { id: 'CH10' }),
name: 'Engineering',
...fields,
})
if (flags.includes('--default-groups')) {
expect(refsMocks.resolveGroupRef).toHaveBeenCalledWith(
'26Q4|KLM',
69,
expect.any(Promise),
)
if (flags[1].includes(','))
expect(refsMocks.resolveGroupRef).toHaveBeenCalledWith(
'id:GR2',
69,
expect.any(Promise),
)
}
if (flags.includes('--default-users')) {
expect(refsMocks.resolveUserRefs).toHaveBeenCalledWith('id:10,alice@doist.com,Bob', 69)
}
})

it.each([false, true])(
'sends audience fields through the SDK transport (clear=%s)',
async (clear) => {
const { args } = setup()
const response = {
id: 'CeRAj1WU3YFhsTejuePLW',
name: 'Engineering',
workspace_id: 69,
public: true,
archived: false,
creator: 1,
created_ts: 1767225600,
version: 1,
}
const transport = vi.fn<CustomFetch>().mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
headers: {},
text: async () => JSON.stringify(response),
json: async () => response,
})
const sdk = new CommsApi('test-token', { customFetch: transport })
apiMocks.getCommsClient.mockResolvedValue(sdk)
await runChannelCommand([
...args,
...(clear
? ['--clear-default-audience']
: ['--default-groups', '26Q4|KLM', '--default-users', 'Alice']),
'--json',
])
const posts = transport.mock.calls.filter(([, init]) => init?.method === 'POST')
expect(posts).toHaveLength(1)
const body = JSON.parse(String(posts[0][1]?.body))
expect(body).toMatchObject({
use_default_recipients: !clear,
default_groups: clear ? [] : ['GR1'],
default_recipients: clear ? [] : [10, 20, 30],
})
},
)

it('prints resolved IDs in dry-run without mutating', async () => {
const { args, client, log } = setup()
await runChannelCommand([
...args,
'--default-groups',
'26Q4|KLM',
'--default-users',
'Alice',
'--dry-run',
])
expect(client.channels.createChannel).not.toHaveBeenCalled()
expect(client.channels.updateChannel).not.toHaveBeenCalled()
const output = log.mock.calls.map((call) => call[0]).join('\n')
expect(output).toContain('useDefaultRecipients: true')
expect(output).toContain('defaultGroups: ["GR1"]')
expect(output).toContain('defaultRecipients: [10,20,30]')
})

it('previews clearing both lists without resolving references', async () => {
const { args, mutation, log } = setup()
await runChannelCommand([...args, '--clear-default-audience', '--dry-run'])
expect(mutation).not.toHaveBeenCalled()
expect(refsMocks.resolveGroupRef).not.toHaveBeenCalled()
expect(refsMocks.resolveUserRefs).not.toHaveBeenCalled()
const output = log.mock.calls.map((call) => call[0]).join('\n')
expect(output).toContain('useDefaultRecipients: false')
expect(output).toContain('defaultGroups: []')
expect(output).toContain('defaultRecipients: []')
})

it.each(['--default-groups', '--default-users'])(
'rejects %s combined with clearing',
async (flag) => {
const { args, mutation } = setup()
await expect(
runChannelCommand([...args, flag, 'Alice', '--clear-default-audience']),
).rejects.toHaveProperty('code', 'CONFLICTING_OPTIONS')
expect(mutation).not.toHaveBeenCalled()
expect(apiMocks.getCommsClient).not.toHaveBeenCalled()
expect(refsMocks.resolveGroupRef).not.toHaveBeenCalled()
expect(refsMocks.resolveUserRefs).not.toHaveBeenCalled()
},
)

for (const flag of ['--default-groups', '--default-users']) {
it.each(['', ' ', ',', 'Alice,', ',Alice', 'Alice, ,Bob'])(
'rejects empty list entries in ' + flag + ' %j',
async (refs) => {
const { args, mutation } = setup()
await expect(runChannelCommand([...args, flag, refs])).rejects.toHaveProperty(
'code',
'INVALID_VALUE',
)
expect(mutation).not.toHaveBeenCalled()
expect(refsMocks.resolveGroupRef).not.toHaveBeenCalled()
expect(refsMocks.resolveUserRefs).not.toHaveBeenCalled()
},
)
}

it.each(['--default-groups', '--default-users'])(
'does not mutate when %s fails to resolve',
async (flag) => {
const { args, mutation } = setup()
const error = new Error('No match')
if (flag === '--default-groups') refsMocks.resolveGroupRef.mockRejectedValueOnce(error)
else refsMocks.resolveUserRefs.mockRejectedValueOnce(error)
await expect(runChannelCommand([...args, flag, 'Typo'])).rejects.toThrow(error)
expect(mutation).not.toHaveBeenCalled()
},
)

if (command === 'update') {
it('resolves audience in the selected workspace for a channel name', async () => {
const { mutation } = setup()
await runChannelCommand([
'update',
'Engineering',
'--workspace',
'Doist',
'--default-groups',
'26Q4|KLM',
])
expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('Engineering', 69)
expect(refsMocks.resolveGroupRef).toHaveBeenCalledWith(
'26Q4|KLM',
69,
expect.any(Promise),
)
expect(mutation).toHaveBeenCalledWith({
id: 'CH10',
name: 'Engineering',
useDefaultRecipients: true,
defaultGroups: ['GR1'],
})
})

it('fetches the channel workspace even when also renaming a direct ref', async () => {
const { args, client, mutation } = setup()
await runChannelCommand([...args, '--name', 'Renamed', '--default-users', 'Alice'])
expect(client.channels.getChannel).toHaveBeenCalledWith('CH10')
expect(refsMocks.resolveUserRefs).toHaveBeenCalledWith('Alice', 69)
expect(apiMocks.getCurrentWorkspaceId).not.toHaveBeenCalled()
expect(mutation).toHaveBeenCalledWith({
id: 'CH10',
name: 'Renamed',
useDefaultRecipients: true,
defaultRecipients: [10, 20, 30],
})
})
}
})
Loading
Loading