plane-workflow-mcp 0.6.0__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 @@
1
+ """Package data for the portable Plane Workflow MCP distribution."""
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m plane_workflow_mcp``."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,603 @@
1
+ """Install and operate Plane Workflow as a portable MCP command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import getpass
7
+ import importlib.metadata
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any, Callable
16
+
17
+ from . import configuration, server
18
+
19
+
20
+ CLIENTS = ("claude-code", "codex", "claude-desktop", "cursor", "opencode", "zed")
21
+ JSON_CLIENTS = ("claude-desktop", "cursor", "opencode", "zed")
22
+ CLI_CLIENTS = {"claude-code": "claude", "codex": "codex"}
23
+ PACKAGE_NAME = "plane-workflow-mcp"
24
+ LATEST_RELEASE_URL = "https://api.github.com/repos/amirhosseinkz/plane-workflow-mcp/releases/latest"
25
+ UPDATE_COMMAND = "uv tool install --force {wheel_url}"
26
+
27
+
28
+ class SetupError(RuntimeError):
29
+ """A setup issue that the user can safely correct."""
30
+
31
+
32
+ def _jsonc_to_json(text: str) -> str:
33
+ """Remove JSONC comments and trailing commas without touching strings."""
34
+ output: list[str] = []
35
+ index = 0
36
+ in_string = False
37
+ escaped = False
38
+ while index < len(text):
39
+ character = text[index]
40
+ following = text[index + 1] if index + 1 < len(text) else ""
41
+ if in_string:
42
+ output.append(character)
43
+ if escaped:
44
+ escaped = False
45
+ elif character == "\\":
46
+ escaped = True
47
+ elif character == '"':
48
+ in_string = False
49
+ index += 1
50
+ continue
51
+ if character == '"':
52
+ in_string = True
53
+ output.append(character)
54
+ elif character == "/" and following == "/":
55
+ index = text.find("\n", index)
56
+ if index == -1:
57
+ break
58
+ output.append("\n")
59
+ elif character == "/" and following == "*":
60
+ closing = text.find("*/", index + 2)
61
+ if closing == -1:
62
+ raise SetupError("The client configuration has an unterminated block comment.")
63
+ index = closing + 1
64
+ else:
65
+ output.append(character)
66
+ index += 1
67
+ result = "".join(output)
68
+ compact: list[str] = []
69
+ index = 0
70
+ in_string = False
71
+ escaped = False
72
+ while index < len(result):
73
+ character = result[index]
74
+ if in_string:
75
+ compact.append(character)
76
+ if escaped:
77
+ escaped = False
78
+ elif character == "\\":
79
+ escaped = True
80
+ elif character == '"':
81
+ in_string = False
82
+ index += 1
83
+ continue
84
+ if character == '"':
85
+ in_string = True
86
+ compact.append(character)
87
+ elif character == ",":
88
+ look_ahead = index + 1
89
+ while look_ahead < len(result) and result[look_ahead].isspace():
90
+ look_ahead += 1
91
+ if look_ahead < len(result) and result[look_ahead] in "}]":
92
+ index += 1
93
+ continue
94
+ compact.append(character)
95
+ else:
96
+ compact.append(character)
97
+ index += 1
98
+ return "".join(compact)
99
+
100
+
101
+ def _read_jsonc(path: Path) -> dict[str, Any]:
102
+ if not path.exists():
103
+ return {}
104
+ try:
105
+ payload = json.loads(_jsonc_to_json(path.read_text(encoding="utf-8")))
106
+ except json.JSONDecodeError as error:
107
+ raise SetupError(f"Could not parse client configuration: {path}") from error
108
+ if not isinstance(payload, dict):
109
+ raise SetupError(f"Client configuration must be a JSON object: {path}")
110
+ return payload
111
+
112
+
113
+ def _write_json_config(path: Path, payload: dict[str, Any]) -> None:
114
+ path.parent.mkdir(parents=True, exist_ok=True)
115
+ if path.exists():
116
+ backup = path.with_suffix(path.suffix + ".plane-workflow-backup")
117
+ if not backup.exists():
118
+ backup.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
119
+ temporary = path.with_suffix(path.suffix + ".tmp")
120
+ temporary.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
121
+ temporary.replace(path)
122
+
123
+
124
+ def _opencode_config_path(override: str | None = None) -> Path:
125
+ if override:
126
+ return Path(override).expanduser()
127
+ configured = os.getenv("OPENCODE_CONFIG")
128
+ if configured:
129
+ return Path(configured).expanduser()
130
+ directory = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) / "opencode"
131
+ jsonc_path = directory / "opencode.jsonc"
132
+ return jsonc_path if jsonc_path.exists() else directory / "opencode.json"
133
+
134
+
135
+ def _zed_settings_path(override: str | None = None) -> Path:
136
+ if override:
137
+ return Path(override).expanduser()
138
+ configured = os.getenv("ZED_SETTINGS_PATH")
139
+ if configured:
140
+ return Path(configured).expanduser()
141
+ if sys.platform == "darwin":
142
+ return Path.home() / "Library" / "Application Support" / "Zed" / "settings.json"
143
+ if os.name == "nt":
144
+ return Path(os.getenv("APPDATA", Path.home())) / "Zed" / "settings.json"
145
+ return Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) / "zed" / "settings.json"
146
+
147
+
148
+ def _cursor_config_path(override: str | None = None) -> Path:
149
+ if override:
150
+ return Path(override).expanduser()
151
+ return Path.home() / ".cursor" / "mcp.json"
152
+
153
+
154
+ def _claude_desktop_config_path(override: str | None = None) -> Path:
155
+ if override:
156
+ return Path(override).expanduser()
157
+ if sys.platform == "darwin":
158
+ directory = Path.home() / "Library" / "Application Support" / "Claude"
159
+ elif os.name == "nt":
160
+ directory = Path(os.getenv("APPDATA", Path.home())) / "Claude"
161
+ else:
162
+ directory = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) / "Claude"
163
+ return directory / "claude_desktop_config.json"
164
+
165
+
166
+ def _json_config_path(client: str, override: str | None = None) -> Path:
167
+ paths = {
168
+ "claude-desktop": _claude_desktop_config_path,
169
+ "cursor": _cursor_config_path,
170
+ "opencode": _opencode_config_path,
171
+ "zed": _zed_settings_path,
172
+ }
173
+ return paths[client](override)
174
+
175
+
176
+ def _server_command() -> str:
177
+ """Prefer an absolute launcher path; desktop apps often start without the shell PATH."""
178
+ found = shutil.which("plane-workflow")
179
+ return str(Path(found).absolute()) if found else "plane-workflow"
180
+
181
+
182
+ def _install_json_client(client: str, path: Path, *, dry_run: bool = False) -> Path:
183
+ payload = _read_jsonc(path)
184
+ if client == "opencode":
185
+ payload.setdefault("$schema", "https://opencode.ai/config.json")
186
+ mcp_servers = payload.setdefault("mcp", {})
187
+ if not isinstance(mcp_servers, dict):
188
+ raise SetupError("OpenCode config has a non-object mcp field.")
189
+ mcp_servers["plane-workflow"] = {"type": "local", "command": ["plane-workflow", "mcp"], "enabled": True}
190
+ elif client == "zed":
191
+ servers = payload.setdefault("context_servers", {})
192
+ if not isinstance(servers, dict):
193
+ raise SetupError("Zed settings have a non-object context_servers field.")
194
+ servers["plane-workflow"] = {"command": "plane-workflow", "args": ["mcp"], "env": {}}
195
+ elif client in {"claude-desktop", "cursor"}:
196
+ servers = payload.setdefault("mcpServers", {})
197
+ if not isinstance(servers, dict):
198
+ raise SetupError(f"{client} config has a non-object mcpServers field.")
199
+ servers["plane-workflow"] = {"command": _server_command(), "args": ["mcp"]}
200
+ else:
201
+ raise SetupError(f"Unsupported JSON-configured client: {client}")
202
+ if not dry_run:
203
+ _write_json_config(path, payload)
204
+ return path
205
+
206
+
207
+ def _remove_json_client(client: str, path: Path, *, dry_run: bool = False) -> bool:
208
+ payload = _read_jsonc(path)
209
+ key = {"opencode": "mcp", "zed": "context_servers"}.get(client, "mcpServers")
210
+ servers = payload.get(key)
211
+ if not isinstance(servers, dict) or "plane-workflow" not in servers:
212
+ return False
213
+ servers.pop("plane-workflow")
214
+ if not dry_run:
215
+ _write_json_config(path, payload)
216
+ return True
217
+
218
+
219
+ def _run_client_cli(client: str, arguments: list[str], runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run) -> subprocess.CompletedProcess[str]:
220
+ binary = CLI_CLIENTS[client]
221
+ try:
222
+ return runner([binary, "mcp", *arguments], text=True, capture_output=True, check=False)
223
+ except OSError as error:
224
+ raise SetupError(f"The {binary} CLI is not available on PATH.") from error
225
+
226
+
227
+ def _cli_add_arguments(client: str) -> list[str]:
228
+ if client == "claude-code":
229
+ return ["add", "--scope", "user", "plane-workflow", "--", _server_command(), "mcp"]
230
+ return ["add", "plane-workflow", "--", "plane-workflow", "mcp"]
231
+
232
+
233
+ def _cli_remove_arguments(client: str) -> list[str]:
234
+ if client == "claude-code":
235
+ return ["remove", "--scope", "user", "plane-workflow"]
236
+ return ["remove", "plane-workflow"]
237
+
238
+
239
+ def _install_cli_client(client: str, *, dry_run: bool = False, runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run) -> str:
240
+ if dry_run:
241
+ return " ".join([CLI_CLIENTS[client], "mcp", *_cli_add_arguments(client)])
242
+ current = _run_client_cli(client, ["get", "plane-workflow"], runner)
243
+ if current.returncode == 0:
244
+ removed = _run_client_cli(client, _cli_remove_arguments(client), runner)
245
+ if removed.returncode != 0:
246
+ raise SetupError(f"Could not replace the existing {client} MCP server: {removed.stderr.strip()}")
247
+ added = _run_client_cli(client, _cli_add_arguments(client), runner)
248
+ if added.returncode != 0:
249
+ raise SetupError(f"Could not configure {client}: {added.stderr.strip()}")
250
+ return f"{client} MCP configuration"
251
+
252
+
253
+ def _remove_cli_client(client: str, *, dry_run: bool = False, runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run) -> bool:
254
+ if dry_run:
255
+ return True
256
+ result = _run_client_cli(client, _cli_remove_arguments(client), runner)
257
+ if result.returncode != 0:
258
+ if "not found" in (result.stderr + result.stdout).casefold():
259
+ return False
260
+ raise SetupError(f"Could not remove the {client} MCP server: {result.stderr.strip()}")
261
+ return True
262
+
263
+
264
+ def _install_codex(*, dry_run: bool = False, runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run) -> str:
265
+ return _install_cli_client("codex", dry_run=dry_run, runner=runner)
266
+
267
+
268
+ def detected_clients() -> list[str]:
269
+ binaries = {"claude-code": "claude", "codex": "codex", "cursor": "cursor", "opencode": "opencode", "zed": "zed"}
270
+ found = [client for client, binary in binaries.items() if shutil.which(binary)]
271
+ if "cursor" not in found and _cursor_config_path().parent.is_dir():
272
+ found.append("cursor")
273
+ if _claude_desktop_config_path().parent.is_dir():
274
+ found.append("claude-desktop")
275
+ return [client for client in CLIENTS if client in found]
276
+
277
+
278
+ def _choose_client(requested: str | None, input_fn: Callable[[str], str] = input) -> str:
279
+ if requested:
280
+ return requested
281
+ available = detected_clients()
282
+ if len(available) == 1:
283
+ return available[0]
284
+ if not available:
285
+ raise SetupError("No supported client was found. Re-run with --client and one of: " + ", ".join(CLIENTS) + ".")
286
+ options = ", ".join(f"{index + 1}) {client}" for index, client in enumerate(available))
287
+ answer = input_fn(f"Choose a client ({options}): ").strip()
288
+ if answer.isdigit() and 1 <= int(answer) <= len(available):
289
+ return available[int(answer) - 1]
290
+ if answer in available:
291
+ return answer
292
+ raise SetupError("Choose one of the detected clients.")
293
+
294
+
295
+ def _confirm(message: str, *, yes: bool, input_fn: Callable[[str], str] = input) -> bool:
296
+ return yes or input_fn(f"{message} [y/N]: ").strip().casefold() in {"y", "yes"}
297
+
298
+
299
+ def _prompt(value: str | None, label: str, input_fn: Callable[[str], str] = input) -> str:
300
+ answer = value or input_fn(f"{label}: ")
301
+ if not answer.strip():
302
+ raise SetupError(f"{label} is required.")
303
+ return answer.strip()
304
+
305
+
306
+ def _validate_connection(base_url: str, workspace: str, api_key: str) -> None:
307
+ try:
308
+ api = server.PlaneApi(server.PlaneSettings(base_url=base_url, workspace=workspace, api_key=api_key))
309
+ api.request("GET", "projects", params={"per_page": 1})
310
+ except (server.PlaneWorkflowError, server.requests.RequestException, OSError) as error:
311
+ raise SetupError("Plane could not validate this URL, workspace, and API key. Nothing was saved.") from error
312
+
313
+
314
+ def command_setup(arguments: argparse.Namespace) -> int:
315
+ client = _choose_client(arguments.client)
316
+ if client == "none":
317
+ destination = "your OS keyring only (no client is registered)"
318
+ else:
319
+ destination = f"your {client} MCP configuration" if client in CLI_CLIENTS else str(_json_config_path(client, arguments.config_file))
320
+ if not _confirm(f"Configure Plane Workflow for {client} and update {destination}?", yes=arguments.yes):
321
+ print("Setup cancelled; no settings were changed.")
322
+ return 0
323
+ if arguments.dry_run:
324
+ if client == "none":
325
+ preview = "would store Plane credentials without registering a client"
326
+ elif client in CLI_CLIENTS:
327
+ preview = _install_cli_client(client, dry_run=True)
328
+ else:
329
+ path = _json_config_path(client, arguments.config_file)
330
+ _install_json_client(client, path, dry_run=True)
331
+ preview = f"would update {path} with plane-workflow -> plane-workflow mcp"
332
+ print(f"Setup preview for {client}: {preview}. No credentials or client settings were changed.")
333
+ return 0
334
+ base_url = _prompt(arguments.base_url, "Plane base URL")
335
+ workspace = _prompt(arguments.workspace, "Plane workspace slug")
336
+ api_key = os.getenv("PLANE_API_KEY") if arguments.non_interactive else getpass.getpass("Plane API key (stored in your OS keyring): ")
337
+ if not api_key:
338
+ raise SetupError("Plane API key is required.")
339
+ _validate_connection(base_url, workspace, api_key)
340
+ profile_path = configuration.save_stored_plane_settings(base_url=base_url, workspace=workspace, api_key=api_key, profile=arguments.profile)
341
+ if client == "none":
342
+ print(f"Plane credentials are stored. Settings: {profile_path}. No client was registered.")
343
+ print("Next: run plane-workflow project list, then plane-workflow project activate PROJECT_ID.")
344
+ return 0
345
+ if client in CLI_CLIENTS:
346
+ configured = _install_cli_client(client, dry_run=arguments.dry_run)
347
+ else:
348
+ configured = _install_json_client(client, _json_config_path(client, arguments.config_file), dry_run=arguments.dry_run)
349
+ print(f"Plane Workflow is configured for {client}. Settings: {profile_path}. Client: {configured}.")
350
+ print("Next: run plane-workflow project list, then plane-workflow project activate PROJECT_ID.")
351
+ print(f"Restart {client}, then ask:")
352
+ print('"Confirm my active Plane workspace and project, then show overdue and unassigned work. '
353
+ 'Include item identifiers and totals, flag truncated lists, and do not change Plane data."')
354
+ return 0
355
+
356
+
357
+ def installed_version() -> str | None:
358
+ try:
359
+ return importlib.metadata.version(PACKAGE_NAME)
360
+ except importlib.metadata.PackageNotFoundError:
361
+ return None
362
+
363
+
364
+ def _version_key(value: str) -> tuple[int, ...]:
365
+ return tuple(int(part) for part in re.findall(r"\d+", value.split("+")[0])[:3])
366
+
367
+
368
+ def check_for_update(current: str | None, get: Callable[..., Any] | None = None) -> dict[str, Any] | None:
369
+ """Best-effort lookup of the latest GitHub release; never fails the caller."""
370
+ if not current or os.getenv("PLANE_WORKFLOW_NO_UPDATE_CHECK"):
371
+ return None
372
+ get = get or server.requests.get
373
+ try:
374
+ response = get(LATEST_RELEASE_URL, timeout=3, headers={"Accept": "application/vnd.github+json"})
375
+ release = response.json() if response.ok else None
376
+ except (server.requests.RequestException, ValueError):
377
+ return None
378
+ tag = release.get("tag_name") if isinstance(release, dict) else None
379
+ if not isinstance(tag, str) or _version_key(tag) <= _version_key(current):
380
+ return None
381
+ wheel = next(
382
+ (
383
+ asset.get("browser_download_url")
384
+ for asset in release.get("assets", [])
385
+ if isinstance(asset, dict) and str(asset.get("name", "")).endswith(".whl")
386
+ ),
387
+ None,
388
+ )
389
+ return {
390
+ "latest_version": tag.lstrip("v"),
391
+ "release_url": release.get("html_url"),
392
+ "update_command": UPDATE_COMMAND.format(wheel_url=wheel) if wheel else None,
393
+ }
394
+
395
+
396
+ def command_status(arguments: argparse.Namespace) -> int:
397
+ try:
398
+ settings = configuration.load_stored_plane_settings(arguments.profile)
399
+ except configuration.ConfigurationError as error:
400
+ print(json.dumps({"status": "needs_setup", "configured": False, "next_command": "plane-workflow setup", "reason": str(error)}, indent=2))
401
+ return 1
402
+ version = installed_version()
403
+ print(
404
+ json.dumps(
405
+ {
406
+ "status": "configured" if settings else "needs_setup",
407
+ "version": version,
408
+ "update_available": check_for_update(version),
409
+ "configured": settings is not None,
410
+ "profile": settings.profile if settings else None,
411
+ "base_url": settings.base_url if settings else None,
412
+ "workspace": settings.workspace if settings else None,
413
+ "active_project": _project_view(settings.active_project) if settings else None,
414
+ "detected_clients": detected_clients(),
415
+ "next_command": None if settings else "plane-workflow setup",
416
+ },
417
+ indent=2,
418
+ )
419
+ )
420
+ return 0 if settings else 1
421
+
422
+
423
+ def _project_view(project: configuration.StoredPlaneProject | None) -> dict[str, str | None] | None:
424
+ if project is None:
425
+ return None
426
+ return {"id": project.id, "identifier": project.identifier, "name": project.name}
427
+
428
+
429
+ def command_workspace_list(_: argparse.Namespace) -> int:
430
+ profiles = configuration.list_stored_plane_profiles()
431
+ print(
432
+ json.dumps(
433
+ {
434
+ "status": "configured" if profiles else "needs_setup",
435
+ "workspaces": [
436
+ {
437
+ "profile": profile.profile,
438
+ "base_url": profile.base_url,
439
+ "workspace": profile.workspace,
440
+ "active": profile.active,
441
+ "active_project": _project_view(profile.active_project),
442
+ }
443
+ for profile in profiles
444
+ ],
445
+ },
446
+ indent=2,
447
+ )
448
+ )
449
+ return 0 if profiles else 1
450
+
451
+
452
+ def command_workspace_activate(arguments: argparse.Namespace) -> int:
453
+ profile = configuration.activate_stored_plane_profile(arguments.profile)
454
+ print(
455
+ json.dumps(
456
+ {
457
+ "status": "activated",
458
+ "workspace": profile.workspace,
459
+ "profile": profile.profile,
460
+ "active_project": _project_view(profile.active_project),
461
+ },
462
+ indent=2,
463
+ )
464
+ )
465
+ return 0
466
+
467
+
468
+ def _active_stored_settings() -> configuration.StoredPlaneSettings:
469
+ settings = configuration.load_stored_plane_settings()
470
+ if settings is None:
471
+ raise SetupError("No active Plane workspace is configured. Run plane-workflow setup first.")
472
+ return settings
473
+
474
+
475
+ def command_project_list(_: argparse.Namespace) -> int:
476
+ settings = _active_stored_settings()
477
+ api = server.PlaneApi(
478
+ server.PlaneSettings(
479
+ base_url=settings.base_url,
480
+ workspace=settings.workspace,
481
+ api_key=settings.api_key,
482
+ profile=settings.profile,
483
+ )
484
+ )
485
+ projects = api.projects()
486
+ print(
487
+ json.dumps(
488
+ {
489
+ "status": "projects",
490
+ "workspace": settings.workspace,
491
+ "active_project": _project_view(settings.active_project),
492
+ "projects": [
493
+ {"id": project.get("id"), "identifier": project.get("identifier"), "name": project.get("name")}
494
+ for project in projects
495
+ ],
496
+ },
497
+ indent=2,
498
+ )
499
+ )
500
+ return 0
501
+
502
+
503
+ def command_project_activate(arguments: argparse.Namespace) -> int:
504
+ settings = _active_stored_settings()
505
+ api = server.PlaneApi(
506
+ server.PlaneSettings(
507
+ base_url=settings.base_url,
508
+ workspace=settings.workspace,
509
+ api_key=settings.api_key,
510
+ profile=settings.profile,
511
+ )
512
+ )
513
+ project = api.project(arguments.project_id)
514
+ active_project = configuration.set_stored_active_project(
515
+ profile=settings.profile,
516
+ project_id=str(project.get("id") or arguments.project_id),
517
+ identifier=str(project["identifier"]) if project.get("identifier") else None,
518
+ name=str(project["name"]) if project.get("name") else None,
519
+ )
520
+ print(
521
+ json.dumps(
522
+ {
523
+ "status": "activated",
524
+ "workspace": settings.workspace,
525
+ "profile": settings.profile,
526
+ "active_project": _project_view(active_project),
527
+ },
528
+ indent=2,
529
+ )
530
+ )
531
+ return 0
532
+
533
+
534
+ def command_remove(arguments: argparse.Namespace) -> int:
535
+ client = _choose_client(arguments.client)
536
+ if not _confirm(f"Remove Plane Workflow from {client} and delete profile {arguments.profile!r}?", yes=arguments.yes):
537
+ print("Removal cancelled; no settings were changed.")
538
+ return 0
539
+ if client in CLI_CLIENTS:
540
+ removed_client = _remove_cli_client(client, dry_run=arguments.dry_run)
541
+ else:
542
+ removed_client = _remove_json_client(client, _json_config_path(client, arguments.config_file), dry_run=arguments.dry_run)
543
+ removed_profile = configuration.remove_stored_plane_settings(arguments.profile) if not arguments.dry_run else True
544
+ print(f"Removed client entry: {removed_client}. Removed profile: {removed_profile}.")
545
+ return 0
546
+
547
+
548
+ def build_parser() -> argparse.ArgumentParser:
549
+ parser = argparse.ArgumentParser(prog="plane-workflow", description="Plane Workflow MCP setup and launcher")
550
+ parser.add_argument("--version", action="version", version=f"%(prog)s {installed_version() or 'unknown'}")
551
+ subcommands = parser.add_subparsers(dest="command")
552
+ setup = subcommands.add_parser("setup", help="configure a client and securely store Plane credentials")
553
+ setup.add_argument("--client", choices=(*CLIENTS, "none"), help="client to register; none stores credentials only (for the Claude Code plugin)")
554
+ setup.add_argument("--profile", default="default")
555
+ setup.add_argument("--base-url")
556
+ setup.add_argument("--workspace")
557
+ setup.add_argument("--config-file", help="override the Claude Desktop, Cursor, OpenCode, or Zed settings path")
558
+ setup.add_argument("--non-interactive", action="store_true", help="read the API key from PLANE_API_KEY")
559
+ setup.add_argument("--dry-run", action="store_true")
560
+ setup.add_argument("--yes", action="store_true", help="skip the client-change confirmation")
561
+ setup.set_defaults(handler=command_setup)
562
+ status = subcommands.add_parser("status", help="show setup status without exposing credentials")
563
+ status.add_argument("--profile")
564
+ status.set_defaults(handler=command_status)
565
+ workspace = subcommands.add_parser("workspace", help="list or switch configured Plane workspaces")
566
+ workspace_commands = workspace.add_subparsers(dest="workspace_command", required=True)
567
+ workspace_commands.add_parser("list", help="list configured workspaces and their selected projects").set_defaults(handler=command_workspace_list)
568
+ workspace_activate = workspace_commands.add_parser("activate", help="switch the active workspace profile")
569
+ workspace_activate.add_argument("profile")
570
+ workspace_activate.set_defaults(handler=command_workspace_activate)
571
+ project = subcommands.add_parser("project", help="list or switch projects in the active workspace")
572
+ project_commands = project.add_subparsers(dest="project_command", required=True)
573
+ project_commands.add_parser("list", help="list projects in the active workspace").set_defaults(handler=command_project_list)
574
+ project_activate = project_commands.add_parser("activate", help="select a project for work-item operations")
575
+ project_activate.add_argument("project_id")
576
+ project_activate.set_defaults(handler=command_project_activate)
577
+ remove = subcommands.add_parser("remove", help="remove a configured client and local credentials")
578
+ remove.add_argument("--client", choices=CLIENTS)
579
+ remove.add_argument("--profile", default="default")
580
+ remove.add_argument("--config-file")
581
+ remove.add_argument("--dry-run", action="store_true")
582
+ remove.add_argument("--yes", action="store_true")
583
+ remove.set_defaults(handler=command_remove)
584
+ subcommands.add_parser("mcp", help="start the stdio MCP server").set_defaults(handler=lambda _: server.mcp.run() or 0)
585
+ return parser
586
+
587
+
588
+ def main(argv: list[str] | None = None) -> int:
589
+ parser = build_parser()
590
+ arguments = parser.parse_args(argv)
591
+ if not getattr(arguments, "command", None):
592
+ parser.print_usage()
593
+ print("First-time setup: plane-workflow setup")
594
+ return 2
595
+ try:
596
+ return int(arguments.handler(arguments))
597
+ except (SetupError, configuration.ConfigurationError, server.PlaneWorkflowError) as error:
598
+ print(f"Error: {error}", file=sys.stderr)
599
+ return 1
600
+
601
+
602
+ if __name__ == "__main__":
603
+ raise SystemExit(main())