@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.
- package/README.md +96 -0
- package/dist/cli.js +2666 -0
- package/package.json +32 -0
- package/template/.claude/CLAUDE.md +90 -0
- package/template/.claude/agents/README.md +19 -0
- package/template/.claude/agents/implementation-agent.md +29 -0
- package/template/.claude/agents/pm-agent.md +29 -0
- package/template/.claude/agents/research-agent.md +25 -0
- package/template/.claude/agents/review-agent.md +29 -0
- package/template/.claude/commands/activate.md +10 -0
- package/template/.claude/commands/attach.md +9 -0
- package/template/.claude/commands/block.md +10 -0
- package/template/.claude/commands/capture.md +10 -0
- package/template/.claude/commands/close.md +10 -0
- package/template/.claude/commands/flydocs-setup.md +598 -0
- package/template/.claude/commands/flydocs-update.md +27 -0
- package/template/.claude/commands/implement.md +10 -0
- package/template/.claude/commands/new-project.md +11 -0
- package/template/.claude/commands/project-update.md +10 -0
- package/template/.claude/commands/refine.md +10 -0
- package/template/.claude/commands/review.md +10 -0
- package/template/.claude/commands/start-session.md +10 -0
- package/template/.claude/commands/status.md +10 -0
- package/template/.claude/commands/validate.md +10 -0
- package/template/.claude/commands/wrap-session.md +10 -0
- package/template/.claude/settings.json +49 -0
- package/template/.claude/skills/README.md +293 -0
- package/template/.claude/skills/flydocs-cloud/SKILL.md +96 -0
- package/template/.claude/skills/flydocs-cloud/cursor-rule.mdc +50 -0
- package/template/.claude/skills/flydocs-cloud/scripts/assign.py +38 -0
- package/template/.claude/skills/flydocs-cloud/scripts/assign_cycle.py +44 -0
- package/template/.claude/skills/flydocs-cloud/scripts/assign_milestone.py +44 -0
- package/template/.claude/skills/flydocs-cloud/scripts/comment.py +39 -0
- package/template/.claude/skills/flydocs-cloud/scripts/create_issue.py +100 -0
- package/template/.claude/skills/flydocs-cloud/scripts/create_milestone.py +46 -0
- package/template/.claude/skills/flydocs-cloud/scripts/create_project.py +40 -0
- package/template/.claude/skills/flydocs-cloud/scripts/estimate.py +38 -0
- package/template/.claude/skills/flydocs-cloud/scripts/flydocs_api.py +277 -0
- package/template/.claude/skills/flydocs-cloud/scripts/get_issue.py +77 -0
- package/template/.claude/skills/flydocs-cloud/scripts/link.py +47 -0
- package/template/.claude/skills/flydocs-cloud/scripts/list_cycles.py +35 -0
- package/template/.claude/skills/flydocs-cloud/scripts/list_issues.py +105 -0
- package/template/.claude/skills/flydocs-cloud/scripts/list_milestones.py +40 -0
- package/template/.claude/skills/flydocs-cloud/scripts/list_projects.py +45 -0
- package/template/.claude/skills/flydocs-cloud/scripts/priority.py +38 -0
- package/template/.claude/skills/flydocs-cloud/scripts/project_update.py +59 -0
- package/template/.claude/skills/flydocs-cloud/scripts/transition.py +67 -0
- package/template/.claude/skills/flydocs-cloud/scripts/update_description.py +47 -0
- package/template/.claude/skills/flydocs-cloud/scripts/update_issue.py +111 -0
- package/template/.claude/skills/flydocs-context-graph/SKILL.md +87 -0
- package/template/.claude/skills/flydocs-context-graph/schema.md +78 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_build.py +299 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_context.py +338 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_query.py +191 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_session.py +161 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_update.py +194 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_utils.py +118 -0
- package/template/.claude/skills/flydocs-estimates/SKILL.md +384 -0
- package/template/.claude/skills/flydocs-estimates/references/provider-costs.md +152 -0
- package/template/.claude/skills/flydocs-figma/SKILL.md +377 -0
- package/template/.claude/skills/flydocs-figma/references/PROMPTING.md +108 -0
- package/template/.claude/skills/flydocs-figma/references/TROUBLESHOOTING.md +112 -0
- package/template/.claude/skills/flydocs-local/SKILL.md +103 -0
- package/template/.claude/skills/flydocs-local/cursor-rule.mdc +43 -0
- package/template/.claude/skills/flydocs-local/scripts/assign.py +20 -0
- package/template/.claude/skills/flydocs-local/scripts/comment.py +27 -0
- package/template/.claude/skills/flydocs-local/scripts/create_issue.py +44 -0
- package/template/.claude/skills/flydocs-local/scripts/estimate.py +37 -0
- package/template/.claude/skills/flydocs-local/scripts/flydocs_api.py +272 -0
- package/template/.claude/skills/flydocs-local/scripts/get_issue.py +20 -0
- package/template/.claude/skills/flydocs-local/scripts/link.py +41 -0
- package/template/.claude/skills/flydocs-local/scripts/list_issues.py +34 -0
- package/template/.claude/skills/flydocs-local/scripts/priority.py +37 -0
- package/template/.claude/skills/flydocs-local/scripts/project_update.py +67 -0
- package/template/.claude/skills/flydocs-local/scripts/status_summary.py +16 -0
- package/template/.claude/skills/flydocs-local/scripts/transition.py +24 -0
- package/template/.claude/skills/flydocs-local/scripts/update_description.py +35 -0
- package/template/.claude/skills/flydocs-local/scripts/update_issue.py +84 -0
- package/template/.claude/skills/flydocs-workflow/SKILL.md +85 -0
- package/template/.claude/skills/flydocs-workflow/cursor-rule.mdc +53 -0
- package/template/.claude/skills/flydocs-workflow/reference/comment-templates.md +131 -0
- package/template/.claude/skills/flydocs-workflow/reference/golden-rules.md +76 -0
- package/template/.claude/skills/flydocs-workflow/reference/priority-estimates.md +28 -0
- package/template/.claude/skills/flydocs-workflow/reference/status-workflow.md +50 -0
- package/template/.claude/skills/flydocs-workflow/session.md +128 -0
- package/template/.claude/skills/flydocs-workflow/stages/activate.md +46 -0
- package/template/.claude/skills/flydocs-workflow/stages/capture.md +50 -0
- package/template/.claude/skills/flydocs-workflow/stages/close.md +32 -0
- package/template/.claude/skills/flydocs-workflow/stages/implement.md +124 -0
- package/template/.claude/skills/flydocs-workflow/stages/refine.md +51 -0
- package/template/.claude/skills/flydocs-workflow/stages/review.md +86 -0
- package/template/.claude/skills/flydocs-workflow/stages/validate.md +90 -0
- package/template/.claude/skills/flydocs-workflow/templates/bug.md +95 -0
- package/template/.claude/skills/flydocs-workflow/templates/chore.md +75 -0
- package/template/.claude/skills/flydocs-workflow/templates/feature.md +93 -0
- package/template/.claude/skills/flydocs-workflow/templates/idea.md +84 -0
- package/template/.cursor/agents/implementation-agent.md +28 -0
- package/template/.cursor/agents/pm-agent.md +27 -0
- package/template/.cursor/agents/research-agent.md +23 -0
- package/template/.cursor/agents/review-agent.md +27 -0
- package/template/.cursor/hooks.json +29 -0
- package/template/.cursor/mcp.json +16 -0
- package/template/.env.example +44 -0
- package/template/.flydocs/config.json +104 -0
- package/template/.flydocs/hooks/auto-approve.py +71 -0
- package/template/.flydocs/hooks/post-edit.py +72 -0
- package/template/.flydocs/hooks/prefer-scripts.py +89 -0
- package/template/.flydocs/hooks/prompt-submit.py +277 -0
- package/template/.flydocs/scripts/generate_manifest.py +287 -0
- package/template/.flydocs/scripts/skill_manager.py +541 -0
- package/template/.flydocs/templates/README.md +46 -0
- package/template/.flydocs/templates/bug.md +166 -0
- package/template/.flydocs/templates/chore.md +110 -0
- package/template/.flydocs/templates/design-system/README.md +27 -0
- package/template/.flydocs/templates/design-system/component-patterns.md +92 -0
- package/template/.flydocs/templates/design-system/token-mapping.md +168 -0
- package/template/.flydocs/templates/feature.md +173 -0
- package/template/.flydocs/templates/idea.md +122 -0
- package/template/.flydocs/templates/instructions.md +228 -0
- package/template/.flydocs/templates/quick-capture.md +35 -0
- package/template/.flydocs/templates/scripts/check-design-system.template.mjs +179 -0
- package/template/.flydocs/version +1 -0
- package/template/AGENTS.md +95 -0
- package/template/CHANGELOG.md +271 -0
- package/template/flydocs/README.md +186 -0
- package/template/flydocs/context/project.md +51 -0
- package/template/flydocs/design-system/README.md +126 -0
- package/template/flydocs/design-system/component-patterns.md +173 -0
- package/template/flydocs/design-system/token-mapping.md +114 -0
- package/template/flydocs/knowledge/INDEX.md +100 -0
- package/template/flydocs/knowledge/README.md +62 -0
- package/template/flydocs/knowledge/product/personas.md +79 -0
- package/template/flydocs/knowledge/product/user-flows.md +88 -0
- 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()
|