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