plane-cli 0.1.0__tar.gz

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,7 @@
1
+ Metadata-Version: 2.3
2
+ Name: plane-cli
3
+ Version: 0.1.0
4
+ Requires-Dist: typer[all]>=0.12.0
5
+ Requires-Dist: plane-sdk>=0.2.0
6
+ Requires-Dist: tomlkit>=0.13.0
7
+ Requires-Python: >=3.11
@@ -0,0 +1,3 @@
1
+ """plane-cli: A command-line interface for Plane.so project management."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,90 @@
1
+ """PlaneClient factory and retry logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import TYPE_CHECKING, Any, Callable, TypeVar
7
+
8
+ import typer
9
+ from plane.client.plane_client import PlaneClient
10
+ from plane.errors.errors import HttpError
11
+
12
+ from plane_cli.output import print_error
13
+
14
+ if TYPE_CHECKING:
15
+ from plane_cli.config import Config
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ def get_client(cfg: "Config") -> PlaneClient:
21
+ """Build and return a PlaneClient, validating required credentials."""
22
+ if not cfg.api_key:
23
+ print_error(
24
+ "auth_error",
25
+ "No API key configured. Set PLANE_API_KEY, use --api-key, or run `plane config init`.",
26
+ )
27
+ raise typer.Exit(1)
28
+ if not cfg.workspace_slug:
29
+ print_error(
30
+ "config_error",
31
+ "No workspace slug configured. Set PLANE_WORKSPACE_SLUG, use --workspace, or run `plane config init`.",
32
+ )
33
+ raise typer.Exit(1)
34
+
35
+ return PlaneClient(base_url=cfg.base_url, api_key=cfg.api_key)
36
+
37
+
38
+ def call_with_retry(fn: Callable[..., T], *args: Any, max_retries: int = 3, **kwargs: Any) -> T:
39
+ """Call fn(*args, **kwargs) with retry on HTTP 429."""
40
+ for attempt in range(max_retries):
41
+ try:
42
+ return fn(*args, **kwargs)
43
+ except HttpError as exc:
44
+ if exc.status_code == 429:
45
+ # Try to read Retry-After header
46
+ retry_after = 5
47
+ if hasattr(exc, "response") and exc.response is not None:
48
+ try:
49
+ retry_after = int(exc.response.headers.get("Retry-After", 5))
50
+ except (AttributeError, ValueError):
51
+ retry_after = 5
52
+
53
+ if attempt < max_retries - 1:
54
+ time.sleep(retry_after)
55
+ continue
56
+ else:
57
+ print_error(
58
+ "rate_limit",
59
+ f"Rate limit exceeded after {max_retries} attempts.",
60
+ status_code=429,
61
+ )
62
+ raise typer.Exit(2)
63
+ else:
64
+ _handle_http_error(exc)
65
+ raise typer.Exit(1)
66
+
67
+ # Should not reach here
68
+ raise typer.Exit(1)
69
+
70
+
71
+ def _handle_http_error(exc: HttpError) -> None:
72
+ """Translate an HttpError into a structured error output."""
73
+ status = exc.status_code
74
+ message = str(exc)
75
+
76
+ if status == 401:
77
+ error_type = "auth_error"
78
+ message = "Authentication failed. Check your API key."
79
+ elif status == 403:
80
+ error_type = "forbidden"
81
+ message = "Permission denied."
82
+ elif status == 404:
83
+ error_type = "not_found"
84
+ message = f"Resource not found. {message}"
85
+ elif status == 400:
86
+ error_type = "validation_error"
87
+ else:
88
+ error_type = "api_error"
89
+
90
+ print_error(error_type, message, status_code=status)
File without changes
@@ -0,0 +1,123 @@
1
+ """config subcommand: show / set / init."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Optional
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.prompt import Prompt
11
+
12
+ from plane_cli.config import (
13
+ Config,
14
+ CONFIG_PATH,
15
+ config_as_dict,
16
+ load_config,
17
+ save_config,
18
+ save_config_key,
19
+ )
20
+ from plane_cli.output import print_json, print_error, out_console
21
+
22
+ app = typer.Typer(name="config", help="Manage plane-cli configuration.", no_args_is_help=True)
23
+ console = Console()
24
+ err_console = Console(stderr=True)
25
+
26
+
27
+ @app.command("show")
28
+ def config_show(
29
+ ctx: typer.Context,
30
+ reveal: bool = typer.Option(False, "--reveal", help="Show the full API key."),
31
+ ) -> None:
32
+ """Show the resolved configuration (masks api_key by default)."""
33
+ cfg: Config = ctx.obj
34
+ print_json(config_as_dict(cfg, reveal=reveal))
35
+
36
+
37
+ @app.command("set")
38
+ def config_set(
39
+ ctx: typer.Context,
40
+ key: str = typer.Argument(..., help="Dotted config key, e.g. defaults.project"),
41
+ value: str = typer.Argument(..., help="Value to set"),
42
+ ) -> None:
43
+ """Write a config value. Example: plane config set defaults.project <UUID>"""
44
+ try:
45
+ save_config_key(key, value)
46
+ print_json({"ok": True, "key": key, "value": value})
47
+ except Exception as exc:
48
+ print_error("config_error", f"Failed to write config: {exc}")
49
+ raise typer.Exit(1)
50
+
51
+
52
+ @app.command("init")
53
+ def config_init(ctx: typer.Context) -> None:
54
+ """Interactive first-run wizard (TTY only)."""
55
+ if not sys.stdin.isatty():
56
+ print_error("validation_error", "`plane config init` requires an interactive terminal.")
57
+ raise typer.Exit(1)
58
+
59
+ console.print("[bold]plane-cli setup wizard[/bold]")
60
+ console.print(f"Config will be saved to: [dim]{CONFIG_PATH}[/dim]\n")
61
+
62
+ cfg: Config = ctx.obj
63
+
64
+ api_key = Prompt.ask(
65
+ "API key",
66
+ password=True,
67
+ default=cfg.api_key or "",
68
+ )
69
+ workspace_slug = Prompt.ask(
70
+ "Workspace slug",
71
+ default=cfg.workspace_slug or "",
72
+ )
73
+ base_url = Prompt.ask(
74
+ "Base URL",
75
+ default=cfg.base_url,
76
+ )
77
+
78
+ if not api_key:
79
+ print_error("validation_error", "API key is required.")
80
+ raise typer.Exit(1)
81
+ if not workspace_slug:
82
+ print_error("validation_error", "Workspace slug is required.")
83
+ raise typer.Exit(1)
84
+
85
+ # Validate credentials by listing projects
86
+ console.print("\nValidating credentials…")
87
+ try:
88
+ from plane.client.plane_client import PlaneClient
89
+ client = PlaneClient(base_url=base_url, api_key=api_key)
90
+ response = client.projects.list(workspace_slug)
91
+ projects = response.results or []
92
+ console.print(f"[green]✓[/green] Connected. Found {len(projects)} project(s).")
93
+ except Exception as exc:
94
+ print_error("auth_error", f"Could not connect: {exc}")
95
+ raise typer.Exit(1)
96
+
97
+ # Optionally set default project
98
+ default_project: Optional[str] = None
99
+ if projects:
100
+ console.print("\nAvailable projects:")
101
+ for i, p in enumerate(projects, 1):
102
+ console.print(f" {i}. [{p.identifier}] {p.name} (id: {p.id})")
103
+ choice = Prompt.ask(
104
+ "Set a default project? Enter number or press Enter to skip",
105
+ default="",
106
+ )
107
+ if choice.strip().isdigit():
108
+ idx = int(choice.strip()) - 1
109
+ if 0 <= idx < len(projects):
110
+ default_project = projects[idx].id
111
+ console.print(f"[green]✓[/green] Default project: {projects[idx].name}")
112
+
113
+ # Build and save config
114
+ new_cfg = load_config()
115
+ new_cfg.api_key = api_key
116
+ new_cfg.workspace_slug = workspace_slug
117
+ new_cfg.base_url = base_url
118
+ if default_project:
119
+ new_cfg.project = default_project
120
+
121
+ save_config(new_cfg)
122
+ console.print(f"\n[green]✓[/green] Config saved to {CONFIG_PATH}")
123
+ print_json(config_as_dict(new_cfg))
@@ -0,0 +1,317 @@
1
+ """issues subcommand: list / get / create / update / delete + comment add/list."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import List, Optional
7
+
8
+ import typer
9
+ from plane.models.work_items import (
10
+ CreateWorkItem,
11
+ CreateWorkItemComment,
12
+ UpdateWorkItem,
13
+ )
14
+ from plane.models.query_params import WorkItemQueryParams
15
+
16
+ from plane_cli.client import get_client, call_with_retry
17
+ from plane_cli.config import Config
18
+ from plane_cli.output import (
19
+ build_comments_table,
20
+ build_issues_table,
21
+ out_console,
22
+ print_error,
23
+ print_json,
24
+ read_text_arg,
25
+ )
26
+
27
+ app = typer.Typer(name="issues", help="Manage issues (work items).", no_args_is_help=True)
28
+ comment_app = typer.Typer(name="comment", help="Manage issue comments.", no_args_is_help=True)
29
+ app.add_typer(comment_app)
30
+
31
+ _VALID_PRIORITIES = ("urgent", "high", "medium", "low", "none")
32
+ _MAX_ALL_PAGES = 1000
33
+
34
+
35
+ def _issue_to_dict(issue: object) -> dict:
36
+ return issue.model_dump() if hasattr(issue, "model_dump") else dict(issue)
37
+
38
+
39
+ def _resolve_project(cfg: Config, project_flag: Optional[str]) -> str:
40
+ project_id = project_flag or cfg.project
41
+ if not project_id:
42
+ print_error(
43
+ "config_error",
44
+ "No project specified. Use --project or set defaults.project in config.",
45
+ )
46
+ raise typer.Exit(1)
47
+ return project_id
48
+
49
+
50
+ @app.command("list")
51
+ def issues_list(
52
+ ctx: typer.Context,
53
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
54
+ state: Optional[str] = typer.Option(None, "--state", help="Filter by state ID"),
55
+ priority: Optional[str] = typer.Option(None, "--priority", help="Filter by priority"),
56
+ label: Optional[List[str]] = typer.Option(None, "--label", help="Filter by label ID (repeatable)"),
57
+ assignee: Optional[List[str]] = typer.Option(None, "--assignee", help="Filter by assignee ID (repeatable)"),
58
+ page: int = typer.Option(1, "--page", help="Page number"),
59
+ per_page: Optional[int] = typer.Option(None, "--per-page", help="Results per page"),
60
+ all_pages: bool = typer.Option(False, "--all", help="Fetch all pages (cap: 1000)"),
61
+ ) -> None:
62
+ """List issues in a project."""
63
+ cfg: Config = ctx.obj
64
+ project_id = _resolve_project(cfg, project)
65
+ client = get_client(cfg)
66
+
67
+ effective_per_page = per_page or cfg.per_page
68
+
69
+ if all_pages:
70
+ all_issues: list[dict] = []
71
+ cursor: Optional[str] = None
72
+
73
+ while True:
74
+ params = WorkItemQueryParams(per_page=effective_per_page)
75
+ if cursor:
76
+ params.cursor = cursor
77
+
78
+ response = call_with_retry(
79
+ client.work_items.list, cfg.workspace_slug, project_id, params
80
+ )
81
+ batch = [_issue_to_dict(i) for i in (response.results or [])]
82
+ all_issues.extend(batch)
83
+
84
+ if len(all_issues) >= _MAX_ALL_PAGES:
85
+ from plane_cli.output import err_console
86
+ err_console.print(
87
+ f"[yellow]Warning: reached {_MAX_ALL_PAGES} issue limit; stopping pagination.[/yellow]"
88
+ )
89
+ all_issues = all_issues[:_MAX_ALL_PAGES]
90
+ break
91
+
92
+ if not response.next_page_results:
93
+ break
94
+ cursor = response.next_cursor
95
+
96
+ issues = all_issues
97
+ else:
98
+ # Manual single-page fetch
99
+ # cursor-based: build cursor from page number
100
+ cursor_str: Optional[str] = None
101
+ if page > 1:
102
+ # Plane uses cursor format: "per_page:offset:0" style but the SDK
103
+ # exposes next_cursor strings from responses. For manual page navigation
104
+ # we use the offset pattern.
105
+ offset = (page - 1) * effective_per_page
106
+ cursor_str = f"{effective_per_page}:{offset}:0"
107
+
108
+ params = WorkItemQueryParams(per_page=effective_per_page)
109
+ if cursor_str:
110
+ params.cursor = cursor_str
111
+
112
+ response = call_with_retry(
113
+ client.work_items.list, cfg.workspace_slug, project_id, params
114
+ )
115
+ issues = [_issue_to_dict(i) for i in (response.results or [])]
116
+
117
+ if cfg.pretty:
118
+ table = build_issues_table(issues)
119
+ out_console.print(table)
120
+ else:
121
+ print_json(issues)
122
+
123
+
124
+ @app.command("get")
125
+ def issues_get(
126
+ ctx: typer.Context,
127
+ issue_id: str = typer.Argument(..., help="Issue ID"),
128
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
129
+ ) -> None:
130
+ """Get a single issue by ID."""
131
+ cfg: Config = ctx.obj
132
+ project_id = _resolve_project(cfg, project)
133
+ client = get_client(cfg)
134
+
135
+ issue = call_with_retry(
136
+ client.work_items.retrieve, cfg.workspace_slug, project_id, issue_id
137
+ )
138
+ print_json(_issue_to_dict(issue))
139
+
140
+
141
+ @app.command("create")
142
+ def issues_create(
143
+ ctx: typer.Context,
144
+ title: str = typer.Option(..., "--title", "-t", help="Issue title"),
145
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
146
+ description: Optional[str] = typer.Option(
147
+ None, "--description", "-d", help="Description or '-' for stdin"
148
+ ),
149
+ state: Optional[str] = typer.Option(None, "--state", help="State ID"),
150
+ priority: Optional[str] = typer.Option(
151
+ None, "--priority", help=f"Priority: {', '.join(_VALID_PRIORITIES)}"
152
+ ),
153
+ label: Optional[List[str]] = typer.Option(None, "--label", help="Label ID (repeatable)"),
154
+ assignee: Optional[List[str]] = typer.Option(None, "--assignee", help="Assignee ID (repeatable)"),
155
+ due_date: Optional[str] = typer.Option(None, "--due-date", help="Due date YYYY-MM-DD"),
156
+ ) -> None:
157
+ """Create a new issue."""
158
+ cfg: Config = ctx.obj
159
+ project_id = _resolve_project(cfg, project)
160
+
161
+ if priority is not None and priority not in _VALID_PRIORITIES:
162
+ print_error(
163
+ "validation_error",
164
+ f"Invalid priority '{priority}'. Choose from: {', '.join(_VALID_PRIORITIES)}",
165
+ )
166
+ raise typer.Exit(1)
167
+
168
+ data_kwargs: dict = {"name": title}
169
+
170
+ if description is not None:
171
+ desc_text = read_text_arg(description)
172
+ data_kwargs["description_html"] = f"<p>{desc_text}</p>"
173
+ data_kwargs["description_stripped"] = desc_text
174
+
175
+ if state is not None:
176
+ data_kwargs["state"] = state
177
+ if priority is not None:
178
+ data_kwargs["priority"] = priority
179
+ if label:
180
+ data_kwargs["labels"] = list(label)
181
+ if assignee:
182
+ data_kwargs["assignees"] = list(assignee)
183
+ if due_date is not None:
184
+ data_kwargs["target_date"] = due_date
185
+
186
+ client = get_client(cfg)
187
+ data = CreateWorkItem(**data_kwargs)
188
+ issue = call_with_retry(client.work_items.create, cfg.workspace_slug, project_id, data)
189
+ print_json(_issue_to_dict(issue))
190
+
191
+
192
+ @app.command("update")
193
+ def issues_update(
194
+ ctx: typer.Context,
195
+ issue_id: str = typer.Argument(..., help="Issue ID"),
196
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
197
+ title: Optional[str] = typer.Option(None, "--title", "-t"),
198
+ description: Optional[str] = typer.Option(
199
+ None, "--description", "-d", help="Description or '-' for stdin"
200
+ ),
201
+ state: Optional[str] = typer.Option(None, "--state", help="State ID"),
202
+ priority: Optional[str] = typer.Option(None, "--priority"),
203
+ label: Optional[List[str]] = typer.Option(None, "--label", help="Label ID (repeatable)"),
204
+ due_date: Optional[str] = typer.Option(None, "--due-date", help="Due date YYYY-MM-DD"),
205
+ ) -> None:
206
+ """Update an issue."""
207
+ cfg: Config = ctx.obj
208
+ project_id = _resolve_project(cfg, project)
209
+
210
+ data_kwargs: dict = {}
211
+
212
+ if title is not None:
213
+ data_kwargs["name"] = title
214
+ if description is not None:
215
+ desc_text = read_text_arg(description)
216
+ data_kwargs["description_html"] = f"<p>{desc_text}</p>"
217
+ data_kwargs["description_stripped"] = desc_text
218
+ if state is not None:
219
+ data_kwargs["state"] = state
220
+ if priority is not None:
221
+ if priority not in _VALID_PRIORITIES:
222
+ print_error(
223
+ "validation_error",
224
+ f"Invalid priority '{priority}'. Choose from: {', '.join(_VALID_PRIORITIES)}",
225
+ )
226
+ raise typer.Exit(1)
227
+ data_kwargs["priority"] = priority
228
+ if label:
229
+ data_kwargs["labels"] = list(label)
230
+ if due_date is not None:
231
+ data_kwargs["target_date"] = due_date
232
+
233
+ if not data_kwargs:
234
+ print_error("validation_error", "No fields to update.")
235
+ raise typer.Exit(1)
236
+
237
+ client = get_client(cfg)
238
+ data = UpdateWorkItem(**data_kwargs)
239
+ issue = call_with_retry(
240
+ client.work_items.update, cfg.workspace_slug, project_id, issue_id, data
241
+ )
242
+ print_json(_issue_to_dict(issue))
243
+
244
+
245
+ @app.command("delete")
246
+ def issues_delete(
247
+ ctx: typer.Context,
248
+ issue_id: str = typer.Argument(..., help="Issue ID"),
249
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
250
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
251
+ ) -> None:
252
+ """Delete an issue."""
253
+ cfg: Config = ctx.obj
254
+ project_id = _resolve_project(cfg, project)
255
+
256
+ if not yes and not sys.stdin.isatty():
257
+ print_error("validation_error", "Pass --yes for non-interactive deletion.")
258
+ raise typer.Exit(1)
259
+ if not yes:
260
+ typer.confirm(f"Delete issue {issue_id}?", abort=True)
261
+
262
+ client = get_client(cfg)
263
+ call_with_retry(client.work_items.delete, cfg.workspace_slug, project_id, issue_id)
264
+ print_json({"ok": True, "deleted": issue_id})
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # comment sub-subcommand
269
+ # ---------------------------------------------------------------------------
270
+
271
+ @comment_app.command("list")
272
+ def comment_list(
273
+ ctx: typer.Context,
274
+ issue_id: str = typer.Argument(..., help="Issue ID"),
275
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
276
+ ) -> None:
277
+ """List comments on an issue."""
278
+ cfg: Config = ctx.obj
279
+ project_id = _resolve_project(cfg, project)
280
+ client = get_client(cfg)
281
+
282
+ response = call_with_retry(
283
+ client.work_items.comments.list, cfg.workspace_slug, project_id, issue_id
284
+ )
285
+ comments = [_issue_to_dict(c) for c in (response.results or [])]
286
+
287
+ if cfg.pretty:
288
+ table = build_comments_table(comments)
289
+ out_console.print(table)
290
+ else:
291
+ print_json(comments)
292
+
293
+
294
+ @comment_app.command("add")
295
+ def comment_add(
296
+ ctx: typer.Context,
297
+ issue_id: str = typer.Argument(..., help="Issue ID"),
298
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
299
+ body: str = typer.Option(..., "--body", "-b", help="Comment body or '-' for stdin"),
300
+ ) -> None:
301
+ """Add a comment to an issue."""
302
+ cfg: Config = ctx.obj
303
+ project_id = _resolve_project(cfg, project)
304
+
305
+ body_text = read_text_arg(body)
306
+ comment_html = f"<p>{body_text}</p>"
307
+
308
+ client = get_client(cfg)
309
+ data = CreateWorkItemComment(comment_html=comment_html)
310
+ comment = call_with_retry(
311
+ client.work_items.comments.create,
312
+ cfg.workspace_slug,
313
+ project_id,
314
+ issue_id,
315
+ data,
316
+ )
317
+ print_json(_issue_to_dict(comment))
@@ -0,0 +1,141 @@
1
+ """labels subcommand: list / get / create / update / delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Optional
7
+
8
+ import typer
9
+ from plane.models.labels import CreateLabel, UpdateLabel
10
+
11
+ from plane_cli.client import get_client, call_with_retry
12
+ from plane_cli.config import Config
13
+ from plane_cli.output import (
14
+ print_json,
15
+ print_error,
16
+ build_labels_table,
17
+ out_console,
18
+ )
19
+
20
+ app = typer.Typer(name="labels", help="Manage work item labels.", no_args_is_help=True)
21
+
22
+
23
+ def _label_to_dict(lb: object) -> dict:
24
+ return lb.model_dump() if hasattr(lb, "model_dump") else dict(lb)
25
+
26
+
27
+ def _resolve_project(cfg: Config, project_flag: Optional[str]) -> str:
28
+ project_id = project_flag or cfg.project
29
+ if not project_id:
30
+ print_error(
31
+ "config_error",
32
+ "No project specified. Use --project or set defaults.project in config.",
33
+ )
34
+ raise typer.Exit(1)
35
+ return project_id
36
+
37
+
38
+ @app.command("list")
39
+ def labels_list(
40
+ ctx: typer.Context,
41
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
42
+ ) -> None:
43
+ """List all labels in a project."""
44
+ cfg: Config = ctx.obj
45
+ project_id = _resolve_project(cfg, project)
46
+ client = get_client(cfg)
47
+
48
+ response = call_with_retry(client.labels.list, cfg.workspace_slug, project_id)
49
+ labels = [_label_to_dict(lb) for lb in (response.results or [])]
50
+
51
+ if cfg.pretty:
52
+ table = build_labels_table(labels)
53
+ out_console.print(table)
54
+ else:
55
+ print_json(labels)
56
+
57
+
58
+ @app.command("get")
59
+ def labels_get(
60
+ ctx: typer.Context,
61
+ label_id: str = typer.Argument(..., help="Label ID"),
62
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
63
+ ) -> None:
64
+ """Get a single label by ID."""
65
+ cfg: Config = ctx.obj
66
+ project_id = _resolve_project(cfg, project)
67
+ client = get_client(cfg)
68
+
69
+ label = call_with_retry(client.labels.retrieve, cfg.workspace_slug, project_id, label_id)
70
+ print_json(_label_to_dict(label))
71
+
72
+
73
+ @app.command("create")
74
+ def labels_create(
75
+ ctx: typer.Context,
76
+ name: str = typer.Option(..., "--name", "-n", help="Label name"),
77
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
78
+ color: Optional[str] = typer.Option(None, "--color", help="Hex color code"),
79
+ ) -> None:
80
+ """Create a new label."""
81
+ cfg: Config = ctx.obj
82
+ project_id = _resolve_project(cfg, project)
83
+
84
+ data_kwargs: dict = {"name": name}
85
+ if color is not None:
86
+ data_kwargs["color"] = color
87
+
88
+ client = get_client(cfg)
89
+ data = CreateLabel(**data_kwargs)
90
+ label = call_with_retry(client.labels.create, cfg.workspace_slug, project_id, data)
91
+ print_json(_label_to_dict(label))
92
+
93
+
94
+ @app.command("update")
95
+ def labels_update(
96
+ ctx: typer.Context,
97
+ label_id: str = typer.Argument(..., help="Label ID"),
98
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
99
+ name: Optional[str] = typer.Option(None, "--name", "-n"),
100
+ color: Optional[str] = typer.Option(None, "--color"),
101
+ ) -> None:
102
+ """Update a label."""
103
+ cfg: Config = ctx.obj
104
+ project_id = _resolve_project(cfg, project)
105
+
106
+ data_kwargs: dict = {}
107
+ if name is not None:
108
+ data_kwargs["name"] = name
109
+ if color is not None:
110
+ data_kwargs["color"] = color
111
+
112
+ if not data_kwargs:
113
+ print_error("validation_error", "No fields to update.")
114
+ raise typer.Exit(1)
115
+
116
+ client = get_client(cfg)
117
+ data = UpdateLabel(**data_kwargs)
118
+ label = call_with_retry(client.labels.update, cfg.workspace_slug, project_id, label_id, data)
119
+ print_json(_label_to_dict(label))
120
+
121
+
122
+ @app.command("delete")
123
+ def labels_delete(
124
+ ctx: typer.Context,
125
+ label_id: str = typer.Argument(..., help="Label ID"),
126
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
127
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
128
+ ) -> None:
129
+ """Delete a label."""
130
+ cfg: Config = ctx.obj
131
+ project_id = _resolve_project(cfg, project)
132
+
133
+ if not yes and not sys.stdin.isatty():
134
+ print_error("validation_error", "Pass --yes for non-interactive deletion.")
135
+ raise typer.Exit(1)
136
+ if not yes:
137
+ typer.confirm(f"Delete label {label_id}?", abort=True)
138
+
139
+ client = get_client(cfg)
140
+ call_with_retry(client.labels.delete, cfg.workspace_slug, project_id, label_id)
141
+ print_json({"ok": True, "deleted": label_id})