feat(channel): add create, delete, archive, unarchive subcommands - #246
Conversation
doistbot
left a comment
There was a problem hiding this comment.
This PR successfully bridges a crucial functionality gap by introducing subcommands for creating, deleting, archiving, and unarchiving channels. These additions will significantly improve the CLI's utility for workspace administration and end-to-end testing. There are a few opportunities for refinement, specifically around extracting shared logic for delete confirmations and archive toggling, broadening the channel resolution bounds to include unjoined or archived channels, and addressing minor gaps in test coverage, error handling, and agent documentation.
| options: DeleteChannelOptions, | ||
| ): Promise<void> { | ||
| const workspaceId = await getCurrentWorkspaceId() | ||
| const channel = await resolveChannelRef(ref, workspaceId) |
There was a problem hiding this comment.
[P2] resolveChannelRef() only does name matching against getChannels({ workspaceId }), which this codebase treats as the joined-channel set. That means tw channel delete "Foo" can't resolve a public channel the admin hasn't joined, and archived channels can't be deleted by name at all. The same lookup pattern is used in archive/unarchive. For these management commands, use a broader lookup (public + archived) or tighten the accepted-ref docs/examples.
There was a problem hiding this comment.
Agreed, but this is the existing behaviour of resolveChannelRef from before this PR — it bites every command that uses it, not just these. I've drafted a focused follow-up that widens the name-resolution to also cover public and archived channels (and removes the temporary pass id: for archived channels signpost from the unarchive help). Keeping this PR scoped to the create/delete/archive surface so the fix can land as one self-contained change. Leaving this thread open until that PR lands.
Wraps the SDK's createChannel, deleteChannel, archiveChannel and unarchiveChannel endpoints so channel lifecycle no longer requires the Twist web UI. Includes a clear FORBIDDEN error when the API rejects deletion (typically restricted to workspace admins), plus unit tests and skill-content updates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add FORBIDDEN to the ErrorCode union. - Use SDK CreateChannelArgs type for the createChannel wrapper. - Use TwistRequestError instanceof for the 403 check in delete. - Reorder delete guards: short-circuit --json without --yes before hitting workspace/channel lookups, and gate --dry-run before --yes. - Add --workspace flag to delete/archive/unarchive for parity with other channel subcommands. - Extract setArchiveState helper to share archive/unarchive flow, and skip the API call when the channel is already in the target state. - Update skill content to mention --workspace and the no-op archive behaviour, regenerate SKILL.md. - Tests: fix unrestored console.log spies in create tests, add --json --full create test, add --yes --dry-run delete test, add already-archived no-op test, and rework the 403 test to throw a real TwistRequestError. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
95817ad to
e8d108d
Compare
## [2.44.0](v2.43.1...v2.44.0) (2026-05-27) ### Features * **channel:** add create, delete, archive, unarchive subcommands ([#246](#246)) ([62dbda9](62dbda9))
|
🎉 This PR is included in version 2.44.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
## Summary
- `resolveChannelRef`'s name branch only called
`client.channels.getChannels({ workspaceId })`, which is
**membership-scoped** and excludes public channels the current user
hasn't joined.
- Effect: `tw channel <action> "Public Channel I Haven't Joined"` fails
with `CHANNEL_NOT_FOUND`, while `id:N` and URL refs work because
`getChannel(id)` isn't membership-filtered.
- Fix merges `getChannels` with `workspaces.getPublicChannels` and
dedupes by id. Same shape as `src/commands/channel/list.ts:177-180`
already uses for the `--scope public` path.
## Why this exists
The bug was first suspected to be "active vs archived" — that turned out
to be wrong: `getChannels({ workspaceId })` already returns both states
(Twist's API is tri-state — see `list.ts:163-169`). The real axis is
**joined vs unjoined**, which I confirmed live and which also holds for
non-admin users (`getPublicChannels` is not admin-gated). Scale check in
Doist workspace 1585: 998 public channels, 829 of which I haven't joined
— so the bug bites in production, not just edge cases.
## Test plan
- [x] `npm run lint` — clean
- [x] `npm run type-check` — no errors in `refs.ts` / `refs.test.ts`
(pre-existing `@doist/cli-core` export errors elsewhere on `main` are
unrelated)
- [x] `npx vitest run src/lib/refs.test.ts` — **71/71 pass** (4 new
tests: unjoined-public exact match, unjoined-public substring match,
dedup of joined-and-public, ambiguity across joined+public)
- [x] **Live smoke** in workspace 1767 (Testing Environment), against a
fresh test channel — no existing channels touched:
1. `tw channel create tw-cli-resolver-smoke-<ts> --workspace 1767` → id
837258
2. Left the channel via SDK `removeUser`
3. `tw channel archive "tw-cli-resolver-smoke-<ts>" --workspace 1767` →
`{ id: 837258, archived: true }` ✅ (previously: `CHANNEL_NOT_FOUND`)
4. `tw channel delete id:837258 --workspace 1767 --yes` → cleaned up
Smoke required #246's `channel create/archive/delete` commands to
exercise an action on the resolved channel; they were checked out
temporarily and reverted after.
## Out of scope
- Help-text cleanup on `tw channel unarchive` in #246's
`src/commands/channel/index.ts` — separate trivial follow-up once both
this and #246 land.
- `id:` and URL branches — already work for archived and unjoined
channels via `getChannel(id)`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
tw channel create,tw channel delete,tw channel archive,tw channel unarchive— wraps the four SDK endpoints that were already exposed but not surfaced in the CLI. Discovered as a gap while landing channel-membership commands in #244; end-to-end channel testing previously required the web UI.createsupports--workspace,--description,--private(default public),--dry-run,--json,--full.deleterequires--yesto mutate, with structuredMISSING_YES_FLAGCliError in--jsonmode, and a clearFORBIDDENCliError when the Twist API returns 403 (channel deletion is typically restricted to workspace admins).archive/unarchivemirror the same shape with--dry-runand--json.SKILL.mdregenerated.Test plan
npm run type-checknpm run lintnpm test— 667/667 passing (17 new tests across the four commands)--state archived→ unarchive → re-archiveFORBIDDENerror message ondelete --yes(Twist returns 403 for non-admins, as expected)delete— blocked by workspace admin policy; unit-tested only🤖 Generated with Claude Code