From 5b7c57b5666bfd5d612f9ecc7369dbbe871484eb Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Wed, 27 May 2026 17:22:15 +0100 Subject: [PATCH 1/2] 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) --- skills/comms-cli/SKILL.md | 4 + src/commands/channel/archive.ts | 51 +++++++ src/commands/channel/channel.test.ts | 199 +++++++++++++++++++++++++++ src/commands/channel/delete.ts | 59 ++++++++ src/commands/channel/index.ts | 59 ++++++++ src/lib/api.ts | 2 + src/lib/errors.ts | 1 + src/lib/skills/content.ts | 4 + 8 files changed, 379 insertions(+) create mode 100644 src/commands/channel/archive.ts create mode 100644 src/commands/channel/delete.ts diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index 1b8ac0e..2d1a3eb 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -219,6 +219,10 @@ tdc channel update --description "Team discussions" # Update channel descr 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 delete --yes # Permanently delete a channel (requires --yes; usually admin-only) +tdc channel delete --dry-run # Preview deletion +tdc channel archive # Archive a channel (no-op if already archived) +tdc channel unarchive id: # Unarchive a channel (pass id:/numeric ref for archived channels) 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/archive.ts b/src/commands/channel/archive.ts new file mode 100644 index 0000000..834fbaf --- /dev/null +++ b/src/commands/channel/archive.ts @@ -0,0 +1,51 @@ +import { getCommsClient } from '../../lib/api.js' +import type { MutationOptions } from '../../lib/options.js' +import { formatJson, printDryRun } from '../../lib/output.js' +import { resolveChannelRef } from '../../lib/refs.js' +import { resolveChannelWorkspaceId } from './helpers.js' + +type ArchiveChannelOptions = MutationOptions & { workspace?: string } + +async function setArchiveState( + ref: string, + options: ArchiveChannelOptions, + archive: boolean, +): Promise { + const action = archive ? 'archive' : 'unarchive' + const workspaceId = await resolveChannelWorkspaceId(options.workspace) + const channel = await resolveChannelRef(ref, workspaceId) + + if (options.dryRun) { + printDryRun(`${action} channel`, { + Channel: `${channel.name} (id:${channel.id})`, + 'Currently archived': channel.archived ? 'yes' : 'no', + }) + return + } + + if (channel.archived !== archive) { + const client = await getCommsClient() + if (archive) { + await client.channels.archiveChannel(channel.id) + } else { + await client.channels.unarchiveChannel(channel.id) + } + } + + if (options.json) { + console.log(formatJson({ id: channel.id, archived: archive })) + return + } + + const verb = archive ? 'archived' : 'unarchived' + const noop = channel.archived === archive ? ' (already in target state)' : '' + console.log(`Channel "${channel.name}" (id:${channel.id}) ${verb}${noop}.`) +} + +export async function archiveChannel(ref: string, options: ArchiveChannelOptions): Promise { + await setArchiveState(ref, options, true) +} + +export async function unarchiveChannel(ref: string, options: ArchiveChannelOptions): Promise { + await setArchiveState(ref, options, false) +} diff --git a/src/commands/channel/channel.test.ts b/src/commands/channel/channel.test.ts index ac562ae..28e0f6c 100644 --- a/src/commands/channel/channel.test.ts +++ b/src/commands/channel/channel.test.ts @@ -3,6 +3,7 @@ import { createTestProgram, describeEmptyMachineOutput, } from '@doist/cli-core/testing' +import { CommsRequestError } from '@doist/comms-sdk' import { beforeEach, describe, expect, it, vi } from 'vitest' const apiMocks = vi.hoisted(() => ({ @@ -83,6 +84,9 @@ function createClient({ getChannel: vi.fn(), createChannel: vi.fn().mockResolvedValue(createdChannel), updateChannel: vi.fn().mockResolvedValue(updatedChannel), + deleteChannel: vi.fn().mockResolvedValue({ status: 'ok' }), + archiveChannel: vi.fn().mockResolvedValue({ status: 'ok' }), + unarchiveChannel: vi.fn().mockResolvedValue({ status: 'ok' }), }, workspaces: { getPublicChannels: vi.fn().mockResolvedValue(publicChannels), @@ -626,3 +630,198 @@ describe('channels update', () => { ).rejects.toHaveProperty('code', 'CONFLICTING_OPTIONS') }) }) + +describe('channels delete', () => { + beforeEach(() => { + vi.clearAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + refsMocks.resolveChannelRef.mockResolvedValue( + createChannel(500, 'Engineering', { id: 'CH500' }), + ) + }) + + it('refuses to delete without --yes', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = captureConsole('log') + + await runChannelCommand(['delete', 'Engineering']) + + expect(client.channels.deleteChannel).not.toHaveBeenCalled() + expect(consoleSpy.mock.calls.some((c) => String(c[0]).includes('Use --yes'))).toBe(true) + }) + + it('deletes when --yes is passed', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = captureConsole('log') + + await runChannelCommand(['delete', 'Engineering', '--yes']) + + expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('Engineering', 1) + expect(client.channels.deleteChannel).toHaveBeenCalledWith('CH500') + expect(consoleSpy.mock.calls[0][0]).toContain('Engineering') + }) + + it('does not delete on --dry-run', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = captureConsole('log') + + await runChannelCommand(['delete', 'Engineering', '--dry-run']) + + expect(client.channels.deleteChannel).not.toHaveBeenCalled() + const text = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(text).toContain('delete channel') + }) + + it('does not delete when --yes is combined with --dry-run', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = captureConsole('log') + + await runChannelCommand(['delete', 'Engineering', '--yes', '--dry-run']) + + expect(client.channels.deleteChannel).not.toHaveBeenCalled() + const text = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(text).toContain('delete channel') + }) + + it('errors in --json mode without --yes before doing any lookups', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + + await expect(runChannelCommand(['delete', 'Engineering', '--json'])).rejects.toHaveProperty( + 'code', + 'MISSING_YES_FLAG', + ) + expect(client.channels.deleteChannel).not.toHaveBeenCalled() + expect(refsMocks.resolveChannelRef).not.toHaveBeenCalled() + expect(apiMocks.getCurrentWorkspaceId).not.toHaveBeenCalled() + }) + + it('translates a 403 from the API into a FORBIDDEN CliError', async () => { + const client = createClient() + client.channels.deleteChannel.mockRejectedValueOnce( + new CommsRequestError('Request failed with status 403', 403, {}), + ) + apiMocks.getCommsClient.mockResolvedValue(client) + + await expect(runChannelCommand(['delete', 'Engineering', '--yes'])).rejects.toHaveProperty( + 'code', + 'FORBIDDEN', + ) + }) + + it('outputs JSON result with --yes --json', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = captureConsole('log') + + await runChannelCommand(['delete', 'Engineering', '--yes', '--json']) + + expect(JSON.parse(consoleSpy.mock.calls[0][0])).toEqual({ id: 'CH500', deleted: true }) + }) +}) + +describe('channels archive', () => { + beforeEach(() => { + vi.clearAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + refsMocks.resolveChannelRef.mockResolvedValue( + createChannel(500, 'Engineering', { id: 'CH500' }), + ) + }) + + it('archives the resolved channel', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = captureConsole('log') + + await runChannelCommand(['archive', 'Engineering']) + + expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('Engineering', 1) + expect(client.channels.archiveChannel).toHaveBeenCalledWith('CH500') + expect(consoleSpy.mock.calls[0][0]).toContain('archived') + }) + + it('does not call the API on --dry-run', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = captureConsole('log') + + await runChannelCommand(['archive', 'Engineering', '--dry-run']) + + expect(client.channels.archiveChannel).not.toHaveBeenCalled() + const text = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(text).toContain('archive channel') + }) + + it('outputs JSON with --json', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = captureConsole('log') + + await runChannelCommand(['archive', 'Engineering', '--json']) + + expect(JSON.parse(consoleSpy.mock.calls[0][0])).toEqual({ id: 'CH500', archived: true }) + }) + + it('skips the API call when channel is already archived', async () => { + refsMocks.resolveChannelRef.mockResolvedValue( + createChannel(500, 'Engineering', { id: 'CH500', archived: true }), + ) + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = captureConsole('log') + + await runChannelCommand(['archive', 'Engineering']) + + expect(client.channels.archiveChannel).not.toHaveBeenCalled() + expect(consoleSpy.mock.calls[0][0]).toContain('already in target state') + }) +}) + +describe('channels unarchive', () => { + beforeEach(() => { + vi.clearAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + refsMocks.resolveChannelRef.mockResolvedValue( + createChannel(500, 'Engineering', { id: 'CH500', archived: true }), + ) + }) + + it('unarchives the resolved channel', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = captureConsole('log') + + await runChannelCommand(['unarchive', 'id:CH500']) + + expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('id:CH500', 1) + expect(client.channels.unarchiveChannel).toHaveBeenCalledWith('CH500') + expect(consoleSpy.mock.calls[0][0]).toContain('unarchived') + }) + + it('does not call the API on --dry-run', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = captureConsole('log') + + await runChannelCommand(['unarchive', 'id:CH500', '--dry-run']) + + expect(client.channels.unarchiveChannel).not.toHaveBeenCalled() + const text = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(text).toContain('unarchive channel') + }) + + it('outputs JSON with --json', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = captureConsole('log') + + await runChannelCommand(['unarchive', 'id:CH500', '--json']) + + expect(JSON.parse(consoleSpy.mock.calls[0][0])).toEqual({ id: 'CH500', archived: false }) + }) +}) diff --git a/src/commands/channel/delete.ts b/src/commands/channel/delete.ts new file mode 100644 index 0000000..cda0d57 --- /dev/null +++ b/src/commands/channel/delete.ts @@ -0,0 +1,59 @@ +import { CommsRequestError } 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 { resolveChannelRef } from '../../lib/refs.js' +import { resolveChannelWorkspaceId } from './helpers.js' + +type DeleteChannelOptions = MutationOptions & { yes?: boolean; workspace?: string } + +export async function deleteChannel(ref: string, options: DeleteChannelOptions): Promise { + if (!options.yes && options.json && !options.dryRun) { + throw new CliError( + 'MISSING_YES_FLAG', + '--yes is required to execute deletion in --json mode.', + ) + } + + const workspaceId = await resolveChannelWorkspaceId(options.workspace) + const channel = await resolveChannelRef(ref, workspaceId) + + if (options.dryRun) { + printDryRun('delete channel', { + Channel: `${channel.name} (id:${channel.id})`, + Visibility: channel.public ? 'public' : 'private', + }) + return + } + + if (!options.yes) { + console.log(`Would delete: ${channel.name} (id:${channel.id})`) + console.log('Use --yes to confirm.') + return + } + + const client = await getCommsClient() + try { + await client.channels.deleteChannel(channel.id) + } catch (error) { + if (error instanceof CommsRequestError && error.httpStatusCode === 403) { + throw new CliError( + 'FORBIDDEN', + `Comms refused to delete "${channel.name}" (id:${channel.id}): 403 Forbidden.`, + [ + 'Channel deletion is typically restricted to workspace admins', + 'Ask a workspace admin to delete it, or use the Comms web UI', + ], + ) + } + throw error + } + + if (options.json) { + console.log(formatJson({ id: channel.id, deleted: true })) + return + } + + console.log(`Channel "${channel.name}" (id:${channel.id}) deleted.`) +} diff --git a/src/commands/channel/index.ts b/src/commands/channel/index.ts index a2518a5..98bf2c5 100644 --- a/src/commands/channel/index.ts +++ b/src/commands/channel/index.ts @@ -1,7 +1,9 @@ import { Command, Option } from 'commander' import { withCaseInsensitiveChoices } from '../../lib/completion.js' import { addChannelMembers } from './add.js' +import { archiveChannel, unarchiveChannel } from './archive.js' import { createChannel } from './create.js' +import { deleteChannel } from './delete.js' import { listChannels } from './list.js' import { listChannelMembers } from './members.js' import { removeChannelMembers } from './remove.js' @@ -98,6 +100,63 @@ Examples: ) .action(updateChannel) + channel + .command('delete ') + .description('Permanently delete a channel') + .option('--workspace ', 'Workspace ID or name') + .option('--yes', 'Confirm deletion') + .option('--dry-run', 'Show what would happen without executing') + .option('--json', 'Output result as JSON') + .addHelpText( + 'after', + ` +Examples: + tdc channel delete "Engineering" --dry-run + tdc channel delete "Engineering" --yes + tdc channel delete id:abc123 --yes --json + +Notes: + Channel deletion is typically restricted to workspace admins.`, + ) + .action(deleteChannel) + + channel + .command('archive ') + .description('Archive a channel') + .option('--workspace ', 'Workspace ID or name') + .option('--dry-run', 'Show what would happen without executing') + .option('--json', 'Output result as JSON') + .addHelpText( + 'after', + ` +Examples: + tdc channel archive "Engineering" + tdc channel archive id:abc123 --json + +Notes: + No-op if the channel is already archived. + Archived channels can be listed with: tdc channels --state archived`, + ) + .action(archiveChannel) + + channel + .command('unarchive ') + .description('Unarchive a channel') + .option('--workspace ', 'Workspace ID or name') + .option('--dry-run', 'Show what would happen without executing') + .option('--json', 'Output result as JSON') + .addHelpText( + 'after', + ` +Examples: + tdc channel unarchive id:abc123 + tdc channel unarchive id:abc123 --json + +Notes: + Name-ref resolution only finds active channels — pass id: or numeric ID for archived channels.`, + ) + .action(unarchiveChannel) + channel .command('threads [workspace-ref]') .description('List threads in a channel with pagination and filtering') diff --git a/src/lib/api.ts b/src/lib/api.ts index 2be200a..afe7e82 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -52,6 +52,8 @@ const API_SPINNER_MESSAGES: Record --description "Team discussions" # Update channel descr 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 delete --yes # Permanently delete a channel (requires --yes; usually admin-only) +tdc channel delete --dry-run # Preview deletion +tdc channel archive # Archive a channel (no-op if already archived) +tdc channel unarchive id: # Unarchive a channel (pass id:/numeric ref for archived channels) 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) From 19684efad174c72a8718e99ba283d619df53105c Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Wed, 27 May 2026 17:36:35 +0100 Subject: [PATCH 2/2] 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) --- src/commands/channel/archive.ts | 6 +-- src/commands/channel/channel.test.ts | 68 ++++++++++++++++++++++++++++ src/commands/channel/delete.ts | 27 +++++------ src/commands/channel/helpers.ts | 32 ++++++++++++- 4 files changed, 112 insertions(+), 21 deletions(-) diff --git a/src/commands/channel/archive.ts b/src/commands/channel/archive.ts index 834fbaf..0fff20e 100644 --- a/src/commands/channel/archive.ts +++ b/src/commands/channel/archive.ts @@ -1,8 +1,7 @@ import { getCommsClient } from '../../lib/api.js' import type { MutationOptions } from '../../lib/options.js' import { formatJson, printDryRun } from '../../lib/output.js' -import { resolveChannelRef } from '../../lib/refs.js' -import { resolveChannelWorkspaceId } from './helpers.js' +import { resolveChannelByRef } from './helpers.js' type ArchiveChannelOptions = MutationOptions & { workspace?: string } @@ -12,8 +11,7 @@ async function setArchiveState( archive: boolean, ): Promise { const action = archive ? 'archive' : 'unarchive' - const workspaceId = await resolveChannelWorkspaceId(options.workspace) - const channel = await resolveChannelRef(ref, workspaceId) + const channel = await resolveChannelByRef(ref, options.workspace) if (options.dryRun) { printDryRun(`${action} channel`, { diff --git a/src/commands/channel/channel.test.ts b/src/commands/channel/channel.test.ts index 28e0f6c..4d7c2f2 100644 --- a/src/commands/channel/channel.test.ts +++ b/src/commands/channel/channel.test.ts @@ -635,6 +635,7 @@ describe('channels delete', () => { beforeEach(() => { vi.clearAllMocks() apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + refsMocks.getDirectChannelId.mockReturnValue(null) refsMocks.resolveChannelRef.mockResolvedValue( createChannel(500, 'Engineering', { id: 'CH500' }), ) @@ -728,6 +729,7 @@ describe('channels archive', () => { beforeEach(() => { vi.clearAllMocks() apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + refsMocks.getDirectChannelId.mockReturnValue(null) refsMocks.resolveChannelRef.mockResolvedValue( createChannel(500, 'Engineering', { id: 'CH500' }), ) @@ -786,6 +788,7 @@ describe('channels unarchive', () => { beforeEach(() => { vi.clearAllMocks() apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + refsMocks.getDirectChannelId.mockReturnValue(null) refsMocks.resolveChannelRef.mockResolvedValue( createChannel(500, 'Engineering', { id: 'CH500', archived: true }), ) @@ -825,3 +828,68 @@ describe('channels unarchive', () => { expect(JSON.parse(consoleSpy.mock.calls[0][0])).toEqual({ id: 'CH500', archived: false }) }) }) + +const lifecycleCommands = [ + { name: 'delete', extraArgs: ['--yes'], method: 'deleteChannel', archived: false }, + { name: 'archive', extraArgs: [], method: 'archiveChannel', archived: false }, + { name: 'unarchive', extraArgs: [], method: 'unarchiveChannel', archived: true }, +] as const + +describe('channels lifecycle --workspace', () => { + beforeEach(() => { + vi.clearAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + refsMocks.getDirectChannelId.mockReturnValue(null) + refsMocks.resolveWorkspaceRef.mockResolvedValue({ id: 42, name: 'Other' }) + }) + + for (const cmd of lifecycleCommands) { + it(`${cmd.name} resolves --workspace and passes its ID to resolveChannelRef`, async () => { + refsMocks.resolveChannelRef.mockResolvedValue( + createChannel(500, 'Engineering', { id: 'CH500', archived: cmd.archived }), + ) + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + captureConsole('log') + + await runChannelCommand([ + cmd.name, + 'Engineering', + '--workspace', + 'Other', + ...cmd.extraArgs, + ]) + + expect(refsMocks.resolveWorkspaceRef).toHaveBeenCalledWith('Other') + expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('Engineering', 42) + expect(client.channels[cmd.method]).toHaveBeenCalledWith('CH500') + expect(apiMocks.getCurrentWorkspaceId).not.toHaveBeenCalled() + }) + } +}) + +describe('channels lifecycle direct refs', () => { + beforeEach(() => { + vi.clearAllMocks() + refsMocks.getDirectChannelId.mockReturnValue('CH500') + }) + + for (const cmd of lifecycleCommands) { + it(`${cmd.name} fetches a direct ref by ID without resolving a workspace`, async () => { + const client = createClient() + client.channels.getChannel.mockResolvedValue( + createChannel(500, 'Engineering', { id: 'CH500', archived: cmd.archived }), + ) + apiMocks.getCommsClient.mockResolvedValue(client) + captureConsole('log') + + await runChannelCommand([cmd.name, 'id:CH500', ...cmd.extraArgs]) + + expect(client.channels.getChannel).toHaveBeenCalledWith('CH500') + expect(refsMocks.resolveChannelRef).not.toHaveBeenCalled() + expect(refsMocks.resolveWorkspaceRef).not.toHaveBeenCalled() + expect(apiMocks.getCurrentWorkspaceId).not.toHaveBeenCalled() + expect(client.channels[cmd.method]).toHaveBeenCalledWith('CH500') + }) + } +}) diff --git a/src/commands/channel/delete.ts b/src/commands/channel/delete.ts index cda0d57..f4e1761 100644 --- a/src/commands/channel/delete.ts +++ b/src/commands/channel/delete.ts @@ -3,21 +3,24 @@ 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 { resolveChannelRef } from '../../lib/refs.js' -import { resolveChannelWorkspaceId } from './helpers.js' +import { resolveChannelByRef } from './helpers.js' type DeleteChannelOptions = MutationOptions & { yes?: boolean; workspace?: string } export async function deleteChannel(ref: string, options: DeleteChannelOptions): Promise { - if (!options.yes && options.json && !options.dryRun) { - throw new CliError( - 'MISSING_YES_FLAG', - '--yes is required to execute deletion in --json mode.', - ) + if (!options.yes && !options.dryRun) { + if (options.json) { + throw new CliError( + 'MISSING_YES_FLAG', + '--yes is required to execute deletion in --json mode.', + ) + } + console.log(`Would delete: ${ref}`) + console.log('Use --yes to confirm.') + return } - const workspaceId = await resolveChannelWorkspaceId(options.workspace) - const channel = await resolveChannelRef(ref, workspaceId) + const channel = await resolveChannelByRef(ref, options.workspace) if (options.dryRun) { printDryRun('delete channel', { @@ -27,12 +30,6 @@ export async function deleteChannel(ref: string, options: DeleteChannelOptions): return } - if (!options.yes) { - console.log(`Would delete: ${channel.name} (id:${channel.id})`) - console.log('Use --yes to confirm.') - return - } - const client = await getCommsClient() try { await client.channels.deleteChannel(channel.id) diff --git a/src/commands/channel/helpers.ts b/src/commands/channel/helpers.ts index cf8997c..1412c96 100644 --- a/src/commands/channel/helpers.ts +++ b/src/commands/channel/helpers.ts @@ -1,6 +1,12 @@ -import { getCurrentWorkspaceId } from '../../lib/api.js' +import type { Channel } from '@doist/comms-sdk' +import { getCommsClient, getCurrentWorkspaceId } from '../../lib/api.js' import { CliError } from '../../lib/errors.js' -import { parseRef, resolveWorkspaceRef } from '../../lib/refs.js' +import { + getDirectChannelId, + parseRef, + resolveChannelRef, + resolveWorkspaceRef, +} from '../../lib/refs.js' import { validateNonEmptyName } from '../../lib/validation.js' export function validateChannelName(name: string): void { @@ -34,6 +40,28 @@ export async function resolveChannelWorkspaceId(workspaceRef: string | undefined return getCurrentWorkspaceId() } +/** + * Resolve a channel for a mutation. Direct refs (`id:` or URL) are fetched by + * ID via `getChannel`, which is workspace-agnostic — so a channel in another + * workspace resolves without a `--workspace` flag. Name refs still need a + * workspace to disambiguate. An explicit `--workspace` forces the name path. + */ +export async function resolveChannelByRef( + ref: string, + workspaceRef: string | undefined, +): Promise { + if (!workspaceRef) { + const directId = getDirectChannelId(ref) + if (directId) { + const client = await getCommsClient() + return client.channels.getChannel(directId) + } + } + + const workspaceId = await resolveChannelWorkspaceId(workspaceRef) + return resolveChannelRef(ref, workspaceId) +} + export function encodeCursor(offset: number): string { return Buffer.from(JSON.stringify({ offset })).toString('base64url') }