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 +0 -0
- tsk_cli/api.py +75 -0
- tsk_cli/auth.py +133 -0
- tsk_cli/config.py +73 -0
- tsk_cli/list_cmd.py +87 -0
- tsk_cli/main.py +234 -0
- tsk_cli/pull.py +432 -0
- tsk_cli/push.py +246 -0
- tsk_cli/resolve.py +40 -0
- tsk_cli/set_status.py +60 -0
- tsk_cli/show.py +59 -0
- tsk_cli-0.1.0.dist-info/METADATA +72 -0
- tsk_cli-0.1.0.dist-info/RECORD +16 -0
- tsk_cli-0.1.0.dist-info/WHEEL +5 -0
- tsk_cli-0.1.0.dist-info/entry_points.txt +2 -0
- tsk_cli-0.1.0.dist-info/top_level.txt +1 -0
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)
|