cupt 0.6.1__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.
- cupt/__init__.py +39 -0
- cupt/ai.py +82 -0
- cupt/api.py +290 -0
- cupt/attachments.py +145 -0
- cupt/auth.py +246 -0
- cupt/config.py +153 -0
- cupt/context.py +42 -0
- cupt/exceptions.py +17 -0
- cupt/main.py +255 -0
- cupt/notes.py +52 -0
- cupt/services/__init__.py +5 -0
- cupt/services/note_service.py +16 -0
- cupt/services/task_service.py +305 -0
- cupt/services/time_service.py +29 -0
- cupt/summary.py +181 -0
- cupt/tags.py +42 -0
- cupt/tasks.py +891 -0
- cupt/time_tracker.py +107 -0
- cupt/utils.py +162 -0
- cupt-0.6.1.dist-info/METADATA +162 -0
- cupt-0.6.1.dist-info/RECORD +25 -0
- cupt-0.6.1.dist-info/WHEEL +5 -0
- cupt-0.6.1.dist-info/entry_points.txt +2 -0
- cupt-0.6.1.dist-info/licenses/LICENSE +21 -0
- cupt-0.6.1.dist-info/top_level.txt +1 -0
cupt/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CUPT — ClickUp Task Management CLI and Python library.
|
|
3
|
+
|
|
4
|
+
Use as a CLI:
|
|
5
|
+
$ cupt list --tag urgent
|
|
6
|
+
|
|
7
|
+
Use as a library:
|
|
8
|
+
from cupt import ClickUpClient, TaskService
|
|
9
|
+
|
|
10
|
+
client = ClickUpClient("pk_xxxxx") # personal API token
|
|
11
|
+
service = TaskService(client)
|
|
12
|
+
tasks = service.list_tasks(team_id="123", tags=["urgent"])
|
|
13
|
+
|
|
14
|
+
Public exports are intentionally limited to the API client, services, and
|
|
15
|
+
typed exceptions. Internal helpers (config, CLI commands, formatting) are
|
|
16
|
+
not part of the public API and may change between releases.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from cupt.api import ClickUpClient
|
|
20
|
+
from cupt.exceptions import APIError, AuthError, ConfigError, CuptError
|
|
21
|
+
from cupt.services.note_service import NoteService
|
|
22
|
+
from cupt.services.task_service import TaskService
|
|
23
|
+
from cupt.services.time_service import TimeService
|
|
24
|
+
|
|
25
|
+
__version__ = "0.6.1"
|
|
26
|
+
__author__ = "Matthew Nuzum"
|
|
27
|
+
__email__ = "matthew@nuzum.com"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"ClickUpClient",
|
|
31
|
+
"TaskService",
|
|
32
|
+
"TimeService",
|
|
33
|
+
"NoteService",
|
|
34
|
+
"CuptError",
|
|
35
|
+
"APIError",
|
|
36
|
+
"AuthError",
|
|
37
|
+
"ConfigError",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
cupt/ai.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local AI backend abstraction for CUPT.
|
|
3
|
+
|
|
4
|
+
Provider priority:
|
|
5
|
+
1. Apple Intelligence (apple-fm-sdk, macOS 26+)
|
|
6
|
+
|
|
7
|
+
Future providers (not yet implemented):
|
|
8
|
+
2. Windows Copilot+ (WinRT Microsoft.Windows.AI)
|
|
9
|
+
3. Ollama (http://localhost:11434)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_SYSTEM_INSTRUCTIONS = (
|
|
19
|
+
"You are a concise assistant helping a professional complete task management notes. "
|
|
20
|
+
"Write clear, brief, first-person completion notes. No preamble or explanation."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_ai_suggestion(prompt: str) -> Optional[str]:
|
|
25
|
+
"""
|
|
26
|
+
Try available local AI backends and return a suggestion, or None if unavailable.
|
|
27
|
+
|
|
28
|
+
Returns None silently when no provider is found — callers are responsible
|
|
29
|
+
for surfacing a user-facing message.
|
|
30
|
+
"""
|
|
31
|
+
result = _try_apple_intelligence(prompt)
|
|
32
|
+
if result is not None:
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
# Future: _try_windows_copilot(prompt)
|
|
36
|
+
# Future: _try_ollama(prompt)
|
|
37
|
+
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_ai_available() -> bool:
|
|
42
|
+
"""Return True if at least one local AI provider is available."""
|
|
43
|
+
return _apple_intelligence_available()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
# Apple Intelligence
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _apple_intelligence_available() -> bool:
|
|
52
|
+
try:
|
|
53
|
+
import apple_fm_sdk as fm
|
|
54
|
+
|
|
55
|
+
available, _ = fm.SystemLanguageModel().is_available()
|
|
56
|
+
return available
|
|
57
|
+
except ImportError:
|
|
58
|
+
return False
|
|
59
|
+
except Exception:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _try_apple_intelligence(prompt: str) -> Optional[str]:
|
|
64
|
+
try:
|
|
65
|
+
import apple_fm_sdk as fm
|
|
66
|
+
|
|
67
|
+
model = fm.SystemLanguageModel()
|
|
68
|
+
available, reason = model.is_available()
|
|
69
|
+
if not available:
|
|
70
|
+
logger.debug("Apple Intelligence not available: %s", reason)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
session = fm.LanguageModelSession(instructions=_SYSTEM_INSTRUCTIONS)
|
|
74
|
+
result = asyncio.run(session.respond(prompt))
|
|
75
|
+
return result.strip() if result else None
|
|
76
|
+
|
|
77
|
+
except ImportError:
|
|
78
|
+
logger.debug("apple-fm-sdk not installed")
|
|
79
|
+
return None
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.debug("Apple Intelligence error: %s", e)
|
|
82
|
+
return None
|
cupt/api.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ClickUp API client
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
from requests.adapters import HTTPAdapter
|
|
12
|
+
from urllib3.util.retry import Retry
|
|
13
|
+
|
|
14
|
+
from cupt.exceptions import APIError
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ClickUpClient:
|
|
20
|
+
"""ClickUp API client with connection pooling, retry logic, and request timeout."""
|
|
21
|
+
|
|
22
|
+
BASE_URL = "https://api.clickup.com/api/v2"
|
|
23
|
+
TIMEOUT = 10 # seconds — prevents the CLI from hanging on a slow/unresponsive API
|
|
24
|
+
|
|
25
|
+
def __init__(self, access_token: str):
|
|
26
|
+
self.access_token = access_token
|
|
27
|
+
self.session = requests.Session()
|
|
28
|
+
# Only Authorization on the session. Content-Type is set per-request
|
|
29
|
+
# inside _make_request so it never leaks into multipart uploads
|
|
30
|
+
# (which generate their own Content-Type with a boundary).
|
|
31
|
+
self.session.headers.update({"Authorization": self.access_token})
|
|
32
|
+
|
|
33
|
+
# Retry transient server errors with exponential backoff.
|
|
34
|
+
# 429 included so ClickUp rate limits trigger backoff (urllib3's
|
|
35
|
+
# Retry honors the Retry-After header automatically).
|
|
36
|
+
_retry = Retry(
|
|
37
|
+
total=3,
|
|
38
|
+
backoff_factor=0.5,
|
|
39
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
40
|
+
respect_retry_after_header=True,
|
|
41
|
+
raise_on_status=False,
|
|
42
|
+
)
|
|
43
|
+
# Pool keeps persistent TCP connections open across requests in the
|
|
44
|
+
# same process (the default pool size of 10 is fine here).
|
|
45
|
+
_adapter = HTTPAdapter(max_retries=_retry)
|
|
46
|
+
self.session.mount("https://", _adapter)
|
|
47
|
+
self.session.mount("http://", _adapter)
|
|
48
|
+
|
|
49
|
+
def _make_request(
|
|
50
|
+
self,
|
|
51
|
+
method: str,
|
|
52
|
+
endpoint: str,
|
|
53
|
+
data: Optional[Dict] = None,
|
|
54
|
+
params: Optional[Dict] = None,
|
|
55
|
+
) -> Dict[str, Any]:
|
|
56
|
+
"""Make an API request with error handling, timeout, and retry."""
|
|
57
|
+
url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
|
|
58
|
+
logger.debug("%s %s params=%s", method.upper(), url, params)
|
|
59
|
+
|
|
60
|
+
# Per-request JSON header only on methods that carry a JSON body.
|
|
61
|
+
json_headers = {"Content-Type": "application/json"}
|
|
62
|
+
try:
|
|
63
|
+
if method.upper() == "GET":
|
|
64
|
+
response = self.session.get(url, params=params, timeout=self.TIMEOUT)
|
|
65
|
+
elif method.upper() == "POST":
|
|
66
|
+
response = self.session.post(
|
|
67
|
+
url, json=data, headers=json_headers, timeout=self.TIMEOUT
|
|
68
|
+
)
|
|
69
|
+
elif method.upper() == "PUT":
|
|
70
|
+
response = self.session.put(
|
|
71
|
+
url, json=data, headers=json_headers, timeout=self.TIMEOUT
|
|
72
|
+
)
|
|
73
|
+
elif method.upper() == "DELETE":
|
|
74
|
+
response = self.session.delete(url, timeout=self.TIMEOUT)
|
|
75
|
+
else:
|
|
76
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
77
|
+
|
|
78
|
+
response.raise_for_status()
|
|
79
|
+
return response.json()
|
|
80
|
+
|
|
81
|
+
except requests.exceptions.HTTPError as e:
|
|
82
|
+
error_msg = f"HTTP {e.response.status_code}"
|
|
83
|
+
if e.response.text:
|
|
84
|
+
try:
|
|
85
|
+
error_data = e.response.json()
|
|
86
|
+
error_msg += f": {error_data.get('err', '')}"
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
error_msg += f": {e.response.text[:200]}"
|
|
89
|
+
logger.debug("API error on %s %s: %s", method, endpoint, error_msg)
|
|
90
|
+
raise APIError(error_msg)
|
|
91
|
+
except requests.exceptions.Timeout:
|
|
92
|
+
msg = f"Request timed out after {self.TIMEOUT}s: {endpoint}"
|
|
93
|
+
logger.debug(msg)
|
|
94
|
+
raise APIError(msg)
|
|
95
|
+
except requests.exceptions.RequestException as e:
|
|
96
|
+
msg = f"Request failed: {e}"
|
|
97
|
+
logger.debug(msg)
|
|
98
|
+
raise APIError(msg)
|
|
99
|
+
except json.JSONDecodeError as e:
|
|
100
|
+
msg = f"Invalid JSON response: {e}"
|
|
101
|
+
logger.debug(msg)
|
|
102
|
+
raise APIError(msg)
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
# Auth / user
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def get_user(self) -> Dict[str, Any]:
|
|
109
|
+
return self._make_request("GET", "/user")
|
|
110
|
+
|
|
111
|
+
def get_teams(self) -> List[Dict[str, Any]]:
|
|
112
|
+
return self._make_request("GET", "/team").get("teams", [])
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
# Tasks
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def get_team_tasks(
|
|
119
|
+
self, team_id: str, filters: Optional[Dict] = None
|
|
120
|
+
) -> List[Dict[str, Any]]:
|
|
121
|
+
params: Dict = {}
|
|
122
|
+
if filters:
|
|
123
|
+
params.update(filters)
|
|
124
|
+
return self._make_request("GET", f"/team/{team_id}/task", params=params).get(
|
|
125
|
+
"tasks", []
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def get_tasks_by_ids(
|
|
129
|
+
self, team_id: str, task_ids: List[str]
|
|
130
|
+
) -> List[Dict[str, Any]]:
|
|
131
|
+
"""Bulk-fetch up to 100 tasks by ID."""
|
|
132
|
+
if not task_ids:
|
|
133
|
+
return []
|
|
134
|
+
params = {"ids[]": task_ids[:100], "include_subtasks": "true"}
|
|
135
|
+
return self._make_request("GET", f"/team/{team_id}/task", params=params).get(
|
|
136
|
+
"tasks", []
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def get_task(self, task_id: str) -> Dict[str, Any]:
|
|
140
|
+
return self._make_request("GET", f"/task/{task_id}")
|
|
141
|
+
|
|
142
|
+
def get_task_children(
|
|
143
|
+
self, team_id: str, parent_id: str, params: Optional[Dict] = None
|
|
144
|
+
) -> List[Dict[str, Any]]:
|
|
145
|
+
"""Fetch direct subtasks of a task."""
|
|
146
|
+
p: Dict = {"parent": parent_id}
|
|
147
|
+
if params:
|
|
148
|
+
p.update(params)
|
|
149
|
+
return self._make_request("GET", f"/team/{team_id}/task", params=p).get(
|
|
150
|
+
"tasks", []
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def update_task(self, task_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
154
|
+
return self._make_request("PUT", f"/task/{task_id}", data=data)
|
|
155
|
+
|
|
156
|
+
def add_task_tag(self, task_id: str, tag_name: str) -> Dict[str, Any]:
|
|
157
|
+
return self._make_request("POST", f"/task/{task_id}/tag/{tag_name}")
|
|
158
|
+
|
|
159
|
+
def remove_task_tag(self, task_id: str, tag_name: str) -> Dict[str, Any]:
|
|
160
|
+
return self._make_request("DELETE", f"/task/{task_id}/tag/{tag_name}")
|
|
161
|
+
|
|
162
|
+
def upload_task_attachment(
|
|
163
|
+
self, task_id: str, file_path: str, filename: Optional[str] = None
|
|
164
|
+
) -> Dict[str, Any]:
|
|
165
|
+
"""
|
|
166
|
+
Upload a file as a task attachment.
|
|
167
|
+
|
|
168
|
+
Bypasses the shared session because that session sets
|
|
169
|
+
Content-Type: application/json, which prevents `requests` from
|
|
170
|
+
generating the multipart boundary header and causes the server to
|
|
171
|
+
receive a malformed body (resulting in corrupted attachments).
|
|
172
|
+
"""
|
|
173
|
+
import os
|
|
174
|
+
|
|
175
|
+
url = f"{self.BASE_URL}/task/{task_id}/attachment"
|
|
176
|
+
name = filename or os.path.basename(file_path)
|
|
177
|
+
headers = {"Authorization": self.access_token}
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
with open(file_path, "rb") as fh:
|
|
181
|
+
response = requests.post(
|
|
182
|
+
url,
|
|
183
|
+
headers=headers,
|
|
184
|
+
files={"attachment": (name, fh)},
|
|
185
|
+
timeout=60, # uploads can legitimately take longer than reads
|
|
186
|
+
)
|
|
187
|
+
response.raise_for_status()
|
|
188
|
+
return response.json()
|
|
189
|
+
except requests.exceptions.HTTPError as e:
|
|
190
|
+
msg = f"HTTP {e.response.status_code}: {e.response.text[:200]}"
|
|
191
|
+
raise APIError(msg)
|
|
192
|
+
except requests.exceptions.RequestException as e:
|
|
193
|
+
raise APIError(f"Upload failed: {e}")
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
# Statuses
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def get_list_statuses(self, list_id: str) -> List[Dict[str, Any]]:
|
|
200
|
+
return self._make_request("GET", f"/list/{list_id}").get("statuses", [])
|
|
201
|
+
|
|
202
|
+
def get_space_statuses(self, space_id: str) -> List[Dict[str, Any]]:
|
|
203
|
+
return self._make_request("GET", f"/space/{space_id}").get("statuses", [])
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
# Comments / notes
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def get_task_comments(self, task_id: str) -> List[Dict[str, Any]]:
|
|
210
|
+
return self._make_request("GET", f"/task/{task_id}/comment").get("comments", [])
|
|
211
|
+
|
|
212
|
+
def add_task_comment(
|
|
213
|
+
self, task_id: str, comment_text: str, notify_all: bool = False
|
|
214
|
+
) -> Dict[str, Any]:
|
|
215
|
+
data = {
|
|
216
|
+
"comment_text": comment_text,
|
|
217
|
+
"notify_all": notify_all,
|
|
218
|
+
"assignee": None,
|
|
219
|
+
}
|
|
220
|
+
return self._make_request("POST", f"/task/{task_id}/comment", data=data)
|
|
221
|
+
|
|
222
|
+
# ------------------------------------------------------------------
|
|
223
|
+
# Time tracking
|
|
224
|
+
# ------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def start_timer(
|
|
227
|
+
self, team_id: str, task_id: Optional[str] = None
|
|
228
|
+
) -> Dict[str, Any]:
|
|
229
|
+
data: Dict = {}
|
|
230
|
+
if task_id:
|
|
231
|
+
data["task_id"] = task_id
|
|
232
|
+
data["tid"] = task_id
|
|
233
|
+
return self._make_request(
|
|
234
|
+
"POST", f"/team/{team_id}/time_entries/start", data=data
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def stop_timer(self, team_id: str) -> Dict[str, Any]:
|
|
238
|
+
return self._make_request("POST", f"/team/{team_id}/time_entries/stop")
|
|
239
|
+
|
|
240
|
+
def get_running_timer(self, team_id: str) -> Optional[Dict[str, Any]]:
|
|
241
|
+
try:
|
|
242
|
+
return self._make_request(
|
|
243
|
+
"GET", f"/team/{team_id}/time_entries/current"
|
|
244
|
+
).get("data")
|
|
245
|
+
except Exception:
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
def get_time_entries(
|
|
249
|
+
self,
|
|
250
|
+
team_id: str,
|
|
251
|
+
start_date: int,
|
|
252
|
+
end_date: int,
|
|
253
|
+
user_id: Optional[str] = None,
|
|
254
|
+
) -> List[Dict[str, Any]]:
|
|
255
|
+
"""Fetch time entries within a date range (timestamps in ms)."""
|
|
256
|
+
params: Dict = {"start_date": start_date, "end_date": end_date}
|
|
257
|
+
if user_id:
|
|
258
|
+
params["assignee"] = user_id
|
|
259
|
+
return self._make_request(
|
|
260
|
+
"GET", f"/team/{team_id}/time_entries", params=params
|
|
261
|
+
).get("data", [])
|
|
262
|
+
|
|
263
|
+
def add_time_entry(
|
|
264
|
+
self,
|
|
265
|
+
team_id: str,
|
|
266
|
+
task_id: str,
|
|
267
|
+
duration: int,
|
|
268
|
+
description: Optional[str] = None,
|
|
269
|
+
) -> Dict[str, Any]:
|
|
270
|
+
now_ms = int(datetime.now().timestamp() * 1000)
|
|
271
|
+
data: Dict = {
|
|
272
|
+
"task_id": task_id,
|
|
273
|
+
"tid": task_id,
|
|
274
|
+
"duration": duration,
|
|
275
|
+
"start": now_ms - duration,
|
|
276
|
+
"end": now_ms,
|
|
277
|
+
}
|
|
278
|
+
if description:
|
|
279
|
+
data["description"] = description
|
|
280
|
+
return self._make_request("POST", f"/team/{team_id}/time_entries", data=data)
|
|
281
|
+
|
|
282
|
+
# ------------------------------------------------------------------
|
|
283
|
+
# Hierarchy
|
|
284
|
+
# ------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
def get_spaces(self, team_id: str) -> List[Dict[str, Any]]:
|
|
287
|
+
return self._make_request("GET", f"/team/{team_id}/space").get("spaces", [])
|
|
288
|
+
|
|
289
|
+
def get_lists(self, space_id: str) -> List[Dict[str, Any]]:
|
|
290
|
+
return self._make_request("GET", f"/space/{space_id}/list").get("lists", [])
|
cupt/attachments.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from cupt.context import get_client_context
|
|
7
|
+
from cupt.utils import print_error, print_success, print_warning
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _human_size(n_bytes):
|
|
11
|
+
if not n_bytes:
|
|
12
|
+
return "-"
|
|
13
|
+
n = float(n_bytes)
|
|
14
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
15
|
+
if n < 1024 or unit == "GB":
|
|
16
|
+
return f"{n:.1f}{unit}" if unit != "B" else f"{int(n)}B"
|
|
17
|
+
n /= 1024
|
|
18
|
+
return f"{n:.1f}GB"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _resolve(attachments, selector):
|
|
22
|
+
"""Find an attachment by 1-based index or substring of title."""
|
|
23
|
+
if selector.isdigit():
|
|
24
|
+
idx = int(selector) - 1
|
|
25
|
+
if 0 <= idx < len(attachments):
|
|
26
|
+
return attachments[idx]
|
|
27
|
+
return None
|
|
28
|
+
needle = selector.lower()
|
|
29
|
+
matches = [a for a in attachments if needle in (a.get("title", "")).lower()]
|
|
30
|
+
if len(matches) == 1:
|
|
31
|
+
return matches[0]
|
|
32
|
+
if len(matches) > 1:
|
|
33
|
+
raise click.ClickException(
|
|
34
|
+
f"'{selector}' matches {len(matches)} attachments — be more specific or use an index"
|
|
35
|
+
)
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@click.group(name="attach")
|
|
40
|
+
def attach_group():
|
|
41
|
+
"""List, download, and upload task attachments"""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@attach_group.command("list")
|
|
46
|
+
@click.argument("task_id")
|
|
47
|
+
def list_attachments(task_id):
|
|
48
|
+
"""List attachments on a task"""
|
|
49
|
+
_, client, _ = get_client_context(need_team=False)
|
|
50
|
+
if not client:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
task = client.get_task(task_id)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
print_error(f"Failed to fetch task: {e}")
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
attachments = task.get("attachments") or []
|
|
60
|
+
if not attachments:
|
|
61
|
+
print_warning(f"No attachments on {task_id}.")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
click.echo(f"\n{'#':<4} {'Size':<10} {'Name'}")
|
|
65
|
+
click.echo("-" * 60)
|
|
66
|
+
for i, a in enumerate(attachments, start=1):
|
|
67
|
+
click.echo(
|
|
68
|
+
f"{i:<4} {_human_size(a.get('size')):<10} {a.get('title', '(untitled)')}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@attach_group.command("get")
|
|
73
|
+
@click.argument("task_id")
|
|
74
|
+
@click.argument("selector")
|
|
75
|
+
@click.option(
|
|
76
|
+
"-o",
|
|
77
|
+
"--output",
|
|
78
|
+
type=click.Path(),
|
|
79
|
+
help="Save path (default: original filename in current dir)",
|
|
80
|
+
)
|
|
81
|
+
def get_attachment(task_id, selector, output):
|
|
82
|
+
"""Download an attachment by 1-based index or filename substring"""
|
|
83
|
+
_, client, _ = get_client_context(need_team=False)
|
|
84
|
+
if not client:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
task = client.get_task(task_id)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
print_error(f"Failed to fetch task: {e}")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
attachments = task.get("attachments") or []
|
|
94
|
+
if not attachments:
|
|
95
|
+
print_warning(f"No attachments on {task_id}.")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
target = _resolve(attachments, selector)
|
|
99
|
+
if not target:
|
|
100
|
+
print_error(f"No attachment matches '{selector}'.")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
url = target.get("url")
|
|
104
|
+
if not url:
|
|
105
|
+
print_error("Attachment has no download URL.")
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
# No auth header — these are pre-signed S3 URLs; sending Authorization
|
|
109
|
+
# can invalidate the signature.
|
|
110
|
+
try:
|
|
111
|
+
response = requests.get(url, timeout=60, stream=True)
|
|
112
|
+
response.raise_for_status()
|
|
113
|
+
except requests.exceptions.RequestException as e:
|
|
114
|
+
print_error(f"Download failed: {e}")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
out_path = output or target.get("title") or "attachment.bin"
|
|
118
|
+
try:
|
|
119
|
+
with open(out_path, "wb") as fh:
|
|
120
|
+
for chunk in response.iter_content(chunk_size=64 * 1024):
|
|
121
|
+
if chunk:
|
|
122
|
+
fh.write(chunk)
|
|
123
|
+
except OSError as e:
|
|
124
|
+
print_error(f"Could not write to {out_path}: {e}")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
print_success(f"Downloaded {target.get('title')} -> {out_path}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@attach_group.command("add")
|
|
131
|
+
@click.argument("task_id")
|
|
132
|
+
@click.argument("file_path", type=click.Path(exists=True, dir_okay=False))
|
|
133
|
+
@click.option("--name", help="Override the filename stored on ClickUp")
|
|
134
|
+
def add_attachment(task_id, file_path, name):
|
|
135
|
+
"""Upload a file as a task attachment"""
|
|
136
|
+
_, client, _ = get_client_context(need_team=False)
|
|
137
|
+
if not client:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
result = client.upload_task_attachment(task_id, file_path, name)
|
|
142
|
+
title = result.get("title") or name or os.path.basename(file_path)
|
|
143
|
+
print_success(f"Attached '{title}' to {task_id}")
|
|
144
|
+
except Exception as e:
|
|
145
|
+
print_error(f"Failed to upload attachment: {e}")
|