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/__init__.py +5 -0
- kanbaroo_cli/app.py +114 -0
- kanbaroo_cli/client.py +304 -0
- kanbaroo_cli/commands/__init__.py +6 -0
- kanbaroo_cli/commands/audit.py +123 -0
- kanbaroo_cli/commands/backup.py +78 -0
- kanbaroo_cli/commands/epic.py +323 -0
- kanbaroo_cli/commands/export.py +81 -0
- kanbaroo_cli/commands/init.py +160 -0
- kanbaroo_cli/commands/server.py +133 -0
- kanbaroo_cli/commands/story.py +627 -0
- kanbaroo_cli/commands/tag.py +252 -0
- kanbaroo_cli/commands/token.py +150 -0
- kanbaroo_cli/commands/workspace.py +263 -0
- kanbaroo_cli/config.py +233 -0
- kanbaroo_cli/context.py +104 -0
- kanbaroo_cli/rendering.py +83 -0
- kanbaroo_cli/resolvers.py +185 -0
- kanbaroo_cli/similar.py +67 -0
- kanbaroo_cli-0.2.2.dist-info/METADATA +26 -0
- kanbaroo_cli-0.2.2.dist-info/RECORD +23 -0
- kanbaroo_cli-0.2.2.dist-info/WHEEL +4 -0
- kanbaroo_cli-0.2.2.dist-info/entry_points.txt +3 -0
|
@@ -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])")
|