Summary
td task update <ref> --labels "" always fails with HTTP 400. The bug is client-side, not server-side: the CLI's truthy guard at src/commands/task/update.ts:47 treats the empty string as "labels flag absent" and never sets args.labels. With no other field flags passed, the SDK then sends an empty body {}, which the server (correctly) rejects.
This is structurally similar to #316 (now fixed), but unrelated in cause. The server already accepts {"labels": []} on the task update endpoint just fine. Only the CLI mishandles --labels "".
Reproduce (CLI, td 1.62.0)
$ td task add "zzz-labels-test" --labels "deep,quick"
Created: zzz-labels-test
ID: 6gcg5X49F6XJWjV4
$ td task update 6gcg5X49F6XJWjV4 --labels ""
Error: API_ERROR
HTTP 400: Bad Request
$ echo $?
1
$ td task view 6gcg5X49F6XJWjV4 --json | jq '.labels'
[
"deep",
"quick"
]
Labels are unchanged after the failed call. Same result against any task with one or more labels.
Reproduce (instrumented wire payload + response body)
Wrapped globalThis.fetch to log both POST request body and the server's response:
// /tmp/td-fetch-trace.mjs
const orig = globalThis.fetch;
globalThis.fetch = async (url, init = {}) => {
const method = init.method ?? 'GET';
const isTaskPost = method === 'POST' && url.toString().includes('/api/v1/tasks/');
if (isTaskPost) {
process.stderr.write(`[FETCH-TRACE] ${method} ${url}\n`);
process.stderr.write(`[FETCH-TRACE] req-body=${init.body ?? '<none>'}\n`);
}
const res = await orig(url, init);
if (isTaskPost) {
const text = await res.clone().text();
process.stderr.write(`[FETCH-TRACE] resp-status=${res.status}\n`);
process.stderr.write(`[FETCH-TRACE] resp-body=${text}\n`);
}
return res;
};
Run:
$ NODE_OPTIONS="--import file:///tmp/td-fetch-trace.mjs" td task update 6gcgghgpWjGgGWr4 --labels ""
[FETCH-TRACE] POST https://api.todoist.com/api/v1/tasks/6gcgghgpWjGgGWr4
[FETCH-TRACE] req-body={}
[FETCH-TRACE] resp-status=400
[FETCH-TRACE] resp-body={"error":"At least one of supported fields should be set and non-empty","error_code":42,"error_extra":{"event_id":"397d22e72c424083898861299532961f","retry_after":2},"error_tag":"BAD_REQUEST","http_code":400}
Error: API_ERROR
HTTP 400: Bad Request
Two facts captured directly:
- The literal wire payload is
{} — not {"labels": []}, not {"labels": null}.
- The server's response is
"At least one of supported fields should be set and non-empty" — same string returned to a hand-rolled curl POST with body {} (probe F below).
The CLI swallows the response body and only surfaces HTTP 400: Bad Request, which is why this took some digging.
Reproduce (raw REST)
Same OAuth token, same task endpoint, four probes:
--- Probe B: empty array accepted ---
POST https://api.todoist.com/api/v1/tasks/6gcg5X49F6XJWjV4
body: {"labels": []}
HTTP 200 — task returned with "labels": []
--- Probe E: empty-string array accepted (server normalizes) ---
body: {"labels": [""]}
HTTP 200 — response shows "labels": []
--- Probe F: empty body rejected ---
body: {}
HTTP 400
{"error":"At least one of supported fields should be set and non-empty","error_code":42,"error_extra":{"event_id":"5081e1d2f0444022a4d4b70797280c3c","retry_after":3},"error_tag":"BAD_REQUEST","http_code":400}
--- Probe G: null rejected ---
body: {"labels": null}
HTTP 400
{"error":"labels must be an array of labels","error_code":42,...}
Probe F's error string matches the CLI failure exactly — confirming the CLI sends {}.
Root cause
src/commands/task/update.ts:47 (and the compiled dist/commands/task/update.js:30):
if (options.labels) args.labels = options.labels.split(',').map((l) => l.trim())
options.labels === "" is falsy in JS, so args.labels is never set. When --labels "" is the only flag, the surrounding args object stays {} and the SDK's updateTask(id, {}) POSTs an empty body.
Note the same file already handles "clear this field" correctly for siblings — src/commands/task/update.ts lines 19–28:
if (options.due === false) {
args.dueString = null
} else if (options.due) {
args.dueString = options.due
}
if (options.deadline === false) {
args.deadlineDate = null
} else if (options.deadline) {
args.deadlineDate = options.deadline
}
backed by --no-due / --no-deadline declarations in src/commands/task/index.ts:183,185. Labels has no equivalent --no-labels declaration and no clearing path.
Suggested fix
Two reasonable shapes; preference for (a):
(a) Add --no-labels flag (mirrors --no-due / --no-deadline) — cleanest UX, consistent with sibling clear-flags:
// src/commands/task/index.ts (option block around line 192)
.option('--labels <a,b>', 'New labels (replaces existing)')
.option('--no-labels', 'Remove all labels')
// src/commands/task/update.ts (around line 47)
if (options.labels === false) {
args.labels = []
} else if (options.labels) {
args.labels = options.labels.split(',').map((l) => l.trim())
}
(b) Minimal patch — change the truthy guard so "" passes through:
if (options.labels !== undefined) args.labels = options.labels.split(',').map((l) => l.trim())
"".split(",").map(trim) is [""], and probe E shows the server normalizes [""] to []. Works, but relies on server-side normalization and is less discoverable than --no-labels.
Workaround (current users)
Direct REST clears labels reliably:
TOKEN=$(security find-generic-password -s "todoist-cli" -w) # macOS keychain example
curl -sS -X POST "https://api.todoist.com/api/v1/tasks/<task-id>" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"labels": []}'
Versions
td 1.62.0 (npm @doist/todoist-cli, dist/index.js)
- Endpoint:
POST https://api.todoist.com/api/v1/tasks/{id}
- Upstream
main src/commands/task/update.ts:47 still has the same guard at the time of filing.
- macOS, Node 20+
Related
#316 (closed, server-side fix shipped) — same shape of bug for --no-favorite on labels, but rooted in the server validator. This task-update bug is the mirror image: it lives in the CLI, and the server was never the problem.
Summary
td task update <ref> --labels ""always fails withHTTP 400. The bug is client-side, not server-side: the CLI's truthy guard atsrc/commands/task/update.ts:47treats the empty string as "labels flag absent" and never setsargs.labels. With no other field flags passed, the SDK then sends an empty body{}, which the server (correctly) rejects.This is structurally similar to
#316(now fixed), but unrelated in cause. The server already accepts{"labels": []}on the task update endpoint just fine. Only the CLI mishandles--labels "".Reproduce (CLI, td 1.62.0)
Labels are unchanged after the failed call. Same result against any task with one or more labels.
Reproduce (instrumented wire payload + response body)
Wrapped
globalThis.fetchto log both POST request body and the server's response:Run:
Two facts captured directly:
{}— not{"labels": []}, not{"labels": null}."At least one of supported fields should be set and non-empty"— same string returned to a hand-rolled curlPOSTwith body{}(probe F below).The CLI swallows the response body and only surfaces
HTTP 400: Bad Request, which is why this took some digging.Reproduce (raw REST)
Same OAuth token, same task endpoint, four probes:
Probe F's error string matches the CLI failure exactly — confirming the CLI sends
{}.Root cause
src/commands/task/update.ts:47(and the compileddist/commands/task/update.js:30):options.labels === ""is falsy in JS, soargs.labelsis never set. When--labels ""is the only flag, the surroundingargsobject stays{}and the SDK'supdateTask(id, {})POSTs an empty body.Note the same file already handles "clear this field" correctly for siblings —
src/commands/task/update.tslines 19–28:backed by
--no-due/--no-deadlinedeclarations insrc/commands/task/index.ts:183,185. Labels has no equivalent--no-labelsdeclaration and no clearing path.Suggested fix
Two reasonable shapes; preference for (a):
(a) Add
--no-labelsflag (mirrors--no-due/--no-deadline) — cleanest UX, consistent with sibling clear-flags:(b) Minimal patch — change the truthy guard so
""passes through:"".split(",").map(trim)is[""], and probe E shows the server normalizes[""]to[]. Works, but relies on server-side normalization and is less discoverable than--no-labels.Workaround (current users)
Direct REST clears labels reliably:
Versions
td1.62.0 (npm@doist/todoist-cli, dist/index.js)POST https://api.todoist.com/api/v1/tasks/{id}mainsrc/commands/task/update.ts:47still has the same guard at the time of filing.Related
#316(closed, server-side fix shipped) — same shape of bug for--no-favoriteon labels, but rooted in the server validator. This task-update bug is the mirror image: it lives in the CLI, and the server was never the problem.