clickup-cli 1.7.0__tar.gz → 1.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/CHANGELOG.md +9 -0
  2. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/FEATURE-GAP-ANALYSIS.md +3 -3
  3. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/INTEGRATION.md +7 -2
  4. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/PKG-INFO +11 -7
  5. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/README.md +10 -6
  6. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/__init__.py +1 -1
  7. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/client.py +19 -0
  8. clickup_cli-1.8.0/src/clickup_cli/commands/backup.py +162 -0
  9. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/folders.py +140 -1
  10. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/lists.py +60 -1
  11. clickup_cli-1.8.0/src/clickup_cli/commands/tags.py +343 -0
  12. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/tasks.py +2 -0
  13. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/tasks_internal/parser.py +65 -0
  14. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/tasks_internal/read.py +13 -1
  15. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/tasks_internal/write.py +154 -7
  16. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/tests/test_cli.py +126 -1
  17. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/tests/test_client.py +97 -0
  18. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/tests/test_commands_misc.py +229 -0
  19. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/tests/test_commands_spaces_lists_folders.py +257 -2
  20. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/tests/test_commands_tasks.py +233 -5
  21. clickup_cli-1.7.0/src/clickup_cli/commands/tags.py +0 -142
  22. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/.claude/agents/api-compatibility-checker.md +0 -0
  23. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/.claude/agents/test-writer.md +0 -0
  24. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/.claude/settings.json +0 -0
  25. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/.claude/skills/add-command.md +0 -0
  26. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/.claude/skills/changelog/SKILL.md +0 -0
  27. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/.claude/skills/clickup-cli.md +0 -0
  28. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/.claude/skills/release/SKILL.md +0 -0
  29. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/.claude/skills/validate-output/SKILL.md +0 -0
  30. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/.github/workflows/ci.yml +0 -0
  31. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/.gitignore +0 -0
  32. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/.mcp.json +0 -0
  33. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/CLAUDE.md +0 -0
  34. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/CODE_OF_CONDUCT.md +0 -0
  35. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/CONTRIBUTING.md +0 -0
  36. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/LICENSE +0 -0
  37. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/RELEASE.md +0 -0
  38. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/SECURITY.md +0 -0
  39. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/TROUBLESHOOTING.md +0 -0
  40. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/clickup-config.example.json +0 -0
  41. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/docs/superpowers/README.md +0 -0
  42. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/docs/superpowers/plans/2026-04-18-gsd-public-repo-autonomous-rollout.md +0 -0
  43. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/docs/superpowers/specs/2026-04-18-gsd-public-repo-autonomous-design.md +0 -0
  44. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/docs/superpowers/specs/2026-04-19-core-workflow-parity-design.md +0 -0
  45. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/pyproject.toml +0 -0
  46. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/scripts/validate-cli-output.sh +0 -0
  47. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/__main__.py +0 -0
  48. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/cli.py +0 -0
  49. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/__init__.py +0 -0
  50. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/comments.py +0 -0
  51. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/docs.py +0 -0
  52. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/fields.py +0 -0
  53. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/init.py +0 -0
  54. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/privacy.py +0 -0
  55. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/spaces.py +0 -0
  56. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/task_types.py +0 -0
  57. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/tasks_internal/__init__.py +0 -0
  58. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/tasks_internal/shared.py +0 -0
  59. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/commands/team.py +0 -0
  60. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/config.py +0 -0
  61. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/helpers.py +0 -0
  62. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/src/clickup_cli/runtime.py +0 -0
  63. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/tests/command_fakes.py +0 -0
  64. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/tests/conftest.py +0 -0
  65. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/tests/test_command_manifest.py +0 -0
  66. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/tests/test_commands_docs_comments.py +0 -0
  67. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/tests/test_commands_metadata.py +0 -0
  68. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/tests/test_config.py +0 -0
  69. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/tests/test_helpers.py +0 -0
  70. {clickup_cli-1.7.0 → clickup_cli-1.8.0}/tests/test_tasks_facade.py +0 -0
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 1.8.0 (2026-04-26)
6
+
7
+ - **Task output shaping and tagging workflows expanded.** `tasks get` now accepts `--fields` and `--full` alongside comment hydration flags, and `tasks create` supports repeatable `--tag` values applied after creation.
8
+ - **Migration and safety workflows are broader.** New `tasks bulk move` and `tasks bulk tags` commands provide dry-run-friendly bulk operations with resume-oriented failure output, while `lists backup`, `folders backup`, enriched delete dry-runs, and `folders purge-empty` add safer hierarchy cleanup paths.
9
+ - **Space tag management is now first-class.** `tags create`, `tags delete`, and `tags usage` cover Space-level tag lifecycle and usage auditing, including archived-list coverage fixes.
10
+ - **API resilience and destructive safety were tightened.** Safe GET requests retry transient 502/503/504 responses without changing 429 behavior, and bulk/tag/destructive validation now rejects unsafe or empty plans earlier.
11
+
3
12
  ## 1.7.0 (2026-04-20)
4
13
 
5
14
  - **Metadata discovery and custom-field lookup are easier to drive from the CLI.** New discovery flows cover field and task-type metadata, and task search can now target custom fields directly instead of forcing clients to pre-resolve everything out of band.
@@ -22,11 +22,11 @@ This document identifies the largest feature gaps between the current `clickup-c
22
22
 
23
23
  The repo already covers a solid core developer workflow surface:
24
24
 
25
- - Tasks: `list`, `get`, `create`, `update`, `search`, `delete`, `move`, `merge`, `depend`, linked-task management, multi-list operations
25
+ - Tasks: `list`, `get`, `create`, `update`, `search`, `delete`, `move`, `merge`, `bulk`, `depend`, linked-task management, multi-list operations, output shaping, create-time tags
26
26
  - Comments: task comments only via `list`, `add`, `update`, `delete`, `thread`, `reply`
27
27
  - Docs/pages: doc list/get/create, page list/get/create/edit
28
- - Hierarchy: spaces/folders/lists CRUD, statuses, privacy toggles
29
- - Metadata/admin-lite: fields, task types, tags, `whoami`, workspace members, bootstrap/init
28
+ - Hierarchy: spaces/folders/lists CRUD, statuses, privacy toggles, local backups, guarded empty-folder purge
29
+ - Metadata/admin-lite: fields, task types, Space tag lifecycle and usage audit, task tag add/remove, `whoami`, workspace members, bootstrap/init
30
30
 
31
31
  Depth is strongest in tasks and docs/pages. The CLI already mixes v2 and v3 pragmatically, which is a good foundation for further expansion.
32
32
 
@@ -12,8 +12,8 @@
12
12
  ## Output Modes
13
13
 
14
14
  - Default task list and search output is compact
15
- - `--fields` returns only the requested fields
16
- - `--full` returns full task objects with normalized status metadata
15
+ - `--fields` returns only the requested fields on task list/search/get flows
16
+ - `--full` returns full task objects with normalized status metadata where applicable
17
17
 
18
18
  ## Bounded Defaults
19
19
 
@@ -32,4 +32,9 @@
32
32
 
33
33
  - Validate response metadata instead of assuming a default scan is exhaustive
34
34
  - Use `--dry-run` before automating mutations in a new environment
35
+ - Use `tasks bulk ...` and backup commands for migration workflows that need resumable failure output or local JSON snapshots
35
36
  - Treat doc IDs and page IDs as different values
37
+
38
+ ## Reliability
39
+
40
+ - Safe GET requests retry transient 502/503/504 responses; 429 rate-limit handling remains explicit and separate
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clickup-cli
3
- Version: 1.7.0
3
+ Version: 1.8.0
4
4
  Summary: The missing ClickUp CLI. Built for developers and AI agents.
5
5
  Project-URL: Homepage, https://github.com/efetoker/clickup-cli
6
6
  Project-URL: Repository, https://github.com/efetoker/clickup-cli
@@ -111,15 +111,15 @@ Output is JSON on stdout; errors go to stderr.
111
111
  | Group | Subcommands | Description |
112
112
  |-------|-------------|-------------|
113
113
  | `init` | — | Interactive workspace setup |
114
- | `tasks` | list, get, create, update, search, delete, move, merge, lists, add-to-list, remove-from-list, depend, link | Full task CRUD + memberships, links, and dependencies |
114
+ | `tasks` | list, get, create, update, search, delete, move, merge, bulk, lists, add-to-list, remove-from-list, depend, link | Full task CRUD + memberships, links, dependencies, and migration-safe bulk operations |
115
115
  | `comments` | list, add, update, delete, thread, reply | Full comment CRUD with threading |
116
116
  | `docs` | list, get, create, pages, get-page, edit-page, create-page | Docs and page management |
117
117
  | `fields` | list | Custom field discovery for list or space scope |
118
- | `folders` | list, get, create, update, delete, privacy | Folder CRUD + privacy toggle |
119
- | `lists` | list, get, create, update, delete, privacy | List CRUD + privacy toggle |
118
+ | `folders` | list, get, create, update, delete, backup, purge-empty, privacy | Folder CRUD + backups, guarded empty-folder purge, and privacy toggle |
119
+ | `lists` | list, get, create, update, delete, backup, privacy | List CRUD + backups and privacy toggle |
120
120
  | `spaces` | list, get, create, update, delete, statuses, privacy | Full space CRUD + statuses + privacy toggle |
121
121
  | `team` | whoami, members | Workspace and member info |
122
- | `tags` | list, add, remove | Tag management |
122
+ | `tags` | list, create, delete, usage, add, remove | Space tag lifecycle, usage audit, and task tag management |
123
123
  | `task-types` | list | Workspace custom task type discovery |
124
124
 
125
125
  Use `clickup <group> <command> --help` for detailed usage, examples, and return format.
@@ -144,7 +144,7 @@ clickup tasks list --space <name> --pretty
144
144
 
145
145
  - **Flag aliases** — every positional argument also accepts a `--flag` form. `tasks get abc123` and `tasks get --task-id abc123` are equivalent. Same for `--query`, `--doc-id`, `--page-id`, `--folder-id`, `--list-id`, `--comment-id`, `--space`.
146
146
  - **Raw numeric IDs** on `--space` and `--folder` flags are accepted transparently alongside config aliases. On tasks commands, list-bound commands may resolve a raw space ID to its first folderless list via one API call.
147
- - **`tasks create`** auto-infers `--space` from `--list` via API lookup. You can omit `--space` if `--list` is provided, and create also supports start/due dates, time estimates, points, repeatable `--custom-field` values, and `--task-type`.
147
+ - **`tasks create`** auto-infers `--space` from `--list` via API lookup. You can omit `--space` if `--list` is provided, and create also supports start/due dates, time estimates, points, repeatable `--custom-field` values, repeatable `--tag` values, and `--task-type`.
148
148
  - **`tasks update`** handles core fields, assignee diffs (`--add-assignee` / `--remove-assignee`), tag diffs (`--add-tag` / `--remove-tag`), and custom fields (`--custom-field FIELD_ID=VALUE`) in one call. All flags are repeatable; `--dry-run` returns a structured plan.
149
149
  - **`tasks lists` / `tasks add-to-list` / `tasks remove-from-list`** let you inspect and manage multi-list task membership separately from home-list moves.
150
150
  - **`tasks link`** manages non-blocking linked-task relationships; use `tasks depend` for blocking dependencies.
@@ -153,8 +153,12 @@ clickup tasks list --space <name> --pretty
153
153
  - **`tasks list` / `tasks search --include-archived`** — second paginated call with `archived=true`, merged into the default results. Since ClickUp's `archived` param is a filter, this is the only way to see both in one command.
154
154
  - **`tasks list` / `tasks search` bounded defaults** — by default, these commands fetch a bounded aggregate scan and return `pages_fetched`, `results_complete`, and `results_truncated`. Use `--all-pages` for an exhaustive scan.
155
155
  - **`tasks list --full` / `tasks search --full`** return full task objects with a normalized `status` dict shape (`{status, color, type, orderindex}`), not just the compact projection.
156
- - **`tasks get`** auto-fetches comments and appends them to the output. By default this is a bounded slice and the response includes `comment_count_returned`, `comments_complete`, and `comments_truncated`. Use `--all-comments` for exhaustive comment hydration or `--no-comments` to skip it.
156
+ - **`tasks get`** auto-fetches comments and appends them to the output. By default this is a bounded slice and the response includes `comment_count_returned`, `comments_complete`, and `comments_truncated`. Use `--fields` to select specific task fields, `--full` to request the full task payload explicitly, `--all-comments` for exhaustive comment hydration, or `--no-comments` to skip it.
157
157
  - **`tasks search`** auto-detects task ID patterns like `PROJ-39` and applies prefix filtering.
158
+ - **`tasks bulk move` / `tasks bulk tags`** provide dry-run-friendly batch migration flows with resume-oriented failure details.
159
+ - **`lists backup` / `folders backup`** write local JSON backups with safety-first defaults before migration or deletion; `folders purge-empty` only deletes after exhaustive empty-folder proof.
160
+ - **Space tag lifecycle** — `tags create`, `tags delete`, and `tags usage` manage and audit Space-level tags; task-level `tags add` / `tags remove` remain available.
161
+ - **GET retries** — transient ClickUp 502/503/504 responses are retried only for safe GET requests; 429 rate-limit handling remains separate.
158
162
  - **`docs edit-page --append`** reads the current page content, appends your new content, and sends one update.
159
163
  - **Tag names** are auto-lowercased (ClickUp API stores them lowercase regardless of UI display).
160
164
  - **Doc ID ≠ page ID.** Always use `docs pages <doc_id>` to discover page IDs before using `get-page` or `edit-page`.
@@ -79,15 +79,15 @@ Output is JSON on stdout; errors go to stderr.
79
79
  | Group | Subcommands | Description |
80
80
  |-------|-------------|-------------|
81
81
  | `init` | — | Interactive workspace setup |
82
- | `tasks` | list, get, create, update, search, delete, move, merge, lists, add-to-list, remove-from-list, depend, link | Full task CRUD + memberships, links, and dependencies |
82
+ | `tasks` | list, get, create, update, search, delete, move, merge, bulk, lists, add-to-list, remove-from-list, depend, link | Full task CRUD + memberships, links, dependencies, and migration-safe bulk operations |
83
83
  | `comments` | list, add, update, delete, thread, reply | Full comment CRUD with threading |
84
84
  | `docs` | list, get, create, pages, get-page, edit-page, create-page | Docs and page management |
85
85
  | `fields` | list | Custom field discovery for list or space scope |
86
- | `folders` | list, get, create, update, delete, privacy | Folder CRUD + privacy toggle |
87
- | `lists` | list, get, create, update, delete, privacy | List CRUD + privacy toggle |
86
+ | `folders` | list, get, create, update, delete, backup, purge-empty, privacy | Folder CRUD + backups, guarded empty-folder purge, and privacy toggle |
87
+ | `lists` | list, get, create, update, delete, backup, privacy | List CRUD + backups and privacy toggle |
88
88
  | `spaces` | list, get, create, update, delete, statuses, privacy | Full space CRUD + statuses + privacy toggle |
89
89
  | `team` | whoami, members | Workspace and member info |
90
- | `tags` | list, add, remove | Tag management |
90
+ | `tags` | list, create, delete, usage, add, remove | Space tag lifecycle, usage audit, and task tag management |
91
91
  | `task-types` | list | Workspace custom task type discovery |
92
92
 
93
93
  Use `clickup <group> <command> --help` for detailed usage, examples, and return format.
@@ -112,7 +112,7 @@ clickup tasks list --space <name> --pretty
112
112
 
113
113
  - **Flag aliases** — every positional argument also accepts a `--flag` form. `tasks get abc123` and `tasks get --task-id abc123` are equivalent. Same for `--query`, `--doc-id`, `--page-id`, `--folder-id`, `--list-id`, `--comment-id`, `--space`.
114
114
  - **Raw numeric IDs** on `--space` and `--folder` flags are accepted transparently alongside config aliases. On tasks commands, list-bound commands may resolve a raw space ID to its first folderless list via one API call.
115
- - **`tasks create`** auto-infers `--space` from `--list` via API lookup. You can omit `--space` if `--list` is provided, and create also supports start/due dates, time estimates, points, repeatable `--custom-field` values, and `--task-type`.
115
+ - **`tasks create`** auto-infers `--space` from `--list` via API lookup. You can omit `--space` if `--list` is provided, and create also supports start/due dates, time estimates, points, repeatable `--custom-field` values, repeatable `--tag` values, and `--task-type`.
116
116
  - **`tasks update`** handles core fields, assignee diffs (`--add-assignee` / `--remove-assignee`), tag diffs (`--add-tag` / `--remove-tag`), and custom fields (`--custom-field FIELD_ID=VALUE`) in one call. All flags are repeatable; `--dry-run` returns a structured plan.
117
117
  - **`tasks lists` / `tasks add-to-list` / `tasks remove-from-list`** let you inspect and manage multi-list task membership separately from home-list moves.
118
118
  - **`tasks link`** manages non-blocking linked-task relationships; use `tasks depend` for blocking dependencies.
@@ -121,8 +121,12 @@ clickup tasks list --space <name> --pretty
121
121
  - **`tasks list` / `tasks search --include-archived`** — second paginated call with `archived=true`, merged into the default results. Since ClickUp's `archived` param is a filter, this is the only way to see both in one command.
122
122
  - **`tasks list` / `tasks search` bounded defaults** — by default, these commands fetch a bounded aggregate scan and return `pages_fetched`, `results_complete`, and `results_truncated`. Use `--all-pages` for an exhaustive scan.
123
123
  - **`tasks list --full` / `tasks search --full`** return full task objects with a normalized `status` dict shape (`{status, color, type, orderindex}`), not just the compact projection.
124
- - **`tasks get`** auto-fetches comments and appends them to the output. By default this is a bounded slice and the response includes `comment_count_returned`, `comments_complete`, and `comments_truncated`. Use `--all-comments` for exhaustive comment hydration or `--no-comments` to skip it.
124
+ - **`tasks get`** auto-fetches comments and appends them to the output. By default this is a bounded slice and the response includes `comment_count_returned`, `comments_complete`, and `comments_truncated`. Use `--fields` to select specific task fields, `--full` to request the full task payload explicitly, `--all-comments` for exhaustive comment hydration, or `--no-comments` to skip it.
125
125
  - **`tasks search`** auto-detects task ID patterns like `PROJ-39` and applies prefix filtering.
126
+ - **`tasks bulk move` / `tasks bulk tags`** provide dry-run-friendly batch migration flows with resume-oriented failure details.
127
+ - **`lists backup` / `folders backup`** write local JSON backups with safety-first defaults before migration or deletion; `folders purge-empty` only deletes after exhaustive empty-folder proof.
128
+ - **Space tag lifecycle** — `tags create`, `tags delete`, and `tags usage` manage and audit Space-level tags; task-level `tags add` / `tags remove` remain available.
129
+ - **GET retries** — transient ClickUp 502/503/504 responses are retried only for safe GET requests; 429 rate-limit handling remains separate.
126
130
  - **`docs edit-page --append`** reads the current page content, appends your new content, and sends one update.
127
131
  - **Tag names** are auto-lowercased (ClickUp API stores them lowercase regardless of UI display).
128
132
  - **Doc ID ≠ page ID.** Always use `docs pages <doc_id>` to discover page IDs before using `get-page` or `edit-page`.
@@ -1,3 +1,3 @@
1
1
  """ClickUp CLI — the missing ClickUp CLI for developers and AI agents."""
2
2
 
3
- __version__ = "1.7.0"
3
+ __version__ = "1.8.0"
@@ -13,6 +13,10 @@ from .runtime import RuntimeContext
13
13
  class ClickUpClient:
14
14
  BASE_V2 = "https://api.clickup.com/api/v2"
15
15
  BASE_V3 = "https://api.clickup.com/api/v3"
16
+ TRANSIENT_RETRY_STATUSES = {502, 503, 504}
17
+ TRANSIENT_RETRY_METHODS = {"GET"}
18
+ TRANSIENT_RETRY_ATTEMPTS = 3
19
+ TRANSIENT_RETRY_BACKOFF_SECONDS = 0.25
16
20
 
17
21
  def __init__(self, token, dry_run=False, debug=False, runtime=None):
18
22
  self.token = token
@@ -72,6 +76,21 @@ class ClickUpClient:
72
76
  time.sleep(wait)
73
77
  response = _do_request()
74
78
 
79
+ attempt = 1
80
+ while (
81
+ method in self.TRANSIENT_RETRY_METHODS
82
+ and response.status_code in self.TRANSIENT_RETRY_STATUSES
83
+ and attempt < self.TRANSIENT_RETRY_ATTEMPTS
84
+ ):
85
+ attempt += 1
86
+ self._log(
87
+ f" retrying transient {response.status_code} "
88
+ f"(attempt {attempt}/{self.TRANSIENT_RETRY_ATTEMPTS})"
89
+ )
90
+ time.sleep(self.TRANSIENT_RETRY_BACKOFF_SECONDS)
91
+ response = _do_request()
92
+ self._log(f" → {response.status_code} ({len(response.text)} bytes)")
93
+
75
94
  if response.status_code == 401:
76
95
  error(
77
96
  "Authentication failed — check your API token"
@@ -0,0 +1,162 @@
1
+ """Shared backup and destructive-safety helpers for list/folder commands."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from ..helpers import fetch_all_comments
7
+
8
+
9
+ def backup_options(args):
10
+ """Return safety-first backup options from argparse flags."""
11
+ return {
12
+ "include_closed": not getattr(args, "no_closed", False),
13
+ "include_archived": not getattr(args, "no_archived", False),
14
+ "subtasks": not getattr(args, "no_subtasks", False),
15
+ "all_pages": not getattr(args, "first_page", False),
16
+ "comments": not getattr(args, "no_comments", False),
17
+ }
18
+
19
+
20
+ def scan_list_tasks(client, list_id, options):
21
+ """Scan task summaries for a list using explicit completeness metadata."""
22
+ tasks = []
23
+ pages_fetched = 0
24
+ complete = True
25
+ passes = [{"archived": "false"}]
26
+ if options["include_archived"]:
27
+ passes.append({"archived": "true"})
28
+
29
+ for pass_params in passes:
30
+ page = 0
31
+ while True:
32
+ params = dict(pass_params)
33
+ params["page"] = str(page)
34
+ if options["include_closed"]:
35
+ params["include_closed"] = "true"
36
+ if options["subtasks"]:
37
+ params["subtasks"] = "true"
38
+
39
+ response = client.get_v2(
40
+ f"/list/{list_id}/task", params=params, allow_dry_run=True
41
+ )
42
+ pages_fetched += 1
43
+ tasks.extend(response.get("tasks", []))
44
+
45
+ if response.get("last_page", False):
46
+ break
47
+ if not options["all_pages"]:
48
+ complete = False
49
+ break
50
+ page += 1
51
+
52
+ deduped = list({task.get("id"): task for task in tasks if task.get("id")}.values())
53
+ return {
54
+ "tasks": deduped,
55
+ "task_ids": [task["id"] for task in deduped],
56
+ "count": len(deduped),
57
+ "pages_fetched": pages_fetched,
58
+ "complete": complete,
59
+ }
60
+
61
+
62
+ def count_list_tasks(client, list_id):
63
+ """Count all active, closed, archived, and subtask items for safety checks."""
64
+ options = {
65
+ "include_closed": True,
66
+ "include_archived": True,
67
+ "subtasks": True,
68
+ "all_pages": True,
69
+ "comments": False,
70
+ }
71
+ scan = scan_list_tasks(client, list_id, options)
72
+ return {
73
+ "total": scan["count"],
74
+ "task_ids": scan["task_ids"],
75
+ "pages_fetched": scan["pages_fetched"],
76
+ "complete": scan["complete"],
77
+ }
78
+
79
+
80
+ def write_json(path, payload):
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
83
+
84
+
85
+ def backup_list(client, list_id, output_dir, options, *, prefix=""):
86
+ """Write a deterministic backup for one list and return manifest details."""
87
+ root = Path(output_dir)
88
+ list_meta = client.get_v2(f"/list/{list_id}", allow_dry_run=True)
89
+ scan = scan_list_tasks(client, list_id, options)
90
+
91
+ files = []
92
+ list_file = root / "list.json"
93
+ tasks_file = root / "tasks.json"
94
+ write_json(list_file, list_meta)
95
+ write_json(tasks_file, scan["tasks"])
96
+ files.extend([f"{prefix}list.json", f"{prefix}tasks.json"])
97
+
98
+ comments_complete = True
99
+ for task_id in scan["task_ids"]:
100
+ task = client.get_v2(f"/task/{task_id}", allow_dry_run=True)
101
+ if options["comments"]:
102
+ comment_result = fetch_all_comments(client, task_id, all_pages=True)
103
+ task["comments"] = comment_result["comments"]
104
+ task["comments_complete"] = comment_result["complete"]
105
+ comments_complete = comments_complete and comment_result["complete"]
106
+ task_file = root / "tasks" / f"{task_id}.json"
107
+ write_json(task_file, task)
108
+ files.append(f"{prefix}tasks/{task_id}.json")
109
+
110
+ manifest = {
111
+ "type": "list_backup",
112
+ "list_id": list_id,
113
+ "task_ids": scan["task_ids"],
114
+ "task_count": scan["count"],
115
+ "options": options,
116
+ "complete": scan["complete"] and comments_complete,
117
+ "tasks_complete": scan["complete"],
118
+ "comments_complete": comments_complete,
119
+ "pages_fetched": scan["pages_fetched"],
120
+ "files": files,
121
+ }
122
+ write_json(root / "manifest.json", manifest)
123
+ return manifest
124
+
125
+
126
+ def count_folder_tasks(client, folder):
127
+ """Count all tasks in every child list included by folder metadata."""
128
+ list_counts = []
129
+ total = 0
130
+ task_ids = []
131
+ complete = True
132
+ pages_fetched = 0
133
+ for child_list in folder_child_lists(client, folder, include_archived=True):
134
+ count = count_list_tasks(client, child_list["id"])
135
+ list_counts.append({"list_id": child_list["id"], **count})
136
+ total += count["total"]
137
+ task_ids.extend(count["task_ids"])
138
+ complete = complete and count["complete"]
139
+ pages_fetched += count["pages_fetched"]
140
+ return {
141
+ "total": total,
142
+ "task_ids": task_ids,
143
+ "lists": list_counts,
144
+ "pages_fetched": pages_fetched,
145
+ "complete": complete,
146
+ }
147
+
148
+
149
+ def folder_child_lists(client, folder, include_archived=False):
150
+ """Return folder child lists, optionally adding archived lists not in metadata."""
151
+ child_lists = list(folder.get("lists", []))
152
+ if include_archived and folder.get("id"):
153
+ archived_response = client.get_v2(
154
+ f"/folder/{folder['id']}/list",
155
+ params={"archived": "true"},
156
+ allow_dry_run=True,
157
+ )
158
+ child_lists.extend(archived_response.get("lists", []))
159
+ deduped = {}
160
+ for child_list in child_lists:
161
+ deduped.setdefault(child_list["id"], child_list)
162
+ return list(deduped.values())
@@ -1,6 +1,7 @@
1
1
  """Folder command handlers — list, get, create, update, delete, privacy."""
2
2
 
3
3
  from ..helpers import error, resolve_space_id, add_id_argument
4
+ from .backup import backup_list, backup_options, count_folder_tasks, folder_child_lists, write_json
4
5
  from .privacy import handle_privacy_request, register_privacy_subcommand
5
6
 
6
7
 
@@ -22,6 +23,8 @@ Subcommands:
22
23
  create — create a new folder in a space (mutating)
23
24
  update — update a folder's name (mutating)
24
25
  delete — delete a folder (destructive)
26
+ backup — export folder/list metadata and tasks to local JSON files
27
+ purge-empty — delete only if exhaustive scans prove the folder is empty
25
28
  privacy — make a folder private or public (mutating)
26
29
 
27
30
  Does not cover: reordering folders or setting folder-level statuses
@@ -158,6 +161,54 @@ examples:
158
161
  )
159
162
  add_id_argument(fd, "folder_id", "ClickUp folder ID to delete")
160
163
 
164
+ fb = folders_sub.add_parser(
165
+ "backup",
166
+ formatter_class=F,
167
+ help="Back up a folder and child lists to local JSON files",
168
+ description="""\
169
+ Back up a folder before migration or deletion. Writes folder metadata,
170
+ per-list backups, per-task full JSON, and a deterministic manifest.json to
171
+ --output-dir.
172
+
173
+ Defaults are safety-first: include closed tasks, archived tasks, subtasks,
174
+ all task pages, and all comments unless explicitly disabled.""",
175
+ epilog="""\
176
+ returns:
177
+ {"status": "ok", "action": "backup_folder", "folder_id": "...", ...}
178
+
179
+ examples:
180
+ clickup folders backup 12345 --output-dir ./backup/folder-12345
181
+ clickup folders backup --folder-id 12345 --output-dir ./backup --no-comments
182
+
183
+ notes:
184
+ This command writes local files and does not mutate ClickUp.""",
185
+ )
186
+ add_id_argument(fb, "folder_id", "ClickUp folder ID to back up")
187
+ fb.add_argument("--output-dir", required=True, help="Directory for backup JSON files")
188
+ fb.add_argument("--no-closed", action="store_true", help="Do not include closed tasks")
189
+ fb.add_argument("--no-archived", action="store_true", help="Do not include archived tasks")
190
+ fb.add_argument("--no-subtasks", action="store_true", help="Do not include subtasks")
191
+ fb.add_argument("--first-page", action="store_true", help="Only fetch the first task page")
192
+ fb.add_argument("--no-comments", action="store_true", help="Do not hydrate task comments")
193
+
194
+ fpe = folders_sub.add_parser(
195
+ "purge-empty",
196
+ formatter_class=F,
197
+ help="Delete a folder only after proving every child list is empty",
198
+ description="""\
199
+ Exhaustively scans every child list for active, closed, archived, and subtask
200
+ items. The folder is deleted only when all scans are complete and zero tasks
201
+ are found. Use --dry-run to preview the proof without deleting.""",
202
+ epilog="""\
203
+ returns:
204
+ {"status": "ok", "action": "purged_empty_folder", "folder_id": "..."}
205
+
206
+ examples:
207
+ clickup --dry-run folders purge-empty 12345
208
+ clickup folders purge-empty 12345""",
209
+ )
210
+ add_id_argument(fpe, "folder_id", "ClickUp folder ID to purge if empty")
211
+
161
212
  register_privacy_subcommand(
162
213
  folders_sub,
163
214
  F,
@@ -230,12 +281,98 @@ def cmd_folders_update(client, args):
230
281
  def cmd_folders_delete(client, args):
231
282
  """Delete a folder by ID."""
232
283
  if client.dry_run:
233
- return {"dry_run": True, "action": "delete_folder", "folder_id": args.folder_id}
284
+ folder = client.get_v2(f"/folder/{args.folder_id}", allow_dry_run=True)
285
+ task_counts = count_folder_tasks(client, folder)
286
+ return {
287
+ "dry_run": True,
288
+ "action": "delete_folder",
289
+ "folder_id": args.folder_id,
290
+ "folder": folder,
291
+ "lists": folder.get("lists", []),
292
+ "task_counts": task_counts,
293
+ }
234
294
 
235
295
  client.delete_v2(f"/folder/{args.folder_id}")
236
296
  return {"status": "ok", "action": "deleted", "folder_id": args.folder_id}
237
297
 
238
298
 
299
+ def cmd_folders_backup(client, args):
300
+ """Back up a folder and its child lists to local JSON files."""
301
+ from pathlib import Path
302
+
303
+ root = Path(args.output_dir)
304
+ options = backup_options(args)
305
+ folder = client.get_v2(f"/folder/{args.folder_id}", allow_dry_run=True)
306
+ write_json(root / "folder.json", folder)
307
+
308
+ files = ["folder.json"]
309
+ list_ids = []
310
+ task_ids = []
311
+ task_count = 0
312
+ complete = True
313
+ list_manifests = []
314
+ for child_list in folder_child_lists(client, folder, include_archived=options["include_archived"]):
315
+ list_id = child_list["id"]
316
+ list_ids.append(list_id)
317
+ list_manifest = backup_list(
318
+ client,
319
+ list_id,
320
+ root / "lists" / list_id,
321
+ options,
322
+ prefix=f"lists/{list_id}/",
323
+ )
324
+ list_manifests.append(list_manifest)
325
+ task_ids.extend(list_manifest["task_ids"])
326
+ task_count += list_manifest["task_count"]
327
+ complete = complete and list_manifest["complete"]
328
+ files.extend(list_manifest["files"])
329
+
330
+ manifest = {
331
+ "type": "folder_backup",
332
+ "folder_id": args.folder_id,
333
+ "list_ids": list_ids,
334
+ "task_ids": task_ids,
335
+ "task_count": task_count,
336
+ "options": options,
337
+ "complete": complete,
338
+ "lists": list_manifests,
339
+ "files": files,
340
+ }
341
+ write_json(root / "manifest.json", manifest)
342
+ return {
343
+ "status": "ok",
344
+ "action": "backup_folder",
345
+ "folder_id": args.folder_id,
346
+ "output_dir": args.output_dir,
347
+ "manifest": "manifest.json",
348
+ "task_count": task_count,
349
+ "complete": complete,
350
+ }
351
+
352
+
353
+ def cmd_folders_purge_empty(client, args):
354
+ """Delete a folder only after exhaustive scans prove it is empty."""
355
+ folder = client.get_v2(f"/folder/{args.folder_id}", allow_dry_run=True)
356
+ task_counts = count_folder_tasks(client, folder)
357
+ if not task_counts["complete"]:
358
+ error("Cannot purge folder: exhaustive task scan did not complete")
359
+ if task_counts["total"] > 0:
360
+ error("Cannot purge folder: child lists contain tasks")
361
+
362
+ if client.dry_run:
363
+ return {
364
+ "dry_run": True,
365
+ "action": "purge_empty_folder",
366
+ "folder_id": args.folder_id,
367
+ "deletable": True,
368
+ "folder": folder,
369
+ "task_counts": task_counts,
370
+ }
371
+
372
+ client.delete_v2(f"/folder/{args.folder_id}")
373
+ return {"status": "ok", "action": "purged_empty_folder", "folder_id": args.folder_id}
374
+
375
+
239
376
  def cmd_folders_privacy(client, args):
240
377
  """Set a folder private or public via the v3 ACLs endpoint."""
241
378
  return handle_privacy_request(
@@ -255,6 +392,8 @@ COMMAND_MANIFEST = {
255
392
  "create": cmd_folders_create,
256
393
  "update": cmd_folders_update,
257
394
  "delete": cmd_folders_delete,
395
+ "backup": cmd_folders_backup,
396
+ "purge-empty": cmd_folders_purge_empty,
258
397
  "privacy": cmd_folders_privacy,
259
398
  },
260
399
  }
@@ -1,6 +1,7 @@
1
1
  """List command handlers — list, get, create, update, delete, privacy."""
2
2
 
3
3
  from ..helpers import read_content, error, resolve_space_id, add_id_argument
4
+ from .backup import backup_list, backup_options, count_list_tasks
4
5
  from .privacy import handle_privacy_request, register_privacy_subcommand
5
6
 
6
7
 
@@ -22,6 +23,7 @@ Subcommands:
22
23
  create — create a new list in a folder or space (mutating)
23
24
  update — update a list's name, content, or status (mutating)
24
25
  delete — delete a list (destructive)
26
+ backup — export list metadata, tasks, and comments to local JSON files
25
27
  privacy — make a list private or public (mutating)""",
26
28
  epilog="""\
27
29
  examples:
@@ -199,6 +201,35 @@ examples:
199
201
  )
200
202
  add_id_argument(ld, "list_id", "ClickUp list ID to delete")
201
203
 
204
+ lb = lists_sub.add_parser(
205
+ "backup",
206
+ formatter_class=F,
207
+ help="Back up a list to local JSON files",
208
+ description="""\
209
+ Back up a list before migration or deletion. Writes list metadata, task-list
210
+ JSON, per-task full JSON, and a deterministic manifest.json to --output-dir.
211
+
212
+ Defaults are safety-first: include closed tasks, archived tasks, subtasks,
213
+ all task pages, and all comments unless explicitly disabled.""",
214
+ epilog="""\
215
+ returns:
216
+ {"status": "ok", "action": "backup_list", "list_id": "...", ...}
217
+
218
+ examples:
219
+ clickup lists backup 12345 --output-dir ./backup/list-12345
220
+ clickup lists backup --list-id 12345 --output-dir ./backup --no-comments
221
+
222
+ notes:
223
+ This command writes local files and does not mutate ClickUp.""",
224
+ )
225
+ add_id_argument(lb, "list_id", "ClickUp list ID to back up")
226
+ lb.add_argument("--output-dir", required=True, help="Directory for backup JSON files")
227
+ lb.add_argument("--no-closed", action="store_true", help="Do not include closed tasks")
228
+ lb.add_argument("--no-archived", action="store_true", help="Do not include archived tasks")
229
+ lb.add_argument("--no-subtasks", action="store_true", help="Do not include subtasks")
230
+ lb.add_argument("--first-page", action="store_true", help="Only fetch the first task page")
231
+ lb.add_argument("--no-comments", action="store_true", help="Do not hydrate task comments")
232
+
202
233
  register_privacy_subcommand(
203
234
  lists_sub,
204
235
  F,
@@ -292,12 +323,39 @@ def cmd_lists_update(client, args):
292
323
  def cmd_lists_delete(client, args):
293
324
  """Delete a list by ID."""
294
325
  if client.dry_run:
295
- return {"dry_run": True, "action": "delete_list", "list_id": args.list_id}
326
+ list_meta = client.get_v2(f"/list/{args.list_id}", allow_dry_run=True)
327
+ task_counts = count_list_tasks(client, args.list_id)
328
+ return {
329
+ "dry_run": True,
330
+ "action": "delete_list",
331
+ "list_id": args.list_id,
332
+ "list": list_meta,
333
+ "task_counts": task_counts,
334
+ }
296
335
 
297
336
  client.delete_v2(f"/list/{args.list_id}")
298
337
  return {"status": "ok", "action": "deleted", "list_id": args.list_id}
299
338
 
300
339
 
340
+ def cmd_lists_backup(client, args):
341
+ """Back up a list to local JSON files."""
342
+ manifest = backup_list(
343
+ client,
344
+ args.list_id,
345
+ args.output_dir,
346
+ backup_options(args),
347
+ )
348
+ return {
349
+ "status": "ok",
350
+ "action": "backup_list",
351
+ "list_id": args.list_id,
352
+ "output_dir": args.output_dir,
353
+ "manifest": "manifest.json",
354
+ "task_count": manifest["task_count"],
355
+ "complete": manifest["complete"],
356
+ }
357
+
358
+
301
359
  def cmd_lists_privacy(client, args):
302
360
  """Set a list private or public via the v3 ACLs endpoint."""
303
361
  return handle_privacy_request(
@@ -317,6 +375,7 @@ COMMAND_MANIFEST = {
317
375
  "create": cmd_lists_create,
318
376
  "update": cmd_lists_update,
319
377
  "delete": cmd_lists_delete,
378
+ "backup": cmd_lists_backup,
320
379
  "privacy": cmd_lists_privacy,
321
380
  },
322
381
  }