clickup-cli 1.2.0__py3-none-any.whl

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.
@@ -0,0 +1,114 @@
1
+ """Team command handlers — whoami, members."""
2
+
3
+ from ..config import WORKSPACE_ID
4
+
5
+
6
+ def register_parser(subparsers, F):
7
+ """Register all team subcommands on the given subparsers object."""
8
+ team_parser = subparsers.add_parser(
9
+ "team",
10
+ formatter_class=F,
11
+ help="Workspace and team member information",
12
+ description="""\
13
+ Inspect workspace and team details — authenticated user info and member list.
14
+
15
+ Subcommands:
16
+ whoami — show workspace info and authenticated user context
17
+ members — list all members of the workspace
18
+
19
+ All commands are read-only.""",
20
+ epilog="""\
21
+ examples:
22
+ clickup team whoami
23
+ clickup team members
24
+ clickup --pretty team whoami""",
25
+ )
26
+ team_sub = team_parser.add_subparsers(dest="command", required=True)
27
+
28
+ # team whoami
29
+ team_sub.add_parser(
30
+ "whoami",
31
+ formatter_class=F,
32
+ help="Show workspace info and member context",
33
+ description="""\
34
+ Show the workspace name, ID, and all members visible to the authenticated
35
+ user. Use this as a quick sanity check to confirm which workspace and
36
+ identity the CLI is operating under.""",
37
+ epilog="""\
38
+ returns:
39
+ {"workspace": {"id": ..., "name": ...}, "members": [...], "member_count": N}
40
+
41
+ examples:
42
+ clickup team whoami
43
+ clickup --pretty team whoami""",
44
+ )
45
+
46
+ # team members
47
+ team_sub.add_parser(
48
+ "members",
49
+ formatter_class=F,
50
+ help="List all workspace members",
51
+ description="""\
52
+ List all members of the workspace with their IDs, usernames, emails,
53
+ and roles. Use this to discover user IDs for task assignment or to
54
+ verify team membership.""",
55
+ epilog="""\
56
+ returns:
57
+ {"members": [...], "count": N}
58
+
59
+ Each member has: id, username, email, role, initials.
60
+
61
+ examples:
62
+ clickup team members
63
+ clickup --pretty team members""",
64
+ )
65
+
66
+
67
+ def _get_workspace(client):
68
+ """Fetch teams and find the configured workspace."""
69
+ resp = client.get_v2("/team")
70
+ teams = resp.get("teams", [])
71
+ for team in teams:
72
+ if str(team.get("id")) == WORKSPACE_ID:
73
+ return team
74
+ # Shouldn't happen, but return first team if workspace ID doesn't match
75
+ if teams:
76
+ return teams[0]
77
+ return resp
78
+
79
+
80
+ def _format_member(m):
81
+ """Extract user fields from a member object."""
82
+ u = m.get("user", {})
83
+ return {
84
+ "id": u.get("id"),
85
+ "username": u.get("username"),
86
+ "email": u.get("email"),
87
+ "role": u.get("role_key"),
88
+ "initials": u.get("initials"),
89
+ }
90
+
91
+
92
+ def cmd_team_whoami(client, args):
93
+ """Show authenticated user and workspace info."""
94
+ team = _get_workspace(client)
95
+ members = team.get("members", [])
96
+ return {
97
+ "workspace": {
98
+ "id": team.get("id"),
99
+ "name": team.get("name"),
100
+ "color": team.get("color"),
101
+ },
102
+ "members": [_format_member(m) for m in members],
103
+ "member_count": len(members),
104
+ }
105
+
106
+
107
+ def cmd_team_members(client, args):
108
+ """List workspace members."""
109
+ team = _get_workspace(client)
110
+ members = team.get("members", [])
111
+ return {
112
+ "members": [_format_member(m) for m in members],
113
+ "count": len(members),
114
+ }
clickup_cli/config.py ADDED
@@ -0,0 +1,200 @@
1
+ """ClickUp CLI configuration — lazy-loaded from JSON file or environment variables.
2
+
3
+ Config resolution order:
4
+ 1. CLICKUP_CONFIG_PATH env var → exact file path
5
+ 2. ~/.config/clickup-cli/config.json → XDG-ish default
6
+ 3. clickup-config.json in current working directory → project-local override
7
+ 4. Environment variables only (CLICKUP_API_TOKEN + CLICKUP_WORKSPACE_ID)
8
+
9
+ workspace_id is auto-detected from the API when missing (single-workspace accounts).
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import sys
15
+
16
+ from .helpers import error
17
+
18
+ _config_cache = None
19
+
20
+
21
+ def _auto_detect_workspace(token):
22
+ """Auto-detect workspace ID when only a token is available.
23
+
24
+ Returns workspace_id string. Exits with error if detection fails.
25
+ """
26
+ import requests
27
+
28
+ print("Auto-detecting workspace...", file=sys.stderr)
29
+ headers = {"Authorization": token, "Content-Type": "application/json"}
30
+
31
+ try:
32
+ resp = requests.get(
33
+ "https://api.clickup.com/api/v2/team", headers=headers, timeout=15
34
+ )
35
+ except requests.ConnectionError:
36
+ error("Could not reach ClickUp API to auto-detect workspace")
37
+
38
+ if resp.status_code == 401:
39
+ error("Authentication failed — check your API token")
40
+
41
+ if not resp.ok:
42
+ error(f"API error {resp.status_code} while detecting workspace: {resp.text}")
43
+
44
+ teams = resp.json().get("teams", [])
45
+
46
+ if not teams:
47
+ error("No workspaces found for this token")
48
+
49
+ if len(teams) == 1:
50
+ workspace_id = str(teams[0]["id"])
51
+ print(
52
+ f"Found workspace: {teams[0]['name']} (ID: {workspace_id})",
53
+ file=sys.stderr,
54
+ )
55
+ return workspace_id
56
+
57
+ # Multiple workspaces — can't auto-select
58
+ lines = ["Multiple workspaces found — set workspace_id manually:"]
59
+ for t in teams:
60
+ lines.append(f" {t['name']}: {t['id']}")
61
+ lines.append("\nSet it in your config file or via: export CLICKUP_WORKSPACE_ID=<id>")
62
+ error("\n".join(lines))
63
+
64
+
65
+ def _save_field_to_config(path, field, value):
66
+ """Update a single field in an existing config file."""
67
+ try:
68
+ with open(path, "r", encoding="utf-8") as f:
69
+ config = json.load(f)
70
+ config[field] = value
71
+ with open(path, "w", encoding="utf-8") as f:
72
+ json.dump(config, f, indent=2, ensure_ascii=False)
73
+ f.write("\n")
74
+ print(f"Saved {field} to {path}", file=sys.stderr)
75
+ except (OSError, json.JSONDecodeError):
76
+ pass # Non-critical — config works in memory even if save fails
77
+
78
+
79
+ def _find_config_path():
80
+ """Find config file using the fallback chain. Returns path or None."""
81
+ # 1. Explicit env var
82
+ env_path = os.environ.get("CLICKUP_CONFIG_PATH")
83
+ if env_path:
84
+ if os.path.exists(env_path):
85
+ return env_path
86
+ error(f"CLICKUP_CONFIG_PATH points to a missing file: {env_path}")
87
+
88
+ # 2. XDG-ish default
89
+ xdg_path = os.path.expanduser("~/.config/clickup-cli/config.json")
90
+ if os.path.exists(xdg_path):
91
+ return xdg_path
92
+
93
+ # 3. Current working directory
94
+ cwd_path = os.path.join(os.getcwd(), "clickup-config.json")
95
+ if os.path.exists(cwd_path):
96
+ return cwd_path
97
+
98
+ return None
99
+
100
+
101
+ def _load_from_file(path):
102
+ """Load and validate config from a JSON file."""
103
+ with open(path, "r", encoding="utf-8") as f:
104
+ try:
105
+ config = json.load(f)
106
+ except json.JSONDecodeError as e:
107
+ error(f"Invalid JSON in {path}: {e}")
108
+
109
+ # Allow env var to override token from file
110
+ env_token = os.environ.get("CLICKUP_API_TOKEN")
111
+ if env_token:
112
+ config["api_token"] = env_token
113
+
114
+ if not config.get("api_token"):
115
+ error(
116
+ f"Missing required field in {path}: api_token\n"
117
+ "See clickup-config.example.json for the expected schema."
118
+ )
119
+
120
+ # Auto-detect workspace_id if missing
121
+ if not config.get("workspace_id"):
122
+ config["workspace_id"] = _auto_detect_workspace(config["api_token"])
123
+ _save_field_to_config(path, "workspace_id", config["workspace_id"])
124
+
125
+ return config
126
+
127
+
128
+ def _load_from_env():
129
+ """Build minimal config from environment variables only."""
130
+ token = os.environ.get("CLICKUP_API_TOKEN")
131
+ if not token:
132
+ return None
133
+
134
+ workspace_id = os.environ.get("CLICKUP_WORKSPACE_ID")
135
+ if not workspace_id:
136
+ workspace_id = _auto_detect_workspace(token)
137
+
138
+ return {
139
+ "api_token": token,
140
+ "workspace_id": workspace_id,
141
+ "user_id": os.environ.get("CLICKUP_USER_ID", ""),
142
+ "spaces": {},
143
+ }
144
+
145
+
146
+ def load_config():
147
+ """Load config from file or environment. Caches after first call."""
148
+ global _config_cache
149
+ if _config_cache is not None:
150
+ return _config_cache
151
+
152
+ path = _find_config_path()
153
+
154
+ if path:
155
+ _config_cache = _load_from_file(path)
156
+ return _config_cache
157
+
158
+ env_config = _load_from_env()
159
+ if env_config:
160
+ _config_cache = env_config
161
+ return _config_cache
162
+
163
+ error(
164
+ "No ClickUp configuration found.\n\n"
165
+ "Set up with one of:\n"
166
+ " clickup init # interactive setup\n"
167
+ " clickup init --token pk_YOUR_TOKEN # non-interactive setup\n\n"
168
+ "Or configure manually:\n"
169
+ " 1. Copy clickup-config.example.json to ~/.config/clickup-cli/config.json\n"
170
+ " 2. Fill in your API token and workspace ID\n\n"
171
+ "Or set environment variables:\n"
172
+ " export CLICKUP_API_TOKEN=pk_YOUR_TOKEN\n"
173
+ " export CLICKUP_WORKSPACE_ID=YOUR_WORKSPACE_ID"
174
+ )
175
+
176
+
177
+ def _reset():
178
+ """Reset cached config (for testing)."""
179
+ global _config_cache
180
+ _config_cache = None
181
+
182
+
183
+ # Lazy attribute access — config is loaded on first use, not at import time.
184
+ # This allows `clickup init` to run before any config file exists.
185
+ _ATTR_MAP = {
186
+ "WORKSPACE_ID": lambda c: c["workspace_id"],
187
+ "USER_ID": lambda c: c.get("user_id", ""),
188
+ "SPACES": lambda c: c.get("spaces", {}),
189
+ "DEFAULT_TAGS": lambda c: c.get("default_tags", []),
190
+ "DRAFT_TAG": lambda c: c.get("draft_tag", "draft"),
191
+ "GOOD_AS_IS_TAG": lambda c: c.get("good_as_is_tag", "good as is"),
192
+ "DEFAULT_PRIORITY": lambda c: c.get("default_priority", 4),
193
+ }
194
+
195
+
196
+ def __getattr__(name):
197
+ if name in _ATTR_MAP:
198
+ config = load_config()
199
+ return _ATTR_MAP[name](config)
200
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
clickup_cli/helpers.py ADDED
@@ -0,0 +1,163 @@
1
+ """Shared helper functions for output, errors, and content reading."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+
8
+ def add_id_argument(parser, name, help_text):
9
+ """Add an argument that accepts both positional and --flag forms.
10
+
11
+ Agents naturally use flag forms (--task-id) while humans prefer positional.
12
+ This adds both so either style works.
13
+
14
+ Example:
15
+ add_id_argument(parser, 'task_id', 'ClickUp task ID')
16
+ # Accepts: `command abc123` OR `command --task-id abc123`
17
+ """
18
+ parser.add_argument(name, nargs="?", default=None, help=help_text)
19
+ flag = f"--{name.replace('_', '-')}"
20
+ parser.add_argument(
21
+ flag, dest=f"_{name}_flag", default=None, help=argparse.SUPPRESS
22
+ )
23
+
24
+
25
+ def resolve_id_args(args):
26
+ """Resolve positional-or-flag ID arguments after parsing.
27
+
28
+ For each _<name>_flag attribute, merges with the positional <name>:
29
+ - If flag provided, use it
30
+ - If positional provided, use it
31
+ - If both provided, error
32
+ - If neither provided, error with helpful message
33
+ """
34
+ flag_attrs = [a for a in vars(args) if a.endswith("_flag") and a.startswith("_")]
35
+ for flag_attr in flag_attrs:
36
+ base_attr = flag_attr[1:-5] # strip _ prefix and _flag suffix
37
+ flag_val = getattr(args, flag_attr)
38
+ pos_val = getattr(args, base_attr, None)
39
+ flag_name = f"--{base_attr.replace('_', '-')}"
40
+
41
+ if flag_val is not None and pos_val is not None:
42
+ error(f"Provide {base_attr} as positional or {flag_name}, not both")
43
+
44
+ if flag_val is not None:
45
+ setattr(args, base_attr, flag_val)
46
+ elif pos_val is None:
47
+ error(f"Missing required argument: {base_attr} (positional or {flag_name})")
48
+
49
+ delattr(args, flag_attr)
50
+
51
+
52
+ def output(data, pretty=False):
53
+ """Print JSON to stdout."""
54
+ print(json.dumps(data, indent=2 if pretty else None, ensure_ascii=False))
55
+
56
+
57
+ def error(msg):
58
+ """Print error to stderr and exit."""
59
+ print(f"Error: {msg}", file=sys.stderr)
60
+ sys.exit(1)
61
+
62
+
63
+ def read_content(inline, file_path, flag_name="--content"):
64
+ """Read content from inline string or file path. Returns string or None."""
65
+ if inline and file_path:
66
+ error(f"Cannot use both {flag_name} and {flag_name}-file")
67
+ if file_path:
68
+ try:
69
+ with open(file_path, "r", encoding="utf-8") as f:
70
+ return f.read()
71
+ except FileNotFoundError:
72
+ error(f"File not found: {file_path}")
73
+ return inline
74
+
75
+
76
+ def resolve_space_id(space_arg):
77
+ """Resolve a space name (from config) or raw space ID."""
78
+ from .config import SPACES
79
+
80
+ if space_arg in SPACES:
81
+ return SPACES[space_arg]["space_id"]
82
+ # If it's not numeric, it's likely a misspelled config name
83
+ if not space_arg.isdigit():
84
+ available = ", ".join(sorted(SPACES.keys())) if SPACES else "(none configured)"
85
+ error(f"Unknown space: {space_arg}. Available: {available}")
86
+ return space_arg
87
+
88
+
89
+ def fetch_all_comments(client, task_id):
90
+ """Fetch all comments for a task, paginating through all pages."""
91
+ all_comments = []
92
+ params = None
93
+ while True:
94
+ resp = client.get_v2(f"/task/{task_id}/comment", params=params)
95
+ comments = resp.get("comments", [])
96
+ if not comments:
97
+ break
98
+ all_comments.extend(comments)
99
+ last = comments[-1]
100
+ params = {"start": str(last["date"]), "start_id": last["id"]}
101
+ return all_comments
102
+
103
+
104
+ # Default fields for compact task output
105
+ COMPACT_FIELDS = ["id", "name", "status", "priority", "url"]
106
+
107
+ # Reverse map for priority numbers to names
108
+ PRIORITY_NAMES = {1: "urgent", 2: "high", 3: "normal", 4: "low"}
109
+
110
+
111
+ def _extract_status(task):
112
+ """Extract the status string from a task's status field."""
113
+ status = task.get("status")
114
+ return status.get("status") if isinstance(status, dict) else status
115
+
116
+
117
+ def _extract_priority(task):
118
+ """Extract the priority string from a task's priority field."""
119
+ priority = task.get("priority")
120
+ if isinstance(priority, dict) and priority:
121
+ return priority.get("priority") or PRIORITY_NAMES.get(
122
+ priority.get("orderindex"), "unknown"
123
+ )
124
+ return None
125
+
126
+
127
+ def compact_task(task):
128
+ """Return a compact view of a task with only essential fields."""
129
+ return {
130
+ "id": task.get("id"),
131
+ "name": task.get("name"),
132
+ "status": _extract_status(task),
133
+ "priority": _extract_priority(task),
134
+ "url": task.get("url"),
135
+ }
136
+
137
+
138
+ def filter_task_fields(task, fields):
139
+ """Return only the requested fields from a task.
140
+
141
+ Supports nested status/priority extraction: if 'status' or 'priority'
142
+ is requested, returns the string name rather than the nested object.
143
+ """
144
+ extractors = {"status": _extract_status, "priority": _extract_priority}
145
+ result = {}
146
+ for field in fields:
147
+ extractor = extractors.get(field)
148
+ result[field] = extractor(task) if extractor else task.get(field)
149
+ return result
150
+
151
+
152
+ def format_tasks(tasks, full=False, fields=None):
153
+ """Apply compact/fields/full formatting to a list of tasks.
154
+
155
+ - full=True: return raw API objects unchanged
156
+ - fields: return only those fields per task
157
+ - default: return compact view (id, name, status, priority, url)
158
+ """
159
+ if full:
160
+ return tasks
161
+ if fields:
162
+ return [filter_task_fields(t, fields) for t in tasks]
163
+ return [compact_task(t) for t in tasks]
@@ -0,0 +1,204 @@
1
+ Metadata-Version: 2.4
2
+ Name: clickup-cli
3
+ Version: 1.2.0
4
+ Summary: The missing ClickUp CLI. Built for developers and AI agents.
5
+ Project-URL: Homepage, https://github.com/efetoker/clickup-cli
6
+ Project-URL: Repository, https://github.com/efetoker/clickup-cli
7
+ Project-URL: Issues, https://github.com/efetoker/clickup-cli/issues
8
+ Project-URL: Changelog, https://github.com/efetoker/clickup-cli/releases
9
+ Author-email: Efe Toker <efetoker@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai-agent,api,cli,clickup,task-management
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Office/Business :: Scheduling
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.9
26
+ Requires-Dist: requests>=2.20
27
+ Requires-Dist: urllib3<2
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest; extra == 'dev'
30
+ Requires-Dist: ruff; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # clickup-cli
34
+
35
+ The missing ClickUp CLI. Built for developers and AI agents.
36
+
37
+ There's no official ClickUp CLI. If you're a developer who lives in the terminal, or an AI agent that needs structured data from ClickUp, this fills the gap. JSON stdout, errors to stderr, dry-run on every mutation.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install clickup-cli
43
+ ```
44
+
45
+ ## Setup
46
+
47
+ ```bash
48
+ clickup init
49
+ ```
50
+
51
+ This prompts for your API token, discovers your workspaces and spaces, identifies your user, and writes a config file to `~/.config/clickup-cli/config.json`.
52
+
53
+ If you have a single workspace, it's selected automatically. Same for user detection in single-member workspaces.
54
+
55
+ Get your API token at [app.clickup.com/settings/apps](https://app.clickup.com/settings/apps).
56
+
57
+ ## Quick Start
58
+
59
+ ```bash
60
+ clickup spaces list # see your spaces
61
+ clickup tasks list --space <name> # list tasks
62
+ clickup tasks search "login bug" # search across workspace
63
+ clickup tasks get abc123 # get task + comments
64
+ clickup --dry-run tasks create --space <name> --name "Fix auth" # preview
65
+ clickup tasks create --space <name> --name "Fix auth" # create for real
66
+ clickup comments add abc123 --text "Done" # add a comment
67
+ ```
68
+
69
+ ## For AI Agents
70
+
71
+ This CLI is designed to be used by AI coding agents (Claude Code, Codex, etc.) as a tool for interacting with ClickUp.
72
+
73
+ **What makes it agent-friendly:**
74
+ - All output is JSON on stdout — easy to parse
75
+ - Errors go to stderr with non-zero exit code — easy to detect
76
+ - Every command has detailed `--help` — agents can self-discover usage
77
+ - `--dry-run` on all mutations — agents can preview before acting
78
+ - `--pretty` for readable output during debugging
79
+ - **Flag aliases for all positional args** — agents can use `--task-id`, `--query`, `--doc-id`, `--comment-id` etc. instead of positional arguments (both forms work)
80
+ - **Auto-infer `--space` from `--list`** — `tasks create --list 12345 --name "Fix bug"` works without `--space`
81
+
82
+ **Plug-and-play skill file:** Copy `.claude/skills/clickup-cli.md` from this repo into your project's `.claude/skills/` directory. It teaches Claude Code how to use the CLI: command discovery, safety patterns, common workflows.
83
+
84
+ **Minimal system prompt snippet:**
85
+ ```
86
+ You have access to the `clickup` CLI for managing ClickUp tasks and docs.
87
+ Use `clickup <group> --help` to discover commands.
88
+ Always use `--dry-run` before mutating commands.
89
+ Output is JSON on stdout; errors go to stderr.
90
+ ```
91
+
92
+ ## Commands
93
+
94
+ | Group | Subcommands | Description |
95
+ |-------|-------------|-------------|
96
+ | `init` | — | Interactive workspace setup |
97
+ | `tasks` | list, get, create, update, search, delete, move, merge | Full task CRUD |
98
+ | `comments` | list, add, update, delete, thread, reply | Full comment CRUD with threading |
99
+ | `docs` | list, get, create, pages, get-page, edit-page, create-page | Docs and page management |
100
+ | `folders` | list, get, create, update, delete | Folder CRUD |
101
+ | `lists` | list, get, create, update, delete | List CRUD |
102
+ | `spaces` | list, get, statuses | Space inspection |
103
+ | `team` | whoami, members | Workspace and member info |
104
+ | `tags` | list, add, remove | Tag management |
105
+
106
+ Use `clickup <group> <command> --help` for detailed usage, examples, and return format.
107
+
108
+ ## Global Flags
109
+
110
+ ```
111
+ --pretty Pretty-print JSON output
112
+ --dry-run Preview mutations without executing
113
+ --debug Log API requests and responses to stderr
114
+ --version Show version
115
+ ```
116
+
117
+ Global flags can appear before or after the command group:
118
+
119
+ ```bash
120
+ clickup --pretty tasks list --space <name>
121
+ clickup tasks list --space <name> --pretty
122
+ ```
123
+
124
+ ## Key Behaviors
125
+
126
+ - **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`.
127
+ - **`tasks create`** auto-infers `--space` from `--list` via API lookup. You can omit `--space` if `--list` is provided.
128
+ - **`tasks get`** auto-fetches comments and appends them to the output. Use `--no-comments` to skip.
129
+ - **`tasks search`** auto-detects task ID patterns like `PROJ-39` and applies prefix filtering.
130
+ - **`tasks create`** checks for duplicates before creating. Use `--skip-dedup` to bypass.
131
+ - **`docs edit-page --append`** reads the current page content, appends your new content, and sends one update.
132
+ - **Tag names** are auto-lowercased (ClickUp API stores them lowercase regardless of UI display).
133
+ - **Doc ID ≠ page ID.** Always use `docs pages <doc_id>` to discover page IDs before using `get-page` or `edit-page`.
134
+
135
+ ## Configuration
136
+
137
+ ### Config file
138
+
139
+ `clickup init` creates `~/.config/clickup-cli/config.json`:
140
+
141
+ ```json
142
+ {
143
+ "api_token": "pk_...",
144
+ "workspace_id": "12345",
145
+ "user_id": "67890",
146
+ "spaces": {
147
+ "myspace": {"space_id": "111", "list_id": "222"}
148
+ },
149
+ "default_tags": [],
150
+ "draft_tag": "draft",
151
+ "good_as_is_tag": "good as is",
152
+ "default_priority": 4
153
+ }
154
+ ```
155
+
156
+ ### Config resolution order
157
+
158
+ 1. `CLICKUP_CONFIG_PATH` env var → exact file path
159
+ 2. `~/.config/clickup-cli/config.json` → default location
160
+ 3. `clickup-config.json` in current working directory → project-local override
161
+
162
+ ### Environment variables
163
+
164
+ | Variable | Purpose |
165
+ |----------|---------|
166
+ | `CLICKUP_API_TOKEN` | API token (overrides config file token) |
167
+ | `CLICKUP_WORKSPACE_ID` | Workspace ID (auto-detected if you have one workspace) |
168
+ | `CLICKUP_USER_ID` | User ID for task assignment |
169
+ | `CLICKUP_CONFIG_PATH` | Custom config file path |
170
+
171
+ You can run without a config file by setting just `CLICKUP_API_TOKEN` — the workspace ID is auto-detected if your account has a single workspace. Set `CLICKUP_WORKSPACE_ID` explicitly for multi-workspace accounts.
172
+
173
+ ## Coverage and Gaps
174
+
175
+ **Covered:** tasks, comments, docs/pages, folders, lists, spaces, tags, team/workspace info.
176
+
177
+ **Not yet covered:** checklists, time tracking, custom fields, task relationships, attachments, goals, webhooks, automations.
178
+
179
+ ## Development Tools
180
+
181
+ This repo includes automations for contributors using [Claude Code](https://claude.com/claude-code):
182
+
183
+ - **Auto-lint hook** — ruff check + format runs on every Python file edit
184
+ - **Sensitive file guard** — blocks accidental edits to `.env`, `.key`, `.pem`, and credentials files
185
+ - **context7 MCP** — live ClickUp API docs available during development (via `.mcp.json`)
186
+ - **Skills** — `/release` workflow, `add-command` step-by-step guide, `clickup-cli` usage reference
187
+ - **Subagents** — `test-writer` generates pytest tests following project patterns
188
+
189
+ These are configured in `.claude/` and `.mcp.json`. Non-Claude-Code contributors can ignore them.
190
+
191
+ ## Contributing
192
+
193
+ ```bash
194
+ git clone https://github.com/efetoker/clickup-cli.git
195
+ cd clickup-cli
196
+ pip install -e ".[dev]"
197
+ pytest -v
198
+ ```
199
+
200
+ Issues and PRs welcome at [github.com/efetoker/clickup-cli](https://github.com/efetoker/clickup-cli).
201
+
202
+ ## License
203
+
204
+ MIT