@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.
Files changed (151) hide show
  1. package/dist/cli.js +2054 -470
  2. package/package.json +1 -1
  3. package/template/.claude/CLAUDE.md +43 -48
  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 +359 -72
  12. package/template/.claude/commands/flydocs-upgrade.md +26 -27
  13. package/template/.claude/commands/implement.md +1 -1
  14. package/template/.claude/commands/knowledge.md +61 -0
  15. package/template/.claude/commands/new-project.md +1 -1
  16. package/template/.claude/commands/onboard.md +275 -0
  17. package/template/.claude/commands/project-update.md +1 -1
  18. package/template/.claude/commands/refine.md +1 -1
  19. package/template/.claude/commands/review.md +1 -1
  20. package/template/.claude/commands/start-session.md +1 -1
  21. package/template/.claude/commands/status.md +1 -1
  22. package/template/.claude/commands/validate.md +1 -1
  23. package/template/.claude/commands/wrap-session.md +1 -1
  24. package/template/.claude/hooks/auto-approve.py +212 -0
  25. package/template/.claude/hooks/post-pr-check.py +108 -0
  26. package/template/.claude/hooks/post-transition-check.py +281 -0
  27. package/template/.claude/hooks/prompt-submit.py +554 -0
  28. package/template/.claude/hooks/session-start.py +262 -0
  29. package/template/.claude/hooks/stop-gate.py +162 -0
  30. package/template/.claude/settings.json +41 -4
  31. package/template/.claude/skills/README.md +23 -25
  32. package/template/.claude/skills/flydocs-workflow/SKILL.md +134 -42
  33. package/template/.claude/skills/flydocs-workflow/cursor-rule.mdc +9 -8
  34. package/template/.claude/skills/flydocs-workflow/reference/comment-templates.md +1 -0
  35. package/template/.claude/skills/flydocs-workflow/reference/golden-rules.md +28 -17
  36. package/template/.claude/skills/flydocs-workflow/reference/graph-schema.md +116 -0
  37. package/template/.claude/skills/flydocs-workflow/reference/pr-workflow.md +120 -0
  38. package/template/.claude/skills/flydocs-workflow/reference/priority-estimates.md +37 -15
  39. package/template/.claude/skills/flydocs-workflow/reference/service-descriptor-schema.md +260 -0
  40. package/template/.claude/skills/flydocs-workflow/reference/status-workflow.md +26 -26
  41. package/template/.claude/skills/flydocs-workflow/scripts/_local/__init__.py +0 -0
  42. package/template/.claude/skills/{flydocs-local/scripts/flydocs_api.py → flydocs-workflow/scripts/_local/file_store.py} +137 -47
  43. package/template/.claude/skills/flydocs-workflow/scripts/flydocs_api.py +724 -0
  44. package/template/{.flydocs → .claude/skills/flydocs-workflow}/scripts/generate_manifest.py +4 -4
  45. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_build.py +132 -1
  46. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_query.py +18 -5
  47. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_session.py +1 -10
  48. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_update.py +4 -4
  49. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_utils.py +2 -1
  50. package/template/.claude/skills/flydocs-workflow/scripts/issues.py +738 -0
  51. package/template/.claude/skills/flydocs-workflow/scripts/projects.py +144 -0
  52. package/template/.claude/skills/flydocs-workflow/scripts/pull_services.py +128 -0
  53. package/template/.claude/skills/flydocs-workflow/scripts/push_service.py +132 -0
  54. package/template/.claude/skills/flydocs-workflow/scripts/session.py +54 -0
  55. package/template/.claude/skills/flydocs-workflow/scripts/test_enforcement.py +225 -0
  56. package/template/.claude/skills/flydocs-workflow/scripts/workspace.py +902 -0
  57. package/template/.claude/skills/flydocs-workflow/session.md +87 -29
  58. package/template/.claude/skills/flydocs-workflow/stages/activate.md +18 -7
  59. package/template/.claude/skills/flydocs-workflow/stages/capture.md +10 -5
  60. package/template/.claude/skills/flydocs-workflow/stages/close.md +4 -3
  61. package/template/.claude/skills/flydocs-workflow/stages/implement.md +33 -9
  62. package/template/.claude/skills/flydocs-workflow/stages/refine.md +22 -6
  63. package/template/.claude/skills/flydocs-workflow/stages/review.md +16 -4
  64. package/template/.claude/skills/flydocs-workflow/stages/validate.md +3 -1
  65. package/template/.claude/skills/flydocs-workflow/templates/pr/default.md +33 -0
  66. package/template/.cursor/agents/implementation-agent.md +1 -1
  67. package/template/.cursor/agents/pm-agent.md +2 -2
  68. package/template/.cursor/hooks.json +10 -3
  69. package/template/.env.example +6 -6
  70. package/template/.flydocs/config.json +5 -18
  71. package/template/.flydocs/templates/README.md +13 -14
  72. package/template/.flydocs/templates/bug.md +17 -153
  73. package/template/.flydocs/templates/chore.md +10 -98
  74. package/template/.flydocs/templates/feature.md +12 -158
  75. package/template/.flydocs/templates/idea.md +11 -111
  76. package/template/.flydocs/templates/quick-capture.md +4 -8
  77. package/template/.flydocs/version +1 -1
  78. package/template/AGENTS.md +44 -32
  79. package/template/CHANGELOG.md +37 -0
  80. package/template/flydocs/README.md +1 -3
  81. package/template/flydocs/context/project.md +6 -3
  82. package/template/flydocs/design-system/README.md +3 -3
  83. package/template/flydocs/knowledge/INDEX.md +38 -53
  84. package/template/flydocs/knowledge/README.md +60 -9
  85. package/template/flydocs/knowledge/templates/decision.md +47 -0
  86. package/template/flydocs/knowledge/templates/feature.md +35 -0
  87. package/template/flydocs/knowledge/templates/note.md +25 -0
  88. package/template/manifest.json +24 -20
  89. package/template/.claude/skills/flydocs-cloud/SKILL.md +0 -113
  90. package/template/.claude/skills/flydocs-cloud/cursor-rule.mdc +0 -50
  91. package/template/.claude/skills/flydocs-cloud/scripts/assign.py +0 -22
  92. package/template/.claude/skills/flydocs-cloud/scripts/assign_cycle.py +0 -28
  93. package/template/.claude/skills/flydocs-cloud/scripts/assign_milestone.py +0 -22
  94. package/template/.claude/skills/flydocs-cloud/scripts/comment.py +0 -29
  95. package/template/.claude/skills/flydocs-cloud/scripts/create_issue.py +0 -66
  96. package/template/.claude/skills/flydocs-cloud/scripts/create_milestone.py +0 -35
  97. package/template/.claude/skills/flydocs-cloud/scripts/create_project.py +0 -33
  98. package/template/.claude/skills/flydocs-cloud/scripts/create_team.py +0 -39
  99. package/template/.claude/skills/flydocs-cloud/scripts/estimate.py +0 -29
  100. package/template/.claude/skills/flydocs-cloud/scripts/flydocs_api.py +0 -210
  101. package/template/.claude/skills/flydocs-cloud/scripts/get_issue.py +0 -24
  102. package/template/.claude/skills/flydocs-cloud/scripts/link.py +0 -28
  103. package/template/.claude/skills/flydocs-cloud/scripts/list_cycles.py +0 -28
  104. package/template/.claude/skills/flydocs-cloud/scripts/list_issues.py +0 -44
  105. package/template/.claude/skills/flydocs-cloud/scripts/list_labels.py +0 -19
  106. package/template/.claude/skills/flydocs-cloud/scripts/list_milestones.py +0 -28
  107. package/template/.claude/skills/flydocs-cloud/scripts/list_projects.py +0 -31
  108. package/template/.claude/skills/flydocs-cloud/scripts/list_providers.py +0 -19
  109. package/template/.claude/skills/flydocs-cloud/scripts/list_teams.py +0 -19
  110. package/template/.claude/skills/flydocs-cloud/scripts/priority.py +0 -29
  111. package/template/.claude/skills/flydocs-cloud/scripts/project_update.py +0 -45
  112. package/template/.claude/skills/flydocs-cloud/scripts/set_labels.py +0 -68
  113. package/template/.claude/skills/flydocs-cloud/scripts/set_provider.py +0 -46
  114. package/template/.claude/skills/flydocs-cloud/scripts/set_team.py +0 -41
  115. package/template/.claude/skills/flydocs-cloud/scripts/transition.py +0 -26
  116. package/template/.claude/skills/flydocs-cloud/scripts/update_description.py +0 -36
  117. package/template/.claude/skills/flydocs-cloud/scripts/update_issue.py +0 -82
  118. package/template/.claude/skills/flydocs-context-graph/SKILL.md +0 -87
  119. package/template/.claude/skills/flydocs-context-graph/schema.md +0 -78
  120. package/template/.claude/skills/flydocs-context-graph/scripts/graph_context.py +0 -338
  121. package/template/.claude/skills/flydocs-context7/SKILL.md +0 -105
  122. package/template/.claude/skills/flydocs-context7/cursor-rule.mdc +0 -49
  123. package/template/.claude/skills/flydocs-context7/scripts/context7.py +0 -293
  124. package/template/.claude/skills/flydocs-estimates/SKILL.md +0 -384
  125. package/template/.claude/skills/flydocs-figma/SKILL.md +0 -377
  126. package/template/.claude/skills/flydocs-figma/references/PROMPTING.md +0 -108
  127. package/template/.claude/skills/flydocs-figma/references/TROUBLESHOOTING.md +0 -112
  128. package/template/.claude/skills/flydocs-local/SKILL.md +0 -103
  129. package/template/.claude/skills/flydocs-local/cursor-rule.mdc +0 -43
  130. package/template/.claude/skills/flydocs-local/scripts/assign.py +0 -20
  131. package/template/.claude/skills/flydocs-local/scripts/comment.py +0 -27
  132. package/template/.claude/skills/flydocs-local/scripts/create_issue.py +0 -44
  133. package/template/.claude/skills/flydocs-local/scripts/estimate.py +0 -37
  134. package/template/.claude/skills/flydocs-local/scripts/get_issue.py +0 -20
  135. package/template/.claude/skills/flydocs-local/scripts/link.py +0 -41
  136. package/template/.claude/skills/flydocs-local/scripts/list_issues.py +0 -34
  137. package/template/.claude/skills/flydocs-local/scripts/priority.py +0 -37
  138. package/template/.claude/skills/flydocs-local/scripts/project_update.py +0 -67
  139. package/template/.claude/skills/flydocs-local/scripts/status_summary.py +0 -16
  140. package/template/.claude/skills/flydocs-local/scripts/transition.py +0 -24
  141. package/template/.claude/skills/flydocs-local/scripts/update_description.py +0 -35
  142. package/template/.claude/skills/flydocs-local/scripts/update_issue.py +0 -84
  143. package/template/.flydocs/hooks/auto-approve.py +0 -71
  144. package/template/.flydocs/hooks/prompt-submit.py +0 -277
  145. package/template/.flydocs/scripts/skill_manager.py +0 -541
  146. /package/template/{.flydocs → .claude}/hooks/post-edit.py +0 -0
  147. /package/template/.claude/skills/{flydocs-estimates/references → flydocs-workflow/reference}/provider-costs.md +0 -0
  148. /package/template/.claude/skills/flydocs-workflow/templates/{bug.md → issues/bug.md} +0 -0
  149. /package/template/.claude/skills/flydocs-workflow/templates/{chore.md → issues/chore.md} +0 -0
  150. /package/template/.claude/skills/flydocs-workflow/templates/{feature.md → issues/feature.md} +0 -0
  151. /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