Skip to content

Commit 46236b3

Browse files
authored
feat: lenient CLI ergonomics (raw IDs, implicit view, flag aliases) (#60)
1 parent 941bd82 commit 46236b3

21 files changed

+544
-159
lines changed

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ src/
4747

4848
## Key Patterns
4949

50-
- **ID references**: All explicit IDs use `id:` prefix. Use `requireIdRef()` for ID-only args, `isIdRef()`/`extractId()` for mixed refs (fuzzy name + explicit ID)
50+
- **Lenient ID handling**: Raw IDs (alphanumeric or numeric) are accepted everywhere without `id:` prefix. `lenientIdRef()` accepts `id:xxx` or raw ID-like strings, rejects plain text. `resolveRef()` auto-retries raw IDs as direct lookups before giving up. Use `isIdRef()`/`extractId()` for mixed refs (fuzzy name + explicit ID)
51+
- **Implicit view subcommand**: `td project <ref>` defaults to `td project view <ref>` via Commander's `{ isDefault: true }`. Same for task, workspace, comment, notification. Edge case: if a project/task name matches a subcommand name (e.g., "list"), the subcommand wins — user must use `td project view list`
52+
- **Named flag aliases**: Where commands accept positional args for context (project, task, workspace), named flags are also accepted (`--project`, `--task`, `--workspace`). Error if both positional and flag are provided
5153
- **API responses**: Client returns `{ results: T[], nextCursor? }` - always destructure
5254
- **Priority mapping**: API uses 4=p1 (highest), 1=p4 (lowest)
5355
- **Command registration**: Each command exports `registerXxxCommand(program: Command)` function
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# 001: Lenient CLI Ergonomics
2+
3+
## Status
4+
5+
Accepted
6+
7+
## Context
8+
9+
The CLI required strict `id:` prefix for all ID arguments (e.g., `id:6QwcgwGW2H73WrVJ`). This created friction for both human users (copy-pasting IDs from other tools) and AI agents (extra formatting step). Additionally, viewing a resource required spelling out the `view` subcommand, and some positional arguments had no named flag alternative, making them harder to use in scripts.
10+
11+
## Decision
12+
13+
Three ergonomic improvements, all backward compatible:
14+
15+
**1. Lenient ID handling.** Accept raw IDs without `id:` prefix everywhere. `lenientIdRef()` replaces `requireIdRef()`. `resolveRef()` auto-retries raw-ID-looking strings as direct lookups before failing. Detection: `looksLikeRawId()` matches numeric strings or alphanumeric mix (no spaces, not pure alpha).
16+
17+
**2. Implicit view subcommand.** `td project <ref>` defaults to `td project view <ref>` using Commander's `{ isDefault: true }`. Applied to project, task, workspace, comment, notification commands.
18+
19+
**3. Named flag aliases.** Positional context arguments also accept named flags: `--project` (section list), `--task` (reminder list/add), `--workspace` (workspace projects/users). Error if both positional and flag are provided.
20+
21+
## Consequences
22+
23+
- `id:` prefix still works everywhere (backward compatible)
24+
- Subcommand names take priority over implicit view: a project named "list" requires `td project view list`
25+
- Skill content documents only canonical forms (explicit subcommands, `id:xxx`) to avoid encouraging collision-prone patterns
26+
- AGENTS.md documents the full picture including implicit behavior

src/__tests__/comment.test.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -199,11 +199,11 @@ describe('comment delete', () => {
199199
mockGetApi.mockResolvedValue(mockApi)
200200
})
201201

202-
it('requires id: prefix', async () => {
202+
it('rejects plain text references', async () => {
203203
const program = createProgram()
204204

205205
await expect(
206-
program.parseAsync(['node', 'td', 'comment', 'delete', 'comment-1', '--yes']),
206+
program.parseAsync(['node', 'td', 'comment', 'delete', 'my-comment', '--yes']),
207207
).rejects.toThrow('INVALID_REF')
208208
})
209209

@@ -251,7 +251,7 @@ describe('comment update', () => {
251251
mockGetApi.mockResolvedValue(mockApi)
252252
})
253253

254-
it('requires id: prefix', async () => {
254+
it('rejects plain text references', async () => {
255255
const program = createProgram()
256256

257257
await expect(
@@ -260,7 +260,7 @@ describe('comment update', () => {
260260
'td',
261261
'comment',
262262
'update',
263-
'comment-1',
263+
'my-comment',
264264
'--content',
265265
'New text',
266266
]),
@@ -529,14 +529,31 @@ describe('comment view', () => {
529529
mockGetApi.mockResolvedValue(mockApi)
530530
})
531531

532-
it('requires id: prefix', async () => {
532+
it('rejects plain text references', async () => {
533533
const program = createProgram()
534534

535535
await expect(
536-
program.parseAsync(['node', 'td', 'comment', 'view', 'comment-1']),
536+
program.parseAsync(['node', 'td', 'comment', 'view', 'my-comment']),
537537
).rejects.toThrow('INVALID_REF')
538538
})
539539

540+
it('implicit view: td comment <ref> behaves like td comment view <ref>', async () => {
541+
const program = createProgram()
542+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
543+
544+
mockApi.getComment.mockResolvedValue({
545+
id: 'comment-123',
546+
content: 'Test content',
547+
postedAt: '2026-01-08T10:00:00Z',
548+
fileAttachment: null,
549+
})
550+
551+
await program.parseAsync(['node', 'td', 'comment', 'id:comment-123'])
552+
553+
expect(mockApi.getComment).toHaveBeenCalledWith('comment-123')
554+
consoleSpy.mockRestore()
555+
})
556+
540557
it('shows full comment content', async () => {
541558
const program = createProgram()
542559
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

src/__tests__/label.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,12 +240,16 @@ describe('label delete', () => {
240240
const program = createProgram()
241241
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
242242

243-
mockApi.getLabels.mockResolvedValue({ results: [], nextCursor: null })
243+
mockApi.getLabels.mockResolvedValue({
244+
results: [{ id: 'label-123', name: 'urgent' }],
245+
nextCursor: null,
246+
})
244247
mockApi.deleteLabel.mockResolvedValue(undefined)
245248

246249
await program.parseAsync(['node', 'td', 'label', 'delete', 'id:label-123', '--yes'])
247250

248251
expect(mockApi.deleteLabel).toHaveBeenCalledWith('label-123')
252+
expect(consoleSpy).toHaveBeenCalledWith('Deleted: @urgent')
249253
consoleSpy.mockRestore()
250254
})
251255

src/__tests__/notification.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,18 @@ describe('notification view', () => {
278278
vi.clearAllMocks()
279279
})
280280

281+
it('implicit view: td notification <ref> behaves like td notification view <ref>', async () => {
282+
const program = createProgram()
283+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
284+
285+
mockFetchNotifications.mockResolvedValue([createShareInvite()])
286+
287+
await program.parseAsync(['node', 'td', 'notification', 'id:notif-1'])
288+
289+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('share_invitation_sent'))
290+
consoleSpy.mockRestore()
291+
})
292+
281293
it('shows notification details', async () => {
282294
const program = createProgram()
283295
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

src/__tests__/project.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,23 @@ describe('project view', () => {
186186
expect(mockApi.getProject).toHaveBeenCalledWith('proj-1')
187187
})
188188

189+
it('implicit view: td project <ref> behaves like td project view <ref>', async () => {
190+
const program = createProgram()
191+
192+
mockApi.getProject.mockResolvedValue({
193+
id: 'proj-1',
194+
name: 'Work',
195+
color: 'blue',
196+
isFavorite: false,
197+
url: 'https://...',
198+
})
199+
mockApi.getTasks.mockResolvedValue({ results: [], nextCursor: null })
200+
201+
await program.parseAsync(['node', 'td', 'project', 'id:proj-1'])
202+
203+
expect(mockApi.getProject).toHaveBeenCalledWith('proj-1')
204+
})
205+
189206
it('shows project details', async () => {
190207
const program = createProgram()
191208

src/__tests__/refs.test.ts

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
22
import {
33
extractId,
44
isIdRef,
5+
lenientIdRef,
56
looksLikeRawId,
6-
requireIdRef,
77
resolveParentTaskId,
88
resolveProjectId,
99
resolveProjectRef,
@@ -47,19 +47,28 @@ describe('extractId', () => {
4747
})
4848
})
4949

50-
describe('requireIdRef', () => {
50+
describe('lenientIdRef', () => {
5151
it('returns ID when valid id: prefix', () => {
52-
expect(requireIdRef('id:123', 'task')).toBe('123')
53-
expect(requireIdRef('id:sec-1', 'section')).toBe('sec-1')
52+
expect(lenientIdRef('id:123', 'task')).toBe('123')
53+
expect(lenientIdRef('id:sec-1', 'section')).toBe('sec-1')
5454
})
5555

56-
it('throws with formatted error for invalid ref', () => {
57-
expect(() => requireIdRef('some-name', 'comment')).toThrow('INVALID_REF')
58-
expect(() => requireIdRef('some-name', 'comment')).toThrow('Invalid comment reference')
56+
it('accepts raw alphanumeric IDs', () => {
57+
expect(lenientIdRef('6fmg66Fr27R59RPg', 'task')).toBe('6fmg66Fr27R59RPg')
58+
expect(lenientIdRef('abc123', 'section')).toBe('abc123')
5959
})
6060

61-
it('includes hint in error message', () => {
62-
expect(() => requireIdRef('abc', 'section')).toThrow('id:abc')
61+
it('accepts raw numeric IDs', () => {
62+
expect(lenientIdRef('12345678', 'task')).toBe('12345678')
63+
})
64+
65+
it('throws for plain text names', () => {
66+
expect(() => lenientIdRef('some-name', 'comment')).toThrow('INVALID_REF')
67+
expect(() => lenientIdRef('Shopping', 'project')).toThrow('INVALID_REF')
68+
})
69+
70+
it('throws for strings with spaces', () => {
71+
expect(() => lenientIdRef('Buy milk', 'task')).toThrow('INVALID_REF')
6372
})
6473
})
6574

@@ -181,6 +190,17 @@ describe('resolveTaskRef', () => {
181190
await expect(resolveTaskRef(api, 'nonexistent')).rejects.toThrow('not found')
182191
expect(api.getTask).not.toHaveBeenCalled()
183192
})
193+
194+
it('rethrows non-404 API errors from raw ID fallback', async () => {
195+
const { TodoistRequestError } = await import('@doist/todoist-api-typescript')
196+
const networkError = new TodoistRequestError('Service Unavailable', 503)
197+
const api = createMockApi({
198+
getTask: vi.fn().mockRejectedValue(networkError),
199+
getTasksByFilter: vi.fn().mockResolvedValue({ results: [], nextCursor: null }),
200+
})
201+
202+
await expect(resolveTaskRef(api, '6fmg66Fr27R59RPg')).rejects.toThrow('Service Unavailable')
203+
})
184204
})
185205

186206
describe('resolveProjectRef', () => {
@@ -246,6 +266,37 @@ describe('resolveProjectRef', () => {
246266

247267
await expect(resolveProjectRef(api, 'nonexistent')).rejects.toThrow('not found')
248268
})
269+
270+
it('auto-retries id-like refs as direct ID lookup', async () => {
271+
const api = createMockApi({
272+
getProject: vi.fn().mockResolvedValue(projects[0]),
273+
getProjects: vi.fn().mockResolvedValue({ results: [] }),
274+
})
275+
276+
const result = await resolveProjectRef(api, '6fmg66Fr27R59RPg')
277+
expect(result.id).toBe('proj-1')
278+
expect(api.getProject).toHaveBeenCalledWith('6fmg66Fr27R59RPg')
279+
})
280+
281+
it('auto-retries numeric refs as direct ID lookup', async () => {
282+
const api = createMockApi({
283+
getProject: vi.fn().mockResolvedValue(projects[0]),
284+
getProjects: vi.fn().mockResolvedValue({ results: [] }),
285+
})
286+
287+
const result = await resolveProjectRef(api, '12345678')
288+
expect(result.id).toBe('proj-1')
289+
expect(api.getProject).toHaveBeenCalledWith('12345678')
290+
})
291+
292+
it('does not auto-retry plain text refs', async () => {
293+
const api = createMockApi({
294+
getProjects: vi.fn().mockResolvedValue({ results: projects }),
295+
})
296+
297+
await expect(resolveProjectRef(api, 'nonexistent')).rejects.toThrow('not found')
298+
expect(api.getProject).not.toHaveBeenCalled()
299+
})
249300
})
250301

251302
describe('resolveProjectId', () => {
@@ -333,6 +384,16 @@ describe('resolveSectionId', () => {
333384
'not found in project',
334385
)
335386
})
387+
388+
it('resolves raw ID-like string as section ID', async () => {
389+
const sectionsWithNumericId = [...sections, { id: '99887766', name: 'Done' }]
390+
const api = createMockApi({
391+
getSections: vi.fn().mockResolvedValue({ results: sectionsWithNumericId }),
392+
})
393+
394+
const result = await resolveSectionId(api, '99887766', 'proj-1')
395+
expect(result).toBe('99887766')
396+
})
336397
})
337398

338399
describe('resolveParentTaskId', () => {
@@ -421,4 +482,13 @@ describe('resolveParentTaskId', () => {
421482
'not found in project',
422483
)
423484
})
485+
486+
it('accepts raw ID-like string without id: prefix', async () => {
487+
const api = createMockApi({
488+
getTasks: vi.fn().mockResolvedValue({ results: [] }),
489+
})
490+
491+
const result = await resolveParentTaskId(api, '6fmg66Fr27R59RPg', 'proj-1')
492+
expect(result).toBe('6fmg66Fr27R59RPg')
493+
})
424494
})

0 commit comments

Comments
 (0)