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.
- package/_shared/pr-loop/audit-contract.md +24 -12
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
- package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
- package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
- package/agents/clean-coder.md +3 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/bin/install.mjs +16 -8
- package/bin/install.test.mjs +17 -6
- package/commands/CLAUDE.md +0 -1
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +0 -4
- package/hooks/blocking/code_rules_constants_config.py +5 -0
- package/hooks/blocking/code_rules_docstrings.py +116 -6
- package/hooks/blocking/code_rules_enforcer.py +4 -0
- package/hooks/blocking/code_rules_magic_values.py +5 -0
- package/hooks/blocking/code_rules_naming_collection.py +11 -6
- package/hooks/blocking/code_rules_shared.py +34 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
- package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
- package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
- package/hooks/blocking/test_code_rules_magic_values.py +54 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
- package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
- package/hooks/hooks_constants/CLAUDE.md +0 -3
- package/hooks/hooks_constants/blocking_check_limits.py +3 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
- package/hooks/validation/post_tool_use_dispatcher.py +1 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
- package/hooks/workflow/CLAUDE.md +2 -8
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -1
- package/rules/plain-illustrative-docstrings.md +35 -1
- package/skills/CLAUDE.md +6 -1
- package/skills/autoconverge/SKILL.md +38 -57
- package/skills/autoconverge/reference/closing-report.md +6 -6
- package/skills/autoconverge/reference/convergence.md +17 -15
- package/skills/autoconverge/reference/gotchas.md +6 -3
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
- package/skills/autoconverge/workflow/converge.contract.test.mjs +87 -13
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
- package/skills/autoconverge/workflow/converge.mjs +70 -73
- package/skills/autoconverge/workflow/render_report.py +7 -11
- package/skills/bugteam/CLAUDE.md +1 -1
- package/skills/bugteam/PROMPTS.md +7 -6
- package/skills/bugteam/SKILL.md +25 -72
- package/skills/bugteam/reference/CLAUDE.md +1 -3
- package/skills/bugteam/reference/README.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +1 -1
- package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
- package/skills/bugteam/reference/team-setup.md +8 -7
- package/skills/bugteam/scripts/CLAUDE.md +0 -6
- package/skills/bugteam/scripts/README.md +0 -4
- package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
- package/skills/code/SKILL.md +2 -0
- package/skills/copilot-review/CLAUDE.md +1 -1
- package/skills/copilot-review/SKILL.md +25 -23
- package/skills/findbugs/CLAUDE.md +2 -2
- package/skills/findbugs/SKILL.md +22 -83
- package/skills/fixbugs/SKILL.md +2 -4
- package/skills/log-audit/CLAUDE.md +20 -0
- package/skills/log-audit/SKILL.md +68 -0
- package/skills/log-audit/reference/CLAUDE.md +9 -0
- package/skills/log-audit/reference/charter.md +52 -0
- package/skills/log-audit/scripts/CLAUDE.md +27 -0
- package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
- package/skills/log-audit/scripts/collect_log_window.py +199 -0
- package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
- package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
- package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
- package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
- package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
- package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
- package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
- package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
- package/skills/monitor-open-prs/SKILL.md +2 -2
- package/skills/post-audit-findings/SKILL.md +84 -0
- package/skills/pr-converge/CLAUDE.md +2 -0
- package/skills/pr-converge/SKILL.md +72 -59
- package/skills/pr-converge/reference/CLAUDE.md +1 -1
- package/skills/pr-converge/reference/convergence-gates.md +16 -19
- package/skills/pr-converge/reference/examples.md +5 -5
- package/skills/pr-converge/reference/fix-protocol.md +16 -43
- package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
- package/skills/pr-converge/reference/per-tick.md +24 -45
- package/skills/pr-converge/reference/state-schema.md +15 -0
- package/skills/pr-converge/scripts/README.md +3 -5
- package/skills/pr-fix-protocol/SKILL.md +70 -0
- package/skills/pr-loop-lifecycle/SKILL.md +73 -0
- package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
- package/skills/pr-scope-resolve/SKILL.md +48 -0
- package/skills/qbug/CLAUDE.md +4 -4
- package/skills/qbug/SKILL.md +46 -144
- package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
- package/skills/reviewer-gates/SKILL.md +96 -0
- package/skills/session-log/CLAUDE.md +7 -7
- package/skills/session-log/SKILL.md +27 -44
- package/skills/test_markdown_link_integrity.py +103 -0
- package/commands/doc-gist.md +0 -16
- package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
- package/hooks/blocking/conftest.py +0 -30
- package/hooks/blocking/md_path_exemptions.py +0 -224
- package/hooks/blocking/md_to_html_blocker.py +0 -155
- package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
- package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
- package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
- package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
- package/hooks/hooks_constants/html_companion_constants.py +0 -20
- package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
- package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
- package/hooks/workflow/doc_gist_auto_publish.py +0 -144
- package/hooks/workflow/md_to_html_companion.py +0 -358
- package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
- package/hooks/workflow/test_md_to_html_companion.py +0 -613
- package/skills/bugteam/reference/audit-contract.md +0 -163
- package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
- package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
- package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
- package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
- package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
- package/skills/doc-gist/CLAUDE.md +0 -25
- package/skills/doc-gist/SKILL.md +0 -97
- package/skills/doc-gist/references/CLAUDE.md +0 -9
- package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
- package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
- package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
- package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
- package/skills/doc-gist/references/examples/05-design-system.html +0 -629
- package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
- package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
- package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
- package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
- package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
- package/skills/doc-gist/references/examples/11-status-report.html +0 -528
- package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
- package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
- package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
- package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
- package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
- package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
- package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
- package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
- package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
- package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
- package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
- package/skills/doc-gist/references/examples/README.md +0 -5
- package/skills/doc-gist/scripts/CLAUDE.md +0 -27
- package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
- package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
- package/skills/doc-gist/scripts/gist_upload.py +0 -177
- package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
- /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 == ""
|