@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.
- package/dist/commands/hooks.js +70 -1
- package/dist/commands/hooks.js.map +1 -1
- package/dist/hook-packs/a11y/hookify.focus-ring-required.local.md +20 -0
- package/dist/hook-packs/a11y/hookify.semantic-html.local.md +17 -0
- package/dist/hook-packs/a11y/hookify.ux-touch-targets.local.md +17 -0
- package/dist/hook-packs/a11y/manifest.json +5 -0
- package/dist/hook-packs/clean-commits/hookify.no-ai-attribution.local.md +17 -0
- package/dist/hook-packs/clean-commits/manifest.json +5 -0
- package/dist/hook-packs/css-discipline/hookify.no-important.local.md +17 -0
- package/dist/hook-packs/css-discipline/hookify.no-inline-styles.local.md +20 -0
- package/dist/hook-packs/css-discipline/manifest.json +5 -0
- package/dist/hook-packs/full/manifest.json +6 -0
- package/dist/hook-packs/installer.d.ts +19 -0
- package/dist/hook-packs/installer.js +103 -0
- package/dist/hook-packs/installer.js.map +1 -0
- package/dist/hook-packs/installer.ts +113 -0
- package/dist/hook-packs/registry.d.ts +21 -0
- package/dist/hook-packs/registry.js +69 -0
- package/dist/hook-packs/registry.js.map +1 -0
- package/dist/hook-packs/registry.ts +84 -0
- package/dist/hook-packs/typescript-safety/hookify.no-any-types.local.md +17 -0
- package/dist/hook-packs/typescript-safety/hookify.no-console-log.local.md +20 -0
- package/dist/hook-packs/typescript-safety/manifest.json +5 -0
- package/package.json +2 -2
- package/src/__tests__/hook-packs.test.ts +195 -0
- package/src/commands/hooks.ts +75 -1
- package/src/hook-packs/a11y/hookify.focus-ring-required.local.md +20 -0
- package/src/hook-packs/a11y/hookify.semantic-html.local.md +17 -0
- package/src/hook-packs/a11y/hookify.ux-touch-targets.local.md +17 -0
- package/src/hook-packs/a11y/manifest.json +5 -0
- package/src/hook-packs/clean-commits/hookify.no-ai-attribution.local.md +17 -0
- package/src/hook-packs/clean-commits/manifest.json +5 -0
- package/src/hook-packs/css-discipline/hookify.no-important.local.md +17 -0
- package/src/hook-packs/css-discipline/hookify.no-inline-styles.local.md +20 -0
- package/src/hook-packs/css-discipline/manifest.json +5 -0
- package/src/hook-packs/full/manifest.json +6 -0
- package/src/hook-packs/installer.ts +113 -0
- package/src/hook-packs/registry.ts +84 -0
- package/src/hook-packs/typescript-safety/hookify.no-any-types.local.md +17 -0
- package/src/hook-packs/typescript-safety/hookify.no-console-log.local.md +20 -0
- 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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soleri/cli",
|
|
3
|
-
"version": "1.0
|
|
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
|
+
});
|
package/src/commands/hooks.ts
CHANGED
|
@@ -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
|
|
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,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,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,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
|
+
}
|