Skip to content

Commit 41acfe6

Browse files
feat(channel): add delete, archive, unarchive subcommands (#8)
* feat(channel): add delete, archive, unarchive subcommands Ports the remaining channel-lifecycle commands from twist-cli #246 (create already existed). Wraps the SDK's deleteChannel, archiveChannel and unarchiveChannel endpoints so channel lifecycle no longer requires the web UI. - delete requires --yes to mutate, short-circuits MISSING_YES_FLAG in --json mode before any lookup, and translates a 403 into a clear FORBIDDEN CliError (deletion is typically admin-only). - archive/unarchive share a setArchiveState helper and skip the API call when the channel is already in the target state. - All three support --workspace, --dry-run and --json. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: bypass workspace resolution for direct channel refs Addresses doistbot feedback on PR #8. - Add a shared resolveChannelByRef helper: direct refs (id:/URL) are fetched by ID via getChannel (workspace-agnostic), so deleting or (un)archiving a channel in another workspace no longer fails with CHANNEL_NOT_FOUND. Name refs still resolve a workspace. Mirrors how `channel update` already special-cases direct refs. - delete: move the non-yes/non-dry-run early return above the channel lookup and reference the raw ref, so the confirmation prompt no longer pays for API calls. - Tests: table-driven --workspace coverage (asserts the passed workspace ID reaches resolveChannelRef) and direct-ref bypass coverage (asserts getChannel is used and no workspace is resolved) across all three subcommands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5b786e4 commit 41acfe6

9 files changed

Lines changed: 472 additions & 2 deletions

File tree

skills/comms-cli/SKILL.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,10 @@ tdc channel update <ref> --description "Team discussions" # Update channel descr
219219
tdc channel update <ref> --clear-description # Clear channel description
220220
tdc channel update <ref> --public # Make a channel public (--private makes it private)
221221
tdc channel update <ref> --description "Team discussions" --json --full # Update and return all channel fields
222+
tdc channel delete <channel-ref> --yes # Permanently delete a channel (requires --yes; usually admin-only)
223+
tdc channel delete <ref> --dry-run # Preview deletion
224+
tdc channel archive <channel-ref> # Archive a channel (no-op if already archived)
225+
tdc channel unarchive id:<id> # Unarchive a channel (pass id:/numeric ref for archived channels)
222226
tdc channel threads <channel-ref> # List threads in a channel (fuzzy name, id:, numeric ID, or URL)
223227
tdc channel threads "general" --unread # Only unread threads
224228
tdc channel threads <ref> --archive-filter all # Include archived threads (active|archived|all)

src/commands/channel/archive.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { getCommsClient } from '../../lib/api.js'
2+
import type { MutationOptions } from '../../lib/options.js'
3+
import { formatJson, printDryRun } from '../../lib/output.js'
4+
import { resolveChannelByRef } from './helpers.js'
5+
6+
type ArchiveChannelOptions = MutationOptions & { workspace?: string }
7+
8+
async function setArchiveState(
9+
ref: string,
10+
options: ArchiveChannelOptions,
11+
archive: boolean,
12+
): Promise<void> {
13+
const action = archive ? 'archive' : 'unarchive'
14+
const channel = await resolveChannelByRef(ref, options.workspace)
15+
16+
if (options.dryRun) {
17+
printDryRun(`${action} channel`, {
18+
Channel: `${channel.name} (id:${channel.id})`,
19+
'Currently archived': channel.archived ? 'yes' : 'no',
20+
})
21+
return
22+
}
23+
24+
if (channel.archived !== archive) {
25+
const client = await getCommsClient()
26+
if (archive) {
27+
await client.channels.archiveChannel(channel.id)
28+
} else {
29+
await client.channels.unarchiveChannel(channel.id)
30+
}
31+
}
32+
33+
if (options.json) {
34+
console.log(formatJson({ id: channel.id, archived: archive }))
35+
return
36+
}
37+
38+
const verb = archive ? 'archived' : 'unarchived'
39+
const noop = channel.archived === archive ? ' (already in target state)' : ''
40+
console.log(`Channel "${channel.name}" (id:${channel.id}) ${verb}${noop}.`)
41+
}
42+
43+
export async function archiveChannel(ref: string, options: ArchiveChannelOptions): Promise<void> {
44+
await setArchiveState(ref, options, true)
45+
}
46+
47+
export async function unarchiveChannel(ref: string, options: ArchiveChannelOptions): Promise<void> {
48+
await setArchiveState(ref, options, false)
49+
}

src/commands/channel/channel.test.ts

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
createTestProgram,
44
describeEmptyMachineOutput,
55
} from '@doist/cli-core/testing'
6+
import { CommsRequestError } from '@doist/comms-sdk'
67
import { beforeEach, describe, expect, it, vi } from 'vitest'
78

89
const apiMocks = vi.hoisted(() => ({
@@ -83,6 +84,9 @@ function createClient({
8384
getChannel: vi.fn(),
8485
createChannel: vi.fn().mockResolvedValue(createdChannel),
8586
updateChannel: vi.fn().mockResolvedValue(updatedChannel),
87+
deleteChannel: vi.fn().mockResolvedValue({ status: 'ok' }),
88+
archiveChannel: vi.fn().mockResolvedValue({ status: 'ok' }),
89+
unarchiveChannel: vi.fn().mockResolvedValue({ status: 'ok' }),
8690
},
8791
workspaces: {
8892
getPublicChannels: vi.fn().mockResolvedValue(publicChannels),
@@ -626,3 +630,266 @@ describe('channels update', () => {
626630
).rejects.toHaveProperty('code', 'CONFLICTING_OPTIONS')
627631
})
628632
})
633+
634+
describe('channels delete', () => {
635+
beforeEach(() => {
636+
vi.clearAllMocks()
637+
apiMocks.getCurrentWorkspaceId.mockResolvedValue(1)
638+
refsMocks.getDirectChannelId.mockReturnValue(null)
639+
refsMocks.resolveChannelRef.mockResolvedValue(
640+
createChannel(500, 'Engineering', { id: 'CH500' }),
641+
)
642+
})
643+
644+
it('refuses to delete without --yes', async () => {
645+
const client = createClient()
646+
apiMocks.getCommsClient.mockResolvedValue(client)
647+
const consoleSpy = captureConsole('log')
648+
649+
await runChannelCommand(['delete', 'Engineering'])
650+
651+
expect(client.channels.deleteChannel).not.toHaveBeenCalled()
652+
expect(consoleSpy.mock.calls.some((c) => String(c[0]).includes('Use --yes'))).toBe(true)
653+
})
654+
655+
it('deletes when --yes is passed', async () => {
656+
const client = createClient()
657+
apiMocks.getCommsClient.mockResolvedValue(client)
658+
const consoleSpy = captureConsole('log')
659+
660+
await runChannelCommand(['delete', 'Engineering', '--yes'])
661+
662+
expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('Engineering', 1)
663+
expect(client.channels.deleteChannel).toHaveBeenCalledWith('CH500')
664+
expect(consoleSpy.mock.calls[0][0]).toContain('Engineering')
665+
})
666+
667+
it('does not delete on --dry-run', async () => {
668+
const client = createClient()
669+
apiMocks.getCommsClient.mockResolvedValue(client)
670+
const consoleSpy = captureConsole('log')
671+
672+
await runChannelCommand(['delete', 'Engineering', '--dry-run'])
673+
674+
expect(client.channels.deleteChannel).not.toHaveBeenCalled()
675+
const text = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n')
676+
expect(text).toContain('delete channel')
677+
})
678+
679+
it('does not delete when --yes is combined with --dry-run', async () => {
680+
const client = createClient()
681+
apiMocks.getCommsClient.mockResolvedValue(client)
682+
const consoleSpy = captureConsole('log')
683+
684+
await runChannelCommand(['delete', 'Engineering', '--yes', '--dry-run'])
685+
686+
expect(client.channels.deleteChannel).not.toHaveBeenCalled()
687+
const text = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n')
688+
expect(text).toContain('delete channel')
689+
})
690+
691+
it('errors in --json mode without --yes before doing any lookups', async () => {
692+
const client = createClient()
693+
apiMocks.getCommsClient.mockResolvedValue(client)
694+
695+
await expect(runChannelCommand(['delete', 'Engineering', '--json'])).rejects.toHaveProperty(
696+
'code',
697+
'MISSING_YES_FLAG',
698+
)
699+
expect(client.channels.deleteChannel).not.toHaveBeenCalled()
700+
expect(refsMocks.resolveChannelRef).not.toHaveBeenCalled()
701+
expect(apiMocks.getCurrentWorkspaceId).not.toHaveBeenCalled()
702+
})
703+
704+
it('translates a 403 from the API into a FORBIDDEN CliError', async () => {
705+
const client = createClient()
706+
client.channels.deleteChannel.mockRejectedValueOnce(
707+
new CommsRequestError('Request failed with status 403', 403, {}),
708+
)
709+
apiMocks.getCommsClient.mockResolvedValue(client)
710+
711+
await expect(runChannelCommand(['delete', 'Engineering', '--yes'])).rejects.toHaveProperty(
712+
'code',
713+
'FORBIDDEN',
714+
)
715+
})
716+
717+
it('outputs JSON result with --yes --json', async () => {
718+
const client = createClient()
719+
apiMocks.getCommsClient.mockResolvedValue(client)
720+
const consoleSpy = captureConsole('log')
721+
722+
await runChannelCommand(['delete', 'Engineering', '--yes', '--json'])
723+
724+
expect(JSON.parse(consoleSpy.mock.calls[0][0])).toEqual({ id: 'CH500', deleted: true })
725+
})
726+
})
727+
728+
describe('channels archive', () => {
729+
beforeEach(() => {
730+
vi.clearAllMocks()
731+
apiMocks.getCurrentWorkspaceId.mockResolvedValue(1)
732+
refsMocks.getDirectChannelId.mockReturnValue(null)
733+
refsMocks.resolveChannelRef.mockResolvedValue(
734+
createChannel(500, 'Engineering', { id: 'CH500' }),
735+
)
736+
})
737+
738+
it('archives the resolved channel', async () => {
739+
const client = createClient()
740+
apiMocks.getCommsClient.mockResolvedValue(client)
741+
const consoleSpy = captureConsole('log')
742+
743+
await runChannelCommand(['archive', 'Engineering'])
744+
745+
expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('Engineering', 1)
746+
expect(client.channels.archiveChannel).toHaveBeenCalledWith('CH500')
747+
expect(consoleSpy.mock.calls[0][0]).toContain('archived')
748+
})
749+
750+
it('does not call the API on --dry-run', async () => {
751+
const client = createClient()
752+
apiMocks.getCommsClient.mockResolvedValue(client)
753+
const consoleSpy = captureConsole('log')
754+
755+
await runChannelCommand(['archive', 'Engineering', '--dry-run'])
756+
757+
expect(client.channels.archiveChannel).not.toHaveBeenCalled()
758+
const text = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n')
759+
expect(text).toContain('archive channel')
760+
})
761+
762+
it('outputs JSON with --json', async () => {
763+
const client = createClient()
764+
apiMocks.getCommsClient.mockResolvedValue(client)
765+
const consoleSpy = captureConsole('log')
766+
767+
await runChannelCommand(['archive', 'Engineering', '--json'])
768+
769+
expect(JSON.parse(consoleSpy.mock.calls[0][0])).toEqual({ id: 'CH500', archived: true })
770+
})
771+
772+
it('skips the API call when channel is already archived', async () => {
773+
refsMocks.resolveChannelRef.mockResolvedValue(
774+
createChannel(500, 'Engineering', { id: 'CH500', archived: true }),
775+
)
776+
const client = createClient()
777+
apiMocks.getCommsClient.mockResolvedValue(client)
778+
const consoleSpy = captureConsole('log')
779+
780+
await runChannelCommand(['archive', 'Engineering'])
781+
782+
expect(client.channels.archiveChannel).not.toHaveBeenCalled()
783+
expect(consoleSpy.mock.calls[0][0]).toContain('already in target state')
784+
})
785+
})
786+
787+
describe('channels unarchive', () => {
788+
beforeEach(() => {
789+
vi.clearAllMocks()
790+
apiMocks.getCurrentWorkspaceId.mockResolvedValue(1)
791+
refsMocks.getDirectChannelId.mockReturnValue(null)
792+
refsMocks.resolveChannelRef.mockResolvedValue(
793+
createChannel(500, 'Engineering', { id: 'CH500', archived: true }),
794+
)
795+
})
796+
797+
it('unarchives the resolved channel', async () => {
798+
const client = createClient()
799+
apiMocks.getCommsClient.mockResolvedValue(client)
800+
const consoleSpy = captureConsole('log')
801+
802+
await runChannelCommand(['unarchive', 'id:CH500'])
803+
804+
expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('id:CH500', 1)
805+
expect(client.channels.unarchiveChannel).toHaveBeenCalledWith('CH500')
806+
expect(consoleSpy.mock.calls[0][0]).toContain('unarchived')
807+
})
808+
809+
it('does not call the API on --dry-run', async () => {
810+
const client = createClient()
811+
apiMocks.getCommsClient.mockResolvedValue(client)
812+
const consoleSpy = captureConsole('log')
813+
814+
await runChannelCommand(['unarchive', 'id:CH500', '--dry-run'])
815+
816+
expect(client.channels.unarchiveChannel).not.toHaveBeenCalled()
817+
const text = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n')
818+
expect(text).toContain('unarchive channel')
819+
})
820+
821+
it('outputs JSON with --json', async () => {
822+
const client = createClient()
823+
apiMocks.getCommsClient.mockResolvedValue(client)
824+
const consoleSpy = captureConsole('log')
825+
826+
await runChannelCommand(['unarchive', 'id:CH500', '--json'])
827+
828+
expect(JSON.parse(consoleSpy.mock.calls[0][0])).toEqual({ id: 'CH500', archived: false })
829+
})
830+
})
831+
832+
const lifecycleCommands = [
833+
{ name: 'delete', extraArgs: ['--yes'], method: 'deleteChannel', archived: false },
834+
{ name: 'archive', extraArgs: [], method: 'archiveChannel', archived: false },
835+
{ name: 'unarchive', extraArgs: [], method: 'unarchiveChannel', archived: true },
836+
] as const
837+
838+
describe('channels lifecycle --workspace', () => {
839+
beforeEach(() => {
840+
vi.clearAllMocks()
841+
apiMocks.getCurrentWorkspaceId.mockResolvedValue(1)
842+
refsMocks.getDirectChannelId.mockReturnValue(null)
843+
refsMocks.resolveWorkspaceRef.mockResolvedValue({ id: 42, name: 'Other' })
844+
})
845+
846+
for (const cmd of lifecycleCommands) {
847+
it(`${cmd.name} resolves --workspace and passes its ID to resolveChannelRef`, async () => {
848+
refsMocks.resolveChannelRef.mockResolvedValue(
849+
createChannel(500, 'Engineering', { id: 'CH500', archived: cmd.archived }),
850+
)
851+
const client = createClient()
852+
apiMocks.getCommsClient.mockResolvedValue(client)
853+
captureConsole('log')
854+
855+
await runChannelCommand([
856+
cmd.name,
857+
'Engineering',
858+
'--workspace',
859+
'Other',
860+
...cmd.extraArgs,
861+
])
862+
863+
expect(refsMocks.resolveWorkspaceRef).toHaveBeenCalledWith('Other')
864+
expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('Engineering', 42)
865+
expect(client.channels[cmd.method]).toHaveBeenCalledWith('CH500')
866+
expect(apiMocks.getCurrentWorkspaceId).not.toHaveBeenCalled()
867+
})
868+
}
869+
})
870+
871+
describe('channels lifecycle direct refs', () => {
872+
beforeEach(() => {
873+
vi.clearAllMocks()
874+
refsMocks.getDirectChannelId.mockReturnValue('CH500')
875+
})
876+
877+
for (const cmd of lifecycleCommands) {
878+
it(`${cmd.name} fetches a direct ref by ID without resolving a workspace`, async () => {
879+
const client = createClient()
880+
client.channels.getChannel.mockResolvedValue(
881+
createChannel(500, 'Engineering', { id: 'CH500', archived: cmd.archived }),
882+
)
883+
apiMocks.getCommsClient.mockResolvedValue(client)
884+
captureConsole('log')
885+
886+
await runChannelCommand([cmd.name, 'id:CH500', ...cmd.extraArgs])
887+
888+
expect(client.channels.getChannel).toHaveBeenCalledWith('CH500')
889+
expect(refsMocks.resolveChannelRef).not.toHaveBeenCalled()
890+
expect(refsMocks.resolveWorkspaceRef).not.toHaveBeenCalled()
891+
expect(apiMocks.getCurrentWorkspaceId).not.toHaveBeenCalled()
892+
expect(client.channels[cmd.method]).toHaveBeenCalledWith('CH500')
893+
})
894+
}
895+
})

0 commit comments

Comments
 (0)