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,252 @@
1
+ """
2
+ Implementation of ``kb tag``.
3
+
4
+ Tags are workspace-scoped and do not participate in optimistic
5
+ concurrency, so rename and delete do not require ``If-Match``. The
6
+ commands mirror the REST surface in ``kanbaroo_api.routers.tags``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ import typer
14
+
15
+ from kanbaroo_cli.client import ApiError
16
+ from kanbaroo_cli.context import build_client, require_config
17
+ from kanbaroo_cli.rendering import (
18
+ exit_on_api_error,
19
+ print_json,
20
+ print_table,
21
+ stdout_console,
22
+ )
23
+ from kanbaroo_cli.resolvers import require_effective_workspace, resolve_workspace
24
+ from kanbaroo_cli.similar import fetch_similar_entities, print_similar_entities
25
+
26
+ app = typer.Typer(
27
+ name="tag",
28
+ help="Manage workspace-scoped tags.",
29
+ no_args_is_help=True,
30
+ )
31
+
32
+
33
+ def _swatch_markup(color: str | None) -> str:
34
+ """
35
+ Build a Rich-markup color swatch for the ``color`` column.
36
+
37
+ Renders a two-space background-coloured block followed by the hex
38
+ text so the listing shows both the swatch and the raw value. Tags
39
+ with no colour get a plain dash so the column never collapses to
40
+ an empty cell.
41
+ """
42
+ if not color:
43
+ return "-"
44
+ return f"[on {color}] [/] {color}"
45
+
46
+
47
+ def _tag_row(item: dict[str, Any]) -> list[str]:
48
+ """
49
+ Format a single tag record into a :func:`print_table` row.
50
+
51
+ Soft-deleted rows dim the ``name`` cell so they are obviously
52
+ distinct from live tags when ``--include-deleted`` is active.
53
+ """
54
+ name = str(item["name"])
55
+ is_deleted = bool(item["deleted_at"])
56
+ name_cell = f"[dim]{name}[/dim]" if is_deleted else name
57
+ return [
58
+ name_cell,
59
+ _swatch_markup(item["color"]),
60
+ str(item["id"]),
61
+ "yes" if is_deleted else "no",
62
+ ]
63
+
64
+
65
+ @app.command("list")
66
+ def list_tags(
67
+ workspace: str | None = typer.Option(
68
+ None,
69
+ "--workspace",
70
+ help=(
71
+ "Workspace key or UUID. Falls back to $KANBAROO_WORKSPACE "
72
+ "and default_workspace from config."
73
+ ),
74
+ ),
75
+ include_deleted: bool = typer.Option(
76
+ False,
77
+ "--include-deleted",
78
+ help="Include soft-deleted tags in the listing.",
79
+ ),
80
+ as_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
81
+ ) -> None:
82
+ """
83
+ List every tag in a workspace.
84
+
85
+ Soft-deleted tags are hidden by default. When any exist a short
86
+ hint points at ``--include-deleted`` so the CLI does not silently
87
+ omit them.
88
+ """
89
+ config = require_config()
90
+ workspace_ref = require_effective_workspace(workspace, config)
91
+ with build_client(config) as client:
92
+ try:
93
+ ws = resolve_workspace(client, workspace_ref)
94
+ # Always request the full set so we can count soft-deleted
95
+ # tags without a second round-trip. Tag volume per
96
+ # workspace is small (single-page), so the cost is nil.
97
+ response = client.get(
98
+ f"/workspaces/{ws['id']}/tags",
99
+ params={"include_deleted": True},
100
+ )
101
+ except ApiError as exc:
102
+ exit_on_api_error(exc)
103
+ items = response.json()["items"]
104
+
105
+ deleted_items = [item for item in items if item["deleted_at"]]
106
+ displayed = (
107
+ items if include_deleted else [item for item in items if not item["deleted_at"]]
108
+ )
109
+
110
+ if as_json:
111
+ print_json(displayed)
112
+ return
113
+ rows = [_tag_row(item) for item in displayed]
114
+ print_table(
115
+ columns=["name", "color", "id", "deleted"],
116
+ rows=rows,
117
+ title=f"tags in {ws['key']}",
118
+ )
119
+ if not include_deleted and deleted_items:
120
+ count = len(deleted_items)
121
+ plural = "" if count == 1 else "s"
122
+ stdout_console.print(
123
+ f"[dim]Note: {count} soft-deleted tag{plural} not shown. "
124
+ "Use --include-deleted to see them.[/dim]"
125
+ )
126
+
127
+
128
+ @app.command("create")
129
+ def create_tag(
130
+ name: str = typer.Argument(..., help="Tag name."),
131
+ workspace: str | None = typer.Option(
132
+ None,
133
+ "--workspace",
134
+ help=(
135
+ "Workspace key or UUID. Falls back to $KANBAROO_WORKSPACE "
136
+ "and default_workspace from config."
137
+ ),
138
+ ),
139
+ color: str | None = typer.Option(
140
+ None,
141
+ "--color",
142
+ help="Optional hex color (e.g. #cc3333).",
143
+ ),
144
+ force: bool = typer.Option(
145
+ False,
146
+ "--force",
147
+ help="Skip the duplicate-name prompt and create the tag regardless.",
148
+ ),
149
+ as_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
150
+ ) -> None:
151
+ """
152
+ Create a workspace-scoped tag.
153
+
154
+ Mirrors ``kb story create``: a normalised-name check runs first
155
+ so visually similar tags (``UI`` vs ``ui``) trigger an interactive
156
+ confirmation. ``--force`` skips the prompt; ``--json`` never
157
+ prompts but folds the matches into a ``warnings`` field.
158
+ """
159
+ config = require_config()
160
+ workspace_ref = require_effective_workspace(workspace, config)
161
+ payload: dict[str, Any] = {"name": name}
162
+ if color is not None:
163
+ payload["color"] = color
164
+ similar: list[dict[str, Any]] = []
165
+ with build_client(config) as client:
166
+ try:
167
+ ws = resolve_workspace(client, workspace_ref)
168
+ similar = fetch_similar_entities(
169
+ client,
170
+ workspace_id=str(ws["id"]),
171
+ resource="tags",
172
+ field_name="name",
173
+ value=name,
174
+ )
175
+ if similar and not as_json and not force:
176
+ print_similar_entities(similar, label_key="name", entity="tag")
177
+ confirmed = typer.confirm("Create anyway?", default=False)
178
+ if not confirmed:
179
+ stdout_console.print("aborted: existing entity has a similar name")
180
+ raise typer.Exit(code=1)
181
+ response = client.post(
182
+ f"/workspaces/{ws['id']}/tags",
183
+ json=payload,
184
+ )
185
+ except ApiError as exc:
186
+ exit_on_api_error(exc)
187
+ body = response.json()
188
+
189
+ if as_json:
190
+ if similar:
191
+ body = {**body, "warnings": {"similar": [s["id"] for s in similar]}}
192
+ print_json(body)
193
+ return
194
+ stdout_console.print(f"created tag [bold]{body['name']}[/bold] (id {body['id']})")
195
+
196
+
197
+ @app.command("rename")
198
+ def rename_tag(
199
+ tag_id: str = typer.Argument(..., help="Tag UUID."),
200
+ new_name: str = typer.Argument(..., help="New tag name."),
201
+ color: str | None = typer.Option(
202
+ None,
203
+ "--color",
204
+ help="Optionally recolor the tag at the same time.",
205
+ ),
206
+ as_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
207
+ ) -> None:
208
+ """
209
+ Rename and optionally recolor a tag.
210
+ """
211
+ config = require_config()
212
+ payload: dict[str, Any] = {"name": new_name}
213
+ if color is not None:
214
+ payload["color"] = color
215
+ with build_client(config) as client:
216
+ try:
217
+ response = client.patch(f"/tags/{tag_id}", json=payload)
218
+ except ApiError as exc:
219
+ exit_on_api_error(exc)
220
+ body = response.json()
221
+
222
+ if as_json:
223
+ print_json(body)
224
+ return
225
+ stdout_console.print(f"renamed tag {tag_id} to [bold]{body['name']}[/bold]")
226
+
227
+
228
+ @app.command("delete")
229
+ def delete_tag(
230
+ tag_id: str = typer.Argument(..., help="Tag UUID."),
231
+ yes: bool = typer.Option(
232
+ False,
233
+ "--yes",
234
+ help="Skip the confirmation prompt.",
235
+ ),
236
+ ) -> None:
237
+ """
238
+ Soft-delete a tag. Detaches the tag from every story that carried it.
239
+ """
240
+ if not yes:
241
+ confirmed = typer.confirm(f"Soft-delete tag {tag_id}?")
242
+ if not confirmed:
243
+ stdout_console.print("aborted.")
244
+ raise typer.Exit(code=0)
245
+ config = require_config()
246
+ with build_client(config) as client:
247
+ try:
248
+ client.delete(f"/tags/{tag_id}")
249
+ except ApiError as exc:
250
+ exit_on_api_error(exc)
251
+
252
+ stdout_console.print(f"soft-deleted tag [bold]{tag_id}[/bold].")
@@ -0,0 +1,150 @@
1
+ """
2
+ Implementation of ``kb token``.
3
+
4
+ Wraps the ``/tokens`` REST surface. The create endpoint returns the
5
+ plaintext token exactly once; we surface it prominently and warn the
6
+ user to save it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ import typer
14
+
15
+ from kanbaroo_cli.client import ApiError
16
+ from kanbaroo_cli.context import build_client, require_config
17
+ from kanbaroo_cli.rendering import (
18
+ exit_on_api_error,
19
+ print_json,
20
+ print_table,
21
+ stdout_console,
22
+ )
23
+
24
+ app = typer.Typer(
25
+ name="token",
26
+ help="Manage Kanbaroo API tokens.",
27
+ no_args_is_help=True,
28
+ )
29
+
30
+
31
+ @app.command("list")
32
+ def list_tokens(
33
+ include_revoked: bool = typer.Option(
34
+ False,
35
+ "--include-revoked",
36
+ help="Include revoked tokens in the listing.",
37
+ ),
38
+ as_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
39
+ ) -> None:
40
+ """
41
+ List every API token. Plaintext is never returned; only the hash
42
+ prefix is surfaced so operators can identify tokens without seeing
43
+ secrets.
44
+ """
45
+ config = require_config()
46
+ with build_client(config) as client:
47
+ try:
48
+ params: dict[str, Any] = {}
49
+ if include_revoked:
50
+ params["include_revoked"] = True
51
+ response = client.get("/tokens", params=params or None)
52
+ except ApiError as exc:
53
+ exit_on_api_error(exc)
54
+ items = response.json()
55
+
56
+ if as_json:
57
+ print_json(items)
58
+ return
59
+ rows = [
60
+ [
61
+ str(item["id"]),
62
+ str(item["name"]),
63
+ f"{item['actor_type']}/{item['actor_id']}",
64
+ str(item["created_at"]),
65
+ "yes" if item["revoked_at"] else "no",
66
+ ]
67
+ for item in items
68
+ ]
69
+ print_table(
70
+ columns=["id", "name", "actor", "created_at", "revoked"],
71
+ rows=rows,
72
+ title="api tokens",
73
+ )
74
+
75
+
76
+ @app.command("create")
77
+ def create_token(
78
+ name: str = typer.Option(..., "--name", help="Human-readable token label."),
79
+ actor_type: str = typer.Option(
80
+ ...,
81
+ "--actor-type",
82
+ help="Actor type (human|claude|system).",
83
+ ),
84
+ actor_id: str = typer.Option(
85
+ ...,
86
+ "--actor-id",
87
+ help="Actor id (free-form label, e.g. 'outer-claude').",
88
+ ),
89
+ as_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
90
+ ) -> None:
91
+ """
92
+ Issue a new API token. The plaintext is returned exactly once;
93
+ save it before leaving this command.
94
+ """
95
+ config = require_config()
96
+ payload: dict[str, Any] = {
97
+ "name": name,
98
+ "actor_type": actor_type,
99
+ "actor_id": actor_id,
100
+ }
101
+ with build_client(config) as client:
102
+ try:
103
+ response = client.post("/tokens", json=payload)
104
+ except ApiError as exc:
105
+ exit_on_api_error(exc)
106
+ body = response.json()
107
+
108
+ if as_json:
109
+ print_json(body)
110
+ return
111
+ stdout_console.print(
112
+ "[bold yellow]Save this token now. It will not be shown again.[/bold yellow]"
113
+ )
114
+ print_table(
115
+ columns=["field", "value"],
116
+ rows=[
117
+ ["plaintext", body["plaintext"]],
118
+ ["id", body["id"]],
119
+ ["name", body["name"]],
120
+ ["actor_type", body["actor_type"]],
121
+ ["actor_id", body["actor_id"]],
122
+ ],
123
+ title=f"created token {body['name']}",
124
+ )
125
+
126
+
127
+ @app.command("revoke")
128
+ def revoke_token(
129
+ token_id: str = typer.Argument(..., help="Token UUID."),
130
+ yes: bool = typer.Option(
131
+ False,
132
+ "--yes",
133
+ help="Skip the confirmation prompt.",
134
+ ),
135
+ ) -> None:
136
+ """
137
+ Revoke a token. Idempotent against already-revoked rows.
138
+ """
139
+ if not yes:
140
+ confirmed = typer.confirm(f"Revoke token {token_id}?")
141
+ if not confirmed:
142
+ stdout_console.print("aborted.")
143
+ raise typer.Exit(code=0)
144
+ config = require_config()
145
+ with build_client(config) as client:
146
+ try:
147
+ client.delete(f"/tokens/{token_id}")
148
+ except ApiError as exc:
149
+ exit_on_api_error(exc)
150
+ stdout_console.print(f"revoked token [bold]{token_id}[/bold].")
@@ -0,0 +1,263 @@
1
+ """
2
+ Implementation of ``kb workspace``.
3
+
4
+ Wraps the workspace REST surface. ``show`` takes either a UUID or a
5
+ short key (``KAN``) and falls back to a list scan when the first lookup
6
+ returns 404, so the user can address workspaces the same way they do
7
+ in the rest of the CLI.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import typer
13
+
14
+ from kanbaroo_cli.client import ApiError, ApiRequestError
15
+ from kanbaroo_cli.config import write_default_workspace
16
+ from kanbaroo_cli.context import build_client, require_config
17
+ from kanbaroo_cli.rendering import (
18
+ exit_on_api_error,
19
+ print_json,
20
+ print_table,
21
+ stdout_console,
22
+ )
23
+ from kanbaroo_cli.resolvers import (
24
+ WORKSPACE_ENV_VAR,
25
+ WorkspaceSource,
26
+ effective_workspace,
27
+ resolve_workspace,
28
+ )
29
+
30
+ app = typer.Typer(
31
+ name="workspace",
32
+ help="Create, list, and inspect workspaces.",
33
+ no_args_is_help=True,
34
+ )
35
+
36
+
37
+ @app.command("list")
38
+ def list_workspaces(
39
+ as_json: bool = typer.Option(
40
+ False,
41
+ "--json",
42
+ help="Emit machine-readable JSON instead of a Rich table.",
43
+ ),
44
+ include_deleted: bool = typer.Option(
45
+ False,
46
+ "--include-deleted",
47
+ help="Include soft-deleted workspaces in the listing.",
48
+ ),
49
+ ) -> None:
50
+ """
51
+ List every workspace. Walks the paginated endpoint to collect the
52
+ entire set in one command.
53
+ """
54
+ config = require_config()
55
+ items: list[dict[str, object]] = []
56
+ cursor: str | None = None
57
+ with build_client(config) as client:
58
+ try:
59
+ while True:
60
+ params: dict[str, object] = {"limit": 200}
61
+ if include_deleted:
62
+ params["include_deleted"] = True
63
+ if cursor is not None:
64
+ params["cursor"] = cursor
65
+ response = client.get("/workspaces", params=params)
66
+ body = response.json()
67
+ items.extend(body["items"])
68
+ cursor = body.get("next_cursor")
69
+ if cursor is None:
70
+ break
71
+ except ApiError as exc:
72
+ exit_on_api_error(exc)
73
+
74
+ if as_json:
75
+ print_json(items)
76
+ return
77
+
78
+ rows = [
79
+ [
80
+ str(item["key"]),
81
+ str(item["name"]),
82
+ str(item["id"]),
83
+ str(item["next_issue_num"]),
84
+ "yes" if item["deleted_at"] else "no",
85
+ ]
86
+ for item in items
87
+ ]
88
+ print_table(
89
+ columns=["key", "name", "id", "next_issue", "deleted"],
90
+ rows=rows,
91
+ title="workspaces",
92
+ )
93
+
94
+
95
+ @app.command("create")
96
+ def create_workspace(
97
+ key: str = typer.Option(..., "--key", help="Short prefix, e.g. KAN."),
98
+ name: str = typer.Option(..., "--name", help="Human-readable workspace name."),
99
+ description: str | None = typer.Option(
100
+ None,
101
+ "--description",
102
+ help="Optional markdown description.",
103
+ ),
104
+ as_json: bool = typer.Option(
105
+ False,
106
+ "--json",
107
+ help="Emit the created workspace as JSON instead of a Rich panel.",
108
+ ),
109
+ ) -> None:
110
+ """
111
+ Create a new workspace.
112
+ """
113
+ config = require_config()
114
+ payload: dict[str, object] = {"key": key, "name": name}
115
+ if description is not None:
116
+ payload["description"] = description
117
+ with build_client(config) as client:
118
+ try:
119
+ response = client.post("/workspaces", json=payload)
120
+ except ApiError as exc:
121
+ exit_on_api_error(exc)
122
+ body = response.json()
123
+
124
+ if as_json:
125
+ print_json(body)
126
+ return
127
+ print_table(
128
+ columns=["field", "value"],
129
+ rows=[
130
+ ["id", body["id"]],
131
+ ["key", body["key"]],
132
+ ["name", body["name"]],
133
+ ["description", body["description"] or ""],
134
+ ],
135
+ title=f"created workspace {body['key']}",
136
+ )
137
+
138
+
139
+ @app.command("show")
140
+ def show_workspace(
141
+ key_or_id: str = typer.Argument(..., help="Workspace key (KAN) or UUID."),
142
+ as_json: bool = typer.Option(
143
+ False,
144
+ "--json",
145
+ help="Emit the workspace body as JSON instead of a Rich table.",
146
+ ),
147
+ ) -> None:
148
+ """
149
+ Show a single workspace by its short key or UUID.
150
+ """
151
+ config = require_config()
152
+ with build_client(config) as client:
153
+ try:
154
+ body = resolve_workspace(client, key_or_id)
155
+ except ApiRequestError as exc:
156
+ exit_on_api_error(exc)
157
+ except ApiError as exc:
158
+ exit_on_api_error(exc)
159
+
160
+ if as_json:
161
+ print_json(body)
162
+ return
163
+ print_table(
164
+ columns=["field", "value"],
165
+ rows=[
166
+ ["id", body["id"]],
167
+ ["key", body["key"]],
168
+ ["name", body["name"]],
169
+ ["description", body["description"] or ""],
170
+ ["next_issue_num", str(body["next_issue_num"])],
171
+ ["version", str(body["version"])],
172
+ ["created_at", body["created_at"]],
173
+ ["updated_at", body["updated_at"]],
174
+ ["deleted_at", body["deleted_at"] or ""],
175
+ ],
176
+ title=f"workspace {body['key']}",
177
+ )
178
+
179
+
180
+ @app.command("use")
181
+ def use_workspace(
182
+ key: str = typer.Argument(..., help="Workspace key to set as the default."),
183
+ ) -> None:
184
+ """
185
+ Set the CLI's default workspace.
186
+
187
+ Validates the key against the live server, then writes
188
+ ``default_workspace`` into ``config.toml`` so subsequent commands
189
+ may omit ``--workspace``. The ``--workspace`` flag and
190
+ ``$KANBAROO_WORKSPACE`` still take precedence when set.
191
+ """
192
+ config = require_config()
193
+ with build_client(config) as client:
194
+ try:
195
+ body = resolve_workspace(client, key)
196
+ except ApiRequestError as exc:
197
+ exit_on_api_error(exc)
198
+ except ApiError as exc:
199
+ exit_on_api_error(exc)
200
+
201
+ stored_key = str(body["key"])
202
+ write_default_workspace(config.config_path, stored_key)
203
+ stdout_console.print(
204
+ f"Default workspace set to [bold]{stored_key}[/bold]. "
205
+ "Commands will now default to this workspace unless "
206
+ f"[bold]--workspace[/bold] or [bold]${WORKSPACE_ENV_VAR}[/bold] is set."
207
+ )
208
+
209
+
210
+ @app.command("delete")
211
+ def delete_workspace(
212
+ key_or_id: str = typer.Argument(..., help="Workspace key (KAN) or UUID."),
213
+ yes: bool = typer.Option(
214
+ False,
215
+ "--yes",
216
+ help="Skip the confirmation prompt.",
217
+ ),
218
+ ) -> None:
219
+ """
220
+ Soft-delete a workspace. Prompts for confirmation unless ``--yes``.
221
+ """
222
+ config = require_config()
223
+ with build_client(config) as client:
224
+ try:
225
+ workspace = resolve_workspace(client, key_or_id)
226
+ except ApiRequestError as exc:
227
+ exit_on_api_error(exc)
228
+ except ApiError as exc:
229
+ exit_on_api_error(exc)
230
+ if not yes:
231
+ confirmed = typer.confirm(
232
+ f"Soft-delete workspace {workspace['key']} ({workspace['name']!r})?"
233
+ )
234
+ if not confirmed:
235
+ stdout_console.print("aborted.")
236
+ raise typer.Exit(code=0)
237
+ try:
238
+ client.delete_with_etag(f"/workspaces/{workspace['id']}")
239
+ except ApiError as exc:
240
+ exit_on_api_error(exc)
241
+
242
+ stdout_console.print(f"soft-deleted [bold]{workspace['key']}[/bold].")
243
+
244
+
245
+ @app.command("current")
246
+ def current_workspace() -> None:
247
+ """
248
+ Show the effective default workspace and the source supplying it.
249
+
250
+ Applies the same resolution order as every command that accepts
251
+ ``--workspace`` (env > config). Reports ``unset`` when no source
252
+ supplies a value.
253
+ """
254
+ config = require_config()
255
+ value, source = effective_workspace(None, config)
256
+ if source == WorkspaceSource.UNSET:
257
+ stdout_console.print(
258
+ "[yellow]No default workspace configured.[/yellow] "
259
+ f"Set [bold]${WORKSPACE_ENV_VAR}[/bold] or run "
260
+ "[bold]kb workspace use KEY[/bold]."
261
+ )
262
+ return
263
+ stdout_console.print(f"[bold]{value}[/bold] (source: [cyan]{source}[/cyan])")