@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,902 @@
1
+ #!/usr/bin/env python3
2
+ """Workspace setup, validation, and configuration dispatcher.
3
+
4
+ All subcommands are cloud-only. Routes through the unified client's relay
5
+ backend for provider operations.
6
+
7
+ Usage:
8
+ python workspace.py validate
9
+ python workspace.py list-labels
10
+ python workspace.py refresh-labels [--fix]
11
+ python workspace.py list-statuses
12
+ python workspace.py list-providers
13
+ python workspace.py set-provider linear
14
+ python workspace.py list-teams
15
+ python workspace.py create-team --name NAME [--key KEY] [--description DESC] [--parent ID]
16
+ python workspace.py set-team TEAM_ID
17
+ python workspace.py set-labels --defaults '["app"]' --type-map '{"feature":["Feature"]}'
18
+ python workspace.py set-status-mapping --auto
19
+ python workspace.py set-status-mapping --mapping '{"BACKLOG":"Backlog",...}'
20
+ python workspace.py set-identity linear USER_ID
21
+ python workspace.py set-preferences [--workspace ID] [--assignee SELF] [--display JSON]
22
+ python workspace.py get-estimate-scale
23
+ python workspace.py generate-config [--dry-run]
24
+ python workspace.py get-me
25
+ python workspace.py set-active-project PROJECT_ID
26
+ python workspace.py add-active-project PROJECT_ID
27
+ python workspace.py remove-active-project PROJECT_ID
28
+ python workspace.py clear-active-projects
29
+ """
30
+
31
+ import argparse
32
+ import json
33
+ import os
34
+ import sys
35
+ from datetime import datetime, timezone
36
+ from pathlib import Path
37
+ from typing import Optional
38
+
39
+ sys.path.insert(0, str(Path(__file__).parent))
40
+ from flydocs_api import get_client, output_json, fail, find_project_root
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Human-readable messages for validation checks
45
+ # ---------------------------------------------------------------------------
46
+
47
+ CHECK_MESSAGES: dict[str, str] = {
48
+ "provider": "No provider connected — configure in FlyDocs dashboard",
49
+ "team": "No team selected — configure in FlyDocs dashboard",
50
+ "statusMapping": "Status mapping not configured — configure in FlyDocs dashboard",
51
+ "labelConfig": "Label config not configured — configure in FlyDocs dashboard",
52
+ "userIdentity": (
53
+ "Provider identity not linked — link your identity in the FlyDocs dashboard "
54
+ "profile page, or run: workspace.py set-identity <provider> <your-account-id>"
55
+ ),
56
+ "repos": "No repos linked — GitHub features won't work until you push and link a repo",
57
+ }
58
+
59
+ DEFAULT_MESSAGE = "Not configured — check FlyDocs dashboard"
60
+
61
+ VALID_PROVIDERS = ("linear", "jira")
62
+
63
+ # Fields owned by the server — overwritten from generate response
64
+ SERVER_OWNED_FIELDS = {
65
+ "workspaceId",
66
+ "setupComplete",
67
+ "workspace",
68
+ "issueLabels",
69
+ }
70
+
71
+ # Fields owned locally — never overwritten by generate
72
+ LOCAL_ONLY_FIELDS = {
73
+ "version",
74
+ "sourceRepo",
75
+ "tier",
76
+ "paths",
77
+ "detectedStack",
78
+ "skills",
79
+ "designSystem",
80
+ "aiLabor",
81
+ }
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Subcommand handlers
86
+ # ---------------------------------------------------------------------------
87
+
88
+ def _check_integrity(client: "FlyDocsClient") -> dict:
89
+ """Check install integrity against .flydocs/integrity.json."""
90
+ integrity_path = client.project_root / ".flydocs" / "integrity.json"
91
+ if not integrity_path.exists():
92
+ return {"checked": False, "reason": "integrity.json not found"}
93
+
94
+ try:
95
+ data = json.loads(integrity_path.read_text())
96
+ except (json.JSONDecodeError, OSError):
97
+ return {"checked": False, "reason": "integrity.json unreadable"}
98
+
99
+ missing_files = []
100
+ for f in data.get("ownedFiles", []):
101
+ if not (client.project_root / f).exists():
102
+ missing_files.append(f)
103
+
104
+ missing_dirs = []
105
+ for d in data.get("ownedDirectories", []):
106
+ if not (client.project_root / d).exists():
107
+ missing_dirs.append(d)
108
+
109
+ return {
110
+ "checked": True,
111
+ "valid": len(missing_files) == 0 and len(missing_dirs) == 0,
112
+ "version": data.get("version", "unknown"),
113
+ "missingFiles": missing_files,
114
+ "missingDirectories": missing_dirs,
115
+ }
116
+
117
+
118
+ def _try_auto_resolve_identity(client: "FlyDocsClient") -> bool:
119
+ """Attempt to auto-resolve provider identity via get-me. Returns True if resolved."""
120
+ try:
121
+ result = client.relay.get("/auth/me")
122
+ except Exception:
123
+ return False
124
+
125
+ provider_id = result.get("providerId")
126
+ if not provider_id:
127
+ # Check providerIdentities array as fallback
128
+ identities = result.get("providerIdentities", [])
129
+ if identities:
130
+ provider_id = identities[0].get("providerId")
131
+
132
+ if not provider_id:
133
+ return False
134
+
135
+ # Write me.json
136
+ me_data = {
137
+ "displayName": result.get("displayName"),
138
+ "email": result.get("email"),
139
+ "providerId": provider_id,
140
+ "provider": result.get("provider"),
141
+ "providerIdentities": result.get("providerIdentities", []),
142
+ "preferences": result.get("preferences", {}),
143
+ }
144
+
145
+ me_path = client.project_root / ".flydocs" / "me.json"
146
+ me_path.parent.mkdir(parents=True, exist_ok=True)
147
+ me_path.write_text(json.dumps(me_data, indent=2) + "\n")
148
+ return True
149
+
150
+
151
+ def cmd_validate(args: argparse.Namespace) -> None:
152
+ """Validate workspace setup via GET /auth/config."""
153
+ client = get_client()
154
+ client.require_cloud("validate")
155
+
156
+ config_response = client.relay.get("/auth/config")
157
+
158
+ is_valid = config_response.get("valid", False)
159
+ missing_keys: list[str] = config_response.get("missing", [])
160
+ warning_keys: list[str] = config_response.get("warnings", [])
161
+
162
+ # Auto-resolve identity if missing — try get-me before requiring manual step
163
+ if "userIdentity" in missing_keys or "userIdentity" in warning_keys:
164
+ if _try_auto_resolve_identity(client):
165
+ missing_keys = [k for k in missing_keys if k != "userIdentity"]
166
+ warning_keys = [k for k in warning_keys if k != "userIdentity"]
167
+ # Re-check validity — if userIdentity was the only missing item, we're valid now
168
+ if not missing_keys:
169
+ is_valid = True
170
+
171
+ # Build structured missing/warning lists with messages
172
+ missing = [
173
+ {"check": k, "action": CHECK_MESSAGES.get(k, DEFAULT_MESSAGE)}
174
+ for k in missing_keys
175
+ ]
176
+ warnings = [
177
+ {"check": k, "action": CHECK_MESSAGES.get(k, DEFAULT_MESSAGE)}
178
+ for k in warning_keys
179
+ ]
180
+
181
+ # Build checks map for cache
182
+ all_keys = set(CHECK_MESSAGES.keys())
183
+ checks: dict[str, bool] = {}
184
+ for k in all_keys:
185
+ checks[k] = k not in missing_keys and k not in warning_keys
186
+
187
+ # Build workspace info from response
188
+ workspace = config_response.get("workspace", {})
189
+ provider_type = config_response.get("provider", {}).get("type", "unknown")
190
+
191
+ # Write validation cache
192
+ cache = {
193
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
194
+ "valid": is_valid,
195
+ "workspace": {
196
+ "id": workspace.get("id", ""),
197
+ "name": workspace.get("name", ""),
198
+ },
199
+ "provider": provider_type,
200
+ "checks": checks,
201
+ "missing": missing_keys,
202
+ "warnings": warning_keys,
203
+ }
204
+
205
+ cache_path = client.project_root / ".flydocs" / "validation-cache.json"
206
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
207
+ with open(cache_path, "w") as f:
208
+ json.dump(cache, f, indent=2)
209
+ f.write("\n")
210
+
211
+ # If all required checks pass, set setupComplete in config
212
+ if is_valid:
213
+ config_path = client.config_path
214
+ if config_path.exists():
215
+ with open(config_path, "r") as f:
216
+ local_config = json.load(f)
217
+ else:
218
+ local_config = {}
219
+
220
+ local_config["setupComplete"] = True
221
+ with open(config_path, "w") as f:
222
+ json.dump(local_config, f, indent=2)
223
+ f.write("\n")
224
+
225
+ # Check install integrity
226
+ integrity = _check_integrity(client)
227
+
228
+ # Check activeProjects (local validation, not server-side)
229
+ active_projects = client.config.get("workspace", {}).get("activeProjects", [])
230
+ active_projects_set = len(active_projects) > 0
231
+
232
+ # Output structured report
233
+ report: dict = {
234
+ "valid": is_valid,
235
+ "checks": checks,
236
+ "passed": [k for k, v in checks.items() if v],
237
+ "integrity": integrity,
238
+ "activeProjects": {
239
+ "set": active_projects_set,
240
+ "count": len(active_projects),
241
+ "ids": active_projects,
242
+ },
243
+ }
244
+ if not active_projects_set:
245
+ report.setdefault("warnings", [])
246
+ if isinstance(report["warnings"], list):
247
+ report["warnings"].append({
248
+ "check": "activeProjects",
249
+ "action": "No active project set — run: workspace.py set-active-project <PROJECT_ID>",
250
+ })
251
+ if missing:
252
+ report["missing"] = missing
253
+ if warnings:
254
+ report["warnings"] = warnings
255
+ if is_valid:
256
+ report["setupComplete"] = True
257
+
258
+ output_json(report)
259
+
260
+
261
+ def cmd_list_labels(args: argparse.Namespace) -> None:
262
+ """List available team labels."""
263
+ client = get_client()
264
+ client.require_cloud("list-labels")
265
+ result = client.relay.get("/labels")
266
+ output_json(result)
267
+
268
+
269
+ def cmd_refresh_labels(args: argparse.Namespace) -> None:
270
+ """Refresh label config — validate and optionally fix stale IDs."""
271
+ client = get_client()
272
+ client.require_cloud("refresh-labels")
273
+
274
+ # Fetch current labels from relay
275
+ labels = client.relay.get("/labels")
276
+ label_map = {label["id"]: label["name"] for label in labels}
277
+ label_by_name = {label["name"].lower(): label["id"] for label in labels}
278
+
279
+ # Load local config
280
+ config_path = client.config_path
281
+ if not config_path.exists():
282
+ fail("No .flydocs/config.json found")
283
+
284
+ with open(config_path, "r") as f:
285
+ config = json.load(f)
286
+
287
+ issue_labels = config.get("issueLabels", {})
288
+ stale: list[dict] = []
289
+ valid: list[dict] = []
290
+
291
+ # Check each label ID in config against relay
292
+ for category, entries in issue_labels.items():
293
+ if isinstance(entries, dict):
294
+ for key, label_id in entries.items():
295
+ if label_id in label_map:
296
+ valid.append({
297
+ "category": category,
298
+ "key": key,
299
+ "id": label_id,
300
+ "name": label_map[label_id],
301
+ })
302
+ else:
303
+ # Try to find by key name
304
+ resolved = label_by_name.get(key.lower())
305
+ stale.append({
306
+ "category": category,
307
+ "key": key,
308
+ "staleId": label_id,
309
+ "resolvedId": resolved,
310
+ "resolvedName": key if resolved else None,
311
+ })
312
+
313
+ if args.fix and stale:
314
+ # Update stale IDs in config
315
+ fixed = 0
316
+ for item in stale:
317
+ if item["resolvedId"]:
318
+ issue_labels[item["category"]][item["key"]] = item["resolvedId"]
319
+ fixed += 1
320
+
321
+ with open(config_path, "w") as f:
322
+ json.dump(config, f, indent=2)
323
+ f.write("\n")
324
+
325
+ output_json({
326
+ "success": True,
327
+ "valid": len(valid),
328
+ "stale": len(stale),
329
+ "fixed": fixed,
330
+ "unfixable": len(stale) - fixed,
331
+ "details": stale,
332
+ })
333
+ else:
334
+ output_json({
335
+ "valid": len(valid),
336
+ "stale": len(stale),
337
+ "totalProviderLabels": len(labels),
338
+ "details": stale if stale else "All label IDs are current",
339
+ "hint": "Run with --fix to update stale IDs" if stale else None,
340
+ })
341
+
342
+
343
+ def cmd_list_statuses(args: argparse.Namespace) -> None:
344
+ """List provider workflow states."""
345
+ client = get_client()
346
+ client.require_cloud("list-statuses")
347
+ result = client.relay.get("/auth/statuses")
348
+ output_json(result)
349
+
350
+
351
+ def cmd_list_providers(args: argparse.Namespace) -> None:
352
+ """List available providers."""
353
+ client = get_client()
354
+ client.require_cloud("list-providers")
355
+ result = client.relay.get("/providers")
356
+ output_json(result)
357
+
358
+
359
+ def cmd_set_provider(args: argparse.Namespace) -> None:
360
+ """Set provider preference."""
361
+ client = get_client()
362
+ client.require_cloud("set-provider")
363
+ result = client.relay.post("/auth/provider", {"providerType": args.provider_type})
364
+ output_json(result)
365
+
366
+
367
+ def cmd_list_teams(args: argparse.Namespace) -> None:
368
+ """List available teams/projects."""
369
+ client = get_client()
370
+ client.require_cloud("list-teams")
371
+ result = client.relay.get("/teams")
372
+ output_json(result)
373
+
374
+
375
+ def cmd_create_team(args: argparse.Namespace) -> None:
376
+ """Create a team/project."""
377
+ client = get_client()
378
+ client.require_cloud("create-team")
379
+
380
+ body: dict = {"name": args.name}
381
+ if args.key:
382
+ body["key"] = args.key
383
+ if args.description:
384
+ body["description"] = args.description
385
+ if args.parent:
386
+ body["parentId"] = args.parent
387
+
388
+ result = client.relay.post("/teams", body)
389
+ output_json({
390
+ "id": result["id"],
391
+ "name": result["name"],
392
+ "key": result.get("key", ""),
393
+ })
394
+
395
+
396
+ def cmd_set_team(args: argparse.Namespace) -> None:
397
+ """Set team/project preference."""
398
+ client = get_client()
399
+ client.require_cloud("set-team")
400
+ result = client.relay.post("/auth/team", {"teamId": args.team_id})
401
+ output_json(result)
402
+
403
+
404
+ def cmd_set_labels(args: argparse.Namespace) -> None:
405
+ """Set label config on the relay."""
406
+ client = get_client()
407
+ client.require_cloud("set-labels")
408
+
409
+ # Build body from flags or stdin
410
+ if args.defaults is not None or args.type_map is not None:
411
+ body: dict = {}
412
+ if args.defaults is not None:
413
+ try:
414
+ body["defaults"] = json.loads(args.defaults)
415
+ except json.JSONDecodeError:
416
+ fail("Invalid JSON for --defaults")
417
+ if args.type_map is not None:
418
+ try:
419
+ body["typeMap"] = json.loads(args.type_map)
420
+ except json.JSONDecodeError:
421
+ fail("Invalid JSON for --type-map")
422
+ elif not sys.stdin.isatty():
423
+ try:
424
+ body = json.loads(sys.stdin.read().strip())
425
+ except json.JSONDecodeError:
426
+ fail("Invalid JSON on stdin")
427
+ else:
428
+ fail("Provide --defaults/--type-map flags or pipe JSON via stdin")
429
+
430
+ result = client.relay.post("/auth/labels", body)
431
+ output_json(result)
432
+
433
+
434
+ def cmd_set_status_mapping(args: argparse.Namespace) -> None:
435
+ """Set status mapping on the relay."""
436
+ client = get_client()
437
+ client.require_cloud("set-status-mapping")
438
+
439
+ if args.auto:
440
+ body: dict = {"mapping": "auto"}
441
+ elif args.mapping is not None:
442
+ try:
443
+ body = {"mapping": json.loads(args.mapping)}
444
+ except json.JSONDecodeError:
445
+ fail("Invalid JSON for --mapping")
446
+ elif not sys.stdin.isatty():
447
+ try:
448
+ body = json.loads(sys.stdin.read().strip())
449
+ except json.JSONDecodeError:
450
+ fail("Invalid JSON on stdin")
451
+ else:
452
+ fail("Provide --auto, --mapping '{...}', or pipe JSON via stdin")
453
+
454
+ result = client.relay.post("/auth/statuses", body)
455
+ output_json(result)
456
+
457
+
458
+ def cmd_set_identity(args: argparse.Namespace) -> None:
459
+ """Set provider identity and write me.json."""
460
+ provider = args.provider.lower()
461
+ if provider not in VALID_PROVIDERS:
462
+ fail(f"Invalid provider: {provider}. Must be one of: {', '.join(VALID_PROVIDERS)}")
463
+
464
+ provider_user_id = args.provider_user_id
465
+ if not provider_user_id:
466
+ fail("Provider user ID cannot be empty")
467
+
468
+ client = get_client()
469
+ client.require_cloud("set-identity")
470
+
471
+ result = client.relay.post("/auth/identity", {
472
+ "provider": provider,
473
+ "providerId": provider_user_id,
474
+ })
475
+
476
+ # Write me.json for local identity resolution
477
+ me_data = {
478
+ "provider": result.get("provider", provider),
479
+ "providerId": result.get("providerId", provider_user_id),
480
+ "displayName": result.get("displayName"),
481
+ "email": result.get("email"),
482
+ }
483
+ me_path = client.project_root / ".flydocs" / "me.json"
484
+ me_path.parent.mkdir(parents=True, exist_ok=True)
485
+ me_path.write_text(json.dumps(me_data, indent=2) + "\n")
486
+
487
+ output_json({
488
+ "success": result.get("success", True),
489
+ "provider": me_data["provider"],
490
+ "providerId": me_data["providerId"],
491
+ "meJson": str(me_path),
492
+ })
493
+
494
+
495
+ def cmd_set_preferences(args: argparse.Namespace) -> None:
496
+ """Get or set user preferences."""
497
+ client = get_client()
498
+ client.require_cloud("set-preferences")
499
+
500
+ # If no flags provided, GET current preferences
501
+ if args.workspace is None and args.assignee is None and args.display is None:
502
+ result = client.relay.get("/auth/preferences")
503
+ output_json(result)
504
+ return
505
+
506
+ # Build update body from provided flags
507
+ body: dict = {}
508
+ if args.workspace is not None:
509
+ body["defaultWorkspaceId"] = args.workspace
510
+ if args.assignee is not None:
511
+ body["defaultAssignee"] = args.assignee
512
+ if args.display is not None:
513
+ body["displayPreferences"] = args.display
514
+
515
+ result = client.relay.post("/auth/preferences", body)
516
+ output_json({
517
+ "success": result.get("success", True),
518
+ "preferences": result.get("preferences", body),
519
+ })
520
+
521
+
522
+ def cmd_get_estimate_scale(args: argparse.Namespace) -> None:
523
+ """Get the provider's estimate scale."""
524
+ client = get_client()
525
+ client.require_cloud("get-estimate-scale")
526
+ result = client.relay.get("/auth/estimates")
527
+ output_json(result)
528
+
529
+
530
+ def cmd_generate_config(args: argparse.Namespace) -> None:
531
+ """Generate config.json from the relay's canonical workspace config."""
532
+ client = get_client()
533
+ client.require_cloud("generate-config")
534
+
535
+ # Fetch server-owned config from relay
536
+ response = client.relay.get("/config/generate")
537
+
538
+ if not response.get("valid", False):
539
+ missing = response.get("missing", [])
540
+ warnings = response.get("warnings", [])
541
+ parts = []
542
+ if missing:
543
+ parts.append(f"missing: {', '.join(missing)}")
544
+ if warnings:
545
+ parts.append(f"warnings: {', '.join(warnings)}")
546
+ detail = "; ".join(parts) if parts else "unknown reason"
547
+ print(f"WARNING: Config not fully valid — {detail}", file=sys.stderr)
548
+
549
+ server_config = response.get("config", {})
550
+
551
+ # Read existing local config
552
+ config_path = client.config_path
553
+ if config_path.exists():
554
+ with open(config_path, "r") as f:
555
+ local_config = json.load(f)
556
+ else:
557
+ local_config = {}
558
+
559
+ # Merge: server-owned fields overwrite, local-only fields preserved
560
+ merged = dict(local_config)
561
+
562
+ # Apply server-owned fields
563
+ if "workspaceId" in server_config:
564
+ merged["workspaceId"] = server_config["workspaceId"]
565
+ if "setupComplete" in server_config:
566
+ merged["setupComplete"] = server_config["setupComplete"]
567
+ if "workspace" in server_config:
568
+ local_ws = merged.get("workspace", {})
569
+ server_ws = server_config["workspace"]
570
+ # Start with server values
571
+ merged_ws = dict(server_ws)
572
+ # Preserve local values when server returns empty/null
573
+ if not merged_ws.get("activeProjects") and local_ws.get("activeProjects"):
574
+ merged_ws["activeProjects"] = local_ws["activeProjects"]
575
+ if not merged_ws.get("defaultMilestoneId") and local_ws.get("defaultMilestoneId"):
576
+ merged_ws["defaultMilestoneId"] = local_ws["defaultMilestoneId"]
577
+ if not merged_ws.get("repoSlug") and local_ws.get("repoSlug"):
578
+ merged_ws["repoSlug"] = local_ws["repoSlug"]
579
+ merged["workspace"] = merged_ws
580
+ if "issueLabels" in server_config:
581
+ merged["issueLabels"] = server_config["issueLabels"]
582
+
583
+ # Store configVersion for freshness tracking
584
+ if "configVersion" in response:
585
+ merged["configVersion"] = response["configVersion"]
586
+
587
+ # Clean up ghost fields that are now server-owned
588
+ for ghost in ("provider", "statusMapping", "labels"):
589
+ merged.pop(ghost, None)
590
+
591
+ if args.dry_run:
592
+ output_json(merged)
593
+ return
594
+
595
+ # Write merged config
596
+ config_path.parent.mkdir(parents=True, exist_ok=True)
597
+ with open(config_path, "w") as f:
598
+ json.dump(merged, f, indent=2)
599
+ f.write("\n")
600
+
601
+ output_json({
602
+ "success": True,
603
+ "configVersion": response.get("configVersion"),
604
+ "valid": response.get("valid", False),
605
+ "missing": response.get("missing", []),
606
+ "warnings": response.get("warnings", []),
607
+ })
608
+
609
+
610
+ def _update_active_projects(project_root: Path, config_path: Path,
611
+ operation: str, project_id: str | None = None) -> dict:
612
+ """Read config, modify workspace.activeProjects, write config."""
613
+ if config_path.exists():
614
+ with open(config_path, "r") as f:
615
+ config = json.load(f)
616
+ else:
617
+ config = {}
618
+
619
+ workspace = config.setdefault("workspace", {})
620
+ current = workspace.get("activeProjects", [])
621
+
622
+ if operation == "set":
623
+ workspace["activeProjects"] = [project_id] if project_id else []
624
+ elif operation == "add":
625
+ if project_id and project_id not in current:
626
+ current.append(project_id)
627
+ workspace["activeProjects"] = current
628
+ elif operation == "remove":
629
+ workspace["activeProjects"] = [p for p in current if p != project_id]
630
+ elif operation == "clear":
631
+ workspace["activeProjects"] = []
632
+
633
+ config["workspace"] = workspace
634
+ with open(config_path, "w") as f:
635
+ json.dump(config, f, indent=2)
636
+ f.write("\n")
637
+
638
+ return {
639
+ "success": True,
640
+ "activeProjects": workspace["activeProjects"],
641
+ }
642
+
643
+
644
+ def cmd_set_active_project(args: argparse.Namespace) -> None:
645
+ """Set the active project (replaces any existing)."""
646
+ client = get_client()
647
+ # Validate project exists on cloud tier
648
+ if client.is_cloud:
649
+ projects = client.list_projects(show_all=True)
650
+ if not any(p["id"] == args.project_id for p in projects):
651
+ fail(f"Project not found: {args.project_id}")
652
+ result = _update_active_projects(
653
+ client.project_root, client.config_path, "set", args.project_id
654
+ )
655
+ output_json(result)
656
+
657
+
658
+ def cmd_add_active_project(args: argparse.Namespace) -> None:
659
+ """Add a project to the active projects list."""
660
+ client = get_client()
661
+ if client.is_cloud:
662
+ projects = client.list_projects(show_all=True)
663
+ if not any(p["id"] == args.project_id for p in projects):
664
+ fail(f"Project not found: {args.project_id}")
665
+ result = _update_active_projects(
666
+ client.project_root, client.config_path, "add", args.project_id
667
+ )
668
+ output_json(result)
669
+
670
+
671
+ def cmd_remove_active_project(args: argparse.Namespace) -> None:
672
+ """Remove a project from the active projects list."""
673
+ client = get_client()
674
+ result = _update_active_projects(
675
+ client.project_root, client.config_path, "remove", args.project_id
676
+ )
677
+ output_json(result)
678
+
679
+
680
+ def cmd_clear_active_projects(args: argparse.Namespace) -> None:
681
+ """Clear all active projects."""
682
+ client = get_client()
683
+ result = _update_active_projects(
684
+ client.project_root, client.config_path, "clear"
685
+ )
686
+ output_json(result)
687
+
688
+
689
+ def _load_api_key(project_root: Path) -> Optional[str]:
690
+ """Load API key from environment or .env files (for get-me direct HTTP)."""
691
+ if os.environ.get("FLYDOCS_API_KEY"):
692
+ return os.environ["FLYDOCS_API_KEY"]
693
+ for name in [".env.local", ".env", ".env.development", ".env.production"]:
694
+ env_file = project_root / name
695
+ if env_file.exists():
696
+ with open(env_file, "r") as f:
697
+ for line in f:
698
+ line = line.strip()
699
+ if line.startswith("#") or "=" not in line:
700
+ continue
701
+ k, _, v = line.partition("=")
702
+ if k.strip() == "FLYDOCS_API_KEY":
703
+ v = v.strip().strip("\"'")
704
+ return v if v else None
705
+ return None
706
+
707
+
708
+ def _resolve_base_url() -> str:
709
+ """Resolve relay base URL from environment (for get-me direct HTTP)."""
710
+ env_url = os.environ.get("FLYDOCS_RELAY_URL")
711
+ if env_url:
712
+ return env_url.rstrip("/")
713
+ return "https://app.flydocs.ai/api/relay"
714
+
715
+
716
+ def cmd_get_me(args: argparse.Namespace) -> None:
717
+ """Fetch current user identity via direct HTTP (no client/workspace needed)."""
718
+ import urllib.request
719
+ import urllib.error
720
+
721
+ project_root = find_project_root()
722
+ api_key = _load_api_key(project_root)
723
+ if not api_key:
724
+ fail("FLYDOCS_API_KEY not found. Set in environment or .env/.env.local file")
725
+
726
+ base_url = _resolve_base_url()
727
+ url = f"{base_url}/auth/me"
728
+
729
+ headers = {
730
+ "Authorization": f"Bearer {api_key}",
731
+ "Accept": "application/json",
732
+ }
733
+
734
+ try:
735
+ req = urllib.request.Request(url, headers=headers, method="GET")
736
+ with urllib.request.urlopen(req, timeout=15) as resp:
737
+ result = json.loads(resp.read().decode("utf-8"))
738
+ except urllib.error.HTTPError as e:
739
+ error_body = e.read().decode("utf-8") if e.fp else ""
740
+ try:
741
+ error_data = json.loads(error_body)
742
+ except json.JSONDecodeError:
743
+ error_data = {"error": error_body}
744
+ fail(f"API error ({e.code}): {error_data.get('error', 'Unknown')}")
745
+ except (urllib.error.URLError, TimeoutError):
746
+ fail("Network error: unable to reach relay API")
747
+
748
+ # Write me.json
749
+ me_data = {
750
+ "displayName": result.get("displayName"),
751
+ "email": result.get("email"),
752
+ "providerId": result.get("providerId"),
753
+ "provider": result.get("provider"),
754
+ "providerIdentities": result.get("providerIdentities", []),
755
+ "preferences": result.get("preferences", {}),
756
+ }
757
+
758
+ me_path = project_root / ".flydocs" / "me.json"
759
+ me_path.parent.mkdir(parents=True, exist_ok=True)
760
+ me_path.write_text(json.dumps(me_data, indent=2) + "\n")
761
+
762
+ output_json({
763
+ "success": True,
764
+ "displayName": me_data["displayName"],
765
+ "email": me_data["email"],
766
+ "provider": me_data["provider"],
767
+ "meJson": str(me_path),
768
+ })
769
+
770
+
771
+ # ---------------------------------------------------------------------------
772
+ # CLI parser
773
+ # ---------------------------------------------------------------------------
774
+
775
+ def main() -> None:
776
+ parser = argparse.ArgumentParser(
777
+ description="FlyDocs workspace setup and configuration"
778
+ )
779
+ sub = parser.add_subparsers(dest="command", required=True)
780
+
781
+ # validate
782
+ sub.add_parser("validate", help="Validate workspace setup")
783
+
784
+ # list-labels
785
+ sub.add_parser("list-labels", help="List available team labels")
786
+
787
+ # refresh-labels
788
+ rl = sub.add_parser("refresh-labels", help="Refresh label config from relay")
789
+ rl.add_argument("--fix", action="store_true", help="Update stale label IDs")
790
+
791
+ # list-statuses
792
+ sub.add_parser("list-statuses", help="List provider workflow states")
793
+
794
+ # list-providers
795
+ sub.add_parser("list-providers", help="List available providers")
796
+
797
+ # set-provider
798
+ sp = sub.add_parser("set-provider", help="Set provider preference")
799
+ sp.add_argument(
800
+ "provider_type",
801
+ choices=["linear", "jira"],
802
+ help="Provider type",
803
+ )
804
+
805
+ # list-teams
806
+ sub.add_parser("list-teams", help="List available teams/projects")
807
+
808
+ # create-team
809
+ ct = sub.add_parser("create-team", help="Create a team/project")
810
+ ct.add_argument("--name", required=True, help="Team name")
811
+ ct.add_argument("--key", default=None, help="Team key (e.g., PROD)")
812
+ ct.add_argument("--description", default=None, help="Team description")
813
+ ct.add_argument("--parent", default=None, help="Parent team ID")
814
+
815
+ # set-team
816
+ st = sub.add_parser("set-team", help="Set team/project preference")
817
+ st.add_argument("team_id", help="Provider team/workspace UUID")
818
+
819
+ # set-labels
820
+ sl = sub.add_parser("set-labels", help="Set label config on relay")
821
+ sl.add_argument("--defaults", default=None, help="JSON array of default label names")
822
+ sl.add_argument(
823
+ "--type-map", default=None, dest="type_map",
824
+ help="JSON object mapping issue types to label arrays",
825
+ )
826
+
827
+ # set-status-mapping
828
+ ssm = sub.add_parser("set-status-mapping", help="Set status mapping on relay")
829
+ ssm.add_argument(
830
+ "--auto", action="store_true",
831
+ help="Auto-map provider states to FlyDocs statuses",
832
+ )
833
+ ssm.add_argument("--mapping", default=None, help="JSON mapping object")
834
+
835
+ # set-identity
836
+ si = sub.add_parser("set-identity", help="Set provider identity")
837
+ si.add_argument("provider", help="Provider type (linear, jira)")
838
+ si.add_argument("provider_user_id", help="Provider-specific user ID")
839
+
840
+ # set-preferences
841
+ spref = sub.add_parser("set-preferences", help="Get or set user preferences")
842
+ spref.add_argument("--workspace", default=None, help="Default workspace ID")
843
+ spref.add_argument("--assignee", default=None, help="Default assignee")
844
+ spref.add_argument("--display", default=None, help="Display preferences (JSON)")
845
+
846
+ # get-estimate-scale
847
+ sub.add_parser("get-estimate-scale", help="Get provider estimate scale")
848
+
849
+ # generate-config
850
+ gc = sub.add_parser("generate-config", help="Generate config from relay")
851
+ gc.add_argument(
852
+ "--dry-run", action="store_true",
853
+ help="Print merged config without writing",
854
+ )
855
+
856
+ # get-me
857
+ sub.add_parser("get-me", help="Fetch current user identity")
858
+
859
+ # set-active-project
860
+ sap = sub.add_parser("set-active-project", help="Set the active project")
861
+ sap.add_argument("project_id", help="Project UUID")
862
+
863
+ # add-active-project
864
+ aap = sub.add_parser("add-active-project", help="Add to active projects")
865
+ aap.add_argument("project_id", help="Project UUID")
866
+
867
+ # remove-active-project
868
+ rap = sub.add_parser("remove-active-project", help="Remove from active projects")
869
+ rap.add_argument("project_id", help="Project UUID")
870
+
871
+ # clear-active-projects
872
+ sub.add_parser("clear-active-projects", help="Clear all active projects")
873
+
874
+ args = parser.parse_args()
875
+
876
+ commands = {
877
+ "validate": cmd_validate,
878
+ "list-labels": cmd_list_labels,
879
+ "refresh-labels": cmd_refresh_labels,
880
+ "list-statuses": cmd_list_statuses,
881
+ "list-providers": cmd_list_providers,
882
+ "set-provider": cmd_set_provider,
883
+ "list-teams": cmd_list_teams,
884
+ "create-team": cmd_create_team,
885
+ "set-team": cmd_set_team,
886
+ "set-labels": cmd_set_labels,
887
+ "set-status-mapping": cmd_set_status_mapping,
888
+ "set-identity": cmd_set_identity,
889
+ "set-preferences": cmd_set_preferences,
890
+ "get-estimate-scale": cmd_get_estimate_scale,
891
+ "generate-config": cmd_generate_config,
892
+ "get-me": cmd_get_me,
893
+ "set-active-project": cmd_set_active_project,
894
+ "add-active-project": cmd_add_active_project,
895
+ "remove-active-project": cmd_remove_active_project,
896
+ "clear-active-projects": cmd_clear_active_projects,
897
+ }
898
+ commands[args.command](args)
899
+
900
+
901
+ if __name__ == "__main__":
902
+ main()