Skip to content

Commit ea28735

Browse files
amixclaude
andcommitted
feat: bootstrap Comms CLI from twist-cli
Initial port of [@doist/twist-cli](https://github.com/Doist/twist-cli) to the Comms backend, pointed at the new [@doist/comms-sdk](https://github.com/Doist/comms-sdk-typescript). This commit lands the rebrand pass: - package.json: name -> @doist/comms-cli, binary -> `cm`, dep -> @doist/comms-sdk@0.1.0-alpha.1 - Bulk rename: TwistApi -> CommsApi, TwistAccount -> CommsAccount, TwistTokenStore -> CommsTokenStore, TwistRequestError -> CommsRequestError, parseTwistUrl -> parseCommsUrl, classifyTwistUrl -> classifyCommsUrl, getFullTwistURL -> getFullCommsURL, getTwistBaseUri -> getCommsBaseUri, TWIST_INCLUDE_PRIVATE_CHANNELS -> COMMS_INCLUDE_PRIVATE_CHANNELS, TWIST_API_TOKEN -> COMMS_API_TOKEN, TWIST_SCOPES -> COMMS_SCOPES. - OAuth URLs swung from twist.com -> comms.todoist.com. - src/lib/twist-account.ts renamed to comms-account.ts. - User-facing strings: "Twist CLI" / "Twist URL" / "Twist API" -> Comms. - user.name -> user.fullName per the Comms_API_changes.md rename. What still needs follow-up (see open type-check failures): - Channel / thread / comment / conversation / group / message IDs are now base58 UUIDv7 strings, not numbers. Every command that parses these from CLI args or formats them in output needs a per-command update. - Group endpoints now require workspace_id alongside the id. - WorkspaceUser shape changed (no profession/contact_info/feature_flags, no away_mode, fullName instead of name, theme is int, etc.). - search-helpers.ts SearchResult shape changed: threadId / channelId / conversationId / commentId are strings now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0 parents  commit ea28735

186 files changed

Lines changed: 34661 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
---
2+
name: add-command
3+
description: Guide for adding new CLI commands or subcommands to twist-cli. Use when implementing new SDK endpoints, adding subcommands to existing command groups, or extending CLI functionality.
4+
---
5+
6+
# Adding a New CLI Command or Subcommand
7+
8+
Follow this checklist when adding new commands. Each step references the exact file to modify.
9+
10+
## 1. Spinner Messages (`src/lib/api.ts`)
11+
12+
Add an entry to `API_SPINNER_MESSAGES` for each new SDK method.
13+
14+
Color convention:
15+
16+
- `blue` — read/fetch operations (e.g., loading threads, listing channels)
17+
- `green` — create/join operations (e.g., creating a thread, starting a conversation)
18+
- `yellow` — update/delete/archive mutations (e.g., muting, deleting, archiving)
19+
20+
## 2. Read-Only Permissions (`src/lib/permissions.ts`)
21+
22+
If the new command uses a **read-only** SDK method (e.g., `getXxx`, `listXxx`), add it to the `KNOWN_SAFE_API_METHODS` set. This set uses a default-deny approach: any method **not** listed is treated as mutating and will be blocked when the CLI is authenticated with a read-only OAuth token (`tw auth login --read-only`).
23+
24+
- **Read-only methods** (fetch/list/view): add to `KNOWN_SAFE_API_METHODS`
25+
- **Mutating methods** (create/update/delete/archive/mute): do NOT add — they are blocked by default, which is the correct behavior
26+
27+
## 3. Command Implementation (`src/commands/<entity>/`)
28+
29+
Commands with multiple subcommands use a folder-based structure:
30+
31+
```
32+
src/commands/<entity>/
33+
index.ts # registerXxxCommand — creates parent cmd, wires subcommands
34+
list.ts # async function listXxx(...) — one file per subcommand
35+
view.ts # async function viewXxx(...)
36+
create.ts # async function createXxx(...)
37+
helpers.ts # shared constants/utilities used by multiple subcommands (optional)
38+
```
39+
40+
- **index.ts**: Imports all subcommand handlers, creates the Commander tree, exports `registerXxxCommand`
41+
- **Subcommand files**: Export one async action handler + any option interfaces. Use `../../lib/` for lib imports. No Commander imports (only index.ts uses Commander).
42+
- **helpers.ts**: Only needed when multiple subcommands share a utility/constant.
43+
44+
Single-subcommand commands (e.g., `channel.ts`, `inbox.ts`) remain as flat files.
45+
46+
### Adding a subcommand to an existing command
47+
48+
1. Create a new file `src/commands/<entity>/<action>.ts` with the handler function
49+
2. Import and wire it in `src/commands/<entity>/index.ts`
50+
51+
### Flag conventions
52+
53+
| Command type | Flags |
54+
| ------------------------------ | ---------------------------------------- |
55+
| Read-only | `--json` (and `--ndjson` for lists) |
56+
| Mutating (returns entity) | `--json` (use `formatJson`), `--dry-run` |
57+
| Mutating (no return) | `--dry-run` |
58+
| Destructive + irreversible | `--yes`, `--dry-run` |
59+
| Reversible (archive/unarchive) | `--dry-run` (no `--yes`) |
60+
61+
### ID resolution
62+
63+
- `resolveThreadId(ref)` — resolve thread by numeric ID or Twist URL
64+
- `resolveChannelId(ref)` — resolve channel by numeric ID, URL, or fuzzy name
65+
- `resolveWorkspaceRef(ref)` — resolve workspace by ID or fuzzy name
66+
- `resolveConversationId(ref)` — resolve conversation by numeric ID or URL
67+
- `parseRef(ref)` — low-level parser: returns `{ type: 'id' | 'url' | 'name', ... }`
68+
69+
Add new resolver wrappers in `refs.ts` when needed.
70+
71+
### Subcommand registration pattern
72+
73+
```typescript
74+
const myCmd = parent
75+
.command('my-action [ref]')
76+
.description('Do something')
77+
.option('--json', 'Output as JSON')
78+
.option('--dry-run', 'Preview what would happen without executing')
79+
.action((ref, options) => {
80+
if (!ref) {
81+
myCmd.help()
82+
return
83+
}
84+
return myAction(ref, options)
85+
})
86+
```
87+
88+
The variable assignment (`const myCmd = ...`) is needed so the `.action()` callback can call `myCmd.help()` when the argument is missing.
89+
90+
### Implicit view subcommand
91+
92+
For entity commands with a `view` subcommand, mark it as the default so `tw thread 123` maps to `tw thread view 123`:
93+
94+
```typescript
95+
thread
96+
.command('view [thread-ref]', { isDefault: true })
97+
.description('Display a thread with its comments')
98+
.action((ref, options) => viewThread(ref, options))
99+
```
100+
101+
### Named flag aliases
102+
103+
Where commands accept positional `[workspace-ref]`, also accept a `--workspace` flag. Error if both are provided:
104+
105+
```typescript
106+
if (workspaceRef && options.workspace) {
107+
throw new Error('Cannot specify workspace both as argument and --workspace flag')
108+
}
109+
const ref = workspaceRef ?? options.workspace
110+
```
111+
112+
### Error handling
113+
114+
**Never use `process.exit(1)` in command handlers.** It terminates immediately without running `finally` blocks, leaving the spinner stuck. Use `process.exitCode = 1` followed by `return` instead.
115+
116+
### Lazy loading
117+
118+
New top-level commands must be registered in `src/index.ts` using the lazy loading pattern:
119+
120+
```typescript
121+
const loadMyCommand = async () => (await import('./commands/my-entity/index.js')).registerMyCommand
122+
123+
const commands: Record<string, [string, () => Promise<(p: Command) => void>]> = {
124+
// ... existing commands
125+
'my-entity': ['My entity operations', loadMyCommand],
126+
}
127+
```
128+
129+
## 4. Accessibility (`src/lib/output.ts`)
130+
131+
The CLI supports accessible mode via `isAccessible()` (checks `TW_ACCESSIBLE=1` or `--accessible` flag). When adding output that uses color or visual elements, consider whether information is conveyed **only** by color or decoration.
132+
133+
### When to add accessible alternatives
134+
135+
- **Color-coded status/severity**: If color conveys meaning (e.g., green=good, red=bad), add a text prefix or label in accessible mode so the meaning is available without color.
136+
- **ASCII art / visual bars**: Omit entirely in accessible mode — screen readers read each character individually. Show only the numeric value instead.
137+
- **Decorative symbols**: Stars, checkmarks, or icons used alongside color should have text equivalents.
138+
139+
### When you don't need to do anything
140+
141+
- **Text that is already descriptive**: Status names like `archived`, `muted` are self-explanatory.
142+
- **Plain numbers and dates**: Already accessible.
143+
- **Dim/styled labels**: `chalk.dim()` for secondary info is fine — screen readers ignore styling.
144+
145+
### Pattern
146+
147+
```typescript
148+
import { isAccessible } from '../lib/output.js'
149+
150+
const a11y = isAccessible()
151+
const prefix = a11y ? '[!] ' : ''
152+
console.log(chalk.yellow(`${prefix}Warning: thread is archived`))
153+
```
154+
155+
## 5. Tests (`src/__tests__/<entity>.test.ts`)
156+
157+
Tests mock the API layer directly using `vi.mock` and `vi.hoisted`. Follow the existing pattern in test files like `thread.test.ts` or `conversation.test.ts`.
158+
159+
### Test setup pattern
160+
161+
```typescript
162+
const apiMocks = vi.hoisted(() => ({
163+
getTwistClient: vi.fn(),
164+
}))
165+
166+
vi.mock('../lib/api.js', async (importOriginal) => ({
167+
...(await importOriginal<typeof import('../lib/api.js')>()),
168+
getTwistClient: apiMocks.getTwistClient,
169+
}))
170+
171+
vi.mock('../lib/markdown.js', () => ({
172+
renderMarkdown: vi.fn((text: string) => text),
173+
}))
174+
175+
vi.mock('chalk')
176+
```
177+
178+
### Creating mock clients
179+
180+
Build a mock client object that matches the SDK structure:
181+
182+
```typescript
183+
function createClient({ thread, comments, channel } = {}) {
184+
return {
185+
threads: {
186+
getThread: vi.fn().mockResolvedValue(thread),
187+
createThread: vi.fn().mockResolvedValue(thread),
188+
},
189+
channels: {
190+
getChannel: vi.fn().mockResolvedValue(channel),
191+
},
192+
// ... add mock methods as needed
193+
}
194+
}
195+
```
196+
197+
### Always test
198+
199+
- Happy path (correct output, correct API call)
200+
- `--dry-run` for mutating commands (API method should NOT be called, preview text shown)
201+
- `--json` output where applicable
202+
- Error cases (missing required refs, invalid input)
203+
204+
## 6. Skill Content (`src/lib/skills/content.ts`)
205+
206+
Update `SKILL_CONTENT` with examples for the new command. Update relevant sections:
207+
208+
- Command examples in the entity's `### Section` block
209+
- Quick Reference if adding a top-level command
210+
- Mutating `--json` list if the command returns an entity
211+
- `--dry-run` list if applicable
212+
213+
## 7. Sync Skill File
214+
215+
After all code changes are complete:
216+
217+
```bash
218+
npm run build && npm run sync:skill
219+
```
220+
221+
This builds the project and regenerates `skills/twist-cli/SKILL.md` from the compiled skill content. The regenerated file must be committed. CI will fail (`npm run check:skill-sync`) if it is out of sync.
222+
223+
## 8. Verify
224+
225+
```bash
226+
npm run type-check
227+
npm test
228+
npm run lint:check
229+
```
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
name: add-command
3+
description: Guide for adding new CLI commands or subcommands to twist-cli. Use when implementing new SDK endpoints, adding subcommands to existing command groups, or extending CLI functionality.
4+
---
5+
6+
See [/.agents/skills/add-command/SKILL.md](../../../.agents/skills/add-command/SKILL.md) for the full guide.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: Semantic Pull Request
2+
3+
on:
4+
pull_request_target:
5+
types:
6+
- edited
7+
- opened
8+
- synchronize
9+
10+
jobs:
11+
validate-title:
12+
name: Validate Title
13+
runs-on: ubuntu-latest
14+
timeout-minutes: 5
15+
16+
steps:
17+
- name: Validate pull request title
18+
uses: amannn/action-semantic-pull-request@01d5fd8a8ebb9aafe902c40c53f0f4744f7381eb
19+
env:
20+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: Skill Sync Check
2+
3+
on:
4+
pull_request:
5+
6+
permissions:
7+
contents: read
8+
9+
jobs:
10+
skill-sync:
11+
name: SKILL.md Sync
12+
runs-on: ubuntu-latest
13+
timeout-minutes: 10
14+
steps:
15+
- name: Checkout
16+
uses: actions/checkout@v5
17+
18+
- name: Setup Node.js
19+
uses: actions/setup-node@v6
20+
with:
21+
node-version-file: '.nvmrc'
22+
cache: 'npm'
23+
24+
- name: Install dependencies
25+
run: npm ci
26+
27+
- name: Build
28+
run: npm run build
29+
30+
- name: Check SKILL.md is in sync
31+
run: npm run check:skill-sync
32+
33+
- name: Validate SKILL.md against agentskills.io spec
34+
run: |
35+
if ! gh skill --help >/dev/null 2>&1; then
36+
echo "::notice::gh skill subcommand not available on this runner (requires gh >= 2.90.0); skipping validation"
37+
exit 0
38+
fi
39+
gh skill publish --dry-run
40+
env:
41+
GH_TOKEN: ${{ github.token }}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: Add new issues to project
2+
3+
on:
4+
issues:
5+
types: [opened]
6+
7+
permissions:
8+
contents: read
9+
issues: read
10+
11+
jobs:
12+
add-to-project:
13+
name: Add issue to project
14+
runs-on: ubicloud
15+
timeout-minutes: 5
16+
17+
steps:
18+
- name: Add issue to project
19+
uses: actions/add-to-project@27022a19346d0433f2b9859dd2b69d38f4277808
20+
with:
21+
project-url: https://github.com/orgs/Doist/projects/83
22+
github-token: ${{ secrets.HERO_BOARD_PAT }}

.github/workflows/lint.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Lint
2+
3+
on:
4+
pull_request:
5+
6+
permissions:
7+
contents: read
8+
9+
jobs:
10+
lint:
11+
runs-on: ubuntu-latest
12+
timeout-minutes: 10
13+
14+
steps:
15+
- name: Checkout
16+
uses: actions/checkout@v5
17+
18+
- name: Setup Node.js
19+
uses: actions/setup-node@v6
20+
with:
21+
node-version-file: '.nvmrc'
22+
cache: 'npm'
23+
24+
- name: Install dependencies
25+
run: npm ci
26+
27+
- name: Type check
28+
run: npm run type-check
29+
30+
- name: Lint & format check
31+
run: npm run lint:check

0 commit comments

Comments
 (0)