tisit-cli 0.1.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.
- tisit_cli/__init__.py +2 -0
- tisit_cli/auth.py +54 -0
- tisit_cli/client.py +392 -0
- tisit_cli/commands/__init__.py +0 -0
- tisit_cli/commands/article_commands.py +132 -0
- tisit_cli/commands/auth_commands.py +91 -0
- tisit_cli/commands/book_commands.py +186 -0
- tisit_cli/commands/browse_commands.py +76 -0
- tisit_cli/commands/chat_commands.py +94 -0
- tisit_cli/commands/focus_commands.py +198 -0
- tisit_cli/commands/graph_commands.py +126 -0
- tisit_cli/commands/note_commands.py +131 -0
- tisit_cli/commands/paper_commands.py +134 -0
- tisit_cli/commands/patent_commands.py +138 -0
- tisit_cli/commands/podcast_commands.py +132 -0
- tisit_cli/commands/radar_commands.py +246 -0
- tisit_cli/commands/search_commands.py +42 -0
- tisit_cli/commands/status_commands.py +50 -0
- tisit_cli/commands/tweet_commands.py +132 -0
- tisit_cli/commands/video_commands.py +132 -0
- tisit_cli/config.py +53 -0
- tisit_cli/display.py +582 -0
- tisit_cli/exceptions.py +29 -0
- tisit_cli/main.py +68 -0
- tisit_cli-0.1.0.dist-info/METADATA +114 -0
- tisit_cli-0.1.0.dist-info/RECORD +29 -0
- tisit_cli-0.1.0.dist-info/WHEEL +4 -0
- tisit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- tisit_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
tisit_cli/__init__.py
ADDED
tisit_cli/auth.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Token storage — keyring with file fallback."""
|
|
2
|
+
import os
|
|
3
|
+
import stat
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
SERVICE_NAME = "tisit-cli"
|
|
7
|
+
TOKEN_FILE = Path.home() / ".tisit" / "token"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def store_token(token: str) -> None:
|
|
11
|
+
"""Store API token securely."""
|
|
12
|
+
try:
|
|
13
|
+
import keyring
|
|
14
|
+
keyring.set_password(SERVICE_NAME, "api_token", token)
|
|
15
|
+
# Clean up file if migrating to keyring
|
|
16
|
+
if TOKEN_FILE.exists():
|
|
17
|
+
TOKEN_FILE.unlink()
|
|
18
|
+
return
|
|
19
|
+
except Exception:
|
|
20
|
+
pass
|
|
21
|
+
# File fallback
|
|
22
|
+
TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
TOKEN_FILE.write_text(token)
|
|
24
|
+
os.chmod(TOKEN_FILE, stat.S_IRUSR | stat.S_IWUSR)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_token() -> str | None:
|
|
28
|
+
"""Retrieve stored API token."""
|
|
29
|
+
try:
|
|
30
|
+
import keyring
|
|
31
|
+
token = keyring.get_password(SERVICE_NAME, "api_token")
|
|
32
|
+
if token:
|
|
33
|
+
return token
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
if TOKEN_FILE.exists():
|
|
37
|
+
return TOKEN_FILE.read_text().strip()
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def delete_token() -> None:
|
|
42
|
+
"""Remove stored API token."""
|
|
43
|
+
try:
|
|
44
|
+
import keyring
|
|
45
|
+
keyring.delete_password(SERVICE_NAME, "api_token")
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
if TOKEN_FILE.exists():
|
|
49
|
+
TOKEN_FILE.unlink()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_token_format(token: str) -> bool:
|
|
53
|
+
"""Check that the token has the expected tsk_ prefix and length."""
|
|
54
|
+
return token.startswith("tsk_") and len(token) >= 20
|
tisit_cli/client.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""HTTP API client — all communication with the TISIT server goes through here."""
|
|
2
|
+
import httpx
|
|
3
|
+
|
|
4
|
+
from . import __version__
|
|
5
|
+
from .exceptions import APIError, AuthenticationError, NotFoundError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TisitClient:
|
|
9
|
+
"""Synchronous HTTP client for the TISIT API v1."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, base_url: str, token: str):
|
|
12
|
+
self._base = base_url.rstrip("/") + "/api/v1"
|
|
13
|
+
self._http = httpx.Client(
|
|
14
|
+
base_url=self._base,
|
|
15
|
+
headers={
|
|
16
|
+
"Authorization": f"Bearer {token}",
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
"User-Agent": f"tisit-cli/{__version__}",
|
|
19
|
+
},
|
|
20
|
+
timeout=30.0,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def close(self):
|
|
24
|
+
self._http.close()
|
|
25
|
+
|
|
26
|
+
# ── internal ──────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def _parse_json(resp):
|
|
30
|
+
"""Safely parse JSON from response, returning {} on empty/invalid."""
|
|
31
|
+
if not resp.content or not resp.content.strip():
|
|
32
|
+
return {}
|
|
33
|
+
try:
|
|
34
|
+
return resp.json()
|
|
35
|
+
except Exception:
|
|
36
|
+
return {}
|
|
37
|
+
|
|
38
|
+
def _request(self, method, path, **kwargs):
|
|
39
|
+
"""Issue request and unwrap the API envelope."""
|
|
40
|
+
try:
|
|
41
|
+
resp = self._http.request(method, path, **kwargs)
|
|
42
|
+
except httpx.ConnectError:
|
|
43
|
+
raise APIError(
|
|
44
|
+
"Cannot reach the TISIT server. Is it running?",
|
|
45
|
+
status_code=0,
|
|
46
|
+
)
|
|
47
|
+
except httpx.TimeoutException:
|
|
48
|
+
raise APIError("Request timed out", status_code=0)
|
|
49
|
+
|
|
50
|
+
if resp.status_code == 401:
|
|
51
|
+
raise AuthenticationError(
|
|
52
|
+
"Token expired or invalid. Run: tisit login"
|
|
53
|
+
)
|
|
54
|
+
if resp.status_code == 403:
|
|
55
|
+
raise AuthenticationError(
|
|
56
|
+
"Insufficient permissions for this operation"
|
|
57
|
+
)
|
|
58
|
+
if resp.status_code == 404:
|
|
59
|
+
body = self._parse_json(resp)
|
|
60
|
+
msg = body.get("error", {}).get("message", "Resource not found")
|
|
61
|
+
raise NotFoundError(msg)
|
|
62
|
+
|
|
63
|
+
body = self._parse_json(resp)
|
|
64
|
+
|
|
65
|
+
if not body.get("success", False):
|
|
66
|
+
err = body.get("error", {})
|
|
67
|
+
raise APIError(
|
|
68
|
+
err.get("message", f"API error (HTTP {resp.status_code})"),
|
|
69
|
+
status_code=resp.status_code,
|
|
70
|
+
detail=err.get("errors"),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return body.get("data"), body.get("meta")
|
|
74
|
+
|
|
75
|
+
# ── public methods ────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def status(self):
|
|
78
|
+
data, _ = self._request("GET", "/status")
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
def list_notes(self, *, q=None, category=None, domain=None,
|
|
82
|
+
sort="created_at", order="desc", page=1, per_page=20):
|
|
83
|
+
params = {"sort": sort, "order": order, "page": page,
|
|
84
|
+
"per_page": per_page}
|
|
85
|
+
if q:
|
|
86
|
+
params["q"] = q
|
|
87
|
+
if category:
|
|
88
|
+
params["category"] = category
|
|
89
|
+
if domain:
|
|
90
|
+
params["domain"] = domain
|
|
91
|
+
data, meta = self._request("GET", "/notes", params=params)
|
|
92
|
+
return data, meta
|
|
93
|
+
|
|
94
|
+
def get_note(self, note_id: int):
|
|
95
|
+
data, _ = self._request("GET", f"/notes/{note_id}")
|
|
96
|
+
return data
|
|
97
|
+
|
|
98
|
+
def create_note(self, term: str, context: str):
|
|
99
|
+
data, _ = self._request(
|
|
100
|
+
"POST", "/notes", json={"term": term, "context": context}
|
|
101
|
+
)
|
|
102
|
+
return data
|
|
103
|
+
|
|
104
|
+
def delete_note(self, note_id: int):
|
|
105
|
+
data, _ = self._request("DELETE", f"/notes/{note_id}")
|
|
106
|
+
return data
|
|
107
|
+
|
|
108
|
+
# ── papers ─────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
def list_papers(self, *, q=None, sort="uploaded_at", order="desc",
|
|
111
|
+
page=1, per_page=20):
|
|
112
|
+
params = {"sort": sort, "order": order, "page": page,
|
|
113
|
+
"per_page": per_page}
|
|
114
|
+
if q:
|
|
115
|
+
params["q"] = q
|
|
116
|
+
data, meta = self._request("GET", "/papers", params=params)
|
|
117
|
+
return data, meta
|
|
118
|
+
|
|
119
|
+
def get_paper(self, paper_id: int):
|
|
120
|
+
data, _ = self._request("GET", f"/papers/{paper_id}")
|
|
121
|
+
return data
|
|
122
|
+
|
|
123
|
+
def add_paper(self, url: str, *, title=None, authors=None):
|
|
124
|
+
payload = {"url": url}
|
|
125
|
+
if title:
|
|
126
|
+
payload["title"] = title
|
|
127
|
+
if authors:
|
|
128
|
+
payload["authors"] = authors
|
|
129
|
+
data, _ = self._request("POST", "/papers", json=payload)
|
|
130
|
+
return data
|
|
131
|
+
|
|
132
|
+
def delete_paper(self, paper_id: int):
|
|
133
|
+
data, _ = self._request("DELETE", f"/papers/{paper_id}")
|
|
134
|
+
return data
|
|
135
|
+
|
|
136
|
+
# ── articles ───────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
def list_articles(self, *, q=None, sort="fetched_at", order="desc",
|
|
139
|
+
page=1, per_page=20):
|
|
140
|
+
params = {"sort": sort, "order": order, "page": page,
|
|
141
|
+
"per_page": per_page}
|
|
142
|
+
if q:
|
|
143
|
+
params["q"] = q
|
|
144
|
+
data, meta = self._request("GET", "/articles", params=params)
|
|
145
|
+
return data, meta
|
|
146
|
+
|
|
147
|
+
def get_article(self, article_id: int):
|
|
148
|
+
data, _ = self._request("GET", f"/articles/{article_id}")
|
|
149
|
+
return data
|
|
150
|
+
|
|
151
|
+
def add_article(self, url: str):
|
|
152
|
+
data, _ = self._request("POST", "/articles", json={"url": url})
|
|
153
|
+
return data
|
|
154
|
+
|
|
155
|
+
def delete_article(self, article_id: int):
|
|
156
|
+
data, _ = self._request("DELETE", f"/articles/{article_id}")
|
|
157
|
+
return data
|
|
158
|
+
|
|
159
|
+
# ── videos ─────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def list_videos(self, *, q=None, sort="created_at", order="desc",
|
|
162
|
+
page=1, per_page=20):
|
|
163
|
+
params = {"sort": sort, "order": order, "page": page,
|
|
164
|
+
"per_page": per_page}
|
|
165
|
+
if q:
|
|
166
|
+
params["q"] = q
|
|
167
|
+
data, meta = self._request("GET", "/videos", params=params)
|
|
168
|
+
return data, meta
|
|
169
|
+
|
|
170
|
+
def get_video(self, video_id: int):
|
|
171
|
+
data, _ = self._request("GET", f"/videos/{video_id}")
|
|
172
|
+
return data
|
|
173
|
+
|
|
174
|
+
def add_video(self, url: str):
|
|
175
|
+
data, _ = self._request("POST", "/videos", json={"url": url})
|
|
176
|
+
return data
|
|
177
|
+
|
|
178
|
+
def delete_video(self, video_id: int):
|
|
179
|
+
data, _ = self._request("DELETE", f"/videos/{video_id}")
|
|
180
|
+
return data
|
|
181
|
+
|
|
182
|
+
# ── tweets ─────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
def list_tweets(self, *, q=None, sort="created_at", order="desc",
|
|
185
|
+
page=1, per_page=20):
|
|
186
|
+
params = {"sort": sort, "order": order, "page": page,
|
|
187
|
+
"per_page": per_page}
|
|
188
|
+
if q:
|
|
189
|
+
params["q"] = q
|
|
190
|
+
data, meta = self._request("GET", "/tweets", params=params)
|
|
191
|
+
return data, meta
|
|
192
|
+
|
|
193
|
+
def get_tweet(self, tweet_id: int):
|
|
194
|
+
data, _ = self._request("GET", f"/tweets/{tweet_id}")
|
|
195
|
+
return data
|
|
196
|
+
|
|
197
|
+
def add_tweet(self, url: str):
|
|
198
|
+
data, _ = self._request("POST", "/tweets", json={"url": url})
|
|
199
|
+
return data
|
|
200
|
+
|
|
201
|
+
def delete_tweet(self, tweet_id: int):
|
|
202
|
+
data, _ = self._request("DELETE", f"/tweets/{tweet_id}")
|
|
203
|
+
return data
|
|
204
|
+
|
|
205
|
+
# ── books ──────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
def search_books(self, *, title="", author="", limit=10):
|
|
208
|
+
params = {"limit": limit}
|
|
209
|
+
if title:
|
|
210
|
+
params["title"] = title
|
|
211
|
+
if author:
|
|
212
|
+
params["author"] = author
|
|
213
|
+
data, meta = self._request("GET", "/books/search", params=params)
|
|
214
|
+
return data, meta
|
|
215
|
+
|
|
216
|
+
def list_books(self, *, q=None, sort="added_at", order="desc",
|
|
217
|
+
page=1, per_page=20):
|
|
218
|
+
params = {"sort": sort, "order": order, "page": page,
|
|
219
|
+
"per_page": per_page}
|
|
220
|
+
if q:
|
|
221
|
+
params["q"] = q
|
|
222
|
+
data, meta = self._request("GET", "/books", params=params)
|
|
223
|
+
return data, meta
|
|
224
|
+
|
|
225
|
+
def get_book(self, book_id: int):
|
|
226
|
+
data, _ = self._request("GET", f"/books/{book_id}")
|
|
227
|
+
return data
|
|
228
|
+
|
|
229
|
+
def add_book(self, title: str, *, authors=None, isbn_13=None,
|
|
230
|
+
google_books_id=None, description=None):
|
|
231
|
+
payload = {"title": title}
|
|
232
|
+
if authors:
|
|
233
|
+
payload["authors"] = authors
|
|
234
|
+
if isbn_13:
|
|
235
|
+
payload["isbn_13"] = isbn_13
|
|
236
|
+
if google_books_id:
|
|
237
|
+
payload["google_books_id"] = google_books_id
|
|
238
|
+
if description:
|
|
239
|
+
payload["description"] = description
|
|
240
|
+
data, _ = self._request("POST", "/books", json=payload)
|
|
241
|
+
return data
|
|
242
|
+
|
|
243
|
+
def delete_book(self, book_id: int):
|
|
244
|
+
data, _ = self._request("DELETE", f"/books/{book_id}")
|
|
245
|
+
return data
|
|
246
|
+
|
|
247
|
+
# ── podcasts ───────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
def list_podcasts(self, *, q=None, sort="created_at", order="desc",
|
|
250
|
+
page=1, per_page=20):
|
|
251
|
+
params = {"sort": sort, "order": order, "page": page,
|
|
252
|
+
"per_page": per_page}
|
|
253
|
+
if q:
|
|
254
|
+
params["q"] = q
|
|
255
|
+
data, meta = self._request("GET", "/podcasts", params=params)
|
|
256
|
+
return data, meta
|
|
257
|
+
|
|
258
|
+
def get_podcast(self, podcast_id: int):
|
|
259
|
+
data, _ = self._request("GET", f"/podcasts/{podcast_id}")
|
|
260
|
+
return data
|
|
261
|
+
|
|
262
|
+
def add_podcast(self, url: str):
|
|
263
|
+
data, _ = self._request("POST", "/podcasts", json={"url": url})
|
|
264
|
+
return data
|
|
265
|
+
|
|
266
|
+
def delete_podcast(self, podcast_id: int):
|
|
267
|
+
data, _ = self._request("DELETE", f"/podcasts/{podcast_id}")
|
|
268
|
+
return data
|
|
269
|
+
|
|
270
|
+
# ── patents ────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
def list_patents(self, *, q=None, sort="fetched_at", order="desc",
|
|
273
|
+
page=1, per_page=20):
|
|
274
|
+
params = {"sort": sort, "order": order, "page": page,
|
|
275
|
+
"per_page": per_page}
|
|
276
|
+
if q:
|
|
277
|
+
params["q"] = q
|
|
278
|
+
data, meta = self._request("GET", "/patents", params=params)
|
|
279
|
+
return data, meta
|
|
280
|
+
|
|
281
|
+
def get_patent(self, patent_id: int):
|
|
282
|
+
data, _ = self._request("GET", f"/patents/{patent_id}")
|
|
283
|
+
return data
|
|
284
|
+
|
|
285
|
+
def add_patent(self, *, url=None, patent_number=None):
|
|
286
|
+
payload = {}
|
|
287
|
+
if url:
|
|
288
|
+
payload["url"] = url
|
|
289
|
+
if patent_number:
|
|
290
|
+
payload["patent_number"] = patent_number
|
|
291
|
+
data, _ = self._request("POST", "/patents", json=payload)
|
|
292
|
+
return data
|
|
293
|
+
|
|
294
|
+
def delete_patent(self, patent_id: int):
|
|
295
|
+
data, _ = self._request("DELETE", f"/patents/{patent_id}")
|
|
296
|
+
return data
|
|
297
|
+
|
|
298
|
+
# ── chat ──────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
def chat_ask(self, question: str, *, session_id=None):
|
|
301
|
+
payload = {"question": question}
|
|
302
|
+
if session_id:
|
|
303
|
+
payload["session_id"] = session_id
|
|
304
|
+
data, _ = self._request("POST", "/chat/ask", json=payload)
|
|
305
|
+
return data
|
|
306
|
+
|
|
307
|
+
# ── radar sweep ────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
def radar_status(self):
|
|
310
|
+
data, _ = self._request("GET", "/radar/status")
|
|
311
|
+
return data
|
|
312
|
+
|
|
313
|
+
def list_watch_topics(self):
|
|
314
|
+
data, _ = self._request("GET", "/radar/topics")
|
|
315
|
+
return data
|
|
316
|
+
|
|
317
|
+
def create_watch_topic(self, topic: str, keywords=None):
|
|
318
|
+
payload = {"topic": topic}
|
|
319
|
+
if keywords:
|
|
320
|
+
payload["keywords"] = keywords
|
|
321
|
+
data, _ = self._request("POST", "/radar/topics", json=payload)
|
|
322
|
+
return data
|
|
323
|
+
|
|
324
|
+
def delete_watch_topic(self, topic_id: int):
|
|
325
|
+
data, _ = self._request("DELETE", f"/radar/topics/{topic_id}")
|
|
326
|
+
return data
|
|
327
|
+
|
|
328
|
+
def get_sweep_run(self, run_id: int):
|
|
329
|
+
data, _ = self._request("GET", f"/radar/runs/{run_id}")
|
|
330
|
+
return data
|
|
331
|
+
|
|
332
|
+
def accept_source(self, source_id: int):
|
|
333
|
+
data, _ = self._request("POST", f"/radar/sources/{source_id}/accept")
|
|
334
|
+
return data
|
|
335
|
+
|
|
336
|
+
def ignore_source(self, source_id: int):
|
|
337
|
+
data, _ = self._request("POST", f"/radar/sources/{source_id}/ignore")
|
|
338
|
+
return data
|
|
339
|
+
|
|
340
|
+
# ── knowledge graph ────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
def graph_stats(self):
|
|
343
|
+
data, _ = self._request("GET", "/graph/stats")
|
|
344
|
+
return data
|
|
345
|
+
|
|
346
|
+
def graph_neighbors(self, term: str, *, limit=10):
|
|
347
|
+
data, meta = self._request(
|
|
348
|
+
"GET", f"/graph/neighbors/{term}", params={"limit": limit}
|
|
349
|
+
)
|
|
350
|
+
return data, meta
|
|
351
|
+
|
|
352
|
+
def graph_paths(self, source: str, target: str, *, max_depth=3):
|
|
353
|
+
data, meta = self._request(
|
|
354
|
+
"POST", "/graph/paths",
|
|
355
|
+
json={"source": source, "target": target, "max_depth": max_depth},
|
|
356
|
+
)
|
|
357
|
+
return data, meta
|
|
358
|
+
|
|
359
|
+
# ── focus mode ─────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
def list_focus_bubbles(self):
|
|
362
|
+
data, _ = self._request("GET", "/focus")
|
|
363
|
+
return data
|
|
364
|
+
|
|
365
|
+
def get_focus_bubble(self, bubble_id: int):
|
|
366
|
+
data, _ = self._request("GET", f"/focus/{bubble_id}")
|
|
367
|
+
return data
|
|
368
|
+
|
|
369
|
+
def create_focus_bubble(self, topic: str):
|
|
370
|
+
data, _ = self._request("POST", "/focus", json={"topic": topic})
|
|
371
|
+
return data
|
|
372
|
+
|
|
373
|
+
def delete_focus_bubble(self, bubble_id: int):
|
|
374
|
+
data, _ = self._request("DELETE", f"/focus/{bubble_id}")
|
|
375
|
+
return data
|
|
376
|
+
|
|
377
|
+
def add_focus_concept(self, bubble_id: int, name: str, *,
|
|
378
|
+
definition=None, category="term"):
|
|
379
|
+
payload = {"name": name, "category": category}
|
|
380
|
+
if definition:
|
|
381
|
+
payload["definition"] = definition
|
|
382
|
+
data, _ = self._request(
|
|
383
|
+
"POST", f"/focus/{bubble_id}/concepts", json=payload
|
|
384
|
+
)
|
|
385
|
+
return data
|
|
386
|
+
|
|
387
|
+
# ── search ────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
def search(self, query: str, *, type="all", limit=10):
|
|
390
|
+
params = {"q": query, "type": type, "limit": limit}
|
|
391
|
+
data, meta = self._request("GET", "/search", params=params)
|
|
392
|
+
return data, meta
|
|
File without changes
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Article commands: add, list, view, delete."""
|
|
2
|
+
import json
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ..auth import get_token
|
|
8
|
+
from ..client import TisitClient
|
|
9
|
+
from ..config import Config
|
|
10
|
+
from ..display import (
|
|
11
|
+
print_error, print_article_detail, print_article_table,
|
|
12
|
+
print_success,
|
|
13
|
+
)
|
|
14
|
+
from ..exceptions import AuthenticationError, APIError, NotFoundError
|
|
15
|
+
|
|
16
|
+
article_app = typer.Typer(help="Manage your web articles")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_client() -> TisitClient:
|
|
20
|
+
token = get_token()
|
|
21
|
+
if not token:
|
|
22
|
+
print_error("Not logged in. Run: tisit login")
|
|
23
|
+
raise typer.Exit(code=1)
|
|
24
|
+
cfg = Config()
|
|
25
|
+
return TisitClient(cfg.api_url, token)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@article_app.command("list")
|
|
29
|
+
def article_list(
|
|
30
|
+
query: Optional[str] = typer.Option(None, "--query", "-q", help="Search title or domain"),
|
|
31
|
+
sort: str = typer.Option("fetched_at", "--sort", "-s", help="Sort by: fetched_at, title"),
|
|
32
|
+
order: str = typer.Option("desc", "--order", "-o", help="asc or desc"),
|
|
33
|
+
page: int = typer.Option(1, "--page", "-p"),
|
|
34
|
+
per_page: int = typer.Option(20, "--per-page"),
|
|
35
|
+
output_json: bool = typer.Option(False, "--json", help="JSON output"),
|
|
36
|
+
):
|
|
37
|
+
"""List your articles."""
|
|
38
|
+
client = _get_client()
|
|
39
|
+
try:
|
|
40
|
+
articles, meta = client.list_articles(
|
|
41
|
+
q=query, sort=sort, order=order, page=page, per_page=per_page,
|
|
42
|
+
)
|
|
43
|
+
except (AuthenticationError, APIError) as exc:
|
|
44
|
+
print_error(str(exc))
|
|
45
|
+
raise typer.Exit(code=1)
|
|
46
|
+
finally:
|
|
47
|
+
client.close()
|
|
48
|
+
|
|
49
|
+
if output_json:
|
|
50
|
+
typer.echo(json.dumps({"data": articles, "meta": meta}, indent=2, default=str))
|
|
51
|
+
else:
|
|
52
|
+
if not articles:
|
|
53
|
+
print_error("No articles found.")
|
|
54
|
+
else:
|
|
55
|
+
print_article_table(articles, meta)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@article_app.command("view")
|
|
59
|
+
def article_view(
|
|
60
|
+
article_id: int = typer.Argument(..., help="Article ID"),
|
|
61
|
+
output_json: bool = typer.Option(False, "--json", help="JSON output"),
|
|
62
|
+
):
|
|
63
|
+
"""View an article in detail."""
|
|
64
|
+
client = _get_client()
|
|
65
|
+
try:
|
|
66
|
+
article = client.get_article(article_id)
|
|
67
|
+
except NotFoundError:
|
|
68
|
+
print_error(f"Article {article_id} not found.")
|
|
69
|
+
raise typer.Exit(code=1)
|
|
70
|
+
except (AuthenticationError, APIError) as exc:
|
|
71
|
+
print_error(str(exc))
|
|
72
|
+
raise typer.Exit(code=1)
|
|
73
|
+
finally:
|
|
74
|
+
client.close()
|
|
75
|
+
|
|
76
|
+
if output_json:
|
|
77
|
+
typer.echo(json.dumps(article, indent=2, default=str))
|
|
78
|
+
else:
|
|
79
|
+
print_article_detail(article)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@article_app.command("add")
|
|
83
|
+
def article_add(
|
|
84
|
+
url: str = typer.Argument(..., help="Article URL"),
|
|
85
|
+
output_json: bool = typer.Option(False, "--json", help="JSON output"),
|
|
86
|
+
):
|
|
87
|
+
"""Add an article by URL (async — queued for processing)."""
|
|
88
|
+
client = _get_client()
|
|
89
|
+
try:
|
|
90
|
+
data = client.add_article(url)
|
|
91
|
+
except (AuthenticationError, APIError) as exc:
|
|
92
|
+
print_error(str(exc))
|
|
93
|
+
raise typer.Exit(code=1)
|
|
94
|
+
finally:
|
|
95
|
+
client.close()
|
|
96
|
+
|
|
97
|
+
if output_json:
|
|
98
|
+
typer.echo(json.dumps(data, indent=2, default=str))
|
|
99
|
+
else:
|
|
100
|
+
if data.get("is_duplicate"):
|
|
101
|
+
print_success(f"Article already exists (ID: {data['article_id']}).")
|
|
102
|
+
else:
|
|
103
|
+
print_success(
|
|
104
|
+
f"Article queued for processing (ID: {data['article_id']}).\n"
|
|
105
|
+
f" Check status with: tisit article view {data['article_id']}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@article_app.command("delete")
|
|
110
|
+
def article_delete(
|
|
111
|
+
article_id: int = typer.Argument(..., help="Article ID to delete"),
|
|
112
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
113
|
+
):
|
|
114
|
+
"""Delete an article."""
|
|
115
|
+
if not yes:
|
|
116
|
+
confirm = typer.confirm(f"Delete article {article_id}?")
|
|
117
|
+
if not confirm:
|
|
118
|
+
raise typer.Abort()
|
|
119
|
+
|
|
120
|
+
client = _get_client()
|
|
121
|
+
try:
|
|
122
|
+
client.delete_article(article_id)
|
|
123
|
+
except NotFoundError:
|
|
124
|
+
print_error(f"Article {article_id} not found.")
|
|
125
|
+
raise typer.Exit(code=1)
|
|
126
|
+
except (AuthenticationError, APIError) as exc:
|
|
127
|
+
print_error(str(exc))
|
|
128
|
+
raise typer.Exit(code=1)
|
|
129
|
+
finally:
|
|
130
|
+
client.close()
|
|
131
|
+
|
|
132
|
+
print_success(f"Article {article_id} deleted.")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Auth commands: login, logout, whoami."""
|
|
2
|
+
import json
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from ..auth import store_token, get_token, delete_token, validate_token_format
|
|
6
|
+
from ..client import TisitClient
|
|
7
|
+
from ..config import Config
|
|
8
|
+
from ..display import print_success, print_error, print_info
|
|
9
|
+
from ..exceptions import APIError, AuthenticationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def login(
|
|
13
|
+
token: str = typer.Option(
|
|
14
|
+
None, "--token", "-t",
|
|
15
|
+
help="API token (prompted securely if omitted)",
|
|
16
|
+
),
|
|
17
|
+
api_url: str = typer.Option(
|
|
18
|
+
None, "--api-url", "-u",
|
|
19
|
+
help="Server URL (saved to config)",
|
|
20
|
+
),
|
|
21
|
+
):
|
|
22
|
+
"""Authenticate with your TISIT API token."""
|
|
23
|
+
if not token:
|
|
24
|
+
token = typer.prompt("API token", hide_input=True)
|
|
25
|
+
|
|
26
|
+
token = token.strip()
|
|
27
|
+
if not validate_token_format(token):
|
|
28
|
+
print_error("Invalid token format. Tokens start with 'tsk_'.")
|
|
29
|
+
raise typer.Exit(code=1)
|
|
30
|
+
|
|
31
|
+
cfg = Config()
|
|
32
|
+
if api_url:
|
|
33
|
+
cfg.set("api_url", api_url.rstrip("/"))
|
|
34
|
+
cfg.save()
|
|
35
|
+
|
|
36
|
+
# Verify token against the server
|
|
37
|
+
client = TisitClient(cfg.api_url, token)
|
|
38
|
+
try:
|
|
39
|
+
client.status()
|
|
40
|
+
except (AuthenticationError, APIError) as exc:
|
|
41
|
+
print_error(f"Token verification failed: {exc}")
|
|
42
|
+
raise typer.Exit(code=1)
|
|
43
|
+
finally:
|
|
44
|
+
client.close()
|
|
45
|
+
|
|
46
|
+
store_token(token)
|
|
47
|
+
print_success(f"Logged in to {cfg.api_url}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def logout():
|
|
51
|
+
"""Clear stored credentials."""
|
|
52
|
+
delete_token()
|
|
53
|
+
print_success("Logged out.")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def whoami(
|
|
57
|
+
output_json: bool = typer.Option(False, "--json", help="JSON output"),
|
|
58
|
+
):
|
|
59
|
+
"""Show current authentication status."""
|
|
60
|
+
token = get_token()
|
|
61
|
+
if not token:
|
|
62
|
+
print_error("Not logged in. Run: tisit login")
|
|
63
|
+
raise typer.Exit(code=1)
|
|
64
|
+
|
|
65
|
+
cfg = Config()
|
|
66
|
+
client = TisitClient(cfg.api_url, token)
|
|
67
|
+
try:
|
|
68
|
+
data = client.status()
|
|
69
|
+
connected = True
|
|
70
|
+
except Exception:
|
|
71
|
+
data = {}
|
|
72
|
+
connected = False
|
|
73
|
+
finally:
|
|
74
|
+
client.close()
|
|
75
|
+
|
|
76
|
+
prefix = token[:12] + "..."
|
|
77
|
+
|
|
78
|
+
if output_json:
|
|
79
|
+
typer.echo(json.dumps({
|
|
80
|
+
"token_prefix": prefix,
|
|
81
|
+
"api_url": cfg.api_url,
|
|
82
|
+
"connected": connected,
|
|
83
|
+
"server_status": data.get("status") if data else None,
|
|
84
|
+
}, indent=2))
|
|
85
|
+
else:
|
|
86
|
+
print_info(f"Token: {prefix}")
|
|
87
|
+
print_info(f"Server: {cfg.api_url}")
|
|
88
|
+
if connected:
|
|
89
|
+
print_success(f"Status: connected (server {data.get('status', 'ok')})")
|
|
90
|
+
else:
|
|
91
|
+
print_error("Status: cannot reach server")
|