taskops-cli 0.4.0__py3-none-any.whl → 0.4.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 (81) hide show
  1. taskops/_version.py +1 -1
  2. taskops/_wire.py +48 -0
  3. taskops/cli/commands.py +45 -42
  4. taskops/cli/enrol.py +12 -0
  5. taskops/cli/github.py +196 -0
  6. taskops/cli/grants.py +59 -0
  7. taskops/cli/hooks.py +50 -0
  8. taskops/cli/main.py +42 -91
  9. taskops/cli/operate.py +59 -38
  10. taskops/cli/paging.py +75 -0
  11. taskops/cli/parser.py +140 -0
  12. taskops/cli/pull.py +162 -0
  13. taskops/cli/rm.py +63 -0
  14. taskops/cli/serving.py +23 -19
  15. taskops/cli/team.py +96 -0
  16. taskops/cli/window.py +154 -0
  17. taskops/cli/wording.py +2 -2
  18. taskops/core/chapters.py +5 -0
  19. taskops/core/event.py +17 -7
  20. taskops/core/forge.py +85 -0
  21. taskops/core/graph.py +4 -0
  22. taskops/core/holding.py +100 -0
  23. taskops/core/hours.py +81 -11
  24. taskops/core/kinds.py +45 -0
  25. taskops/core/replay.py +1 -1
  26. taskops/core/reports.py +116 -0
  27. taskops/core/scope.py +26 -0
  28. taskops/core/types.py +30 -33
  29. taskops/gitwork/bind.py +11 -2
  30. taskops/gitwork/catchup.py +59 -4
  31. taskops/gitwork/install.py +15 -0
  32. taskops/gitwork/patch.py +24 -4
  33. taskops/gitwork/remote.py +14 -1
  34. taskops/http/admin.py +36 -2
  35. taskops/http/gitdoor.py +96 -1
  36. taskops/http/handler.py +15 -1
  37. taskops/http/ingest.py +10 -5
  38. taskops/http/login.py +16 -2
  39. taskops/http/members.py +178 -0
  40. taskops/http/mounts.py +30 -2
  41. taskops/http/removal.py +106 -0
  42. taskops/http/server.py +20 -0
  43. taskops/http/static.py +30 -0
  44. taskops/http/upstream.py +60 -8
  45. taskops/mcp/activity.py +131 -0
  46. taskops/mcp/before.py +10 -0
  47. taskops/mcp/boardview.py +10 -1
  48. taskops/mcp/brief.py +8 -0
  49. taskops/mcp/chapter.py +154 -0
  50. taskops/mcp/dossier.py +14 -4
  51. taskops/mcp/gitmoves.py +9 -110
  52. taskops/mcp/integrate.py +17 -6
  53. taskops/mcp/orders.py +79 -0
  54. taskops/mcp/schema.py +38 -49
  55. taskops/mcp/server.py +2 -2
  56. taskops/mcp/tools.py +37 -5
  57. taskops/store/handover.py +61 -0
  58. taskops/ui/app.js +11 -10
  59. taskops/ui/style.css +1 -1
  60. taskops/verbs/__init__.py +10 -0
  61. taskops/verbs/_chapter.py +64 -0
  62. taskops/verbs/_facts.py +41 -5
  63. taskops/verbs/_rows.py +36 -4
  64. taskops/verbs/_stories.py +94 -0
  65. taskops/verbs/_waiting.py +1 -1
  66. taskops/verbs/_windows.py +95 -0
  67. taskops/verbs/activity.py +151 -0
  68. taskops/verbs/assign.py +13 -10
  69. taskops/verbs/filed.py +114 -0
  70. taskops/verbs/plan.py +3 -0
  71. taskops/verbs/project.py +53 -7
  72. taskops/verbs/pulse.py +27 -0
  73. taskops/verbs/record.py +23 -6
  74. taskops/verbs/report.py +72 -26
  75. taskops/verbs/update.py +9 -39
  76. taskops_cli-0.4.2.dist-info/METADATA +458 -0
  77. taskops_cli-0.4.2.dist-info/RECORD +143 -0
  78. {taskops_cli-0.4.0.dist-info → taskops_cli-0.4.2.dist-info}/WHEEL +1 -1
  79. taskops_cli-0.4.0.dist-info/METADATA +0 -239
  80. taskops_cli-0.4.0.dist-info/RECORD +0 -119
  81. {taskops_cli-0.4.0.dist-info → taskops_cli-0.4.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.4.0" # single source of truth; pyproject reads it dynamically
4
+ __version__ = "0.4.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 OWNER's `board forge` learned to ask GitHub, one GET that
3
+ deliberately does NOT wear that envelope — `text()` 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,52 @@ def post(
53
55
  return _unwrap(envelope, url)
54
56
 
55
57
 
58
+ def text(url: str, headers: dict[str, str], timeout: float) -> tuple[int, str]:
59
+ """GET a foreign body UNDECODED — the status, and whatever bytes came back.
60
+
61
+ `post` unwraps `{"ok", "data"}` because both of its callers speak to a
62
+ taskops door. GitHub 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" — so
64
+ flattening those into one error would throw away the only thing the caller
65
+ can turn into a sentence somebody can act on.
66
+
67
+ Nor is the body decoded here. It briefly was, into an OBJECT, and its two
68
+ callers cannot use that: `https://github.com/<login>.keys` answers
69
+ `text/plain`, one ssh key line each, and `/repos/…/collaborators` answers a
70
+ JSON ARRAY, which `as_object` flattens to `{}` — a page of people read as
71
+ nobody, silently. So the decoding belongs to whoever knows what it asked
72
+ for, and what is shared here is the part that must never differ: the status
73
+ is preserved, and a failure with NO status at all — refused connection, DNS
74
+ miss, timeout — is `Unreachable` rather than an empty answer somebody could
75
+ read as "this person has no keys" and then quietly leave them out of a
76
+ team.
77
+ """
78
+ request = Request(url, headers=headers, method="GET") # noqa: S310 — https, from our config
79
+ try:
80
+ with urlopen(request, timeout=timeout) as answer: # noqa: S310
81
+ return int(answer.status), _read(answer.read())
82
+ except HTTPError as err:
83
+ return int(err.code), _read(_body_of(err))
84
+ except (URLError, TimeoutError, ValueError) as err:
85
+ raise Unreachable(
86
+ f"{url} did not answer ({err}). Nothing was granted and nothing was written — "
87
+ "a host that cannot ask does not guess."
88
+ ) from err
89
+
90
+
91
+ def _body_of(err: HTTPError) -> bytes:
92
+ try:
93
+ return err.read()
94
+ except OSError: # pragma: no cover — a body that dies mid-read
95
+ return b""
96
+
97
+
98
+ def _read(raw: bytes) -> str:
99
+ """Undecodable bytes are not an exception here: the STATUS already carried
100
+ the meaning, and a mangled character must not become `Unreachable`."""
101
+ return raw.decode("utf-8", "replace")
102
+
103
+
56
104
  def _unwrap(body: dict[str, Any], url: str) -> dict[str, Any]:
57
105
  """v1 let three verbs answer with a bare array, which the client decoder turned
58
106
  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
@@ -13,10 +15,10 @@ from pathlib import Path
13
15
  from . import enrol, watch
14
16
  from .. import session, identity
15
17
  from .._json import query
16
- from ..board import DIR, find_root, open_board
18
+ from ..board import DIR, find_root, open_board, read_config
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
@@ -41,14 +43,21 @@ ORPHAN = (
41
43
 
42
44
 
43
45
  def join(
44
- here: Path, target: str, given: str, key: str = "", discard: bool = False, invite: str = ""
46
+ here: Path,
47
+ target: str,
48
+ given: str,
49
+ key: str = "",
50
+ discard: bool = False,
51
+ invite: str = "",
45
52
  ) -> int:
46
- """Connect this repo to a board. Bare like every other verb since the host is
47
- recorded and the key is discovered:
48
-
49
- taskops remote add https://host:8787 once per checkout
50
- taskops join my-project registered key: signs in, done
51
- taskops join my-project --invite <id> first time: enrols the key too
53
+ """Connect this repo to a board. Bare like every other verb: the address is
54
+ CARRIED by the clone, the key is discovered, and the recorded remote is the
55
+ fallback for a checkout that carries nothing:
56
+
57
+ taskops join a clone: board.json travels, done
58
+ taskops join my-project --invite <id> first time by invite: enrols the key
59
+ taskops remote add https://host:8787 no carried address? record the host…
60
+ taskops join my-project …and name the board
52
61
  taskops join my-project no key anywhere + public board: read-only
53
62
 
54
63
  The name defaults exactly as `board create`'s does (recorded name, else the
@@ -64,21 +73,38 @@ def join(
64
73
  archives (never deletes) before proceeding.
65
74
 
66
75
  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.
76
+ burns the invite and enrols the key in one act. The key then signs in ON THE
77
+ SPOT and what lands in remote.json is a SESSION with an expiry — never a
78
+ standing token to copy around. Keys exist so tokens do not travel.
79
+
80
+ **There is no GitHub branch here any more, and there must not be one again.**
81
+ The flag that was here posted the joiner's own GitHub token to the host to be
82
+ verified, which made every dev's credential travel for a fact the OWNER
83
+ already holds. `taskops board forge` enrols the team from the owner's laptop
84
+ instead, so a dev whose key that sync published arrives at the `bare and
85
+ found` branch below: the plain `taskops join`, nothing to prove, nothing to
86
+ type, and no token of theirs anywhere.
70
87
  """
71
88
  from . import remote as remote_cli
72
89
 
73
90
  root = find_root(here)
74
91
  bare = "://" not in target
75
92
  if bare:
76
- host, name = remote_cli.named(target)
77
- target = f"{host}/{name}"
93
+ # v1's whole ambition, restored (its join.py said it in one line: "a
94
+ # clone carries `.taskops/board.json`, so the second developer types
95
+ # two words"). The committed address is read FIRST, so a fresh clone
96
+ # joins with no URL and no `remote add` — the recorded remote is for
97
+ # the checkout that has no carried address, or a DIFFERENT board.
98
+ carried = str(read_config(root).get("url", ""))
99
+ if carried and target in ("", carried.rsplit("/", 1)[-1]):
100
+ target = carried
101
+ else:
102
+ host, name = remote_cli.named(target)
103
+ target = f"{host}/{name}"
78
104
  base = target.partition("?")[0]
79
- _keep_or_archive(root, base, discard)
80
105
  params = query(target)
81
106
  invite = invite or params.get("invite", "")
107
+ _keep_or_archive(root, base, discard)
82
108
  who = given or actor()
83
109
  name = who.partition(":")[2] or "me"
84
110
  found = Path(key).expanduser() if key else identity.discover_key()
@@ -123,29 +149,6 @@ def _keep_or_archive(root: Path, url: str, discard: bool) -> None:
123
149
  print(f" the local board ({len(events)} events) is archived at {archive(local)}")
124
150
 
125
151
 
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
152
  # ── plumbing ────────────────────────────────────────────────────────────────
150
153
 
151
154
 
taskops/cli/enrol.py CHANGED
@@ -4,6 +4,18 @@ 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
+ **One introduction lives on this side, and it is the invite.** There was a
9
+ second for a day — it put the joiner's own GitHub token in a request body for
10
+ the host to verify — and it is deleted, not disabled: the owner's `taskops board
11
+ forge` now enrols the whole team from their own laptop (`cli/team.py`), so by the
12
+ time a dev types `taskops join` their key is already in `allowed_signers` and
13
+ there is nothing left to prove at the door. A dev's GitHub token never travels
14
+ anywhere now, which is a stronger rule than never storing one.
15
+
16
+ So this module knows GitHub not at all: `redeem` burns an invite somebody
17
+ minted, `pubkey` reads the public half of a key, and neither leaves a standing
18
+ credential behind.
7
19
  """
8
20
 
9
21
  from __future__ import annotations
taskops/cli/github.py ADDED
@@ -0,0 +1,196 @@
1
+ """The questions the OWNER asks GitHub, from their own laptop, with their own token.
2
+
3
+ collaborators("bernatch22/taskops", "push", token) -> ["ana", "leo", …]
4
+ keys_of("ana") -> ["ssh-ed25519 AAAA…", …]
5
+
6
+ Two calls, and only the FIRST one is authenticated — `<login>.keys` is a public
7
+ endpoint, which is the whole reason this chapter exists: somebody with push on a
8
+ repo has already published the key they push with, so asking them for a second
9
+ one was always redundant.
10
+
11
+ **The token's whole life is inside `collaborators`.** Read by `token()`, put in
12
+ one `Authorization` header per page, dropped with the frame: not returned, not
13
+ printed, not written, and never sent to the taskops host — `http/members.py`
14
+ takes principals and ssh key lines and does not know what GitHub is. That is how
15
+ `taskops board forge` syncs a team without the host ever holding somebody's
16
+ credential.
17
+
18
+ **This is the ONLY place in taskops that talks to GitHub.** A board door used to
19
+ ask the neighbouring question — does the token this stranger just POSTed have
20
+ `need` on the repo — and it is deleted: it made every dev's own credential travel
21
+ to the host for a fact the owner already holds. Here the token never leaves the
22
+ machine that owns it, and the host is told nothing but principals and ssh key
23
+ lines. A second door asking GitHub anything is the thing not to re-introduce.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import json
30
+ import getpass
31
+ from typing import Any
32
+
33
+ from .. import _wire
34
+ from .._json import as_rows, as_object
35
+ from .._errors import TaskopsError
36
+
37
+ API = "https://api.github.com"
38
+ KEYS = "https://github.com"
39
+ """The two roots, as module constants so a test can point them at a stub over a
40
+ real socket — mocking `_wire` instead would test neither urllib, nor the header
41
+ the token travels in, nor the pagination, which is where a team of 31 silently
42
+ became a team of 30."""
43
+
44
+ TIMEOUT = 20.0
45
+ PAGE = 100
46
+ """GitHub's own maximum. Asking for fewer would only make the loop below run
47
+ more times against the same rate budget."""
48
+
49
+ NO_TOKEN = (
50
+ "no GitHub token, and nothing was asked. Any ONE of these gives me one:\n"
51
+ " gh auth login the CLI's token is read automatically\n"
52
+ " export GITHUB_TOKEN=… an environment variable\n"
53
+ " (or paste it at the hidden prompt)\n"
54
+ "No flag takes a token, deliberately: a token in a flag value lands in your shell "
55
+ "history forever, and this one is only ever used for a single command."
56
+ )
57
+
58
+
59
+ def token() -> str:
60
+ """WHERE the token comes from, in order — and never from a flag value.
61
+
62
+ `gh auth token` first, because the CLI is already installed and already
63
+ authenticated on the machine of anybody who ADMINS a repo, so the common
64
+ case asks the human nothing. Then `$GITHUB_TOKEN`, which is how CI and a
65
+ shell that never installed `gh` say it. Then a HIDDEN prompt.
66
+
67
+ **A flag is not one of the sources and must never become one.** A secret
68
+ passed as an argument is written to `~/.zsh_history` by the shell before the
69
+ process starts, survives every rotation of the token, and is visible in `ps`
70
+ to every user on the box while the command runs. The prompt is the fallback
71
+ precisely because it is the one input that leaves no trace.
72
+ """
73
+ from ..gitwork import run
74
+
75
+ try:
76
+ asked = run.tool("gh", "auth", "token", timeout=20.0)
77
+ except TaskopsError:
78
+ asked = None # no `gh` on PATH, or it hung — the next source answers
79
+ if asked is not None and asked.ok and asked.out.strip():
80
+ return asked.out.strip()
81
+ if os.environ.get("GITHUB_TOKEN", "").strip():
82
+ return os.environ["GITHUB_TOKEN"].strip()
83
+ typed = getpass.getpass("GitHub token (input hidden, used once, stored nowhere): ").strip()
84
+ if not typed:
85
+ raise TaskopsError(NO_TOKEN)
86
+ return typed
87
+
88
+
89
+ def collaborators(repo: str, need: str, secret: str) -> list[str]:
90
+ """Every human with `need` on `repo`, ALL of them — pagination is not optional.
91
+
92
+ A first page is 30 by default and 100 at most, and a team that outgrows one
93
+ page is exactly the team this command exists for: stopping at page one would
94
+ enrol some of them and report the rest as DRIFT, which reads as a revocation
95
+ list. The loop ends on a short page rather than on `Link: rel="next"`, which
96
+ would be a second condition saying the same thing.
97
+
98
+ The `permission=` filter is GitHub's own, and each row's `permissions` object
99
+ is checked against it because `http/github.py`'s door grants on exactly that
100
+ field — a filter and a door that disagree let somebody in through one and
101
+ never enrol them through the other. `type` drops bots: `github-actions[bot]`
102
+ is not a principal name and has no key to publish.
103
+ """
104
+ found: list[str] = []
105
+ page = 1
106
+ while True:
107
+ rows = _page(f"{API}/repos/{repo}/collaborators", repo, need, secret, page)
108
+ found.extend(
109
+ str(row.get("login", "")).strip()
110
+ for row in rows
111
+ if str(row.get("login", "")).strip() and _allowed(row, need)
112
+ )
113
+ if len(rows) < PAGE:
114
+ return found
115
+ page += 1
116
+
117
+
118
+ def _page(url: str, repo: str, need: str, secret: str, page: int) -> list[dict[str, Any]]:
119
+ where = f"{url}?permission={need}&per_page={PAGE}&page={page}"
120
+ status, raw = _wire.text(where, headers(secret), TIMEOUT)
121
+ if status != 200:
122
+ raise TaskopsError(_why(status, repo, need, raw))
123
+ try:
124
+ return as_rows(json.loads(raw or "[]"))
125
+ except ValueError as err: # pragma: no cover — a 200 that is not JSON
126
+ raise TaskopsError(f"GitHub answered {where} with something that is not JSON: {err}") from err
127
+
128
+
129
+ def _allowed(row: dict[str, Any], need: str) -> bool:
130
+ """A row GitHub filtered in, confirmed against the field the door reads. An
131
+ entry with no `permissions` object at all is trusted to the filter — the
132
+ alternative is dropping a whole team the day that field is renamed."""
133
+ if str(row.get("type", "User")) != "User":
134
+ return False
135
+ seen = as_object(row.get("permissions"))
136
+ return not seen or seen.get(need) is True
137
+
138
+
139
+ def keys_of(login: str) -> list[str]:
140
+ """`https://github.com/<login>.keys` — PUBLIC, no token, no rate budget spent.
141
+
142
+ An empty list is a real answer, not a failure: an account that pushes over
143
+ HTTPS has published no ssh key, and the caller NAMES those people with an
144
+ invite rather than leaving them out. A 404 — renamed or deleted between the
145
+ two calls — collapses to the same empty list for the same reason.
146
+ """
147
+ status, raw = _wire.text(f"{KEYS}/{login}.keys", {"User-Agent": "taskops"}, TIMEOUT)
148
+ if status != 200:
149
+ return []
150
+ seen: list[str] = []
151
+ for line in raw.splitlines():
152
+ one = line.strip()
153
+ if one and one not in seen:
154
+ seen.append(one)
155
+ return seen
156
+
157
+
158
+ def headers(secret: str) -> dict[str, str]:
159
+ """The token's whole life. `X-GitHub-Api-Version` is pinned because the
160
+ shape of `permissions` is what this module reads, and an unpinned API is
161
+ free to reshape it — a silently missing key would read as "no access"."""
162
+ return {
163
+ "Authorization": f"Bearer {secret}",
164
+ "Accept": "application/vnd.github+json",
165
+ "X-GitHub-Api-Version": "2022-11-28",
166
+ "User-Agent": "taskops",
167
+ }
168
+
169
+
170
+ def _why(status: int, repo: str, need: str, raw: str) -> str:
171
+ """The refusal, and never the token in it — `raw` is GitHub's own body, which
172
+ holds its message and cannot hold the header it was refused for."""
173
+ try:
174
+ said = str(as_object(json.loads(raw or "{}")).get("message", ""))
175
+ except ValueError:
176
+ said = ""
177
+ tail = f" GitHub said: {said}" if said else ""
178
+ if status == 401:
179
+ return (
180
+ f"GitHub rejected that token (401) — expired, revoked or mistyped. Listing the "
181
+ f"collaborators of {repo} needs a token of somebody who can read them.{tail}"
182
+ )
183
+ if status == 403:
184
+ return (
185
+ f"GitHub refused that token (403) listing the collaborators of {repo} — an "
186
+ f"organisation that enforces SSO has to authorise it, and a rate limit answers "
187
+ f"the same way. Nobody was enrolled; the board's declaration stands, so the fix "
188
+ f"is to run the same command again.{tail}"
189
+ )
190
+ if status == 404:
191
+ return (
192
+ f"GitHub has no repository {repo!r} that this token can see — which is also what "
193
+ f"a private repo answers a token without access. Only somebody who can read its "
194
+ f"collaborators can sync them.{tail}"
195
+ )
196
+ return f"GitHub answered HTTP {status} listing the {need} collaborators of {repo} — nothing was enrolled.{tail}"
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