@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,89 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PreToolUse Hook: Guide AI to prefer scripts over MCP
4
+
5
+ Triggers before mcp__linear tool calls and provides guidance
6
+ to use the bundled Python scripts instead.
7
+
8
+ Soft guidance — doesn't block the MCP call, but reminds the AI
9
+ that scripts exist and are preferred.
10
+
11
+ Exit codes:
12
+ - 0 with JSON: Continue with guidance message
13
+ - 0 with no output: No opinion, continue normally
14
+ - 2: Block (not used)
15
+ """
16
+
17
+ import sys
18
+ import json
19
+
20
+
21
+ # Mapping of MCP operations to equivalent mechanism scripts
22
+ SCRIPT_ALTERNATIVES = {
23
+ "create_issue": "create_issue.py --title '...' --type feature",
24
+ "update_issue": "transition.py ISSUE_ID STATUS 'comment'",
25
+ "get_issue": "get_issue.py ISSUE_ID",
26
+ "list_issues": "list_issues.py --status STATUS",
27
+ "create_comment": "comment.py ISSUE_ID 'message'",
28
+ "update_issue_state": "transition.py ISSUE_ID STATUS 'comment'",
29
+ "list_projects": "list_projects.py --active",
30
+ "create_project": "create_project.py --name '...'",
31
+ "list_workflow_states": "list_states.py",
32
+ "list_labels": "list_labels.py",
33
+ "assign_issue": "assign.py ISSUE_ID 'email'",
34
+ "set_issue_priority": "priority.py ISSUE_ID LEVEL",
35
+ "set_issue_estimate": "estimate.py ISSUE_ID POINTS",
36
+ }
37
+
38
+
39
+ def get_script_suggestion(tool_name: str) -> str | None:
40
+ """Get the equivalent script for an MCP operation."""
41
+ operation = tool_name.replace("mcp__linear__", "").replace("mcp__linear.", "")
42
+ return SCRIPT_ALTERNATIVES.get(operation)
43
+
44
+
45
+ def main():
46
+ try:
47
+ input_data = json.load(sys.stdin)
48
+ except (json.JSONDecodeError, EOFError):
49
+ sys.exit(0)
50
+
51
+ tool_name = input_data.get('tool_name', '')
52
+
53
+ if not tool_name.startswith('mcp__linear'):
54
+ # No opinion — exit 0 with no output to avoid hook error
55
+ sys.exit(0)
56
+
57
+ script_suggestion = get_script_suggestion(tool_name)
58
+
59
+ if script_suggestion:
60
+ guidance = {
61
+ "decision": "continue",
62
+ "message": f"""**Reminder:** FlyDocs prefers Python scripts over MCP for issue operations.
63
+
64
+ Equivalent script (in the active mechanism skill):
65
+ ```bash
66
+ python3 .claude/skills/flydocs-cloud/scripts/{script_suggestion}
67
+ ```
68
+
69
+ Scripts are:
70
+ - Auto-approved (no permission prompt)
71
+ - Config-aware (reads team ID, status mappings)
72
+ - Pattern-enforced (mandatory comments on transitions)
73
+
74
+ You may continue with MCP if the script doesn't cover your use case."""
75
+ }
76
+ else:
77
+ guidance = {
78
+ "decision": "continue",
79
+ "message": """**Reminder:** Check if the active mechanism skill has a script for this operation.
80
+ Read the mechanism skill's SKILL.md for the full script catalog.
81
+ Scripts are preferred over MCP for performance and auto-approval."""
82
+ }
83
+
84
+ print(json.dumps(guidance))
85
+ sys.exit(0)
86
+
87
+
88
+ if __name__ == "__main__":
89
+ main()
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ FlyDocs Hook: prompt-submit.py
4
+ Triggered: When user submits a prompt
5
+ Purpose: Inject context and validate workflow state
6
+
7
+ Exit codes:
8
+ 0 - Success (plain text output adds context to conversation)
9
+ 2 - Block prompt (stderr shown as reason)
10
+
11
+ NOTE: Uses plain text output instead of JSON due to Claude Code bug (Issue #13912)
12
+ where JSON output from UserPromptSubmit hooks causes "hook error" despite documentation.
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+
22
+
23
+ DEBUG_HOOK = os.environ.get('DEBUG_HOOK', '0') == '1'
24
+ SCRIPT_DIR = Path(__file__).parent.resolve()
25
+ DEBUG_LOG = SCRIPT_DIR.parent / 'logs' / 'hook-debug.log'
26
+
27
+
28
+ def debug_log(message: str) -> None:
29
+ """Write debug message to log file if DEBUG_HOOK is enabled."""
30
+ if not DEBUG_HOOK:
31
+ return
32
+ try:
33
+ DEBUG_LOG.parent.mkdir(parents=True, exist_ok=True)
34
+ from datetime import datetime
35
+ timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
36
+ with open(DEBUG_LOG, 'a') as f:
37
+ f.write(f'[{timestamp}] {message}\n')
38
+ except (OSError, IOError):
39
+ pass
40
+
41
+
42
+ def get_git_context() -> str | None:
43
+ """Get git branch and uncommitted status."""
44
+ try:
45
+ # Check if in git repo
46
+ result = subprocess.run(
47
+ ['git', 'rev-parse', '--is-inside-work-tree'],
48
+ capture_output=True,
49
+ text=True,
50
+ timeout=5
51
+ )
52
+ if result.returncode != 0:
53
+ return None
54
+
55
+ # Get branch name
56
+ result = subprocess.run(
57
+ ['git', 'branch', '--show-current'],
58
+ capture_output=True,
59
+ text=True,
60
+ timeout=5
61
+ )
62
+ branch = result.stdout.strip() or 'detached'
63
+
64
+ # Check for uncommitted changes
65
+ uncommitted = 'no'
66
+ result = subprocess.run(['git', 'diff', '--quiet'], capture_output=True, timeout=5)
67
+ if result.returncode != 0:
68
+ uncommitted = 'yes'
69
+ result = subprocess.run(['git', 'diff', '--cached', '--quiet'], capture_output=True, timeout=5)
70
+ if result.returncode != 0:
71
+ uncommitted = 'yes'
72
+
73
+ return f'Git: branch={branch}, uncommitted={uncommitted}'
74
+ except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
75
+ return None
76
+
77
+
78
+ def get_issue_context() -> tuple[str | None, str | None]:
79
+ """Get active issue and status from session files."""
80
+ focus_file = Path('.flydocs/session/focus.md')
81
+ status_file = Path('.flydocs/session/status')
82
+
83
+ issue_id = None
84
+ status = None
85
+
86
+ if focus_file.exists():
87
+ try:
88
+ content = focus_file.read_text()
89
+ match = re.search(r'[A-Z]+-[0-9]+', content)
90
+ if match:
91
+ issue_id = match.group(0)
92
+ except (OSError, IOError):
93
+ pass
94
+
95
+ if status_file.exists():
96
+ try:
97
+ status = status_file.read_text().strip()
98
+ except (OSError, IOError):
99
+ pass
100
+
101
+ return issue_id, status
102
+
103
+
104
+ def get_ac_progress() -> str | None:
105
+ """Get acceptance criteria progress."""
106
+ ac_file = Path('.flydocs/session/acceptance-criteria.md')
107
+ if not ac_file.exists():
108
+ return None
109
+
110
+ try:
111
+ content = ac_file.read_text()
112
+ total = len(re.findall(r'^\s*-\s*\[', content, re.MULTILINE))
113
+ done = len(re.findall(r'^\s*-\s*\[x\]', content, re.MULTILINE | re.IGNORECASE))
114
+ if total > 0:
115
+ return f'AC: {done}/{total} complete'
116
+ except (OSError, IOError):
117
+ pass
118
+
119
+ return None
120
+
121
+
122
+ def get_workflow_reminder(status: str) -> str | None:
123
+ """Get workflow reminder based on current status."""
124
+ reminders = {
125
+ 'IMPLEMENTING': '[Reminder: Log progress with comments, run tests before REVIEW]',
126
+ 'REVIEW': '[Reminder: Check acceptance criteria before approving]',
127
+ 'TESTING': '[Reminder: Validate all AC met before marking COMPLETE]'
128
+ }
129
+ return reminders.get(status)
130
+
131
+
132
+ def get_graph_context(issue_id: str | None, branch: str | None) -> str | None:
133
+ """Get context graph output for the current session."""
134
+ graph_script = Path('.claude/skills/flydocs-context-graph/scripts/graph_context.py')
135
+ if not graph_script.exists():
136
+ return None
137
+
138
+ try:
139
+ cmd = ['python3', str(graph_script)]
140
+ if issue_id:
141
+ cmd.extend(['--issue', issue_id])
142
+ if branch:
143
+ cmd.extend(['--branch', branch])
144
+
145
+ result = subprocess.run(
146
+ cmd,
147
+ capture_output=True,
148
+ text=True,
149
+ timeout=5
150
+ )
151
+ if result.returncode == 0 and result.stdout.strip():
152
+ return result.stdout.strip()
153
+ except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
154
+ pass
155
+
156
+ return None
157
+
158
+
159
+ def get_flydocs_version() -> str | None:
160
+ """Get FlyDocs version."""
161
+ version_file = Path('.flydocs/version')
162
+ if version_file.exists():
163
+ try:
164
+ return version_file.read_text().strip()
165
+ except (OSError, IOError):
166
+ pass
167
+ return None
168
+
169
+
170
+ def get_setup_nudge() -> str | None:
171
+ """Check if setup has been completed, return nudge if not."""
172
+ config_file = Path('.flydocs/config.json')
173
+ if not config_file.exists():
174
+ return None
175
+ try:
176
+ config = json.loads(config_file.read_text())
177
+ # Only nudge if field is explicitly set to false (not missing)
178
+ if 'setupComplete' in config and config['setupComplete'] is False:
179
+ return '[Setup incomplete — run /flydocs-setup to configure your project]'
180
+ except (json.JSONDecodeError, OSError, IOError):
181
+ pass
182
+ return None
183
+
184
+
185
+ def main() -> None:
186
+ """Main hook execution."""
187
+ debug_log('=== Hook invoked ===')
188
+ debug_log(f'PWD: {os.getcwd()}')
189
+ debug_log(f'SCRIPT_DIR: {SCRIPT_DIR}')
190
+ debug_log(f'CLAUDE_PROJECT_DIR: {os.environ.get("CLAUDE_PROJECT_DIR", "<not set>")}')
191
+
192
+ # Read hook input from stdin
193
+ try:
194
+ input_data = json.loads(sys.stdin.read())
195
+ except (json.JSONDecodeError, ValueError):
196
+ input_data = {}
197
+
198
+ prompt = input_data.get('prompt', '')
199
+ session_id = input_data.get('session_id', '')
200
+ cwd = input_data.get('cwd', '')
201
+
202
+ debug_log(f'Parsed PROMPT: {prompt[:100]}...' if prompt else 'Parsed PROMPT: <empty>')
203
+ debug_log(f'Parsed SESSION_ID: {session_id}')
204
+ debug_log(f'Parsed CWD: {cwd}')
205
+
206
+ # Change to working directory
207
+ debug_log('Attempting to change directory...')
208
+ if cwd and Path(cwd).is_dir():
209
+ debug_log(f'Using CWD from input: {cwd}')
210
+ os.chdir(cwd)
211
+ elif os.environ.get('CLAUDE_PROJECT_DIR') and Path(os.environ['CLAUDE_PROJECT_DIR']).is_dir():
212
+ debug_log(f'Using CLAUDE_PROJECT_DIR: {os.environ["CLAUDE_PROJECT_DIR"]}')
213
+ os.chdir(os.environ['CLAUDE_PROJECT_DIR'])
214
+ else:
215
+ debug_log(f'No valid directory to change to, staying in: {os.getcwd()}')
216
+
217
+ debug_log(f'Now in directory: {os.getcwd()}')
218
+
219
+ # Build context parts
220
+ context_parts = []
221
+
222
+ # Git context
223
+ git_context = get_git_context()
224
+ branch = None
225
+ if git_context:
226
+ debug_log(f'Git context: {git_context}')
227
+ context_parts.append(git_context)
228
+ # Extract branch name for graph context
229
+ branch_match = re.search(r'branch=(\S+)', git_context)
230
+ if branch_match:
231
+ branch = branch_match.group(1).rstrip(',')
232
+
233
+ # Issue and status context
234
+ issue_id, status = get_issue_context()
235
+ if issue_id:
236
+ context_parts.append(f'Active issue: {issue_id}')
237
+ if status:
238
+ context_parts.append(f'Status: {status}')
239
+ reminder = get_workflow_reminder(status)
240
+ if reminder:
241
+ context_parts.append(reminder)
242
+
243
+ # AC progress
244
+ ac_progress = get_ac_progress()
245
+ if ac_progress:
246
+ context_parts.append(ac_progress)
247
+
248
+ # Setup completion nudge
249
+ setup_nudge = get_setup_nudge()
250
+ if setup_nudge:
251
+ context_parts.append(setup_nudge)
252
+
253
+ # FlyDocs version
254
+ version = get_flydocs_version()
255
+ if version:
256
+ context_parts.append(f'FlyDocs: {version}')
257
+
258
+ # Output status line
259
+ context = ' | '.join(context_parts)
260
+ debug_log(f'Final CONTEXT: {context}')
261
+
262
+ if context:
263
+ debug_log(f'Outputting plain text context: {context}')
264
+ print(context)
265
+
266
+ # Graph context (appended as separate block below status line)
267
+ graph_context = get_graph_context(issue_id, branch)
268
+ if graph_context:
269
+ debug_log(f'Graph context: {graph_context[:100]}...')
270
+ print(graph_context)
271
+
272
+ debug_log('=== Hook completed successfully ===')
273
+ sys.exit(0)
274
+
275
+
276
+ if __name__ == '__main__':
277
+ main()
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env python3
2
+ """Generate a compressed skills manifest and inject it into CLAUDE.md and AGENTS.md.
3
+
4
+ Scans .claude/skills/*/SKILL.md for YAML frontmatter, extracts name/description/triggers,
5
+ and produces a markdown table injected between manifest markers.
6
+
7
+ Usage:
8
+ python3 .flydocs/scripts/generate_manifest.py [--root PATH] [--dry-run]
9
+
10
+ Options:
11
+ --root PATH Project root (default: current working directory)
12
+ --dry-run Print manifest to stdout without writing files
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import re
19
+ import sys
20
+
21
+
22
+ MARKER_START = "<!-- flydocs:skills-manifest:start -->"
23
+ MARKER_END = "<!-- flydocs:skills-manifest:end -->"
24
+
25
+ TARGET_FILES = [
26
+ os.path.join(".claude", "CLAUDE.md"),
27
+ "AGENTS.md",
28
+ ]
29
+
30
+
31
+ def parse_frontmatter(text):
32
+ """Parse YAML frontmatter from a SKILL.md file.
33
+
34
+ Handles simple key: value, block/folded scalars (| and >), and lists (- item).
35
+ No PyYAML dependency required.
36
+ """
37
+ match = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
38
+ if not match:
39
+ return None
40
+
41
+ block = match.group(1)
42
+ result = {}
43
+ current_key = None
44
+ current_mode = None # "scalar", "block", "list"
45
+ current_lines = []
46
+
47
+ for line in block.split("\n"):
48
+ # Check for a new top-level key
49
+ key_match = re.match(r"^(\w[\w-]*):\s*(.*)", line)
50
+ if key_match and not line.startswith(" "):
51
+ # Flush previous key
52
+ if current_key is not None:
53
+ result[current_key] = _flush(current_mode, current_lines)
54
+
55
+ current_key = key_match.group(1)
56
+ value = key_match.group(2).strip()
57
+
58
+ if value in ("|", ">"):
59
+ current_mode = "block"
60
+ current_lines = []
61
+ elif value == "":
62
+ # Could be a list or block starting next line
63
+ current_mode = "list"
64
+ current_lines = []
65
+ else:
66
+ # Simple inline value — strip quotes
67
+ current_mode = "scalar"
68
+ current_lines = [value.strip("\"'")]
69
+ continue
70
+
71
+ # Continuation lines (indented)
72
+ stripped = line.strip()
73
+ if current_key is not None:
74
+ if current_mode == "list" and stripped.startswith("- "):
75
+ current_lines.append(stripped[2:].strip().strip("\"'"))
76
+ elif current_mode == "block":
77
+ current_lines.append(line.lstrip())
78
+
79
+ # Flush last key
80
+ if current_key is not None:
81
+ result[current_key] = _flush(current_mode, current_lines)
82
+
83
+ return result
84
+
85
+
86
+ def _flush(mode, lines):
87
+ """Convert accumulated lines into a final value."""
88
+ if mode == "scalar":
89
+ return lines[0] if lines else ""
90
+ elif mode == "list":
91
+ return lines
92
+ elif mode == "block":
93
+ return "\n".join(lines).strip()
94
+ return ""
95
+
96
+
97
+ def scan_skills(root):
98
+ """Scan .claude/skills/*/SKILL.md and return parsed skill metadata."""
99
+ skills_dir = os.path.join(root, ".claude", "skills")
100
+ if not os.path.isdir(skills_dir):
101
+ print(f"Skills directory not found: {skills_dir}", file=sys.stderr)
102
+ return []
103
+
104
+ skills = []
105
+ for entry in sorted(os.listdir(skills_dir)):
106
+ skill_file = os.path.join(skills_dir, entry, "SKILL.md")
107
+ if not os.path.isfile(skill_file):
108
+ continue
109
+
110
+ with open(skill_file, "r", encoding="utf-8") as f:
111
+ content = f.read()
112
+
113
+ fm = parse_frontmatter(content)
114
+ if fm is None:
115
+ continue
116
+
117
+ name = fm.get("name", entry)
118
+ description = fm.get("description", "")
119
+ triggers = fm.get("triggers", [])
120
+
121
+ if isinstance(triggers, str):
122
+ triggers = [t.strip() for t in triggers.split(",") if t.strip()]
123
+
124
+ if not triggers:
125
+ continue
126
+
127
+ skills.append({
128
+ "name": name,
129
+ "description": description,
130
+ "triggers": triggers,
131
+ "entry": os.path.join(".claude", "skills", entry, "SKILL.md"),
132
+ })
133
+
134
+ return skills
135
+
136
+
137
+ def load_skill_ordering(root):
138
+ """Load PRECEDES edges from the context graph to derive skill ordering.
139
+
140
+ Returns a dict mapping skill name -> list of skill names it loads after.
141
+ """
142
+ graph_path = os.path.join(root, "flydocs", "context", "graph.json")
143
+ if not os.path.isfile(graph_path):
144
+ return {}
145
+
146
+ try:
147
+ with open(graph_path, "r", encoding="utf-8") as f:
148
+ graph = json.load(f)
149
+ except (json.JSONDecodeError, OSError):
150
+ return {}
151
+
152
+ # Build loads_after map from PRECEDES edges
153
+ # PRECEDES means: from loads before to (from PRECEDES to)
154
+ loads_after = {}
155
+ for edge in graph.get("edges", []):
156
+ if edge.get("rel") != "PRECEDES":
157
+ continue
158
+ src = edge["from"]
159
+ dst = edge["to"]
160
+ # Both must be skill nodes
161
+ if not src.startswith("skill:") or not dst.startswith("skill:"):
162
+ continue
163
+ dep_name = src.split(":", 1)[1]
164
+ skill_name = dst.split(":", 1)[1]
165
+ if skill_name not in loads_after:
166
+ loads_after[skill_name] = []
167
+ loads_after[skill_name].append(dep_name)
168
+
169
+ return loads_after
170
+
171
+
172
+ def build_manifest(skills, loads_after=None):
173
+ """Build the manifest markdown block.
174
+
175
+ If loads_after is provided and non-empty, includes a "Loads After" column.
176
+ """
177
+ if loads_after is None:
178
+ loads_after = {}
179
+
180
+ has_ordering = any(
181
+ skill["name"] in loads_after for skill in skills
182
+ )
183
+
184
+ if has_ordering:
185
+ lines = [
186
+ MARKER_START,
187
+ "## Skills Index",
188
+ "",
189
+ "IMPORTANT: Prefer skill-led reasoning over pre-training reasoning.",
190
+ "Consult the relevant skill BEFORE writing code or making workflow decisions.",
191
+ "",
192
+ "| Skill | Triggers | Entry | Loads After |",
193
+ "|-------|----------|-------|-------------|",
194
+ ]
195
+
196
+ for skill in skills:
197
+ trigger_str = ", ".join(skill["triggers"])
198
+ deps = loads_after.get(skill["name"], [])
199
+ deps_str = ", ".join(deps) if deps else "\u2014"
200
+ lines.append(
201
+ f"| {skill['name']} | {trigger_str} | {skill['entry']} | {deps_str} |"
202
+ )
203
+ else:
204
+ lines = [
205
+ MARKER_START,
206
+ "## Skills Index",
207
+ "",
208
+ "IMPORTANT: Prefer skill-led reasoning over pre-training reasoning.",
209
+ "Consult the relevant skill BEFORE writing code or making workflow decisions.",
210
+ "",
211
+ "| Skill | Triggers | Entry |",
212
+ "|-------|----------|-------|",
213
+ ]
214
+
215
+ for skill in skills:
216
+ trigger_str = ", ".join(skill["triggers"])
217
+ lines.append(f"| {skill['name']} | {trigger_str} | {skill['entry']} |")
218
+
219
+ lines.append(MARKER_END)
220
+ return "\n".join(lines)
221
+
222
+
223
+ def inject_manifest(file_path, manifest):
224
+ """Inject manifest between markers in a file.
225
+
226
+ If markers exist, replaces content between them.
227
+ If markers don't exist, appends to end of file.
228
+ Returns True if the file was modified.
229
+ """
230
+ if not os.path.isfile(file_path):
231
+ return False
232
+
233
+ with open(file_path, "r", encoding="utf-8") as f:
234
+ content = f.read()
235
+
236
+ pattern = re.compile(
237
+ re.escape(MARKER_START) + r".*?" + re.escape(MARKER_END),
238
+ re.DOTALL,
239
+ )
240
+
241
+ if pattern.search(content):
242
+ new_content = pattern.sub(manifest, content)
243
+ else:
244
+ # Append with spacing
245
+ new_content = content.rstrip("\n") + "\n\n" + manifest + "\n"
246
+
247
+ if new_content == content:
248
+ return False
249
+
250
+ with open(file_path, "w", encoding="utf-8") as f:
251
+ f.write(new_content)
252
+
253
+ return True
254
+
255
+
256
+ def main():
257
+ parser = argparse.ArgumentParser(description="Generate FlyDocs skill manifest")
258
+ parser.add_argument("--root", default=os.getcwd(), help="Project root directory")
259
+ parser.add_argument("--dry-run", action="store_true", help="Print manifest without writing")
260
+ args = parser.parse_args()
261
+
262
+ root = os.path.abspath(args.root)
263
+ skills = scan_skills(root)
264
+
265
+ if not skills:
266
+ print("No skills with triggers found.", file=sys.stderr)
267
+ sys.exit(1)
268
+
269
+ loads_after = load_skill_ordering(root)
270
+ manifest = build_manifest(skills, loads_after)
271
+
272
+ if args.dry_run:
273
+ print(manifest)
274
+ return
275
+
276
+ for target in TARGET_FILES:
277
+ file_path = os.path.join(root, target)
278
+ if inject_manifest(file_path, manifest):
279
+ print(f"Updated: {target}")
280
+ elif os.path.isfile(file_path):
281
+ print(f"No change: {target}")
282
+ else:
283
+ print(f"Not found: {target}")
284
+
285
+
286
+ if __name__ == "__main__":
287
+ main()