kanbaroo-cli 0.2.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.
@@ -0,0 +1,5 @@
1
+ """
2
+ Kanbaroo CLI: Typer-based command-line interface (kanbaroo, kb).
3
+ """
4
+
5
+ __version__ = "0.2.2"
kanbaroo_cli/app.py ADDED
@@ -0,0 +1,114 @@
1
+ """
2
+ Kanbaroo CLI entry point.
3
+
4
+ Exposes the root Typer app and mounts every sub-app defined in
5
+ ``kanbaroo_cli.commands``. The root callback is a no-op so Typer
6
+ always treats registered entries as subcommands; this mirrors the
7
+ pattern established when only ``kb init`` existed and keeps the help
8
+ output consistent as new commands arrive.
9
+ """
10
+
11
+ from importlib.metadata import PackageNotFoundError
12
+ from importlib.metadata import version as _pkg_version
13
+
14
+ import typer
15
+
16
+ from kanbaroo_cli.commands import audit as audit_command
17
+ from kanbaroo_cli.commands import backup as backup_command
18
+ from kanbaroo_cli.commands import epic as epic_command
19
+ from kanbaroo_cli.commands import export as export_command
20
+ from kanbaroo_cli.commands import init as init_command
21
+ from kanbaroo_cli.commands import server as server_command
22
+ from kanbaroo_cli.commands import story as story_command
23
+ from kanbaroo_cli.commands import tag as tag_command
24
+ from kanbaroo_cli.commands import token as token_command
25
+ from kanbaroo_cli.commands import workspace as workspace_command
26
+ from kanbaroo_cli.rendering import stdout_console
27
+
28
+ app = typer.Typer(
29
+ name="kanbaroo",
30
+ help="Kanbaroo: a kanban-style issue tracker for terminals and AI agents.",
31
+ no_args_is_help=True,
32
+ add_completion=False,
33
+ )
34
+
35
+
36
+ def _installed_version() -> str:
37
+ """
38
+ Return the installed ``kanbaroo-cli`` version, or ``"unknown"`` when
39
+ the package is not installed (e.g. running directly from source
40
+ without an editable install).
41
+ """
42
+ try:
43
+ return _pkg_version("kanbaroo-cli")
44
+ except PackageNotFoundError:
45
+ return "unknown"
46
+
47
+
48
+ def _version_callback(value: bool) -> None:
49
+ """
50
+ Eager Typer option callback for ``--version``. Prints the installed
51
+ version and exits cleanly before any subcommand runs.
52
+ """
53
+ if value:
54
+ stdout_console.print(_installed_version())
55
+ raise typer.Exit(code=0)
56
+
57
+
58
+ @app.callback()
59
+ def _root(
60
+ _version: bool = typer.Option(
61
+ False,
62
+ "--version",
63
+ help="Print the installed kanbaroo-cli version and exit.",
64
+ is_eager=True,
65
+ callback=_version_callback,
66
+ ),
67
+ ) -> None:
68
+ """
69
+ Root callback. Exists only so Typer treats registered commands as
70
+ subcommands; it also wires the ``--version`` option.
71
+ """
72
+
73
+
74
+ @app.command(
75
+ name="version",
76
+ help="Print the installed kanbaroo-cli version.",
77
+ )
78
+ def version_command() -> None:
79
+ """
80
+ Print the installed kanbaroo-cli version. Mirrors ``kb --version``.
81
+ """
82
+ stdout_console.print(_installed_version())
83
+
84
+
85
+ app.command(
86
+ name="init",
87
+ help="Initialise the Kanbaroo config directory, database, and first token.",
88
+ )(init_command.init)
89
+
90
+ app.command(
91
+ name="audit",
92
+ help="Show the audit history for a story or epic.",
93
+ )(audit_command.audit_command)
94
+
95
+ app.command(
96
+ name="export",
97
+ help="Download an export archive for a workspace.",
98
+ )(export_command.export_command)
99
+
100
+ app.command(
101
+ name="backup",
102
+ help="Copy the configured SQLite database file to a timestamped path.",
103
+ )(backup_command.backup_command)
104
+
105
+ app.add_typer(workspace_command.app, name="workspace")
106
+ app.add_typer(story_command.app, name="story")
107
+ app.add_typer(epic_command.app, name="epic")
108
+ app.add_typer(tag_command.app, name="tag")
109
+ app.add_typer(token_command.app, name="token")
110
+ app.add_typer(server_command.app, name="server")
111
+
112
+
113
+ if __name__ == "__main__":
114
+ app()
kanbaroo_cli/client.py ADDED
@@ -0,0 +1,304 @@
1
+ """
2
+ Thin HTTP client wrapper for the Kanbaroo CLI.
3
+
4
+ Every command that hits the server goes through :class:`ApiClient` so
5
+ that authentication, base URLs, error-envelope handling, and ETag /
6
+ ``If-Match`` plumbing live in exactly one place. The wrapper is
7
+ intentionally not a general-purpose HTTP abstraction: its job is to
8
+ turn the server's documented REST surface into Python calls that raise
9
+ typed exceptions on failure.
10
+
11
+ The client is built on top of :class:`httpx.Client` and accepts an
12
+ optional ``transport`` argument so tests can swap in
13
+ :class:`httpx.MockTransport` without monkey-patching. This is the same
14
+ pattern the FastAPI ``TestClient`` uses internally.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from types import TracebackType
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ API_PREFIX = "/api/v1"
25
+
26
+
27
+ class ApiError(Exception):
28
+ """
29
+ Base class for every error surfaced from the CLI HTTP client.
30
+
31
+ Commands translate this into a Rich-rendered message and a
32
+ non-zero exit code; no command handler should ever let a raw
33
+ :class:`httpx.HTTPError` escape.
34
+ """
35
+
36
+
37
+ class ApiRequestError(ApiError):
38
+ """
39
+ Raised when the server returns a non-2xx response.
40
+
41
+ ``code`` and ``message`` come from the canonical error envelope
42
+ defined in ``docs/spec.md`` section 4.1. ``details`` is the
43
+ per-error dict the server chose to attach (or ``None``).
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ *,
49
+ status_code: int,
50
+ code: str,
51
+ message: str,
52
+ details: dict[str, Any] | None = None,
53
+ ) -> None:
54
+ super().__init__(f"{code}: {message}")
55
+ self.status_code = status_code
56
+ self.code = code
57
+ self.message = message
58
+ self.details = details
59
+
60
+
61
+ class ApiTransportError(ApiError):
62
+ """
63
+ Raised when the HTTP round-trip itself fails (DNS, refused
64
+ connection, timeout). Distinct from :class:`ApiRequestError`
65
+ because the user's remediation is different: start the server,
66
+ check the network, fix the URL.
67
+ """
68
+
69
+
70
+ def _extract_error(response: httpx.Response) -> ApiRequestError:
71
+ """
72
+ Parse the canonical error envelope out of a non-2xx response.
73
+
74
+ Falls back to a best-effort message if the server returned a
75
+ body that is not valid JSON or does not match the envelope shape;
76
+ the CLI still surfaces a non-zero exit with a human-readable
77
+ message in that case.
78
+ """
79
+ code = "unknown"
80
+ message = response.text or f"HTTP {response.status_code}"
81
+ details: dict[str, Any] | None = None
82
+ try:
83
+ body = response.json()
84
+ except ValueError:
85
+ body = None
86
+ if isinstance(body, dict) and "error" in body and isinstance(body["error"], dict):
87
+ error = body["error"]
88
+ code = str(error.get("code", code))
89
+ message = str(error.get("message", message))
90
+ raw_details = error.get("details")
91
+ if isinstance(raw_details, dict):
92
+ details = raw_details
93
+ return ApiRequestError(
94
+ status_code=response.status_code,
95
+ code=code,
96
+ message=message,
97
+ details=details,
98
+ )
99
+
100
+
101
+ class ApiClient:
102
+ """
103
+ Authenticated HTTP wrapper around the Kanbaroo REST API.
104
+
105
+ The wrapper handles four things the command handlers would
106
+ otherwise duplicate:
107
+
108
+ * Base URL composition: every call takes a path (``/workspaces``)
109
+ and the client prepends ``{base_url}{API_PREFIX}``.
110
+ * Auth: ``Authorization: Bearer <token>`` on every request.
111
+ * Error envelope translation: non-2xx responses become
112
+ :class:`ApiRequestError` instances.
113
+ * ETag / If-Match round trips: :meth:`patch_with_etag` and
114
+ :meth:`delete_with_etag` take an entity path and do the GET to
115
+ read the ETag before issuing the mutation.
116
+ """
117
+
118
+ def __init__(
119
+ self,
120
+ *,
121
+ base_url: str,
122
+ token: str,
123
+ transport: httpx.BaseTransport | None = None,
124
+ timeout: float = 30.0,
125
+ ) -> None:
126
+ """
127
+ Build a new client.
128
+
129
+ ``transport`` is injected so tests can drive the CLI with an
130
+ :class:`httpx.MockTransport` without touching the network.
131
+ """
132
+ self._base_url = base_url.rstrip("/")
133
+ self._client = httpx.Client(
134
+ base_url=f"{self._base_url}{API_PREFIX}",
135
+ headers={
136
+ "Authorization": f"Bearer {token}",
137
+ "Accept": "application/json",
138
+ },
139
+ transport=transport,
140
+ timeout=timeout,
141
+ )
142
+
143
+ def __enter__(self) -> ApiClient:
144
+ """
145
+ Support ``with ApiClient(...) as client`` in command handlers.
146
+ """
147
+ return self
148
+
149
+ def __exit__(
150
+ self,
151
+ exc_type: type[BaseException] | None,
152
+ exc: BaseException | None,
153
+ tb: TracebackType | None,
154
+ ) -> None:
155
+ """
156
+ Close the underlying ``httpx.Client`` on context exit.
157
+ """
158
+ self.close()
159
+
160
+ def close(self) -> None:
161
+ """
162
+ Close the underlying ``httpx.Client``.
163
+ """
164
+ self._client.close()
165
+
166
+ def request(
167
+ self,
168
+ method: str,
169
+ path: str,
170
+ *,
171
+ params: dict[str, Any] | None = None,
172
+ json: Any | None = None,
173
+ headers: dict[str, str] | None = None,
174
+ ) -> httpx.Response:
175
+ """
176
+ Issue an HTTP request and return the raw response.
177
+
178
+ Low-level escape hatch: most callers should use the typed
179
+ wrappers (:meth:`get`, :meth:`post`, ...). Non-2xx responses
180
+ still raise :class:`ApiRequestError` so callers never have to
181
+ inspect ``response.status_code`` themselves.
182
+ """
183
+ try:
184
+ response = self._client.request(
185
+ method=method,
186
+ url=path,
187
+ params=params,
188
+ json=json,
189
+ headers=headers,
190
+ )
191
+ except httpx.TransportError as exc:
192
+ raise ApiTransportError(f"could not reach {self._base_url}: {exc}") from exc
193
+ if response.status_code >= 400:
194
+ raise _extract_error(response)
195
+ return response
196
+
197
+ def get(
198
+ self,
199
+ path: str,
200
+ *,
201
+ params: dict[str, Any] | None = None,
202
+ ) -> httpx.Response:
203
+ """
204
+ Issue a GET request.
205
+ """
206
+ return self.request("GET", path, params=params)
207
+
208
+ def post(
209
+ self,
210
+ path: str,
211
+ *,
212
+ json: Any | None = None,
213
+ params: dict[str, Any] | None = None,
214
+ headers: dict[str, str] | None = None,
215
+ ) -> httpx.Response:
216
+ """
217
+ Issue a POST request.
218
+ """
219
+ return self.request("POST", path, params=params, json=json, headers=headers)
220
+
221
+ def patch(
222
+ self,
223
+ path: str,
224
+ *,
225
+ json: Any | None = None,
226
+ headers: dict[str, str] | None = None,
227
+ ) -> httpx.Response:
228
+ """
229
+ Issue a PATCH request. Callers that need automatic ETag
230
+ round-tripping should use :meth:`patch_with_etag`.
231
+ """
232
+ return self.request("PATCH", path, json=json, headers=headers)
233
+
234
+ def delete(
235
+ self,
236
+ path: str,
237
+ *,
238
+ headers: dict[str, str] | None = None,
239
+ ) -> httpx.Response:
240
+ """
241
+ Issue a DELETE request. Callers that need automatic ETag
242
+ round-tripping should use :meth:`delete_with_etag`.
243
+ """
244
+ return self.request("DELETE", path, headers=headers)
245
+
246
+ def patch_with_etag(
247
+ self,
248
+ path: str,
249
+ *,
250
+ json: Any | None = None,
251
+ ) -> httpx.Response:
252
+ """
253
+ PATCH ``path`` after fetching its current ETag via GET.
254
+
255
+ Convenience for the common CLI flow: fetch, mutate, send back.
256
+ If the server returns a 412 the caller gets the raw
257
+ :class:`ApiRequestError` so it can render a friendly
258
+ "version conflict" message.
259
+ """
260
+ etag = self._fetch_etag(path)
261
+ return self.patch(path, json=json, headers={"If-Match": etag})
262
+
263
+ def delete_with_etag(self, path: str) -> httpx.Response:
264
+ """
265
+ DELETE ``path`` after fetching its current ETag via GET.
266
+ """
267
+ etag = self._fetch_etag(path)
268
+ return self.delete(path, headers={"If-Match": etag})
269
+
270
+ def post_with_etag(
271
+ self,
272
+ entity_path: str,
273
+ action_path: str,
274
+ *,
275
+ json: Any | None = None,
276
+ ) -> httpx.Response:
277
+ """
278
+ POST to ``action_path`` after fetching the ETag of
279
+ ``entity_path``.
280
+
281
+ Used for the ``/epics/{id}/close`` and ``/stories/{id}/transition``
282
+ endpoints, which require ``If-Match`` but do not share a URL
283
+ with their parent entity.
284
+ """
285
+ etag = self._fetch_etag(entity_path)
286
+ return self.post(action_path, json=json, headers={"If-Match": etag})
287
+
288
+ def _fetch_etag(self, path: str) -> str:
289
+ """
290
+ GET ``path`` and return the ``ETag`` header value.
291
+
292
+ Raises :class:`ApiError` if the response did not include an
293
+ ETag header, which signals that the caller mixed up a
294
+ non-versioned resource (tags, linkages) with the versioned
295
+ helper.
296
+ """
297
+ response = self.get(path)
298
+ etag = response.headers.get("etag")
299
+ if etag is None:
300
+ raise ApiError(
301
+ f"response for {path} did not include an ETag header; "
302
+ "this resource does not support optimistic concurrency"
303
+ )
304
+ return str(etag)
@@ -0,0 +1,6 @@
1
+ """
2
+ Kanbaroo CLI command groups.
3
+
4
+ Each module in this package implements one command or command group and
5
+ is registered onto the root Typer app in :mod:`kanbaroo_cli.app`.
6
+ """
@@ -0,0 +1,123 @@
1
+ """
2
+ Implementation of ``kb audit``.
3
+
4
+ Calls the ``/audit/entity/{entity_type}/{id}`` endpoint per spec
5
+ section 4.2. That endpoint is not yet implemented on the server as of
6
+ this cage (the GET-audit router belongs to a later cage); when the
7
+ server returns 404 the CLI prints a clear message rather than a
8
+ traceback, so the contract is stable for callers even while the
9
+ backend catches up.
10
+
11
+ ``kb audit`` accepts a human id (``KAN-123``) or a UUID. For human
12
+ ids we try ``/stories/by-key`` first and fall back to
13
+ ``/epics/by-key``; that lets the same command serve either kind of
14
+ entity without the user having to specify the type.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ import typer
22
+
23
+ from kanbaroo_cli.client import ApiClient, ApiError, ApiRequestError
24
+ from kanbaroo_cli.context import build_client, require_config
25
+ from kanbaroo_cli.rendering import (
26
+ exit_on_api_error,
27
+ print_json,
28
+ print_table,
29
+ )
30
+
31
+
32
+ def _resolve_entity(client: ApiClient, ref: str) -> tuple[str, str, str]:
33
+ """
34
+ Resolve ``ref`` to a ``(entity_type, entity_id, human_id_or_ref)``
35
+ triple.
36
+
37
+ For a human id (``KAN-N``) this hits ``by-key`` on both stories
38
+ and epics; for a UUID it tries story-by-id then epic-by-id.
39
+ Raises :class:`ApiRequestError` with ``not_found`` when neither
40
+ lookup succeeds, so the CLI's error path is uniform.
41
+ """
42
+ if "-" in ref and ref.rsplit("-", 1)[-1].isdigit():
43
+ try:
44
+ body = client.get(f"/stories/by-key/{ref}").json()
45
+ return "story", str(body["id"]), str(body["human_id"])
46
+ except ApiRequestError as exc:
47
+ if exc.code != "not_found":
48
+ raise
49
+ try:
50
+ body = client.get(f"/epics/by-key/{ref}").json()
51
+ return "epic", str(body["id"]), str(body["human_id"])
52
+ except ApiRequestError as exc:
53
+ if exc.code != "not_found":
54
+ raise
55
+ else:
56
+ try:
57
+ body = client.get(f"/stories/{ref}").json()
58
+ return "story", str(body["id"]), str(body["human_id"])
59
+ except ApiRequestError as exc:
60
+ if exc.code != "not_found":
61
+ raise
62
+ try:
63
+ body = client.get(f"/epics/{ref}").json()
64
+ return "epic", str(body["id"]), str(body["human_id"])
65
+ except ApiRequestError as exc:
66
+ if exc.code != "not_found":
67
+ raise
68
+ raise ApiRequestError(
69
+ status_code=404,
70
+ code="not_found",
71
+ message=f"no story or epic matches {ref!r}",
72
+ details={"ref": ref},
73
+ )
74
+
75
+
76
+ def audit_command(
77
+ ref: str = typer.Argument(
78
+ ...,
79
+ help="Story or epic handle (KAN-123) or UUID.",
80
+ ),
81
+ as_json: bool = typer.Option(
82
+ False,
83
+ "--json",
84
+ help="Emit JSON output instead of a Rich table.",
85
+ ),
86
+ ) -> None:
87
+ """
88
+ Show the audit history for a single story or epic.
89
+ """
90
+ config = require_config()
91
+ with build_client(config) as client:
92
+ try:
93
+ entity_type, entity_id, human_id = _resolve_entity(client, ref)
94
+ response = client.get(f"/audit/entity/{entity_type}/{entity_id}")
95
+ except ApiError as exc:
96
+ exit_on_api_error(exc)
97
+ payload: Any = response.json()
98
+
99
+ items: list[dict[str, Any]]
100
+ if isinstance(payload, dict) and "items" in payload:
101
+ items = list(payload["items"])
102
+ elif isinstance(payload, list):
103
+ items = list(payload)
104
+ else:
105
+ items = []
106
+
107
+ if as_json:
108
+ print_json(items)
109
+ return
110
+ rows = [
111
+ [
112
+ str(item.get("occurred_at", "")),
113
+ str(item.get("action", "")),
114
+ f"{item.get('actor_type', '')}/{item.get('actor_id', '')}",
115
+ str(item.get("id", "")),
116
+ ]
117
+ for item in items
118
+ ]
119
+ print_table(
120
+ columns=["occurred_at", "action", "actor", "event_id"],
121
+ rows=rows,
122
+ title=f"audit trail for {human_id}",
123
+ )
@@ -0,0 +1,78 @@
1
+ """
2
+ Implementation of ``kb backup``.
3
+
4
+ Local-only command: reads the configured SQLite file path from the
5
+ CLI config (or ``$KANBAROO_DATABASE_URL``) and copies it to a
6
+ timestamped path under ``--output`` (default ``~/.kanbaroo/backups``).
7
+ Does not touch the running server: snapshotting a SQLite file while
8
+ the writer is live is safe as long as readers see a consistent page
9
+ view, and using the filesystem here lets the command work even when
10
+ the server is offline.
11
+
12
+ Any database URL that is not SQLite (i.e. Postgres in a later phase)
13
+ causes the command to print a warning and exit with code 0. The
14
+ contract is "SQLite only for now"; we do not want to silently imply a
15
+ Postgres backup is happening.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import shutil
21
+ from datetime import UTC, datetime
22
+ from pathlib import Path
23
+
24
+ import typer
25
+
26
+ from kanbaroo_cli.config import default_config_dir
27
+ from kanbaroo_cli.context import require_config
28
+ from kanbaroo_cli.rendering import stderr_console, stdout_console
29
+
30
+ SQLITE_PREFIXES: tuple[str, ...] = ("sqlite:///", "sqlite+pysqlite:///")
31
+
32
+
33
+ def _sqlite_path_from_url(url: str) -> Path | None:
34
+ """
35
+ Return the file path embedded in a ``sqlite://`` URL, or ``None``
36
+ if the URL does not point at a SQLite file.
37
+
38
+ ``sqlite:///:memory:`` and other non-file forms return ``None``.
39
+ """
40
+ for prefix in SQLITE_PREFIXES:
41
+ if url.startswith(prefix):
42
+ raw = url[len(prefix) :]
43
+ if not raw or raw == ":memory:":
44
+ return None
45
+ return Path(raw)
46
+ return None
47
+
48
+
49
+ def backup_command(
50
+ output: Path | None = typer.Option(
51
+ None,
52
+ "--output",
53
+ help="Directory to write the backup into. Default: ~/.kanbaroo/backups.",
54
+ ),
55
+ ) -> None:
56
+ """
57
+ Copy the configured SQLite database file to a timestamped path.
58
+ """
59
+ config = require_config()
60
+ assert config.database_url is not None # require_config() guarantees this
61
+ src = _sqlite_path_from_url(config.database_url)
62
+ if src is None:
63
+ stderr_console.print(
64
+ "[yellow]Warning:[/yellow] database_url is not a SQLite file URL; "
65
+ "kb backup only supports SQLite in v1. Skipping."
66
+ )
67
+ raise typer.Exit(code=0)
68
+
69
+ if not src.exists():
70
+ stderr_console.print(f"[red]Error:[/red] database file {src} does not exist.")
71
+ raise typer.Exit(code=1)
72
+
73
+ dest_dir = output if output is not None else default_config_dir() / "backups"
74
+ dest_dir.mkdir(parents=True, exist_ok=True)
75
+ timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
76
+ dest = dest_dir / f"kanbaroo-{timestamp}.db"
77
+ shutil.copy2(src, dest)
78
+ stdout_console.print(f"wrote [bold]{dest}[/bold].")