diff --git a/skills/twist-cli/SKILL.md b/skills/twist-cli/SKILL.md index 1572e7c..38d7ed7 100644 --- a/skills/twist-cli/SKILL.md +++ b/skills/twist-cli/SKILL.md @@ -217,6 +217,16 @@ tw channel threads --since 2026-01-01 # Filter by last-updated date (ISO) tw channel threads --limit 20 # Max threads per page (default: 50) tw channel threads --limit 20 --cursor # Paginate tw channel threads --json # { results, nextCursor } with isUnread + url +tw channel create "Name" # Create a new (public) channel in the default/current workspace +tw channel create "Name" --workspace # Target a different workspace +tw channel create "Name" --private # Create a private channel +tw channel create "Name" --description "..." # Set channel description +tw channel create "Name" --json --full # Output created channel as JSON (all fields) +tw channel delete --yes # Delete a channel (requires --yes; usually admin-only on Twist) +tw channel delete --workspace --yes # Target a different workspace +tw channel delete --dry-run # Preview deletion +tw channel archive # Archive a channel (no-op if already archived) +tw channel unarchive id: # Unarchive a channel (pass id:/numeric ref for archived channels) tw groups # List workspace groups tw groups --search "frontend" # Filter groups by name (case-insensitive) tw groups --json # JSON output diff --git a/src/commands/channel/archive.ts b/src/commands/channel/archive.ts new file mode 100644 index 0000000..4e507c6 --- /dev/null +++ b/src/commands/channel/archive.ts @@ -0,0 +1,57 @@ +import { archiveChannel, getCurrentWorkspaceId, unarchiveChannel } from '../../lib/api.js' +import type { MutationOptions } from '../../lib/options.js' +import { formatJson, printDryRun } from '../../lib/output.js' +import { resolveChannelRef, resolveWorkspaceRef } from '../../lib/refs.js' + +type ArchiveChannelOptions = MutationOptions & { workspace?: string } + +async function setArchiveState( + ref: string, + options: ArchiveChannelOptions, + archive: boolean, +): Promise { + const action = archive ? 'archive' : 'unarchive' + const workspaceId = options.workspace + ? (await resolveWorkspaceRef(options.workspace)).id + : await getCurrentWorkspaceId() + 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) { + if (archive) { + await archiveChannel(channel.id) + } else { + await 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 archiveChannelCommand( + ref: string, + options: ArchiveChannelOptions, +): Promise { + await setArchiveState(ref, options, true) +} + +export async function unarchiveChannelCommand( + 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 5c7bcc0..b4ce0e6 100644 --- a/src/commands/channel/channel.test.ts +++ b/src/commands/channel/channel.test.ts @@ -5,6 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const apiMocks = vi.hoisted(() => ({ getTwistClient: vi.fn(), getCurrentWorkspaceId: vi.fn().mockResolvedValue(1), + createChannel: vi.fn(), + deleteChannel: vi.fn(), + archiveChannel: vi.fn(), + unarchiveChannel: vi.fn(), })) const refsMocks = vi.hoisted(() => ({ @@ -20,6 +24,10 @@ const globalArgsMocks = vi.hoisted(() => ({ vi.mock('../../lib/api.js', () => ({ getTwistClient: apiMocks.getTwistClient, getCurrentWorkspaceId: apiMocks.getCurrentWorkspaceId, + createChannel: apiMocks.createChannel, + deleteChannel: apiMocks.deleteChannel, + archiveChannel: apiMocks.archiveChannel, + unarchiveChannel: apiMocks.unarchiveChannel, })) vi.mock('../../lib/refs.js', () => ({ @@ -401,3 +409,368 @@ describe('channels list', () => { ).rejects.toHaveProperty('code', 'INVALID_STATE') }) }) + +describe('tw channel create', () => { + beforeEach(() => { + vi.clearAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + apiMocks.createChannel.mockResolvedValue(createChannel(999, 'Engineering')) + }) + + it('creates a public channel by default in the current workspace', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'create', 'Engineering']) + + expect(apiMocks.createChannel).toHaveBeenCalledWith({ + workspaceId: 1, + name: 'Engineering', + description: undefined, + public: true, + }) + expect(consoleSpy.mock.calls[0][0]).toContain('Engineering') + expect(consoleSpy.mock.calls[0][0]).toContain('public') + + consoleSpy.mockRestore() + }) + + it('resolves --workspace ref when provided', async () => { + refsMocks.resolveWorkspaceRef.mockResolvedValue({ id: 42, name: 'Other' }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'create', + 'Engineering', + '--workspace', + 'Other', + ]) + + expect(refsMocks.resolveWorkspaceRef).toHaveBeenCalledWith('Other') + expect(apiMocks.createChannel).toHaveBeenCalledWith({ + workspaceId: 42, + name: 'Engineering', + description: undefined, + public: true, + }) + + consoleSpy.mockRestore() + }) + + it('passes --description and --private through to createChannel', async () => { + apiMocks.createChannel.mockResolvedValue( + createChannel(999, 'Leadership', { public: false, description: 'Internal' }), + ) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'create', + 'Leadership', + '--private', + '--description', + 'Internal', + ]) + + expect(apiMocks.createChannel).toHaveBeenCalledWith({ + workspaceId: 1, + name: 'Leadership', + description: 'Internal', + public: false, + }) + + consoleSpy.mockRestore() + }) + + it('does not call the API on --dry-run', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'create', + 'Engineering', + '--private', + '--dry-run', + ]) + + expect(apiMocks.createChannel).not.toHaveBeenCalled() + const text = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(text).toContain('create channel') + expect(text).toContain('private') + + consoleSpy.mockRestore() + }) + + it('outputs created channel as JSON', async () => { + apiMocks.createChannel.mockResolvedValue(createChannel(123, 'Engineering')) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'create', 'Engineering', '--json']) + + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(output.id).toBe(123) + expect(output.name).toBe('Engineering') + + consoleSpy.mockRestore() + }) + + it('forwards --full so JSON output includes all channel fields', async () => { + apiMocks.createChannel.mockResolvedValue( + createChannel(123, 'Engineering', { description: 'Eng channel', creator: 9 }), + ) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'create', + 'Engineering', + '--json', + '--full', + ]) + + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + // The default-shape filter drops `public`/`creator`; --full keeps them. + expect(output).toMatchObject({ id: 123, name: 'Engineering', public: true, creator: 9 }) + + consoleSpy.mockRestore() + }) + + it('rejects an empty name', async () => { + const program = createProgram() + await expect( + program.parseAsync(['node', 'tw', 'channel', 'create', ' ']), + ).rejects.toMatchObject({ code: 'INVALID_NAME' }) + }) +}) + +describe('tw channel delete', () => { + beforeEach(() => { + vi.clearAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + refsMocks.resolveChannelRef.mockResolvedValue(createChannel(500, 'Engineering')) + }) + + it('refuses to delete without --yes', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'delete', 'Engineering']) + + expect(apiMocks.deleteChannel).not.toHaveBeenCalled() + expect(consoleSpy.mock.calls.some((c) => String(c[0]).includes('Use --yes'))).toBe(true) + + consoleSpy.mockRestore() + }) + + it('deletes when --yes is passed', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'delete', 'Engineering', '--yes']) + + expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('Engineering', 1) + expect(apiMocks.deleteChannel).toHaveBeenCalledWith(500) + expect(consoleSpy.mock.calls[0][0]).toContain('Engineering') + + consoleSpy.mockRestore() + }) + + it('does not delete on --dry-run', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'delete', 'Engineering', '--dry-run']) + + expect(apiMocks.deleteChannel).not.toHaveBeenCalled() + const text = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(text).toContain('delete channel') + + consoleSpy.mockRestore() + }) + + it('does not delete when --yes is combined with --dry-run', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'delete', + 'Engineering', + '--yes', + '--dry-run', + ]) + + expect(apiMocks.deleteChannel).not.toHaveBeenCalled() + const text = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(text).toContain('delete channel') + + consoleSpy.mockRestore() + }) + + it('errors in --json mode without --yes before doing any lookups', async () => { + const program = createProgram() + await expect( + program.parseAsync(['node', 'tw', 'channel', 'delete', 'Engineering', '--json']), + ).rejects.toMatchObject({ code: 'MISSING_YES_FLAG' }) + expect(apiMocks.deleteChannel).not.toHaveBeenCalled() + // The guard fires before any ref/workspace resolution. + expect(refsMocks.resolveChannelRef).not.toHaveBeenCalled() + expect(apiMocks.getCurrentWorkspaceId).not.toHaveBeenCalled() + }) + + it('translates a 403 from the API into a FORBIDDEN CliError', async () => { + const { TwistRequestError } = await import('@doist/twist-sdk') + const apiError = new TwistRequestError('Request failed with status 403', 403, {}) + apiMocks.deleteChannel.mockRejectedValueOnce(apiError) + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tw', 'channel', 'delete', 'Engineering', '--yes']), + ).rejects.toMatchObject({ code: 'FORBIDDEN' }) + }) + + it('outputs JSON result with --yes --json', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'delete', + 'Engineering', + '--yes', + '--json', + ]) + + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(output).toEqual({ id: 500, deleted: true }) + + consoleSpy.mockRestore() + }) +}) + +describe('tw channel archive', () => { + beforeEach(() => { + vi.clearAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + refsMocks.resolveChannelRef.mockResolvedValue(createChannel(500, 'Engineering')) + }) + + it('archives the resolved channel', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'archive', 'Engineering']) + + expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('Engineering', 1) + expect(apiMocks.archiveChannel).toHaveBeenCalledWith(500) + expect(consoleSpy.mock.calls[0][0]).toContain('archived') + + consoleSpy.mockRestore() + }) + + it('does not call the API on --dry-run', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'archive', 'Engineering', '--dry-run']) + + expect(apiMocks.archiveChannel).not.toHaveBeenCalled() + const text = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(text).toContain('archive channel') + + consoleSpy.mockRestore() + }) + + it('outputs JSON with --json', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'archive', 'Engineering', '--json']) + + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(output).toEqual({ id: 500, archived: true }) + + consoleSpy.mockRestore() + }) + + it('skips the API call when channel is already archived', async () => { + refsMocks.resolveChannelRef.mockResolvedValue( + createChannel(500, 'Engineering', { archived: true }), + ) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'archive', 'Engineering']) + + expect(apiMocks.archiveChannel).not.toHaveBeenCalled() + expect(consoleSpy.mock.calls[0][0]).toContain('already in target state') + + consoleSpy.mockRestore() + }) +}) + +describe('tw channel unarchive', () => { + beforeEach(() => { + vi.clearAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + refsMocks.resolveChannelRef.mockResolvedValue( + createChannel(500, 'Engineering', { archived: true }), + ) + }) + + it('unarchives the resolved channel', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'unarchive', 'id:500']) + + expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('id:500', 1) + expect(apiMocks.unarchiveChannel).toHaveBeenCalledWith(500) + expect(consoleSpy.mock.calls[0][0]).toContain('unarchived') + + consoleSpy.mockRestore() + }) + + it('does not call the API on --dry-run', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'unarchive', 'id:500', '--dry-run']) + + expect(apiMocks.unarchiveChannel).not.toHaveBeenCalled() + const text = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(text).toContain('unarchive channel') + + consoleSpy.mockRestore() + }) + + it('outputs JSON with --json', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'unarchive', 'id:500', '--json']) + + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(output).toEqual({ id: 500, archived: false }) + + consoleSpy.mockRestore() + }) +}) diff --git a/src/commands/channel/create.ts b/src/commands/channel/create.ts new file mode 100644 index 0000000..eb89ab9 --- /dev/null +++ b/src/commands/channel/create.ts @@ -0,0 +1,51 @@ +import { createChannel, 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 { resolveWorkspaceRef } from '../../lib/refs.js' + +type CreateChannelOptions = MutationOptions & { + workspace?: string + description?: string + private?: boolean +} + +export async function createChannelCommand( + name: string, + options: CreateChannelOptions, +): Promise { + if (!name || name.trim() === '') { + throw new CliError('INVALID_NAME', 'Channel name cannot be empty.') + } + + const workspaceId = options.workspace + ? (await resolveWorkspaceRef(options.workspace)).id + : await getCurrentWorkspaceId() + + const isPublic = options.private ? false : true + + if (options.dryRun) { + printDryRun('create channel', { + Workspace: String(workspaceId), + Name: name, + Visibility: isPublic ? 'public' : 'private', + Description: options.description, + }) + return + } + + const channel = await createChannel({ + workspaceId, + name, + description: options.description, + public: isPublic, + }) + + if (options.json) { + console.log(formatJson(channel, 'channel', options.full)) + return + } + + const visibility = channel.public ? 'public' : 'private' + console.log(`Channel "${channel.name}" (id:${channel.id}, ${visibility}) created.`) +} diff --git a/src/commands/channel/delete.ts b/src/commands/channel/delete.ts new file mode 100644 index 0000000..ad5d97f --- /dev/null +++ b/src/commands/channel/delete.ts @@ -0,0 +1,62 @@ +import { TwistRequestError } from '@doist/twist-sdk' +import { deleteChannel, 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 { resolveChannelRef, resolveWorkspaceRef } from '../../lib/refs.js' + +type DeleteChannelOptions = MutationOptions & { yes?: boolean; workspace?: string } + +export async function deleteChannelCommand( + 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 = options.workspace + ? (await resolveWorkspaceRef(options.workspace)).id + : await getCurrentWorkspaceId() + 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 + } + + try { + await deleteChannel(channel.id) + } catch (error) { + if (error instanceof TwistRequestError && error.httpStatusCode === 403) { + throw new CliError( + 'FORBIDDEN', + `Twist 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 Twist 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 fa15046..26a9887 100644 --- a/src/commands/channel/index.ts +++ b/src/commands/channel/index.ts @@ -1,5 +1,8 @@ import { Command, Option } from 'commander' import { withCaseInsensitiveChoices } from '../../lib/completion.js' +import { archiveChannelCommand, unarchiveChannelCommand } from './archive.js' +import { createChannelCommand } from './create.js' +import { deleteChannelCommand } from './delete.js' import { listChannels } from './list.js' import { showChannelThreads } from './threads.js' @@ -86,4 +89,77 @@ Notes: and --unread are applied client-side; --archive-filter is applied server-side.`, ) .action(showChannelThreads) + + channel + .command('create ') + .description('Create a new channel') + .option('--workspace ', 'Workspace ID or name') + .option('--description ', 'Channel description') + .option('--private', 'Create a private channel (default is public)') + .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: + tw channel create "Engineering" + tw channel create "Leadership" --private + tw channel create "Marketing" --description "Marketing team channel" + tw channel create "Design" --private --json`, + ) + .action(createChannelCommand) + + 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: + tw channel delete 12345 --yes + tw channel delete "Engineering" --dry-run + tw channel delete id:12345 --yes --json`, + ) + .action(deleteChannelCommand) + + 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: + tw channel archive 12345 + tw channel archive "Engineering" --json + +Notes: + Archived channels can be listed with: tw channels --state archived`, + ) + .action(archiveChannelCommand) + + 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: + tw channel unarchive id:12345 + tw channel unarchive 12345 --json + +Notes: + Name-ref resolution only finds active channels — pass id: or numeric ID for archived channels.`, + ) + .action(unarchiveChannelCommand) } diff --git a/src/lib/api.ts b/src/lib/api.ts index 590c6bb..1fde2c1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,5 +1,7 @@ import { type BatchResponse, + type Channel, + type CreateChannelArgs, type Group, TwistApi, type User, @@ -52,6 +54,8 @@ const API_SPINNER_MESSAGES: Record { + const client = await getTwistClient() + return client.channels.createChannel(args) +} + +export async function deleteChannel(id: number): Promise { + const client = await getTwistClient() + await client.channels.deleteChannel(id) +} + +export async function archiveChannel(id: number): Promise { + const client = await getTwistClient() + await client.channels.archiveChannel(id) +} + +export async function unarchiveChannel(id: number): Promise { + const client = await getTwistClient() + await client.channels.unarchiveChannel(id) +} + export function clearUserCache(): void { sessionUserCache = null } @@ -439,4 +463,4 @@ export function buildOptionalBatchNameMap --since 2026-01-01 # Filter by last-updated date (ISO) tw channel threads --limit 20 # Max threads per page (default: 50) tw channel threads --limit 20 --cursor # Paginate tw channel threads --json # { results, nextCursor } with isUnread + url +tw channel create "Name" # Create a new (public) channel in the default/current workspace +tw channel create "Name" --workspace # Target a different workspace +tw channel create "Name" --private # Create a private channel +tw channel create "Name" --description "..." # Set channel description +tw channel create "Name" --json --full # Output created channel as JSON (all fields) +tw channel delete --yes # Delete a channel (requires --yes; usually admin-only on Twist) +tw channel delete --workspace --yes # Target a different workspace +tw channel delete --dry-run # Preview deletion +tw channel archive # Archive a channel (no-op if already archived) +tw channel unarchive id: # Unarchive a channel (pass id:/numeric ref for archived channels) tw groups # List workspace groups tw groups --search "frontend" # Filter groups by name (case-insensitive) tw groups --json # JSON output