@soleri/cli 1.0.4 → 1.1.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 (41) hide show
  1. package/dist/commands/hooks.js +70 -1
  2. package/dist/commands/hooks.js.map +1 -1
  3. package/dist/hook-packs/a11y/hookify.focus-ring-required.local.md +20 -0
  4. package/dist/hook-packs/a11y/hookify.semantic-html.local.md +17 -0
  5. package/dist/hook-packs/a11y/hookify.ux-touch-targets.local.md +17 -0
  6. package/dist/hook-packs/a11y/manifest.json +5 -0
  7. package/dist/hook-packs/clean-commits/hookify.no-ai-attribution.local.md +17 -0
  8. package/dist/hook-packs/clean-commits/manifest.json +5 -0
  9. package/dist/hook-packs/css-discipline/hookify.no-important.local.md +17 -0
  10. package/dist/hook-packs/css-discipline/hookify.no-inline-styles.local.md +20 -0
  11. package/dist/hook-packs/css-discipline/manifest.json +5 -0
  12. package/dist/hook-packs/full/manifest.json +6 -0
  13. package/dist/hook-packs/installer.d.ts +19 -0
  14. package/dist/hook-packs/installer.js +103 -0
  15. package/dist/hook-packs/installer.js.map +1 -0
  16. package/dist/hook-packs/installer.ts +113 -0
  17. package/dist/hook-packs/registry.d.ts +21 -0
  18. package/dist/hook-packs/registry.js +69 -0
  19. package/dist/hook-packs/registry.js.map +1 -0
  20. package/dist/hook-packs/registry.ts +84 -0
  21. package/dist/hook-packs/typescript-safety/hookify.no-any-types.local.md +17 -0
  22. package/dist/hook-packs/typescript-safety/hookify.no-console-log.local.md +20 -0
  23. package/dist/hook-packs/typescript-safety/manifest.json +5 -0
  24. package/package.json +2 -2
  25. package/src/__tests__/hook-packs.test.ts +195 -0
  26. package/src/commands/hooks.ts +75 -1
  27. package/src/hook-packs/a11y/hookify.focus-ring-required.local.md +20 -0
  28. package/src/hook-packs/a11y/hookify.semantic-html.local.md +17 -0
  29. package/src/hook-packs/a11y/hookify.ux-touch-targets.local.md +17 -0
  30. package/src/hook-packs/a11y/manifest.json +5 -0
  31. package/src/hook-packs/clean-commits/hookify.no-ai-attribution.local.md +17 -0
  32. package/src/hook-packs/clean-commits/manifest.json +5 -0
  33. package/src/hook-packs/css-discipline/hookify.no-important.local.md +17 -0
  34. package/src/hook-packs/css-discipline/hookify.no-inline-styles.local.md +20 -0
  35. package/src/hook-packs/css-discipline/manifest.json +5 -0
  36. package/src/hook-packs/full/manifest.json +6 -0
  37. package/src/hook-packs/installer.ts +113 -0
  38. package/src/hook-packs/registry.ts +84 -0
  39. package/src/hook-packs/typescript-safety/hookify.no-any-types.local.md +17 -0
  40. package/src/hook-packs/typescript-safety/hookify.no-console-log.local.md +20 -0
  41. package/src/hook-packs/typescript-safety/manifest.json +5 -0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Hook pack registry — discovers built-in packs and checks installation status.
3
+ */
4
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
5
+ import { join, dirname } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { homedir } from 'node:os';
8
+
9
+ export interface HookPackManifest {
10
+ name: string;
11
+ description: string;
12
+ hooks: string[];
13
+ composedFrom?: string[];
14
+ }
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = dirname(__filename);
18
+
19
+ /** Root directory containing all built-in hook packs. */
20
+ function getPacksRoot(): string {
21
+ return __dirname;
22
+ }
23
+
24
+ /**
25
+ * List all available built-in hook packs.
26
+ */
27
+ export function listPacks(): HookPackManifest[] {
28
+ const root = getPacksRoot();
29
+ const entries = readdirSync(root, { withFileTypes: true });
30
+ const packs: HookPackManifest[] = [];
31
+
32
+ for (const entry of entries) {
33
+ if (!entry.isDirectory()) continue;
34
+ const manifestPath = join(root, entry.name, 'manifest.json');
35
+ if (!existsSync(manifestPath)) continue;
36
+
37
+ try {
38
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as HookPackManifest;
39
+ packs.push(manifest);
40
+ } catch {
41
+ // Skip malformed manifests
42
+ }
43
+ }
44
+
45
+ return packs;
46
+ }
47
+
48
+ /**
49
+ * Get a specific pack by name.
50
+ */
51
+ export function getPack(name: string): { manifest: HookPackManifest; dir: string } | null {
52
+ const root = getPacksRoot();
53
+ const dir = join(root, name);
54
+ const manifestPath = join(dir, 'manifest.json');
55
+
56
+ if (!existsSync(manifestPath)) return null;
57
+
58
+ try {
59
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as HookPackManifest;
60
+ return { manifest, dir };
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Get names of packs that are fully installed in ~/.claude/.
68
+ */
69
+ export function getInstalledPacks(): string[] {
70
+ const claudeDir = join(homedir(), '.claude');
71
+ const packs = listPacks();
72
+ const installed: string[] = [];
73
+
74
+ for (const pack of packs) {
75
+ const allPresent = pack.hooks.every((hook) =>
76
+ existsSync(join(claudeDir, `hookify.${hook}.local.md`)),
77
+ );
78
+ if (allPresent) {
79
+ installed.push(pack.name);
80
+ }
81
+ }
82
+
83
+ return installed;
84
+ }
@@ -0,0 +1,17 @@
1
+ ---
2
+ # Soleri Hook Pack: typescript-safety
3
+ # Rule: no-any-types
4
+ name: no-any-types
5
+ enabled: true
6
+ event: file
7
+ action: block
8
+ conditions:
9
+ - field: file_path
10
+ operator: regex_match
11
+ pattern: \.(tsx?|jsx?)$
12
+ - field: content
13
+ operator: regex_match
14
+ pattern: (:\s*any(?![a-zA-Z])|as\s+any(?![a-zA-Z])|<any>|Record<string,\s*any>)
15
+ ---
16
+
17
+ 🚫 **Type bypass blocked.** Replace `:any` with specific type or `unknown`. Fix `as any` at the source.
@@ -0,0 +1,20 @@
1
+ ---
2
+ # Soleri Hook Pack: typescript-safety
3
+ # Rule: no-console-log
4
+ name: no-console-log
5
+ enabled: true
6
+ event: file
7
+ action: warn
8
+ conditions:
9
+ - field: file_path
10
+ operator: regex_match
11
+ pattern: src/.*\.(tsx?|jsx?)$
12
+ - field: file_path
13
+ operator: not_regex_match
14
+ pattern: (\.test\.|\.spec\.|\.stories\.)
15
+ - field: content
16
+ operator: regex_match
17
+ pattern: console\.(log|debug|info)\(
18
+ ---
19
+
20
+ ⚠️ **Debug code detected.** Remove `console.log/debug/info` before commit. `console.error/warn` allowed.
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "typescript-safety",
3
+ "description": "Block unsafe TypeScript patterns",
4
+ "hooks": ["no-any-types", "no-console-log"]
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soleri/cli",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "description": "Developer CLI for creating and managing Soleri AI agents.",
5
5
  "keywords": [
6
6
  "agent",
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "scripts": {
30
30
  "dev": "tsx src/main.ts",
31
- "build": "tsc",
31
+ "build": "tsc && cp -r src/hook-packs dist/",
32
32
  "start": "node dist/main.js",
33
33
  "typecheck": "tsc --noEmit",
34
34
  "test": "vitest run",
@@ -0,0 +1,195 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ // Mock homedir to use a temp directory instead of real ~/.claude/
7
+ const tempHome = join(tmpdir(), `cli-hookpacks-test-${Date.now()}`);
8
+
9
+ vi.mock('node:os', async () => {
10
+ const actual = await vi.importActual<typeof import('node:os')>('node:os');
11
+ return { ...actual, homedir: () => tempHome };
12
+ });
13
+
14
+ import { listPacks, getPack, getInstalledPacks } from '../hook-packs/registry.js';
15
+ import { installPack, removePack, isPackInstalled } from '../hook-packs/installer.js';
16
+
17
+ describe('hook-packs', () => {
18
+ beforeEach(() => {
19
+ mkdirSync(join(tempHome, '.claude'), { recursive: true });
20
+ });
21
+
22
+ afterEach(() => {
23
+ rmSync(tempHome, { recursive: true, force: true });
24
+ });
25
+
26
+ describe('registry', () => {
27
+ it('should list all 5 built-in packs', () => {
28
+ const packs = listPacks();
29
+ expect(packs.length).toBe(5);
30
+
31
+ const names = packs.map((p) => p.name).sort();
32
+ expect(names).toEqual([
33
+ 'a11y',
34
+ 'clean-commits',
35
+ 'css-discipline',
36
+ 'full',
37
+ 'typescript-safety',
38
+ ]);
39
+ });
40
+
41
+ it('should get a specific pack by name', () => {
42
+ const pack = getPack('typescript-safety');
43
+ expect(pack).not.toBeNull();
44
+ expect(pack!.manifest.name).toBe('typescript-safety');
45
+ expect(pack!.manifest.hooks).toEqual(['no-any-types', 'no-console-log']);
46
+ expect(pack!.manifest.description).toBe('Block unsafe TypeScript patterns');
47
+ });
48
+
49
+ it('should return null for unknown pack', () => {
50
+ expect(getPack('nonexistent')).toBeNull();
51
+ });
52
+
53
+ it('should return full pack with composedFrom', () => {
54
+ const pack = getPack('full');
55
+ expect(pack).not.toBeNull();
56
+ expect(pack!.manifest.composedFrom).toEqual([
57
+ 'typescript-safety',
58
+ 'a11y',
59
+ 'css-discipline',
60
+ 'clean-commits',
61
+ ]);
62
+ expect(pack!.manifest.hooks).toHaveLength(8);
63
+ });
64
+
65
+ it('should return empty installed packs when none installed', () => {
66
+ expect(getInstalledPacks()).toEqual([]);
67
+ });
68
+ });
69
+
70
+ describe('installer', () => {
71
+ it('should install a simple pack', () => {
72
+ const result = installPack('typescript-safety');
73
+
74
+ expect(result.installed).toEqual(['no-any-types', 'no-console-log']);
75
+ expect(result.skipped).toEqual([]);
76
+
77
+ const claudeDir = join(tempHome, '.claude');
78
+ expect(existsSync(join(claudeDir, 'hookify.no-any-types.local.md'))).toBe(true);
79
+ expect(existsSync(join(claudeDir, 'hookify.no-console-log.local.md'))).toBe(true);
80
+
81
+ // Verify content was copied correctly
82
+ const content = readFileSync(join(claudeDir, 'hookify.no-any-types.local.md'), 'utf-8');
83
+ expect(content).toContain('name: no-any-types');
84
+ expect(content).toContain('Soleri Hook Pack: typescript-safety');
85
+ });
86
+
87
+ it('should be idempotent — skip existing files', () => {
88
+ installPack('typescript-safety');
89
+ const result = installPack('typescript-safety');
90
+
91
+ expect(result.installed).toEqual([]);
92
+ expect(result.skipped).toEqual(['no-any-types', 'no-console-log']);
93
+ });
94
+
95
+ it('should install composed pack (full)', () => {
96
+ const result = installPack('full');
97
+
98
+ expect(result.installed).toHaveLength(8);
99
+ expect(result.skipped).toEqual([]);
100
+
101
+ const claudeDir = join(tempHome, '.claude');
102
+ const expectedHooks = [
103
+ 'no-any-types',
104
+ 'no-console-log',
105
+ 'no-important',
106
+ 'no-inline-styles',
107
+ 'semantic-html',
108
+ 'focus-ring-required',
109
+ 'ux-touch-targets',
110
+ 'no-ai-attribution',
111
+ ];
112
+ for (const hook of expectedHooks) {
113
+ expect(existsSync(join(claudeDir, `hookify.${hook}.local.md`))).toBe(true);
114
+ }
115
+ });
116
+
117
+ it('should skip already-installed hooks when installing full after partial', () => {
118
+ installPack('typescript-safety');
119
+ const result = installPack('full');
120
+
121
+ expect(result.skipped).toContain('no-any-types');
122
+ expect(result.skipped).toContain('no-console-log');
123
+ expect(result.installed).toHaveLength(6); // 8 total - 2 already installed
124
+ });
125
+
126
+ it('should throw for unknown pack', () => {
127
+ expect(() => installPack('nonexistent')).toThrow('Unknown hook pack: "nonexistent"');
128
+ });
129
+
130
+ it('should remove a pack', () => {
131
+ installPack('a11y');
132
+ const result = removePack('a11y');
133
+
134
+ expect(result.removed).toEqual(['semantic-html', 'focus-ring-required', 'ux-touch-targets']);
135
+
136
+ const claudeDir = join(tempHome, '.claude');
137
+ expect(existsSync(join(claudeDir, 'hookify.semantic-html.local.md'))).toBe(false);
138
+ });
139
+
140
+ it('should return empty removed list when pack not installed', () => {
141
+ const result = removePack('a11y');
142
+ expect(result.removed).toEqual([]);
143
+ });
144
+
145
+ it('should throw for unknown pack on remove', () => {
146
+ expect(() => removePack('nonexistent')).toThrow('Unknown hook pack: "nonexistent"');
147
+ });
148
+ });
149
+
150
+ describe('isPackInstalled', () => {
151
+ it('should return false when nothing installed', () => {
152
+ expect(isPackInstalled('typescript-safety')).toBe(false);
153
+ });
154
+
155
+ it('should return true when fully installed', () => {
156
+ installPack('typescript-safety');
157
+ expect(isPackInstalled('typescript-safety')).toBe(true);
158
+ });
159
+
160
+ it('should return partial when some hooks present', () => {
161
+ // Install just one of the two hooks
162
+ const claudeDir = join(tempHome, '.claude');
163
+ writeFileSync(join(claudeDir, 'hookify.no-any-types.local.md'), 'test');
164
+ expect(isPackInstalled('typescript-safety')).toBe('partial');
165
+ });
166
+
167
+ it('should return false for unknown pack', () => {
168
+ expect(isPackInstalled('nonexistent')).toBe(false);
169
+ });
170
+ });
171
+
172
+ describe('getInstalledPacks', () => {
173
+ it('should list installed packs', () => {
174
+ installPack('typescript-safety');
175
+ installPack('a11y');
176
+
177
+ const installed = getInstalledPacks();
178
+ expect(installed).toContain('typescript-safety');
179
+ expect(installed).toContain('a11y');
180
+ expect(installed).not.toContain('css-discipline');
181
+ });
182
+
183
+ it('should include full when all 8 hooks are present', () => {
184
+ installPack('full');
185
+
186
+ const installed = getInstalledPacks();
187
+ // All packs should show as installed since full installs all hooks
188
+ expect(installed).toContain('full');
189
+ expect(installed).toContain('typescript-safety');
190
+ expect(installed).toContain('a11y');
191
+ expect(installed).toContain('css-discipline');
192
+ expect(installed).toContain('clean-commits');
193
+ });
194
+ });
195
+ });
@@ -2,10 +2,12 @@ import type { Command } from 'commander';
2
2
  import { SUPPORTED_EDITORS, type EditorId } from '../hooks/templates.js';
3
3
  import { installHooks, removeHooks, detectInstalledHooks } from '../hooks/generator.js';
4
4
  import { detectAgent } from '../utils/agent-context.js';
5
+ import { listPacks, getPack } from '../hook-packs/registry.js';
6
+ import { installPack, removePack, isPackInstalled } from '../hook-packs/installer.js';
5
7
  import * as log from '../utils/logger.js';
6
8
 
7
9
  export function registerHooks(program: Command): void {
8
- const hooks = program.command('hooks').description('Manage editor hooks for this agent');
10
+ const hooks = program.command('hooks').description('Manage editor hooks and hook packs');
9
11
 
10
12
  hooks
11
13
  .command('add')
@@ -79,6 +81,78 @@ export function registerHooks(program: Command): void {
79
81
  }
80
82
  }
81
83
  });
84
+
85
+ // ── Hook Pack subcommands ──
86
+
87
+ hooks
88
+ .command('add-pack')
89
+ .argument('<pack>', 'Hook pack name')
90
+ .description('Install a hook pack globally (~/.claude/)')
91
+ .action((packName: string) => {
92
+ const pack = getPack(packName);
93
+ if (!pack) {
94
+ const available = listPacks().map((p) => p.name);
95
+ log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`);
96
+ process.exit(1);
97
+ }
98
+
99
+ const { installed, skipped } = installPack(packName);
100
+ for (const hook of installed) {
101
+ log.pass(`Installed hookify.${hook}.local.md`);
102
+ }
103
+ for (const hook of skipped) {
104
+ log.dim(` hookify.${hook}.local.md — already exists, skipped`);
105
+ }
106
+ if (installed.length > 0) {
107
+ log.info(`Pack "${packName}" installed (${installed.length} hooks)`);
108
+ } else {
109
+ log.info(`Pack "${packName}" — all hooks already installed`);
110
+ }
111
+ });
112
+
113
+ hooks
114
+ .command('remove-pack')
115
+ .argument('<pack>', 'Hook pack name')
116
+ .description('Remove a hook pack from ~/.claude/')
117
+ .action((packName: string) => {
118
+ const pack = getPack(packName);
119
+ if (!pack) {
120
+ const available = listPacks().map((p) => p.name);
121
+ log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`);
122
+ process.exit(1);
123
+ }
124
+
125
+ const { removed } = removePack(packName);
126
+ if (removed.length === 0) {
127
+ log.info(`No hooks from pack "${packName}" found to remove.`);
128
+ } else {
129
+ for (const hook of removed) {
130
+ log.warn(`Removed hookify.${hook}.local.md`);
131
+ }
132
+ log.info(`Pack "${packName}" removed (${removed.length} hooks)`);
133
+ }
134
+ });
135
+
136
+ hooks
137
+ .command('list-packs')
138
+ .description('Show available hook packs and their status')
139
+ .action(() => {
140
+ const packs = listPacks();
141
+
142
+ log.heading('Hook Packs');
143
+
144
+ for (const pack of packs) {
145
+ const status = isPackInstalled(pack.name);
146
+
147
+ if (status === true) {
148
+ log.pass(`${pack.name}`, `${pack.description} (${pack.hooks.length} hooks)`);
149
+ } else if (status === 'partial') {
150
+ log.warn(`${pack.name}`, `${pack.description} (${pack.hooks.length} hooks) — partial`);
151
+ } else {
152
+ log.dim(` ${pack.name} — ${pack.description} (${pack.hooks.length} hooks)`);
153
+ }
154
+ }
155
+ });
82
156
  }
83
157
 
84
158
  function isValidEditor(editor: string): editor is EditorId {
@@ -0,0 +1,20 @@
1
+ ---
2
+ # Soleri Hook Pack: a11y
3
+ # Rule: focus-ring-required
4
+ name: focus-ring-required
5
+ enabled: true
6
+ event: file
7
+ action: warn
8
+ conditions:
9
+ - field: file_path
10
+ operator: regex_match
11
+ pattern: src/components/ui/.*\.tsx$
12
+ - field: content
13
+ operator: regex_match
14
+ pattern: (<button|<Button|<a\s+href)
15
+ - field: content
16
+ operator: not_regex_match
17
+ pattern: (focus:ring|focus-visible:ring|focus:outline)
18
+ ---
19
+
20
+ ⚠️ **A11y:** Add focus ring for keyboard navigation: `focus:ring-2 focus:ring-accent focus:ring-offset-2`
@@ -0,0 +1,17 @@
1
+ ---
2
+ # Soleri Hook Pack: a11y
3
+ # Rule: semantic-html
4
+ name: semantic-html
5
+ enabled: true
6
+ event: file
7
+ action: warn
8
+ conditions:
9
+ - field: file_path
10
+ operator: regex_match
11
+ pattern: src/components/.*\.tsx$
12
+ - field: content
13
+ operator: regex_match
14
+ pattern: (<div\s+onClick(?!=)|<span\s+onClick(?!=))
15
+ ---
16
+
17
+ ⚠️ **A11y:** Use `<button>` instead of `<div onClick>`. Semantic HTML provides keyboard support.
@@ -0,0 +1,17 @@
1
+ ---
2
+ # Soleri Hook Pack: a11y
3
+ # Rule: ux-touch-targets
4
+ name: ux-touch-targets
5
+ enabled: true
6
+ event: file
7
+ action: warn
8
+ conditions:
9
+ - field: file_path
10
+ operator: regex_match
11
+ pattern: src/components/.*\.tsx$
12
+ - field: content
13
+ operator: regex_match
14
+ pattern: (<button[^>]*className="[^"]*(?:p-1|p-2|h-6|h-7|h-8|w-6|w-7|w-8)[^"]*"[^>]*>|className="[^"]*(?:p-1|p-2)[^"]*"[^>]*onClick)
15
+ ---
16
+
17
+ ⚠️ **Touch target too small.** Min 44x44px required. Add `min-h-11 min-w-11` to small interactive elements.
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "a11y",
3
+ "description": "Accessibility enforcement",
4
+ "hooks": ["semantic-html", "focus-ring-required", "ux-touch-targets"]
5
+ }
@@ -0,0 +1,17 @@
1
+ ---
2
+ # Soleri Hook Pack: clean-commits
3
+ # Rule: no-ai-attribution
4
+ name: no-ai-attribution
5
+ enabled: true
6
+ event: bash
7
+ action: block
8
+ conditions:
9
+ - field: command
10
+ operator: regex_match
11
+ pattern: git\s+commit.*(-m|--message)
12
+ - field: command
13
+ operator: regex_match
14
+ pattern: (🤖|Co-Authored-By|Generated with|AI-generated|by Claude|Claude Code|with Claude|noreply@anthropic\.com|Anthropic|Claude\s+(Opus|Sonnet|Haiku))
15
+ ---
16
+
17
+ 🚫 **AI attribution blocked.** Use clean conventional commits: `feat:`, `fix:`, `refactor:`. No 🤖, Claude, Co-Authored-By, or Anthropic references.
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "clean-commits",
3
+ "description": "No AI attribution in git commits",
4
+ "hooks": ["no-ai-attribution"]
5
+ }
@@ -0,0 +1,17 @@
1
+ ---
2
+ # Soleri Hook Pack: css-discipline
3
+ # Rule: no-important
4
+ name: no-important
5
+ enabled: true
6
+ event: file
7
+ action: block
8
+ conditions:
9
+ - field: file_path
10
+ operator: regex_match
11
+ pattern: \.(tsx?|jsx?|css)$
12
+ - field: content
13
+ operator: regex_match
14
+ pattern: "!important"
15
+ ---
16
+
17
+ 🚫 **!important blocked.** Fix specificity instead. Use Tailwind `!` prefix (`!text-error`) only if needed.
@@ -0,0 +1,20 @@
1
+ ---
2
+ # Soleri Hook Pack: css-discipline
3
+ # Rule: no-inline-styles
4
+ name: no-inline-styles
5
+ enabled: true
6
+ event: file
7
+ action: warn
8
+ conditions:
9
+ - field: file_path
10
+ operator: regex_match
11
+ pattern: src/components/.*\.tsx$
12
+ - field: file_path
13
+ operator: not_contains
14
+ pattern: .stories.tsx
15
+ - field: content
16
+ operator: regex_match
17
+ pattern: style=\{\{[^}]*(padding|margin|width|height|fontSize|color|background)[^}]*\}\}
18
+ ---
19
+
20
+ ⚠️ **Inline style detected.** Use Tailwind: `p-4` not `style={{ padding: '16px' }}`. Exception: CSS variables.
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "css-discipline",
3
+ "description": "CSS best practices",
4
+ "hooks": ["no-important", "no-inline-styles"]
5
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "full",
3
+ "description": "Complete quality suite — all 8 hooks",
4
+ "hooks": ["no-any-types", "no-console-log", "no-important", "no-inline-styles", "semantic-html", "focus-ring-required", "ux-touch-targets", "no-ai-attribution"],
5
+ "composedFrom": ["typescript-safety", "a11y", "css-discipline", "clean-commits"]
6
+ }