tsk-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.
tsk_cli/__init__.py ADDED
File without changes
tsk_cli/api.py ADDED
@@ -0,0 +1,75 @@
1
+ """
2
+ HTTP client that uses stored session cookies to talk to the TSK backend.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import sys
8
+
9
+ import click
10
+ import requests
11
+
12
+ from .config import get_auth, get_server_url
13
+
14
+
15
+ def create_session() -> requests.Session:
16
+ """Build a requests.Session pre-loaded with stored auth cookies."""
17
+ auth = get_auth()
18
+ s = requests.Session()
19
+ sessionid = auth.get("sessionid", "")
20
+ csrftoken = auth.get("csrftoken", "")
21
+ if sessionid:
22
+ s.cookies.set("sessionid", sessionid)
23
+ if csrftoken:
24
+ s.cookies.set("csrftoken", csrftoken)
25
+ s.headers["X-CSRFToken"] = csrftoken
26
+ return s
27
+
28
+
29
+ def require_auth() -> tuple[requests.Session, str]:
30
+ """
31
+ Return (session, server_url) or exit with an error message
32
+ if not configured / not authenticated.
33
+ """
34
+ server_url = get_server_url()
35
+ auth = get_auth()
36
+ if not auth.get("sessionid"):
37
+ click.echo("Not logged in. Run: tsk login", err=True)
38
+ sys.exit(1)
39
+
40
+ session = create_session()
41
+ server_url = server_url.rstrip("/")
42
+ return session, server_url
43
+
44
+
45
+ def api_get(path: str) -> dict | list:
46
+ """GET an API endpoint. Exits on auth failure."""
47
+ session, server_url = require_auth()
48
+ resp = session.get(f"{server_url}{path}")
49
+ if resp.status_code == 401 or resp.status_code == 403:
50
+ click.echo("Session expired. Run: tsk login", err=True)
51
+ sys.exit(1)
52
+ resp.raise_for_status()
53
+ return resp.json()
54
+
55
+
56
+ def api_post(path: str, json_data: dict) -> dict:
57
+ """POST to an API endpoint. Exits on auth failure."""
58
+ session, server_url = require_auth()
59
+ resp = session.post(f"{server_url}{path}", json=json_data)
60
+ if resp.status_code in (401, 403):
61
+ click.echo("Session expired. Run: tsk login", err=True)
62
+ sys.exit(1)
63
+ resp.raise_for_status()
64
+ return resp.json()
65
+
66
+
67
+ def api_patch(path: str, json_data: dict) -> dict:
68
+ """PATCH an API endpoint. Exits on auth failure."""
69
+ session, server_url = require_auth()
70
+ resp = session.patch(f"{server_url}{path}", json=json_data)
71
+ if resp.status_code in (401, 403):
72
+ click.echo("Session expired. Run: tsk login", err=True)
73
+ sys.exit(1)
74
+ resp.raise_for_status()
75
+ return resp.json()
tsk_cli/auth.py ADDED
@@ -0,0 +1,133 @@
1
+ """
2
+ Browser-based OAuth login flow for the CLI.
3
+
4
+ 1. Start a tiny HTTP server on a random localhost port.
5
+ 2. Open the browser to the app's login page with ?cli_callback=http://localhost:<port>/callback
6
+ 3. After the user signs in, the frontend redirects to our callback with a one-time auth code.
7
+ 4. We exchange the code for session credentials via the backend's code-exchange endpoint.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import http.server
13
+ import threading
14
+ import urllib.parse
15
+ import webbrowser
16
+ from typing import Optional
17
+
18
+ import click
19
+ import requests
20
+
21
+ from .config import get_server_url, save_auth
22
+
23
+
24
+ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
25
+ """Handles the redirect from the frontend after successful OAuth."""
26
+
27
+ auth_code: Optional[str] = None
28
+
29
+ def do_GET(self) -> None:
30
+ parsed = urllib.parse.urlparse(self.path)
31
+ params = urllib.parse.parse_qs(parsed.query)
32
+
33
+ code = params.get("code", [None])[0]
34
+
35
+ if code:
36
+ _CallbackHandler.auth_code = code
37
+ self.send_response(200)
38
+ self.send_header("Content-Type", "text/html")
39
+ self.end_headers()
40
+ self.wfile.write(
41
+ b"<html><body><h2>Authenticated!</h2>"
42
+ b"<p>You can close this tab and return to the terminal.</p>"
43
+ b"</body></html>"
44
+ )
45
+ else:
46
+ self.send_response(400)
47
+ self.send_header("Content-Type", "text/html")
48
+ self.end_headers()
49
+ self.wfile.write(b"<html><body><p>Missing credentials.</p></body></html>")
50
+
51
+ def log_message(self, format: str, *args: object) -> None: # noqa: A002
52
+ pass
53
+
54
+
55
+ def login_flow() -> bool:
56
+ """Run the interactive browser login. Returns True on success."""
57
+ server_url = get_server_url().rstrip("/")
58
+
59
+ server = http.server.HTTPServer(("127.0.0.1", 0), _CallbackHandler)
60
+ port = server.server_address[1]
61
+ callback_url = f"http://localhost:{port}/callback"
62
+
63
+ login_url = f"{server_url}/login?cli_callback={urllib.parse.quote(callback_url)}"
64
+
65
+ click.echo(f"Opening browser to sign in...")
66
+ click.echo(f" {login_url}")
67
+ click.echo()
68
+ click.echo("Waiting for authentication...")
69
+
70
+ webbrowser.open(login_url)
71
+
72
+ _CallbackHandler.auth_code = None
73
+
74
+ server.timeout = 120
75
+ while _CallbackHandler.auth_code is None:
76
+ server.handle_request()
77
+ if _CallbackHandler.auth_code is None:
78
+ break
79
+
80
+ server.server_close()
81
+
82
+ if _CallbackHandler.auth_code:
83
+ ok = _exchange_code(server_url, _CallbackHandler.auth_code)
84
+ if ok:
85
+ ok2, who = _verify_session(server_url)
86
+ if ok2:
87
+ click.echo(f"Logged in as {who}")
88
+ return True
89
+ else:
90
+ click.echo("Session could not be verified. Try again.")
91
+ return False
92
+ else:
93
+ click.echo("Code exchange failed. Try again.")
94
+ return False
95
+
96
+ click.echo("Login timed out or was cancelled.")
97
+ return False
98
+
99
+
100
+ def _exchange_code(server_url: str, code: str) -> bool:
101
+ """Exchange the one-time auth code for session credentials."""
102
+ try:
103
+ resp = requests.post(
104
+ f"{server_url}/api/auth/cli-code-exchange/",
105
+ json={"code": code},
106
+ timeout=15,
107
+ )
108
+ if resp.status_code == 200:
109
+ data = resp.json()
110
+ sid = data.get("sessionid", "")
111
+ csrf = data.get("csrftoken", "")
112
+ if sid:
113
+ save_auth(sessionid=sid, csrftoken=csrf)
114
+ return True
115
+ except requests.RequestException:
116
+ pass
117
+ return False
118
+
119
+
120
+ def _verify_session(server_url: str) -> tuple[bool, str]:
121
+ """Hit /api/auth/me/ to confirm the stored session is valid."""
122
+ from .api import create_session
123
+
124
+ session = create_session()
125
+ try:
126
+ resp = session.get(f"{server_url}/api/auth/me/")
127
+ if resp.status_code == 200:
128
+ data = resp.json()
129
+ name = data.get("display_name") or data.get("email", "unknown")
130
+ return True, name
131
+ except requests.RequestException:
132
+ pass
133
+ return False, ""
tsk_cli/config.py ADDED
@@ -0,0 +1,73 @@
1
+ """
2
+ Manage CLI configuration stored in ~/.config/tsk/.
3
+
4
+ config.json -- server URL, default org id/name
5
+ auth.json -- session cookie + CSRF token
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any, Optional
13
+
14
+ CONFIG_DIR = Path.home() / ".config" / "tsk"
15
+ CONFIG_FILE = CONFIG_DIR / "config.json"
16
+ AUTH_FILE = CONFIG_DIR / "auth.json"
17
+
18
+
19
+ def _ensure_dir() -> None:
20
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
21
+
22
+
23
+ def _read_json(path: Path) -> dict[str, Any]:
24
+ if not path.exists():
25
+ return {}
26
+ return json.loads(path.read_text())
27
+
28
+
29
+ def _write_json(path: Path, data: dict[str, Any]) -> None:
30
+ _ensure_dir()
31
+ path.write_text(json.dumps(data, indent=2) + "\n")
32
+
33
+
34
+ # ── Config (server + org) ────────────────────────────────────────────
35
+
36
+
37
+ def get_config() -> dict[str, Any]:
38
+ return _read_json(CONFIG_FILE)
39
+
40
+
41
+ def set_config(**fields: Any) -> dict[str, Any]:
42
+ cfg = get_config()
43
+ cfg.update(fields)
44
+ _write_json(CONFIG_FILE, cfg)
45
+ return cfg
46
+
47
+
48
+ DEFAULT_SERVER_URL = "https://tsk.tools"
49
+
50
+
51
+ def get_server_url() -> str:
52
+ return get_config().get("server_url") or DEFAULT_SERVER_URL
53
+
54
+
55
+ def get_default_org() -> Optional[dict[str, str]]:
56
+ """Return {"id": ..., "name": ...} or None."""
57
+ return get_config().get("org")
58
+
59
+
60
+ # ── Auth (cookies) ───────────────────────────────────────────────────
61
+
62
+
63
+ def get_auth() -> dict[str, Any]:
64
+ return _read_json(AUTH_FILE)
65
+
66
+
67
+ def save_auth(*, sessionid: str, csrftoken: str) -> None:
68
+ _write_json(AUTH_FILE, {"sessionid": sessionid, "csrftoken": csrftoken})
69
+
70
+
71
+ def clear_auth() -> None:
72
+ if AUTH_FILE.exists():
73
+ AUTH_FILE.unlink()
tsk_cli/list_cmd.py ADDED
@@ -0,0 +1,87 @@
1
+ """
2
+ tsk list -- list things with optional filters.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import click
11
+
12
+ from .push import parse_thing_markdown
13
+ from .resolve import task_root
14
+
15
+ VALID_STATUSES = ("not_started", "in_progress", "in_review", "done")
16
+
17
+ _STATUS_COLORS = {
18
+ "not_started": "white",
19
+ "in_progress": "cyan",
20
+ "in_review": "yellow",
21
+ "done": "green",
22
+ }
23
+
24
+
25
+ def list_things(
26
+ *,
27
+ status_filter: Optional[str] = None,
28
+ project_filter: Optional[str] = None,
29
+ target_dir: Path | None = None,
30
+ ) -> None:
31
+ root = task_root(target_dir)
32
+ projects_dir = root / "projects"
33
+
34
+ if not projects_dir.is_dir():
35
+ click.echo("No .task/projects directory. Run: tsk pull", err=True)
36
+ raise SystemExit(1)
37
+
38
+ if status_filter and status_filter not in VALID_STATUSES:
39
+ click.echo(
40
+ f"Invalid status: {status_filter}. Must be one of: {', '.join(VALID_STATUSES)}",
41
+ err=True,
42
+ )
43
+ raise SystemExit(1)
44
+
45
+ items: list[tuple[str, str, str, str]] = []
46
+
47
+ for md in sorted(projects_dir.glob("**/things/*.md")):
48
+ try:
49
+ parsed = parse_thing_markdown(md.read_text())
50
+ except ValueError:
51
+ continue
52
+
53
+ ref = parsed.frontmatter.get("ref", md.stem)
54
+ status = parsed.frontmatter.get("status", "not_started")
55
+ assigned = parsed.frontmatter.get("assigned_to", "")
56
+ name = parsed.name
57
+
58
+ if project_filter:
59
+ abbr_part = ref.split("-")[0] if "-" in ref else ""
60
+ if abbr_part.upper() != project_filter.upper():
61
+ continue
62
+
63
+ if status_filter and status != status_filter:
64
+ continue
65
+
66
+ items.append((ref, status, name, assigned))
67
+
68
+ if not items:
69
+ click.echo("No things found matching filters.")
70
+ return
71
+
72
+ ref_w = max(len(r) for r, _, _, _ in items)
73
+ status_w = max(len(s) for _, s, _, _ in items)
74
+
75
+ for ref, status, name, assigned in items:
76
+ color = _STATUS_COLORS.get(status, "white")
77
+ name_trunc = name[:50] + ("…" if len(name) > 50 else "")
78
+ line = (
79
+ f" {ref:<{ref_w}} "
80
+ f"{click.style(status, fg=color):<{status_w + 10}} "
81
+ f"{name_trunc}"
82
+ )
83
+ if assigned:
84
+ line += f" ({assigned})"
85
+ click.echo(line)
86
+
87
+ click.echo(f"\n {len(items)} thing(s)")
tsk_cli/main.py ADDED
@@ -0,0 +1,234 @@
1
+ """
2
+ tsk -- CLI for TSK task management.
3
+
4
+ Commands:
5
+ tsk login [--server URL] Authenticate via browser
6
+ tsk logout Clear stored credentials
7
+ tsk orgs List your organizations
8
+ tsk org <name-or-id> Set the default organization
9
+ tsk pull Sync projects/things to .task/
10
+ tsk push [--force] Upload local thing edits to the server
11
+ tsk status Show current config and auth state
12
+ tsk list [--status] [--project] List things with optional filters
13
+ tsk show <ref> Display a thing's details
14
+ tsk set-status <ref> <status> Update status and push immediately
15
+ tsk start <ref> Set status to in_progress
16
+ tsk done <ref> Set status to done
17
+ tsk review <ref> Set status to in_review
18
+ tsk open <ref> Open thing in browser
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import click
24
+
25
+ from . import config as cfg
26
+
27
+
28
+ @click.group()
29
+ @click.version_option(package_name="tsk-cli")
30
+ def cli() -> None:
31
+ """TSK -- pull projects and things for editor agents."""
32
+
33
+
34
+ @cli.command()
35
+ @click.option("--server", default=None, help="Server URL (default: https://tsk.tools)")
36
+ def login(server: str | None) -> None:
37
+ """Authenticate with the TSK server via browser OAuth."""
38
+ if server:
39
+ cfg.set_config(server_url=server.rstrip("/"))
40
+ click.echo(f"Server set to: {server}")
41
+
42
+ from .auth import login_flow
43
+
44
+ login_flow()
45
+
46
+
47
+ @cli.command()
48
+ def logout() -> None:
49
+ """Clear stored credentials."""
50
+ cfg.clear_auth()
51
+ click.echo("Logged out.")
52
+
53
+
54
+ @cli.command()
55
+ def orgs() -> None:
56
+ """List organizations you belong to."""
57
+ from .api import api_get
58
+
59
+ data = api_get("/api/organizations/")
60
+ if not data:
61
+ click.echo("No organizations found.")
62
+ return
63
+
64
+ current = cfg.get_default_org()
65
+ current_id = current["id"] if current else None
66
+
67
+ for org in data:
68
+ marker = " *" if org["id"] == current_id else ""
69
+ click.echo(f" {org['name']} ({org['id']}){marker}")
70
+
71
+ if not current_id:
72
+ click.echo()
73
+ click.echo("Set a default org with: tsk org <name-or-id>")
74
+
75
+
76
+ @cli.command()
77
+ @click.argument("name_or_id")
78
+ def org(name_or_id: str) -> None:
79
+ """Set the default organization by name or ID."""
80
+ from .api import api_get
81
+
82
+ data = api_get("/api/organizations/")
83
+ match = None
84
+ needle = name_or_id.lower()
85
+ for o in data:
86
+ if o["id"] == name_or_id or o["name"].lower() == needle:
87
+ match = o
88
+ break
89
+
90
+ if not match:
91
+ click.echo(f"Organization not found: {name_or_id}", err=True)
92
+ raise SystemExit(1)
93
+
94
+ cfg.set_config(org={"id": match["id"], "name": match["name"]})
95
+ click.echo(f"Default org set to: {match['name']}")
96
+
97
+
98
+ @cli.command()
99
+ def pull() -> None:
100
+ """Fetch projects and things, write to .task/ directory."""
101
+ from .pull import pull as do_pull
102
+
103
+ do_pull()
104
+
105
+
106
+ @cli.command()
107
+ @click.option(
108
+ "--force",
109
+ is_flag=True,
110
+ help="Skip server updated_at check (may overwrite others' edits).",
111
+ )
112
+ def push(force: bool) -> None:
113
+ """Upload local changes under .task/ to the server (things only)."""
114
+ from .push import push as do_push
115
+
116
+ do_push(force=force)
117
+
118
+
119
+ @cli.command()
120
+ def status() -> None:
121
+ """Show current configuration and auth state."""
122
+ conf = cfg.get_config()
123
+ auth = cfg.get_auth()
124
+
125
+ server = conf.get("server_url") or cfg.get_server_url()
126
+ click.echo(f"Server: {server}")
127
+
128
+ org_info = conf.get("org")
129
+ if org_info:
130
+ click.echo(f"Org: {org_info['name']} ({org_info['id']})")
131
+ else:
132
+ click.echo("Org: (not set)")
133
+
134
+ if auth.get("sessionid"):
135
+ click.echo("Auth: logged in")
136
+ # Verify the session is still valid
137
+ if conf.get("server_url"):
138
+ from .api import create_session
139
+
140
+ session = create_session()
141
+ try:
142
+ resp = session.get(f"{conf['server_url']}/api/auth/me/")
143
+ if resp.status_code == 200:
144
+ user = resp.json()
145
+ name = user.get("display_name") or user.get("email", "")
146
+ click.echo(f"User: {name}")
147
+ else:
148
+ click.echo("User: (session expired -- run tsk login)")
149
+ except Exception:
150
+ click.echo("User: (could not reach server)")
151
+ else:
152
+ click.echo("Auth: not logged in")
153
+
154
+
155
+ # ── New commands ──────────────────────────────────────────────────────
156
+
157
+
158
+ @cli.command("set-status")
159
+ @click.argument("ref")
160
+ @click.argument("new_status")
161
+ def set_status_cmd(ref: str, new_status: str) -> None:
162
+ """Update a thing's status and push immediately. REF is e.g. COM-001."""
163
+ from .set_status import set_status
164
+
165
+ set_status(ref, new_status)
166
+
167
+
168
+ @cli.command()
169
+ @click.argument("ref")
170
+ def start(ref: str) -> None:
171
+ """Set a thing's status to in_progress."""
172
+ from .set_status import set_status
173
+
174
+ set_status(ref, "in_progress")
175
+
176
+
177
+ @cli.command()
178
+ @click.argument("ref")
179
+ def done(ref: str) -> None:
180
+ """Set a thing's status to done."""
181
+ from .set_status import set_status
182
+
183
+ set_status(ref, "done")
184
+
185
+
186
+ @cli.command()
187
+ @click.argument("ref")
188
+ def review(ref: str) -> None:
189
+ """Set a thing's status to in_review."""
190
+ from .set_status import set_status
191
+
192
+ set_status(ref, "in_review")
193
+
194
+
195
+ @cli.command("show")
196
+ @click.argument("ref")
197
+ def show_cmd(ref: str) -> None:
198
+ """Display a thing's details."""
199
+ from .show import show
200
+
201
+ show(ref)
202
+
203
+
204
+ @cli.command("list")
205
+ @click.option("--status", "status_filter", default=None, help="Filter by status.")
206
+ @click.option("--project", "project_filter", default=None, help="Filter by project abbreviation.")
207
+ def list_cmd(status_filter: str | None, project_filter: str | None) -> None:
208
+ """List things with optional filters."""
209
+ from .list_cmd import list_things
210
+
211
+ list_things(status_filter=status_filter, project_filter=project_filter)
212
+
213
+
214
+ @cli.command("open")
215
+ @click.argument("ref")
216
+ def open_cmd(ref: str) -> None:
217
+ """Open a thing in the browser at tsk.tools."""
218
+ from .resolve import find_thing_file, parse_thing_at
219
+
220
+ thing_path = find_thing_file(ref)
221
+ if not thing_path:
222
+ click.echo(f"Thing not found: {ref}. Run tsk pull first.", err=True)
223
+ raise SystemExit(1)
224
+
225
+ parsed = parse_thing_at(thing_path)
226
+ thing_id = parsed.frontmatter.get("id", "")
227
+ if not thing_id:
228
+ click.echo(f"Thing file missing id: {thing_path}", err=True)
229
+ raise SystemExit(1)
230
+
231
+ server = cfg.get_server_url()
232
+ url = f"{server}/projects?thing={thing_id}"
233
+ click.echo(f"Opening {url}")
234
+ click.launch(url)