@windyroad/style-guide 0.2.0 → 0.2.1-preview.77

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 ADDED
@@ -0,0 +1,57 @@
1
+ # @windyroad/style-guide
2
+
3
+ **Style guide enforcement for Claude Code.** Reviews CSS and UI component changes against your design system before they're applied.
4
+
5
+ Part of [Windy Road Agent Plugins](../../README.md).
6
+
7
+ ## What It Does
8
+
9
+ AI agents generate CSS that works but doesn't match your design system. They pick arbitrary colours, invent spacing values, and ignore your component patterns. This plugin catches that.
10
+
11
+ The style-guide plugin:
12
+
13
+ 1. **Detects** when an edit touches CSS, style tokens, or visual styling
14
+ 2. **Blocks** the edit until the style-guide agent has reviewed it
15
+ 3. **Reviews** the proposed changes against your `docs/STYLE-GUIDE.md`
16
+ 4. **Reports** violations with suggested fixes
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npx @windyroad/style-guide
22
+ ```
23
+
24
+ Restart Claude Code after installing.
25
+
26
+ ## Usage
27
+
28
+ The plugin works automatically. On first use in a project without a style guide, it blocks edits and directs you to create one:
29
+
30
+ ```
31
+ /wr-style-guide:update-guide
32
+ ```
33
+
34
+ This examines your existing CSS, components, and design patterns, then asks about your style preferences to generate a `docs/STYLE-GUIDE.md`.
35
+
36
+ ## How It Works
37
+
38
+ | Hook | Trigger | What it does |
39
+ |------|---------|-------------|
40
+ | `style-guide-eval.sh` | Every prompt | Evaluates whether the task involves visual styling |
41
+ | `style-guide-enforce-edit.sh` | Edit or Write | Blocks edits until the style-guide agent has reviewed |
42
+ | `style-guide-mark-reviewed.sh` | Agent completes | Marks the review as done (TTL: 1800s) |
43
+
44
+ ## Agent
45
+
46
+ The `wr-style-guide:agent` reads your `docs/STYLE-GUIDE.md` and reviews proposed changes against your documented design system.
47
+
48
+ ## Updating and Uninstalling
49
+
50
+ ```bash
51
+ npx @windyroad/style-guide --update
52
+ npx @windyroad/style-guide --uninstall
53
+ ```
54
+
55
+ ## Licence
56
+
57
+ [MIT](../../LICENSE)
package/bin/install.mjs CHANGED
@@ -4,7 +4,7 @@ import { resolve, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
- const utils = await import(resolve(__dirname, "../../shared/install-utils.mjs"));
7
+ const utils = await import(resolve(__dirname, "../lib/install-utils.mjs"));
8
8
 
9
9
  const PLUGIN = "wr-style-guide";
10
10
  const DEPS = [];
@@ -20,6 +20,7 @@ Style guide enforcement for CSS and UI components
20
20
  Options:
21
21
  --update Update this plugin and its skills
22
22
  --uninstall Remove this plugin
23
+ --scope Installation scope: project (default) or user
23
24
  --dry-run Show what would be done without executing
24
25
  --help, -h Show this help
25
26
  `);
@@ -36,7 +37,7 @@ utils.checkPrerequisites();
36
37
  if (flags.uninstall) {
37
38
  utils.uninstallPackage(PLUGIN);
38
39
  } else if (flags.update) {
39
- utils.updatePackage(PLUGIN);
40
+ utils.updatePackage(PLUGIN, { scope: flags.scope });
40
41
  } else {
41
- utils.installPackage(PLUGIN, { deps: DEPS });
42
+ utils.installPackage(PLUGIN, { deps: DEPS, scope: flags.scope });
42
43
  }
package/hooks/hooks.json CHANGED
@@ -8,9 +8,6 @@
8
8
  ],
9
9
  "PostToolUse": [
10
10
  { "matcher": "Agent", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/style-guide-mark-reviewed.sh" }] }
11
- ],
12
- "Stop": [
13
- { "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/style-guide-reset-marker.sh" }] }
14
11
  ]
15
12
  }
16
13
  }
@@ -0,0 +1,174 @@
1
+ #!/bin/bash
2
+ # Shared portable helpers for gate enforcement hooks.
3
+ # Sourced by architect-gate.sh, risk-gate.sh, and all hook scripts.
4
+ # Provides: _mtime, _hashcmd, _doc_exclusions, _err_trap, _get_*
5
+
6
+ # ---------------------------------------------------------------------------
7
+ # Portable utilities
8
+ # ---------------------------------------------------------------------------
9
+
10
+ # Portable mtime: tries GNU stat, falls back to macOS stat
11
+ _mtime() { stat -c%Y "$1" 2>/dev/null || /usr/bin/stat -f%m "$1" 2>/dev/null || echo 0; }
12
+
13
+ # Portable hash: tries md5sum, falls back to md5 -r, then shasum
14
+ _hashcmd() { md5sum 2>/dev/null || md5 -r 2>/dev/null || shasum 2>/dev/null; }
15
+
16
+ # Paths excluded from pipeline state hashing and docs-only detection.
17
+ _doc_exclusions() {
18
+ echo ':!docs/' ':!.risk-reports/' ':!.changeset/' ':!governance/' ':!.claude/plans/' ':!CLAUDE.md' ':!AGENTS.md' ':!PRINCIPLES.md' ':!DECISION-MANAGEMENT.md' ':!AGENTIC_RISK_REGISTER.md' ':!PROBLEM-MANAGEMENT.md'
19
+ }
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # ERR trap: outputs diagnostic JSON on hook errors (P010)
23
+ # Usage: source gate-helpers.sh at top of hook, then call _enable_err_trap
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _enable_err_trap() {
27
+ trap '_err_trap_handler "$BASH_SOURCE" "$LINENO" "$BASH_COMMAND"' ERR
28
+ }
29
+
30
+ _err_trap_handler() {
31
+ local script="$1" line="$2" cmd="$3"
32
+ local name
33
+ name=$(basename "$script" 2>/dev/null || echo "$script")
34
+ # Output diagnostic as systemMessage so it's visible in conversation
35
+ cat <<EOF
36
+ {
37
+ "systemMessage": "Hook error in ${name} at line ${line}: ${cmd}"
38
+ }
39
+ EOF
40
+ }
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # JSON input parsing: standardised helpers replacing inline python3
44
+ # Each reads from _HOOK_INPUT (set by the hook before calling these)
45
+ # ---------------------------------------------------------------------------
46
+
47
+ # Store hook input for reuse by parsing helpers
48
+ _HOOK_INPUT=""
49
+
50
+ _parse_input() {
51
+ _HOOK_INPUT=$(cat)
52
+ }
53
+
54
+ _get_tool_name() {
55
+ echo "$_HOOK_INPUT" | python3 -c "
56
+ import sys, json
57
+ try:
58
+ data = json.load(sys.stdin)
59
+ print(data.get('tool_name', ''))
60
+ except:
61
+ print('')
62
+ " 2>/dev/null || echo ""
63
+ }
64
+
65
+ _get_session_id() {
66
+ echo "$_HOOK_INPUT" | python3 -c "
67
+ import sys, json
68
+ try:
69
+ data = json.load(sys.stdin)
70
+ print(data.get('session_id', ''))
71
+ except:
72
+ print('')
73
+ " 2>/dev/null || echo ""
74
+ }
75
+
76
+ _get_command() {
77
+ echo "$_HOOK_INPUT" | python3 -c "
78
+ import sys, json
79
+ try:
80
+ data = json.load(sys.stdin)
81
+ print(data.get('tool_input', {}).get('command', ''))
82
+ except:
83
+ print('')
84
+ " 2>/dev/null || echo ""
85
+ }
86
+
87
+ _get_file_path() {
88
+ echo "$_HOOK_INPUT" | python3 -c "
89
+ import sys, json
90
+ try:
91
+ data = json.load(sys.stdin)
92
+ ti = data.get('tool_input', {})
93
+ print(ti.get('file_path', ti.get('path', '')))
94
+ except:
95
+ print('')
96
+ " 2>/dev/null || echo ""
97
+ }
98
+
99
+ _get_subagent_type() {
100
+ echo "$_HOOK_INPUT" | python3 -c "
101
+ import sys, json
102
+ try:
103
+ data = json.load(sys.stdin)
104
+ print(data.get('tool_input', {}).get('subagent_type', ''))
105
+ except:
106
+ print('')
107
+ " 2>/dev/null || echo ""
108
+ }
109
+
110
+ _get_user_prompt() {
111
+ echo "$_HOOK_INPUT" | python3 -c "
112
+ import sys, json
113
+ try:
114
+ data = json.load(sys.stdin)
115
+ print(data.get('user_prompt', ''))
116
+ except:
117
+ print('')
118
+ " 2>/dev/null || echo ""
119
+ }
120
+
121
+ _get_tool_output() {
122
+ echo "$_HOOK_INPUT" | python3 -c "
123
+ import sys, json
124
+ try:
125
+ data = json.load(sys.stdin)
126
+ # PostToolUse provides tool_response (dict with content array), not tool_output
127
+ tr = data.get('tool_response', {})
128
+ if isinstance(tr, dict):
129
+ content = tr.get('content', [])
130
+ if isinstance(content, list):
131
+ texts = [c.get('text', '') for c in content if isinstance(c, dict) and c.get('type') == 'text']
132
+ if texts:
133
+ print('\n'.join(texts))
134
+ sys.exit(0)
135
+ # Fallback for older/different hook formats
136
+ print(data.get('tool_output', ''))
137
+ except:
138
+ print('')
139
+ " 2>/dev/null || echo ""
140
+ }
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Session-scoped tmp directory for risk files
144
+ # ---------------------------------------------------------------------------
145
+
146
+ # Returns the session-scoped directory for risk temp files.
147
+ # Creates the directory if it doesn't exist.
148
+ # Usage: DIR=$(_risk_dir "$SESSION_ID"); echo "1" > "$DIR/commit"
149
+ _risk_dir() {
150
+ local sid="$1"
151
+ local dir="${TMPDIR:-/tmp}/claude-risk-${sid}"
152
+ mkdir -p "$dir"
153
+ echo "$dir"
154
+ }
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Non-doc file detection for WIP gating
158
+ # ---------------------------------------------------------------------------
159
+
160
+ _is_doc_file() {
161
+ local file_path="$1"
162
+ local EXCL
163
+ EXCL=$(_doc_exclusions)
164
+ for pattern in $EXCL; do
165
+ local clean="${pattern#:!}"
166
+ case "$file_path" in
167
+ *"$clean"*) return 0 ;;
168
+ esac
169
+ done
170
+ case "$file_path" in
171
+ *.claude/*|*.risk-reports/*|*RISK-POLICY.md) return 0 ;;
172
+ esac
173
+ return 1
174
+ }
@@ -16,7 +16,7 @@ check_review_gate() {
16
16
  local POLICY_FILE="$3" # e.g., "docs/STYLE-GUIDE.md"
17
17
  local MARKER="/tmp/${SYSTEM}-reviewed-${SESSION_ID}"
18
18
  local HASH_FILE="/tmp/${SYSTEM}-reviewed-${SESSION_ID}.hash"
19
- local TTL_SECONDS="${REVIEW_TTL:-600}"
19
+ local TTL_SECONDS="${REVIEW_TTL:-1800}"
20
20
 
21
21
  # 1. Marker must exist
22
22
  if [ ! -f "$MARKER" ]; then
@@ -35,6 +35,16 @@ if [ -z "$FILE_PATH" ]; then
35
35
  exit 0
36
36
  fi
37
37
 
38
+ # P004: Only gate files inside the project root.
39
+ case "$FILE_PATH" in
40
+ /*)
41
+ case "$FILE_PATH" in
42
+ "$PWD"/*) ;;
43
+ *) exit 0 ;;
44
+ esac
45
+ ;;
46
+ esac
47
+
38
48
  # Gate all UI source files (CSS and component files)
39
49
  case "$FILE_PATH" in
40
50
  *.css|*.html|*.jsx|*.tsx|*.vue|*.svelte|*.ejs|*.hbs) ;;
@@ -45,7 +55,7 @@ BASENAME=$(basename "$FILE_PATH")
45
55
 
46
56
  # If no policy file exists, block and direct to create skill
47
57
  if [ ! -f "docs/STYLE-GUIDE.md" ]; then
48
- review_gate_deny "BLOCKED: Cannot edit '${BASENAME}' because docs/STYLE-GUIDE.md does not exist. Run /wr-style-guide:create to generate a style guide for this project, then delegate to wr-style-guide:agent for review."
58
+ review_gate_deny "BLOCKED: Cannot edit '${BASENAME}' because docs/STYLE-GUIDE.md does not exist. Run /wr-style-guide:update-guide to generate a style guide for this project, then delegate to wr-style-guide:agent for review."
49
59
  exit 0
50
60
  fi
51
61
 
@@ -28,7 +28,7 @@ else
28
28
  cat <<'HOOK_OUTPUT'
29
29
  NOTE: This project has UI files but no docs/STYLE-GUIDE.md.
30
30
  If the user's task involves editing CSS or UI components, the edit will be blocked
31
- until a style guide exists. Run /wr-style-guide:create to generate one.
31
+ until a style guide exists. Run /wr-style-guide:update-guide to generate one.
32
32
  HOOK_OUTPUT
33
33
  fi
34
34
  fi
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # P001 / ADR-009: Stop-hook marker reset removed.
4
+
5
+ setup() {
6
+ PLUGIN_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
7
+ }
8
+
9
+ @test "style-guide: hooks.json has no Stop hook entry (ADR-009)" {
10
+ ! grep -q '"Stop"' "$PLUGIN_DIR/hooks/hooks.json"
11
+ }
12
+
13
+ @test "style-guide: style-guide-reset-marker.sh has been removed" {
14
+ [ ! -f "$PLUGIN_DIR/hooks/style-guide-reset-marker.sh" ]
15
+ }
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # P004: style-guide-enforce-edit.sh project-root check.
4
+
5
+ setup() {
6
+ SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
7
+ HOOK="$SCRIPT_DIR/style-guide-enforce-edit.sh"
8
+ }
9
+
10
+ run_hook_with_file() {
11
+ local file_path="$1"
12
+ local json="{\"tool_input\":{\"file_path\":\"${file_path}\"},\"session_id\":\"test-$$\"}"
13
+ echo "$json" | bash "$HOOK"
14
+ }
15
+
16
+ @test "style-guide project-root: absolute .css outside project exits 0" {
17
+ run run_hook_with_file "/Users/other/project/src/style.css"
18
+ [ "$status" -eq 0 ]
19
+ [[ "$output" != *"BLOCKED"* ]]
20
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Shared install utilities for @windyroad/* packages.
3
+ * Used by both per-plugin installers and the meta-installer.
4
+ */
5
+
6
+ import { execSync } from "node:child_process";
7
+
8
+ const MARKETPLACE_REPO = "windyroad/agent-plugins";
9
+ const MARKETPLACE_NAME = "windyroad";
10
+
11
+ let _dryRun = false;
12
+
13
+ export { MARKETPLACE_REPO, MARKETPLACE_NAME };
14
+
15
+ export function setDryRun(value) {
16
+ _dryRun = value;
17
+ }
18
+
19
+ export function isDryRun() {
20
+ return _dryRun;
21
+ }
22
+
23
+ export function run(cmd, label) {
24
+ console.log(` ${label}...`);
25
+ if (_dryRun) {
26
+ console.log(` [dry-run] ${cmd}`);
27
+ return true;
28
+ }
29
+ try {
30
+ execSync(cmd, { stdio: "inherit" });
31
+ return true;
32
+ } catch {
33
+ console.error(` FAILED: ${label}`);
34
+ return false;
35
+ }
36
+ }
37
+
38
+ export function checkPrerequisites() {
39
+ if (_dryRun) return;
40
+
41
+ try {
42
+ execSync("claude --version", { stdio: "pipe" });
43
+ } catch {
44
+ console.error(
45
+ "Error: 'claude' CLI not found. Install Claude Code first:\n https://docs.anthropic.com/en/docs/claude-code\n"
46
+ );
47
+ process.exit(1);
48
+ }
49
+ }
50
+
51
+ export function addMarketplace() {
52
+ return run(
53
+ `claude plugin marketplace add ${MARKETPLACE_REPO}`,
54
+ `Marketplace: ${MARKETPLACE_NAME}`
55
+ );
56
+ }
57
+
58
+ export function installPlugin(pluginName, { scope = "project" } = {}) {
59
+ return run(
60
+ `claude plugin install ${pluginName}@${MARKETPLACE_NAME} --scope ${scope}`,
61
+ pluginName
62
+ );
63
+ }
64
+
65
+ export function updatePlugin(pluginName, { scope = "project" } = {}) {
66
+ return run(
67
+ `claude plugin update "${pluginName}@${MARKETPLACE_NAME}" --scope ${scope}`,
68
+ pluginName
69
+ );
70
+ }
71
+
72
+ export function uninstallPlugin(pluginName) {
73
+ return run(`claude plugin uninstall ${pluginName}`, `Removing ${pluginName}`);
74
+ }
75
+
76
+ /**
77
+ * Install a single package: marketplace add + plugin install.
78
+ */
79
+ export function installPackage(pluginName, { deps = [], scope = "project" } = {}) {
80
+ console.log(`\nInstalling @windyroad/${pluginName.replace("wr-", "")} (${scope} scope)...\n`);
81
+
82
+ addMarketplace();
83
+ installPlugin(pluginName, { scope });
84
+
85
+ if (deps.length > 0) {
86
+ console.log(`\nNote: This plugin works best with:`);
87
+ for (const dep of deps) {
88
+ console.log(` - @windyroad/${dep.replace("wr-", "")} (npx @windyroad/${dep.replace("wr-", "")})`);
89
+ }
90
+ }
91
+
92
+ console.log(
93
+ `\nDone! Restart Claude Code to activate.\n`
94
+ );
95
+ }
96
+
97
+ /**
98
+ * Update a single package.
99
+ */
100
+ export function updatePackage(pluginName, { scope = "project" } = {}) {
101
+ console.log(`\nUpdating @windyroad/${pluginName.replace("wr-", "")}...\n`);
102
+
103
+ run(
104
+ `claude plugin marketplace update ${MARKETPLACE_NAME}`,
105
+ "Updating marketplace"
106
+ );
107
+ updatePlugin(pluginName, { scope });
108
+
109
+ console.log("\nDone! Restart Claude Code to apply updates.\n");
110
+ }
111
+
112
+ /**
113
+ * Uninstall a single package.
114
+ */
115
+ export function uninstallPackage(pluginName) {
116
+ console.log(`\nUninstalling @windyroad/${pluginName.replace("wr-", "")}...\n`);
117
+
118
+ uninstallPlugin(pluginName);
119
+
120
+ console.log("\nDone. Restart Claude Code to apply changes.\n");
121
+ }
122
+
123
+ /**
124
+ * Parse standard flags used by all per-plugin installers.
125
+ */
126
+ export function parseStandardArgs(argv) {
127
+ const args = argv.slice(2);
128
+ const flags = {
129
+ help: args.includes("--help") || args.includes("-h"),
130
+ uninstall: args.includes("--uninstall"),
131
+ update: args.includes("--update"),
132
+ dryRun: args.includes("--dry-run"),
133
+ scope: "project",
134
+ };
135
+ const scopeIdx = args.indexOf("--scope");
136
+ if (scopeIdx !== -1 && args[scopeIdx + 1]) {
137
+ const val = args[scopeIdx + 1];
138
+ if (["project", "user", "local"].includes(val)) {
139
+ flags.scope = val;
140
+ } else {
141
+ console.error("--scope requires: project, user, or local");
142
+ process.exit(1);
143
+ }
144
+ }
145
+ return flags;
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/style-guide",
3
- "version": "0.2.0",
3
+ "version": "0.2.1-preview.77",
4
4
  "description": "Style guide enforcement for CSS and UI components",
5
5
  "bin": {
6
6
  "windyroad-style-guide": "./bin/install.mjs"
@@ -23,6 +23,7 @@
23
23
  "agents/",
24
24
  "hooks/",
25
25
  "skills/",
26
- ".claude-plugin/"
26
+ ".claude-plugin/",
27
+ "lib/"
27
28
  ]
28
29
  }
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: wr:style-guide
2
+ name: wr-style-guide:update-guide
3
3
  description: Create or update the project's docs/STYLE-GUIDE.md by examining existing CSS, components, and design patterns, then asking the user about style preferences.
4
4
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
5
5
  ---
@@ -1,22 +0,0 @@
1
- #!/bin/bash
2
- # Style Guide - Stop hook
3
- # Removes the session marker so the next prompt requires a fresh style review.
4
- # This tightens the gate from per-session to per-turn.
5
-
6
- INPUT=$(cat)
7
-
8
- SESSION_ID=$(echo "$INPUT" | python3 -c "
9
- import sys, json
10
- data = json.load(sys.stdin)
11
- print(data.get('session_id', ''))
12
- " 2>/dev/null)
13
-
14
- if [ -n "$SESSION_ID" ]; then
15
- rm -f "/tmp/style-guide-reviewed-${SESSION_ID}" \
16
- "/tmp/style-guide-reviewed-${SESSION_ID}.hash" \
17
- "/tmp/style-guide-verdict" \
18
- "/tmp/style-guide-plan-reviewed-${SESSION_ID}" \
19
- "/tmp/style-guide-plan-verdict"
20
- fi
21
-
22
- exit 0