@flydocs/cli 0.5.0-beta.0

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 (134) hide show
  1. package/README.md +96 -0
  2. package/dist/cli.js +2666 -0
  3. package/package.json +32 -0
  4. package/template/.claude/CLAUDE.md +90 -0
  5. package/template/.claude/agents/README.md +19 -0
  6. package/template/.claude/agents/implementation-agent.md +29 -0
  7. package/template/.claude/agents/pm-agent.md +29 -0
  8. package/template/.claude/agents/research-agent.md +25 -0
  9. package/template/.claude/agents/review-agent.md +29 -0
  10. package/template/.claude/commands/activate.md +10 -0
  11. package/template/.claude/commands/attach.md +9 -0
  12. package/template/.claude/commands/block.md +10 -0
  13. package/template/.claude/commands/capture.md +10 -0
  14. package/template/.claude/commands/close.md +10 -0
  15. package/template/.claude/commands/flydocs-setup.md +598 -0
  16. package/template/.claude/commands/flydocs-update.md +27 -0
  17. package/template/.claude/commands/implement.md +10 -0
  18. package/template/.claude/commands/new-project.md +11 -0
  19. package/template/.claude/commands/project-update.md +10 -0
  20. package/template/.claude/commands/refine.md +10 -0
  21. package/template/.claude/commands/review.md +10 -0
  22. package/template/.claude/commands/start-session.md +10 -0
  23. package/template/.claude/commands/status.md +10 -0
  24. package/template/.claude/commands/validate.md +10 -0
  25. package/template/.claude/commands/wrap-session.md +10 -0
  26. package/template/.claude/settings.json +49 -0
  27. package/template/.claude/skills/README.md +293 -0
  28. package/template/.claude/skills/flydocs-cloud/SKILL.md +96 -0
  29. package/template/.claude/skills/flydocs-cloud/cursor-rule.mdc +50 -0
  30. package/template/.claude/skills/flydocs-cloud/scripts/assign.py +38 -0
  31. package/template/.claude/skills/flydocs-cloud/scripts/assign_cycle.py +44 -0
  32. package/template/.claude/skills/flydocs-cloud/scripts/assign_milestone.py +44 -0
  33. package/template/.claude/skills/flydocs-cloud/scripts/comment.py +39 -0
  34. package/template/.claude/skills/flydocs-cloud/scripts/create_issue.py +100 -0
  35. package/template/.claude/skills/flydocs-cloud/scripts/create_milestone.py +46 -0
  36. package/template/.claude/skills/flydocs-cloud/scripts/create_project.py +40 -0
  37. package/template/.claude/skills/flydocs-cloud/scripts/estimate.py +38 -0
  38. package/template/.claude/skills/flydocs-cloud/scripts/flydocs_api.py +277 -0
  39. package/template/.claude/skills/flydocs-cloud/scripts/get_issue.py +77 -0
  40. package/template/.claude/skills/flydocs-cloud/scripts/link.py +47 -0
  41. package/template/.claude/skills/flydocs-cloud/scripts/list_cycles.py +35 -0
  42. package/template/.claude/skills/flydocs-cloud/scripts/list_issues.py +105 -0
  43. package/template/.claude/skills/flydocs-cloud/scripts/list_milestones.py +40 -0
  44. package/template/.claude/skills/flydocs-cloud/scripts/list_projects.py +45 -0
  45. package/template/.claude/skills/flydocs-cloud/scripts/priority.py +38 -0
  46. package/template/.claude/skills/flydocs-cloud/scripts/project_update.py +59 -0
  47. package/template/.claude/skills/flydocs-cloud/scripts/transition.py +67 -0
  48. package/template/.claude/skills/flydocs-cloud/scripts/update_description.py +47 -0
  49. package/template/.claude/skills/flydocs-cloud/scripts/update_issue.py +111 -0
  50. package/template/.claude/skills/flydocs-context-graph/SKILL.md +87 -0
  51. package/template/.claude/skills/flydocs-context-graph/schema.md +78 -0
  52. package/template/.claude/skills/flydocs-context-graph/scripts/graph_build.py +299 -0
  53. package/template/.claude/skills/flydocs-context-graph/scripts/graph_context.py +338 -0
  54. package/template/.claude/skills/flydocs-context-graph/scripts/graph_query.py +191 -0
  55. package/template/.claude/skills/flydocs-context-graph/scripts/graph_session.py +161 -0
  56. package/template/.claude/skills/flydocs-context-graph/scripts/graph_update.py +194 -0
  57. package/template/.claude/skills/flydocs-context-graph/scripts/graph_utils.py +118 -0
  58. package/template/.claude/skills/flydocs-estimates/SKILL.md +384 -0
  59. package/template/.claude/skills/flydocs-estimates/references/provider-costs.md +152 -0
  60. package/template/.claude/skills/flydocs-figma/SKILL.md +377 -0
  61. package/template/.claude/skills/flydocs-figma/references/PROMPTING.md +108 -0
  62. package/template/.claude/skills/flydocs-figma/references/TROUBLESHOOTING.md +112 -0
  63. package/template/.claude/skills/flydocs-local/SKILL.md +103 -0
  64. package/template/.claude/skills/flydocs-local/cursor-rule.mdc +43 -0
  65. package/template/.claude/skills/flydocs-local/scripts/assign.py +20 -0
  66. package/template/.claude/skills/flydocs-local/scripts/comment.py +27 -0
  67. package/template/.claude/skills/flydocs-local/scripts/create_issue.py +44 -0
  68. package/template/.claude/skills/flydocs-local/scripts/estimate.py +37 -0
  69. package/template/.claude/skills/flydocs-local/scripts/flydocs_api.py +272 -0
  70. package/template/.claude/skills/flydocs-local/scripts/get_issue.py +20 -0
  71. package/template/.claude/skills/flydocs-local/scripts/link.py +41 -0
  72. package/template/.claude/skills/flydocs-local/scripts/list_issues.py +34 -0
  73. package/template/.claude/skills/flydocs-local/scripts/priority.py +37 -0
  74. package/template/.claude/skills/flydocs-local/scripts/project_update.py +67 -0
  75. package/template/.claude/skills/flydocs-local/scripts/status_summary.py +16 -0
  76. package/template/.claude/skills/flydocs-local/scripts/transition.py +24 -0
  77. package/template/.claude/skills/flydocs-local/scripts/update_description.py +35 -0
  78. package/template/.claude/skills/flydocs-local/scripts/update_issue.py +84 -0
  79. package/template/.claude/skills/flydocs-workflow/SKILL.md +85 -0
  80. package/template/.claude/skills/flydocs-workflow/cursor-rule.mdc +53 -0
  81. package/template/.claude/skills/flydocs-workflow/reference/comment-templates.md +131 -0
  82. package/template/.claude/skills/flydocs-workflow/reference/golden-rules.md +76 -0
  83. package/template/.claude/skills/flydocs-workflow/reference/priority-estimates.md +28 -0
  84. package/template/.claude/skills/flydocs-workflow/reference/status-workflow.md +50 -0
  85. package/template/.claude/skills/flydocs-workflow/session.md +128 -0
  86. package/template/.claude/skills/flydocs-workflow/stages/activate.md +46 -0
  87. package/template/.claude/skills/flydocs-workflow/stages/capture.md +50 -0
  88. package/template/.claude/skills/flydocs-workflow/stages/close.md +32 -0
  89. package/template/.claude/skills/flydocs-workflow/stages/implement.md +124 -0
  90. package/template/.claude/skills/flydocs-workflow/stages/refine.md +51 -0
  91. package/template/.claude/skills/flydocs-workflow/stages/review.md +86 -0
  92. package/template/.claude/skills/flydocs-workflow/stages/validate.md +90 -0
  93. package/template/.claude/skills/flydocs-workflow/templates/bug.md +95 -0
  94. package/template/.claude/skills/flydocs-workflow/templates/chore.md +75 -0
  95. package/template/.claude/skills/flydocs-workflow/templates/feature.md +93 -0
  96. package/template/.claude/skills/flydocs-workflow/templates/idea.md +84 -0
  97. package/template/.cursor/agents/implementation-agent.md +28 -0
  98. package/template/.cursor/agents/pm-agent.md +27 -0
  99. package/template/.cursor/agents/research-agent.md +23 -0
  100. package/template/.cursor/agents/review-agent.md +27 -0
  101. package/template/.cursor/hooks.json +29 -0
  102. package/template/.cursor/mcp.json +16 -0
  103. package/template/.env.example +44 -0
  104. package/template/.flydocs/config.json +104 -0
  105. package/template/.flydocs/hooks/auto-approve.py +71 -0
  106. package/template/.flydocs/hooks/post-edit.py +72 -0
  107. package/template/.flydocs/hooks/prefer-scripts.py +89 -0
  108. package/template/.flydocs/hooks/prompt-submit.py +277 -0
  109. package/template/.flydocs/scripts/generate_manifest.py +287 -0
  110. package/template/.flydocs/scripts/skill_manager.py +541 -0
  111. package/template/.flydocs/templates/README.md +46 -0
  112. package/template/.flydocs/templates/bug.md +166 -0
  113. package/template/.flydocs/templates/chore.md +110 -0
  114. package/template/.flydocs/templates/design-system/README.md +27 -0
  115. package/template/.flydocs/templates/design-system/component-patterns.md +92 -0
  116. package/template/.flydocs/templates/design-system/token-mapping.md +168 -0
  117. package/template/.flydocs/templates/feature.md +173 -0
  118. package/template/.flydocs/templates/idea.md +122 -0
  119. package/template/.flydocs/templates/instructions.md +228 -0
  120. package/template/.flydocs/templates/quick-capture.md +35 -0
  121. package/template/.flydocs/templates/scripts/check-design-system.template.mjs +179 -0
  122. package/template/.flydocs/version +1 -0
  123. package/template/AGENTS.md +95 -0
  124. package/template/CHANGELOG.md +271 -0
  125. package/template/flydocs/README.md +186 -0
  126. package/template/flydocs/context/project.md +51 -0
  127. package/template/flydocs/design-system/README.md +126 -0
  128. package/template/flydocs/design-system/component-patterns.md +173 -0
  129. package/template/flydocs/design-system/token-mapping.md +114 -0
  130. package/template/flydocs/knowledge/INDEX.md +100 -0
  131. package/template/flydocs/knowledge/README.md +62 -0
  132. package/template/flydocs/knowledge/product/personas.md +79 -0
  133. package/template/flydocs/knowledge/product/user-flows.md +88 -0
  134. package/template/manifest.json +221 -0
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env python3
2
+ """Create a new issue in Linear."""
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ sys.path.insert(0, str(Path(__file__).parent))
9
+ from flydocs_api import get_client, output_json, fail
10
+
11
+
12
+ def main():
13
+ parser = argparse.ArgumentParser(description="Create issue")
14
+ parser.add_argument("--title", required=True)
15
+ parser.add_argument("--type", required=True, choices=["feature", "bug", "chore", "idea"], dest="issue_type")
16
+ parser.add_argument("--description", default="")
17
+ parser.add_argument("--description-file", default="", dest="description_file")
18
+ parser.add_argument("--priority", type=int, default=3, choices=range(5))
19
+ parser.add_argument("--estimate", type=int, default=0, choices=[0, 1, 2, 3, 5])
20
+ parser.add_argument("--assignee", default=None)
21
+ parser.add_argument("--project", default=None)
22
+ parser.add_argument("--triage", action="store_true")
23
+ args = parser.parse_args()
24
+
25
+ # Resolve description: --description-file > stdin > --description
26
+ description = args.description
27
+ if args.description_file:
28
+ try:
29
+ description = Path(args.description_file).read_text()
30
+ except FileNotFoundError:
31
+ fail(f"File not found: {args.description_file}")
32
+ elif not description and not sys.stdin.isatty():
33
+ description = sys.stdin.read().strip()
34
+
35
+ client = get_client()
36
+
37
+ issue_input = {
38
+ "teamId": client.team_id,
39
+ "title": args.title,
40
+ "description": description,
41
+ "priority": args.priority,
42
+ }
43
+ if args.estimate:
44
+ issue_input["estimate"] = args.estimate
45
+
46
+ # Labels
47
+ label_ids = []
48
+ cat_id = client.get_category_label_id(args.issue_type)
49
+ if cat_id:
50
+ label_ids.append(cat_id)
51
+ if args.triage:
52
+ triage_id = client.get_other_label_id("triage")
53
+ if triage_id:
54
+ label_ids.append(triage_id)
55
+ if label_ids:
56
+ issue_input["labelIds"] = label_ids
57
+
58
+ # Project
59
+ project_id = args.project
60
+ if not project_id:
61
+ active = client.workspace.get("activeProjects", [])
62
+ if active:
63
+ project_id = active[0]
64
+ if project_id:
65
+ issue_input["projectId"] = project_id
66
+
67
+ # Assignee
68
+ if args.assignee:
69
+ user_id, _ = client.resolve_user_id(args.assignee)
70
+ if user_id:
71
+ issue_input["assigneeId"] = user_id
72
+
73
+ mutation = """mutation($input: IssueCreateInput!) {
74
+ issueCreate(input: $input) {
75
+ success
76
+ issue { id identifier title url }
77
+ }
78
+ }"""
79
+
80
+ result = client.query(mutation, {"input": issue_input})
81
+
82
+ data = result.get("data", {}).get("issueCreate", {})
83
+ if not data.get("success"):
84
+ # Retry without labels if team mismatch (stale label IDs in config)
85
+ errors = result.get("errors", [])
86
+ label_error = any("label" in (e.get("message", "") or "").lower() for e in errors)
87
+ if label_error and "labelIds" in issue_input:
88
+ print("Label IDs rejected — retrying without labels...", file=sys.stderr)
89
+ del issue_input["labelIds"]
90
+ result = client.query(mutation, {"input": issue_input})
91
+ data = result.get("data", {}).get("issueCreate", {})
92
+ if not data.get("success"):
93
+ fail(f"Failed to create issue: {result}")
94
+
95
+ issue = data["issue"]
96
+ output_json({"id": issue["id"], "identifier": issue["identifier"], "title": issue["title"], "url": issue["url"]})
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env python3
2
+ """Create a milestone."""
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ sys.path.insert(0, str(Path(__file__).parent))
9
+ from flydocs_api import get_client, output_json, fail
10
+
11
+ parser = argparse.ArgumentParser(description="Create milestone")
12
+ parser.add_argument("--name", required=True)
13
+ parser.add_argument("--project", default="", help="Project ID (defaults to first activeProject)")
14
+ parser.add_argument("--target-date", default=None, help="YYYY-MM-DD")
15
+ args = parser.parse_args()
16
+
17
+ client = get_client()
18
+
19
+ project_id = args.project
20
+ if not project_id:
21
+ active = client.workspace.get("activeProjects", [])
22
+ if active:
23
+ project_id = active[0]
24
+ else:
25
+ fail("No --project specified and no activeProjects in config")
26
+
27
+ milestone_input = {"name": args.name, "projectId": project_id}
28
+ if args.target_date:
29
+ milestone_input["targetDate"] = args.target_date
30
+
31
+ result = client.query(
32
+ """mutation($input: ProjectMilestoneCreateInput!) {
33
+ projectMilestoneCreate(input: $input) {
34
+ success
35
+ projectMilestone { id name }
36
+ }
37
+ }""",
38
+ {"input": milestone_input},
39
+ )
40
+
41
+ data = result.get("data", {}).get("projectMilestoneCreate", {})
42
+ if not data.get("success"):
43
+ fail(f"Failed to create milestone: {result}")
44
+
45
+ ms = data["projectMilestone"]
46
+ output_json({"id": ms["id"], "name": ms["name"]})
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env python3
2
+ """Create a new project."""
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ sys.path.insert(0, str(Path(__file__).parent))
9
+ from flydocs_api import get_client, output_json, fail
10
+
11
+ parser = argparse.ArgumentParser(description="Create project")
12
+ parser.add_argument("--name", required=True)
13
+ parser.add_argument("--description", default="")
14
+ args = parser.parse_args()
15
+
16
+ client = get_client()
17
+
18
+ project_input = {
19
+ "name": args.name,
20
+ "teamIds": [client.team_id],
21
+ }
22
+ if args.description:
23
+ project_input["description"] = args.description
24
+
25
+ result = client.query(
26
+ """mutation($input: ProjectCreateInput!) {
27
+ projectCreate(input: $input) {
28
+ success
29
+ project { id name url }
30
+ }
31
+ }""",
32
+ {"input": project_input},
33
+ )
34
+
35
+ data = result.get("data", {}).get("projectCreate", {})
36
+ if not data.get("success"):
37
+ fail(f"Failed to create project: {result}")
38
+
39
+ project = data["project"]
40
+ output_json({"id": project["id"], "name": project["name"], "url": project["url"]})
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env python3
2
+ """Set estimate on an issue."""
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ sys.path.insert(0, str(Path(__file__).parent))
8
+ from flydocs_api import get_client, output_json, fail
9
+
10
+ if len(sys.argv) < 3:
11
+ fail("Usage: estimate.py <ref> <1-5>")
12
+
13
+ ref = sys.argv[1]
14
+ try:
15
+ estimate = int(sys.argv[2])
16
+ except ValueError:
17
+ fail("Estimate must be a number (1-5)")
18
+
19
+ client = get_client()
20
+ issue_uuid = client.resolve_issue_id(ref)
21
+ if not issue_uuid:
22
+ fail(f"Issue not found: {ref}")
23
+
24
+ result = client.query(
25
+ """mutation($id: String!, $estimate: Int!) {
26
+ issueUpdate(id: $id, input: { estimate: $estimate }) {
27
+ success
28
+ issue { identifier estimate }
29
+ }
30
+ }""",
31
+ {"id": issue_uuid, "estimate": estimate},
32
+ )
33
+
34
+ if not result.get("data", {}).get("issueUpdate", {}).get("success"):
35
+ fail(f"Failed to set estimate: {result}")
36
+
37
+ issue = result["data"]["issueUpdate"]["issue"]
38
+ output_json({"success": True, "issue": issue["identifier"], "estimate": issue["estimate"]})
@@ -0,0 +1,277 @@
1
+ """FlyDocs Cloud API — Linear GraphQL client.
2
+
3
+ Central module for all Linear API operations.
4
+ Loads config from .flydocs/config.json and API key from .env files.
5
+
6
+ Usage:
7
+ from flydocs_api import get_client
8
+ client = get_client()
9
+ result = client.query("{ viewer { id name } }")
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import sys
15
+ import time
16
+ import urllib.request
17
+ import urllib.error
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+
23
+ class FlyDocsClient:
24
+ """Client for Linear GraphQL API with retry logic and config awareness."""
25
+
26
+ API_URL = "https://api.linear.app/graphql"
27
+ MAX_RETRIES = 5
28
+ RETRY_DELAY = 2
29
+
30
+ def __init__(self):
31
+ self.project_root = find_project_root()
32
+ self.config_path = self.project_root / ".flydocs" / "config.json"
33
+ self.log_path = self.project_root / ".flydocs" / "logs" / "linear-ops.jsonl"
34
+
35
+ self.config = self._load_config()
36
+ self.api_key = self._load_api_key()
37
+ if not self.api_key:
38
+ print("ERROR: LINEAR_API_KEY not found", file=sys.stderr)
39
+ print("Set in environment or .env/.env.local file", file=sys.stderr)
40
+ sys.exit(1)
41
+
42
+ self.team_id = self.config.get("provider", {}).get("teamId")
43
+ self.status_mapping = self.config.get("statusMapping", {})
44
+ self.issue_labels = self.config.get("issueLabels", {})
45
+ self.workspace = self.config.get("workspace", {})
46
+
47
+ # In-process caches (live for one script invocation)
48
+ self._id_cache: dict[str, str] = {}
49
+ self._team_members: Optional[list] = None
50
+ self._active_cycle: Optional[dict] = None
51
+
52
+ def _load_config(self) -> dict:
53
+ if self.config_path.exists():
54
+ with open(self.config_path, "r") as f:
55
+ return json.load(f)
56
+ return {}
57
+
58
+ def _load_api_key(self) -> Optional[str]:
59
+ if os.environ.get("LINEAR_API_KEY"):
60
+ return os.environ["LINEAR_API_KEY"]
61
+ for name in [".env.local", ".env"]:
62
+ env_file = self.project_root / name
63
+ if env_file.exists():
64
+ key = self._parse_env_file(env_file, "LINEAR_API_KEY")
65
+ if key:
66
+ return key
67
+ return None
68
+
69
+ def _parse_env_file(self, path: Path, key: str) -> Optional[str]:
70
+ with open(path, "r") as f:
71
+ for line in f:
72
+ line = line.strip()
73
+ if line.startswith("#") or "=" not in line:
74
+ continue
75
+ k, _, v = line.partition("=")
76
+ if k.strip() == key:
77
+ v = v.strip().strip("\"'")
78
+ return v if v else None
79
+ return None
80
+
81
+ def query(self, query: str, variables: Optional[dict] = None) -> dict:
82
+ payload = {"query": query}
83
+ if variables:
84
+ payload["variables"] = variables
85
+
86
+ data = json.dumps(payload).encode("utf-8")
87
+ headers = {
88
+ "Content-Type": "application/json",
89
+ "Authorization": self.api_key,
90
+ }
91
+
92
+ for attempt in range(self.MAX_RETRIES):
93
+ try:
94
+ req = urllib.request.Request(self.API_URL, data=data, headers=headers)
95
+ with urllib.request.urlopen(req, timeout=10) as resp:
96
+ result = json.loads(resp.read().decode("utf-8"))
97
+ # Normalize null data to empty dict for safe .get() chaining.
98
+ # Linear returns {"data": null, "errors": [...]} on errors,
99
+ # and .get("data", {}) returns None (key exists, value is null).
100
+ if result.get("data") is None:
101
+ result["data"] = {}
102
+ self._log_operation(query, variables, result)
103
+ return result
104
+ except urllib.error.HTTPError as e:
105
+ if e.code == 429 and attempt < self.MAX_RETRIES - 1:
106
+ delay = self.RETRY_DELAY * (2 ** attempt)
107
+ print(f"Rate limited, retrying in {delay}s...", file=sys.stderr)
108
+ time.sleep(delay)
109
+ continue
110
+ raise
111
+ except (urllib.error.URLError, TimeoutError):
112
+ if attempt < self.MAX_RETRIES - 1:
113
+ delay = self.RETRY_DELAY * (2 ** attempt)
114
+ print(f"Network error, retrying in {delay}s...", file=sys.stderr)
115
+ time.sleep(delay)
116
+ continue
117
+ raise
118
+
119
+ return {"errors": [{"message": "Max retries exceeded"}]}
120
+
121
+ def mutate(self, mutation: str, variables: Optional[dict] = None) -> dict:
122
+ return self.query(mutation, variables)
123
+
124
+ def resolve_issue_id(self, identifier: str) -> Optional[str]:
125
+ """Resolve issue identifier (e.g., ENG-123) to Linear UUID."""
126
+ if "-" in identifier and len(identifier) > 30:
127
+ return identifier
128
+ if identifier in self._id_cache:
129
+ return self._id_cache[identifier]
130
+ result = self.query(
131
+ """query($term: String!) {
132
+ searchIssues(term: $term, first: 1) {
133
+ nodes { id identifier }
134
+ }
135
+ }""",
136
+ {"term": identifier},
137
+ )
138
+ nodes = (result.get("data") or {}).get("searchIssues", {}).get("nodes", [])
139
+ if nodes:
140
+ self._id_cache[identifier] = nodes[0]["id"]
141
+ return nodes[0]["id"]
142
+ return None
143
+
144
+ def resolve_user_id(self, user_query: str) -> tuple[Optional[str], Optional[str]]:
145
+ """Resolve user email/name to (user_id, user_name)."""
146
+ if user_query.lower() == "me":
147
+ result = self.query("query { viewer { id name } }")
148
+ viewer = result.get("data", {}).get("viewer")
149
+ return (viewer["id"], viewer["name"]) if viewer else (None, None)
150
+
151
+ if self._team_members is None:
152
+ result = self.query(
153
+ """query($teamId: String!) {
154
+ team(id: $teamId) {
155
+ members { nodes { id name email } }
156
+ }
157
+ }""",
158
+ {"teamId": self.team_id},
159
+ )
160
+ self._team_members = result.get("data", {}).get("team", {}).get("members", {}).get("nodes", [])
161
+
162
+ for member in self._team_members:
163
+ if user_query.lower() in member.get("email", "").lower() or \
164
+ user_query.lower() in member.get("name", "").lower():
165
+ return member["id"], member["name"]
166
+ return None, None
167
+
168
+ def get_state_id(self, flydocs_status: str) -> Optional[str]:
169
+ linear_state_name = self.status_mapping.get(flydocs_status)
170
+ if not linear_state_name:
171
+ return None
172
+ result = self.query(
173
+ """query($teamId: ID!) {
174
+ workflowStates(filter: { team: { id: { eq: $teamId } } }) {
175
+ nodes { id name }
176
+ }
177
+ }""",
178
+ {"teamId": self.team_id},
179
+ )
180
+ states = result.get("data", {}).get("workflowStates", {}).get("nodes", [])
181
+ for state in states:
182
+ if state["name"].lower() == linear_state_name.lower():
183
+ return state["id"]
184
+ return None
185
+
186
+ def build_product_scope(self, filters: dict) -> dict:
187
+ """Apply product scope to an existing filter dict.
188
+
189
+ Priority cascade:
190
+ 1. activeProjects set → filter to those projects
191
+ 2. product.labelIds set → filter to issues with ALL those labels
192
+ 3. Neither set → no additional filter (team-wide)
193
+ """
194
+ active = self.workspace.get("activeProjects", [])
195
+ if active:
196
+ if len(active) == 1:
197
+ filters["project"] = {"id": {"eq": active[0]}}
198
+ else:
199
+ filters["project"] = {"id": {"in": active}}
200
+ return filters
201
+
202
+ label_ids = self.workspace.get("product", {}).get("labelIds", [])
203
+ if label_ids:
204
+ if len(label_ids) == 1:
205
+ filters["labels"] = {"id": {"eq": label_ids[0]}}
206
+ else:
207
+ # Multiple labels: issue must have ALL — use AND conditions
208
+ label_conditions = [{"labels": {"id": {"eq": lid}}} for lid in label_ids]
209
+ return {"and": [filters, *label_conditions]}
210
+ return filters
211
+
212
+ return filters
213
+
214
+ def get_category_label_id(self, category: str) -> Optional[str]:
215
+ return self.issue_labels.get("category", {}).get(category)
216
+
217
+ def get_other_label_id(self, label: str) -> Optional[str]:
218
+ return self.issue_labels.get("other", {}).get(label)
219
+
220
+ def get_active_cycle(self) -> Optional[dict]:
221
+ if self._active_cycle is not None:
222
+ return self._active_cycle
223
+ result = self.query(
224
+ """query($teamId: ID!) {
225
+ cycles(filter: {
226
+ team: { id: { eq: $teamId } },
227
+ isActive: { eq: true }
228
+ }, first: 1) {
229
+ nodes { id name number startsAt endsAt }
230
+ }
231
+ }""",
232
+ {"teamId": self.team_id},
233
+ )
234
+ cycles = result.get("data", {}).get("cycles", {}).get("nodes", [])
235
+ self._active_cycle = cycles[0] if cycles else None
236
+ return self._active_cycle
237
+
238
+ def _log_operation(self, query: str, variables: Optional[dict], result: dict):
239
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
240
+ entry = {
241
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
242
+ "query_hash": hash(query) % 10000,
243
+ "variables": variables,
244
+ "success": "errors" not in result,
245
+ }
246
+ with open(self.log_path, "a") as f:
247
+ f.write(json.dumps(entry) + "\n")
248
+
249
+
250
+ def find_project_root() -> Path:
251
+ current = Path.cwd()
252
+ while current != current.parent:
253
+ if (current / ".flydocs").is_dir():
254
+ return current
255
+ current = current.parent
256
+ return Path.cwd()
257
+
258
+
259
+ _client: Optional[FlyDocsClient] = None
260
+
261
+
262
+ def get_client() -> FlyDocsClient:
263
+ global _client
264
+ if _client is None:
265
+ _client = FlyDocsClient()
266
+ return _client
267
+
268
+
269
+ def output_json(data: dict | list) -> None:
270
+ """Print JSON to stdout — standard contract output."""
271
+ print(json.dumps(data))
272
+
273
+
274
+ def fail(message: str) -> None:
275
+ """Print error to stderr and exit 1."""
276
+ print(message, file=sys.stderr)
277
+ sys.exit(1)
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env python3
2
+ """Get full details for an issue."""
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ sys.path.insert(0, str(Path(__file__).parent))
9
+ from flydocs_api import get_client, output_json, fail
10
+
11
+ parser = argparse.ArgumentParser(description="Get issue details")
12
+ parser.add_argument("ref", help="Issue reference (e.g., ENG-123)")
13
+ parser.add_argument("--fields", choices=["basic", "full"], default="full",
14
+ help="basic = no comments, full = with comments (default)")
15
+ args = parser.parse_args()
16
+
17
+ client = get_client()
18
+
19
+ issue_uuid = client.resolve_issue_id(args.ref)
20
+ if not issue_uuid:
21
+ fail(f"Issue not found: {args.ref}")
22
+
23
+ if args.fields == "basic":
24
+ query = """query($id: String!) {
25
+ issue(id: $id) {
26
+ id identifier title description
27
+ state { name }
28
+ assignee { name }
29
+ priority estimate dueDate
30
+ project { id name }
31
+ projectMilestone { id name }
32
+ }
33
+ }"""
34
+ else:
35
+ query = """query($id: String!) {
36
+ issue(id: $id) {
37
+ id identifier title description
38
+ state { name }
39
+ assignee { name }
40
+ priority estimate dueDate
41
+ project { id name }
42
+ projectMilestone { id name }
43
+ comments(first: 50) {
44
+ nodes { id body createdAt user { name } }
45
+ }
46
+ }
47
+ }"""
48
+
49
+ result = client.query(query, {"id": issue_uuid})
50
+
51
+ issue = (result.get("data") or {}).get("issue")
52
+ if not issue:
53
+ fail(f"Issue not found: {args.ref}")
54
+
55
+ output = {
56
+ "id": issue["id"],
57
+ "identifier": issue["identifier"],
58
+ "title": issue["title"],
59
+ "description": issue.get("description", ""),
60
+ "status": issue.get("state", {}).get("name", ""),
61
+ "assignee": (issue.get("assignee") or {}).get("name", ""),
62
+ "priority": issue.get("priority", 0),
63
+ "estimate": issue.get("estimate", 0),
64
+ "dueDate": issue.get("dueDate") or "",
65
+ "milestone": (issue.get("projectMilestone") or {}).get("name", ""),
66
+ "milestoneId": (issue.get("projectMilestone") or {}).get("id", ""),
67
+ "project": (issue.get("project") or {}).get("name", ""),
68
+ "projectId": (issue.get("project") or {}).get("id", ""),
69
+ }
70
+
71
+ if args.fields == "full":
72
+ output["comments"] = [
73
+ {"id": c["id"], "body": c["body"], "createdAt": c["createdAt"], "user": c.get("user", {}).get("name", "")}
74
+ for c in issue.get("comments", {}).get("nodes", [])
75
+ ]
76
+
77
+ output_json(output)
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env python3
2
+ """Create a relationship between two issues."""
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ sys.path.insert(0, str(Path(__file__).parent))
8
+ from flydocs_api import get_client, output_json, fail
9
+
10
+ LINK_TYPES = {"blocks": "blocks", "related": "relatedTo", "duplicate": "duplicate"}
11
+
12
+ if len(sys.argv) < 4:
13
+ fail(f"Usage: link.py <ref> <related_ref> <type>\nTypes: {', '.join(LINK_TYPES.keys())}")
14
+
15
+ ref, related_ref, link_type = sys.argv[1], sys.argv[2], sys.argv[3].lower()
16
+ if link_type not in LINK_TYPES:
17
+ fail(f"Invalid type: {link_type}. Valid: {', '.join(LINK_TYPES.keys())}")
18
+
19
+ client = get_client()
20
+
21
+ issue_uuid = client.resolve_issue_id(ref)
22
+ if not issue_uuid:
23
+ fail(f"Issue not found: {ref}")
24
+
25
+ related_uuid = client.resolve_issue_id(related_ref)
26
+ if not related_uuid:
27
+ fail(f"Related issue not found: {related_ref}")
28
+
29
+ # Linear enum values can't be passed as variables
30
+ api_type = LINK_TYPES[link_type]
31
+ result = client.query(
32
+ f"""mutation($issueId: String!, $relatedId: String!) {{
33
+ issueRelationCreate(input: {{
34
+ issueId: $issueId,
35
+ relatedIssueId: $relatedId,
36
+ type: {api_type}
37
+ }}) {{
38
+ success
39
+ }}
40
+ }}""",
41
+ {"issueId": issue_uuid, "relatedId": related_uuid},
42
+ )
43
+
44
+ if not result.get("data", {}).get("issueRelationCreate", {}).get("success"):
45
+ fail(f"Failed to link: {result}")
46
+
47
+ output_json({"success": True, "type": link_type})
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env python3
2
+ """List cycles (sprints)."""
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ sys.path.insert(0, str(Path(__file__).parent))
9
+ from flydocs_api import get_client, output_json, fail
10
+
11
+ parser = argparse.ArgumentParser(description="List cycles")
12
+ parser.add_argument("--active", action="store_true", help="Only active cycle")
13
+ args = parser.parse_args()
14
+
15
+ client = get_client()
16
+
17
+ filters = {"team": {"id": {"eq": client.team_id}}}
18
+ if args.active:
19
+ filters["isActive"] = {"eq": True}
20
+
21
+ result = client.query(
22
+ """query($filter: CycleFilter!) {
23
+ cycles(filter: $filter, first: 10, orderBy: startsAt) {
24
+ nodes { id name number startsAt endsAt }
25
+ }
26
+ }""",
27
+ {"filter": filters},
28
+ )
29
+
30
+ nodes = result.get("data", {}).get("cycles", {}).get("nodes", [])
31
+ cycles = [
32
+ {"id": n["id"], "name": n.get("name", ""), "number": n["number"], "startsAt": n["startsAt"], "endsAt": n["endsAt"]}
33
+ for n in nodes
34
+ ]
35
+ output_json(cycles)