@flydocs/cli 0.6.0-alpha.13 → 0.6.0-alpha.20

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.
Files changed (152) hide show
  1. package/dist/cli.js +281 -256
  2. package/package.json +1 -1
  3. package/template/.claude/CLAUDE.md +62 -66
  4. package/template/.claude/agents/implementation-agent.md +1 -1
  5. package/template/.claude/agents/pm-agent.md +1 -1
  6. package/template/.claude/commands/activate.md +1 -1
  7. package/template/.claude/commands/attach.md +1 -1
  8. package/template/.claude/commands/block.md +2 -2
  9. package/template/.claude/commands/capture.md +1 -1
  10. package/template/.claude/commands/close.md +1 -1
  11. package/template/.claude/commands/flydocs-setup.md +261 -58
  12. package/template/.claude/commands/flydocs-upgrade.md +26 -27
  13. package/template/.claude/commands/implement.md +1 -1
  14. package/template/.claude/commands/new-project.md +1 -1
  15. package/template/.claude/commands/onboard.md +275 -0
  16. package/template/.claude/commands/project-update.md +1 -1
  17. package/template/.claude/commands/refine.md +1 -1
  18. package/template/.claude/commands/review.md +1 -1
  19. package/template/.claude/commands/start-session.md +1 -1
  20. package/template/.claude/commands/status.md +1 -1
  21. package/template/.claude/commands/validate.md +1 -1
  22. package/template/.claude/commands/wrap-session.md +1 -1
  23. package/template/.claude/hooks/auto-approve.py +132 -0
  24. package/template/.claude/hooks/post-pr-check.py +108 -0
  25. package/template/.claude/hooks/post-transition-check.py +94 -0
  26. package/template/{.flydocs → .claude}/hooks/prompt-submit.py +167 -17
  27. package/template/.claude/hooks/session-start.py +146 -0
  28. package/template/.claude/hooks/stop-gate.py +109 -0
  29. package/template/.claude/settings.json +41 -4
  30. package/template/.claude/skills/README.md +23 -25
  31. package/template/.claude/skills/flydocs-workflow/SKILL.md +121 -34
  32. package/template/.claude/skills/flydocs-workflow/cursor-rule.mdc +9 -8
  33. package/template/.claude/skills/flydocs-workflow/reference/golden-rules.md +28 -17
  34. package/template/.claude/skills/flydocs-workflow/reference/graph-schema.md +116 -0
  35. package/template/.claude/skills/flydocs-workflow/reference/pr-workflow.md +30 -15
  36. package/template/.claude/skills/flydocs-workflow/reference/priority-estimates.md +1 -1
  37. package/template/.claude/skills/flydocs-workflow/reference/service-descriptor-schema.md +251 -0
  38. package/template/.claude/skills/flydocs-workflow/reference/status-workflow.md +26 -26
  39. package/template/.claude/skills/flydocs-workflow/scripts/_local/__init__.py +0 -0
  40. package/template/.claude/skills/{flydocs-local/scripts/flydocs_api.py → flydocs-workflow/scripts/_local/file_store.py} +133 -46
  41. package/template/.claude/skills/flydocs-workflow/scripts/flydocs_api.py +693 -0
  42. package/template/{.flydocs → .claude/skills/flydocs-workflow}/scripts/generate_manifest.py +4 -4
  43. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_build.py +132 -1
  44. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_query.py +18 -5
  45. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_session.py +1 -1
  46. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_update.py +4 -4
  47. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_utils.py +2 -1
  48. package/template/.claude/skills/flydocs-workflow/scripts/issues.py +489 -0
  49. package/template/.claude/skills/flydocs-workflow/scripts/projects.py +144 -0
  50. package/template/.claude/skills/flydocs-workflow/scripts/pull_services.py +128 -0
  51. package/template/.claude/skills/flydocs-workflow/scripts/push_service.py +132 -0
  52. package/template/.claude/skills/flydocs-workflow/scripts/session.py +54 -0
  53. package/template/.claude/skills/flydocs-workflow/scripts/workspace.py +860 -0
  54. package/template/.claude/skills/flydocs-workflow/session.md +16 -11
  55. package/template/.claude/skills/flydocs-workflow/stages/activate.md +13 -8
  56. package/template/.claude/skills/flydocs-workflow/stages/capture.md +4 -4
  57. package/template/.claude/skills/flydocs-workflow/stages/close.md +1 -1
  58. package/template/.claude/skills/flydocs-workflow/stages/implement.md +7 -7
  59. package/template/.claude/skills/flydocs-workflow/stages/refine.md +5 -5
  60. package/template/.claude/skills/flydocs-workflow/stages/review.md +2 -2
  61. package/template/.claude/skills/flydocs-workflow/stages/validate.md +3 -1
  62. package/template/.claude/skills/flydocs-workflow/templates/pr/default.md +33 -0
  63. package/template/.cursor/agents/implementation-agent.md +1 -1
  64. package/template/.cursor/agents/pm-agent.md +2 -2
  65. package/template/.cursor/hooks.json +10 -3
  66. package/template/.env.example +6 -6
  67. package/template/.flydocs/config.json +2 -1
  68. package/template/.flydocs/templates/README.md +13 -14
  69. package/template/.flydocs/templates/quick-capture.md +4 -8
  70. package/template/.flydocs/version +1 -1
  71. package/template/AGENTS.md +39 -32
  72. package/template/flydocs/README.md +1 -3
  73. package/template/flydocs/context/project.md +6 -3
  74. package/template/flydocs/design-system/README.md +3 -3
  75. package/template/manifest.json +17 -19
  76. package/template/.claude/skills/flydocs-cloud/SKILL.md +0 -138
  77. package/template/.claude/skills/flydocs-cloud/cursor-rule.mdc +0 -50
  78. package/template/.claude/skills/flydocs-cloud/scripts/assign.py +0 -28
  79. package/template/.claude/skills/flydocs-cloud/scripts/assign_cycle.py +0 -28
  80. package/template/.claude/skills/flydocs-cloud/scripts/assign_milestone.py +0 -22
  81. package/template/.claude/skills/flydocs-cloud/scripts/comment.py +0 -29
  82. package/template/.claude/skills/flydocs-cloud/scripts/create_issue.py +0 -83
  83. package/template/.claude/skills/flydocs-cloud/scripts/create_milestone.py +0 -35
  84. package/template/.claude/skills/flydocs-cloud/scripts/create_project.py +0 -33
  85. package/template/.claude/skills/flydocs-cloud/scripts/create_team.py +0 -39
  86. package/template/.claude/skills/flydocs-cloud/scripts/delete_milestone.py +0 -21
  87. package/template/.claude/skills/flydocs-cloud/scripts/estimate.py +0 -33
  88. package/template/.claude/skills/flydocs-cloud/scripts/flydocs_api.py +0 -241
  89. package/template/.claude/skills/flydocs-cloud/scripts/generate_config.py +0 -125
  90. package/template/.claude/skills/flydocs-cloud/scripts/get_estimate_scale.py +0 -23
  91. package/template/.claude/skills/flydocs-cloud/scripts/get_issue.py +0 -24
  92. package/template/.claude/skills/flydocs-cloud/scripts/get_me.py +0 -103
  93. package/template/.claude/skills/flydocs-cloud/scripts/link.py +0 -28
  94. package/template/.claude/skills/flydocs-cloud/scripts/list_cycles.py +0 -28
  95. package/template/.claude/skills/flydocs-cloud/scripts/list_issues.py +0 -44
  96. package/template/.claude/skills/flydocs-cloud/scripts/list_labels.py +0 -19
  97. package/template/.claude/skills/flydocs-cloud/scripts/list_milestones.py +0 -28
  98. package/template/.claude/skills/flydocs-cloud/scripts/list_projects.py +0 -31
  99. package/template/.claude/skills/flydocs-cloud/scripts/list_providers.py +0 -19
  100. package/template/.claude/skills/flydocs-cloud/scripts/list_statuses.py +0 -19
  101. package/template/.claude/skills/flydocs-cloud/scripts/list_teams.py +0 -19
  102. package/template/.claude/skills/flydocs-cloud/scripts/priority.py +0 -29
  103. package/template/.claude/skills/flydocs-cloud/scripts/project_update.py +0 -45
  104. package/template/.claude/skills/flydocs-cloud/scripts/refresh_labels.py +0 -87
  105. package/template/.claude/skills/flydocs-cloud/scripts/set_identity.py +0 -54
  106. package/template/.claude/skills/flydocs-cloud/scripts/set_labels.py +0 -54
  107. package/template/.claude/skills/flydocs-cloud/scripts/set_preferences.py +0 -49
  108. package/template/.claude/skills/flydocs-cloud/scripts/set_provider.py +0 -31
  109. package/template/.claude/skills/flydocs-cloud/scripts/set_status_mapping.py +0 -57
  110. package/template/.claude/skills/flydocs-cloud/scripts/set_team.py +0 -28
  111. package/template/.claude/skills/flydocs-cloud/scripts/transition.py +0 -26
  112. package/template/.claude/skills/flydocs-cloud/scripts/update_description.py +0 -36
  113. package/template/.claude/skills/flydocs-cloud/scripts/update_issue.py +0 -100
  114. package/template/.claude/skills/flydocs-cloud/scripts/update_milestone.py +0 -42
  115. package/template/.claude/skills/flydocs-cloud/scripts/validate_setup.py +0 -120
  116. package/template/.claude/skills/flydocs-context-graph/SKILL.md +0 -94
  117. package/template/.claude/skills/flydocs-context-graph/schema.md +0 -78
  118. package/template/.claude/skills/flydocs-context-graph/scripts/graph_context.py +0 -338
  119. package/template/.claude/skills/flydocs-context7/SKILL.md +0 -105
  120. package/template/.claude/skills/flydocs-context7/cursor-rule.mdc +0 -49
  121. package/template/.claude/skills/flydocs-context7/scripts/context7.py +0 -293
  122. package/template/.claude/skills/flydocs-estimates/SKILL.md +0 -384
  123. package/template/.claude/skills/flydocs-figma/SKILL.md +0 -377
  124. package/template/.claude/skills/flydocs-figma/references/PROMPTING.md +0 -108
  125. package/template/.claude/skills/flydocs-figma/references/TROUBLESHOOTING.md +0 -112
  126. package/template/.claude/skills/flydocs-local/SKILL.md +0 -103
  127. package/template/.claude/skills/flydocs-local/cursor-rule.mdc +0 -43
  128. package/template/.claude/skills/flydocs-local/scripts/assign.py +0 -29
  129. package/template/.claude/skills/flydocs-local/scripts/comment.py +0 -27
  130. package/template/.claude/skills/flydocs-local/scripts/create_issue.py +0 -44
  131. package/template/.claude/skills/flydocs-local/scripts/estimate.py +0 -37
  132. package/template/.claude/skills/flydocs-local/scripts/get_issue.py +0 -20
  133. package/template/.claude/skills/flydocs-local/scripts/link.py +0 -41
  134. package/template/.claude/skills/flydocs-local/scripts/list_issues.py +0 -50
  135. package/template/.claude/skills/flydocs-local/scripts/priority.py +0 -37
  136. package/template/.claude/skills/flydocs-local/scripts/project_update.py +0 -67
  137. package/template/.claude/skills/flydocs-local/scripts/status_summary.py +0 -16
  138. package/template/.claude/skills/flydocs-local/scripts/transition.py +0 -24
  139. package/template/.claude/skills/flydocs-local/scripts/update_description.py +0 -35
  140. package/template/.claude/skills/flydocs-local/scripts/update_issue.py +0 -84
  141. package/template/.flydocs/hooks/auto-approve.py +0 -71
  142. package/template/.flydocs/scripts/skill_manager.py +0 -541
  143. package/template/.flydocs/templates/bug.md +0 -166
  144. package/template/.flydocs/templates/chore.md +0 -110
  145. package/template/.flydocs/templates/feature.md +0 -173
  146. package/template/.flydocs/templates/idea.md +0 -122
  147. /package/template/{.flydocs → .claude}/hooks/post-edit.py +0 -0
  148. /package/template/.claude/skills/{flydocs-estimates/references → flydocs-workflow/reference}/provider-costs.md +0 -0
  149. /package/template/.claude/skills/flydocs-workflow/templates/{bug.md → issues/bug.md} +0 -0
  150. /package/template/.claude/skills/flydocs-workflow/templates/{chore.md → issues/chore.md} +0 -0
  151. /package/template/.claude/skills/flydocs-workflow/templates/{feature.md → issues/feature.md} +0 -0
  152. /package/template/.claude/skills/flydocs-workflow/templates/{idea.md → issues/idea.md} +0 -0
@@ -0,0 +1,693 @@
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
+ issue_input: dict = {
310
+ "teamId": relay.team_id,
311
+ "title": kwargs.get("title", ""),
312
+ "description": kwargs.get("description", ""),
313
+ "priority": kwargs.get("priority", 3),
314
+ }
315
+ estimate = kwargs.get("estimate")
316
+ if estimate:
317
+ issue_input["estimate"] = estimate
318
+
319
+ # Labels
320
+ label_ids: list[str] = []
321
+ issue_type = kwargs.get("issue_type", "")
322
+ if isinstance(issue_type, str):
323
+ cat_id = relay.get_category_label_id(issue_type)
324
+ if cat_id:
325
+ label_ids.append(cat_id)
326
+ if kwargs.get("triage"):
327
+ triage_id = relay.get_other_label_id("triage")
328
+ if triage_id:
329
+ label_ids.append(triage_id)
330
+ if label_ids:
331
+ issue_input["labelIds"] = label_ids
332
+
333
+ # Project
334
+ project_id = kwargs.get("project")
335
+ if not project_id:
336
+ active = relay.workspace.get("activeProjects", [])
337
+ if active:
338
+ project_id = active[0]
339
+ if project_id:
340
+ issue_input["projectId"] = project_id
341
+
342
+ # Assignee
343
+ assignee = kwargs.get("assignee")
344
+ if assignee and isinstance(assignee, str):
345
+ user_id, _ = relay.resolve_user_id(assignee)
346
+ if user_id:
347
+ issue_input["assigneeId"] = user_id
348
+
349
+ result = relay.post("/issues", issue_input)
350
+ issue = result.get("issue", result)
351
+ return {
352
+ "id": issue.get("id", ""),
353
+ "identifier": issue.get("identifier", ""),
354
+ "title": issue.get("title", kwargs.get("title", "")),
355
+ "url": issue.get("url", ""),
356
+ }
357
+
358
+ def transition(self, ref: str, status: str, comment: str) -> dict:
359
+ if self.is_cloud:
360
+ result = self.relay.post(f"/issues/{ref}/transition", {
361
+ "status": status.upper(),
362
+ "comment": comment,
363
+ })
364
+ return {
365
+ "success": result.get("success", True),
366
+ "issue": result.get("issue", ref),
367
+ "previousStatus": result.get("previousStatus", ""),
368
+ "newStatus": result.get("newStatus", status),
369
+ }
370
+ from _local.file_store import transition
371
+ return transition(self.project_root, ref, status, comment)
372
+
373
+ def comment(self, ref: str, body: str) -> dict:
374
+ if self.is_cloud:
375
+ result = self.relay.post(f"/issues/{ref}/comment", {"body": body})
376
+ return {
377
+ "success": result.get("success", True),
378
+ "commentId": result.get("commentId", ""),
379
+ }
380
+ from _local.file_store import add_comment
381
+ return add_comment(self.project_root, ref, body)
382
+
383
+ def list_issues(self, **kwargs: object) -> list[dict]:
384
+ if self.is_cloud:
385
+ params: dict = {}
386
+ for key in ("status", "assignee", "project", "milestone", "limit"):
387
+ val = kwargs.get(key)
388
+ if val is not None and val != "":
389
+ params[key] = str(val).upper() if key == "status" else str(val)
390
+ if kwargs.get("active"):
391
+ params["active"] = "true"
392
+ if kwargs.get("mine"):
393
+ params["mine"] = "true"
394
+ # Product scope cascade (bypassed by explicit --project or --all)
395
+ if "project" not in params and not kwargs.get("show_all"):
396
+ active_projects = self.relay.workspace.get("activeProjects", [])
397
+ if active_projects:
398
+ params["project"] = active_projects[0]
399
+ else:
400
+ product_labels = self.relay.workspace.get("product", {}).get("labelIds", [])
401
+ if product_labels:
402
+ params["label"] = product_labels[0]
403
+ result = self.relay.get("/issues", params=params)
404
+ return result if isinstance(result, list) else []
405
+ from _local.file_store import list_issues
406
+ return list_issues(
407
+ self.project_root,
408
+ status=str(kwargs.get("status", "")),
409
+ assignee=str(kwargs.get("assignee", "")),
410
+ limit=int(kwargs.get("limit", 50)),
411
+ )
412
+
413
+ def get_issue(self, ref: str, **kwargs: object) -> dict:
414
+ if self.is_cloud:
415
+ params: dict = {}
416
+ fields = kwargs.get("fields")
417
+ if fields:
418
+ params["fields"] = str(fields)
419
+ return self.relay.get(f"/issues/{ref}", params=params)
420
+ from _local.file_store import get_issue
421
+ return get_issue(self.project_root, ref)
422
+
423
+ def assign(self, ref: str, assignee: str | None) -> dict:
424
+ if self.is_cloud:
425
+ result = self.relay.post(f"/issues/{ref}/assign", {"assignee": assignee})
426
+ return {
427
+ "success": result.get("success", True),
428
+ "issue": result.get("issue", ref),
429
+ "assignee": result.get("assignee", assignee),
430
+ }
431
+ from _local.file_store import assign_issue
432
+ return assign_issue(self.project_root, ref, assignee)
433
+
434
+ def update_description(self, ref: str, text: str) -> dict:
435
+ if self.is_cloud:
436
+ result = self.relay.put(f"/issues/{ref}/description", {"text": text})
437
+ return {
438
+ "success": result.get("success", True),
439
+ "issue": result.get("issue", ref),
440
+ }
441
+ from _local.file_store import update_description
442
+ return update_description(self.project_root, ref, text)
443
+
444
+ def update_issue(self, ref: str, **fields: object) -> dict:
445
+ if self.is_cloud:
446
+ body: dict = {}
447
+ updated: list[str] = []
448
+ for key in ("title", "priority", "estimate", "assignee", "description", "comment"):
449
+ val = fields.get(key)
450
+ if val is not None:
451
+ body[key] = val
452
+ updated.append(key)
453
+ state = fields.get("state")
454
+ if state and isinstance(state, str):
455
+ body["state"] = state.upper()
456
+ updated.append("state")
457
+ labels = fields.get("labels")
458
+ if labels and isinstance(labels, str):
459
+ body["labels"] = [l.strip() for l in labels.split(",") if l.strip()]
460
+ updated.append("labels")
461
+ # Milestone resolution
462
+ milestone = fields.get("milestone")
463
+ if milestone and isinstance(milestone, str):
464
+ milestone_id = milestone
465
+ if len(milestone_id) != 36 or "-" not in milestone_id:
466
+ milestones = self.relay.get("/milestones")
467
+ match = next((m for m in milestones if m["name"].lower() == milestone_id.lower()), None)
468
+ if not match:
469
+ fail(f"Milestone not found: {milestone_id}")
470
+ milestone_id = match["id"]
471
+ body["milestoneId"] = milestone_id
472
+ updated.append("milestone")
473
+ if not body:
474
+ fail("No fields to update")
475
+ result = self.relay.patch(f"/issues/{ref}", body)
476
+ return {
477
+ "success": result.get("success", True),
478
+ "issue": result.get("issue", ref),
479
+ "updated": updated,
480
+ }
481
+ from _local.file_store import update_issue
482
+ return update_issue(self.project_root, ref, **fields)
483
+
484
+ def estimate(self, ref: str, points: int) -> dict:
485
+ if self.is_cloud:
486
+ result = self.relay.put(f"/issues/{ref}/estimate", {"estimate": points})
487
+ return {
488
+ "success": result.get("success", True),
489
+ "issue": result.get("issue", ref),
490
+ "estimate": result.get("estimate", points),
491
+ }
492
+ from _local.file_store import estimate_issue
493
+ return estimate_issue(self.project_root, ref, points)
494
+
495
+ def priority(self, ref: str, level: int) -> dict:
496
+ if self.is_cloud:
497
+ result = self.relay.put(f"/issues/{ref}/priority", {"priority": level})
498
+ return {
499
+ "success": result.get("success", True),
500
+ "issue": result.get("issue", ref),
501
+ "priority": result.get("priority", level),
502
+ }
503
+ from _local.file_store import priority_issue
504
+ return priority_issue(self.project_root, ref, level)
505
+
506
+ def link(self, ref: str, related_ref: str, link_type: str) -> dict:
507
+ if self.is_cloud:
508
+ result = self.relay.post(f"/issues/{ref}/link", {
509
+ "relatedRef": related_ref,
510
+ "type": link_type,
511
+ })
512
+ return {
513
+ "success": result.get("success", True),
514
+ "type": result.get("type", link_type),
515
+ }
516
+ from _local.file_store import link_issues
517
+ return link_issues(self.project_root, ref, related_ref, link_type)
518
+
519
+ def assign_milestone(self, ref: str, milestone_id: str) -> dict:
520
+ self.require_cloud("assign_milestone")
521
+ result = self.relay.put(f"/issues/{ref}/milestone", {"milestoneId": milestone_id})
522
+ return {
523
+ "success": result.get("success", True),
524
+ "issue": result.get("issue", ref),
525
+ "milestone": result.get("milestone", milestone_id),
526
+ }
527
+
528
+ def assign_cycle(self, ref: str, cycle_id: str | None = None) -> dict:
529
+ self.require_cloud("assign_cycle")
530
+ body: dict = {"cycleId": cycle_id} if cycle_id else {}
531
+ result = self.relay.put(f"/issues/{ref}/cycle", body)
532
+ return {
533
+ "success": result.get("success", True),
534
+ "issue": result.get("issue", ref),
535
+ "cycle": result.get("cycle", cycle_id),
536
+ }
537
+
538
+ # --- Project operations (cloud only) ---
539
+
540
+ def list_projects(self, **kwargs: object) -> list[dict]:
541
+ self.require_cloud("list_projects")
542
+ params: dict = {}
543
+ if kwargs.get("active"):
544
+ params["active"] = "true"
545
+ if kwargs.get("show_all"):
546
+ params["all"] = "true"
547
+ result = self.relay.get("/projects", params=params)
548
+ return result if isinstance(result, list) else []
549
+
550
+ def create_project(self, name: str, description: str | None = None) -> dict:
551
+ self.require_cloud("create_project")
552
+ body: dict = {"name": name}
553
+ if description:
554
+ body["description"] = description
555
+ result = self.relay.post("/projects", body)
556
+ return {"id": result["id"], "name": result["name"], "url": result.get("url", "")}
557
+
558
+ def list_milestones(self, **kwargs: object) -> list[dict]:
559
+ self.require_cloud("list_milestones")
560
+ params: dict = {}
561
+ if kwargs.get("show_all"):
562
+ params["all"] = "true"
563
+ result = self.relay.get("/milestones", params=params)
564
+ return result if isinstance(result, list) else []
565
+
566
+ def create_milestone(self, name: str, project: str | None = None,
567
+ target_date: str | None = None) -> dict:
568
+ self.require_cloud("create_milestone")
569
+ body: dict = {"name": name}
570
+ if project:
571
+ body["projectId"] = project
572
+ if target_date:
573
+ body["targetDate"] = target_date
574
+ return self.relay.post("/milestones", body)
575
+
576
+ def update_milestone(self, milestone_id: str, **fields: object) -> dict:
577
+ self.require_cloud("update_milestone")
578
+ body: dict = {}
579
+ for key in ("name", "targetDate", "description"):
580
+ val = fields.get(key)
581
+ if val is not None:
582
+ body[key] = val
583
+ result = self.relay.patch(f"/milestones/{milestone_id}", body)
584
+ return {"success": result.get("success", True), "id": milestone_id, "name": result.get("name", "")}
585
+
586
+ def delete_milestone(self, milestone_id: str) -> dict:
587
+ self.require_cloud("delete_milestone")
588
+ self.relay.delete(f"/milestones/{milestone_id}")
589
+ return {"success": True, "id": milestone_id}
590
+
591
+ def list_cycles(self, active: bool = False) -> list[dict]:
592
+ self.require_cloud("list_cycles")
593
+ params: dict = {}
594
+ if active:
595
+ params["active"] = "true"
596
+ result = self.relay.get("/cycles", params=params)
597
+ return result if isinstance(result, list) else []
598
+
599
+ def project_update(self, health: str, body: str, project_id: str | None = None, **_kwargs: object) -> dict:
600
+ if self.is_cloud:
601
+ payload: dict = {"health": health, "body": body}
602
+ if project_id:
603
+ payload["projectId"] = project_id
604
+ result = self.relay.post("/projects/update", payload)
605
+ return {"success": result.get("success", True), "id": result.get("id", "")}
606
+ from _local.file_store import project_update
607
+ return project_update(self.project_root, health, body)
608
+
609
+ def status_summary(self) -> dict:
610
+ if self.is_cloud:
611
+ # Cloud could use relay, but local summary is always available
612
+ pass
613
+ from _local.file_store import status_summary
614
+ return status_summary(self.project_root)
615
+
616
+ # --- Workspace operations (cloud only) ---
617
+
618
+ def validate_setup(self) -> dict:
619
+ self.require_cloud("validate_setup")
620
+ return self.relay.get("/auth/config")
621
+
622
+ def list_labels(self) -> list[dict]:
623
+ self.require_cloud("list_labels")
624
+ result = self.relay.get("/labels")
625
+ return result if isinstance(result, list) else []
626
+
627
+ def list_statuses(self) -> list[dict]:
628
+ self.require_cloud("list_statuses")
629
+ result = self.relay.get("/auth/statuses")
630
+ return result if isinstance(result, list) else []
631
+
632
+ def list_providers(self) -> list[dict]:
633
+ self.require_cloud("list_providers")
634
+ result = self.relay.get("/providers")
635
+ return result if isinstance(result, list) else []
636
+
637
+ def list_teams(self) -> list[dict]:
638
+ self.require_cloud("list_teams")
639
+ result = self.relay.get("/teams")
640
+ return result if isinstance(result, list) else []
641
+
642
+ def create_team(self, name: str, key: str | None = None,
643
+ description: str | None = None, parent: str | None = None) -> dict:
644
+ self.require_cloud("create_team")
645
+ body: dict = {"name": name}
646
+ if key:
647
+ body["key"] = key
648
+ if description:
649
+ body["description"] = description
650
+ if parent:
651
+ body["parentId"] = parent
652
+ result = self.relay.post("/teams", body)
653
+ return {"id": result["id"], "name": result["name"], "key": result.get("key", "")}
654
+
655
+
656
+ # ---------------------------------------------------------------------------
657
+ # Module-level helpers
658
+ # ---------------------------------------------------------------------------
659
+
660
+ _client: Optional[FlyDocsClient] = None
661
+
662
+
663
+ def get_client() -> FlyDocsClient:
664
+ """Get or create singleton client."""
665
+ global _client
666
+ if _client is None:
667
+ _client = FlyDocsClient()
668
+ return _client
669
+
670
+
671
+ def output_json(data: dict | list) -> None:
672
+ """Print JSON to stdout — standard contract output."""
673
+ print(json.dumps(data))
674
+
675
+
676
+ def fail(message: str) -> None:
677
+ """Print error to stderr and exit 1."""
678
+ print(message, file=sys.stderr)
679
+ sys.exit(1)
680
+
681
+
682
+ def resolve_text_input(text_arg: str | None = None, file_arg: str | None = None) -> str | None:
683
+ """Resolve text from --file > stdin > --text. Shared helper for dispatchers."""
684
+ if file_arg:
685
+ path = Path(file_arg)
686
+ if not path.exists():
687
+ fail(f"File not found: {file_arg}")
688
+ return path.read_text()
689
+ if text_arg is not None:
690
+ return text_arg
691
+ if not sys.stdin.isatty():
692
+ return sys.stdin.read().strip()
693
+ return None