diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index e099e92..5d1474a 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -210,6 +210,15 @@ tdc channels # List active joined workspace channels (alias tdc channels --state all # Include archived joined channels too tdc channels --scope discoverable # Active public channels you can see but have not joined tdc channels --scope public --state all --json # All visible public channels, with joined status +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 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 +tdc channel update --clear-description # Clear channel description +tdc channel update --public # Make a channel public (--private makes it private) +tdc channel update --description "Team discussions" --json --full # Update and return all channel fields tdc channel threads # List threads in a channel (fuzzy name, id:, numeric ID, or URL) tdc channel threads "general" --unread # Only unread threads tdc channel threads --archive-filter all # Include archived threads (active|archived|all) diff --git a/src/commands/channel/channel.test.ts b/src/commands/channel/channel.test.ts index a96b639..8b9cd74 100644 --- a/src/commands/channel/channel.test.ts +++ b/src/commands/channel/channel.test.ts @@ -10,6 +10,9 @@ const apiMocks = vi.hoisted(() => ({ const refsMocks = vi.hoisted(() => ({ resolveWorkspaceRef: vi.fn(), resolveChannelRef: vi.fn(), + parseRef: vi.fn(), + getDirectChannelId: vi.fn(), + resolveUserRefs: vi.fn(), })) const globalArgsMocks = vi.hoisted(() => ({ @@ -25,6 +28,9 @@ vi.mock('../../lib/api.js', () => ({ vi.mock('../../lib/refs.js', () => ({ resolveWorkspaceRef: refsMocks.resolveWorkspaceRef, resolveChannelRef: refsMocks.resolveChannelRef, + parseRef: refsMocks.parseRef, + getDirectChannelId: refsMocks.getDirectChannelId, + resolveUserRefs: refsMocks.resolveUserRefs, })) vi.mock('../../lib/global-args.js', () => ({ @@ -43,6 +49,11 @@ function createProgram() { return program } +async function runChannelCommand(args: string[]): Promise { + const program = createProgram() + await program.parseAsync(['node', 'tdc', 'channel', ...args]) +} + function createChannel(id: number, name: string, overrides: Partial> = {}) { return { id, @@ -60,13 +71,20 @@ function createChannel(id: number, name: string, overrides: Partial[] publicChannels?: ReturnType[] + createdChannel?: ReturnType + updatedChannel?: ReturnType } = {}) { return { channels: { getChannels: vi.fn().mockResolvedValue(joinedChannels), + getChannel: vi.fn(), + createChannel: vi.fn().mockResolvedValue(createdChannel), + updateChannel: vi.fn().mockResolvedValue(updatedChannel), }, workspaces: { getPublicChannels: vi.fn().mockResolvedValue(publicChannels), @@ -401,3 +419,250 @@ describe('channels list', () => { ).rejects.toHaveProperty('code', 'INVALID_STATE') }) }) + +describe('channels create', () => { + beforeEach(() => { + vi.clearAllMocks() + refsMocks.parseRef.mockImplementation((ref: string) => + ref === 'Q3' ? { type: 'id', id: ref } : { type: 'name', name: ref }, + ) + }) + + it('passes supported fields to createChannel', async () => { + refsMocks.resolveWorkspaceRef.mockResolvedValue({ id: 9, name: 'Doist' }) + refsMocks.resolveUserRefs.mockResolvedValue([10, 20]) + const createdChannel = createChannel(200, 'Leadership', { + id: 'CH200', + public: false, + workspaceId: 9, + }) + const client = createClient({ createdChannel }) + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runChannelCommand([ + 'create', + 'Leadership', + '--workspace', + 'Doist', + '--description', + 'Private leadership discussions', + '--private', + '--users', + 'id:10,id:20', + ]) + + expect(refsMocks.resolveWorkspaceRef).toHaveBeenCalledWith('Doist') + expect(refsMocks.resolveUserRefs).toHaveBeenCalledWith('id:10,id:20', 9) + expect(client.channels.createChannel).toHaveBeenCalledWith({ + workspaceId: 9, + name: 'Leadership', + description: 'Private leadership discussions', + userIds: [10, 20], + public: false, + }) + expect(consoleSpy.mock.calls[0][0]).toContain('Leadership') + + consoleSpy.mockRestore() + }) + + it('outputs created channel JSON', async () => { + const createdChannel = createChannel(300, 'Product', { + id: 'CH300', + url: 'https://comms.todoist.com/a/1/ch/CH300', + }) + const client = createClient({ createdChannel }) + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runChannelCommand(['create', 'Product', '--json']) + + expect(JSON.parse(consoleSpy.mock.calls[0][0])).toEqual({ + id: 'CH300', + name: 'Product', + workspaceId: 1, + public: true, + archived: false, + url: 'https://comms.todoist.com/a/1/ch/CH300', + }) + + consoleSpy.mockRestore() + }) + + it('does not create in dry-run mode', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runChannelCommand(['create', 'Engineering', '--dry-run']) + + expect(client.channels.createChannel).not.toHaveBeenCalled() + expect(consoleSpy.mock.calls[0][0]).toContain('[dry-run] Would create channel') + + consoleSpy.mockRestore() + }) + + it('rejects invalid create options', async () => { + await expect(runChannelCommand(['create', ' '])).rejects.toHaveProperty( + 'code', + 'INVALID_NAME', + ) + await expect(runChannelCommand(['create', 'Q3'])).rejects.toHaveProperty( + 'code', + 'INVALID_NAME', + ) + + vi.clearAllMocks() + await expect( + runChannelCommand([ + 'create', + 'Engineering', + '--public', + '--private', + '--users', + 'id:1', + ]), + ).rejects.toHaveProperty('code', 'CONFLICTING_OPTIONS') + expect(apiMocks.getCurrentWorkspaceId).not.toHaveBeenCalled() + expect(refsMocks.resolveUserRefs).not.toHaveBeenCalled() + }) +}) + +describe('channels update', () => { + const engineering = createChannel(10, 'Engineering', { + id: 'CH10', + description: 'Engineering discussion', + url: 'https://comms.todoist.com/a/1/ch/CH10', + }) + + beforeEach(() => { + vi.clearAllMocks() + refsMocks.parseRef.mockImplementation((ref: string) => ({ type: 'name', name: ref })) + refsMocks.getDirectChannelId.mockReturnValue(null) + refsMocks.resolveChannelRef.mockResolvedValue(engineering) + }) + + it('renames direct refs via --name without resolving workspace or channel', async () => { + refsMocks.getDirectChannelId.mockReturnValue('CH10') + const updatedChannel = { ...engineering, name: 'Platform Engineering' } + const client = createClient({ updatedChannel }) + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runChannelCommand(['update', 'id:CH10', '--name', 'Platform Engineering', '--json']) + + expect(apiMocks.getCurrentWorkspaceId).not.toHaveBeenCalled() + expect(refsMocks.resolveChannelRef).not.toHaveBeenCalled() + expect(client.channels.updateChannel).toHaveBeenCalledWith({ + id: 'CH10', + name: 'Platform Engineering', + }) + expect(JSON.parse(consoleSpy.mock.calls[0][0])).toMatchObject({ + id: 'CH10', + name: 'Platform Engineering', + workspaceId: 1, + public: true, + archived: false, + }) + + consoleSpy.mockRestore() + }) + + it('updates direct refs by fetching the current name only when needed', async () => { + refsMocks.getDirectChannelId.mockReturnValue('CH10') + const updatedChannel = { ...engineering, description: 'Team discussion' } + const client = createClient({ updatedChannel }) + client.channels.getChannel = vi.fn().mockResolvedValue(engineering) + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runChannelCommand(['update', 'id:CH10', '--description', 'Team discussion', '--json']) + + expect(refsMocks.resolveChannelRef).not.toHaveBeenCalled() + expect(client.channels.getChannel).toHaveBeenCalledWith('CH10') + expect(client.channels.updateChannel).toHaveBeenCalledWith({ + id: 'CH10', + name: 'Engineering', + description: 'Team discussion', + }) + expect(JSON.parse(consoleSpy.mock.calls[0][0])).toMatchObject({ + id: 'CH10', + description: 'Team discussion', + }) + + consoleSpy.mockRestore() + }) + + it('updates metadata in a selected workspace while keeping the current name', async () => { + refsMocks.resolveWorkspaceRef.mockResolvedValue({ id: 9, name: 'Doist' }) + const selectedWorkspaceChannel = { ...engineering, workspaceId: 9 } + refsMocks.resolveChannelRef.mockResolvedValue(selectedWorkspaceChannel) + const updatedChannel = { ...selectedWorkspaceChannel, description: null, public: false } + const client = createClient({ updatedChannel }) + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runChannelCommand([ + 'update', + 'Engineering', + '--workspace', + 'Doist', + '--clear-description', + '--private', + ]) + + expect(refsMocks.resolveWorkspaceRef).toHaveBeenCalledWith('Doist') + expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('Engineering', 9) + expect(client.channels.updateChannel).toHaveBeenCalledWith({ + id: 'CH10', + name: 'Engineering', + description: null, + public: false, + }) + expect(consoleSpy.mock.calls[0][0]).toContain('Engineering') + + consoleSpy.mockRestore() + }) + + it('does not update or fetch direct refs in dry-run mode', async () => { + refsMocks.getDirectChannelId.mockReturnValue('CH10') + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runChannelCommand([ + 'update', + 'id:CH10', + '--description', + 'New description', + '--dry-run', + ]) + + expect(client.channels.getChannel).not.toHaveBeenCalled() + expect(client.channels.updateChannel).not.toHaveBeenCalled() + expect(consoleSpy.mock.calls[0][0]).toContain('[dry-run] Would update channel') + + consoleSpy.mockRestore() + }) + + it('rejects invalid update options', async () => { + await expect(runChannelCommand(['update', 'Engineering'])).rejects.toHaveProperty( + 'code', + 'INVALID_VALUE', + ) + + await expect( + runChannelCommand(['update', 'Engineering', 'New Name', '--name', 'Other Name']), + ).rejects.toHaveProperty('code', 'CONFLICTING_OPTIONS') + + await expect( + runChannelCommand([ + 'update', + 'Engineering', + '--description', + 'Text', + '--clear-description', + ]), + ).rejects.toHaveProperty('code', 'CONFLICTING_OPTIONS') + }) +}) diff --git a/src/commands/channel/create.ts b/src/commands/channel/create.ts new file mode 100644 index 0000000..6158cb2 --- /dev/null +++ b/src/commands/channel/create.ts @@ -0,0 +1,55 @@ +import type { CreateChannelArgs } from '@doist/comms-sdk' +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 { + resolveChannelWorkspaceId, + resolveVisibilityOption, + validateChannelName, +} from './helpers.js' + +type CreateChannelOptions = MutationOptions & { + workspace?: string + description?: string + users?: string + public?: boolean + private?: boolean +} + +export async function createChannel(name: string, options: CreateChannelOptions): Promise { + validateChannelName(name) + const visibility = resolveVisibilityOption(options) + + const workspaceId = await resolveChannelWorkspaceId(options.workspace) + const userIds = options.users ? await resolveUserRefs(options.users, workspaceId) : undefined + + const args: CreateChannelArgs = { + workspaceId, + name, + ...(options.description !== undefined ? { description: options.description } : {}), + ...(userIds !== undefined ? { userIds } : {}), + ...(visibility !== undefined ? { public: visibility } : {}), + } + + if (options.dryRun) { + printDryRun('create channel', { + Workspace: String(workspaceId), + Name: name, + Description: options.description, + Visibility: visibility === undefined ? undefined : visibility ? 'public' : 'private', + Users: userIds && userIds.length > 0 ? userIds.join(', ') : undefined, + }) + return + } + + const client = await getCommsClient() + const channel = await client.channels.createChannel(args) + + if (options.json) { + console.log(formatJson(channel, 'channel', options.full)) + return + } + + console.log(`Channel "${channel.name}" (id:${channel.id}) created.`) +} diff --git a/src/commands/channel/helpers.ts b/src/commands/channel/helpers.ts index 5adb9ac..cf8997c 100644 --- a/src/commands/channel/helpers.ts +++ b/src/commands/channel/helpers.ts @@ -1,4 +1,38 @@ +import { getCurrentWorkspaceId } from '../../lib/api.js' import { CliError } from '../../lib/errors.js' +import { parseRef, resolveWorkspaceRef } from '../../lib/refs.js' +import { validateNonEmptyName } from '../../lib/validation.js' + +export function validateChannelName(name: string): void { + validateNonEmptyName(name, 'Channel') + + if (parseRef(name).type !== 'name') { + throw new CliError('INVALID_NAME', 'Channel name cannot look like an ID or URL.', [ + 'Use a name with letters and no ID-like pattern, such as "Engineering Team".', + ]) + } +} + +export function resolveVisibilityOption(options: { + public?: boolean + private?: boolean +}): boolean | undefined { + if (options.public && options.private) { + throw new CliError('CONFLICTING_OPTIONS', 'Use either --public or --private, not both.') + } + + if (options.public) return true + if (options.private) return false + return undefined +} + +export async function resolveChannelWorkspaceId(workspaceRef: string | undefined): Promise { + if (workspaceRef) { + return (await resolveWorkspaceRef(workspaceRef)).id + } + + return getCurrentWorkspaceId() +} export function encodeCursor(offset: number): string { return Buffer.from(JSON.stringify({ offset })).toString('base64url') diff --git a/src/commands/channel/index.ts b/src/commands/channel/index.ts index 9bdf722..22bc4ee 100644 --- a/src/commands/channel/index.ts +++ b/src/commands/channel/index.ts @@ -1,13 +1,15 @@ import { Command, Option } from 'commander' import { withCaseInsensitiveChoices } from '../../lib/completion.js' +import { createChannel } from './create.js' import { listChannels } from './list.js' import { showChannelThreads } from './threads.js' +import { updateChannel } from './update.js' export function registerChannelCommand(program: Command): void { const channel = program .command('channel') .alias('channels') - .description('Channel operations (list, threads)') + .description('Channel operations (list, create, update, threads)') channel .command('list [workspace-ref]', { isDefault: true }) @@ -49,6 +51,49 @@ Notes: ) .action(listChannels) + channel + .command('create ') + .description('Create a channel') + .option('--workspace ', 'Workspace ID or name') + .option('--description ', 'Channel description') + .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('--dry-run', 'Show what would be created without creating') + .option('--json', 'Output created channel as JSON') + .option('--full', 'Include all fields in JSON output') + .addHelpText( + 'after', + ` +Examples: + tdc channel create "Engineering" + tdc channel create "Leadership Team" --private --users id:10,id:20 + tdc channel create "Product" --workspace "Doist" --description "Product discussions" --json`, + ) + .action(createChannel) + + channel + .command('update [name]') + .description('Update channel metadata') + .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('--dry-run', 'Show what would be updated without updating') + .option('--json', 'Output updated channel as JSON') + .option('--full', 'Include all fields in JSON output') + .addHelpText( + 'after', + ` +Examples: + tdc channel update "Engineering" "Platform Engineering" + tdc channel update id:abc123 --description "Team discussions" + tdc channel update "Leadership" --private --json`, + ) + .action(updateChannel) + channel .command('threads [workspace-ref]') .description('List threads in a channel with pagination and filtering') diff --git a/src/commands/channel/list.ts b/src/commands/channel/list.ts index 6170f94..b9ab69a 100644 --- a/src/commands/channel/list.ts +++ b/src/commands/channel/list.ts @@ -1,10 +1,10 @@ import type { Channel } from '@doist/comms-sdk' -import { getCurrentWorkspaceId, getCommsClient } from '../../lib/api.js' +import { getCommsClient } from '../../lib/api.js' import { CliError } from '../../lib/errors.js' import { includePrivateChannels } from '../../lib/global-args.js' import type { ViewOptions } from '../../lib/options.js' import { colors, formatJson, formatNdjson, printEmpty } from '../../lib/output.js' -import { resolveWorkspaceRef } from '../../lib/refs.js' +import { resolveChannelWorkspaceId } from './helpers.js' const CHANNEL_SCOPES = ['joined', 'public', 'discoverable'] as const const CHANNEL_STATES = ['active', 'all', 'archived'] as const @@ -122,14 +122,7 @@ async function getWorkspaceId( ) } - const ref = workspaceRef || options.workspace - - if (ref) { - const workspace = await resolveWorkspaceRef(ref) - return workspace.id - } - - return getCurrentWorkspaceId() + return resolveChannelWorkspaceId(workspaceRef || options.workspace) } function formatChannelLine(channel: ListedChannel, scope: ChannelScope): string { diff --git a/src/commands/channel/update.ts b/src/commands/channel/update.ts new file mode 100644 index 0000000..5bd2e12 --- /dev/null +++ b/src/commands/channel/update.ts @@ -0,0 +1,126 @@ +import type { UpdateChannelArgs } from '@doist/comms-sdk' +import { getCommsClient } from '../../lib/api.js' +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 { + resolveChannelWorkspaceId, + resolveVisibilityOption, + validateChannelName, +} from './helpers.js' + +type UpdateChannelOptions = MutationOptions & { + 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) { + throw new CliError( + 'CONFLICTING_OPTIONS', + 'Use either --description or --clear-description, not both.', + ) + } + + if (options.clearDescription) return null + return options.description +} + +function printUpdateDryRun( + targetLabel: string, + newName: string | undefined, + description: string | null | undefined, + visibility: boolean | undefined, +): void { + printDryRun('update channel', { + Channel: targetLabel, + 'New name': newName, + Description: + description === null ? '(clear)' : description !== undefined ? description : undefined, + Visibility: visibility === undefined ? undefined : visibility ? 'public' : 'private', + }) +} + +export async function updateChannel( + channelRef: string, + positionalName: string | undefined, + options: UpdateChannelOptions, +): Promise { + if (positionalName && options.name) { + throw new CliError( + 'CONFLICTING_OPTIONS', + 'Cannot specify channel name both as an argument and --name.', + ) + } + + const newName = positionalName ?? options.name + if (newName !== undefined) { + validateChannelName(newName) + } + + const description = buildDescriptionUpdate(options) + const visibility = resolveVisibilityOption(options) + + if (newName === undefined && description === undefined && visibility === undefined) { + throw new CliError('INVALID_VALUE', 'Provide at least one channel field to update.', [ + 'Use a new name, --name, --description, --clear-description, --public, or --private.', + ]) + } + + let targetLabel: string + let channelId: string + let updateName: string + let client: Awaited> | undefined + + const directChannelId = options.workspace ? null : getDirectChannelId(channelRef) + if (directChannelId) { + channelId = directChannelId + if (newName === undefined && options.dryRun) { + printUpdateDryRun(`id:${directChannelId}`, newName, description, visibility) + return + } + + if (newName === undefined) { + client = await getCommsClient() + const channel = await client.channels.getChannel(directChannelId) + updateName = channel.name + targetLabel = `${channel.name} (id:${channel.id})` + } else { + updateName = newName + targetLabel = `id:${directChannelId}` + } + } else { + const workspaceId = await resolveChannelWorkspaceId(options.workspace) + const channel = await resolveChannelRef(channelRef, workspaceId) + channelId = channel.id + updateName = newName ?? channel.name + targetLabel = `${channel.name} (id:${channel.id})` + } + + if (options.dryRun) { + printUpdateDryRun(targetLabel, newName, description, visibility) + return + } + + const args: UpdateChannelArgs = { + id: channelId, + name: updateName, + ...(description !== undefined ? { description } : {}), + ...(visibility !== undefined ? { public: visibility } : {}), + } + + client ??= await getCommsClient() + const updated = await client.channels.updateChannel(args) + + if (options.json) { + console.log(formatJson(updated, 'channel', options.full)) + return + } + + console.log(`Channel "${updated.name}" (id:${updated.id}) updated.`) +} diff --git a/src/commands/groups/create.ts b/src/commands/groups/create.ts index d161773..df6d6de 100644 --- a/src/commands/groups/create.ts +++ b/src/commands/groups/create.ts @@ -1,8 +1,8 @@ import { createGroup, getCurrentWorkspaceId } from '../../lib/api.js' -import { CliError } from '../../lib/errors.js' import type { MutationOptions } from '../../lib/options.js' import { formatJson, printDryRun } from '../../lib/output.js' import { resolveUserRefs, resolveWorkspaceRef } from '../../lib/refs.js' +import { validateNonEmptyName } from '../../lib/validation.js' type CreateGroupOptions = MutationOptions & { workspace?: string @@ -10,9 +10,7 @@ type CreateGroupOptions = MutationOptions & { } export async function createGroupCommand(name: string, options: CreateGroupOptions): Promise { - if (!name || name.trim() === '') { - throw new CliError('INVALID_NAME', 'Group name cannot be empty.') - } + validateNonEmptyName(name, 'Group') const workspaceId = options.workspace ? (await resolveWorkspaceRef(options.workspace)).id diff --git a/src/commands/groups/rename.ts b/src/commands/groups/rename.ts index 49b25ce..52481d0 100644 --- a/src/commands/groups/rename.ts +++ b/src/commands/groups/rename.ts @@ -1,17 +1,15 @@ import { getCurrentWorkspaceId, updateGroup } from '../../lib/api.js' -import { CliError } from '../../lib/errors.js' import type { MutationOptions } from '../../lib/options.js' import { formatJson, printDryRun } from '../../lib/output.js' import { resolveGroupRef } from '../../lib/refs.js' +import { validateNonEmptyName } from '../../lib/validation.js' export async function renameGroup( ref: string, newName: string, options: MutationOptions, ): Promise { - if (!newName || newName.trim() === '') { - throw new CliError('INVALID_NAME', 'Group name cannot be empty.') - } + validateNonEmptyName(newName, 'Group') const workspaceId = await getCurrentWorkspaceId() const group = await resolveGroupRef(ref, workspaceId) diff --git a/src/index.ts b/src/index.ts index 8e4fff5..f62c6e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,7 +49,7 @@ const commands: Record Promise<(p: Command) => void>]> = workspace: ['Manage workspace', loadWorkspaceCommand], user: ['Show current user info', loadUserCommand], users: ['List users in a workspace', loadUserCommand], - channel: ['Channel operations (list, threads)', loadChannelCommand], + channel: ['Channel operations (list, create, update, threads)', loadChannelCommand], inbox: ['Show inbox threads', loadInboxCommand], thread: ['Thread operations', loadThreadCommand], conversation: ['Conversation (DM/group) operations', loadConversationCommand], diff --git a/src/lib/output.ts b/src/lib/output.ts index 3b71765..61ef630 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -60,7 +60,15 @@ const WORKSPACE_ESSENTIAL_FIELDS = ['id', 'name', 'creator', 'plan'] as const const USER_ESSENTIAL_FIELDS = ['id', 'fullName', 'email', 'timezone', 'userType'] as const -const CHANNEL_ESSENTIAL_FIELDS = ['id', 'name', 'workspaceId'] as const +const CHANNEL_ESSENTIAL_FIELDS = [ + 'id', + 'name', + 'description', + 'workspaceId', + 'public', + 'archived', + 'url', +] as const const GROUP_ESSENTIAL_FIELDS = ['id', 'name', 'workspaceId', 'userIds'] as const diff --git a/src/lib/refs.test.ts b/src/lib/refs.test.ts index ebb62e1..954f84a 100644 --- a/src/lib/refs.test.ts +++ b/src/lib/refs.test.ts @@ -19,10 +19,12 @@ vi.mock('./api.js', () => ({ import { classifyCommsUrl, extractId, + getDirectChannelId, isIdRef, looksLikeRawId, - parseRef, parseCommsUrl, + parseNumericIdRefs, + parseRef, partitionNotifyIds, resolveChannelId, resolveChannelRef, @@ -69,6 +71,20 @@ describe('extractId', () => { }) }) +describe('parseNumericIdRefs', () => { + it('parses comma-separated numeric refs', () => { + expect(parseNumericIdRefs('id:10, 20', 'user')).toEqual([10, 20]) + }) + + it('returns null when any ref needs fuzzy resolution', () => { + expect(parseNumericIdRefs('id:10,alice@doist.com', 'user')).toBeNull() + }) + + it('rejects empty values', () => { + expect(() => parseNumericIdRefs('id:10,,20', 'user')).toThrow('Invalid user reference list') + }) +}) + describe('looksLikeRawId', () => { it('detects numeric strings', () => { expect(looksLikeRawId('123456')).toBe(true) @@ -164,6 +180,24 @@ describe('parseRef', () => { }) }) +describe('getDirectChannelId', () => { + it('returns ids and channel URL ids', () => { + expect(getDirectChannelId('id:CH1')).toBe('CH1') + expect(getDirectChannelId('CH1')).toBe('CH1') + expect(getDirectChannelId('https://comms.todoist.com/a/12345/ch/CH1/t/TH1')).toBe('CH1') + }) + + it('returns null for fuzzy names', () => { + expect(getDirectChannelId('Engineering')).toBeNull() + }) + + it('rejects URLs that do not identify a channel', () => { + expect(() => getDirectChannelId('https://comms.todoist.com/a/12345/msg/CV1')).toThrow( + 'Invalid channel reference', + ) + }) +}) + describe('resolveThreadId', () => { it('resolves id: refs', () => { expect(resolveThreadId('id:TH1')).toBe('TH1') @@ -516,6 +550,7 @@ describe('resolveUserRefs', () => { it('resolves a single id: ref', async () => { const ids = await resolveUserRefs('id:42', 1) expect(ids).toEqual([42]) + expect(apiMocks.getWorkspaceUsers).not.toHaveBeenCalled() }) it('resolves comma-separated mixed refs', async () => { diff --git a/src/lib/refs.ts b/src/lib/refs.ts index 47f9df9..1d23b6a 100644 --- a/src/lib/refs.ts +++ b/src/lib/refs.ts @@ -33,6 +33,26 @@ export function extractNumericId(ref: string): number { return Number(idStr) } +export function parseNumericIdRefs(refs: string, label = 'reference'): number[] | null { + const ids: number[] = [] + + for (const rawRef of refs.split(',')) { + const ref = rawRef.trim() + if (!ref) { + throw new CliError('INVALID_REF', `Invalid ${label} reference list: found empty value`) + } + + const id = extractId(ref) + if (!/^\d+$/.test(id)) { + return null + } + + ids.push(Number(id)) + } + + return ids +} + export function looksLikeRawId(ref: string): boolean { const normalized = normalizeRef(ref) if (!normalized || normalized.includes(' ')) return false @@ -265,20 +285,33 @@ export async function resolveChannelRef(ref: string, workspaceId: number): Promi } export function resolveChannelId(ref: string): string { + const channelId = getDirectChannelId(ref) + if (channelId) return channelId + + throw new CliError( + 'INVALID_REF', + `Invalid channel reference: ${ref}. Use an id, id:, or a Comms URL.`, + ) +} + +export function getDirectChannelId(ref: string): string | null { const parsed = parseRef(ref) if (parsed.type === 'id') { return parsed.id } - if (parsed.type === 'url' && parsed.parsed.channelId) { - return parsed.parsed.channelId + if (parsed.type === 'url') { + if (parsed.parsed.channelId) { + return parsed.parsed.channelId + } + throw new CliError( + 'INVALID_REF', + `Invalid channel reference: ${ref}. Use an id, id:, or a Comms URL.`, + ) } - throw new CliError( - 'INVALID_REF', - `Invalid channel reference: ${ref}. Use an id, id:, or a Comms URL.`, - ) + return null } export function resolveCommentId(ref: string): string { @@ -435,6 +468,9 @@ export async function resolveGroupRef(ref: string, workspaceId: number): Promise } export async function resolveUserRefs(refs: string, workspaceId: number): Promise { + const numericIds = parseNumericIdRefs(refs, 'user') + if (numericIds) return numericIds + const { getWorkspaceUsers } = await import('./api.js') const users = await getWorkspaceUsers(workspaceId) diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 2a4de6c..a624ea5 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -214,6 +214,15 @@ tdc channels # List active joined workspace channels (alias tdc channels --state all # Include archived joined channels too tdc channels --scope discoverable # Active public channels you can see but have not joined tdc channels --scope public --state all --json # All visible public channels, with joined status +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 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 +tdc channel update --clear-description # Clear channel description +tdc channel update --public # Make a channel public (--private makes it private) +tdc channel update --description "Team discussions" --json --full # Update and return all channel fields tdc channel threads # List threads in a channel (fuzzy name, id:, numeric ID, or URL) tdc channel threads "general" --unread # Only unread threads tdc channel threads --archive-filter all # Include archived threads (active|archived|all) diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..8ae5ccf --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,7 @@ +import { CliError } from './errors.js' + +export function validateNonEmptyName(name: string, entityLabel: string): void { + if (!name || name.trim() === '') { + throw new CliError('INVALID_NAME', `${entityLabel} name cannot be empty.`) + } +}