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.
- plane_workflow_mcp/__init__.py +1 -0
- plane_workflow_mcp/__main__.py +5 -0
- plane_workflow_mcp/cli.py +603 -0
- plane_workflow_mcp/configuration.py +273 -0
- plane_workflow_mcp/profiles.json +54 -0
- plane_workflow_mcp/reports.py +447 -0
- plane_workflow_mcp/server.py +3598 -0
- plane_workflow_mcp-0.6.0.dist-info/METADATA +90 -0
- plane_workflow_mcp-0.6.0.dist-info/RECORD +13 -0
- plane_workflow_mcp-0.6.0.dist-info/WHEEL +5 -0
- plane_workflow_mcp-0.6.0.dist-info/entry_points.txt +2 -0
- plane_workflow_mcp-0.6.0.dist-info/licenses/LICENSE +21 -0
- plane_workflow_mcp-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Package data for the portable Plane Workflow MCP distribution."""
|
|
@@ -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())
|