Skip to content

Commit 6dff062

Browse files
authored
feat: add --parent flag to checklist add-item and edit-item for nested items (#54)
* feat: add --parent flag to checklist add-item and edit-item for nested items * feat: render nested checklist items in view output
1 parent f0be361 commit 6dff062

File tree

7 files changed

+190
-34
lines changed

7 files changed

+190
-34
lines changed

docs/commands.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -868,7 +868,9 @@ cup checklist view abc123 # view all checklists on a t
868868
cup checklist create abc123 "QA Checklist" # add a checklist
869869
cup checklist delete <checklistId> # remove a checklist
870870
cup checklist add-item <checklistId> "Run tests" # add an item
871+
cup checklist add-item <clId> "Sub step" --parent <itemId> # nest under parent
871872
cup checklist edit-item <clId> <itemId> --resolved # mark item done
873+
cup checklist edit-item <clId> <itemId> --parent <newParent> # reparent (use "null" to unnest)
872874
cup checklist delete-item <clId> <itemId> # remove an item
873875
```
874876

@@ -877,11 +879,11 @@ cup checklist delete-item <clId> <itemId> # remove an item
877879
| `view` | `<taskId>` | Show all checklists |
878880
| `create` | `<taskId> <name>` | Create a checklist |
879881
| `delete` | `<checklistId>` | Delete a checklist |
880-
| `add-item` | `<checklistId> <name>` | Add checklist item |
882+
| `add-item` | `<checklistId> <name> [flags]` | Add checklist item |
881883
| `edit-item` | `<checklistId> <itemId> [flags]` | Edit checklist item |
882884
| `delete-item` | `<checklistId> <itemId>` | Delete checklist item |
883885

884-
`edit-item` flags: `--name <text>`, `--resolved`, `--unresolved`, `--assignee <userId>`. All subcommands support `--json`.
886+
`add-item` flags: `--parent <itemId>` to nest under a parent item. `edit-item` flags: `--name <text>`, `--resolved`, `--unresolved`, `--assignee <userId>`, `--parent <itemId>` (pass `"null"` to unnest). All subcommands support `--json`.
885887

886888
Checklists are also shown inline in `cup task <id>` detail view.
887889

skills/clickup-cli/SKILL.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,8 @@ All commands support `--help` for full flag details. All commands support `--jso
173173
| `cup checklist view <id>` | View checklists on a task |
174174
| `cup checklist create <id> <name>` | Create a checklist |
175175
| `cup checklist delete <checklistId>` | Delete a checklist |
176-
| `cup checklist add-item <checklistId> <name>` | Add item to checklist |
177-
| `cup checklist edit-item <checklistId> <itemId> [--name n] [--resolved] [--unresolved] [--assignee id]` | Edit checklist item |
176+
| `cup checklist add-item <checklistId> <name> [--parent itemId]` | Add item to checklist (nest under parent via `--parent`) |
177+
| `cup checklist edit-item <checklistId> <itemId> [--name n] [--resolved] [--unresolved] [--assignee id] [--parent itemId\|null]` | Edit checklist item (reparent with `--parent`, use `"null"` to unnest) |
178178
| `cup checklist delete-item <checklistId> <itemId>` | Delete checklist item |
179179
| `cup time start <taskId> [-d desc]` | Start timer |
180180
| `cup time stop` | Stop running timer |
@@ -305,7 +305,9 @@ cup field abc123def --set "Story Points" 5
305305
cup tag abc123def --add "bug,frontend"
306306
cup checklist create abc123def "QA Steps"
307307
cup checklist add-item <clId> "Run unit tests"
308+
cup checklist add-item <clId> "Sub step" --parent <itemId> # nest under parent
308309
cup checklist edit-item <clId> <itemId> --resolved
310+
cup checklist edit-item <clId> <itemId> --parent <newParent> # reparent (use "null" to unnest)
309311
cup link abc123 def456
310312
cup attach abc123def ./screenshot.png
311313
cup time start abc123def -d "Working on feature"

src/api.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ export interface ChecklistItem {
187187
assignee?: { id: number; username: string } | null
188188
orderindex: number
189189
parent?: string | null
190+
children?: ChecklistItem[]
190191
}
191192

192193
export interface Checklist {
@@ -974,10 +975,16 @@ export class ClickUpClient {
974975
await this.request<Record<string, never>>(`/checklist/${checklistId}`, { method: 'DELETE' })
975976
}
976977

977-
async createChecklistItem(checklistId: string, name: string): Promise<Checklist> {
978+
async createChecklistItem(
979+
checklistId: string,
980+
name: string,
981+
parent?: string | null,
982+
): Promise<Checklist> {
983+
const body: Record<string, unknown> = { name }
984+
if (parent !== undefined) body.parent = parent
978985
const data = await this.request<{ checklist: Checklist }>(
979986
`/checklist/${checklistId}/checklist_item`,
980-
{ method: 'POST', body: JSON.stringify({ name }) },
987+
{ method: 'POST', body: JSON.stringify(body) },
981988
)
982989
return expectRecordField(
983990
data as Record<string, unknown>,
@@ -989,7 +996,12 @@ export class ClickUpClient {
989996
async editChecklistItem(
990997
checklistId: string,
991998
checklistItemId: string,
992-
updates: { name?: string; resolved?: boolean; assignee?: number | null },
999+
updates: {
1000+
name?: string
1001+
resolved?: boolean
1002+
assignee?: number | null
1003+
parent?: string | null
1004+
},
9931005
): Promise<Checklist> {
9941006
const data = await this.request<{ checklist: Checklist }>(
9951007
`/checklist/${checklistId}/checklist_item/${checklistItemId}`,

src/commands/checklist.ts

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,22 @@ export async function addChecklistItem(
3131
config: Config,
3232
checklistId: string,
3333
name: string,
34+
parent?: string | null,
3435
): Promise<Checklist> {
3536
const client = new ClickUpClient(config)
36-
return client.createChecklistItem(checklistId, name)
37+
return client.createChecklistItem(checklistId, name, parent)
3738
}
3839

3940
export async function editChecklistItem(
4041
config: Config,
4142
checklistId: string,
4243
checklistItemId: string,
43-
updates: { name?: string; resolved?: boolean; assignee?: number | null },
44+
updates: {
45+
name?: string
46+
resolved?: boolean
47+
assignee?: number | null
48+
parent?: string | null
49+
},
4450
): Promise<Checklist> {
4551
const client = new ClickUpClient(config)
4652
return client.editChecklistItem(checklistId, checklistItemId, updates)
@@ -56,33 +62,63 @@ export async function deleteChecklistItem(
5662
return { checklistId, checklistItemId }
5763
}
5864

65+
function sortByOrder(items: ChecklistItem[]): ChecklistItem[] {
66+
return [...items].sort((a, b) => (a.orderindex ?? 0) - (b.orderindex ?? 0))
67+
}
68+
69+
function countItems(items: ChecklistItem[]): { total: number; resolved: number } {
70+
let total = 0
71+
let resolved = 0
72+
for (const item of items) {
73+
total++
74+
if (item.resolved) resolved++
75+
if (item.children?.length) {
76+
const nested = countItems(item.children)
77+
total += nested.total
78+
resolved += nested.resolved
79+
}
80+
}
81+
return { total, resolved }
82+
}
83+
5984
export function formatChecklists(checklists: Checklist[]): string {
6085
if (checklists.length === 0) return 'No checklists'
6186
const lines: string[] = []
87+
const renderItem = (item: ChecklistItem, depth: number): void => {
88+
const indent = ' '.repeat(depth + 1)
89+
const check = item.resolved ? chalk.green('[x]') : chalk.dim('[ ]')
90+
const name = item.resolved ? chalk.dim(item.name) : item.name
91+
const assignee = item.assignee ? chalk.dim(` @${item.assignee.username}`) : ''
92+
lines.push(`${indent}${check} ${name}${assignee}`)
93+
lines.push(chalk.dim(`${indent} item-id: ${item.id}`))
94+
for (const child of sortByOrder(item.children ?? [])) {
95+
renderItem(child, depth + 1)
96+
}
97+
}
6298
for (const cl of checklists) {
63-
const resolved = cl.items.filter(i => i.resolved).length
64-
lines.push(chalk.bold(`${cl.name} (${resolved}/${cl.items.length})`))
99+
const { total, resolved } = countItems(cl.items)
100+
lines.push(chalk.bold(`${cl.name} (${resolved}/${total})`))
65101
lines.push(chalk.dim(` ID: ${cl.id}`))
66-
for (const item of cl.items) {
67-
const check = item.resolved ? chalk.green('[x]') : chalk.dim('[ ]')
68-
const name = item.resolved ? chalk.dim(item.name) : item.name
69-
const assignee = item.assignee ? chalk.dim(` @${item.assignee.username}`) : ''
70-
lines.push(` ${check} ${name}${assignee}`)
71-
lines.push(chalk.dim(` item-id: ${item.id}`))
72-
}
102+
for (const item of sortByOrder(cl.items)) renderItem(item, 0)
73103
}
74104
return lines.join('\n')
75105
}
76106

77107
export function formatChecklistsMarkdown(checklists: Checklist[]): string {
78108
if (checklists.length === 0) return 'No checklists'
109+
const renderItem = (item: ChecklistItem, depth: number): string[] => {
110+
const indent = ' '.repeat(depth)
111+
const lines = [`${indent}- [${item.resolved ? 'x' : ' '}] ${item.name}`]
112+
for (const child of sortByOrder(item.children ?? [])) {
113+
lines.push(...renderItem(child, depth + 1))
114+
}
115+
return lines
116+
}
79117
return checklists
80118
.map(cl => {
81-
const resolved = cl.items.filter((i: ChecklistItem) => i.resolved).length
82-
const header = `### ${cl.name} (${resolved}/${cl.items.length})`
83-
const items = cl.items.map(
84-
(item: ChecklistItem) => `- [${item.resolved ? 'x' : ' '}] ${item.name}`,
85-
)
119+
const { total, resolved } = countItems(cl.items)
120+
const header = `### ${cl.name} (${resolved}/${total})`
121+
const items = sortByOrder(cl.items).flatMap(item => renderItem(item, 0))
86122
return [header, '', ...items].join('\n')
87123
})
88124
.join('\n\n')

src/index.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,17 +1232,20 @@ export function buildProgram(programName = basename(process.argv[1] ?? 'cup')):
12321232
checklistCmd
12331233
.command('add-item <checklistId> <name>')
12341234
.description('Add an item to a checklist')
1235+
.option('--parent <itemId>', 'Nest under a parent checklist item ID')
12351236
.option('--json', 'Force JSON output even in terminal')
12361237
.action(
1237-
wrapAction(async (checklistId: string, name: string, opts: { json?: boolean }) => {
1238-
const config = loadConfig(getProfileName())
1239-
const result = await addChecklistItem(config, checklistId, name)
1240-
if (shouldOutputJson(opts.json ?? false)) {
1241-
console.log(JSON.stringify(result, null, 2))
1242-
} else {
1243-
console.log(`Added item "${name}" to checklist ${checklistId}`)
1244-
}
1245-
}),
1238+
wrapAction(
1239+
async (checklistId: string, name: string, opts: { parent?: string; json?: boolean }) => {
1240+
const config = loadConfig(getProfileName())
1241+
const result = await addChecklistItem(config, checklistId, name, opts.parent)
1242+
if (shouldOutputJson(opts.json ?? false)) {
1243+
console.log(JSON.stringify(result, null, 2))
1244+
} else {
1245+
console.log(`Added item "${name}" to checklist ${checklistId}`)
1246+
}
1247+
},
1248+
),
12461249
)
12471250

12481251
checklistCmd
@@ -1252,6 +1255,10 @@ export function buildProgram(programName = basename(process.argv[1] ?? 'cup')):
12521255
.option('--resolved', 'Mark item as resolved')
12531256
.option('--unresolved', 'Mark item as unresolved')
12541257
.option('--assignee <userId>', 'Assign user by ID (use "null" to unassign)')
1258+
.option(
1259+
'--parent <itemId>',
1260+
'Reparent item under another checklist item ID (use "null" to unnest)',
1261+
)
12551262
.option('--json', 'Force JSON output even in terminal')
12561263
.action(
12571264
wrapAction(
@@ -1263,11 +1270,17 @@ export function buildProgram(programName = basename(process.argv[1] ?? 'cup')):
12631270
resolved?: boolean
12641271
unresolved?: boolean
12651272
assignee?: string
1273+
parent?: string
12661274
json?: boolean
12671275
},
12681276
) => {
12691277
const config = loadConfig(getProfileName())
1270-
const updates: { name?: string; resolved?: boolean; assignee?: number | null } = {}
1278+
const updates: {
1279+
name?: string
1280+
resolved?: boolean
1281+
assignee?: number | null
1282+
parent?: string | null
1283+
} = {}
12711284
if (opts.name) updates.name = opts.name
12721285
if (opts.resolved) updates.resolved = true
12731286
if (opts.unresolved) updates.resolved = false
@@ -1277,6 +1290,9 @@ export function buildProgram(programName = basename(process.argv[1] ?? 'cup')):
12771290
? null
12781291
: parseOptionalNumberOption(opts.assignee, '--assignee')
12791292
}
1293+
if (opts.parent !== undefined) {
1294+
updates.parent = opts.parent === 'null' ? null : opts.parent
1295+
}
12801296
const result = await editChecklistItem(config, checklistId, checklistItemId, updates)
12811297
if (shouldOutputJson(opts.json ?? false)) {
12821298
console.log(JSON.stringify(result, null, 2))

tests/unit/api.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,24 @@ describe('checklist API methods', () => {
746746
)
747747
})
748748

749+
it('createChecklistItem includes parent in body when provided', async () => {
750+
const checklist = {
751+
id: 'cl1',
752+
name: 'QA',
753+
orderindex: 0,
754+
items: [{ id: 'i1', name: 'Sub', resolved: false, orderindex: 0 }],
755+
}
756+
mockFetch.mockReturnValue(mockResponse({ checklist }))
757+
await client.createChecklistItem('cl1', 'Sub', 'parent1')
758+
expect(mockFetch).toHaveBeenCalledWith(
759+
expect.stringContaining('/checklist/cl1/checklist_item'),
760+
expect.objectContaining({
761+
method: 'POST',
762+
body: JSON.stringify({ name: 'Sub', parent: 'parent1' }),
763+
}),
764+
)
765+
})
766+
749767
it('editChecklistItem sends PUT to checklist item endpoint', async () => {
750768
const checklist = {
751769
id: 'cl1',

tests/unit/commands/checklist.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,23 @@ describe('addChecklistItem', () => {
108108

109109
const { addChecklistItem } = await import('../../../src/commands/checklist.js')
110110
const result = await addChecklistItem(config, 'cl1', 'Step 1')
111-
expect(mockCreateChecklistItem).toHaveBeenCalledWith('cl1', 'Step 1')
111+
expect(mockCreateChecklistItem).toHaveBeenCalledWith('cl1', 'Step 1', undefined)
112112
expect(result).toEqual(checklist)
113113
})
114+
115+
it('forwards parent item ID when provided', async () => {
116+
const checklist = {
117+
id: 'cl1',
118+
name: 'QA',
119+
orderindex: 0,
120+
items: [{ id: 'item1', name: 'Sub step', resolved: false, orderindex: 0 }],
121+
}
122+
mockCreateChecklistItem.mockResolvedValue(checklist)
123+
124+
const { addChecklistItem } = await import('../../../src/commands/checklist.js')
125+
await addChecklistItem(config, 'cl1', 'Sub step', 'parent1')
126+
expect(mockCreateChecklistItem).toHaveBeenCalledWith('cl1', 'Sub step', 'parent1')
127+
})
114128
})
115129

116130
describe('editChecklistItem', () => {
@@ -177,6 +191,38 @@ describe('formatChecklists', () => {
177191
expect(output).toContain('i1')
178192
expect(output).toContain('i2')
179193
})
194+
195+
it('renders nested children with deeper indentation and includes them in totals', async () => {
196+
const { formatChecklists } = await import('../../../src/commands/checklist.js')
197+
const checklists = [
198+
{
199+
id: 'cl1',
200+
name: 'QA Checks',
201+
orderindex: 0,
202+
items: [
203+
{
204+
id: 'p1',
205+
name: 'Parent',
206+
resolved: false,
207+
orderindex: 0,
208+
children: [
209+
{ id: 'c1', name: 'Child A', resolved: true, orderindex: 0 },
210+
{ id: 'c2', name: 'Child B', resolved: false, orderindex: 1 },
211+
],
212+
},
213+
],
214+
},
215+
]
216+
const output = formatChecklists(checklists)
217+
expect(output).toContain('1/3')
218+
expect(output).toContain('Parent')
219+
expect(output).toContain('Child A')
220+
expect(output).toContain('Child B')
221+
const parentLine = output.split('\n').find(l => l.includes('Parent')) ?? ''
222+
const childLine = output.split('\n').find(l => l.includes('Child A')) ?? ''
223+
const leading = (s: string) => s.match(/^\s*/)?.[0].length ?? 0
224+
expect(leading(childLine)).toBeGreaterThan(leading(parentLine))
225+
})
180226
})
181227

182228
describe('formatChecklistsMarkdown', () => {
@@ -225,4 +271,28 @@ describe('formatChecklistsMarkdown', () => {
225271
expect(output).toContain('### Second (1/1)')
226272
expect(output).toContain('\n\n')
227273
})
274+
275+
it('renders nested children as indented markdown list items', async () => {
276+
const { formatChecklistsMarkdown } = await import('../../../src/commands/checklist.js')
277+
const checklists = [
278+
{
279+
id: 'cl1',
280+
name: 'QA Checks',
281+
orderindex: 0,
282+
items: [
283+
{
284+
id: 'p1',
285+
name: 'Parent',
286+
resolved: false,
287+
orderindex: 0,
288+
children: [{ id: 'c1', name: 'Child A', resolved: true, orderindex: 0 }],
289+
},
290+
],
291+
},
292+
]
293+
const output = formatChecklistsMarkdown(checklists)
294+
expect(output).toContain('### QA Checks (1/2)')
295+
expect(output).toContain('- [ ] Parent')
296+
expect(output).toContain(' - [x] Child A')
297+
})
228298
})

0 commit comments

Comments
 (0)