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 +72 -0
- tsk_cli-0.1.0/README.md +49 -0
- tsk_cli-0.1.0/pyproject.toml +35 -0
- tsk_cli-0.1.0/setup.cfg +4 -0
- tsk_cli-0.1.0/tsk_cli/__init__.py +0 -0
- tsk_cli-0.1.0/tsk_cli/api.py +75 -0
- tsk_cli-0.1.0/tsk_cli/auth.py +133 -0
- tsk_cli-0.1.0/tsk_cli/config.py +73 -0
- tsk_cli-0.1.0/tsk_cli/list_cmd.py +87 -0
- tsk_cli-0.1.0/tsk_cli/main.py +234 -0
- tsk_cli-0.1.0/tsk_cli/pull.py +432 -0
- tsk_cli-0.1.0/tsk_cli/push.py +246 -0
- tsk_cli-0.1.0/tsk_cli/resolve.py +40 -0
- tsk_cli-0.1.0/tsk_cli/set_status.py +60 -0
- tsk_cli-0.1.0/tsk_cli/show.py +59 -0
- tsk_cli-0.1.0/tsk_cli.egg-info/PKG-INFO +72 -0
- tsk_cli-0.1.0/tsk_cli.egg-info/SOURCES.txt +19 -0
- tsk_cli-0.1.0/tsk_cli.egg-info/dependency_links.txt +1 -0
- tsk_cli-0.1.0/tsk_cli.egg-info/entry_points.txt +2 -0
- tsk_cli-0.1.0/tsk_cli.egg-info/requires.txt +2 -0
- tsk_cli-0.1.0/tsk_cli.egg-info/top_level.txt +1 -0
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
|
tsk_cli-0.1.0/README.md
ADDED
|
@@ -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"
|
tsk_cli-0.1.0/setup.cfg
ADDED
|
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)")
|