taskops-cli 0.3.0__py3-none-any.whl → 0.3.2__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.
Files changed (52) hide show
  1. taskops/_version.py +1 -1
  2. taskops/_wire.py +45 -0
  3. taskops/cli/commands.py +43 -34
  4. taskops/cli/enrol.py +82 -0
  5. taskops/cli/grants.py +59 -0
  6. taskops/cli/hooks.py +50 -0
  7. taskops/cli/main.py +42 -91
  8. taskops/cli/operate.py +57 -40
  9. taskops/cli/paging.py +75 -0
  10. taskops/cli/parser.py +149 -0
  11. taskops/cli/pull.py +162 -0
  12. taskops/cli/rm.py +63 -0
  13. taskops/cli/serving.py +14 -16
  14. taskops/cli/window.py +154 -0
  15. taskops/core/event.py +17 -7
  16. taskops/core/forge.py +85 -0
  17. taskops/core/holding.py +100 -0
  18. taskops/core/replay.py +1 -1
  19. taskops/core/reports.py +116 -0
  20. taskops/core/scope.py +17 -0
  21. taskops/core/types.py +4 -0
  22. taskops/gitwork/bind.py +11 -2
  23. taskops/gitwork/install.py +15 -0
  24. taskops/gitwork/patch.py +24 -4
  25. taskops/gitwork/remote.py +14 -1
  26. taskops/http/admin.py +33 -2
  27. taskops/http/gitdoor.py +96 -1
  28. taskops/http/github.py +180 -0
  29. taskops/http/handler.py +31 -2
  30. taskops/http/ingest.py +10 -5
  31. taskops/http/login.py +12 -2
  32. taskops/http/mounts.py +30 -2
  33. taskops/http/removal.py +106 -0
  34. taskops/http/server.py +20 -0
  35. taskops/http/static.py +30 -0
  36. taskops/http/upstream.py +1 -1
  37. taskops/mcp/activity.py +131 -0
  38. taskops/mcp/dossier.py +14 -4
  39. taskops/mcp/schema.py +26 -0
  40. taskops/mcp/tools.py +30 -2
  41. taskops/ui/app.js +9 -9
  42. taskops/verbs/__init__.py +10 -0
  43. taskops/verbs/_facts.py +30 -2
  44. taskops/verbs/_stories.py +94 -0
  45. taskops/verbs/activity.py +145 -0
  46. taskops/verbs/filed.py +114 -0
  47. taskops/verbs/project.py +53 -7
  48. taskops/verbs/pulse.py +9 -0
  49. {taskops_cli-0.3.0.dist-info → taskops_cli-0.3.2.dist-info}/METADATA +114 -10
  50. {taskops_cli-0.3.0.dist-info → taskops_cli-0.3.2.dist-info}/RECORD +52 -36
  51. {taskops_cli-0.3.0.dist-info → taskops_cli-0.3.2.dist-info}/WHEEL +0 -0
  52. {taskops_cli-0.3.0.dist-info → taskops_cli-0.3.2.dist-info}/entry_points.txt +0 -0
taskops/_version.py CHANGED
@@ -1,4 +1,4 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  __title__ = "taskops"
4
- __version__ = "0.3.0" # single source of truth; pyproject reads it dynamically
4
+ __version__ = "0.3.2" # single source of truth; pyproject reads it dynamically
taskops/_wire.py CHANGED
@@ -1,4 +1,6 @@
1
1
  """One POST, one envelope, one place that turns an HTTP failure into OUR error.
2
+ And, since the host learned to ask a forge, one GET that deliberately does NOT
3
+ wear that envelope — `get()` at the bottom says why.
2
4
 
3
5
  {"ok": true, "seq": 41, "data": {...}} -> the data, with `seq` in it
4
6
  {"ok": false, "error": {"code", "message"}} -> raised, in the SERVER's words
@@ -53,6 +55,49 @@ def post(
53
55
  return _unwrap(envelope, url)
54
56
 
55
57
 
58
+ def get(url: str, headers: dict[str, str], timeout: float) -> tuple[int, dict[str, Any]]:
59
+ """GET JSON from a server that is NOT a taskops one — the status comes back.
60
+
61
+ `post` unwraps `{"ok", "data"}` because both of its callers speak to a
62
+ taskops door. A forge does not: it answers its own shape and says what it
63
+ MEANS in the status — 401 is a bad token, 404 is "no repo you can see" —
64
+ so flattening those into one error would throw away the only thing the
65
+ caller can turn into a sentence somebody can act on.
66
+
67
+ What stays shared is the failure with no status at all: a refused
68
+ connection, a DNS miss, a timeout. That is `Unreachable` here exactly as it
69
+ is above, because the one caller of this function GRANTS on its answer and
70
+ must never be able to read silence as a yes.
71
+ """
72
+ request = Request(url, headers=headers, method="GET") # noqa: S310 — https, from our config
73
+ try:
74
+ with urlopen(request, timeout=timeout) as answer: # noqa: S310
75
+ return int(answer.status), _decode(answer.read())
76
+ except HTTPError as err:
77
+ return int(err.code), _decode(_body_of(err))
78
+ except (URLError, TimeoutError, ValueError) as err:
79
+ raise Unreachable(
80
+ f"{url} did not answer ({err}). Nothing was granted and nothing was written — "
81
+ "a host that cannot ask does not guess."
82
+ ) from err
83
+
84
+
85
+ def _body_of(err: HTTPError) -> bytes:
86
+ try:
87
+ return err.read()
88
+ except OSError: # pragma: no cover — a body that dies mid-read
89
+ return b""
90
+
91
+
92
+ def _decode(raw: bytes) -> dict[str, Any]:
93
+ """A foreign answer that is not an object is not an error here: the STATUS
94
+ already carried the meaning, and the caller reads a field or finds none."""
95
+ try:
96
+ return as_object(json.loads(raw or b"{}"))
97
+ except ValueError:
98
+ return {}
99
+
100
+
56
101
  def _unwrap(body: dict[str, Any], url: str) -> dict[str, Any]:
57
102
  """v1 let three verbs answer with a bare array, which the client decoder turned
58
103
  into `{}` with no error anywhere. Here a missing envelope is loud."""
taskops/cli/commands.py CHANGED
@@ -1,7 +1,9 @@
1
- """The two commands that CONNECT a repo to a board, and the git hooks.
1
+ """The two commands that CONNECT a repo to a board: `init` and `join`.
2
2
 
3
- `serve`, `invite` and `ui` — the ones that run a server — live in `serving.py`.
4
- `main.py` only parses and dispatches.
3
+ `serve`, `invite` and `ui` — the ones that run a server — live in `serving.py`,
4
+ and what the git hooks call is `hooks.py`. Both were split off this file rather
5
+ than growing it: these two WRITE a checkout's configuration once, and neither
6
+ runs a server nor fires on every commit. `main.py` only parses and dispatches.
5
7
  """
6
8
 
7
9
  from __future__ import annotations
@@ -16,7 +18,7 @@ from .._json import query
16
18
  from ..board import DIR, find_root, open_board
17
19
  from ..store import log
18
20
  from .._errors import TaskopsError
19
- from ..gitwork import run, bind, remote, install, trailer, claudefiles
21
+ from ..gitwork import remote, install, claudefiles
20
22
 
21
23
  # ── the commands ────────────────────────────────────────────────────────────
22
24
 
@@ -24,7 +26,7 @@ from ..gitwork import run, bind, remote, install, trailer, claudefiles
24
26
  def init(here: Path) -> int:
25
27
  root = find_root(here)
26
28
  (root / DIR / "board").mkdir(parents=True, exist_ok=True)
27
- (root / DIR / "board.json").write_text("{}\n", encoding="utf-8")
29
+ install.write_local(root)
28
30
  _wire(root, actor())
29
31
  print(f"local board in {root / DIR / 'board'} — the MCP tools are the way in")
30
32
  return 0
@@ -40,8 +42,21 @@ ORPHAN = (
40
42
  )
41
43
 
42
44
 
43
- def join(
44
- here: Path, target: str, given: str, key: str = "", discard: bool = False, invite: str = ""
45
+ BOTH = (
46
+ "--invite and --github are two ways to be introduced to the same host, and running "
47
+ "both would burn the invite for nothing. Pick one: --invite <id> if somebody minted "
48
+ "you a link, --github if you have push on the repo the board declared."
49
+ )
50
+
51
+
52
+ def join( # noqa: PLR0913 — one command, one config; each flag is a way IN
53
+ here: Path,
54
+ target: str,
55
+ given: str,
56
+ key: str = "",
57
+ discard: bool = False,
58
+ invite: str = "",
59
+ github: bool = False,
45
60
  ) -> int:
46
61
  """Connect this repo to a board. Bare like every other verb since the host is
47
62
  recorded and the key is discovered:
@@ -49,6 +64,7 @@ def join(
49
64
  taskops remote add https://host:8787 once per checkout
50
65
  taskops join my-project registered key: signs in, done
51
66
  taskops join my-project --invite <id> first time: enrols the key too
67
+ taskops join my-project --github first time, no invite: GitHub vouches
52
68
  taskops join my-project no key anywhere + public board: read-only
53
69
 
54
70
  The name defaults exactly as `board create`'s does (recorded name, else the
@@ -64,9 +80,13 @@ def join(
64
80
  archives (never deletes) before proceeding.
65
81
 
66
82
  With an invite, the invite and the PUBKEY travel in the same call: the server
67
- burns the invite and enrols the key in one act. Either way the key then signs
68
- in ON THE SPOT and what lands in remote.json is a SESSION with an expiry —
69
- never a standing token to copy around. Keys exist so tokens do not travel.
83
+ burns the invite and enrols the key in one act. `--github` is the same shape
84
+ with a different proof — a token the CLI already has instead of a link
85
+ somebody minted (`enrol.by_github`), and it takes no value on purpose. Either
86
+ way the key then signs in ON THE SPOT and what lands in remote.json is a
87
+ SESSION with an expiry — never a standing token to copy around, and never the
88
+ GitHub one, which this process has already forgotten by the time it writes.
89
+ Keys exist so tokens do not travel.
70
90
  """
71
91
  from . import remote as remote_cli
72
92
 
@@ -76,9 +96,14 @@ def join(
76
96
  host, name = remote_cli.named(target)
77
97
  target = f"{host}/{name}"
78
98
  base = target.partition("?")[0]
79
- _keep_or_archive(root, base, discard)
80
99
  params = query(target)
81
100
  invite = invite or params.get("invite", "")
101
+ # Before `_keep_or_archive`, which is the first thing here that MOVES anything:
102
+ # a refusal that had already renamed the local board would be the worse order,
103
+ # and the invite in a pasted `?invite=` link counts exactly as the flag does.
104
+ if invite and github:
105
+ raise TaskopsError(BOTH)
106
+ _keep_or_archive(root, base, discard)
82
107
  who = given or actor()
83
108
  name = who.partition(":")[2] or "me"
84
109
  found = Path(key).expanduser() if key else identity.discover_key()
@@ -87,6 +112,13 @@ def join(
87
112
  token, who = enrol.redeem(base, invite, name, enrol.pubkey(str(found) if found else ""))
88
113
  if found:
89
114
  door = {"host": enrol.host_of(base), "principal": name, "key": str(found)}
115
+ elif github:
116
+ # The door mints NOTHING (`http/github.py`), so there is no token to keep
117
+ # here: the enrolled key is the credential from the next line onwards.
118
+ vouched = enrol.by_github(base, name, enrol.pubkey(str(found) if found else ""))
119
+ who = vouched["actor"]
120
+ door = {"host": enrol.host_of(base), "principal": name, "key": str(found)}
121
+ print(f" GitHub: you have {vouched['need']} on {vouched['repo']} — key enrolled")
90
122
  elif bare and found:
91
123
  # No invite and no token: the KEY is the whole credential. Proved against
92
124
  # the host BEFORE anything is written — a refused sign-in must not leave
@@ -123,29 +155,6 @@ def _keep_or_archive(root: Path, url: str, discard: bool) -> None:
123
155
  print(f" the local board ({len(events)} events) is archived at {archive(local)}")
124
156
 
125
157
 
126
- def hook(here: Path, which: str, rest: list[str]) -> int:
127
- """The two GIT hooks; `hook claude` is routed in `main` and never prints.
128
-
129
- Neither of these may block a commit. Failures print and return 0.
130
- """
131
- root = find_root(here)
132
- if which == "trailer":
133
- if rest:
134
- trailer.stamp_file(Path(rest[0]), run.branch_at(here))
135
- return 0
136
- facts = bind.commit_facts(here)
137
- if facts is None:
138
- return 0
139
- try:
140
- board = open_board(root, actor())
141
- bind.record(board, root, facts)
142
- bind.drain(board, root)
143
- except TaskopsError as err:
144
- print(f"taskops: {err}", file=sys.stderr) # visible, never swallowed
145
- bind.push_card(here, str(facts["branch"]))
146
- return 0
147
-
148
-
149
158
  # ── plumbing ────────────────────────────────────────────────────────────────
150
159
 
151
160
 
taskops/cli/enrol.py CHANGED
@@ -4,10 +4,19 @@ Split out of `commands.py` at its own seam — `join` and `board push` both need
4
4
  exactly this and only this: a repo with a local board has never joined
5
5
  anything, so its first push may also be its first introduction to the host,
6
6
  and there must not be two redemptions to keep in step.
7
+
8
+ There are two introductions now and they end in the SAME place: a pubkey this
9
+ host knows, and a session minted from it moments later. `redeem` burns an
10
+ invite somebody minted for you; `by_github` (`http/github.py` is the door)
11
+ lets a repo's membership stand in for that invite. Neither leaves a standing
12
+ credential behind, and the GitHub token in particular is a LOCAL — read here,
13
+ put in one request body, and gone with the frame.
7
14
  """
8
15
 
9
16
  from __future__ import annotations
10
17
 
18
+ import os
19
+ import getpass
11
20
  from pathlib import Path
12
21
 
13
22
  from .._wire import post as post_json
@@ -40,6 +49,79 @@ def pubkey(key: str) -> str:
40
49
  ) from err
41
50
 
42
51
 
52
+ NO_KEY = (
53
+ "--github enrols an ssh KEY, and there is none to enrol — GitHub introduces you once "
54
+ "and the key is what signs you in from then on. Make one and try again:\n"
55
+ " ssh-keygen -t ed25519\n"
56
+ "or point at an existing one: taskops join <board> --github --key <path>"
57
+ )
58
+
59
+ NO_TOKEN = (
60
+ "no GitHub token, and nothing was sent. Any ONE of these gives me one:\n"
61
+ " gh auth login the CLI's token is read automatically\n"
62
+ " export GITHUB_TOKEN=… an environment variable\n"
63
+ " (or paste it at the hidden prompt)\n"
64
+ "There is deliberately no --github <token>: a token in a flag lands in your shell "
65
+ "history forever, and this one is only ever used for a single call."
66
+ )
67
+
68
+
69
+ def by_github(base: str, who: str, public: str) -> dict[str, str]:
70
+ """GitHub vouches for you ONCE, and what stays behind is your pubkey.
71
+
72
+ The token is fetched here, travels in this one request body, and is never
73
+ returned, printed, cached or written: what comes back names the principal
74
+ this host now knows and the repo that proved it, and the caller's very next
75
+ act is the ordinary `/login` challenge with the key just enrolled. That is
76
+ why nothing in this function can put a GitHub token in `remote.json` — it
77
+ has none to put there by the time `join` writes the file.
78
+ """
79
+ if not public:
80
+ raise TaskopsError(NO_KEY)
81
+ data = post_json(
82
+ f"{base.rstrip('/')}/join/github",
83
+ {"github_token": github_token(), "principal": who, "pubkey": public},
84
+ {},
85
+ 20.0,
86
+ )
87
+ return {
88
+ "actor": str(data.get("actor", f"dev:{who}")),
89
+ "repo": str(data.get("repo", "")),
90
+ "need": str(data.get("need", "")),
91
+ }
92
+
93
+
94
+ def github_token() -> str:
95
+ """WHERE the token comes from, in order — and never from a flag value.
96
+
97
+ `gh auth token` first, because the CLI is already installed and already
98
+ authenticated on the machine of anybody who has push on a repo, so the
99
+ common case asks the human nothing. Then `$GITHUB_TOKEN`, which is how CI
100
+ and a shell that never installed `gh` say it. Then a HIDDEN prompt.
101
+
102
+ **A flag is not one of the sources and must never become one.** `--github`
103
+ takes no value: a secret passed as an argument is written to
104
+ `~/.zsh_history` by the shell before the process starts, survives every
105
+ rotation of the token, and is visible in `ps` to every user on the box
106
+ while the command runs. The prompt is the fallback precisely because it is
107
+ the one input that leaves no trace.
108
+ """
109
+ from ..gitwork import run
110
+
111
+ try:
112
+ asked = run.tool("gh", "auth", "token", timeout=20.0)
113
+ except TaskopsError:
114
+ asked = None # no `gh` on PATH, or it hung — the next source answers
115
+ if asked is not None and asked.ok and asked.out.strip():
116
+ return asked.out.strip()
117
+ if os.environ.get("GITHUB_TOKEN", "").strip():
118
+ return os.environ["GITHUB_TOKEN"].strip()
119
+ typed = getpass.getpass("GitHub token (input hidden, used once, stored nowhere): ").strip()
120
+ if not typed:
121
+ raise TaskopsError(NO_TOKEN)
122
+ return typed
123
+
124
+
43
125
  def host_of(base: str) -> str:
44
126
  """The SERVER, out of a board address: `/login` is server scope, not a board's."""
45
127
  return base.rstrip("/").rpartition("/")[0]
taskops/cli/grants.py ADDED
@@ -0,0 +1,59 @@
1
+ """`taskops invite` · `taskops revoke` — handing a credential out, and taking it back.
2
+
3
+ taskops invite <who> --board <name> owner only — prints the join line
4
+ taskops revoke --key <SHA256:…> | --invite <id>
5
+
6
+ The laptop half of `http/grants.py`, and the split is that seam and not a line
7
+ count: `operate.py` is about a BOARD (create it, list them, say who may read it,
8
+ name the forge that opens it) and this is about a CREDENTIAL — the two never
9
+ share an argument. They do share the transport, which stays in `operate.py`
10
+ where `push.py` already reaches for it; a third module holding three lines of
11
+ `_wire.post` would be one more file to find and nothing to decide in it.
12
+
13
+ **THE BREAK-GLASS PATH SURVIVES** on both verbs: `--root <dir>` runs the same
14
+ act against the files directly, on the machine that holds them, and it is what
15
+ you use when the server is down or the owner's key is lost. Not deprecated,
16
+ never to be removed — a system whose only door is its own API cannot be
17
+ repaired when that API is what broke. Those two acts live in `cli/admin.py`,
18
+ beside `server init`: that module runs ON the box, this one is the laptop.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ from pathlib import Path
25
+
26
+ from . import admin
27
+ from .remote import address
28
+ from .operate import call, signed_in
29
+ from .._errors import TaskopsError
30
+
31
+
32
+ def invite(args: argparse.Namespace) -> int:
33
+ """A one-time join line, minted by the server that will honour it."""
34
+ who, name = str(args.who), str(args.board)
35
+ if not who:
36
+ raise TaskopsError("taskops invite <who> --board <name>")
37
+ if args.root: # break-glass: the files, on the box
38
+ return admin.on_box_invite(Path(str(args.root)).expanduser(), who, name)
39
+ if not name:
40
+ raise TaskopsError("which board? taskops invite <who> --board <name>")
41
+ host, _ = address(str(args.host))
42
+ made = call(host, "invite.mint", {"who": who, "board": name}, signed_in(host, args))
43
+ print(f"one-time invite for {who} (id {made['id']}, 7 days):")
44
+ print(f" taskops join \"{host}/{made['board']}?invite={made['token']}\" --key ~/.ssh/id_ed25519")
45
+ return 0
46
+
47
+
48
+ def revoke(args: argparse.Namespace) -> int:
49
+ """A key stops signing anybody in; an invite stops being redeemable."""
50
+ key, ident = str(args.key), str(args.invite)
51
+ if bool(key) == bool(ident):
52
+ raise TaskopsError("taskops revoke --key <SHA256:…> | --invite <id> — exactly one")
53
+ if args.root: # break-glass: the files, on the box
54
+ return admin.on_box_revoke(Path(str(args.root)).expanduser(), key, ident)
55
+ host, _ = address(str(args.host))
56
+ verb = "key.revoke" if key else "invite.revoke"
57
+ gone = call(host, verb, {"key": key} if key else {"invite": ident}, signed_in(host, args, "sign_key"))
58
+ print(f"revoked {key or ident} ({gone.get('principal') or gone.get('subject')}) on {host}")
59
+ return 0
taskops/cli/hooks.py ADDED
@@ -0,0 +1,50 @@
1
+ """What the two GIT hooks call — the other half of what `join` installed.
2
+
3
+ Split off `commands.py` at the seam that file's own docstring named ("the two
4
+ commands that CONNECT a repo to a board, **and the git hooks**), the same way
5
+ `serving.py` and `watch.py` were: `init` and `join` WRITE a checkout's
6
+ configuration once, and these two run on every single commit somebody makes in
7
+ it. Nothing here is reachable from a human's fingers — `gitwork/install.py`
8
+ writes the shell scripts that call it, and `main.py` routes them.
9
+
10
+ **Neither of these may ever block a commit.** A failure prints and returns 0.
11
+ The hook is a courtesy that binds a commit to its card; a board that is down,
12
+ a network that is out or a card that was closed a minute ago must cost somebody
13
+ their commit exactly never. That is why the `TaskopsError` below is caught and
14
+ written to stderr instead of raised — visible, and never in the way.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from ..board import find_root, open_board
23
+ from .._errors import TaskopsError
24
+ from ..gitwork import run, bind, trailer
25
+ from .commands import actor
26
+
27
+
28
+ def hook(here: Path, which: str, rest: list[str]) -> int:
29
+ """`hook trailer` stamps the message, `hook commit` binds the commit.
30
+
31
+ `hook claude` is a third one and it is routed in `main` instead, because it
32
+ prints NOTHING ever — including the `taskops: …` line every other failure
33
+ here is allowed to write.
34
+ """
35
+ root = find_root(here)
36
+ if which == "trailer":
37
+ if rest:
38
+ trailer.stamp_file(Path(rest[0]), run.branch_at(here))
39
+ return 0
40
+ facts = bind.commit_facts(here)
41
+ if facts is None:
42
+ return 0
43
+ try:
44
+ board = open_board(root, actor())
45
+ bind.record(board, root, facts)
46
+ bind.drain(board, root)
47
+ except TaskopsError as err:
48
+ print(f"taskops: {err}", file=sys.stderr) # visible, never swallowed
49
+ bind.push_card(here, str(facts["branch"]))
50
+ return 0
taskops/cli/main.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """The CLI, which behaves like git: it connects, it never manages.
2
2
 
3
3
  taskops init a local board in this repo
4
- taskops join <url> join one (?token= or ?invite=), install the hooks
4
+ taskops join <url> join one (?token=, ?invite= or --github), install the hooks
5
5
  taskops remote add <url> the host this checkout operates, like git's origin
6
6
  taskops serve host boards — an events API, no dashboard
7
7
  taskops server init bootstrap THIS host: its owner and their ssh key
@@ -9,7 +9,13 @@
9
9
  with a remote recorded and a key on disk, all of these
10
10
  go BARE: no URL, no --key, no board name
11
11
  taskops board push THIS repo's local board becomes the hosted one
12
+ taskops board pull the reverse: a hosted board comes down as a SNAPSHOT
13
+ that stops moving — the host keeps everything
14
+ taskops board rm take a board OFF a host — refuses to destroy a history
15
+ this checkout does not hold (--discard-history says so)
12
16
  taskops board visibility <host>/<name> public|private owner only
17
+ taskops board forge <owner>/<repo> [--need push|admin] owner only — GitHub opens it
18
+ taskops board forge --clear invite-only again
13
19
  taskops invite <who> a single-use link · taskops revoke --key|--invite
14
20
  taskops tidy remove worktrees whose work is already in the trunk
15
21
  taskops ui the dashboard — serves it if nothing is, opens the browser
@@ -27,95 +33,29 @@ import argparse
27
33
  from typing import Sequence
28
34
  from pathlib import Path
29
35
 
30
- from . import push as promote, admin, claude, remote, operate, serving, commands
36
+ from . import (
37
+ rm,
38
+ pull as download,
39
+ push as promote,
40
+ admin,
41
+ hooks,
42
+ claude,
43
+ grants,
44
+ parser as flags,
45
+ remote,
46
+ operate,
47
+ serving,
48
+ commands,
49
+ )
31
50
  from ..board import find_root
32
51
  from .._errors import TaskopsError
33
52
  from ..gitwork import trees
34
53
 
35
- AS_HELP = "the principal that key belongs to (default: $USER)"
36
-
37
54
 
38
55
  def main(argv: Sequence[str] | None = None) -> int:
39
- parser = argparse.ArgumentParser(prog="taskops", description=__doc__)
40
- sub = parser.add_subparsers(dest="command", required=True)
41
- sub.add_parser("init", help="a local board in this repo")
42
- join = sub.add_parser("join", help="join a board and install the hooks")
43
- join.add_argument(
44
- "url",
45
- nargs="?",
46
- default="",
47
- help="<name>, <host>/<name>, or nothing (the recorded/directory name) — "
48
- "a full https:// URL with ?token= or ?invite= keeps working",
49
- )
50
- join.add_argument("--as", dest="actor", default="", help="dev:<name> (default: $USER)")
51
- join.add_argument(
52
- "--invite",
53
- default="",
54
- help="first join: the single-use id from `taskops invite` — your key is enrolled with it",
55
- )
56
- join.add_argument(
57
- "--discard-local",
58
- action="store_true",
59
- help="a local board here is archived instead of orphaned by the join",
60
- )
61
- join.add_argument(
62
- "--key",
63
- default="",
64
- help="overrides the discovered ssh key (its .pub is what gets registered)",
65
- )
66
- origin = sub.add_parser("remote", help="the host this checkout operates (git's origin)")
67
- origin.add_argument("action", nargs="?", default="", choices=["", "add"])
68
- origin.add_argument("url", nargs="?", default="", help="https://<host>")
69
- origin.add_argument(
70
- "--replace", action="store_true", help="this checkout already names another host"
71
- )
72
- server = sub.add_parser("serve", help="host boards")
73
- server.add_argument("--root", default="~/taskops-boards")
74
- server.add_argument("--host", default="127.0.0.1")
75
- server.add_argument("--port", type=int, default=8787)
76
- host = sub.add_parser("server", help="operate this HOST (over ssh, once): its owner")
77
- host.add_argument("action", choices=["init"])
78
- host.add_argument("--root", default="~/taskops-boards")
79
- host.add_argument("--key", default="", help="the owner's pubkey: a path, or - for stdin")
80
- host.add_argument("--owner", default="", help="the owner's name (default: $USER)")
81
- boards = sub.add_parser("board", help="create or list the boards on a host")
82
- boards.add_argument("action", choices=["create", "ls", "push", "visibility"])
83
- boards.add_argument("target", nargs="?", default="", help="<host>/<name>, or just <name>")
84
- boards.add_argument(
85
- "visibility",
86
- nargs="?",
87
- default="",
88
- choices=["", "public", "private"],
89
- help="visibility: public means ANONYMOUS READ — writing always needs a key",
90
- )
91
- boards.add_argument("--key", default="", help="the ssh key that signs you in")
92
- boards.add_argument("--invite", default="", help="push: register that key first")
93
- boards.add_argument("--as", dest="principal", default="", help=AS_HELP)
94
- invite = sub.add_parser("invite", help="a single-use link for a teammate")
95
- invite.add_argument("who", nargs="?", default="")
96
- invite.add_argument("--board", default="")
97
- invite.add_argument("--host", default="", help="the server (default: the one you joined)")
98
- invite.add_argument("--key", default="", help="the ssh key that signs you in")
99
- invite.add_argument("--as", dest="principal", default="", help=AS_HELP)
100
- # `--root` is BREAK-GLASS and so it has no default: passing it is the deliberate
101
- # choice to work on the files, on the box, when the API is what broke.
102
- invite.add_argument("--root", default="", help="break-glass: the boards dir, ON the host")
103
- kill = sub.add_parser("revoke", help="a key or an invite stops working")
104
- kill.add_argument("--key", default="", help="a fingerprint: SHA256:…")
105
- kill.add_argument("--invite", default="", help="a credential id")
106
- kill.add_argument("--host", default="")
107
- # NOT `--key`: on this verb that word is already the fingerprint being revoked.
108
- kill.add_argument("--sign-key", default="", help="the ssh key that signs YOU in")
109
- kill.add_argument("--as", dest="principal", default="", help=AS_HELP)
110
- kill.add_argument("--root", default="", help="break-glass: the boards dir, ON the host")
111
- tidy = sub.add_parser("tidy", help="remove integrated worktrees and branches")
112
- tidy.add_argument("--trunk", default="")
113
- sub.add_parser("ui", help="the dashboard: serve if needed, open the browser, token included")
114
- hook = sub.add_parser("hook", help="internal: what the installed hooks call")
115
- hook.add_argument("which", choices=["trailer", "commit", "claude"])
116
- hook.add_argument("rest", nargs="*")
117
-
118
- args = parser.parse_args(argv)
56
+ """Parse (`parser.py` holds every flag), then dispatch — and turn the one
57
+ error type the whole program raises into a line and an exit code."""
58
+ args = flags.build(__doc__ or "").parse_args(argv)
119
59
  try:
120
60
  return _run(args)
121
61
  except TaskopsError as err:
@@ -135,6 +75,7 @@ def _run(args: argparse.Namespace) -> int:
135
75
  str(args.key),
136
76
  bool(args.discard_local),
137
77
  str(args.invite),
78
+ bool(args.github),
138
79
  )
139
80
  if args.command == "remote":
140
81
  return remote.remote(args)
@@ -143,14 +84,24 @@ def _run(args: argparse.Namespace) -> int:
143
84
  if args.command == "server":
144
85
  return admin.server(args)
145
86
  if args.command == "board":
146
- # `push` is its own module — five ordered steps and a config flip, against
147
- # `board`'s two one-shot calls — so `main` routes it, and neither imports
148
- # the other (`push.py` needs `operate`'s transport and its address parser).
149
- return promote.run(args) if str(args.action) == "push" else operate.board(args)
87
+ # `push` and `pull` are each their own module — five ordered steps and a
88
+ # config flip, against `board`'s two one-shot calls — so `main` routes
89
+ # them, and neither imports the other (both need `operate`'s transport
90
+ # and its address parser, and nothing needs them back). `rm` is separated
91
+ # for the mirrored reason: one call, and a guardrail around it that is
92
+ # the whole command.
93
+ action = str(args.action)
94
+ if action == "push":
95
+ return promote.run(args)
96
+ if action == "pull":
97
+ return download.run(args)
98
+ if action == "rm":
99
+ return rm.run(args)
100
+ return operate.board(args)
150
101
  if args.command == "invite":
151
- return operate.invite(args)
102
+ return grants.invite(args)
152
103
  if args.command == "revoke":
153
- return operate.revoke(args)
104
+ return grants.revoke(args)
154
105
  if args.command == "tidy":
155
106
  removed = trees.tidy(find_root(here), str(args.trunk))
156
107
  print("\n".join(removed) if removed else "nothing to tidy — no integrated worktrees")
@@ -162,4 +113,4 @@ def _run(args: argparse.Namespace) -> int:
162
113
  # own error policy end to end: it prints NOTHING, ever, including the
163
114
  # `taskops: …` line `main()` writes for every other failure.
164
115
  return claude.deliver(here)
165
- return commands.hook(here, str(args.which), [str(x) for x in args.rest])
116
+ return hooks.hook(here, str(args.which), [str(x) for x in args.rest])