把常用的 Todoist 操作包成 Claude Code 的 global skill,從此在任何專案裡對 Claude 說一句中文,他就能幫你 CRUD 待辦事項。
- 做了什麼:在
~/.claude/skills/todoist/下打造一個 global skill,包住 Todoist REST API v1 的 CRUD。 - 怎麼觸發:對 Claude 說「加個『買牛奶』明天早上九點到 Todoist」、「看我今天待辦」、「把運動那件事改到下午五點」——skill 的
description已經布好中文觸發詞,Claude 會自己載入。 - 零 pip install:Python 3 stdlib 就夠(
urllib,json,argparse)。 - API key 不外洩:存在
~/.claude/skills/todoist/.apikey(chmod 600),.gitignore就放在 skill 目錄內,跟著 skill 走。
最終長這樣:
~/.claude/skills/todoist/
├── SKILL.md # Claude 的使用指引 + 中文觸發詞
├── .apikey # Todoist API token(600 權限,git-ignored)
├── .gitignore # 排除 .apikey
├── scripts/
│ └── todoist.py # 純 stdlib CLI(CRUD wrapper)
└── references/
└── api-reference.md # 完整端點 / 參數 / 錯誤碼
這是整個設計的核心判斷:
| 方案 | 問題 |
|---|---|
Shell alias (alias td=...) |
Claude 看不到;每次都要我自己打指令 |
寫進 CLAUDE.md |
每輪對話都會佔 context token,即使用不到 |
| 寫進 Skill | 只有 description(一行)常駐 context;實際內容在被觸發時才載入,加上 allowed-tools 可以跳過權限確認 |
Skill 的關鍵優勢是 progressive disclosure——描述永遠在,細節只有用時才攤開。對這種偶爾用但用起來要精準的工具最對味。
Todoist 是跨專案的生活工具,在任何 repo 都該能用。Personal skill 的位置是 ~/.claude/skills/<skill-name>/。
用 stdlib 的 urllib.request + json + argparse,不需要 requests、不需要 pip install。Skill 對任何人、任何機器都能直接跑。
代價是 code 稍長一點、錯誤處理要自己做。但可攜性換來的好處值得。
TODOIST_API_TOKEN env var → ~/.claude/skills/todoist/.apikey → 錯誤退出
環境變數優先,方便臨時切帳號或用 CI。檔案作為長期儲存,chmod 600 + .gitignore。
而不是 repo 根。這樣 skill 被 fork / 複製 / 發布都自帶保護,不依賴外層 repo 的設定。
.apikey
SKILL.md 只放 Claude 操作時需要的最小資訊(~80 行):invocation pattern、common playbooks、workflow guidance。完整的端點/參數表放在 references/api-reference.md,Claude 只在需要查細節時才讀。
目標:SKILL.md < 500 行,reference 檔可長可短。
這是 skill frontmatter 的一個欄位。宣告後,當 skill 啟用時 Claude 可以直接執行 python3 ... 不會跳權限確認——日常使用順暢很多。
Claude 用 description 決定何時自動載入 skill,所以描述裡要放使用者真的會說的話:
description: CRUD operations on the user's Todoist — list/create/update/close/delete tasks, projects, sections, labels, and comments via the Todoist API v1. Use when the user asks about Todoist, their todo list, adding/completing/rescheduling a task, managing projects or labels, or says things like "加到待辦", "今天的 Todoist", "把這個工作排到 Todoist".中英文混雜無妨,重點是包含實際語句。
到 Todoist → Settings → Integrations → Developer → Copy API token。
mkdir -p ~/.claude/skills/todoist/scripts ~/.claude/skills/todoist/references見下方「完整檔案」段落,整檔複製到 ~/.claude/skills/todoist/SKILL.md。
複製下方 Python 檔到 ~/.claude/skills/todoist/scripts/todoist.py,然後:
chmod 755 ~/.claude/skills/todoist/scripts/todoist.py複製下方 reference 檔到 ~/.claude/skills/todoist/references/api-reference.md。
# 把你的 token 寫進去(替換 YOUR_TOKEN_HERE)
install -m 600 /dev/stdin ~/.claude/skills/todoist/.apikey <<< 'YOUR_TOKEN_HERE'
# 加 gitignore
echo '.apikey' > ~/.claude/skills/todoist/.gitignorepython3 ~/.claude/skills/todoist/scripts/todoist.py projects list看到 JSON 回來(包含你的 Inbox)就成了。
Claude Code 會自動偵測 ~/.claude/skills/ 下的變動。打個 /todoist 看有沒有出現、或直接對 Claude 說「加個『測試』到 Todoist」試試看。
---
name: todoist
description: CRUD operations on the user's Todoist — list/create/update/close/delete tasks, projects, sections, labels, and comments via the Todoist API v1. Use when the user asks about Todoist, their todo list, adding/completing/rescheduling a task, managing projects or labels, or says things like "加到待辦", "今天的 Todoist", "把這個工作排到 Todoist".
allowed-tools: Bash(python3 *)
---
# Todoist CRUD
Wraps the Todoist REST API v1 via a bundled Python script. Authentication token lives in `${CLAUDE_SKILL_DIR}/.apikey` (or `TODOIST_API_TOKEN` env var, which takes precedence).
## Invocation
Call the script with `python3`:
```bash
python3 ~/.claude/skills/todoist/scripts/todoist.py <resource> <action> [args...]
```
Resources: `tasks`, `projects`, `sections`, `labels`, `comments`. Every successful call prints the API response as JSON; errors print `HTTP <code>` to stderr and exit non-zero.
For full endpoint/parameter details see [references/api-reference.md](references/api-reference.md).
## Common playbooks
### Add a task (the 90% case)
```bash
python3 ~/.claude/skills/todoist/scripts/todoist.py tasks add "買牛奶" --due "tomorrow 9am" --priority 2
```
- `--due` accepts Todoist natural-language strings (`today`, `tomorrow 9am`, `every monday`, `2026-05-01`).
- `--due-lang` if the user wrote Chinese natural-language (e.g. `"下週二"` → add `--due-lang zh`).
- `--priority` is 1 (lowest) to 4 (highest, p1 in the Todoist UI).
- `--labels` is comma-separated, e.g. `--labels work,urgent`.
- `--project-id` to target a specific project. Resolve project name → id with `projects list` first.
### See today's tasks
```bash
python3 ~/.claude/skills/todoist/scripts/todoist.py tasks list --filter "today"
```
Other filter strings: `overdue`, `7 days`, `p1`, `@work`, `#Inbox`.
### Complete a task
```bash
python3 ~/.claude/skills/todoist/scripts/todoist.py tasks close <task_id>
```
Use `reopen` to un-complete, `delete` to remove entirely.
### Reschedule / edit
```bash
python3 ~/.claude/skills/todoist/scripts/todoist.py tasks update <task_id> --due "friday"
python3 ~/.claude/skills/todoist/scripts/todoist.py tasks update <task_id> --content "new title" --priority 3
```
Only pass the flags you want to change — omitted fields are left untouched.
### Project / label bookkeeping
```bash
# projects
python3 ~/.claude/skills/todoist/scripts/todoist.py projects list
python3 ~/.claude/skills/todoist/scripts/todoist.py projects add "Side project"
# labels
python3 ~/.claude/skills/todoist/scripts/todoist.py labels list
python3 ~/.claude/skills/todoist/scripts/todoist.py labels add "deep-work"
```
## Workflow guidance
1. **Resolve names before acting.** Todoist uses numeric IDs. When the user names a project/label/task, first `list` the relevant resource, match by `name`/`content`, then act on the `id`.
2. **Confirm before destructive ops.** `delete` is irreversible. Confirm with the user when unsure. `close`/`archive` are reversible and safer defaults.
3. **Prefer `close` over `delete`** for completed tasks — it preserves history.
4. **Dates:** pass what the user said verbatim to `--due` (e.g. `"tomorrow 3pm"`, `"下週一"`). The API parses it. Set `--due-lang` when the user wrote non-English.
5. **Soft-delete:** `DELETE` flips `is_deleted: true`; a follow-up `GET` returns **200** with the tombstone, not 404. The script detects this and exits **2** with a stderr warning while still printing the JSON. Treat exit 2 as "gone", not success — don't trust only stdout.
6. **Token errors:** if the script exits with `HTTP 401` or `HTTP 403`, the token is missing/invalid — tell the user to regenerate it at Todoist Settings → Integrations → Developer and write it to `~/.claude/skills/todoist/.apikey`.
7. **Rate limits:** Todoist allows ~450 requests / 15 min per user. For bulk operations, batch via `tasks list --ids "1,2,3"` instead of looping `get`.
## Token file
Stored at `~/.claude/skills/todoist/.apikey`, permissions `600`. To rotate:
```bash
printf '%s' "<new-token>" > ~/.claude/skills/todoist/.apikey
chmod 600 ~/.claude/skills/todoist/.apikey
```
Or export `TODOIST_API_TOKEN` to override without touching the file.#!/usr/bin/env python3
"""Todoist API v1 CLI — CRUD for tasks, projects, sections, labels, comments.
Token source (in order):
1. TODOIST_API_TOKEN environment variable
2. <skill_dir>/.apikey
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
API_BASE = "https://api.todoist.com/api/v1"
SKILL_DIR = Path(__file__).resolve().parent.parent
def load_token() -> str:
token = os.environ.get("TODOIST_API_TOKEN", "").strip()
if token:
return token
key_file = SKILL_DIR / ".apikey"
if key_file.exists():
t = key_file.read_text().strip()
if t:
return t
sys.exit(
"error: Todoist token not found. Set TODOIST_API_TOKEN env var or write "
f"token to {key_file}"
)
def api(method, path, params=None, body=None):
url = f"{API_BASE}{path}"
if params:
cleaned = {k: v for k, v in params.items() if v is not None}
if cleaned:
url += "?" + urllib.parse.urlencode(cleaned, doseq=True)
data = None
if body is not None:
cleaned_body = {k: v for k, v in body.items() if v is not None}
data = json.dumps(cleaned_body).encode("utf-8")
headers = {
"Authorization": f"Bearer {load_token()}",
"Content-Type": "application/json",
}
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req) as resp:
raw = resp.read()
if not raw:
return None
return json.loads(raw.decode("utf-8"))
except urllib.error.HTTPError as e:
body_text = e.read().decode("utf-8", errors="replace")
sys.exit(f"HTTP {e.code} {e.reason}: {body_text}")
except urllib.error.URLError as e:
sys.exit(f"network error: {e.reason}")
def out(obj):
if obj is None:
return
print(json.dumps(obj, indent=2, ensure_ascii=False))
# Todoist soft-deletes: GET on a deleted resource returns 200 with is_deleted=true.
# Surface this clearly so callers don't mistake a tombstone for a live record.
if isinstance(obj, dict) and obj.get("is_deleted") is True:
print(
f"warning: resource id={obj.get('id')} is soft-deleted (is_deleted=true)",
file=sys.stderr,
)
sys.exit(2)
def split_csv(value):
if value is None:
return None
return [item.strip() for item in value.split(",") if item.strip()]
# ---------- Tasks ----------
def tasks_list(a):
out(api("GET", "/tasks", params={
"project_id": a.project_id, "section_id": a.section_id, "label": a.label,
"filter": a.filter, "lang": a.lang, "ids": a.ids,
}))
def tasks_get(a): out(api("GET", f"/tasks/{a.id}"))
def tasks_add(a):
out(api("POST", "/tasks", body={
"content": a.content, "description": a.description, "project_id": a.project_id,
"section_id": a.section_id, "parent_id": a.parent_id, "priority": a.priority,
"due_string": a.due, "due_date": a.due_date, "due_lang": a.due_lang,
"labels": split_csv(a.labels), "duration": a.duration,
"duration_unit": a.duration_unit,
}))
def tasks_update(a):
out(api("POST", f"/tasks/{a.id}", body={
"content": a.content, "description": a.description, "priority": a.priority,
"due_string": a.due, "due_date": a.due_date, "due_lang": a.due_lang,
"labels": split_csv(a.labels), "duration": a.duration,
"duration_unit": a.duration_unit,
}))
def tasks_close(a): api("POST", f"/tasks/{a.id}/close"); print(f"closed task {a.id}")
def tasks_reopen(a): api("POST", f"/tasks/{a.id}/reopen"); print(f"reopened task {a.id}")
def tasks_delete(a): api("DELETE", f"/tasks/{a.id}"); print(f"deleted task {a.id}")
# ---------- Projects ----------
def projects_list(_a): out(api("GET", "/projects"))
def projects_get(a): out(api("GET", f"/projects/{a.id}"))
def projects_add(a):
out(api("POST", "/projects", body={
"name": a.name, "parent_id": a.parent_id, "color": a.color,
"is_favorite": a.favorite, "view_style": a.view_style,
}))
def projects_update(a):
out(api("POST", f"/projects/{a.id}", body={
"name": a.name, "color": a.color, "is_favorite": a.favorite,
"view_style": a.view_style,
}))
def projects_delete(a): api("DELETE", f"/projects/{a.id}"); print(f"deleted project {a.id}")
def projects_archive(a): api("POST", f"/projects/{a.id}/archive"); print(f"archived project {a.id}")
def projects_unarchive(a): api("POST", f"/projects/{a.id}/unarchive"); print(f"unarchived project {a.id}")
# ---------- Sections ----------
def sections_list(a): out(api("GET", "/sections", params={"project_id": a.project_id}))
def sections_get(a): out(api("GET", f"/sections/{a.id}"))
def sections_add(a):
out(api("POST", "/sections", body={"name": a.name, "project_id": a.project_id, "order": a.order}))
def sections_update(a): out(api("POST", f"/sections/{a.id}", body={"name": a.name}))
def sections_delete(a): api("DELETE", f"/sections/{a.id}"); print(f"deleted section {a.id}")
# ---------- Labels ----------
def labels_list(_a): out(api("GET", "/labels"))
def labels_get(a): out(api("GET", f"/labels/{a.id}"))
def labels_add(a):
out(api("POST", "/labels", body={
"name": a.name, "color": a.color, "order": a.order, "is_favorite": a.favorite,
}))
def labels_update(a):
out(api("POST", f"/labels/{a.id}", body={
"name": a.name, "color": a.color, "order": a.order, "is_favorite": a.favorite,
}))
def labels_delete(a): api("DELETE", f"/labels/{a.id}"); print(f"deleted label {a.id}")
# ---------- Comments ----------
def comments_list(a):
if not (a.task_id or a.project_id):
sys.exit("error: --task-id or --project-id required")
out(api("GET", "/comments", params={"task_id": a.task_id, "project_id": a.project_id}))
def comments_get(a): out(api("GET", f"/comments/{a.id}"))
def comments_add(a):
if not (a.task_id or a.project_id):
sys.exit("error: --task-id or --project-id required")
out(api("POST", "/comments", body={
"content": a.content, "task_id": a.task_id, "project_id": a.project_id,
}))
def comments_update(a): out(api("POST", f"/comments/{a.id}", body={"content": a.content}))
def comments_delete(a): api("DELETE", f"/comments/{a.id}"); print(f"deleted comment {a.id}")
# ---------- CLI plumbing ----------
def build_parser():
p = argparse.ArgumentParser(prog="todoist", description="Todoist API v1 CLI")
sub = p.add_subparsers(dest="resource", required=True)
# tasks
t = sub.add_parser("tasks").add_subparsers(dest="action", required=True)
tl = t.add_parser("list"); tl.add_argument("--project-id"); tl.add_argument("--section-id"); tl.add_argument("--label"); tl.add_argument("--filter"); tl.add_argument("--lang"); tl.add_argument("--ids"); tl.set_defaults(func=tasks_list)
tg = t.add_parser("get"); tg.add_argument("id"); tg.set_defaults(func=tasks_get)
ta = t.add_parser("add"); ta.add_argument("content"); ta.add_argument("--description"); ta.add_argument("--project-id"); ta.add_argument("--section-id"); ta.add_argument("--parent-id"); ta.add_argument("--priority", type=int, choices=[1,2,3,4]); ta.add_argument("--due"); ta.add_argument("--due-date"); ta.add_argument("--due-lang"); ta.add_argument("--labels"); ta.add_argument("--duration", type=int); ta.add_argument("--duration-unit", choices=["minute","day"]); ta.set_defaults(func=tasks_add)
tu = t.add_parser("update"); tu.add_argument("id"); tu.add_argument("--content"); tu.add_argument("--description"); tu.add_argument("--priority", type=int, choices=[1,2,3,4]); tu.add_argument("--due"); tu.add_argument("--due-date"); tu.add_argument("--due-lang"); tu.add_argument("--labels"); tu.add_argument("--duration", type=int); tu.add_argument("--duration-unit", choices=["minute","day"]); tu.set_defaults(func=tasks_update)
tc = t.add_parser("close"); tc.add_argument("id"); tc.set_defaults(func=tasks_close)
tr = t.add_parser("reopen"); tr.add_argument("id"); tr.set_defaults(func=tasks_reopen)
td = t.add_parser("delete"); td.add_argument("id"); td.set_defaults(func=tasks_delete)
# projects
pr = sub.add_parser("projects").add_subparsers(dest="action", required=True)
pr.add_parser("list").set_defaults(func=projects_list)
pg = pr.add_parser("get"); pg.add_argument("id"); pg.set_defaults(func=projects_get)
pa = pr.add_parser("add"); pa.add_argument("name"); pa.add_argument("--parent-id"); pa.add_argument("--color"); pa.add_argument("--favorite", action="store_true", default=None); pa.add_argument("--view-style", choices=["list","board"]); pa.set_defaults(func=projects_add)
pu = pr.add_parser("update"); pu.add_argument("id"); pu.add_argument("--name"); pu.add_argument("--color"); pu.add_argument("--favorite", action="store_true", default=None); pu.add_argument("--view-style", choices=["list","board"]); pu.set_defaults(func=projects_update)
pd = pr.add_parser("delete"); pd.add_argument("id"); pd.set_defaults(func=projects_delete)
par = pr.add_parser("archive"); par.add_argument("id"); par.set_defaults(func=projects_archive)
pun = pr.add_parser("unarchive"); pun.add_argument("id"); pun.set_defaults(func=projects_unarchive)
# sections
sc = sub.add_parser("sections").add_subparsers(dest="action", required=True)
sl = sc.add_parser("list"); sl.add_argument("--project-id"); sl.set_defaults(func=sections_list)
sg = sc.add_parser("get"); sg.add_argument("id"); sg.set_defaults(func=sections_get)
sa = sc.add_parser("add"); sa.add_argument("name"); sa.add_argument("--project-id", required=True); sa.add_argument("--order", type=int); sa.set_defaults(func=sections_add)
su = sc.add_parser("update"); su.add_argument("id"); su.add_argument("--name", required=True); su.set_defaults(func=sections_update)
sd = sc.add_parser("delete"); sd.add_argument("id"); sd.set_defaults(func=sections_delete)
# labels
lb = sub.add_parser("labels").add_subparsers(dest="action", required=True)
lb.add_parser("list").set_defaults(func=labels_list)
lg = lb.add_parser("get"); lg.add_argument("id"); lg.set_defaults(func=labels_get)
la = lb.add_parser("add"); la.add_argument("name"); la.add_argument("--color"); la.add_argument("--order", type=int); la.add_argument("--favorite", action="store_true", default=None); la.set_defaults(func=labels_add)
lu = lb.add_parser("update"); lu.add_argument("id"); lu.add_argument("--name"); lu.add_argument("--color"); lu.add_argument("--order", type=int); lu.add_argument("--favorite", action="store_true", default=None); lu.set_defaults(func=labels_update)
ld = lb.add_parser("delete"); ld.add_argument("id"); ld.set_defaults(func=labels_delete)
# comments
cm = sub.add_parser("comments").add_subparsers(dest="action", required=True)
cl = cm.add_parser("list"); cl.add_argument("--task-id"); cl.add_argument("--project-id"); cl.set_defaults(func=comments_list)
cg = cm.add_parser("get"); cg.add_argument("id"); cg.set_defaults(func=comments_get)
ca = cm.add_parser("add"); ca.add_argument("content"); ca.add_argument("--task-id"); ca.add_argument("--project-id"); ca.set_defaults(func=comments_add)
cu = cm.add_parser("update"); cu.add_argument("id"); cu.add_argument("--content", required=True); cu.set_defaults(func=comments_update)
cd = cm.add_parser("delete"); cd.add_argument("id"); cd.set_defaults(func=comments_delete)
return p
def main():
args = build_parser().parse_args()
args.func(args)
if __name__ == "__main__":
main()# Todoist API v1 — endpoint reference
Base URL: `https://api.todoist.com/api/v1`
Auth: `Authorization: Bearer <token>` on every request.
## Tasks
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/tasks` | List active tasks. Query: `project_id`, `section_id`, `label`, `filter`, `lang`, `ids` |
| `GET` | `/tasks/{id}` | Fetch one task |
| `POST` | `/tasks` | Create task |
| `POST` | `/tasks/{id}` | Update task (partial — omitted fields untouched) |
| `POST` | `/tasks/{id}/close` | Mark complete |
| `POST` | `/tasks/{id}/reopen` | Un-complete |
| `DELETE` | `/tasks/{id}` | Hard delete |
**Create/update body fields:**
| Field | Type | Notes |
| --- | --- | --- |
| `content` | string | Task title (required on create) |
| `description` | string | Multi-line details |
| `project_id` | string | Target project; default = Inbox |
| `section_id` | string | Section inside project |
| `parent_id` | string | Parent task — makes this a subtask |
| `priority` | int 1–4 | 4 = p1 in UI (highest) |
| `due_string` | string | Natural language: `today`, `tomorrow 9am`, `every mon`, `2026-05-01` |
| `due_date` | string | ISO `YYYY-MM-DD`; use instead of `due_string` for exact date |
| `due_lang` | string | `en`, `zh`, `ja`, … — parser locale |
| `labels` | `[string]` | Label **names**, not IDs |
| `duration` | int | Amount |
| `duration_unit` | `"minute"` or `"day"` | Pairs with `duration` |
**Filter query examples** (pass via `--filter`):
- `today` — due today
- `overdue`
- `7 days` — due in next week
- `p1` — priority 1 (highest)
- `@label_name` — has label
- `#Project name`
- `!assigned to: others`
- Combine with `&` / `|` — e.g. `today & p1`
## Projects
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/projects` | List all |
| `GET` | `/projects/{id}` | One |
| `POST` | `/projects` | Create |
| `POST` | `/projects/{id}` | Update |
| `DELETE` | `/projects/{id}` | Delete |
| `POST` | `/projects/{id}/archive` | Archive |
| `POST` | `/projects/{id}/unarchive` | Restore |
Body: `name` (req. on create), `parent_id`, `color`, `is_favorite`, `view_style` (`list`/`board`).
Color names: `berry_red`, `red`, `orange`, `yellow`, `olive_green`, `lime_green`, `green`, `mint_green`, `teal`, `sky_blue`, `light_blue`, `blue`, `grape`, `violet`, `lavender`, `magenta`, `salmon`, `charcoal`, `grey`, `taupe`.
## Sections
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/sections?project_id={id}` | List (optionally filtered) |
| `GET` | `/sections/{id}` | One |
| `POST` | `/sections` | Create — needs `name` + `project_id` |
| `POST` | `/sections/{id}` | Rename (body: `name`) |
| `DELETE` | `/sections/{id}` | Delete |
## Labels (personal)
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/labels` | List |
| `GET` | `/labels/{id}` | One |
| `POST` | `/labels` | Create |
| `POST` | `/labels/{id}` | Update |
| `DELETE` | `/labels/{id}` | Delete |
Body: `name`, `color`, `order`, `is_favorite`.
## Comments
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/comments?task_id=...` or `?project_id=...` | List |
| `GET` | `/comments/{id}` | One |
| `POST` | `/comments` | Create (body needs `content` + one of `task_id`/`project_id`) |
| `POST` | `/comments/{id}` | Update (body: `content`) |
| `DELETE` | `/comments/{id}` | Delete |
## Errors
- `400` — bad body (check required fields, enum values)
- `401` — token missing/invalid
- `403` — token lacks scope, or resource not yours
- `404` — resource was never created (or, in some shapes, not yours)
- `429` — rate limited; ~450 req / 15 min window per user. Back off and retry.
- `5xx` — Todoist side; retry with backoff.
## Soft-delete semantics
`DELETE` does **not** purge — it flips `is_deleted: true`. Follow-up behavior:
- `GET /tasks/{id}` on a deleted task returns **200** with `"is_deleted": true` (not 404).
- `list` endpoints filter out `is_deleted` rows automatically — a deleted task won't appear in `GET /tasks`.
- Second `DELETE` on the same id returns 204 (idempotent).
- `close`/`reopen`/`update` on a deleted task typically fail or no-op; don't rely on it.
The bundled `todoist.py` detects `is_deleted: true` on any GET response, writes a warning to stderr, and exits with code **2** (stdout still contains the JSON body). Check the exit code, not just stdout, when you need to know whether the resource is live..apikey
我們原本以為 DELETE /tasks/{id} 之後再 GET 會拿到 404。結果是:
DELETE /tasks/6gR5FpG6QHmjw75C → 204
GET /tasks/6gR5FpG6QHmjw75C → 200 {"is_deleted": true, ...}
Todoist 是 soft-delete。這讓「GET 成功 ⇒ 資源存在」這個習慣假設失效。
解法在 out() helper:
if isinstance(obj, dict) and obj.get("is_deleted") is True:
print(f"warning: resource id={obj.get('id')} is soft-deleted (is_deleted=true)",
file=sys.stderr)
sys.exit(2)退出碼 2 讓 caller(Claude 或 shell script)能明確區分「活的資源」跟「墓碑」,stdout 仍然印 JSON body 供檢查。
其他 Todoist 的 soft-delete 小事實:
listendpoints 會自動過濾掉is_deleted——墓碑不會出現在GET /tasks結果裡。- 對已刪資源再
DELETE是 idempotent 的,回 204。 - 對已刪資源
close/reopen/update行為不一致,別依賴。
這是做 API wrapper 很典型的一類坑:文件不會主動告訴你,要實際跑一輪 CRUD round-trip 才會浮出來。
做 CRUD wrapper 一定要跑完整一輪才算驗收完。把這段存起來,每次改動後都可以跑一次:
set -e
TD=~/.claude/skills/todoist/scripts/todoist.py
# Create
TID=$(python3 "$TD" tasks add "[smoke-test] round-trip" --due "tomorrow 9am" --priority 2 \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
echo "created: $TID"
# Get
python3 "$TD" tasks get "$TID" > /dev/null && echo "get: ok"
# Update
python3 "$TD" tasks update "$TID" --priority 3 > /dev/null && echo "update: ok"
# Close / reopen
python3 "$TD" tasks close "$TID"
python3 "$TD" tasks reopen "$TID"
# Delete
python3 "$TD" tasks delete "$TID"
# Verify deleted (exit 2 expected)
set +e
python3 "$TD" tasks get "$TID" > /dev/null 2>&1
[ $? -eq 2 ] && echo "soft-delete detection: ok" || echo "soft-delete detection: FAIL"- 附件/檔案上傳:Todoist comment 支援 attachment,目前這個 wrapper 沒包——需要時用
curl或擴充comments_add。 - Pagination:
tasks list目前只回第一頁(一般用不到第二頁,200 筆以內)。真要時讀next_cursor加個--all旗標。 - Bulk operations:Todoist 的 Sync API (
/sync/v9/sync) 可以原子化多筆異動,適合「一次新增 20 個 task」這種場景。 - 跟其他 skill 組合:例如 mail skill 讀完信之後把 action item 批次丟進 Todoist。
- 個人自動化:
allowed-tools已經允許python3 *,可以在 Claude Code hook 裡把 commit message / MR title 等自動建成 task。
做這個 skill 前後花不到一小時,但中間有幾個判斷點其實很決定「好不好用」:
- 抗退化的鑰匙存放——env var + 檔案兩條路,加 gitignore。一開始就做好,不要等洩漏才補。
- 自己寫 verifier——不要只依賴「看起來跑得起來」。CRUD round-trip smoke test 的五分鐘會揪出 soft-delete 這種假設錯誤。
- Progressive disclosure 是真的——Claude 不用在每輪對話都扛 800 行 API 文件。我只讓他常駐一個
description+ ~80 行 SKILL.md,其他查references/。 - 中文觸發詞放心寫——Claude 的 skill 載入判斷是語意的,中英文混雜 description 有用。
Claude Code 的 skill 機制對「偶爾用、用起來要精準」的外部 API 特別合適。把你每天手動 curl 的 API 都包一個 skill 試試看。