Skip to content

Commit 9495a62

Browse files
feat: add --json support to mutating commands (add/create/update)
Adds --json flag to all add/create/update commands so scripts and agent workflows can get machine-readable output from write operations without a follow-up read call. Covers: task, project, comment, label, filter, section, and reminder commands. Also documents the convention in AGENTS.md so new commands follow the same pattern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 149d560 commit 9495a62

File tree

16 files changed

+714
-4
lines changed

16 files changed

+714
-4
lines changed

AGENTS.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,28 @@ The file `src/lib/skills/content.ts` exports `SKILL_CONTENT` — a comprehensive
9898
- Keeping examples accurate and consistent with actual CLI behavior
9999

100100
After updating `SKILL_CONTENT`, run `td skill update claude-code` (and any other installed agents) to propagate the changes to installed skill files.
101+
102+
## JSON Output for Mutating Commands
103+
104+
All add/create/update commands support `--json` to output the created or updated entity as machine-readable JSON instead of the default human-readable confirmation message. This applies to:
105+
106+
- `task add`, `task update`
107+
- `project create`, `project update`
108+
- `comment add`, `comment update`
109+
- `label create`, `label update`
110+
- `filter create`
111+
- `section create`, `section update`
112+
- `reminder add`
113+
114+
**When adding new add/create/update commands**, always include a `--json` flag that outputs the resulting entity using `formatJson(result, entityType)` from `src/lib/output.ts`. The pattern is:
115+
116+
```typescript
117+
const result = await api.addXxx(args)
118+
if (options.json) {
119+
console.log(formatJson(result, 'entityType'))
120+
return
121+
}
122+
// normal human-readable output
123+
```
124+
125+
Delete, complete, uncomplete, archive, and unarchive commands do not support `--json` as they return no meaningful entity data.

src/__tests__/comment.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,118 @@ describe('comment add with --stdin', () => {
738738
})
739739
})
740740

741+
describe('comment add --json', () => {
742+
let mockApi: MockApi
743+
744+
beforeEach(() => {
745+
vi.clearAllMocks()
746+
mockApi = createMockApi()
747+
mockGetApi.mockResolvedValue(mockApi)
748+
})
749+
750+
it('outputs created comment as JSON', async () => {
751+
const program = createProgram()
752+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
753+
754+
mockApi.getTask.mockResolvedValue({ id: 'task-1', content: 'Buy milk' })
755+
mockApi.addComment.mockResolvedValue({
756+
id: 'comment-new',
757+
content: 'Test note',
758+
postedAt: '2026-01-08T10:00:00Z',
759+
taskId: 'task-1',
760+
projectId: null,
761+
fileAttachment: null,
762+
})
763+
764+
await program.parseAsync([
765+
'node',
766+
'td',
767+
'comment',
768+
'add',
769+
'id:task-1',
770+
'--content',
771+
'Test note',
772+
'--json',
773+
])
774+
775+
const output = consoleSpy.mock.calls[0][0]
776+
const parsed = JSON.parse(output)
777+
expect(parsed.id).toBe('comment-new')
778+
expect(parsed.content).toBe('Test note')
779+
consoleSpy.mockRestore()
780+
})
781+
782+
it('does not print plain-text confirmation with --json', async () => {
783+
const program = createProgram()
784+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
785+
786+
mockApi.getTask.mockResolvedValue({ id: 'task-1', content: 'Buy milk' })
787+
mockApi.addComment.mockResolvedValue({
788+
id: 'comment-new',
789+
content: 'Test note',
790+
postedAt: '2026-01-08T10:00:00Z',
791+
taskId: 'task-1',
792+
})
793+
794+
await program.parseAsync([
795+
'node',
796+
'td',
797+
'comment',
798+
'add',
799+
'id:task-1',
800+
'--content',
801+
'Test note',
802+
'--json',
803+
])
804+
805+
expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Added comment to'))
806+
consoleSpy.mockRestore()
807+
})
808+
})
809+
810+
describe('comment update --json', () => {
811+
let mockApi: MockApi
812+
813+
beforeEach(() => {
814+
vi.clearAllMocks()
815+
mockApi = createMockApi()
816+
mockGetApi.mockResolvedValue(mockApi)
817+
})
818+
819+
it('outputs updated comment as JSON', async () => {
820+
const program = createProgram()
821+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
822+
823+
mockApi.getComment.mockResolvedValue({
824+
id: 'comment-123',
825+
content: 'Old content',
826+
})
827+
mockApi.updateComment.mockResolvedValue({
828+
id: 'comment-123',
829+
content: 'New content',
830+
postedAt: '2026-01-08T10:00:00Z',
831+
taskId: 'task-1',
832+
})
833+
834+
await program.parseAsync([
835+
'node',
836+
'td',
837+
'comment',
838+
'update',
839+
'id:comment-123',
840+
'--content',
841+
'New content',
842+
'--json',
843+
])
844+
845+
const output = consoleSpy.mock.calls[0][0]
846+
const parsed = JSON.parse(output)
847+
expect(parsed.id).toBe('comment-123')
848+
expect(parsed.content).toBe('New content')
849+
consoleSpy.mockRestore()
850+
})
851+
})
852+
741853
describe('project comment add', () => {
742854
let mockApi: MockApi
743855

src/__tests__/filter.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,48 @@ describe('filter list', () => {
9999
})
100100
})
101101

102+
describe('filter create --json', () => {
103+
beforeEach(() => {
104+
vi.clearAllMocks()
105+
})
106+
107+
it('outputs created filter as JSON', async () => {
108+
const program = createProgram()
109+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
110+
111+
mockAddFilter.mockResolvedValue({
112+
id: 'filter-new',
113+
name: 'My Filter',
114+
query: 'today',
115+
color: 'charcoal',
116+
isFavorite: false,
117+
isDeleted: false,
118+
isFrozen: false,
119+
itemOrder: 0,
120+
})
121+
122+
await program.parseAsync([
123+
'node',
124+
'td',
125+
'filter',
126+
'create',
127+
'--name',
128+
'My Filter',
129+
'--query',
130+
'today',
131+
'--json',
132+
])
133+
134+
const output = consoleSpy.mock.calls[0][0]
135+
const parsed = JSON.parse(output)
136+
expect(parsed.id).toBe('filter-new')
137+
expect(parsed.name).toBe('My Filter')
138+
expect(parsed.query).toBe('today')
139+
expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Created:'))
140+
consoleSpy.mockRestore()
141+
})
142+
})
143+
102144
describe('filter create', () => {
103145
beforeEach(() => {
104146
vi.clearAllMocks()

src/__tests__/label.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,81 @@ describe('label list', () => {
9797
})
9898
})
9999

100+
describe('label create --json', () => {
101+
let mockApi: MockApi
102+
103+
beforeEach(() => {
104+
vi.clearAllMocks()
105+
mockApi = createMockApi()
106+
mockGetApi.mockResolvedValue(mockApi)
107+
})
108+
109+
it('outputs created label as JSON', async () => {
110+
const program = createProgram()
111+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
112+
113+
mockApi.addLabel.mockResolvedValue({
114+
id: 'label-new',
115+
name: 'work',
116+
color: 'charcoal',
117+
isFavorite: false,
118+
})
119+
120+
await program.parseAsync(['node', 'td', 'label', 'create', '--name', 'work', '--json'])
121+
122+
const output = consoleSpy.mock.calls[0][0]
123+
const parsed = JSON.parse(output)
124+
expect(parsed.id).toBe('label-new')
125+
expect(parsed.name).toBe('work')
126+
expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Created:'))
127+
consoleSpy.mockRestore()
128+
})
129+
})
130+
131+
describe('label update --json', () => {
132+
let mockApi: MockApi
133+
134+
beforeEach(() => {
135+
vi.clearAllMocks()
136+
mockApi = createMockApi()
137+
mockGetApi.mockResolvedValue(mockApi)
138+
})
139+
140+
it('outputs updated label as JSON', async () => {
141+
const program = createProgram()
142+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
143+
144+
mockApi.getLabels.mockResolvedValue({
145+
results: [{ id: 'label-1', name: 'work', color: 'charcoal', isFavorite: false }],
146+
nextCursor: null,
147+
})
148+
mockApi.updateLabel.mockResolvedValue({
149+
id: 'label-1',
150+
name: 'renamed',
151+
color: 'charcoal',
152+
isFavorite: false,
153+
})
154+
155+
await program.parseAsync([
156+
'node',
157+
'td',
158+
'label',
159+
'update',
160+
'work',
161+
'--name',
162+
'renamed',
163+
'--json',
164+
])
165+
166+
const output = consoleSpy.mock.calls[0][0]
167+
const parsed = JSON.parse(output)
168+
expect(parsed.id).toBe('label-1')
169+
expect(parsed.name).toBe('renamed')
170+
expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Updated:'))
171+
consoleSpy.mockRestore()
172+
})
173+
})
174+
100175
describe('label create', () => {
101176
let mockApi: MockApi
102177

src/__tests__/project.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,93 @@ describe('project update', () => {
896896
})
897897
})
898898

899+
describe('project create --json', () => {
900+
let mockApi: MockApi
901+
let consoleSpy: ReturnType<typeof vi.spyOn>
902+
903+
beforeEach(() => {
904+
vi.clearAllMocks()
905+
mockApi = createMockApi()
906+
mockGetApi.mockResolvedValue(mockApi)
907+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
908+
})
909+
910+
afterEach(() => {
911+
consoleSpy.mockRestore()
912+
})
913+
914+
it('outputs created project as JSON', async () => {
915+
const program = createProgram()
916+
917+
mockApi.addProject.mockResolvedValue({
918+
id: 'proj-new',
919+
name: 'New Project',
920+
color: 'charcoal',
921+
isFavorite: false,
922+
})
923+
924+
await program.parseAsync([
925+
'node',
926+
'td',
927+
'project',
928+
'create',
929+
'--name',
930+
'New Project',
931+
'--json',
932+
])
933+
934+
const output = consoleSpy.mock.calls[0][0]
935+
const parsed = JSON.parse(output)
936+
expect(parsed.id).toBe('proj-new')
937+
expect(parsed.name).toBe('New Project')
938+
expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Created:'))
939+
})
940+
})
941+
942+
describe('project update --json', () => {
943+
let mockApi: MockApi
944+
let consoleSpy: ReturnType<typeof vi.spyOn>
945+
946+
beforeEach(() => {
947+
vi.clearAllMocks()
948+
mockApi = createMockApi()
949+
mockGetApi.mockResolvedValue(mockApi)
950+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
951+
})
952+
953+
afterEach(() => {
954+
consoleSpy.mockRestore()
955+
})
956+
957+
it('outputs updated project as JSON', async () => {
958+
const program = createProgram()
959+
960+
mockApi.getProject.mockResolvedValue({ id: 'proj-1', name: 'Old Name' })
961+
mockApi.updateProject.mockResolvedValue({
962+
id: 'proj-1',
963+
name: 'New Name',
964+
color: 'charcoal',
965+
})
966+
967+
await program.parseAsync([
968+
'node',
969+
'td',
970+
'project',
971+
'update',
972+
'id:proj-1',
973+
'--name',
974+
'New Name',
975+
'--json',
976+
])
977+
978+
const output = consoleSpy.mock.calls[0][0]
979+
const parsed = JSON.parse(output)
980+
expect(parsed.id).toBe('proj-1')
981+
expect(parsed.name).toBe('New Name')
982+
expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Updated:'))
983+
})
984+
})
985+
899986
describe('project archive', () => {
900987
let mockApi: MockApi
901988
let consoleSpy: ReturnType<typeof vi.spyOn>

0 commit comments

Comments
 (0)