Skip to content

Commit f097378

Browse files
authored
feat: add channel create and update commands (#4)
1 parent ae4d6e1 commit f097378

15 files changed

Lines changed: 646 additions & 28 deletions

File tree

skills/comms-cli/SKILL.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,15 @@ tdc channels # List active joined workspace channels (alias
210210
tdc channels --state all # Include archived joined channels too
211211
tdc channels --scope discoverable # Active public channels you can see but have not joined
212212
tdc channels --scope public --state all --json # All visible public channels, with joined status
213+
tdc channel create "Engineering" # Create a channel in the current workspace
214+
tdc channel create "Leadership Team" --private --users id:10,id:20 # Create private channel with initial members
215+
tdc channel create "Product" --workspace "Doist" --description "Product discussions" --json # Create and return channel as JSON
216+
tdc channel update <channel-ref> "New name" # Rename a channel
217+
tdc channel update <ref> --name "New name" # Rename with an explicit flag
218+
tdc channel update <ref> --description "Team discussions" # Update channel description
219+
tdc channel update <ref> --clear-description # Clear channel description
220+
tdc channel update <ref> --public # Make a channel public (--private makes it private)
221+
tdc channel update <ref> --description "Team discussions" --json --full # Update and return all channel fields
213222
tdc channel threads <channel-ref> # List threads in a channel (fuzzy name, id:, numeric ID, or URL)
214223
tdc channel threads "general" --unread # Only unread threads
215224
tdc channel threads <ref> --archive-filter all # Include archived threads (active|archived|all)

src/commands/channel/channel.test.ts

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const apiMocks = vi.hoisted(() => ({
1010
const refsMocks = vi.hoisted(() => ({
1111
resolveWorkspaceRef: vi.fn(),
1212
resolveChannelRef: vi.fn(),
13+
parseRef: vi.fn(),
14+
getDirectChannelId: vi.fn(),
15+
resolveUserRefs: vi.fn(),
1316
}))
1417

1518
const globalArgsMocks = vi.hoisted(() => ({
@@ -25,6 +28,9 @@ vi.mock('../../lib/api.js', () => ({
2528
vi.mock('../../lib/refs.js', () => ({
2629
resolveWorkspaceRef: refsMocks.resolveWorkspaceRef,
2730
resolveChannelRef: refsMocks.resolveChannelRef,
31+
parseRef: refsMocks.parseRef,
32+
getDirectChannelId: refsMocks.getDirectChannelId,
33+
resolveUserRefs: refsMocks.resolveUserRefs,
2834
}))
2935

3036
vi.mock('../../lib/global-args.js', () => ({
@@ -43,6 +49,11 @@ function createProgram() {
4349
return program
4450
}
4551

52+
async function runChannelCommand(args: string[]): Promise<void> {
53+
const program = createProgram()
54+
await program.parseAsync(['node', 'tdc', 'channel', ...args])
55+
}
56+
4657
function createChannel(id: number, name: string, overrides: Partial<Record<string, unknown>> = {}) {
4758
return {
4859
id,
@@ -60,13 +71,20 @@ function createChannel(id: number, name: string, overrides: Partial<Record<strin
6071
function createClient({
6172
joinedChannels = [],
6273
publicChannels = [],
74+
createdChannel,
75+
updatedChannel,
6376
}: {
6477
joinedChannels?: ReturnType<typeof createChannel>[]
6578
publicChannels?: ReturnType<typeof createChannel>[]
79+
createdChannel?: ReturnType<typeof createChannel>
80+
updatedChannel?: ReturnType<typeof createChannel>
6681
} = {}) {
6782
return {
6883
channels: {
6984
getChannels: vi.fn().mockResolvedValue(joinedChannels),
85+
getChannel: vi.fn(),
86+
createChannel: vi.fn().mockResolvedValue(createdChannel),
87+
updateChannel: vi.fn().mockResolvedValue(updatedChannel),
7088
},
7189
workspaces: {
7290
getPublicChannels: vi.fn().mockResolvedValue(publicChannels),
@@ -401,3 +419,250 @@ describe('channels list', () => {
401419
).rejects.toHaveProperty('code', 'INVALID_STATE')
402420
})
403421
})
422+
423+
describe('channels create', () => {
424+
beforeEach(() => {
425+
vi.clearAllMocks()
426+
refsMocks.parseRef.mockImplementation((ref: string) =>
427+
ref === 'Q3' ? { type: 'id', id: ref } : { type: 'name', name: ref },
428+
)
429+
})
430+
431+
it('passes supported fields to createChannel', async () => {
432+
refsMocks.resolveWorkspaceRef.mockResolvedValue({ id: 9, name: 'Doist' })
433+
refsMocks.resolveUserRefs.mockResolvedValue([10, 20])
434+
const createdChannel = createChannel(200, 'Leadership', {
435+
id: 'CH200',
436+
public: false,
437+
workspaceId: 9,
438+
})
439+
const client = createClient({ createdChannel })
440+
apiMocks.getCommsClient.mockResolvedValue(client)
441+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
442+
443+
await runChannelCommand([
444+
'create',
445+
'Leadership',
446+
'--workspace',
447+
'Doist',
448+
'--description',
449+
'Private leadership discussions',
450+
'--private',
451+
'--users',
452+
'id:10,id:20',
453+
])
454+
455+
expect(refsMocks.resolveWorkspaceRef).toHaveBeenCalledWith('Doist')
456+
expect(refsMocks.resolveUserRefs).toHaveBeenCalledWith('id:10,id:20', 9)
457+
expect(client.channels.createChannel).toHaveBeenCalledWith({
458+
workspaceId: 9,
459+
name: 'Leadership',
460+
description: 'Private leadership discussions',
461+
userIds: [10, 20],
462+
public: false,
463+
})
464+
expect(consoleSpy.mock.calls[0][0]).toContain('Leadership')
465+
466+
consoleSpy.mockRestore()
467+
})
468+
469+
it('outputs created channel JSON', async () => {
470+
const createdChannel = createChannel(300, 'Product', {
471+
id: 'CH300',
472+
url: 'https://comms.todoist.com/a/1/ch/CH300',
473+
})
474+
const client = createClient({ createdChannel })
475+
apiMocks.getCommsClient.mockResolvedValue(client)
476+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
477+
478+
await runChannelCommand(['create', 'Product', '--json'])
479+
480+
expect(JSON.parse(consoleSpy.mock.calls[0][0])).toEqual({
481+
id: 'CH300',
482+
name: 'Product',
483+
workspaceId: 1,
484+
public: true,
485+
archived: false,
486+
url: 'https://comms.todoist.com/a/1/ch/CH300',
487+
})
488+
489+
consoleSpy.mockRestore()
490+
})
491+
492+
it('does not create in dry-run mode', async () => {
493+
const client = createClient()
494+
apiMocks.getCommsClient.mockResolvedValue(client)
495+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
496+
497+
await runChannelCommand(['create', 'Engineering', '--dry-run'])
498+
499+
expect(client.channels.createChannel).not.toHaveBeenCalled()
500+
expect(consoleSpy.mock.calls[0][0]).toContain('[dry-run] Would create channel')
501+
502+
consoleSpy.mockRestore()
503+
})
504+
505+
it('rejects invalid create options', async () => {
506+
await expect(runChannelCommand(['create', ' '])).rejects.toHaveProperty(
507+
'code',
508+
'INVALID_NAME',
509+
)
510+
await expect(runChannelCommand(['create', 'Q3'])).rejects.toHaveProperty(
511+
'code',
512+
'INVALID_NAME',
513+
)
514+
515+
vi.clearAllMocks()
516+
await expect(
517+
runChannelCommand([
518+
'create',
519+
'Engineering',
520+
'--public',
521+
'--private',
522+
'--users',
523+
'id:1',
524+
]),
525+
).rejects.toHaveProperty('code', 'CONFLICTING_OPTIONS')
526+
expect(apiMocks.getCurrentWorkspaceId).not.toHaveBeenCalled()
527+
expect(refsMocks.resolveUserRefs).not.toHaveBeenCalled()
528+
})
529+
})
530+
531+
describe('channels update', () => {
532+
const engineering = createChannel(10, 'Engineering', {
533+
id: 'CH10',
534+
description: 'Engineering discussion',
535+
url: 'https://comms.todoist.com/a/1/ch/CH10',
536+
})
537+
538+
beforeEach(() => {
539+
vi.clearAllMocks()
540+
refsMocks.parseRef.mockImplementation((ref: string) => ({ type: 'name', name: ref }))
541+
refsMocks.getDirectChannelId.mockReturnValue(null)
542+
refsMocks.resolveChannelRef.mockResolvedValue(engineering)
543+
})
544+
545+
it('renames direct refs via --name without resolving workspace or channel', async () => {
546+
refsMocks.getDirectChannelId.mockReturnValue('CH10')
547+
const updatedChannel = { ...engineering, name: 'Platform Engineering' }
548+
const client = createClient({ updatedChannel })
549+
apiMocks.getCommsClient.mockResolvedValue(client)
550+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
551+
552+
await runChannelCommand(['update', 'id:CH10', '--name', 'Platform Engineering', '--json'])
553+
554+
expect(apiMocks.getCurrentWorkspaceId).not.toHaveBeenCalled()
555+
expect(refsMocks.resolveChannelRef).not.toHaveBeenCalled()
556+
expect(client.channels.updateChannel).toHaveBeenCalledWith({
557+
id: 'CH10',
558+
name: 'Platform Engineering',
559+
})
560+
expect(JSON.parse(consoleSpy.mock.calls[0][0])).toMatchObject({
561+
id: 'CH10',
562+
name: 'Platform Engineering',
563+
workspaceId: 1,
564+
public: true,
565+
archived: false,
566+
})
567+
568+
consoleSpy.mockRestore()
569+
})
570+
571+
it('updates direct refs by fetching the current name only when needed', async () => {
572+
refsMocks.getDirectChannelId.mockReturnValue('CH10')
573+
const updatedChannel = { ...engineering, description: 'Team discussion' }
574+
const client = createClient({ updatedChannel })
575+
client.channels.getChannel = vi.fn().mockResolvedValue(engineering)
576+
apiMocks.getCommsClient.mockResolvedValue(client)
577+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
578+
579+
await runChannelCommand(['update', 'id:CH10', '--description', 'Team discussion', '--json'])
580+
581+
expect(refsMocks.resolveChannelRef).not.toHaveBeenCalled()
582+
expect(client.channels.getChannel).toHaveBeenCalledWith('CH10')
583+
expect(client.channels.updateChannel).toHaveBeenCalledWith({
584+
id: 'CH10',
585+
name: 'Engineering',
586+
description: 'Team discussion',
587+
})
588+
expect(JSON.parse(consoleSpy.mock.calls[0][0])).toMatchObject({
589+
id: 'CH10',
590+
description: 'Team discussion',
591+
})
592+
593+
consoleSpy.mockRestore()
594+
})
595+
596+
it('updates metadata in a selected workspace while keeping the current name', async () => {
597+
refsMocks.resolveWorkspaceRef.mockResolvedValue({ id: 9, name: 'Doist' })
598+
const selectedWorkspaceChannel = { ...engineering, workspaceId: 9 }
599+
refsMocks.resolveChannelRef.mockResolvedValue(selectedWorkspaceChannel)
600+
const updatedChannel = { ...selectedWorkspaceChannel, description: null, public: false }
601+
const client = createClient({ updatedChannel })
602+
apiMocks.getCommsClient.mockResolvedValue(client)
603+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
604+
605+
await runChannelCommand([
606+
'update',
607+
'Engineering',
608+
'--workspace',
609+
'Doist',
610+
'--clear-description',
611+
'--private',
612+
])
613+
614+
expect(refsMocks.resolveWorkspaceRef).toHaveBeenCalledWith('Doist')
615+
expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('Engineering', 9)
616+
expect(client.channels.updateChannel).toHaveBeenCalledWith({
617+
id: 'CH10',
618+
name: 'Engineering',
619+
description: null,
620+
public: false,
621+
})
622+
expect(consoleSpy.mock.calls[0][0]).toContain('Engineering')
623+
624+
consoleSpy.mockRestore()
625+
})
626+
627+
it('does not update or fetch direct refs in dry-run mode', async () => {
628+
refsMocks.getDirectChannelId.mockReturnValue('CH10')
629+
const client = createClient()
630+
apiMocks.getCommsClient.mockResolvedValue(client)
631+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
632+
633+
await runChannelCommand([
634+
'update',
635+
'id:CH10',
636+
'--description',
637+
'New description',
638+
'--dry-run',
639+
])
640+
641+
expect(client.channels.getChannel).not.toHaveBeenCalled()
642+
expect(client.channels.updateChannel).not.toHaveBeenCalled()
643+
expect(consoleSpy.mock.calls[0][0]).toContain('[dry-run] Would update channel')
644+
645+
consoleSpy.mockRestore()
646+
})
647+
648+
it('rejects invalid update options', async () => {
649+
await expect(runChannelCommand(['update', 'Engineering'])).rejects.toHaveProperty(
650+
'code',
651+
'INVALID_VALUE',
652+
)
653+
654+
await expect(
655+
runChannelCommand(['update', 'Engineering', 'New Name', '--name', 'Other Name']),
656+
).rejects.toHaveProperty('code', 'CONFLICTING_OPTIONS')
657+
658+
await expect(
659+
runChannelCommand([
660+
'update',
661+
'Engineering',
662+
'--description',
663+
'Text',
664+
'--clear-description',
665+
]),
666+
).rejects.toHaveProperty('code', 'CONFLICTING_OPTIONS')
667+
})
668+
})

src/commands/channel/create.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { CreateChannelArgs } from '@doist/comms-sdk'
2+
import { getCommsClient } from '../../lib/api.js'
3+
import type { MutationOptions } from '../../lib/options.js'
4+
import { formatJson, printDryRun } from '../../lib/output.js'
5+
import { resolveUserRefs } from '../../lib/refs.js'
6+
import {
7+
resolveChannelWorkspaceId,
8+
resolveVisibilityOption,
9+
validateChannelName,
10+
} from './helpers.js'
11+
12+
type CreateChannelOptions = MutationOptions & {
13+
workspace?: string
14+
description?: string
15+
users?: string
16+
public?: boolean
17+
private?: boolean
18+
}
19+
20+
export async function createChannel(name: string, options: CreateChannelOptions): Promise<void> {
21+
validateChannelName(name)
22+
const visibility = resolveVisibilityOption(options)
23+
24+
const workspaceId = await resolveChannelWorkspaceId(options.workspace)
25+
const userIds = options.users ? await resolveUserRefs(options.users, workspaceId) : undefined
26+
27+
const args: CreateChannelArgs = {
28+
workspaceId,
29+
name,
30+
...(options.description !== undefined ? { description: options.description } : {}),
31+
...(userIds !== undefined ? { userIds } : {}),
32+
...(visibility !== undefined ? { public: visibility } : {}),
33+
}
34+
35+
if (options.dryRun) {
36+
printDryRun('create channel', {
37+
Workspace: String(workspaceId),
38+
Name: name,
39+
Description: options.description,
40+
Visibility: visibility === undefined ? undefined : visibility ? 'public' : 'private',
41+
Users: userIds && userIds.length > 0 ? userIds.join(', ') : undefined,
42+
})
43+
return
44+
}
45+
46+
const client = await getCommsClient()
47+
const channel = await client.channels.createChannel(args)
48+
49+
if (options.json) {
50+
console.log(formatJson(channel, 'channel', options.full))
51+
return
52+
}
53+
54+
console.log(`Channel "${channel.name}" (id:${channel.id}) created.`)
55+
}

0 commit comments

Comments
 (0)