tsk-cli 0.1.0__tar.gz

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-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: tsk-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for TSK — pull projects and things for editor agents
5
+ Author: Rob Crosby
6
+ License: MIT
7
+ Project-URL: Homepage, https://tsk.tools
8
+ Project-URL: Repository, https://github.com/rncrosby/text_plan
9
+ Project-URL: Issues, https://github.com/rncrosby/text_plan/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Bug Tracking
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: click>=8.1
22
+ Requires-Dist: requests>=2.31
23
+
24
+ # tsk-cli
25
+
26
+ Command-line interface for [TSK](https://tsk.tools) — pull projects and things into your editor so AI agents can work with them.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pipx install tsk-cli
32
+ # or
33
+ pip install tsk-cli
34
+ ```
35
+
36
+ Requires Python 3.11+.
37
+
38
+ ## Quick start
39
+
40
+ ```bash
41
+ tsk login # authenticate via browser
42
+ tsk orgs # list your organizations
43
+ tsk org <name> # set the default org
44
+ tsk pull # sync projects and things to .task/
45
+ ```
46
+
47
+ ## Commands
48
+
49
+ | Command | Description |
50
+ |---------|-------------|
51
+ | `tsk login [--server URL]` | Authenticate via browser OAuth |
52
+ | `tsk logout` | Clear stored credentials |
53
+ | `tsk orgs` | List your organizations |
54
+ | `tsk org <name-or-id>` | Set the default organization |
55
+ | `tsk pull` | Sync projects/things to `.task/` |
56
+ | `tsk push [--force]` | Upload local edits to the server |
57
+ | `tsk status` | Show current config and auth state |
58
+ | `tsk list [--status S] [--project P]` | List things with optional filters |
59
+ | `tsk show <ref>` | Display a thing's details |
60
+ | `tsk set-status <ref> <status>` | Update status and push immediately |
61
+ | `tsk start <ref>` | Set status to `in_progress` |
62
+ | `tsk done <ref>` | Set status to `done` |
63
+ | `tsk review <ref>` | Set status to `in_review` |
64
+ | `tsk open <ref>` | Open thing in browser |
65
+
66
+ ## How it works
67
+
68
+ `tsk pull` writes a `.task/` directory containing your projects and things as markdown files. Editor agents (Cursor, Copilot, etc.) can read these files to understand what to work on. After making changes locally, `tsk push` syncs them back to the server.
69
+
70
+ ## License
71
+
72
+ MIT
@@ -0,0 +1,49 @@
1
+ # tsk-cli
2
+
3
+ Command-line interface for [TSK](https://tsk.tools) — pull projects and things into your editor so AI agents can work with them.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pipx install tsk-cli
9
+ # or
10
+ pip install tsk-cli
11
+ ```
12
+
13
+ Requires Python 3.11+.
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ tsk login # authenticate via browser
19
+ tsk orgs # list your organizations
20
+ tsk org <name> # set the default org
21
+ tsk pull # sync projects and things to .task/
22
+ ```
23
+
24
+ ## Commands
25
+
26
+ | Command | Description |
27
+ |---------|-------------|
28
+ | `tsk login [--server URL]` | Authenticate via browser OAuth |
29
+ | `tsk logout` | Clear stored credentials |
30
+ | `tsk orgs` | List your organizations |
31
+ | `tsk org <name-or-id>` | Set the default organization |
32
+ | `tsk pull` | Sync projects/things to `.task/` |
33
+ | `tsk push [--force]` | Upload local edits to the server |
34
+ | `tsk status` | Show current config and auth state |
35
+ | `tsk list [--status S] [--project P]` | List things with optional filters |
36
+ | `tsk show <ref>` | Display a thing's details |
37
+ | `tsk set-status <ref> <status>` | Update status and push immediately |
38
+ | `tsk start <ref>` | Set status to `in_progress` |
39
+ | `tsk done <ref>` | Set status to `done` |
40
+ | `tsk review <ref>` | Set status to `in_review` |
41
+ | `tsk open <ref>` | Open thing in browser |
42
+
43
+ ## How it works
44
+
45
+ `tsk pull` writes a `.task/` directory containing your projects and things as markdown files. Editor agents (Cursor, Copilot, etc.) can read these files to understand what to work on. After making changes locally, `tsk push` syncs them back to the server.
46
+
47
+ ## License
48
+
49
+ MIT
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tsk-cli"
7
+ version = "0.1.0"
8
+ description = "CLI for TSK — pull projects and things for editor agents"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.11"
12
+ authors = [{name = "Rob Crosby"}]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Software Development :: Bug Tracking",
23
+ ]
24
+ dependencies = [
25
+ "click>=8.1",
26
+ "requests>=2.31",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://tsk.tools"
31
+ Repository = "https://github.com/rncrosby/text_plan"
32
+ Issues = "https://github.com/rncrosby/text_plan/issues"
33
+
34
+ [project.scripts]
35
+ tsk = "tsk_cli.main:cli"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -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()
@@ -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, ""
@@ -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()
@@ -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)")