@flydocs/cli 0.6.0-alpha.3 → 0.6.0-alpha.30
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.
- package/dist/cli.js +2054 -470
- package/package.json +1 -1
- package/template/.claude/CLAUDE.md +43 -48
- package/template/.claude/agents/implementation-agent.md +1 -1
- package/template/.claude/agents/pm-agent.md +1 -1
- package/template/.claude/commands/activate.md +1 -1
- package/template/.claude/commands/attach.md +1 -1
- package/template/.claude/commands/block.md +2 -2
- package/template/.claude/commands/capture.md +1 -1
- package/template/.claude/commands/close.md +1 -1
- package/template/.claude/commands/flydocs-setup.md +359 -72
- package/template/.claude/commands/flydocs-upgrade.md +26 -27
- package/template/.claude/commands/implement.md +1 -1
- package/template/.claude/commands/knowledge.md +61 -0
- package/template/.claude/commands/new-project.md +1 -1
- package/template/.claude/commands/onboard.md +275 -0
- package/template/.claude/commands/project-update.md +1 -1
- package/template/.claude/commands/refine.md +1 -1
- package/template/.claude/commands/review.md +1 -1
- package/template/.claude/commands/start-session.md +1 -1
- package/template/.claude/commands/status.md +1 -1
- package/template/.claude/commands/validate.md +1 -1
- package/template/.claude/commands/wrap-session.md +1 -1
- package/template/.claude/hooks/auto-approve.py +212 -0
- package/template/.claude/hooks/post-pr-check.py +108 -0
- package/template/.claude/hooks/post-transition-check.py +281 -0
- package/template/.claude/hooks/prompt-submit.py +554 -0
- package/template/.claude/hooks/session-start.py +262 -0
- package/template/.claude/hooks/stop-gate.py +162 -0
- package/template/.claude/settings.json +41 -4
- package/template/.claude/skills/README.md +23 -25
- package/template/.claude/skills/flydocs-workflow/SKILL.md +134 -42
- package/template/.claude/skills/flydocs-workflow/cursor-rule.mdc +9 -8
- package/template/.claude/skills/flydocs-workflow/reference/comment-templates.md +1 -0
- package/template/.claude/skills/flydocs-workflow/reference/golden-rules.md +28 -17
- package/template/.claude/skills/flydocs-workflow/reference/graph-schema.md +116 -0
- package/template/.claude/skills/flydocs-workflow/reference/pr-workflow.md +120 -0
- package/template/.claude/skills/flydocs-workflow/reference/priority-estimates.md +37 -15
- package/template/.claude/skills/flydocs-workflow/reference/service-descriptor-schema.md +260 -0
- package/template/.claude/skills/flydocs-workflow/reference/status-workflow.md +26 -26
- package/template/.claude/skills/flydocs-workflow/scripts/_local/__init__.py +0 -0
- package/template/.claude/skills/{flydocs-local/scripts/flydocs_api.py → flydocs-workflow/scripts/_local/file_store.py} +137 -47
- package/template/.claude/skills/flydocs-workflow/scripts/flydocs_api.py +724 -0
- package/template/{.flydocs → .claude/skills/flydocs-workflow}/scripts/generate_manifest.py +4 -4
- package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_build.py +132 -1
- package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_query.py +18 -5
- package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_session.py +1 -10
- package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_update.py +4 -4
- package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_utils.py +2 -1
- package/template/.claude/skills/flydocs-workflow/scripts/issues.py +738 -0
- package/template/.claude/skills/flydocs-workflow/scripts/projects.py +144 -0
- package/template/.claude/skills/flydocs-workflow/scripts/pull_services.py +128 -0
- package/template/.claude/skills/flydocs-workflow/scripts/push_service.py +132 -0
- package/template/.claude/skills/flydocs-workflow/scripts/session.py +54 -0
- package/template/.claude/skills/flydocs-workflow/scripts/test_enforcement.py +225 -0
- package/template/.claude/skills/flydocs-workflow/scripts/workspace.py +902 -0
- package/template/.claude/skills/flydocs-workflow/session.md +87 -29
- package/template/.claude/skills/flydocs-workflow/stages/activate.md +18 -7
- package/template/.claude/skills/flydocs-workflow/stages/capture.md +10 -5
- package/template/.claude/skills/flydocs-workflow/stages/close.md +4 -3
- package/template/.claude/skills/flydocs-workflow/stages/implement.md +33 -9
- package/template/.claude/skills/flydocs-workflow/stages/refine.md +22 -6
- package/template/.claude/skills/flydocs-workflow/stages/review.md +16 -4
- package/template/.claude/skills/flydocs-workflow/stages/validate.md +3 -1
- package/template/.claude/skills/flydocs-workflow/templates/pr/default.md +33 -0
- package/template/.cursor/agents/implementation-agent.md +1 -1
- package/template/.cursor/agents/pm-agent.md +2 -2
- package/template/.cursor/hooks.json +10 -3
- package/template/.env.example +6 -6
- package/template/.flydocs/config.json +5 -18
- package/template/.flydocs/templates/README.md +13 -14
- package/template/.flydocs/templates/bug.md +17 -153
- package/template/.flydocs/templates/chore.md +10 -98
- package/template/.flydocs/templates/feature.md +12 -158
- package/template/.flydocs/templates/idea.md +11 -111
- package/template/.flydocs/templates/quick-capture.md +4 -8
- package/template/.flydocs/version +1 -1
- package/template/AGENTS.md +44 -32
- package/template/CHANGELOG.md +37 -0
- package/template/flydocs/README.md +1 -3
- package/template/flydocs/context/project.md +6 -3
- package/template/flydocs/design-system/README.md +3 -3
- package/template/flydocs/knowledge/INDEX.md +38 -53
- package/template/flydocs/knowledge/README.md +60 -9
- package/template/flydocs/knowledge/templates/decision.md +47 -0
- package/template/flydocs/knowledge/templates/feature.md +35 -0
- package/template/flydocs/knowledge/templates/note.md +25 -0
- package/template/manifest.json +24 -20
- package/template/.claude/skills/flydocs-cloud/SKILL.md +0 -113
- package/template/.claude/skills/flydocs-cloud/cursor-rule.mdc +0 -50
- package/template/.claude/skills/flydocs-cloud/scripts/assign.py +0 -22
- package/template/.claude/skills/flydocs-cloud/scripts/assign_cycle.py +0 -28
- package/template/.claude/skills/flydocs-cloud/scripts/assign_milestone.py +0 -22
- package/template/.claude/skills/flydocs-cloud/scripts/comment.py +0 -29
- package/template/.claude/skills/flydocs-cloud/scripts/create_issue.py +0 -66
- package/template/.claude/skills/flydocs-cloud/scripts/create_milestone.py +0 -35
- package/template/.claude/skills/flydocs-cloud/scripts/create_project.py +0 -33
- package/template/.claude/skills/flydocs-cloud/scripts/create_team.py +0 -39
- package/template/.claude/skills/flydocs-cloud/scripts/estimate.py +0 -29
- package/template/.claude/skills/flydocs-cloud/scripts/flydocs_api.py +0 -210
- package/template/.claude/skills/flydocs-cloud/scripts/get_issue.py +0 -24
- package/template/.claude/skills/flydocs-cloud/scripts/link.py +0 -28
- package/template/.claude/skills/flydocs-cloud/scripts/list_cycles.py +0 -28
- package/template/.claude/skills/flydocs-cloud/scripts/list_issues.py +0 -44
- package/template/.claude/skills/flydocs-cloud/scripts/list_labels.py +0 -19
- package/template/.claude/skills/flydocs-cloud/scripts/list_milestones.py +0 -28
- package/template/.claude/skills/flydocs-cloud/scripts/list_projects.py +0 -31
- package/template/.claude/skills/flydocs-cloud/scripts/list_providers.py +0 -19
- package/template/.claude/skills/flydocs-cloud/scripts/list_teams.py +0 -19
- package/template/.claude/skills/flydocs-cloud/scripts/priority.py +0 -29
- package/template/.claude/skills/flydocs-cloud/scripts/project_update.py +0 -45
- package/template/.claude/skills/flydocs-cloud/scripts/set_labels.py +0 -68
- package/template/.claude/skills/flydocs-cloud/scripts/set_provider.py +0 -46
- package/template/.claude/skills/flydocs-cloud/scripts/set_team.py +0 -41
- package/template/.claude/skills/flydocs-cloud/scripts/transition.py +0 -26
- package/template/.claude/skills/flydocs-cloud/scripts/update_description.py +0 -36
- package/template/.claude/skills/flydocs-cloud/scripts/update_issue.py +0 -82
- package/template/.claude/skills/flydocs-context-graph/SKILL.md +0 -87
- package/template/.claude/skills/flydocs-context-graph/schema.md +0 -78
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_context.py +0 -338
- package/template/.claude/skills/flydocs-context7/SKILL.md +0 -105
- package/template/.claude/skills/flydocs-context7/cursor-rule.mdc +0 -49
- package/template/.claude/skills/flydocs-context7/scripts/context7.py +0 -293
- package/template/.claude/skills/flydocs-estimates/SKILL.md +0 -384
- package/template/.claude/skills/flydocs-figma/SKILL.md +0 -377
- package/template/.claude/skills/flydocs-figma/references/PROMPTING.md +0 -108
- package/template/.claude/skills/flydocs-figma/references/TROUBLESHOOTING.md +0 -112
- package/template/.claude/skills/flydocs-local/SKILL.md +0 -103
- package/template/.claude/skills/flydocs-local/cursor-rule.mdc +0 -43
- package/template/.claude/skills/flydocs-local/scripts/assign.py +0 -20
- package/template/.claude/skills/flydocs-local/scripts/comment.py +0 -27
- package/template/.claude/skills/flydocs-local/scripts/create_issue.py +0 -44
- package/template/.claude/skills/flydocs-local/scripts/estimate.py +0 -37
- package/template/.claude/skills/flydocs-local/scripts/get_issue.py +0 -20
- package/template/.claude/skills/flydocs-local/scripts/link.py +0 -41
- package/template/.claude/skills/flydocs-local/scripts/list_issues.py +0 -34
- package/template/.claude/skills/flydocs-local/scripts/priority.py +0 -37
- package/template/.claude/skills/flydocs-local/scripts/project_update.py +0 -67
- package/template/.claude/skills/flydocs-local/scripts/status_summary.py +0 -16
- package/template/.claude/skills/flydocs-local/scripts/transition.py +0 -24
- package/template/.claude/skills/flydocs-local/scripts/update_description.py +0 -35
- package/template/.claude/skills/flydocs-local/scripts/update_issue.py +0 -84
- package/template/.flydocs/hooks/auto-approve.py +0 -71
- package/template/.flydocs/hooks/prompt-submit.py +0 -277
- package/template/.flydocs/scripts/skill_manager.py +0 -541
- /package/template/{.flydocs → .claude}/hooks/post-edit.py +0 -0
- /package/template/.claude/skills/{flydocs-estimates/references → flydocs-workflow/reference}/provider-costs.md +0 -0
- /package/template/.claude/skills/flydocs-workflow/templates/{bug.md → issues/bug.md} +0 -0
- /package/template/.claude/skills/flydocs-workflow/templates/{chore.md → issues/chore.md} +0 -0
- /package/template/.claude/skills/flydocs-workflow/templates/{feature.md → issues/feature.md} +0 -0
- /package/template/.claude/skills/flydocs-workflow/templates/{idea.md → issues/idea.md} +0 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
"""FlyDocs Unified API Client — tier-aware routing.
|
|
2
|
+
|
|
3
|
+
Reads tier from .flydocs/config.json and routes operations to either
|
|
4
|
+
the relay REST API (cloud tier) or local filesystem (_local/file_store.py).
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from flydocs_api import get_client, output_json, fail
|
|
8
|
+
client = get_client()
|
|
9
|
+
result = client.create_issue(title="New issue", issue_type="feature")
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Project root discovery
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def find_project_root() -> Path:
|
|
26
|
+
"""Walk up from cwd to find .flydocs/ directory."""
|
|
27
|
+
current = Path.cwd()
|
|
28
|
+
while current != current.parent:
|
|
29
|
+
if (current / ".flydocs").is_dir():
|
|
30
|
+
return current
|
|
31
|
+
current = current.parent
|
|
32
|
+
return Path.cwd()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Relay backend (cloud tier)
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
class RelayBackend:
|
|
40
|
+
"""REST client for the FlyDocs Relay API."""
|
|
41
|
+
|
|
42
|
+
DEFAULT_BASE_URL = "https://app.flydocs.ai/api/relay"
|
|
43
|
+
LOCAL_BASE_URL = "http://localhost:3000/api/relay"
|
|
44
|
+
MAX_RETRIES = 3
|
|
45
|
+
RETRY_DELAY = 2
|
|
46
|
+
|
|
47
|
+
def __init__(self, project_root: Path, config: dict):
|
|
48
|
+
self.project_root = project_root
|
|
49
|
+
self.config = config
|
|
50
|
+
self.log_path = project_root / ".flydocs" / "logs" / "relay-ops.jsonl"
|
|
51
|
+
|
|
52
|
+
self.api_key = self._load_api_key()
|
|
53
|
+
if not self.api_key:
|
|
54
|
+
fail(
|
|
55
|
+
"FLYDOCS_API_KEY not found.\n"
|
|
56
|
+
"Checked: environment, .env.local, .env, .env.development, .env.production\n"
|
|
57
|
+
"To get your API key:\n"
|
|
58
|
+
" 1. Go to app.flydocs.ai → Settings → API Keys\n"
|
|
59
|
+
" 2. Generate a key (starts with fdk_)\n"
|
|
60
|
+
" 3. Add FLYDOCS_API_KEY=fdk_... to .env.local"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
self.workspace_id = config.get("workspaceId")
|
|
64
|
+
if not self.workspace_id:
|
|
65
|
+
fail("workspaceId not found in .flydocs/config.json. Run 'flydocs setup'")
|
|
66
|
+
|
|
67
|
+
self.repo_slug = self._detect_repo_slug()
|
|
68
|
+
self.base_url = self._resolve_base_url()
|
|
69
|
+
|
|
70
|
+
# Workspace and label helpers (for create_issue label resolution)
|
|
71
|
+
self.workspace = config.get("workspace", {})
|
|
72
|
+
self.team_id = config.get("provider", {}).get("teamId")
|
|
73
|
+
|
|
74
|
+
def _load_api_key(self) -> Optional[str]:
|
|
75
|
+
if os.environ.get("FLYDOCS_API_KEY"):
|
|
76
|
+
return os.environ["FLYDOCS_API_KEY"]
|
|
77
|
+
for name in [".env.local", ".env", ".env.development", ".env.production"]:
|
|
78
|
+
env_file = self.project_root / name
|
|
79
|
+
if env_file.exists():
|
|
80
|
+
key = self._parse_env_file(env_file, "FLYDOCS_API_KEY")
|
|
81
|
+
if key:
|
|
82
|
+
return key
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def _parse_env_file(self, path: Path, key: str) -> Optional[str]:
|
|
86
|
+
with open(path, "r") as f:
|
|
87
|
+
for line in f:
|
|
88
|
+
line = line.strip()
|
|
89
|
+
if line.startswith("#") or "=" not in line:
|
|
90
|
+
continue
|
|
91
|
+
k, _, v = line.partition("=")
|
|
92
|
+
if k.strip() == key:
|
|
93
|
+
v = v.strip().strip("\"'")
|
|
94
|
+
return v if v else None
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def _detect_repo_slug(self) -> Optional[str]:
|
|
98
|
+
try:
|
|
99
|
+
url = subprocess.check_output(
|
|
100
|
+
["git", "remote", "get-url", "origin"],
|
|
101
|
+
stderr=subprocess.DEVNULL,
|
|
102
|
+
timeout=5,
|
|
103
|
+
).decode().strip()
|
|
104
|
+
if url.endswith(".git"):
|
|
105
|
+
url = url[:-4]
|
|
106
|
+
if ":" in url and "@" in url:
|
|
107
|
+
return url.split(":")[-1]
|
|
108
|
+
return "/".join(url.split("/")[-2:])
|
|
109
|
+
except Exception:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def _resolve_base_url(self) -> str:
|
|
113
|
+
env_url = os.environ.get("FLYDOCS_RELAY_URL")
|
|
114
|
+
if env_url:
|
|
115
|
+
return env_url.rstrip("/")
|
|
116
|
+
config_url = self.config.get("relay", {}).get("url")
|
|
117
|
+
if config_url:
|
|
118
|
+
return config_url.rstrip("/")
|
|
119
|
+
return self.DEFAULT_BASE_URL
|
|
120
|
+
|
|
121
|
+
def _request(self, method: str, path: str, body: Optional[dict] = None,
|
|
122
|
+
params: Optional[dict] = None) -> dict:
|
|
123
|
+
import urllib.request
|
|
124
|
+
import urllib.error
|
|
125
|
+
import urllib.parse
|
|
126
|
+
|
|
127
|
+
url = f"{self.base_url}{path}"
|
|
128
|
+
if params:
|
|
129
|
+
filtered = {k: v for k, v in params.items() if v is not None and v != ""}
|
|
130
|
+
if filtered:
|
|
131
|
+
url += "?" + urllib.parse.urlencode(filtered, doseq=True)
|
|
132
|
+
|
|
133
|
+
headers = {
|
|
134
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
135
|
+
"X-Workspace": self.workspace_id,
|
|
136
|
+
"Content-Type": "application/json",
|
|
137
|
+
"Accept": "application/json",
|
|
138
|
+
}
|
|
139
|
+
if self.repo_slug:
|
|
140
|
+
headers["X-Repo"] = self.repo_slug
|
|
141
|
+
|
|
142
|
+
data = json.dumps(body).encode("utf-8") if body else None
|
|
143
|
+
|
|
144
|
+
for attempt in range(self.MAX_RETRIES):
|
|
145
|
+
try:
|
|
146
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
147
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
148
|
+
response_body = resp.read().decode("utf-8")
|
|
149
|
+
result = json.loads(response_body) if response_body else {}
|
|
150
|
+
self._log_operation(method, path, resp.status, result)
|
|
151
|
+
return result
|
|
152
|
+
except urllib.error.HTTPError as e:
|
|
153
|
+
error_body = e.read().decode("utf-8") if e.fp else ""
|
|
154
|
+
try:
|
|
155
|
+
error_data = json.loads(error_body) if error_body else {}
|
|
156
|
+
except json.JSONDecodeError:
|
|
157
|
+
error_data = {"error": error_body}
|
|
158
|
+
self._log_operation(method, path, e.code, error_data)
|
|
159
|
+
|
|
160
|
+
if e.code == 429 and attempt < self.MAX_RETRIES - 1:
|
|
161
|
+
delay = self.RETRY_DELAY * (2 ** attempt)
|
|
162
|
+
print(f"Rate limited, retrying in {delay}s...", file=sys.stderr)
|
|
163
|
+
time.sleep(delay)
|
|
164
|
+
continue
|
|
165
|
+
if e.code >= 500 and attempt < self.MAX_RETRIES - 1:
|
|
166
|
+
delay = self.RETRY_DELAY * (2 ** attempt)
|
|
167
|
+
print(f"Server error ({e.code}), retrying in {delay}s...", file=sys.stderr)
|
|
168
|
+
time.sleep(delay)
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
error_msg = error_data.get("error", f"HTTP {e.code}")
|
|
172
|
+
error_code = error_data.get("code", "UNKNOWN")
|
|
173
|
+
provider = error_data.get("provider_error", "")
|
|
174
|
+
msg = f"Relay API error ({error_code}): {error_msg}"
|
|
175
|
+
if provider:
|
|
176
|
+
msg += f" — provider: {provider}"
|
|
177
|
+
fail(msg)
|
|
178
|
+
except (urllib.error.URLError, TimeoutError):
|
|
179
|
+
if attempt < self.MAX_RETRIES - 1:
|
|
180
|
+
delay = self.RETRY_DELAY * (2 ** attempt)
|
|
181
|
+
print(f"Network error, retrying in {delay}s...", file=sys.stderr)
|
|
182
|
+
time.sleep(delay)
|
|
183
|
+
continue
|
|
184
|
+
fail("Network error: unable to reach relay API")
|
|
185
|
+
|
|
186
|
+
fail("Max retries exceeded")
|
|
187
|
+
return {} # unreachable
|
|
188
|
+
|
|
189
|
+
def get(self, path: str, params: Optional[dict] = None) -> dict | list:
|
|
190
|
+
return self._request("GET", path, params=params)
|
|
191
|
+
|
|
192
|
+
def post(self, path: str, body: Optional[dict] = None) -> dict:
|
|
193
|
+
return self._request("POST", path, body=body)
|
|
194
|
+
|
|
195
|
+
def put(self, path: str, body: Optional[dict] = None) -> dict:
|
|
196
|
+
return self._request("PUT", path, body=body)
|
|
197
|
+
|
|
198
|
+
def patch(self, path: str, body: Optional[dict] = None) -> dict:
|
|
199
|
+
return self._request("PATCH", path, body=body)
|
|
200
|
+
|
|
201
|
+
def delete(self, path: str) -> dict:
|
|
202
|
+
return self._request("DELETE", path)
|
|
203
|
+
|
|
204
|
+
def _log_operation(self, method: str, path: str, status: int, result: dict | list) -> None:
|
|
205
|
+
try:
|
|
206
|
+
from datetime import datetime, timezone
|
|
207
|
+
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
entry = {
|
|
209
|
+
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
210
|
+
"method": method,
|
|
211
|
+
"path": path,
|
|
212
|
+
"status": status,
|
|
213
|
+
"success": 200 <= status < 300,
|
|
214
|
+
}
|
|
215
|
+
with open(self.log_path, "a") as f:
|
|
216
|
+
f.write(json.dumps(entry) + "\n")
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
def get_category_label_id(self, issue_type: str) -> Optional[str]:
|
|
221
|
+
labels = self.config.get("issueLabels", {}).get("category", {})
|
|
222
|
+
return labels.get(issue_type)
|
|
223
|
+
|
|
224
|
+
def get_other_label_id(self, label_name: str) -> Optional[str]:
|
|
225
|
+
labels = self.config.get("issueLabels", {}).get("other", {})
|
|
226
|
+
return labels.get(label_name)
|
|
227
|
+
|
|
228
|
+
def resolve_user_id(self, name_or_id: str) -> tuple[Optional[str], Optional[str]]:
|
|
229
|
+
"""Resolve a user name or ID. Returns (id, displayName)."""
|
|
230
|
+
# If it looks like a UUID, return as-is
|
|
231
|
+
if len(name_or_id) == 36 and "-" in name_or_id:
|
|
232
|
+
return name_or_id, None
|
|
233
|
+
# Try identity file
|
|
234
|
+
me_path = self.project_root / ".flydocs" / "me.json"
|
|
235
|
+
if me_path.exists():
|
|
236
|
+
try:
|
|
237
|
+
me = json.loads(me_path.read_text())
|
|
238
|
+
if me.get("displayName", "").lower() == name_or_id.lower():
|
|
239
|
+
return me.get("id"), me.get("displayName")
|
|
240
|
+
except (json.JSONDecodeError, OSError):
|
|
241
|
+
pass
|
|
242
|
+
return name_or_id, None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
# Local backend
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
class LocalBackend:
|
|
250
|
+
"""Filesystem-based backend using _local/file_store.py."""
|
|
251
|
+
|
|
252
|
+
def __init__(self, project_root: Path, config: dict):
|
|
253
|
+
self.project_root = project_root
|
|
254
|
+
self.config = config
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# Unified client
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
class FlyDocsClient:
|
|
262
|
+
"""Tier-aware client. Routes to relay (cloud) or filesystem (local)."""
|
|
263
|
+
|
|
264
|
+
def __init__(self):
|
|
265
|
+
self.project_root = find_project_root()
|
|
266
|
+
self.config_path = self.project_root / ".flydocs" / "config.json"
|
|
267
|
+
self.config = self._load_config()
|
|
268
|
+
self.tier = self.config.get("tier", "local")
|
|
269
|
+
|
|
270
|
+
if self.tier == "cloud":
|
|
271
|
+
self._relay = RelayBackend(self.project_root, self.config)
|
|
272
|
+
else:
|
|
273
|
+
self._relay = None
|
|
274
|
+
self._local_backend = LocalBackend(self.project_root, self.config)
|
|
275
|
+
|
|
276
|
+
def _load_config(self) -> dict:
|
|
277
|
+
if self.config_path.exists():
|
|
278
|
+
with open(self.config_path, "r") as f:
|
|
279
|
+
return json.load(f)
|
|
280
|
+
return {}
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def is_cloud(self) -> bool:
|
|
284
|
+
return self.tier == "cloud"
|
|
285
|
+
|
|
286
|
+
def require_cloud(self, operation: str) -> None:
|
|
287
|
+
"""Fail with clear message if operation requires cloud tier."""
|
|
288
|
+
if not self.is_cloud:
|
|
289
|
+
fail(f"{operation} requires cloud tier. Current tier: {self.tier}")
|
|
290
|
+
|
|
291
|
+
# --- Relay passthrough (for cloud scripts that need raw HTTP) ---
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def relay(self) -> RelayBackend:
|
|
295
|
+
if self._relay is None:
|
|
296
|
+
fail("Relay not available on local tier")
|
|
297
|
+
return self._relay
|
|
298
|
+
|
|
299
|
+
# --- Issue operations (tier-aware) ---
|
|
300
|
+
|
|
301
|
+
def create_issue(self, **kwargs: object) -> dict:
|
|
302
|
+
if self.is_cloud:
|
|
303
|
+
return self._cloud_create_issue(**kwargs)
|
|
304
|
+
from _local.file_store import create_issue
|
|
305
|
+
return create_issue(self.project_root, **kwargs)
|
|
306
|
+
|
|
307
|
+
def _cloud_create_issue(self, **kwargs: object) -> dict:
|
|
308
|
+
relay = self.relay
|
|
309
|
+
auto_resolved: dict[str, str] = {}
|
|
310
|
+
issue_input: dict = {
|
|
311
|
+
"teamId": relay.team_id,
|
|
312
|
+
"title": kwargs.get("title", ""),
|
|
313
|
+
"description": kwargs.get("description", ""),
|
|
314
|
+
"priority": kwargs.get("priority", 3),
|
|
315
|
+
}
|
|
316
|
+
estimate = kwargs.get("estimate")
|
|
317
|
+
if estimate:
|
|
318
|
+
issue_input["estimate"] = estimate
|
|
319
|
+
|
|
320
|
+
# Labels — category + triage + repo (auto-resolved)
|
|
321
|
+
label_ids: list[str] = []
|
|
322
|
+
issue_type = kwargs.get("issue_type", "")
|
|
323
|
+
if isinstance(issue_type, str):
|
|
324
|
+
cat_id = relay.get_category_label_id(issue_type)
|
|
325
|
+
if cat_id:
|
|
326
|
+
label_ids.append(cat_id)
|
|
327
|
+
auto_resolved["categoryLabel"] = issue_type
|
|
328
|
+
if kwargs.get("triage"):
|
|
329
|
+
triage_id = relay.get_other_label_id("triage")
|
|
330
|
+
if triage_id:
|
|
331
|
+
label_ids.append(triage_id)
|
|
332
|
+
|
|
333
|
+
# Repo label for multi-repo workspaces
|
|
334
|
+
repo_labels = relay.config.get("issueLabels", {}).get("repo", {})
|
|
335
|
+
topology = relay.config.get("topology", {})
|
|
336
|
+
topo_type = topology.get("type", 1)
|
|
337
|
+
if topo_type in (3, 4) and repo_labels:
|
|
338
|
+
# Detect current repo name from slug
|
|
339
|
+
slug = relay.repo_slug or ""
|
|
340
|
+
repo_name = slug.split("/")[-1] if "/" in slug else slug
|
|
341
|
+
for name, label_id in repo_labels.items():
|
|
342
|
+
if label_id and name.lower() in repo_name.lower():
|
|
343
|
+
if label_id not in label_ids:
|
|
344
|
+
label_ids.append(label_id)
|
|
345
|
+
auto_resolved["repoLabel"] = name
|
|
346
|
+
break
|
|
347
|
+
|
|
348
|
+
if label_ids:
|
|
349
|
+
issue_input["labelIds"] = label_ids
|
|
350
|
+
|
|
351
|
+
# Project
|
|
352
|
+
project_id = kwargs.get("project")
|
|
353
|
+
if not project_id:
|
|
354
|
+
active = relay.workspace.get("activeProjects", [])
|
|
355
|
+
if active:
|
|
356
|
+
project_id = active[0]
|
|
357
|
+
auto_resolved["project"] = "activeProjects"
|
|
358
|
+
if project_id:
|
|
359
|
+
issue_input["projectId"] = project_id
|
|
360
|
+
|
|
361
|
+
# Milestone
|
|
362
|
+
milestone_id = kwargs.get("milestone")
|
|
363
|
+
if not milestone_id:
|
|
364
|
+
milestone_id = relay.workspace.get("defaultMilestoneId")
|
|
365
|
+
if milestone_id:
|
|
366
|
+
auto_resolved["milestone"] = "defaultMilestoneId"
|
|
367
|
+
if milestone_id:
|
|
368
|
+
issue_input["projectMilestoneId"] = milestone_id
|
|
369
|
+
|
|
370
|
+
# Assignee
|
|
371
|
+
assignee = kwargs.get("assignee")
|
|
372
|
+
if assignee and isinstance(assignee, str):
|
|
373
|
+
user_id, _ = relay.resolve_user_id(assignee)
|
|
374
|
+
if user_id:
|
|
375
|
+
issue_input["assigneeId"] = user_id
|
|
376
|
+
|
|
377
|
+
result = relay.post("/issues", issue_input)
|
|
378
|
+
issue = result.get("issue", result)
|
|
379
|
+
response: dict = {
|
|
380
|
+
"id": issue.get("id", ""),
|
|
381
|
+
"identifier": issue.get("identifier", ""),
|
|
382
|
+
"title": issue.get("title", kwargs.get("title", "")),
|
|
383
|
+
"url": issue.get("url", ""),
|
|
384
|
+
}
|
|
385
|
+
if auto_resolved:
|
|
386
|
+
response["autoResolved"] = auto_resolved
|
|
387
|
+
return response
|
|
388
|
+
|
|
389
|
+
def transition(self, ref: str, status: str, comment: str) -> dict:
|
|
390
|
+
if self.is_cloud:
|
|
391
|
+
result = self.relay.post(f"/issues/{ref}/transition", {
|
|
392
|
+
"status": status.upper(),
|
|
393
|
+
"comment": comment,
|
|
394
|
+
})
|
|
395
|
+
return {
|
|
396
|
+
"success": result.get("success", True),
|
|
397
|
+
"issue": result.get("issue", ref),
|
|
398
|
+
"previousStatus": result.get("previousStatus", ""),
|
|
399
|
+
"newStatus": result.get("newStatus", status),
|
|
400
|
+
}
|
|
401
|
+
from _local.file_store import transition
|
|
402
|
+
return transition(self.project_root, ref, status, comment)
|
|
403
|
+
|
|
404
|
+
def comment(self, ref: str, body: str) -> dict:
|
|
405
|
+
if self.is_cloud:
|
|
406
|
+
result = self.relay.post(f"/issues/{ref}/comment", {"body": body})
|
|
407
|
+
return {
|
|
408
|
+
"success": result.get("success", True),
|
|
409
|
+
"commentId": result.get("commentId", ""),
|
|
410
|
+
}
|
|
411
|
+
from _local.file_store import add_comment
|
|
412
|
+
return add_comment(self.project_root, ref, body)
|
|
413
|
+
|
|
414
|
+
def list_issues(self, **kwargs: object) -> list[dict]:
|
|
415
|
+
if self.is_cloud:
|
|
416
|
+
params: dict = {}
|
|
417
|
+
for key in ("status", "assignee", "project", "milestone", "limit"):
|
|
418
|
+
val = kwargs.get(key)
|
|
419
|
+
if val is not None and val != "":
|
|
420
|
+
params[key] = str(val).upper() if key == "status" else str(val)
|
|
421
|
+
if kwargs.get("active"):
|
|
422
|
+
params["active"] = "true"
|
|
423
|
+
if kwargs.get("mine"):
|
|
424
|
+
params["mine"] = "true"
|
|
425
|
+
# Product scope cascade (bypassed by explicit --project or --all)
|
|
426
|
+
if "project" not in params and not kwargs.get("show_all"):
|
|
427
|
+
active_projects = self.relay.workspace.get("activeProjects", [])
|
|
428
|
+
if active_projects:
|
|
429
|
+
params["project"] = active_projects[0]
|
|
430
|
+
else:
|
|
431
|
+
product_labels = self.relay.workspace.get("product", {}).get("labelIds", [])
|
|
432
|
+
if product_labels:
|
|
433
|
+
params["label"] = product_labels[0]
|
|
434
|
+
result = self.relay.get("/issues", params=params)
|
|
435
|
+
return result if isinstance(result, list) else []
|
|
436
|
+
from _local.file_store import list_issues
|
|
437
|
+
return list_issues(
|
|
438
|
+
self.project_root,
|
|
439
|
+
status=str(kwargs.get("status", "")),
|
|
440
|
+
assignee=str(kwargs.get("assignee", "")),
|
|
441
|
+
limit=int(kwargs.get("limit", 50)),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def get_issue(self, ref: str, **kwargs: object) -> dict:
|
|
445
|
+
if self.is_cloud:
|
|
446
|
+
params: dict = {}
|
|
447
|
+
fields = kwargs.get("fields")
|
|
448
|
+
if fields:
|
|
449
|
+
params["fields"] = str(fields)
|
|
450
|
+
return self.relay.get(f"/issues/{ref}", params=params)
|
|
451
|
+
from _local.file_store import get_issue
|
|
452
|
+
return get_issue(self.project_root, ref)
|
|
453
|
+
|
|
454
|
+
def assign(self, ref: str, assignee: str | None) -> dict:
|
|
455
|
+
if self.is_cloud:
|
|
456
|
+
result = self.relay.post(f"/issues/{ref}/assign", {"assignee": assignee})
|
|
457
|
+
return {
|
|
458
|
+
"success": result.get("success", True),
|
|
459
|
+
"issue": result.get("issue", ref),
|
|
460
|
+
"assignee": result.get("assignee", assignee),
|
|
461
|
+
}
|
|
462
|
+
from _local.file_store import assign_issue
|
|
463
|
+
return assign_issue(self.project_root, ref, assignee)
|
|
464
|
+
|
|
465
|
+
def update_description(self, ref: str, text: str) -> dict:
|
|
466
|
+
if self.is_cloud:
|
|
467
|
+
result = self.relay.put(f"/issues/{ref}/description", {"text": text})
|
|
468
|
+
return {
|
|
469
|
+
"success": result.get("success", True),
|
|
470
|
+
"issue": result.get("issue", ref),
|
|
471
|
+
}
|
|
472
|
+
from _local.file_store import update_description
|
|
473
|
+
return update_description(self.project_root, ref, text)
|
|
474
|
+
|
|
475
|
+
def update_issue(self, ref: str, **fields: object) -> dict:
|
|
476
|
+
if self.is_cloud:
|
|
477
|
+
body: dict = {}
|
|
478
|
+
updated: list[str] = []
|
|
479
|
+
for key in ("title", "priority", "estimate", "assignee", "description", "comment"):
|
|
480
|
+
val = fields.get(key)
|
|
481
|
+
if val is not None:
|
|
482
|
+
body[key] = val
|
|
483
|
+
updated.append(key)
|
|
484
|
+
state = fields.get("state")
|
|
485
|
+
if state and isinstance(state, str):
|
|
486
|
+
body["state"] = state.upper()
|
|
487
|
+
updated.append("state")
|
|
488
|
+
labels = fields.get("labels")
|
|
489
|
+
if labels and isinstance(labels, str):
|
|
490
|
+
body["labels"] = [l.strip() for l in labels.split(",") if l.strip()]
|
|
491
|
+
updated.append("labels")
|
|
492
|
+
# Milestone resolution
|
|
493
|
+
milestone = fields.get("milestone")
|
|
494
|
+
if milestone and isinstance(milestone, str):
|
|
495
|
+
milestone_id = milestone
|
|
496
|
+
if len(milestone_id) != 36 or "-" not in milestone_id:
|
|
497
|
+
milestones = self.relay.get("/milestones")
|
|
498
|
+
match = next((m for m in milestones if m["name"].lower() == milestone_id.lower()), None)
|
|
499
|
+
if not match:
|
|
500
|
+
fail(f"Milestone not found: {milestone_id}")
|
|
501
|
+
milestone_id = match["id"]
|
|
502
|
+
body["milestoneId"] = milestone_id
|
|
503
|
+
updated.append("milestone")
|
|
504
|
+
if not body:
|
|
505
|
+
fail("No fields to update")
|
|
506
|
+
result = self.relay.patch(f"/issues/{ref}", body)
|
|
507
|
+
return {
|
|
508
|
+
"success": result.get("success", True),
|
|
509
|
+
"issue": result.get("issue", ref),
|
|
510
|
+
"updated": updated,
|
|
511
|
+
}
|
|
512
|
+
from _local.file_store import update_issue
|
|
513
|
+
return update_issue(self.project_root, ref, **fields)
|
|
514
|
+
|
|
515
|
+
def estimate(self, ref: str, points: int) -> dict:
|
|
516
|
+
if self.is_cloud:
|
|
517
|
+
result = self.relay.put(f"/issues/{ref}/estimate", {"estimate": points})
|
|
518
|
+
return {
|
|
519
|
+
"success": result.get("success", True),
|
|
520
|
+
"issue": result.get("issue", ref),
|
|
521
|
+
"estimate": result.get("estimate", points),
|
|
522
|
+
}
|
|
523
|
+
from _local.file_store import estimate_issue
|
|
524
|
+
return estimate_issue(self.project_root, ref, points)
|
|
525
|
+
|
|
526
|
+
def priority(self, ref: str, level: int) -> dict:
|
|
527
|
+
if self.is_cloud:
|
|
528
|
+
result = self.relay.put(f"/issues/{ref}/priority", {"priority": level})
|
|
529
|
+
return {
|
|
530
|
+
"success": result.get("success", True),
|
|
531
|
+
"issue": result.get("issue", ref),
|
|
532
|
+
"priority": result.get("priority", level),
|
|
533
|
+
}
|
|
534
|
+
from _local.file_store import priority_issue
|
|
535
|
+
return priority_issue(self.project_root, ref, level)
|
|
536
|
+
|
|
537
|
+
def link(self, ref: str, related_ref: str, link_type: str) -> dict:
|
|
538
|
+
if self.is_cloud:
|
|
539
|
+
result = self.relay.post(f"/issues/{ref}/link", {
|
|
540
|
+
"relatedRef": related_ref,
|
|
541
|
+
"type": link_type,
|
|
542
|
+
})
|
|
543
|
+
return {
|
|
544
|
+
"success": result.get("success", True),
|
|
545
|
+
"type": result.get("type", link_type),
|
|
546
|
+
}
|
|
547
|
+
from _local.file_store import link_issues
|
|
548
|
+
return link_issues(self.project_root, ref, related_ref, link_type)
|
|
549
|
+
|
|
550
|
+
def assign_milestone(self, ref: str, milestone_id: str) -> dict:
|
|
551
|
+
self.require_cloud("assign_milestone")
|
|
552
|
+
result = self.relay.put(f"/issues/{ref}/milestone", {"milestoneId": milestone_id})
|
|
553
|
+
return {
|
|
554
|
+
"success": result.get("success", True),
|
|
555
|
+
"issue": result.get("issue", ref),
|
|
556
|
+
"milestone": result.get("milestone", milestone_id),
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
def assign_cycle(self, ref: str, cycle_id: str | None = None) -> dict:
|
|
560
|
+
self.require_cloud("assign_cycle")
|
|
561
|
+
body: dict = {"cycleId": cycle_id} if cycle_id else {}
|
|
562
|
+
result = self.relay.put(f"/issues/{ref}/cycle", body)
|
|
563
|
+
return {
|
|
564
|
+
"success": result.get("success", True),
|
|
565
|
+
"issue": result.get("issue", ref),
|
|
566
|
+
"cycle": result.get("cycle", cycle_id),
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
# --- Project operations (cloud only) ---
|
|
570
|
+
|
|
571
|
+
def list_projects(self, **kwargs: object) -> list[dict]:
|
|
572
|
+
self.require_cloud("list_projects")
|
|
573
|
+
params: dict = {}
|
|
574
|
+
if kwargs.get("active"):
|
|
575
|
+
params["active"] = "true"
|
|
576
|
+
if kwargs.get("show_all"):
|
|
577
|
+
params["all"] = "true"
|
|
578
|
+
result = self.relay.get("/projects", params=params)
|
|
579
|
+
return result if isinstance(result, list) else []
|
|
580
|
+
|
|
581
|
+
def create_project(self, name: str, description: str | None = None) -> dict:
|
|
582
|
+
self.require_cloud("create_project")
|
|
583
|
+
body: dict = {"name": name}
|
|
584
|
+
if description:
|
|
585
|
+
body["description"] = description
|
|
586
|
+
result = self.relay.post("/projects", body)
|
|
587
|
+
return {"id": result["id"], "name": result["name"], "url": result.get("url", "")}
|
|
588
|
+
|
|
589
|
+
def list_milestones(self, **kwargs: object) -> list[dict]:
|
|
590
|
+
self.require_cloud("list_milestones")
|
|
591
|
+
params: dict = {}
|
|
592
|
+
if kwargs.get("show_all"):
|
|
593
|
+
params["all"] = "true"
|
|
594
|
+
result = self.relay.get("/milestones", params=params)
|
|
595
|
+
return result if isinstance(result, list) else []
|
|
596
|
+
|
|
597
|
+
def create_milestone(self, name: str, project: str | None = None,
|
|
598
|
+
target_date: str | None = None) -> dict:
|
|
599
|
+
self.require_cloud("create_milestone")
|
|
600
|
+
body: dict = {"name": name}
|
|
601
|
+
if project:
|
|
602
|
+
body["projectId"] = project
|
|
603
|
+
if target_date:
|
|
604
|
+
body["targetDate"] = target_date
|
|
605
|
+
return self.relay.post("/milestones", body)
|
|
606
|
+
|
|
607
|
+
def update_milestone(self, milestone_id: str, **fields: object) -> dict:
|
|
608
|
+
self.require_cloud("update_milestone")
|
|
609
|
+
body: dict = {}
|
|
610
|
+
for key in ("name", "targetDate", "description"):
|
|
611
|
+
val = fields.get(key)
|
|
612
|
+
if val is not None:
|
|
613
|
+
body[key] = val
|
|
614
|
+
result = self.relay.patch(f"/milestones/{milestone_id}", body)
|
|
615
|
+
return {"success": result.get("success", True), "id": milestone_id, "name": result.get("name", "")}
|
|
616
|
+
|
|
617
|
+
def delete_milestone(self, milestone_id: str) -> dict:
|
|
618
|
+
self.require_cloud("delete_milestone")
|
|
619
|
+
self.relay.delete(f"/milestones/{milestone_id}")
|
|
620
|
+
return {"success": True, "id": milestone_id}
|
|
621
|
+
|
|
622
|
+
def list_cycles(self, active: bool = False) -> list[dict]:
|
|
623
|
+
self.require_cloud("list_cycles")
|
|
624
|
+
params: dict = {}
|
|
625
|
+
if active:
|
|
626
|
+
params["active"] = "true"
|
|
627
|
+
result = self.relay.get("/cycles", params=params)
|
|
628
|
+
return result if isinstance(result, list) else []
|
|
629
|
+
|
|
630
|
+
def project_update(self, health: str, body: str, project_id: str | None = None, **_kwargs: object) -> dict:
|
|
631
|
+
if self.is_cloud:
|
|
632
|
+
payload: dict = {"health": health, "body": body}
|
|
633
|
+
if project_id:
|
|
634
|
+
payload["projectId"] = project_id
|
|
635
|
+
result = self.relay.post("/projects/update", payload)
|
|
636
|
+
return {"success": result.get("success", True), "id": result.get("id", "")}
|
|
637
|
+
from _local.file_store import project_update
|
|
638
|
+
return project_update(self.project_root, health, body)
|
|
639
|
+
|
|
640
|
+
def status_summary(self) -> dict:
|
|
641
|
+
if self.is_cloud:
|
|
642
|
+
# Cloud could use relay, but local summary is always available
|
|
643
|
+
pass
|
|
644
|
+
from _local.file_store import status_summary
|
|
645
|
+
return status_summary(self.project_root)
|
|
646
|
+
|
|
647
|
+
# --- Workspace operations (cloud only) ---
|
|
648
|
+
|
|
649
|
+
def validate_setup(self) -> dict:
|
|
650
|
+
self.require_cloud("validate_setup")
|
|
651
|
+
return self.relay.get("/auth/config")
|
|
652
|
+
|
|
653
|
+
def list_labels(self) -> list[dict]:
|
|
654
|
+
self.require_cloud("list_labels")
|
|
655
|
+
result = self.relay.get("/labels")
|
|
656
|
+
return result if isinstance(result, list) else []
|
|
657
|
+
|
|
658
|
+
def list_statuses(self) -> list[dict]:
|
|
659
|
+
self.require_cloud("list_statuses")
|
|
660
|
+
result = self.relay.get("/auth/statuses")
|
|
661
|
+
return result if isinstance(result, list) else []
|
|
662
|
+
|
|
663
|
+
def list_providers(self) -> list[dict]:
|
|
664
|
+
self.require_cloud("list_providers")
|
|
665
|
+
result = self.relay.get("/providers")
|
|
666
|
+
return result if isinstance(result, list) else []
|
|
667
|
+
|
|
668
|
+
def list_teams(self) -> list[dict]:
|
|
669
|
+
self.require_cloud("list_teams")
|
|
670
|
+
result = self.relay.get("/teams")
|
|
671
|
+
return result if isinstance(result, list) else []
|
|
672
|
+
|
|
673
|
+
def create_team(self, name: str, key: str | None = None,
|
|
674
|
+
description: str | None = None, parent: str | None = None) -> dict:
|
|
675
|
+
self.require_cloud("create_team")
|
|
676
|
+
body: dict = {"name": name}
|
|
677
|
+
if key:
|
|
678
|
+
body["key"] = key
|
|
679
|
+
if description:
|
|
680
|
+
body["description"] = description
|
|
681
|
+
if parent:
|
|
682
|
+
body["parentId"] = parent
|
|
683
|
+
result = self.relay.post("/teams", body)
|
|
684
|
+
return {"id": result["id"], "name": result["name"], "key": result.get("key", "")}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
# ---------------------------------------------------------------------------
|
|
688
|
+
# Module-level helpers
|
|
689
|
+
# ---------------------------------------------------------------------------
|
|
690
|
+
|
|
691
|
+
_client: Optional[FlyDocsClient] = None
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def get_client() -> FlyDocsClient:
|
|
695
|
+
"""Get or create singleton client."""
|
|
696
|
+
global _client
|
|
697
|
+
if _client is None:
|
|
698
|
+
_client = FlyDocsClient()
|
|
699
|
+
return _client
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def output_json(data: dict | list) -> None:
|
|
703
|
+
"""Print JSON to stdout — standard contract output."""
|
|
704
|
+
print(json.dumps(data))
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def fail(message: str) -> None:
|
|
708
|
+
"""Print error to stderr and exit 1."""
|
|
709
|
+
print(message, file=sys.stderr)
|
|
710
|
+
sys.exit(1)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def resolve_text_input(text_arg: str | None = None, file_arg: str | None = None) -> str | None:
|
|
714
|
+
"""Resolve text from --file > stdin > --text. Shared helper for dispatchers."""
|
|
715
|
+
if file_arg:
|
|
716
|
+
path = Path(file_arg)
|
|
717
|
+
if not path.exists():
|
|
718
|
+
fail(f"File not found: {file_arg}")
|
|
719
|
+
return path.read_text()
|
|
720
|
+
if text_arg is not None:
|
|
721
|
+
return text_arg
|
|
722
|
+
if not sys.stdin.isatty():
|
|
723
|
+
return sys.stdin.read().strip()
|
|
724
|
+
return None
|