claude-dev-env 1.83.0 → 1.84.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 (165) hide show
  1. package/_shared/pr-loop/audit-contract.md +24 -12
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
  4. package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
  5. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
  6. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
  7. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
  8. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
  9. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
  10. package/agents/clean-coder.md +3 -0
  11. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  12. package/bin/install.mjs +16 -8
  13. package/bin/install.test.mjs +17 -6
  14. package/commands/CLAUDE.md +0 -1
  15. package/docs/CODE_RULES.md +1 -1
  16. package/hooks/blocking/CLAUDE.md +0 -4
  17. package/hooks/blocking/code_rules_constants_config.py +5 -0
  18. package/hooks/blocking/code_rules_docstrings.py +116 -6
  19. package/hooks/blocking/code_rules_enforcer.py +4 -0
  20. package/hooks/blocking/code_rules_magic_values.py +5 -0
  21. package/hooks/blocking/code_rules_naming_collection.py +11 -6
  22. package/hooks/blocking/code_rules_shared.py +34 -0
  23. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
  24. package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
  25. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
  26. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
  27. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
  28. package/hooks/blocking/test_code_rules_magic_values.py +54 -0
  29. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
  30. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
  31. package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
  32. package/hooks/hooks_constants/CLAUDE.md +0 -3
  33. package/hooks/hooks_constants/blocking_check_limits.py +3 -0
  34. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  35. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
  36. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
  37. package/hooks/validation/post_tool_use_dispatcher.py +1 -1
  38. package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
  39. package/hooks/workflow/CLAUDE.md +2 -8
  40. package/package.json +1 -1
  41. package/rules/CLAUDE.md +1 -1
  42. package/rules/plain-illustrative-docstrings.md +35 -1
  43. package/skills/CLAUDE.md +6 -1
  44. package/skills/autoconverge/SKILL.md +38 -57
  45. package/skills/autoconverge/reference/closing-report.md +6 -6
  46. package/skills/autoconverge/reference/convergence.md +17 -15
  47. package/skills/autoconverge/reference/gotchas.md +6 -3
  48. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
  49. package/skills/autoconverge/workflow/converge.contract.test.mjs +87 -13
  50. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +1 -1
  51. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
  52. package/skills/autoconverge/workflow/converge.mjs +70 -73
  53. package/skills/autoconverge/workflow/render_report.py +7 -11
  54. package/skills/bugteam/CLAUDE.md +1 -1
  55. package/skills/bugteam/PROMPTS.md +7 -6
  56. package/skills/bugteam/SKILL.md +25 -72
  57. package/skills/bugteam/reference/CLAUDE.md +1 -3
  58. package/skills/bugteam/reference/README.md +1 -1
  59. package/skills/bugteam/reference/audit-and-teammates.md +1 -1
  60. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
  61. package/skills/bugteam/reference/team-setup.md +8 -7
  62. package/skills/bugteam/scripts/CLAUDE.md +0 -6
  63. package/skills/bugteam/scripts/README.md +0 -4
  64. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
  65. package/skills/code/SKILL.md +2 -0
  66. package/skills/copilot-review/CLAUDE.md +1 -1
  67. package/skills/copilot-review/SKILL.md +25 -23
  68. package/skills/findbugs/CLAUDE.md +2 -2
  69. package/skills/findbugs/SKILL.md +22 -83
  70. package/skills/fixbugs/SKILL.md +2 -4
  71. package/skills/log-audit/CLAUDE.md +20 -0
  72. package/skills/log-audit/SKILL.md +68 -0
  73. package/skills/log-audit/reference/CLAUDE.md +9 -0
  74. package/skills/log-audit/reference/charter.md +52 -0
  75. package/skills/log-audit/scripts/CLAUDE.md +27 -0
  76. package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
  77. package/skills/log-audit/scripts/collect_log_window.py +199 -0
  78. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
  79. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
  80. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
  81. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
  82. package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
  83. package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
  84. package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
  85. package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
  86. package/skills/monitor-open-prs/SKILL.md +2 -2
  87. package/skills/post-audit-findings/SKILL.md +84 -0
  88. package/skills/pr-converge/CLAUDE.md +2 -0
  89. package/skills/pr-converge/SKILL.md +72 -59
  90. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  91. package/skills/pr-converge/reference/convergence-gates.md +16 -19
  92. package/skills/pr-converge/reference/examples.md +5 -5
  93. package/skills/pr-converge/reference/fix-protocol.md +16 -43
  94. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
  95. package/skills/pr-converge/reference/per-tick.md +24 -45
  96. package/skills/pr-converge/reference/state-schema.md +15 -0
  97. package/skills/pr-converge/scripts/README.md +3 -5
  98. package/skills/pr-fix-protocol/SKILL.md +70 -0
  99. package/skills/pr-loop-lifecycle/SKILL.md +73 -0
  100. package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
  101. package/skills/pr-scope-resolve/SKILL.md +48 -0
  102. package/skills/qbug/CLAUDE.md +4 -4
  103. package/skills/qbug/SKILL.md +46 -144
  104. package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
  105. package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
  106. package/skills/reviewer-gates/SKILL.md +96 -0
  107. package/skills/session-log/CLAUDE.md +7 -7
  108. package/skills/session-log/SKILL.md +27 -44
  109. package/skills/test_markdown_link_integrity.py +103 -0
  110. package/commands/doc-gist.md +0 -16
  111. package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
  112. package/hooks/blocking/conftest.py +0 -30
  113. package/hooks/blocking/md_path_exemptions.py +0 -224
  114. package/hooks/blocking/md_to_html_blocker.py +0 -155
  115. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
  116. package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
  117. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
  118. package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
  119. package/hooks/hooks_constants/html_companion_constants.py +0 -20
  120. package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
  121. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
  122. package/hooks/workflow/doc_gist_auto_publish.py +0 -144
  123. package/hooks/workflow/md_to_html_companion.py +0 -358
  124. package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
  125. package/hooks/workflow/test_md_to_html_companion.py +0 -613
  126. package/skills/bugteam/reference/audit-contract.md +0 -163
  127. package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
  128. package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
  129. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
  130. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
  131. package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
  132. package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
  133. package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
  134. package/skills/doc-gist/CLAUDE.md +0 -25
  135. package/skills/doc-gist/SKILL.md +0 -97
  136. package/skills/doc-gist/references/CLAUDE.md +0 -9
  137. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
  138. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
  139. package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
  140. package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
  141. package/skills/doc-gist/references/examples/05-design-system.html +0 -629
  142. package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
  143. package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
  144. package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
  145. package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
  146. package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
  147. package/skills/doc-gist/references/examples/11-status-report.html +0 -528
  148. package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
  149. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
  150. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
  151. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
  152. package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
  153. package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
  154. package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
  155. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
  156. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
  157. package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
  158. package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
  159. package/skills/doc-gist/references/examples/README.md +0 -5
  160. package/skills/doc-gist/scripts/CLAUDE.md +0 -27
  161. package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
  162. package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
  163. package/skills/doc-gist/scripts/gist_upload.py +0 -177
  164. package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
  165. /package/skills/{doc-gist/scripts/doc_gist_scripts_constants → log-audit/scripts/log_audit_constants}/__init__.py +0 -0
@@ -1,144 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PostToolUse hook: auto-publish HTML files marked with the doc-gist sentinel.
3
-
4
- When Claude writes an .html file containing the marker
5
- `<!-- @publish-as-gist -->`, this hook invokes the doc-gist `gist_upload.py`
6
- script against that file and prints the resulting gist + htmlpreview URLs
7
- into the harness output so Claude can quote them back to the user.
8
-
9
- The marker is the on-demand trigger: HTML files without it are ignored. This
10
- keeps the hook silent for HTML that is part of code (React components, test
11
- fixtures, scraped pages) and active only for HTML Claude intentionally
12
- designed as a shareable artifact.
13
-
14
- The hook does not modify the file, does not block the write, and exits 0
15
- even on upload failure (failure is logged to stderr but does not break
16
- Claude's flow).
17
- """
18
-
19
- from __future__ import annotations
20
-
21
- import json
22
- import logging
23
- import subprocess
24
- import sys
25
- from pathlib import Path
26
- from typing import IO
27
-
28
-
29
- logging.basicConfig(stream=sys.stderr, level=logging.WARNING, format="%(message)s")
30
-
31
- _hooks_directory = str(Path(__file__).resolve().parent.parent)
32
- if _hooks_directory not in sys.path:
33
- sys.path.insert(0, _hooks_directory)
34
-
35
- from hooks_constants.doc_gist_auto_publish_constants import ( # noqa: E402
36
- ALL_TARGET_TOOL_NAMES,
37
- HOOK_SUBPROCESS_TIMEOUT_SECONDS,
38
- HTML_FILE_EXTENSION,
39
- PUBLISH_SENTINEL,
40
- UPLOAD_SCRIPT_RELATIVE_PATH,
41
- )
42
-
43
-
44
- def _read_hook_payload() -> dict[str, object]:
45
- """Read the PostToolUse JSON payload from stdin. Empty/invalid payload exits clean."""
46
- try:
47
- payload = json.load(sys.stdin)
48
- except json.JSONDecodeError as decode_error:
49
- logging.warning("doc_gist_auto_publish: invalid JSON payload: %s", decode_error)
50
- sys.exit(0)
51
- if not isinstance(payload, dict):
52
- sys.exit(0)
53
- return payload
54
-
55
-
56
- def _resolve_target_path(payload: dict[str, object]) -> Path | None:
57
- """Extract a writable .html file path from the hook payload, or None to skip."""
58
- if payload.get("tool_name") not in ALL_TARGET_TOOL_NAMES:
59
- return None
60
- tool_input = payload.get("tool_input", {})
61
- if not isinstance(tool_input, dict):
62
- return None
63
- raw_path = tool_input.get("file_path", "")
64
- if not isinstance(raw_path, str) or not raw_path.lower().endswith(HTML_FILE_EXTENSION):
65
- return None
66
- candidate = Path(raw_path)
67
- if not candidate.is_file():
68
- return None
69
- return candidate
70
-
71
-
72
- def _has_publish_sentinel(target_path: Path) -> bool:
73
- """Read the HTML and check for the publish marker."""
74
- try:
75
- contents = target_path.read_text(encoding="utf-8", errors="replace")
76
- except OSError:
77
- logging.warning("doc_gist_auto_publish: cannot read %s", target_path)
78
- return False
79
- return PUBLISH_SENTINEL in contents
80
-
81
-
82
- def _resolve_upload_script() -> Path:
83
- """Locate the gist_upload.py script bundled with the doc-gist skill."""
84
- plugin_root_argument = sys.argv[1] if len(sys.argv) > 1 else None
85
- if plugin_root_argument:
86
- return Path(plugin_root_argument) / UPLOAD_SCRIPT_RELATIVE_PATH
87
- plugin_root_directory = Path(__file__).resolve().parent.parent.parent
88
- return plugin_root_directory / UPLOAD_SCRIPT_RELATIVE_PATH
89
-
90
-
91
- def _invoke_upload(
92
- script_path: Path,
93
- target_path: Path,
94
- out_stream: IO[str],
95
- err_stream: IO[str],
96
- ) -> None:
97
- """Run gist_upload.py against target_path and surface its URLs to the harness."""
98
- try:
99
- completed = subprocess.run(
100
- [sys.executable, str(script_path), "--input", str(target_path), "--no-open"],
101
- check=False,
102
- capture_output=True,
103
- text=True,
104
- encoding="utf-8",
105
- errors="replace",
106
- timeout=HOOK_SUBPROCESS_TIMEOUT_SECONDS,
107
- )
108
- except FileNotFoundError:
109
- logging.warning("doc_gist_auto_publish: gist_upload script not found: %s", script_path)
110
- return
111
- except subprocess.TimeoutExpired:
112
- logging.warning("doc_gist_auto_publish: gist_upload timed out after %ds", HOOK_SUBPROCESS_TIMEOUT_SECONDS)
113
- return
114
- if completed.stderr:
115
- err_stream.write(completed.stderr)
116
- if completed.returncode != 0:
117
- logging.warning("doc_gist_auto_publish: gist_upload exited %d", completed.returncode)
118
- return
119
- if completed.stdout:
120
- out_stream.write(completed.stdout)
121
-
122
-
123
- def main() -> None:
124
- """Entry point — process one PostToolUse event, exit 0 always.
125
-
126
- Returns:
127
- None — exits 0 on success or when no action is needed.
128
- """
129
- payload = _read_hook_payload()
130
- target_path = _resolve_target_path(payload)
131
- if target_path is None:
132
- sys.exit(0)
133
- if not _has_publish_sentinel(target_path):
134
- sys.exit(0)
135
- script_path = _resolve_upload_script()
136
- if not script_path.is_file():
137
- logging.warning("doc_gist_auto_publish: missing %s", script_path)
138
- sys.exit(0)
139
- _invoke_upload(script_path, target_path, sys.stdout, sys.stderr)
140
- sys.exit(0)
141
-
142
-
143
- if __name__ == "__main__":
144
- main()
@@ -1,358 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PostToolUse hook: generates a companion .html from a .md file after write.
3
-
4
- The .md serves as a first draft; the .html is a 2nd-pass refinement with
5
- dark-mode styling that also validates the .md structure.
6
- See https://thariqs.github.io/html-effectiveness/
7
- """
8
-
9
- import json
10
- import logging
11
- import os
12
- import re
13
- import sys
14
- from html import escape
15
- from pathlib import Path
16
- from urllib.parse import urlparse
17
-
18
-
19
- logging.basicConfig(stream=sys.stderr, level=logging.WARNING, format="%(message)s")
20
-
21
- _hook_dir = str(Path(__file__).resolve().parent.parent)
22
- if _hook_dir not in sys.path:
23
- sys.path.insert(0, _hook_dir)
24
-
25
- _blocking_dir = str(Path(__file__).resolve().parent.parent / "blocking")
26
- if _blocking_dir not in sys.path:
27
- sys.path.insert(0, _blocking_dir)
28
-
29
- from hooks_constants.html_companion_constants import ( # noqa: E402
30
- BLOCKED_URL_SCHEMES,
31
- CSS_ACCENT_COLOR,
32
- CSS_BG_COLOR,
33
- CSS_BODY_PADDING,
34
- CSS_BORDER_COLOR,
35
- CSS_CODE_SIZE,
36
- CSS_FG_COLOR,
37
- CSS_H1_SIZE,
38
- CSS_H2_SIZE,
39
- CSS_H3_SIZE,
40
- CSS_LINE_HEIGHT,
41
- CSS_MAX_WIDTH,
42
- CSS_MUTED_COLOR,
43
- CSS_STRONG_COLOR,
44
- CSS_SURFACE_COLOR,
45
- CSS_TABLE_WIDTH,
46
- CSS_TH_WEIGHT,
47
- )
48
- from md_path_exemptions import is_exempt_path # noqa: E402
49
-
50
-
51
- def _md_to_html(markdown_text: str) -> str:
52
- text = markdown_text.strip()
53
- if not text:
54
- return ""
55
-
56
- all_lines = text.split("\n")
57
- all_output_lines: list[str] = []
58
- is_in_code_block = False
59
- is_in_unordered_list = False
60
- is_in_ordered_list = False
61
- paragraph_buffer: list[str] = []
62
-
63
- def flush_paragraph() -> None:
64
- if paragraph_buffer:
65
- all_output_lines.append("<p>" + "\n".join(paragraph_buffer) + "</p>")
66
- paragraph_buffer.clear()
67
-
68
- def close_lists() -> None:
69
- nonlocal is_in_unordered_list, is_in_ordered_list
70
- if is_in_unordered_list:
71
- all_output_lines.append("</ul>")
72
- is_in_unordered_list = False
73
- if is_in_ordered_list:
74
- all_output_lines.append("</ol>")
75
- is_in_ordered_list = False
76
-
77
- for each_line in all_lines:
78
- if each_line.startswith("```"):
79
- if is_in_code_block:
80
- all_output_lines.append("</code></pre>")
81
- is_in_code_block = False
82
- else:
83
- flush_paragraph()
84
- close_lists()
85
- language = each_line[3:].strip()
86
- if language and re.match(r"^[A-Za-z0-9_+#-]+$", language):
87
- all_output_lines.append(
88
- f'<pre><code class="language-{language}">'
89
- )
90
- else:
91
- all_output_lines.append("<pre><code>")
92
- is_in_code_block = True
93
- continue
94
-
95
- if is_in_code_block:
96
- all_output_lines.append(escape(each_line))
97
- continue
98
-
99
- stripped = each_line.strip()
100
- if not stripped:
101
- flush_paragraph()
102
- close_lists()
103
- continue
104
-
105
- if stripped.startswith("# "):
106
- flush_paragraph()
107
- close_lists()
108
- all_output_lines.append(f"<h1>{_inline_format(stripped[2:])}</h1>")
109
- elif stripped.startswith("## "):
110
- flush_paragraph()
111
- close_lists()
112
- all_output_lines.append(f"<h2>{_inline_format(stripped[3:])}</h2>")
113
- elif stripped.startswith("### "):
114
- flush_paragraph()
115
- close_lists()
116
- all_output_lines.append(f"<h3>{_inline_format(stripped[4:])}</h3>")
117
- elif stripped.startswith("#### "):
118
- flush_paragraph()
119
- close_lists()
120
- all_output_lines.append(f"<h4>{_inline_format(stripped[5:])}</h4>")
121
- elif stripped.startswith("##### "):
122
- flush_paragraph()
123
- close_lists()
124
- all_output_lines.append(f"<h5>{_inline_format(stripped[6:])}</h5>")
125
- elif stripped.startswith("###### "):
126
- flush_paragraph()
127
- close_lists()
128
- all_output_lines.append(f"<h6>{_inline_format(stripped[7:])}</h6>")
129
- elif stripped.startswith("- ") or stripped.startswith("* "):
130
- flush_paragraph()
131
- if is_in_ordered_list:
132
- all_output_lines.append("</ol>")
133
- is_in_ordered_list = False
134
- if not is_in_unordered_list:
135
- all_output_lines.append("<ul>")
136
- is_in_unordered_list = True
137
- all_output_lines.append(f"<li>{_inline_format(stripped[2:])}</li>")
138
- elif re.match(r"^\d+\.\s", stripped):
139
- flush_paragraph()
140
- if is_in_unordered_list:
141
- all_output_lines.append("</ul>")
142
- is_in_unordered_list = False
143
- if not is_in_ordered_list:
144
- all_output_lines.append("<ol>")
145
- is_in_ordered_list = True
146
- content = re.sub(r"^\d+\.\s", "", stripped)
147
- all_output_lines.append(f"<li>{_inline_format(content)}</li>")
148
- elif stripped == "---":
149
- flush_paragraph()
150
- close_lists()
151
- all_output_lines.append("<hr>")
152
- elif stripped.startswith("> "):
153
- flush_paragraph()
154
- close_lists()
155
- all_output_lines.append(
156
- f"<blockquote>{_inline_format(stripped[2:])}</blockquote>"
157
- )
158
- else:
159
- paragraph_buffer.append(_inline_format(stripped))
160
-
161
- if is_in_code_block:
162
- all_output_lines.append("</code></pre>")
163
- close_lists()
164
- flush_paragraph()
165
-
166
- return "\n".join(all_output_lines)
167
-
168
-
169
- def _placeholder(
170
- match: re.Match, storage: list, open_tag: str, close_tag: str
171
- ) -> str:
172
- placeholder = f"\x00CODE{len(storage)}\x00"
173
- storage.append((placeholder, f"{open_tag}{match.group(1)}{close_tag}"))
174
- return placeholder
175
-
176
-
177
- def _inline_format(text: str) -> str:
178
- text = escape(text)
179
-
180
- code_placeholders: list[tuple[str, str]] = []
181
- text = re.sub(
182
- r"`([^`]+)`",
183
- lambda m: _placeholder(m, code_placeholders, "<code>", "</code>"),
184
- text,
185
- )
186
-
187
- link_placeholders: list[tuple[str, str, str]] = []
188
-
189
- def _save_link(match: re.Match) -> str:
190
- link_text = match.group("text")
191
- url = match.group("url").strip()
192
- parsed_url = urlparse(url)
193
- if parsed_url.scheme.lower() in BLOCKED_URL_SCHEMES:
194
- url = ""
195
- placeholder = f"\x00LINK{len(link_placeholders)}\x00"
196
- link_placeholders.append((placeholder, url, link_text))
197
- return placeholder
198
-
199
- text = re.sub(
200
- r"\[(?P<text>[^\]]+)\]\((?P<url>(?:[^)(]+|\([^)(]*\))*)\)",
201
- _save_link,
202
- text,
203
- )
204
-
205
- text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
206
- text = re.sub(r"\*(.+?)\*", r"<em>\1</em>", text)
207
-
208
- for placeholder, url, link_text in link_placeholders:
209
- if url == "":
210
- text = text.replace(placeholder, link_text)
211
- else:
212
- text = text.replace(placeholder, f'<a href="{url}">{link_text}</a>')
213
- for placeholder, content in code_placeholders:
214
- text = text.replace(placeholder, content)
215
- return text
216
-
217
-
218
- def _extract_title(markdown_text: str) -> str:
219
- for each_line in markdown_text.strip().split("\n"):
220
- stripped = each_line.strip()
221
- if stripped.startswith("# "):
222
- return stripped[2:].strip()
223
- return "Document"
224
-
225
-
226
- def _html_template(title: str, body: str) -> str:
227
- return """<!DOCTYPE html>
228
- <html lang="en">
229
- <head>
230
- <meta charset="UTF-8">
231
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
232
- <title>{title}</title>
233
- <style>
234
- :root {{ color-scheme: dark; }}
235
- * {{ box-sizing: border-box; margin: 0; padding: 0; }}
236
- body {{
237
- font-family: system-ui, -apple-system, sans-serif;
238
- background: rgb({bg}); color: rgb({fg}); line-height: {line_height};
239
- padding: {body_padding}; max-width: {max_width}; margin: 0 auto;
240
- }}
241
- h1 {{ font-size: {h1_size}; border-bottom: 1px solid rgb({border}); padding-bottom: 0.5rem; margin: 1.5rem 0 1rem; }}
242
- h2 {{ font-size: {h2_size}; margin: 1.25rem 0 0.75rem; color: rgb({accent}); }}
243
- h3 {{ font-size: {h3_size}; margin: 1rem 0 0.5rem; }}
244
- p {{ margin: 0.75rem 0; }}
245
- a {{ color: rgb({accent}); }}
246
- code {{
247
- font-family: 'Cascadia Code', 'Fira Code', monospace; font-size: {code_size};
248
- background: rgb({surface}); padding: 0.15em 0.35em; border-radius: 3px;
249
- }}
250
- pre {{
251
- background: rgb({surface}); border: 1px solid rgb({border});
252
- border-radius: 6px; padding: 1rem; overflow-x: auto; margin: 0.75rem 0;
253
- }}
254
- pre code {{ background: none; padding: 0; }}
255
- ul, ol {{ padding-left: 1.5rem; margin: 0.5rem 0; }}
256
- li {{ margin: 0.25rem 0; }}
257
- strong {{ color: rgb({strong}); }}
258
- table {{ width: {table_width}; border-collapse: collapse; margin: 0.75rem 0; }}
259
- th, td {{ padding: 0.5rem 0.75rem; text-align: left; border: 1px solid rgb({border}); }}
260
- th {{ background: rgb({surface}); font-weight: {th_weight}; }}
261
- hr {{ border: none; border-top: 1px solid rgb({border}); margin: 1.5rem 0; }}
262
- blockquote {{
263
- border-left: 3px solid rgb({accent}); padding-left: 1rem;
264
- color: rgb({muted}); margin: 0.75rem 0;
265
- }}
266
- </style>
267
- </head>
268
- <body>
269
- {body}
270
- </body>
271
- </html>""".format(
272
- title=title,
273
- body=body,
274
- bg=CSS_BG_COLOR,
275
- fg=CSS_FG_COLOR,
276
- border=CSS_BORDER_COLOR,
277
- accent=CSS_ACCENT_COLOR,
278
- muted=CSS_MUTED_COLOR,
279
- surface=CSS_SURFACE_COLOR,
280
- strong=CSS_STRONG_COLOR,
281
- line_height=CSS_LINE_HEIGHT,
282
- body_padding=CSS_BODY_PADDING,
283
- max_width=CSS_MAX_WIDTH,
284
- h1_size=CSS_H1_SIZE,
285
- h2_size=CSS_H2_SIZE,
286
- h3_size=CSS_H3_SIZE,
287
- code_size=CSS_CODE_SIZE,
288
- table_width=CSS_TABLE_WIDTH,
289
- th_weight=CSS_TH_WEIGHT,
290
- )
291
-
292
-
293
- def main() -> None:
294
- """Process a PostToolUse hook payload from stdin.
295
-
296
- Reads JSON with tool_name and tool_input.file_path from stdin.
297
- When file_path is a .md file outside .claude/, generates a companion
298
- .html file alongside it.
299
-
300
- Args:
301
- (reads from sys.stdin — no function arguments)
302
-
303
- Returns:
304
- None — exits 0 on success or when no action is needed.
305
- """
306
- try:
307
- input_data = json.load(sys.stdin)
308
- except json.JSONDecodeError:
309
- sys.exit(0)
310
-
311
- if not isinstance(input_data, dict):
312
- sys.exit(0)
313
-
314
- tool_name = input_data.get("tool_name", "")
315
- if tool_name not in ("Write", "Edit"):
316
- sys.exit(0)
317
-
318
- tool_input = input_data.get("tool_input", {})
319
- if not isinstance(tool_input, dict):
320
- sys.exit(0)
321
-
322
- file_path = tool_input.get("file_path", "")
323
- if not file_path or not file_path.lower().endswith(".md"):
324
- sys.exit(0)
325
-
326
- if is_exempt_path(file_path):
327
- sys.exit(0)
328
-
329
- if not os.path.exists(file_path):
330
- sys.exit(0)
331
-
332
- try:
333
- with open(file_path, encoding="utf-8") as f:
334
- md_content = f.read()
335
- except OSError:
336
- sys.exit(0)
337
-
338
- if not md_content.strip():
339
- sys.exit(0)
340
-
341
- title = escape(_extract_title(md_content))
342
- html_body = _md_to_html(md_content)
343
- html_content = _html_template(title=title, body=html_body)
344
-
345
- html_path = os.path.splitext(file_path)[0] + ".html"
346
- try:
347
- with open(html_path, "w", encoding="utf-8") as f:
348
- f.write(html_content)
349
- except OSError:
350
- logging.warning(
351
- "md_to_html_companion: failed to write %s", html_path
352
- )
353
-
354
- sys.exit(0)
355
-
356
-
357
- if __name__ == "__main__":
358
- main()
@@ -1,117 +0,0 @@
1
- """Tests for doc_gist_auto_publish hook.
2
-
3
- Validates payload parsing, target-path resolution, sentinel detection,
4
- and the always-exit-0 contract without needing `gh` authentication.
5
- """
6
-
7
- import json
8
- import os
9
- import subprocess
10
- import sys
11
- import tempfile
12
- from pathlib import Path
13
-
14
-
15
- HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "doc_gist_auto_publish.py")
16
- SENTINEL = "<!-- @publish-as-gist -->"
17
-
18
-
19
- STUB_PREVIEW_URL = "https://htmlpreview.github.io/?stub"
20
-
21
-
22
- class _RunHook:
23
- def __call__(self, tool_name: str, tool_input: dict) -> subprocess.CompletedProcess:
24
- payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
25
- with tempfile.TemporaryDirectory() as isolation_root:
26
- stub_dir = Path(isolation_root) / "skills" / "doc-gist" / "scripts"
27
- stub_dir.mkdir(parents=True)
28
- (stub_dir / "gist_upload.py").write_text(
29
- f"import sys; print({STUB_PREVIEW_URL!r}); "
30
- f"print('Gist: stub', file=sys.stderr); "
31
- f"print('Preview: stub', file=sys.stderr)",
32
- encoding="utf-8",
33
- )
34
- return subprocess.run(
35
- [sys.executable, HOOK_SCRIPT_PATH, isolation_root],
36
- input=payload,
37
- capture_output=True,
38
- text=True,
39
- check=False,
40
- )
41
-
42
-
43
- _run_hook = _RunHook()
44
-
45
-
46
- def test_exits_zero_on_invalid_json():
47
- result = subprocess.run(
48
- [sys.executable, HOOK_SCRIPT_PATH],
49
- input="not json",
50
- capture_output=True,
51
- text=True,
52
- check=False,
53
- )
54
- assert result.returncode == 0
55
- assert "invalid json" in result.stderr.lower()
56
-
57
-
58
- def test_exits_zero_on_non_dict_payload():
59
- result = subprocess.run(
60
- [sys.executable, HOOK_SCRIPT_PATH],
61
- input="[]",
62
- capture_output=True,
63
- text=True,
64
- check=False,
65
- )
66
- assert result.returncode == 0
67
- assert result.stderr == ""
68
-
69
-
70
- def test_skips_non_html_files():
71
- with tempfile.TemporaryDirectory() as tmp:
72
- txt_path = os.path.join(tmp, "notes.txt")
73
- with open(txt_path, "w", encoding="utf-8") as f:
74
- f.write("plain text")
75
-
76
- result = _run_hook("Write", {"file_path": txt_path, "content": "plain text"})
77
- assert result.returncode == 0
78
- assert result.stderr == ""
79
-
80
-
81
- def test_skips_html_without_sentinel():
82
- with tempfile.TemporaryDirectory() as tmp:
83
- html_path = os.path.join(tmp, "page.html")
84
- with open(html_path, "w", encoding="utf-8") as f:
85
- f.write("<html><body>No marker</body></html>")
86
-
87
- result = _run_hook("Write", {"file_path": html_path, "content": "<html>"})
88
- assert result.returncode == 0
89
- assert result.stderr == ""
90
-
91
-
92
- def test_exits_zero_when_html_has_sentinel():
93
- with tempfile.TemporaryDirectory() as tmp:
94
- html_path = os.path.join(tmp, "report.html")
95
- with open(html_path, "w", encoding="utf-8") as f:
96
- f.write(f"<html>{SENTINEL}<body>Report</body></html>")
97
-
98
- result = _run_hook("Write", {"file_path": html_path, "content": "<html>"})
99
- assert result.returncode == 0
100
- assert STUB_PREVIEW_URL in result.stdout
101
-
102
-
103
- def test_skips_non_write_tool():
104
- with tempfile.TemporaryDirectory() as tmp:
105
- html_path = os.path.join(tmp, "page.html")
106
- with open(html_path, "w", encoding="utf-8") as f:
107
- f.write(f"<html>{SENTINEL}</html>")
108
-
109
- result = _run_hook("Read", {"file_path": html_path})
110
- assert result.returncode == 0
111
- assert result.stderr == ""
112
-
113
-
114
- def test_exits_zero_when_file_missing():
115
- result = _run_hook("Write", {"file_path": "/nonexistent/path/page.html"})
116
- assert result.returncode == 0
117
- assert result.stderr == ""