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.
kanbaroo_cli/config.py ADDED
@@ -0,0 +1,233 @@
1
+ """
2
+ Config loader for the Kanbaroo CLI.
3
+
4
+ Every command that talks to the server needs three things: an
5
+ ``api_url``, a ``token``, and (for ``kb backup``) a ``database_url``.
6
+ All three are written to ``$KANBAROO_CONFIG_DIR/config.toml`` by
7
+ ``kb init``. This module provides a single :func:`load_config` entry
8
+ point that reads that file, applies environment-variable overrides,
9
+ and surfaces a dataclass the command handlers can consume without
10
+ re-parsing TOML every time.
11
+
12
+ If the config file is missing the loader raises
13
+ :class:`ConfigNotFoundError`, which the CLI translates into a clean
14
+ Rich-rendered message pointing the user at ``kb init``. We deliberately
15
+ do not fall back to a partial config: if ``config.toml`` does not exist
16
+ the user has not finished setup and we should say so loudly.
17
+ """
18
+
19
+ import os
20
+ import tomllib
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+
26
+ class ConfigError(Exception):
27
+ """
28
+ Base class for CLI configuration failures.
29
+ """
30
+
31
+
32
+ class ConfigNotFoundError(ConfigError):
33
+ """
34
+ Raised when the expected ``config.toml`` does not exist.
35
+
36
+ The CLI translates this into a 1-exit with a Rich-rendered hint
37
+ pointing the user at ``kb init``.
38
+ """
39
+
40
+ def __init__(self, path: Path) -> None:
41
+ super().__init__(f"Kanbaroo config not found at {path}")
42
+ self.path = path
43
+
44
+
45
+ class ConfigMalformedError(ConfigError):
46
+ """
47
+ Raised when ``config.toml`` is present but missing a required key or
48
+ contains a value of the wrong type.
49
+ """
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class CliConfig:
54
+ """
55
+ In-memory representation of the CLI's on-disk configuration.
56
+
57
+ ``database_url`` is needed by ``kb backup`` to locate the raw
58
+ SQLite file for a local snapshot; every other command only needs
59
+ ``api_url`` and ``token``. The field is typed ``str | None`` so the
60
+ API-only loader can skip the database-url validation and still
61
+ return a usable config (the HTTP-probe path in ``kb server start
62
+ --wait`` does not care where the database lives). Commands that
63
+ actually need the URL route through :func:`load_config`, which keeps
64
+ it mandatory.
65
+
66
+ ``default_workspace`` is the optional workspace key most
67
+ mutating/listing commands fall back to when neither ``--workspace``
68
+ nor ``$KANBAROO_WORKSPACE`` is set. It is written by
69
+ ``kb workspace use`` and may be absent in older configs.
70
+ """
71
+
72
+ api_url: str
73
+ token: str
74
+ database_url: str | None
75
+ config_path: Path
76
+ default_workspace: str | None = None
77
+
78
+
79
+ def default_config_dir() -> Path:
80
+ """
81
+ Resolve the config directory from ``$KANBAROO_CONFIG_DIR`` or fall
82
+ back to ``$HOME/.kanbaroo``.
83
+
84
+ Mirrors the helper inside ``kb init`` so both commands share the
85
+ same resolution logic without leaking the init module's internals.
86
+ """
87
+ override = os.environ.get("KANBAROO_CONFIG_DIR")
88
+ if override:
89
+ return Path(override)
90
+ return Path.home() / ".kanbaroo"
91
+
92
+
93
+ def default_config_path() -> Path:
94
+ """
95
+ Resolve the canonical ``config.toml`` path.
96
+ """
97
+ return default_config_dir() / "config.toml"
98
+
99
+
100
+ def load_config(
101
+ path: Path | None = None,
102
+ *,
103
+ require_database_url: bool = True,
104
+ ) -> CliConfig:
105
+ """
106
+ Read ``config.toml`` and return a :class:`CliConfig`.
107
+
108
+ ``$KANBAROO_API_URL``, ``$KANBAROO_TOKEN``, and
109
+ ``$KANBAROO_DATABASE_URL`` each override the matching TOML field
110
+ when set; this lets the test suite and CI pipelines drive the CLI
111
+ without touching a user's config.
112
+
113
+ ``api_url`` and ``token`` are always required.
114
+ ``require_database_url`` defaults to ``True``; pass ``False`` via
115
+ :func:`load_config_api_only` for commands that only hit the HTTP
116
+ API and never open the database directly (e.g.
117
+ ``kb server start --wait``).
118
+
119
+ TODO: broader CLI split needed between API-client commands (that
120
+ speak HTTP) and direct-DB-client commands (``kb backup``) so the
121
+ required-field set is implicit from the command rather than
122
+ manually wired per call site. Track alongside the API-client vs
123
+ direct-DB-client modes note in TODO.md.
124
+
125
+ Raises :class:`ConfigNotFoundError` when the file is absent and
126
+ :class:`ConfigMalformedError` when it is present but incomplete.
127
+ """
128
+ resolved_path = path or default_config_path()
129
+ if not resolved_path.exists():
130
+ raise ConfigNotFoundError(resolved_path)
131
+
132
+ with resolved_path.open("rb") as fh:
133
+ raw = tomllib.load(fh)
134
+
135
+ api_url = os.environ.get("KANBAROO_API_URL") or raw.get("api_url")
136
+ token = os.environ.get("KANBAROO_TOKEN") or raw.get("token")
137
+ database_url_raw = os.environ.get("KANBAROO_DATABASE_URL") or raw.get(
138
+ "database_url"
139
+ )
140
+ default_workspace_raw = raw.get("default_workspace")
141
+
142
+ missing: list[str] = []
143
+ if not isinstance(api_url, str) or not api_url:
144
+ missing.append("api_url")
145
+ if not isinstance(token, str) or not token:
146
+ missing.append("token")
147
+ if require_database_url and (
148
+ not isinstance(database_url_raw, str) or not database_url_raw
149
+ ):
150
+ missing.append("database_url")
151
+ if missing:
152
+ raise ConfigMalformedError(
153
+ f"config.toml at {resolved_path} is missing: {', '.join(missing)}"
154
+ )
155
+ if default_workspace_raw is not None and (
156
+ not isinstance(default_workspace_raw, str) or not default_workspace_raw
157
+ ):
158
+ raise ConfigMalformedError(
159
+ f"config.toml at {resolved_path} has an invalid default_workspace"
160
+ )
161
+
162
+ assert isinstance(api_url, str)
163
+ assert isinstance(token, str)
164
+ database_url: str | None = (
165
+ database_url_raw
166
+ if isinstance(database_url_raw, str) and database_url_raw
167
+ else None
168
+ )
169
+ default_workspace: str | None = (
170
+ default_workspace_raw if isinstance(default_workspace_raw, str) else None
171
+ )
172
+ return CliConfig(
173
+ api_url=api_url,
174
+ token=token,
175
+ database_url=database_url,
176
+ config_path=resolved_path,
177
+ default_workspace=default_workspace,
178
+ )
179
+
180
+
181
+ def load_config_api_only(path: Path | None = None) -> CliConfig:
182
+ """
183
+ Like :func:`load_config` but does not require ``database_url``.
184
+
185
+ Used by commands that only need to talk to the running HTTP API
186
+ and never read the local database. ``database_url`` may be absent
187
+ from ``config.toml`` entirely; in that case the returned
188
+ ``CliConfig.database_url`` is ``None``.
189
+ """
190
+ return load_config(path, require_database_url=False)
191
+
192
+
193
+ def _escape_toml(value: str) -> str:
194
+ """
195
+ Escape a string value for a TOML basic string literal.
196
+ """
197
+ return value.replace("\\", "\\\\").replace('"', '\\"')
198
+
199
+
200
+ def _render_config_toml(values: dict[str, Any]) -> str:
201
+ """
202
+ Render a minimal ``config.toml`` from a mapping of string scalars.
203
+
204
+ ``None`` values are omitted. Key order is preserved so rewrites do
205
+ not reshuffle the file unnecessarily.
206
+ """
207
+ lines: list[str] = []
208
+ for key, value in values.items():
209
+ if value is None:
210
+ continue
211
+ if not isinstance(value, str):
212
+ raise TypeError(f"config value for {key!r} must be str, got {type(value)}")
213
+ lines.append(f'{key} = "{_escape_toml(value)}"')
214
+ return "\n".join(lines) + "\n"
215
+
216
+
217
+ def write_default_workspace(path: Path, workspace_key: str | None) -> None:
218
+ """
219
+ Rewrite ``config.toml`` at ``path`` to set ``default_workspace`` to
220
+ ``workspace_key`` (or remove it when ``None``).
221
+
222
+ Preserves every other key present in the file. Used by
223
+ ``kb workspace use``.
224
+ """
225
+ if not path.exists():
226
+ raise ConfigNotFoundError(path)
227
+ with path.open("rb") as fh:
228
+ raw = tomllib.load(fh)
229
+ if workspace_key is None:
230
+ raw.pop("default_workspace", None)
231
+ else:
232
+ raw["default_workspace"] = workspace_key
233
+ path.write_text(_render_config_toml(raw), encoding="utf-8")
@@ -0,0 +1,104 @@
1
+ """
2
+ Per-command context helpers.
3
+
4
+ Most command handlers load the CLI config and spin up an
5
+ :class:`~kanbaroo_cli.client.ApiClient` in the same pattern. Centralising
6
+ that pattern here keeps each command handler thin and makes it trivial
7
+ to test: injecting a custom :class:`httpx.MockTransport` only happens in
8
+ one place.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable
14
+
15
+ import typer
16
+ from rich.console import Console
17
+
18
+ from kanbaroo_cli.client import ApiClient
19
+ from kanbaroo_cli.config import (
20
+ CliConfig,
21
+ ConfigMalformedError,
22
+ ConfigNotFoundError,
23
+ load_config,
24
+ load_config_api_only,
25
+ )
26
+
27
+ ClientFactory = Callable[[CliConfig], ApiClient]
28
+
29
+ _stderr_console = Console(stderr=True)
30
+
31
+
32
+ def require_config() -> CliConfig:
33
+ """
34
+ Load the CLI config or exit with a friendly error message.
35
+
36
+ Keeps every command from re-implementing the same error-render
37
+ plumbing.
38
+ """
39
+ try:
40
+ return load_config()
41
+ except ConfigNotFoundError as exc:
42
+ _stderr_console.print(
43
+ f"[red]Error:[/red] no Kanbaroo config at {exc.path}.\n"
44
+ "Run [bold]kb init[/bold] to create one."
45
+ )
46
+ raise typer.Exit(code=1) from exc
47
+ except ConfigMalformedError as exc:
48
+ _stderr_console.print(f"[red]Error:[/red] {exc}")
49
+ raise typer.Exit(code=1) from exc
50
+
51
+
52
+ def require_config_api_only() -> CliConfig:
53
+ """
54
+ Like :func:`require_config` but does not demand ``database_url``.
55
+
56
+ For commands that only hit the HTTP API (e.g.
57
+ ``kb server start --wait``). The returned
58
+ :class:`CliConfig.database_url` may be ``None``; the caller must
59
+ not dereference it.
60
+ """
61
+ try:
62
+ return load_config_api_only()
63
+ except ConfigNotFoundError as exc:
64
+ _stderr_console.print(
65
+ f"[red]Error:[/red] no Kanbaroo config at {exc.path}.\n"
66
+ "Run [bold]kb init[/bold] to create one."
67
+ )
68
+ raise typer.Exit(code=1) from exc
69
+ except ConfigMalformedError as exc:
70
+ _stderr_console.print(f"[red]Error:[/red] {exc}")
71
+ raise typer.Exit(code=1) from exc
72
+
73
+
74
+ def _default_client_factory(config: CliConfig) -> ApiClient:
75
+ """
76
+ Default implementation of :data:`_client_factory`: spin up a real
77
+ HTTP-backed client against the user's configured API URL.
78
+ """
79
+ return ApiClient(base_url=config.api_url, token=config.token)
80
+
81
+
82
+ _client_factory: ClientFactory = _default_client_factory
83
+
84
+
85
+ def build_client(config: CliConfig) -> ApiClient:
86
+ """
87
+ Build an :class:`ApiClient` wired to the loaded config.
88
+
89
+ Tests override this through :func:`set_client_factory` so they can
90
+ hand back a client pre-bound to an :class:`httpx.MockTransport`.
91
+ """
92
+ return _client_factory(config)
93
+
94
+
95
+ def set_client_factory(factory: ClientFactory | None) -> None:
96
+ """
97
+ Override the client factory for the duration of a test.
98
+
99
+ Passing ``None`` restores the default (real-HTTP) factory. The
100
+ indirection is deliberate: patching the factory keeps the call
101
+ sites in the command handlers identical to the production path.
102
+ """
103
+ global _client_factory
104
+ _client_factory = factory or _default_client_factory
@@ -0,0 +1,83 @@
1
+ """
2
+ Shared rendering helpers for CLI commands.
3
+
4
+ Every list/show command supports ``--json`` for scripting and defaults
5
+ to a Rich-rendered table. Keeping the two rendering paths in one place
6
+ means individual commands only choose *what* to display, not *how*.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json as _json
12
+ from typing import Any
13
+
14
+ import typer
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+
18
+ from kanbaroo_cli.client import ApiError, ApiRequestError
19
+
20
+ stdout_console = Console()
21
+ stderr_console = Console(stderr=True)
22
+
23
+
24
+ def print_json(payload: Any) -> None:
25
+ """
26
+ Emit ``payload`` as compact JSON on stdout.
27
+
28
+ Uses :func:`json.dumps` directly (rather than Rich's
29
+ ``print_json``) so the output is deterministic and pipe-friendly.
30
+ """
31
+ stdout_console.print_json(_json.dumps(payload))
32
+
33
+
34
+ def print_table(
35
+ *,
36
+ columns: list[str],
37
+ rows: list[list[str]],
38
+ title: str | None = None,
39
+ ) -> None:
40
+ """
41
+ Render a Rich table with the given columns and rows on stdout.
42
+
43
+ Empty ``rows`` renders the header with a single "(none)" cell so
44
+ the user never sees a blank box.
45
+ """
46
+ table = Table(title=title, show_lines=False)
47
+ for col in columns:
48
+ table.add_column(col)
49
+ if not rows:
50
+ table.add_row(*["(none)"] + [""] * (len(columns) - 1))
51
+ else:
52
+ for row in rows:
53
+ table.add_row(*row)
54
+ stdout_console.print(table)
55
+
56
+
57
+ def render_api_error(exc: ApiError) -> None:
58
+ """
59
+ Print a friendly error message for an API failure on stderr.
60
+
61
+ Wraps both transport errors (server unreachable) and HTTP errors
62
+ (canonical envelope) so command handlers can call this once and
63
+ raise :class:`typer.Exit` immediately after.
64
+ """
65
+ if isinstance(exc, ApiRequestError):
66
+ stderr_console.print(
67
+ f"[red]Error ({exc.status_code} {exc.code}):[/red] {exc.message}"
68
+ )
69
+ if exc.details:
70
+ stderr_console.print(f"[dim]details:[/dim] {exc.details}")
71
+ else:
72
+ stderr_console.print(f"[red]Error:[/red] {exc}")
73
+
74
+
75
+ def exit_on_api_error(exc: ApiError) -> None:
76
+ """
77
+ Render the error and raise :class:`typer.Exit` with code 1.
78
+
79
+ Used by every command handler so the exit-code contract is
80
+ identical across the CLI surface.
81
+ """
82
+ render_api_error(exc)
83
+ raise typer.Exit(code=1) from exc
@@ -0,0 +1,185 @@
1
+ """
2
+ ID-resolution helpers shared by commands.
3
+
4
+ Stories and epics are addressed in the CLI by their ``{KEY}-{N}`` human
5
+ identifiers (``KAN-123``); workspaces are addressed by their short key
6
+ (``KAN``) or by UUID. The REST API serves by-key lookups for every
7
+ entity directly so the CLI never has to walk the workspace list.
8
+
9
+ Most commands that accept ``--workspace`` also support a default: when
10
+ the flag is omitted, :func:`resolve_effective_workspace` falls back to
11
+ ``$KANBAROO_WORKSPACE`` and then to ``default_workspace`` from
12
+ ``config.toml`` before surfacing a friendly error.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from typing import Any
19
+
20
+ import typer
21
+
22
+ from kanbaroo_cli.client import ApiClient, ApiRequestError
23
+ from kanbaroo_cli.config import CliConfig
24
+ from kanbaroo_cli.rendering import stderr_console
25
+
26
+
27
+ def _looks_like_human_id(candidate: str) -> bool:
28
+ """
29
+ Return True if ``candidate`` is shaped like ``{KEY}-{N}``.
30
+ """
31
+ if "-" not in candidate:
32
+ return False
33
+ head, tail = candidate.rsplit("-", 1)
34
+ return bool(head) and tail.isdigit()
35
+
36
+
37
+ def resolve_workspace(client: ApiClient, key_or_id: str) -> dict[str, Any]:
38
+ """
39
+ Resolve a workspace by short key or UUID and return its full body.
40
+
41
+ Workspace keys do not contain dashes (the conventional ``KAN``/
42
+ ``DATA`` shape) whereas UUIDs do. Route any dashless reference
43
+ straight to ``GET /workspaces/by-key/{key}``. Otherwise try the
44
+ direct UUID endpoint first and fall back to the by-key endpoint for
45
+ the rare hyphenated-key case. A 404 from the final lookup surfaces
46
+ as :class:`ApiRequestError` with ``code="not_found"``.
47
+ """
48
+ if "-" not in key_or_id:
49
+ response = client.get(f"/workspaces/by-key/{key_or_id}")
50
+ body: dict[str, Any] = response.json()
51
+ return body
52
+
53
+ try:
54
+ response = client.get(f"/workspaces/{key_or_id}")
55
+ except ApiRequestError as exc:
56
+ if exc.code != "not_found":
57
+ raise
58
+ else:
59
+ direct: dict[str, Any] = response.json()
60
+ return direct
61
+
62
+ response = client.get(f"/workspaces/by-key/{key_or_id}")
63
+ fallback: dict[str, Any] = response.json()
64
+ return fallback
65
+
66
+
67
+ WORKSPACE_ENV_VAR = "KANBAROO_WORKSPACE"
68
+
69
+
70
+ class WorkspaceSource:
71
+ """
72
+ Labels for where an effective workspace came from.
73
+
74
+ Consumed by ``kb workspace current`` to tell the user which source
75
+ supplied the value.
76
+ """
77
+
78
+ FLAG = "flag"
79
+ ENV = "env"
80
+ CONFIG = "config"
81
+ UNSET = "unset"
82
+
83
+
84
+ def effective_workspace(
85
+ workspace_arg: str | None,
86
+ config: CliConfig,
87
+ ) -> tuple[str | None, str]:
88
+ """
89
+ Resolve the effective ``--workspace`` value and its source.
90
+
91
+ Order, highest to lowest precedence:
92
+
93
+ 1. ``workspace_arg`` (the explicit ``--workspace`` flag),
94
+ 2. ``$KANBAROO_WORKSPACE`` environment variable,
95
+ 3. ``default_workspace`` in ``config.toml``.
96
+
97
+ Returns ``(value, source)`` where ``value`` is ``None`` only when
98
+ none of the three supplied a non-empty string. Callers that need a
99
+ guaranteed value should use :func:`require_effective_workspace`.
100
+ """
101
+ if workspace_arg:
102
+ return workspace_arg, WorkspaceSource.FLAG
103
+ env_value = os.environ.get(WORKSPACE_ENV_VAR)
104
+ if env_value:
105
+ return env_value, WorkspaceSource.ENV
106
+ if config.default_workspace:
107
+ return config.default_workspace, WorkspaceSource.CONFIG
108
+ return None, WorkspaceSource.UNSET
109
+
110
+
111
+ def require_effective_workspace(
112
+ workspace_arg: str | None,
113
+ config: CliConfig,
114
+ ) -> str:
115
+ """
116
+ Resolve the effective workspace, exiting with a friendly Rich
117
+ error when none of the three sources supplies one.
118
+
119
+ Keeps every command handler's call site to a single line while
120
+ still yielding the same ``exit 1`` / stderr-hint UX across the CLI.
121
+ """
122
+ value, _ = effective_workspace(workspace_arg, config)
123
+ if value is None:
124
+ stderr_console.print(
125
+ "[red]Error:[/red] no workspace specified. "
126
+ "Pass [bold]--workspace KEY[/bold], "
127
+ f"set [bold]${WORKSPACE_ENV_VAR}[/bold], or run "
128
+ "[bold]kb workspace use KEY[/bold]."
129
+ )
130
+ raise typer.Exit(code=1)
131
+ return value
132
+
133
+
134
+ def resolve_story(client: ApiClient, ref: str) -> dict[str, Any]:
135
+ """
136
+ Resolve a story by ``{KEY}-{N}`` human id or by UUID.
137
+
138
+ Uses ``GET /stories/by-key/{ref}`` when ``ref`` matches the
139
+ human-id pattern, otherwise treats ``ref`` as a UUID and calls
140
+ ``GET /stories/{ref}``.
141
+ """
142
+ if _looks_like_human_id(ref):
143
+ response = client.get(f"/stories/by-key/{ref}")
144
+ else:
145
+ response = client.get(f"/stories/{ref}")
146
+ body: dict[str, Any] = response.json()
147
+ return body
148
+
149
+
150
+ def resolve_epic(client: ApiClient, ref: str) -> dict[str, Any]:
151
+ """
152
+ Resolve an epic by ``{KEY}-{N}`` human id or by UUID.
153
+
154
+ Uses ``GET /epics/by-key/{ref}`` when ``ref`` matches the human-id
155
+ pattern, otherwise treats ``ref`` as a UUID and calls
156
+ ``GET /epics/{ref}``.
157
+ """
158
+ if _looks_like_human_id(ref):
159
+ response = client.get(f"/epics/by-key/{ref}")
160
+ else:
161
+ response = client.get(f"/epics/{ref}")
162
+ body: dict[str, Any] = response.json()
163
+ return body
164
+
165
+
166
+ def try_resolve_other(
167
+ client: ApiClient,
168
+ ref: str,
169
+ *,
170
+ other: str,
171
+ ) -> dict[str, Any] | None:
172
+ """
173
+ Attempt to resolve ``ref`` as the other entity kind and return it.
174
+
175
+ ``other`` is either ``"story"`` or ``"epic"``. Used by ``kb story
176
+ show`` and ``kb epic show`` to turn a 404 into a helpful "this
177
+ looks like the other kind of entity" hint. Returns ``None`` when
178
+ the other lookup also misses so callers fall back to the plain
179
+ not-found message.
180
+ """
181
+ resolver = resolve_story if other == "story" else resolve_epic
182
+ try:
183
+ return resolver(client, ref)
184
+ except ApiRequestError:
185
+ return None
@@ -0,0 +1,67 @@
1
+ """
2
+ Shared helpers for the duplicate-title check used by ``kb story``,
3
+ ``kb epic``, and ``kb tag`` ``create`` commands.
4
+
5
+ The wired flow is the same for every entity type: query the
6
+ ``similar`` endpoint with the proposed title (or name), and either
7
+ prompt the user to abort, skip the prompt under ``--force``, or fold
8
+ the matches into a ``warnings`` field for ``--json`` output. The
9
+ shape of the entity differs (stories and epics carry ``human_id``;
10
+ tags carry ``name``), so callers pick the label key they want
11
+ displayed.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from kanbaroo_cli.client import ApiClient
19
+ from kanbaroo_cli.rendering import stdout_console
20
+
21
+
22
+ def fetch_similar_entities(
23
+ client: ApiClient,
24
+ *,
25
+ workspace_id: str,
26
+ resource: str,
27
+ field_name: str,
28
+ value: str,
29
+ ) -> list[dict[str, Any]]:
30
+ """
31
+ Call ``GET /workspaces/{id}/{resource}/similar?{field_name}=...``
32
+ and return the ``items`` list.
33
+
34
+ ``resource`` is the URL segment (``stories``, ``epics``,
35
+ ``tags``); ``field_name`` is the query-string key the endpoint
36
+ expects (``title`` for stories and epics, ``name`` for tags).
37
+ """
38
+ response = client.get(
39
+ f"/workspaces/{workspace_id}/{resource}/similar",
40
+ params={field_name: value},
41
+ )
42
+ body: dict[str, Any] = response.json()
43
+ items = body.get("items") or []
44
+ return [item for item in items if isinstance(item, dict)]
45
+
46
+
47
+ def print_similar_entities(
48
+ items: list[dict[str, Any]],
49
+ *,
50
+ label_key: str,
51
+ entity: str,
52
+ ) -> None:
53
+ """
54
+ Render a short list of similar entities to stdout for the
55
+ interactive prompt.
56
+
57
+ ``label_key`` is the field rendered in the second column
58
+ (``human_id`` for stories and epics, ``name`` for tags). The
59
+ table is intentionally small; we want the user to see the
60
+ overlap, not browse a result set.
61
+ """
62
+ plural = entity if items and len(items) == 1 else f"{entity}s"
63
+ stdout_console.print(f"[yellow]Found {len(items)} similar {plural}:[/yellow]")
64
+ for item in items:
65
+ label = str(item.get(label_key, "?"))
66
+ title = str(item.get("title") or item.get("name") or "")
67
+ stdout_console.print(f" - {label} {title}")
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: kanbaroo-cli
3
+ Version: 0.2.2
4
+ Summary: Kanbaroo command-line interface
5
+ Author: Adam Reese
6
+ License: MIT
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: kanbaroo-core
10
+ Requires-Dist: rich>=13.7
11
+ Requires-Dist: typer>=0.12
12
+ Description-Content-Type: text/markdown
13
+
14
+ # kanbaroo-cli
15
+
16
+ Typer-based command-line interface for [Kanbaroo](https://github.com/areese801/kanbaroo), a kanban-style issue tracker. Provides the `kanbaroo` and `kb` console scripts for working with workspaces, epics, stories, comments, tags, linkages, tokens, audit trails, export, and backups from the shell.
17
+
18
+ ```bash
19
+ pip install kanbaroo-cli
20
+ kb init # mint your first token
21
+ kb --help
22
+ ```
23
+
24
+ Full docs: [github.com/areese801/kanbaroo](https://github.com/areese801/kanbaroo).
25
+
26
+ License: MIT.