diff --git a/package-lock.json b/package-lock.json index d4dc568..2d57cf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "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", @@ -184,9 +184,9 @@ } }, "node_modules/@doist/comms-sdk": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@doist/comms-sdk/-/comms-sdk-3.8.1.tgz", - "integrity": "sha512-6lgdPNDd1u70pwCdCGcW5T2TBJHPkJ5fZubzwKjceCmL0j/OIhvmriOuLa+rFt2YuEmcGleIhJIMB2NBYFXj7Q==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@doist/comms-sdk/-/comms-sdk-3.9.0.tgz", + "integrity": "sha512-Sy1p+T2hpwRUXIHSabgCsAUZkhWhRbW9IjleFlimz9X8DB4wXXvwdUxBHc0pnGUYRHwVm2d/NgFOa+5pgrTjxA==", "license": "MIT", "dependencies": { "@doist/sdk-kmp": "0.2.3", diff --git a/package.json b/package.json index cd672f2..4b4cf29 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index def4551..6082fb8 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -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 --default-groups "Design,Backend" --dry-run # Preview resolved group IDs +tdc channel update --default-users id:10,alice@doist.com # Replace default user recipients +tdc channel update --clear-default-audience # Disable defaults and empty both lists tdc channel update "New name" # Rename a channel tdc channel update --name "New name" # Rename with an explicit flag tdc channel update --description "Team discussions" # Update channel description @@ -289,7 +293,7 @@ tdc channel threads --limit 20 --cursor # Paginate tdc channel threads --json # { results, nextCursor } with isUnread + url tdc channel threads --ids-only # Output only thread IDs; pagination notice goes to stderr 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 --json # JSON with members, groupsFullyInChannel, and defaultAudience tdc channel members --ids-only # Output only member user IDs 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 @@ -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 --json` to inspect the default audience. + 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 diff --git a/src/commands/channel/channel.test.ts b/src/commands/channel/channel.test.ts index 5a277ea..83f5f6e 100644 --- a/src/commands/channel/channel.test.ts +++ b/src/commands/channel/channel.test.ts @@ -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(() => ({ @@ -16,6 +18,7 @@ const refsMocks = vi.hoisted(() => ({ parseRef: vi.fn(), getDirectChannelId: vi.fn(), resolveUserRefs: vi.fn(), + resolveGroupRef: vi.fn(), })) const globalArgsMocks = vi.hoisted(() => ({ @@ -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', () => ({ @@ -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', () => ({ @@ -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')], @@ -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().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], + }) + }) + } +}) diff --git a/src/commands/channel/create.ts b/src/commands/channel/create.ts index 6158cb2..3f2ddc1 100644 --- a/src/commands/channel/create.ts +++ b/src/commands/channel/create.ts @@ -3,28 +3,43 @@ import { getCommsClient } from '../../lib/api.js' import type { MutationOptions } from '../../lib/options.js' import { formatJson, printDryRun } from '../../lib/output.js' import { resolveUserRefs } from '../../lib/refs.js' +import { + type DefaultAudienceOptions, + buildDefaultAudienceArgs, + clearDefaultAudienceArgs, + defaultAudienceDryRun, + validateDefaultAudienceOptions, +} from './default-audience.js' import { resolveChannelWorkspaceId, resolveVisibilityOption, validateChannelName, } from './helpers.js' -type CreateChannelOptions = MutationOptions & { - workspace?: string - description?: string - users?: string - public?: boolean - private?: boolean -} +type CreateChannelOptions = MutationOptions & + DefaultAudienceOptions & { + workspace?: string + description?: string + users?: string + public?: boolean + private?: boolean + } export async function createChannel(name: string, options: CreateChannelOptions): Promise { + validateDefaultAudienceOptions(options) validateChannelName(name) const visibility = resolveVisibilityOption(options) const workspaceId = await resolveChannelWorkspaceId(options.workspace) const userIds = options.users ? await resolveUserRefs(options.users, workspaceId) : undefined + const defaultAudience = { + ...clearDefaultAudienceArgs(options), + ...(await buildDefaultAudienceArgs(options, workspaceId)), + } + const args: CreateChannelArgs = { + ...defaultAudience, workspaceId, name, ...(options.description !== undefined ? { description: options.description } : {}), @@ -34,6 +49,7 @@ export async function createChannel(name: string, options: CreateChannelOptions) if (options.dryRun) { printDryRun('create channel', { + ...defaultAudienceDryRun(defaultAudience), Workspace: String(workspaceId), Name: name, Description: options.description, diff --git a/src/commands/channel/default-audience.test.ts b/src/commands/channel/default-audience.test.ts new file mode 100644 index 0000000..998af4d --- /dev/null +++ b/src/commands/channel/default-audience.test.ts @@ -0,0 +1,103 @@ +import type { Group } from '@doist/comms-sdk' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const apiMocks = vi.hoisted(() => ({ + getWorkspaceGroups: vi.fn(), + getGroup: vi.fn(), + getWorkspaceUsers: vi.fn(), +})) + +vi.mock('../../lib/api.js', () => apiMocks) + +import { buildDefaultAudienceArgs } from './default-audience.js' + +const groups: Group[] = [ + { id: 'GR1', name: 'Design', workspaceId: 69, userIds: [], version: 1 }, + { id: 'GR2', name: 'Backend', workspaceId: 69, userIds: [], version: 1 }, +] + +beforeEach(() => { + vi.resetAllMocks() + apiMocks.getWorkspaceGroups.mockResolvedValue(groups) + apiMocks.getGroup.mockResolvedValue(groups[1]) +}) + +describe('default audience group resolution', () => { + it('shares one workspace list for multiple names and preserves input order', async () => { + await expect( + buildDefaultAudienceArgs({ defaultGroups: ' Back, design ' }, 69), + ).resolves.toEqual({ useDefaultRecipients: true, defaultGroups: ['GR2', 'GR1'] }) + expect(apiMocks.getWorkspaceGroups.mock.calls).toEqual([[69]]) + expect(apiMocks.getGroup).not.toHaveBeenCalled() + }) + + it('does not fetch the workspace list for ID-only input', async () => { + await expect(buildDefaultAudienceArgs({ defaultGroups: 'id:GR2' }, 69)).resolves.toEqual({ + useDefaultRecipients: true, + defaultGroups: ['GR2'], + }) + expect(apiMocks.getWorkspaceGroups).not.toHaveBeenCalled() + expect(apiMocks.getGroup).toHaveBeenCalledWith('GR2', 69) + }) + + it('starts ID lookups without waiting for the shared name lookup', async () => { + const pendingGroups = Promise.withResolvers() + apiMocks.getWorkspaceGroups.mockReturnValue(pendingGroups.promise) + const result = buildDefaultAudienceArgs({ defaultGroups: 'Design,id:GR2,Back' }, 69) + + expect(apiMocks.getGroup).toHaveBeenCalledWith('GR2', 69) + pendingGroups.resolve(groups) + + await expect(result).resolves.toEqual({ + useDefaultRecipients: true, + defaultGroups: ['GR1', 'GR2', 'GR2'], + }) + expect(apiMocks.getWorkspaceGroups.mock.calls).toEqual([[69]]) + }) + + it.each([ + { ref: 'Design', code: 'AMBIGUOUS_GROUP' }, + { ref: 'Marketing', code: 'GROUP_NOT_FOUND' }, + ])('preserves $code errors with a shared list', async ({ ref, code }) => { + apiMocks.getWorkspaceGroups.mockResolvedValue([ + { ...groups[0], name: 'Design East' }, + { ...groups[1], name: 'Design West' }, + ]) + await expect(buildDefaultAudienceArgs({ defaultGroups: ref }, 69)).rejects.toHaveProperty( + 'code', + code, + ) + }) +}) + +describe('default audience concurrent resolution', () => { + it('starts user resolution while groups are pending and waits for both results', async () => { + const pendingGroups = Promise.withResolvers() + const pendingUsers = + Promise.withResolvers<{ id: number; fullName: string; email: string }[]>() + apiMocks.getWorkspaceGroups.mockReturnValue(pendingGroups.promise) + apiMocks.getWorkspaceUsers.mockReturnValue(pendingUsers.promise) + + const resolved = vi.fn() + const result = buildDefaultAudienceArgs( + { defaultGroups: 'Design', defaultUsers: 'alice@doist.com' }, + 69, + ).then((args) => { + resolved(args) + return args + }) + + await vi.waitFor(() => expect(apiMocks.getWorkspaceUsers).toHaveBeenCalled()) + expect(resolved).not.toHaveBeenCalled() + + pendingUsers.resolve([{ id: 10, fullName: 'Alice', email: 'alice@doist.com' }]) + expect(resolved).not.toHaveBeenCalled() + pendingGroups.resolve(groups) + + await expect(result).resolves.toEqual({ + useDefaultRecipients: true, + defaultGroups: ['GR1'], + defaultRecipients: [10], + }) + }) +}) diff --git a/src/commands/channel/default-audience.ts b/src/commands/channel/default-audience.ts new file mode 100644 index 0000000..3165f03 --- /dev/null +++ b/src/commands/channel/default-audience.ts @@ -0,0 +1,88 @@ +import type { Group, UpdateChannelArgs } from '@doist/comms-sdk' +import { getWorkspaceGroups } from '../../lib/api.js' +import { CliError } from '../../lib/errors.js' +import { parseRef, resolveGroupRef, resolveUserRefs } from '../../lib/refs.js' + +export type DefaultAudienceOptions = { + defaultGroups?: string + defaultUsers?: string + clearDefaultAudience?: boolean +} + +export type DefaultAudienceArgs = Pick< + UpdateChannelArgs, + 'useDefaultRecipients' | 'defaultGroups' | 'defaultRecipients' +> + +export function validateDefaultAudienceOptions(options: DefaultAudienceOptions): void { + if ( + options.clearDefaultAudience && + (options.defaultGroups !== undefined || options.defaultUsers !== undefined) + ) { + throw new CliError( + 'CONFLICTING_OPTIONS', + 'Cannot combine --clear-default-audience with --default-groups or --default-users.', + ) + } + + for (const [flag, refs] of [ + ['--default-groups', options.defaultGroups], + ['--default-users', options.defaultUsers], + ]) { + if (refs !== undefined && refs.split(',').some((ref) => !ref.trim())) { + throw new CliError('INVALID_VALUE', `${flag} requires non-empty references.`, [ + 'Use --clear-default-audience to clear the default audience.', + ]) + } + } +} + +export async function buildDefaultAudienceArgs( + options: DefaultAudienceOptions, + workspaceId: number, +): Promise { + let groupsPromise: Promise | undefined + if (options.defaultGroups !== undefined) { + const refs = options.defaultGroups.split(',').map((ref) => ref.trim()) + const workspaceGroups = refs.some((ref) => parseRef(ref).type === 'name') + ? getWorkspaceGroups(workspaceId) + : undefined + groupsPromise = Promise.all( + refs.map((ref) => resolveGroupRef(ref, workspaceId, workspaceGroups)), + ) + } + const usersPromise = + options.defaultUsers !== undefined + ? resolveUserRefs(options.defaultUsers, workspaceId) + : undefined + const [groups, userIds] = await Promise.all([groupsPromise, usersPromise]) + + const args: DefaultAudienceArgs = {} + if (groups !== undefined) { + args.defaultGroups = groups.map((group) => group.id) + args.useDefaultRecipients = true + } + if (userIds !== undefined) { + args.defaultRecipients = userIds + args.useDefaultRecipients = true + } + return args +} + +export function clearDefaultAudienceArgs(options: DefaultAudienceOptions): DefaultAudienceArgs { + return options.clearDefaultAudience + ? { useDefaultRecipients: false, defaultGroups: [], defaultRecipients: [] } + : {} +} + +export function defaultAudienceDryRun( + args: DefaultAudienceArgs, +): Record { + return { + useDefaultRecipients: args.useDefaultRecipients?.toString(), + defaultGroups: args.defaultGroups ? JSON.stringify(args.defaultGroups) : undefined, + defaultRecipients: args.defaultRecipients + ? JSON.stringify(args.defaultRecipients) + : undefined, + } +} diff --git a/src/commands/channel/index.ts b/src/commands/channel/index.ts index 602501f..387387e 100644 --- a/src/commands/channel/index.ts +++ b/src/commands/channel/index.ts @@ -66,6 +66,18 @@ Notes: .option('--users ', 'Comma-separated user references to add (id:N, email, or name)') .option('--public', 'Create a public channel') .option('--private', 'Create a private channel') + .option( + '--default-groups ', + 'Replace default audience groups (comma-separated IDs or names)', + ) + .option( + '--default-users ', + 'Replace default audience users (comma-separated id:N, email, or name)', + ) + .option( + '--clear-default-audience', + 'Disable the default audience and clear its groups and users', + ) .option('--dry-run', 'Show what would be created without creating') .option('--json', 'Output created channel as JSON') .option('--full', 'Include all fields in JSON output') @@ -74,20 +86,37 @@ Notes: ` Examples: tdc channel create "Engineering" + tdc channel create "Squad" --default-groups "Design" --default-users alice@doist.com tdc channel create "Leadership Team" --private --users id:10,id:20 - tdc channel create "Product" --workspace "Doist" --description "Product discussions" --json`, + tdc channel create "Product" --workspace "Doist" --description "Product discussions" --json + +Notes: + Default audience lists must be non-empty and enable default recipients. + --clear-default-audience cannot be combined with either list option.`, ) .action(createChannel) channel .command('update [name]') - .description('Update channel metadata') + .description('Update channel metadata and default audience') .option('--workspace ', 'Workspace ID or name') .option('--name ', 'New channel name') .option('--description ', 'New channel description') .option('--clear-description', 'Clear the channel description') .option('--public', 'Make the channel public') .option('--private', 'Make the channel private') + .option( + '--default-groups ', + 'Replace default audience groups (comma-separated IDs or names)', + ) + .option( + '--default-users ', + 'Replace default audience users (comma-separated id:N, email, or name)', + ) + .option( + '--clear-default-audience', + 'Disable the default audience and clear its groups and users', + ) .option('--dry-run', 'Show what would be updated without updating') .option('--json', 'Output updated channel as JSON') .option('--full', 'Include all fields in JSON output') @@ -97,7 +126,14 @@ Examples: Examples: tdc channel update "Engineering" "Platform Engineering" tdc channel update id:abc123 --description "Team discussions" - tdc channel update "Leadership" --private --json`, + tdc channel update id:abc123 --default-groups "Design" --dry-run + tdc channel update id:abc123 --clear-default-audience + tdc channel update "Leadership" --private --json + +Notes: + Each default audience list replaces that list; omitted lists are unchanged. + Lists must be non-empty and enable default recipients. + Use --clear-default-audience alone to disable defaults and empty both lists.`, ) .action(updateChannel) @@ -220,7 +256,8 @@ Examples: Notes: "Groups fully in channel" lists groups whose entire current membership is - already in the channel — a hint, not a persistent link.`, + already in the channel — a hint, not a persistent link. + JSON includes defaultAudience with resolved group and user names.`, ) .action(listChannelMembers) diff --git a/src/commands/channel/members.test.ts b/src/commands/channel/members.test.ts index 062c2fb..92bc66c 100644 --- a/src/commands/channel/members.test.ts +++ b/src/commands/channel/members.test.ts @@ -126,8 +126,68 @@ describe('tdc channel members list (default)', () => { ]) }) + it.each([['--json'], ['--json', '--full'], ['--ndjson']])( + 'exposes default audience with %j', + async (...flags) => { + const audience = { + useDefaultRecipients: true, + defaultGroups: ['GR200', 'missing'], + defaultRecipients: [1, 4, 99], + } + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1, 2, 3], audience)) + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const log = captureConsole('log') + await createProgram().parseAsync([ + 'node', + 'tdc', + 'channel', + 'members', + 'list', + 'General', + ...flags, + ]) + const payload = JSON.parse(log.mock.calls[0][0] as string) + expect(payload).toMatchObject(audience) + expect(payload.defaultAudience).toEqual({ + useDefaultRecipients: true, + groups: [ + { id: 'GR200', name: 'Backend' }, + { id: 'missing', name: null }, + ], + users: [ + { id: 1, name: 'Alice', email: 'a@d.com' }, + { id: 4, name: 'Dave', email: 'd@d.com' }, + { id: 99, name: null, email: null }, + ], + }) + expect(payload.members).toHaveLength(3) + expect(client.workspaceUsers.getUserById).toHaveBeenCalledTimes(5) + expect(client.channels.getChannel).not.toHaveBeenCalled() + }, + ) + + it('exposes an empty disabled default audience', async () => { + refsMocks.resolveChannelRef.mockResolvedValue( + createChannel([], { + useDefaultRecipients: false, + defaultGroups: [], + defaultRecipients: [], + }), + ) + const log = captureConsole('log') + await createProgram().parseAsync(['node', 'tdc', 'channel', 'members', 'General', '--json']) + expect(JSON.parse(log.mock.calls[0][0] as string).defaultAudience).toEqual({ + useDefaultRecipients: false, + groups: [], + users: [], + }) + }) + it('omits email for restricted members', async () => { - refsMocks.resolveChannelRef.mockResolvedValue(createChannel([6])) + refsMocks.resolveChannelRef.mockResolvedValue( + createChannel([6], { useDefaultRecipients: true, defaultRecipients: [6] }), + ) apiMocks.getCommsClient.mockResolvedValue({ workspaceUsers: { getUserById: vi.fn().mockResolvedValue({ @@ -145,6 +205,11 @@ describe('tdc channel members list (default)', () => { const payload = JSON.parse(consoleSpy.mock.calls[0][0] as string) expect(payload.members[0]).toEqual({ id: 6, name: 'Restricted User', email: null }) + expect(payload.defaultAudience.users[0]).toEqual({ + id: 6, + name: 'Restricted User', + email: null, + }) }) it('falls back to user: for unknown members', async () => { diff --git a/src/commands/channel/members.ts b/src/commands/channel/members.ts index cd60b85..10d83d9 100644 --- a/src/commands/channel/members.ts +++ b/src/commands/channel/members.ts @@ -23,29 +23,51 @@ export async function listChannelMembers( const [channel, groups] = await Promise.all([channelPromise, getWorkspaceGroups(workspaceId)]) const userIds = channelUserIds(channel) - const userMap = await fetchUsersByIds(workspaceId, userIds) + const includeAudience = outputMode === 'json' || outputMode === 'ndjson' + const defaultRecipients = channel.defaultRecipients ?? [] + const defaultGroups = channel.defaultGroups ?? [] + const userMap = await fetchUsersByIds( + workspaceId, + includeAudience ? [...new Set([...userIds, ...defaultRecipients])] : userIds, + ) const userIdSet = new Set(userIds) const fullyInChannel = groupsFullyInChannel(groups, userIdSet) - const members = userIds.map((id) => { + const describeUser = (id: number) => { const user = userMap.get(id) const email = user && !isRestrictedWorkspaceUser(user) ? (user.email ?? null) : null return { id, name: user?.fullName ?? null, email } - }) + } + const members = userIds.map(describeUser) + const groupMap = new Map(groups.map((group) => [group.id, group])) + const defaultAudience = { + useDefaultRecipients: channel.useDefaultRecipients ?? false, + groups: defaultGroups.map((id) => ({ id, name: groupMap.get(id)?.name ?? null })), + users: defaultRecipients.map(describeUser), + } const slimPayload = { id: channel.id, name: channel.name, workspaceId: channel.workspaceId, members, + useDefaultRecipients: channel.useDefaultRecipients ?? false, + defaultGroups, + defaultRecipients, + defaultAudience, groupsFullyInChannel: fullyInChannel.map((g) => ({ id: g.id, name: g.name, userIds: g.userIds, })), } - const fullPayload = { ...channel, members, groupsFullyInChannel: fullyInChannel } + const fullPayload = { + ...channel, + members, + defaultAudience, + groupsFullyInChannel: fullyInChannel, + } if (outputMode === 'json') { console.log(formatJson(options.full ? fullPayload : slimPayload)) diff --git a/src/commands/channel/update.ts b/src/commands/channel/update.ts index 5bd2e12..efdfdb3 100644 --- a/src/commands/channel/update.ts +++ b/src/commands/channel/update.ts @@ -4,20 +4,29 @@ import { CliError } from '../../lib/errors.js' import type { MutationOptions } from '../../lib/options.js' import { formatJson, printDryRun } from '../../lib/output.js' import { getDirectChannelId, resolveChannelRef } from '../../lib/refs.js' +import { + type DefaultAudienceOptions, + type DefaultAudienceArgs, + buildDefaultAudienceArgs, + clearDefaultAudienceArgs, + defaultAudienceDryRun, + validateDefaultAudienceOptions, +} from './default-audience.js' import { resolveChannelWorkspaceId, resolveVisibilityOption, validateChannelName, } from './helpers.js' -type UpdateChannelOptions = MutationOptions & { - workspace?: string - name?: string - description?: string - clearDescription?: boolean - public?: boolean - private?: boolean -} +type UpdateChannelOptions = MutationOptions & + DefaultAudienceOptions & { + workspace?: string + name?: string + description?: string + clearDescription?: boolean + public?: boolean + private?: boolean + } function buildDescriptionUpdate(options: UpdateChannelOptions): string | null | undefined { if (options.description !== undefined && options.clearDescription) { @@ -36,9 +45,11 @@ function printUpdateDryRun( newName: string | undefined, description: string | null | undefined, visibility: boolean | undefined, + defaultAudience: DefaultAudienceArgs, ): void { printDryRun('update channel', { Channel: targetLabel, + ...defaultAudienceDryRun(defaultAudience), 'New name': newName, Description: description === null ? '(clear)' : description !== undefined ? description : undefined, @@ -51,6 +62,11 @@ export async function updateChannel( positionalName: string | undefined, options: UpdateChannelOptions, ): Promise { + validateDefaultAudienceOptions(options) + const hasAudienceRefs = + options.defaultGroups !== undefined || options.defaultUsers !== undefined + let defaultAudience = clearDefaultAudienceArgs(options) + if (positionalName && options.name) { throw new CliError( 'CONFLICTING_OPTIONS', @@ -66,9 +82,15 @@ export async function updateChannel( const description = buildDescriptionUpdate(options) const visibility = resolveVisibilityOption(options) - if (newName === undefined && description === undefined && visibility === undefined) { + if ( + newName === undefined && + description === undefined && + visibility === undefined && + !hasAudienceRefs && + !options.clearDefaultAudience + ) { throw new CliError('INVALID_VALUE', 'Provide at least one channel field to update.', [ - 'Use a new name, --name, --description, --clear-description, --public, or --private.', + 'Use a new name, --name, --description, --clear-description, --public, --private, --default-groups, --default-users, or --clear-default-audience.', ]) } @@ -80,15 +102,24 @@ export async function updateChannel( const directChannelId = options.workspace ? null : getDirectChannelId(channelRef) if (directChannelId) { channelId = directChannelId - if (newName === undefined && options.dryRun) { - printUpdateDryRun(`id:${directChannelId}`, newName, description, visibility) + if (newName === undefined && options.dryRun && !hasAudienceRefs) { + printUpdateDryRun( + `id:${directChannelId}`, + newName, + description, + visibility, + defaultAudience, + ) return } - if (newName === undefined) { + if (newName === undefined || hasAudienceRefs) { client = await getCommsClient() const channel = await client.channels.getChannel(directChannelId) - updateName = channel.name + updateName = newName ?? channel.name + if (hasAudienceRefs) { + defaultAudience = await buildDefaultAudienceArgs(options, channel.workspaceId) + } targetLabel = `${channel.name} (id:${channel.id})` } else { updateName = newName @@ -97,17 +128,21 @@ export async function updateChannel( } else { const workspaceId = await resolveChannelWorkspaceId(options.workspace) const channel = await resolveChannelRef(channelRef, workspaceId) + if (hasAudienceRefs) { + defaultAudience = await buildDefaultAudienceArgs(options, channel.workspaceId) + } channelId = channel.id updateName = newName ?? channel.name targetLabel = `${channel.name} (id:${channel.id})` } if (options.dryRun) { - printUpdateDryRun(targetLabel, newName, description, visibility) + printUpdateDryRun(targetLabel, newName, description, visibility, defaultAudience) return } const args: UpdateChannelArgs = { + ...defaultAudience, id: channelId, name: updateName, ...(description !== undefined ? { description } : {}), diff --git a/src/lib/refs.ts b/src/lib/refs.ts index 921ddc4..9b2e219 100644 --- a/src/lib/refs.ts +++ b/src/lib/refs.ts @@ -500,7 +500,11 @@ export function parseNotifyIdRefs(refs: string): string[] { }) } -export async function resolveGroupRef(ref: string, workspaceId: number): Promise { +export async function resolveGroupRef( + ref: string, + workspaceId: number, + workspaceGroups?: Promise, +): Promise { const parsed = parseRef(ref) if (parsed.type === 'id') { @@ -524,7 +528,7 @@ export async function resolveGroupRef(ref: string, workspaceId: number): Promise } if (parsed.type === 'name') { - const groups = await getWorkspaceGroups(workspaceId) + const groups = await (workspaceGroups ?? getWorkspaceGroups(workspaceId)) return matchByName(groups, parsed.name, { ambiguousCode: 'AMBIGUOUS_GROUP', notFoundCode: 'GROUP_NOT_FOUND', diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 3c7590d..d45c523 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -274,6 +274,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 --default-groups "Design,Backend" --dry-run # Preview resolved group IDs +tdc channel update --default-users id:10,alice@doist.com # Replace default user recipients +tdc channel update --clear-default-audience # Disable defaults and empty both lists tdc channel update "New name" # Rename a channel tdc channel update --name "New name" # Rename with an explicit flag tdc channel update --description "Team discussions" # Update channel description @@ -293,7 +297,7 @@ tdc channel threads --limit 20 --cursor # Paginate tdc channel threads --json # { results, nextCursor } with isUnread + url tdc channel threads --ids-only # Output only thread IDs; pagination notice goes to stderr 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 --json # JSON with members, groupsFullyInChannel, and defaultAudience tdc channel members --ids-only # Output only member user IDs 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 @@ -332,6 +336,12 @@ 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. +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 --json\` to inspect the default audience. + 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