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 ADDED
@@ -0,0 +1,2 @@
1
+ """TISIT CLI — terminal-native client for the TISIT learning platform."""
2
+ __version__ = "0.1.0"
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")