@schalkneethling/toolkit 0.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/LICENSE +21 -0
- package/README.md +103 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +292 -0
- package/dist/index.mjs.map +1 -0
- package/hooks/block-dangerous-commands/hook.js +120 -0
- package/hooks/block-dangerous-commands/settings-fragment.json +15 -0
- package/package.json +35 -0
- package/skills/semantic-html/SKILL.md +603 -0
- package/skills/semantic-html/references/element-decision-trees.md +111 -0
- package/skills/semantic-html/references/heading-patterns.md +275 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Schalk Neethling
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# claude-toolkit
|
|
2
|
+
|
|
3
|
+
CLI for managing [Claude Code](https://claude.com/claude-code) hooks and skills across projects. Hooks are copied into a project's `.claude/` directory; skills are copied into `.claude-toolkit/skills/` and symlinked into wherever Claude Code expects to find them.
|
|
4
|
+
|
|
5
|
+
## Repo layout
|
|
6
|
+
|
|
7
|
+
```plaintext
|
|
8
|
+
.
|
|
9
|
+
├── hooks/
|
|
10
|
+
│ └── block-dangerous-commands/
|
|
11
|
+
│ ├── hook.ts # the hook script itself
|
|
12
|
+
│ └── settings-fragment.json # deep-merged into .claude/settings.json on install
|
|
13
|
+
├── skills/ # (empty) drop skill directories here
|
|
14
|
+
├── src/
|
|
15
|
+
│ └── index.ts # the toolkit CLI
|
|
16
|
+
├── package.json
|
|
17
|
+
├── README.md
|
|
18
|
+
├── tsconfig.json
|
|
19
|
+
├── tsconfig.hooks.json
|
|
20
|
+
└── vite.config.ts
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- Node.js 22+
|
|
26
|
+
- `tsx` (installed as a devDependency)
|
|
27
|
+
|
|
28
|
+
From inside a consuming project, run the CLI with `tsx /path/to/claude-toolkit/cli/index.ts <command>`, or link it as `toolkit` on your `PATH`.
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
### `toolkit add hook <name>`
|
|
33
|
+
|
|
34
|
+
Copies `hooks/<name>/hook.ts` into `<project>/.claude/hooks/<name>.ts` and deep-merges `hooks/<name>/settings-fragment.json` into `<project>/.claude/settings.json`. Records the source hash in `.claude/toolkit-manifest.json`.
|
|
35
|
+
|
|
36
|
+
### `toolkit add skill <name> [--link <target>]...`
|
|
37
|
+
|
|
38
|
+
Copies `skills/<name>/` into `<project>/.claude-toolkit/skills/<name>/` and creates a symlink to that directory inside each `--link` target. If no `--link` is given, the default is `.claude/skills`. Repeat `--link` to create symlinks in multiple locations.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
toolkit add skill css-shared-first --link .claude/skills --link docs/skills
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### `toolkit update [--force]`
|
|
45
|
+
|
|
46
|
+
For every entry in `.claude/toolkit-manifest.json`, compares the current source hash to the installed hash:
|
|
47
|
+
|
|
48
|
+
- If the source changed, shows a line-diff and prompts before overwriting.
|
|
49
|
+
- If the installed file was modified locally (its hash differs from the one recorded in the manifest), warns and skips unless `--force` is passed.
|
|
50
|
+
- Silent if everything is current.
|
|
51
|
+
|
|
52
|
+
### `toolkit list hook` / `toolkit list skill`
|
|
53
|
+
|
|
54
|
+
Lists available hooks or skills shipped by this repo, with the current source hash.
|
|
55
|
+
|
|
56
|
+
## Versioning
|
|
57
|
+
|
|
58
|
+
- Each hook is hashed over `hook.ts` only (not the README or `settings-fragment.json`).
|
|
59
|
+
- Each skill is hashed over every file in the skill directory (sorted by path).
|
|
60
|
+
- SHA-256, truncated to the first 7 hex characters.
|
|
61
|
+
|
|
62
|
+
## Manifest format
|
|
63
|
+
|
|
64
|
+
The CLI writes `<project>/.claude/toolkit-manifest.json`:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"hooks": {
|
|
69
|
+
"block-dangerous-commands": {
|
|
70
|
+
"hash": "a3f9c2d",
|
|
71
|
+
"installedAt": "2026-04-18"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"skills": {
|
|
75
|
+
"css-shared-first": {
|
|
76
|
+
"hash": "f91b3e1",
|
|
77
|
+
"installedAt": "2026-04-18",
|
|
78
|
+
"linkedTo": [".claude/skills"]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Bundled hooks
|
|
85
|
+
|
|
86
|
+
### `block-dangerous-commands`
|
|
87
|
+
|
|
88
|
+
A `PreToolUse` hook for the `Bash` tool. Reads Claude Code's hook payload from stdin and denies commands that match any of:
|
|
89
|
+
|
|
90
|
+
- `rm -rf` (and flag variants: `-rf`, `-fr`, `--recursive --force`, etc.)
|
|
91
|
+
- `git push --force` / `-f` / `--force-with-lease`
|
|
92
|
+
- Direct push to `main`, `master`, `production`, `prod`, `release`
|
|
93
|
+
- `git reset --hard`
|
|
94
|
+
- `chmod 777` / recursive chmod granting world-write
|
|
95
|
+
- `dd if=…`
|
|
96
|
+
- Redirects into `/etc/`, `/boot/`, `/usr/`, `/bin/`, `/sbin/`
|
|
97
|
+
- The classic fork bomb
|
|
98
|
+
- Piping remote content into a shell (`curl … | bash`, `wget … | sh`)
|
|
99
|
+
- `pkill` and `kill -9` / `-SIGKILL`
|
|
100
|
+
- `npm publish`, `npm deprecate`, `npm unpublish`
|
|
101
|
+
- `history -c`
|
|
102
|
+
|
|
103
|
+
On a match it exits `2` with a structured JSON payload on stdout (`permissionDecision: "deny"` plus `permissionDecisionReason` explaining why). On any other input — malformed JSON, empty stdin, a non-string command — it fails open (exit `0`) so a broken hook never blocks all work.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
//#region src/index.ts
|
|
9
|
+
/**
|
|
10
|
+
* toolkit — personal CLI for managing Claude Code hooks and skills.
|
|
11
|
+
*
|
|
12
|
+
* Commands:
|
|
13
|
+
* toolkit add hook <name>
|
|
14
|
+
* toolkit add skill <name> [--link <target>...]
|
|
15
|
+
* toolkit update [--force]
|
|
16
|
+
* toolkit list hook
|
|
17
|
+
* toolkit list skill
|
|
18
|
+
*/
|
|
19
|
+
const TOOLKIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
20
|
+
const HOOKS_SRC = join(TOOLKIT_ROOT, "hooks");
|
|
21
|
+
const SKILLS_SRC = join(TOOLKIT_ROOT, "skills");
|
|
22
|
+
const PROJECT_ROOT = process.cwd();
|
|
23
|
+
const CLAUDE_DIR = join(PROJECT_ROOT, ".claude");
|
|
24
|
+
const TOOLKIT_DIR = join(PROJECT_ROOT, ".claude-toolkit");
|
|
25
|
+
const MANIFEST_PATH = join(CLAUDE_DIR, "toolkit-manifest.json");
|
|
26
|
+
function today() {
|
|
27
|
+
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
28
|
+
}
|
|
29
|
+
function shortHash(content) {
|
|
30
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 7);
|
|
31
|
+
}
|
|
32
|
+
function readManifest() {
|
|
33
|
+
if (!existsSync(MANIFEST_PATH)) return {
|
|
34
|
+
hooks: {},
|
|
35
|
+
skills: {}
|
|
36
|
+
};
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
|
|
39
|
+
return {
|
|
40
|
+
hooks: parsed.hooks ?? {},
|
|
41
|
+
skills: parsed.skills ?? {}
|
|
42
|
+
};
|
|
43
|
+
} catch {
|
|
44
|
+
return {
|
|
45
|
+
hooks: {},
|
|
46
|
+
skills: {}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function writeManifest(m) {
|
|
51
|
+
mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
52
|
+
writeFileSync(MANIFEST_PATH, JSON.stringify(m, null, 2) + "\n");
|
|
53
|
+
}
|
|
54
|
+
function isPlainObject(v) {
|
|
55
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
56
|
+
}
|
|
57
|
+
function deepMerge(target, source) {
|
|
58
|
+
if (Array.isArray(target) && Array.isArray(source)) return [...target, ...source];
|
|
59
|
+
if (isPlainObject(target) && isPlainObject(source)) {
|
|
60
|
+
const out = { ...target };
|
|
61
|
+
for (const [k, v] of Object.entries(source)) out[k] = k in out ? deepMerge(out[k], v) : v;
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
return source;
|
|
65
|
+
}
|
|
66
|
+
function hashHookSource(name) {
|
|
67
|
+
return shortHash(readFileSync(join(HOOKS_SRC, name, "hook.ts")));
|
|
68
|
+
}
|
|
69
|
+
function hashSkillSource(name) {
|
|
70
|
+
const dir = join(SKILLS_SRC, name);
|
|
71
|
+
const files = collectFiles(dir).sort();
|
|
72
|
+
const h = createHash("sha256");
|
|
73
|
+
for (const f of files) {
|
|
74
|
+
h.update(relative(dir, f));
|
|
75
|
+
h.update("\0");
|
|
76
|
+
h.update(readFileSync(f));
|
|
77
|
+
h.update("\0");
|
|
78
|
+
}
|
|
79
|
+
return h.digest("hex").slice(0, 7);
|
|
80
|
+
}
|
|
81
|
+
function collectFiles(dir) {
|
|
82
|
+
const out = [];
|
|
83
|
+
if (!existsSync(dir)) return out;
|
|
84
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
85
|
+
if (entry.name === ".gitkeep") continue;
|
|
86
|
+
const full = join(dir, entry.name);
|
|
87
|
+
if (entry.isDirectory()) out.push(...collectFiles(full));
|
|
88
|
+
else if (entry.isFile()) out.push(full);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
async function confirm(question) {
|
|
93
|
+
const rl = createInterface({
|
|
94
|
+
input: process.stdin,
|
|
95
|
+
output: process.stdout
|
|
96
|
+
});
|
|
97
|
+
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
98
|
+
rl.close();
|
|
99
|
+
return answer === "y" || answer === "yes";
|
|
100
|
+
}
|
|
101
|
+
function diffLines(oldStr, newStr) {
|
|
102
|
+
const a = oldStr.split("\n");
|
|
103
|
+
const b = newStr.split("\n");
|
|
104
|
+
const out = [];
|
|
105
|
+
const max = Math.max(a.length, b.length);
|
|
106
|
+
for (let i = 0; i < max; i++) {
|
|
107
|
+
if (a[i] === b[i]) continue;
|
|
108
|
+
if (a[i] !== void 0) out.push(`- ${a[i]}`);
|
|
109
|
+
if (b[i] !== void 0) out.push(`+ ${b[i]}`);
|
|
110
|
+
}
|
|
111
|
+
return out.join("\n");
|
|
112
|
+
}
|
|
113
|
+
function addHook(name) {
|
|
114
|
+
const srcDir = join(HOOKS_SRC, name);
|
|
115
|
+
if (!existsSync(srcDir)) {
|
|
116
|
+
console.error(`Hook not found: ${name}`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
const hookSrc = join(srcDir, "hook.ts");
|
|
120
|
+
const fragmentPath = join(srcDir, "settings-fragment.json");
|
|
121
|
+
const hooksDir = join(CLAUDE_DIR, "hooks");
|
|
122
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
123
|
+
const destHook = join(hooksDir, `${name}.ts`);
|
|
124
|
+
writeFileSync(destHook, readFileSync(hookSrc));
|
|
125
|
+
if (existsSync(fragmentPath)) {
|
|
126
|
+
const fragment = JSON.parse(readFileSync(fragmentPath, "utf8"));
|
|
127
|
+
const settingsPath = join(CLAUDE_DIR, "settings.json");
|
|
128
|
+
const merged = deepMerge(existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, "utf8")) : {}, fragment);
|
|
129
|
+
writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + "\n");
|
|
130
|
+
}
|
|
131
|
+
const manifest = readManifest();
|
|
132
|
+
manifest.hooks[name] = {
|
|
133
|
+
hash: hashHookSource(name),
|
|
134
|
+
installedAt: today()
|
|
135
|
+
};
|
|
136
|
+
writeManifest(manifest);
|
|
137
|
+
console.log(`Installed hook: ${name} → ${relative(PROJECT_ROOT, destHook)}`);
|
|
138
|
+
}
|
|
139
|
+
function addSkill(name, links) {
|
|
140
|
+
const srcDir = join(SKILLS_SRC, name);
|
|
141
|
+
if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) {
|
|
142
|
+
console.error(`Skill not found: ${name}`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
const destDir = join(TOOLKIT_DIR, "skills", name);
|
|
146
|
+
mkdirSync(dirname(destDir), { recursive: true });
|
|
147
|
+
cpSync(srcDir, destDir, { recursive: true });
|
|
148
|
+
const resolvedLinks = links.length > 0 ? links : [join(".claude", "skills")];
|
|
149
|
+
for (const link of resolvedLinks) {
|
|
150
|
+
const linkDir = resolve(PROJECT_ROOT, link);
|
|
151
|
+
mkdirSync(linkDir, { recursive: true });
|
|
152
|
+
const linkPath = join(linkDir, name);
|
|
153
|
+
if (existsSync(linkPath) || lstatExists(linkPath)) unlinkSync(linkPath);
|
|
154
|
+
symlinkSync(relative(linkDir, destDir), linkPath, "dir");
|
|
155
|
+
}
|
|
156
|
+
const manifest = readManifest();
|
|
157
|
+
manifest.skills[name] = {
|
|
158
|
+
hash: hashSkillSource(name),
|
|
159
|
+
installedAt: today(),
|
|
160
|
+
linkedTo: resolvedLinks
|
|
161
|
+
};
|
|
162
|
+
writeManifest(manifest);
|
|
163
|
+
console.log(`Installed skill: ${name} → ${relative(PROJECT_ROOT, destDir)}`);
|
|
164
|
+
for (const l of resolvedLinks) console.log(` linked: ${join(l, name)}`);
|
|
165
|
+
}
|
|
166
|
+
function lstatExists(p) {
|
|
167
|
+
try {
|
|
168
|
+
lstatSync(p);
|
|
169
|
+
return true;
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function update(force) {
|
|
175
|
+
const manifest = readManifest();
|
|
176
|
+
let changed = false;
|
|
177
|
+
for (const [name, entry] of Object.entries(manifest.hooks)) {
|
|
178
|
+
const srcDir = join(HOOKS_SRC, name);
|
|
179
|
+
if (!existsSync(srcDir)) continue;
|
|
180
|
+
const sourceHash = hashHookSource(name);
|
|
181
|
+
const installedPath = join(CLAUDE_DIR, "hooks", `${name}.ts`);
|
|
182
|
+
const installedHash = existsSync(installedPath) ? shortHash(readFileSync(installedPath)) : null;
|
|
183
|
+
const sourceChanged = sourceHash !== entry.hash;
|
|
184
|
+
const locallyModified = installedHash !== null && installedHash !== entry.hash;
|
|
185
|
+
if (!sourceChanged && !locallyModified) continue;
|
|
186
|
+
changed = true;
|
|
187
|
+
if (locallyModified && !force) {
|
|
188
|
+
console.warn(`! hook "${name}" was modified locally (installed=${installedHash}, manifest=${entry.hash}). Use --force to overwrite.`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (sourceChanged) {
|
|
192
|
+
const oldSrc = existsSync(installedPath) ? readFileSync(installedPath, "utf8") : "";
|
|
193
|
+
const newSrc = readFileSync(join(srcDir, "hook.ts"), "utf8");
|
|
194
|
+
console.log(`\n~ hook: ${name} (${entry.hash} → ${sourceHash})`);
|
|
195
|
+
console.log(diffLines(oldSrc, newSrc));
|
|
196
|
+
if (!(force || await confirm(`Update hook "${name}"?`))) continue;
|
|
197
|
+
writeFileSync(installedPath, newSrc);
|
|
198
|
+
manifest.hooks[name] = {
|
|
199
|
+
hash: sourceHash,
|
|
200
|
+
installedAt: today()
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const [name, entry] of Object.entries(manifest.skills)) {
|
|
205
|
+
const srcDir = join(SKILLS_SRC, name);
|
|
206
|
+
if (!existsSync(srcDir)) continue;
|
|
207
|
+
const sourceHash = hashSkillSource(name);
|
|
208
|
+
if (sourceHash === entry.hash) continue;
|
|
209
|
+
changed = true;
|
|
210
|
+
console.log(`\n~ skill: ${name} (${entry.hash} → ${sourceHash})`);
|
|
211
|
+
if (!(force || await confirm(`Update skill "${name}"?`))) continue;
|
|
212
|
+
cpSync(srcDir, join(TOOLKIT_DIR, "skills", name), {
|
|
213
|
+
recursive: true,
|
|
214
|
+
force: true
|
|
215
|
+
});
|
|
216
|
+
manifest.skills[name] = {
|
|
217
|
+
hash: sourceHash,
|
|
218
|
+
installedAt: today(),
|
|
219
|
+
linkedTo: entry.linkedTo
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (changed) writeManifest(manifest);
|
|
223
|
+
}
|
|
224
|
+
function list(kind) {
|
|
225
|
+
const dir = kind === "hook" ? HOOKS_SRC : SKILLS_SRC;
|
|
226
|
+
if (!existsSync(dir)) {
|
|
227
|
+
console.log(`(no ${kind}s available)`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const entries = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() || kind === "skill" && e.isSymbolicLink()).map((e) => e.name);
|
|
231
|
+
if (entries.length === 0) {
|
|
232
|
+
console.log(`(no ${kind}s available)`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
for (const name of entries) {
|
|
236
|
+
const hash = kind === "hook" ? hashHookSource(name) : hashSkillSource(name);
|
|
237
|
+
console.log(`${name} ${hash}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function usage() {
|
|
241
|
+
console.error(`Usage:
|
|
242
|
+
toolkit add hook <name>
|
|
243
|
+
toolkit add skill <name> [--link <target>]...
|
|
244
|
+
toolkit update [--force]
|
|
245
|
+
toolkit list hook
|
|
246
|
+
toolkit list skill`);
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
async function main() {
|
|
250
|
+
const { values, positionals } = parseArgs({
|
|
251
|
+
options: {
|
|
252
|
+
force: {
|
|
253
|
+
default: false,
|
|
254
|
+
type: "boolean"
|
|
255
|
+
},
|
|
256
|
+
links: {
|
|
257
|
+
multiple: true,
|
|
258
|
+
type: "string"
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
allowPositionals: true
|
|
262
|
+
});
|
|
263
|
+
const { force, links } = values;
|
|
264
|
+
const [command, resource, name] = positionals;
|
|
265
|
+
if (command === "add" && resource === "hook") {
|
|
266
|
+
if (!name) usage();
|
|
267
|
+
addHook(name);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (command === "add" && resource === "skill") {
|
|
271
|
+
if (!name) usage();
|
|
272
|
+
addSkill(name, links ? links : []);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (command === "update") {
|
|
276
|
+
await update(force);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (command === "list" && (resource === "hook" || resource === "skill")) {
|
|
280
|
+
list(resource);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
usage();
|
|
284
|
+
}
|
|
285
|
+
main().catch((err) => {
|
|
286
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
287
|
+
process.exit(1);
|
|
288
|
+
});
|
|
289
|
+
//#endregion
|
|
290
|
+
export {};
|
|
291
|
+
|
|
292
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["#!/usr/bin/env node\n\n/**\n * toolkit — personal CLI for managing Claude Code hooks and skills.\n *\n * Commands:\n * toolkit add hook <name>\n * toolkit add skill <name> [--link <target>...]\n * toolkit update [--force]\n * toolkit list hook\n * toolkit list skill\n */\n\nimport { createHash } from \"node:crypto\";\nimport {\n cpSync,\n existsSync,\n lstatSync,\n mkdirSync,\n readFileSync,\n readdirSync,\n statSync,\n symlinkSync,\n unlinkSync,\n writeFileSync,\n} from \"node:fs\";\nimport { createInterface } from \"node:readline/promises\";\nimport { dirname, join, relative, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { parseArgs } from \"node:util\";\n\nconst TOOLKIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), \"..\");\nconst HOOKS_SRC = join(TOOLKIT_ROOT, \"hooks\");\nconst SKILLS_SRC = join(TOOLKIT_ROOT, \"skills\");\n\nconst PROJECT_ROOT = process.cwd();\nconst CLAUDE_DIR = join(PROJECT_ROOT, \".claude\");\nconst TOOLKIT_DIR = join(PROJECT_ROOT, \".claude-toolkit\");\nconst MANIFEST_PATH = join(CLAUDE_DIR, \"toolkit-manifest.json\");\n\ntype HookEntry = { hash: string; installedAt: string };\ntype SkillEntry = { hash: string; installedAt: string; linkedTo: string[] };\ntype Manifest = {\n hooks: Record<string, HookEntry>;\n skills: Record<string, SkillEntry>;\n};\n\n// ---------- helpers ----------\n\nfunction today(): string {\n return new Date().toISOString().slice(0, 10);\n}\n\nfunction shortHash(content: string | Buffer): string {\n return createHash(\"sha256\").update(content).digest(\"hex\").slice(0, 7);\n}\n\nfunction readManifest(): Manifest {\n if (!existsSync(MANIFEST_PATH)) {\n return { hooks: {}, skills: {} };\n }\n\n try {\n const parsed = JSON.parse(readFileSync(MANIFEST_PATH, \"utf8\")) as Partial<Manifest>;\n return { hooks: parsed.hooks ?? {}, skills: parsed.skills ?? {} };\n } catch {\n return { hooks: {}, skills: {} };\n }\n}\n\nfunction writeManifest(m: Manifest): void {\n mkdirSync(CLAUDE_DIR, { recursive: true });\n writeFileSync(MANIFEST_PATH, JSON.stringify(m, null, 2) + \"\\n\");\n}\n\nfunction isPlainObject(v: unknown): v is Record<string, unknown> {\n return typeof v === \"object\" && v !== null && !Array.isArray(v);\n}\n\nfunction deepMerge<T>(target: T, source: T): T {\n if (Array.isArray(target) && Array.isArray(source)) {\n return [...target, ...source] as T;\n }\n if (isPlainObject(target) && isPlainObject(source)) {\n const out: Record<string, unknown> = { ...target };\n for (const [k, v] of Object.entries(source)) {\n out[k] = k in out ? deepMerge(out[k], v) : v;\n }\n return out as T;\n }\n return source;\n}\n\nfunction hashHookSource(name: string): string {\n const p = join(HOOKS_SRC, name, \"hook.ts\");\n return shortHash(readFileSync(p));\n}\n\nfunction hashSkillSource(name: string): string {\n const dir = join(SKILLS_SRC, name);\n const files = collectFiles(dir).sort();\n const h = createHash(\"sha256\");\n for (const f of files) {\n h.update(relative(dir, f));\n h.update(\"\\0\");\n h.update(readFileSync(f));\n h.update(\"\\0\");\n }\n return h.digest(\"hex\").slice(0, 7);\n}\n\nfunction collectFiles(dir: string): string[] {\n const out: string[] = [];\n if (!existsSync(dir)) {\n return out;\n }\n\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (entry.name === \".gitkeep\") {\n continue;\n }\n\n const full = join(dir, entry.name);\n if (entry.isDirectory()) {\n out.push(...collectFiles(full));\n } else if (entry.isFile()) {\n out.push(full);\n }\n }\n return out;\n}\n\nasync function confirm(question: string): Promise<boolean> {\n const rl = createInterface({ input: process.stdin, output: process.stdout });\n const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();\n rl.close();\n return answer === \"y\" || answer === \"yes\";\n}\n\nfunction diffLines(oldStr: string, newStr: string): string {\n const a = oldStr.split(\"\\n\");\n const b = newStr.split(\"\\n\");\n const out: string[] = [];\n const max = Math.max(a.length, b.length);\n for (let i = 0; i < max; i++) {\n if (a[i] === b[i]) {\n continue;\n }\n\n if (a[i] !== undefined) {\n out.push(`- ${a[i]}`);\n }\n\n if (b[i] !== undefined) {\n out.push(`+ ${b[i]}`);\n }\n }\n return out.join(\"\\n\");\n}\n\n// ---------- commands ----------\n\nfunction addHook(name: string): void {\n const srcDir = join(HOOKS_SRC, name);\n if (!existsSync(srcDir)) {\n console.error(`Hook not found: ${name}`);\n process.exit(1);\n }\n\n const hookSrc = join(srcDir, \"hook.ts\");\n const fragmentPath = join(srcDir, \"settings-fragment.json\");\n\n const hooksDir = join(CLAUDE_DIR, \"hooks\");\n mkdirSync(hooksDir, { recursive: true });\n const destHook = join(hooksDir, `${name}.ts`);\n writeFileSync(destHook, readFileSync(hookSrc));\n\n if (existsSync(fragmentPath)) {\n const fragment = JSON.parse(readFileSync(fragmentPath, \"utf8\"));\n const settingsPath = join(CLAUDE_DIR, \"settings.json\");\n const current = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, \"utf8\")) : {};\n const merged = deepMerge(current, fragment);\n writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + \"\\n\");\n }\n\n const manifest = readManifest();\n manifest.hooks[name] = { hash: hashHookSource(name), installedAt: today() };\n writeManifest(manifest);\n\n console.log(`Installed hook: ${name} → ${relative(PROJECT_ROOT, destHook)}`);\n}\n\nfunction addSkill(name: string, links: string[]): void {\n const srcDir = join(SKILLS_SRC, name);\n if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) {\n console.error(`Skill not found: ${name}`);\n process.exit(1);\n }\n\n const destDir = join(TOOLKIT_DIR, \"skills\", name);\n mkdirSync(dirname(destDir), { recursive: true });\n cpSync(srcDir, destDir, { recursive: true });\n\n const resolvedLinks = links.length > 0 ? links : [join(\".claude\", \"skills\")];\n for (const link of resolvedLinks) {\n const linkDir = resolve(PROJECT_ROOT, link);\n mkdirSync(linkDir, { recursive: true });\n\n const linkPath = join(linkDir, name);\n if (existsSync(linkPath) || lstatExists(linkPath)) {\n unlinkSync(linkPath);\n }\n\n const relTarget = relative(linkDir, destDir);\n symlinkSync(relTarget, linkPath, \"dir\");\n }\n\n const manifest = readManifest();\n manifest.skills[name] = {\n hash: hashSkillSource(name),\n installedAt: today(),\n linkedTo: resolvedLinks,\n };\n writeManifest(manifest);\n\n console.log(`Installed skill: ${name} → ${relative(PROJECT_ROOT, destDir)}`);\n for (const l of resolvedLinks) {\n console.log(` linked: ${join(l, name)}`);\n }\n}\n\nfunction lstatExists(p: string): boolean {\n try {\n lstatSync(p);\n return true;\n } catch {\n return false;\n }\n}\n\nasync function update(force: boolean): Promise<void> {\n const manifest = readManifest();\n let changed = false;\n\n for (const [name, entry] of Object.entries(manifest.hooks)) {\n const srcDir = join(HOOKS_SRC, name);\n if (!existsSync(srcDir)) {\n continue;\n }\n\n const sourceHash = hashHookSource(name);\n const installedPath = join(CLAUDE_DIR, \"hooks\", `${name}.ts`);\n const installedHash = existsSync(installedPath) ? shortHash(readFileSync(installedPath)) : null;\n\n const sourceChanged = sourceHash !== entry.hash;\n const locallyModified = installedHash !== null && installedHash !== entry.hash;\n\n if (!sourceChanged && !locallyModified) {\n continue;\n }\n\n changed = true;\n\n if (locallyModified && !force) {\n console.warn(\n `! hook \"${name}\" was modified locally (installed=${installedHash}, manifest=${entry.hash}). Use --force to overwrite.`,\n );\n continue;\n }\n\n if (sourceChanged) {\n const oldSrc = existsSync(installedPath) ? readFileSync(installedPath, \"utf8\") : \"\";\n const newSrc = readFileSync(join(srcDir, \"hook.ts\"), \"utf8\");\n console.log(`\\n~ hook: ${name} (${entry.hash} → ${sourceHash})`);\n console.log(diffLines(oldSrc, newSrc));\n const ok = force || (await confirm(`Update hook \"${name}\"?`));\n\n if (!ok) {\n continue;\n }\n\n writeFileSync(installedPath, newSrc);\n manifest.hooks[name] = { hash: sourceHash, installedAt: today() };\n }\n }\n\n for (const [name, entry] of Object.entries(manifest.skills)) {\n const srcDir = join(SKILLS_SRC, name);\n if (!existsSync(srcDir)) {\n continue;\n }\n\n const sourceHash = hashSkillSource(name);\n if (sourceHash === entry.hash) {\n continue;\n }\n\n changed = true;\n console.log(`\\n~ skill: ${name} (${entry.hash} → ${sourceHash})`);\n const ok = force || (await confirm(`Update skill \"${name}\"?`));\n if (!ok) {\n continue;\n }\n\n const destDir = join(TOOLKIT_DIR, \"skills\", name);\n cpSync(srcDir, destDir, { recursive: true, force: true });\n manifest.skills[name] = {\n hash: sourceHash,\n installedAt: today(),\n linkedTo: entry.linkedTo,\n };\n }\n\n if (changed) {\n writeManifest(manifest);\n }\n}\n\nfunction list(kind: \"hook\" | \"skill\"): void {\n const dir = kind === \"hook\" ? HOOKS_SRC : SKILLS_SRC;\n if (!existsSync(dir)) {\n console.log(`(no ${kind}s available)`);\n return;\n }\n const entries = readdirSync(dir, { withFileTypes: true })\n .filter((e) => e.isDirectory() || (kind === \"skill\" && e.isSymbolicLink()))\n .map((e) => e.name);\n\n if (entries.length === 0) {\n console.log(`(no ${kind}s available)`);\n return;\n }\n\n for (const name of entries) {\n const hash = kind === \"hook\" ? hashHookSource(name) : hashSkillSource(name);\n console.log(`${name} ${hash}`);\n }\n}\n\n// ---------- argv ----------\n\nfunction usage(): never {\n console.error(\n `Usage:\n toolkit add hook <name>\n toolkit add skill <name> [--link <target>]...\n toolkit update [--force]\n toolkit list hook\n toolkit list skill`,\n );\n process.exit(1);\n}\n\nasync function main(): Promise<void> {\n const { values, positionals } = parseArgs({\n options: {\n force: {\n default: false,\n type: \"boolean\",\n },\n links: {\n multiple: true,\n type: \"string\",\n },\n },\n allowPositionals: true,\n });\n\n const { force, links } = values;\n const [command, resource, name] = positionals;\n\n if (command === \"add\" && resource === \"hook\") {\n if (!name) {\n usage();\n }\n\n addHook(name);\n return;\n }\n\n if (command === \"add\" && resource === \"skill\") {\n if (!name) {\n usage();\n }\n\n addSkill(name, links ? links : []);\n return;\n }\n\n if (command === \"update\") {\n await update(force);\n return;\n }\n\n if (command === \"list\" && (resource === \"hook\" || resource === \"skill\")) {\n list(resource);\n return;\n }\n\n usage();\n}\n\nmain().catch((err) => {\n console.error(err instanceof Error ? err.message : String(err));\n process.exit(1);\n});\n"],"mappings":"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"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env -S node
|
|
2
|
+
/**
|
|
3
|
+
* block-dangerous-commands: PreToolUse hook for Bash.
|
|
4
|
+
*
|
|
5
|
+
* Reads a Claude Code hook payload from stdin, inspects tool_input.command,
|
|
6
|
+
* and blocks commands that match known-dangerous patterns.
|
|
7
|
+
*
|
|
8
|
+
* Exit 2 = block (with structured JSON on stdout)
|
|
9
|
+
* Exit 0 = allow (including on malformed input — fail open)
|
|
10
|
+
*/
|
|
11
|
+
const rules = [
|
|
12
|
+
{
|
|
13
|
+
id: "rm-rf",
|
|
14
|
+
test: (c) => /\brm\s+(?:-[a-zA-Z]*[rRf][a-zA-Z]*|--recursive|--force)(?:\s|$)/.test(c),
|
|
15
|
+
message: "`rm -rf` (and flag variants) is blocked. Delete specific paths with a non-recursive `rm`, or move them to a backup location.",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "git-push-force",
|
|
19
|
+
test: (c) => /\bgit\s+push\b.*\s(?:--force\b|--force-with-lease\b|-f\b)/.test(c),
|
|
20
|
+
message: "`git push --force` is blocked. Use `--force-with-lease` only after coordinating with collaborators, or create a new branch.",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "git-push-protected",
|
|
24
|
+
test: (c) => /\bgit\s+push\b(?:\s+\S+)*\s+(?:origin\s+)?(?:main|master|production|prod|release)(?:\s|$)/.test(c),
|
|
25
|
+
message: "Direct push to a protected branch (main/master/production/prod/release) is blocked. Open a pull request instead.",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "git-reset-hard",
|
|
29
|
+
test: (c) => /\bgit\s+reset\s+(?:\S+\s+)*--hard\b/.test(c),
|
|
30
|
+
message: "`git reset --hard` is blocked — it discards uncommitted work. Consider `git stash`, `git restore`, or a soft reset.",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "chmod-777",
|
|
34
|
+
test: (c) => /\bchmod\s+(?:-[a-zA-Z]*\s+)*(?:777|[ugoa]*[+=][rwx]*w[rwx]*(?:\s|$))/.test(c) &&
|
|
35
|
+
/-R|--recursive|777/.test(c),
|
|
36
|
+
message: "`chmod 777` or recursive world-writable chmod is blocked. Grant the minimum permissions required.",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "dd-if",
|
|
40
|
+
test: (c) => /\bdd\s+if=/.test(c),
|
|
41
|
+
message: "`dd if=` is blocked — it can overwrite disks irrecoverably.",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "system-redirect",
|
|
45
|
+
test: (c) => /(?:>|>>|tee(?:\s+-[a-zA-Z]*)?)\s+\/(?:etc|boot|usr|bin|sbin)\//.test(c),
|
|
46
|
+
message: "Writing into /etc, /boot, /usr, /bin, or /sbin is blocked. These are system directories; use a user-writable path.",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "fork-bomb",
|
|
50
|
+
test: (c) => /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(c) || /\.\s*\|\s*\.\s*&/.test(c),
|
|
51
|
+
message: "Fork bomb pattern detected and blocked.",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "curl-pipe-shell",
|
|
55
|
+
test: (c) => /\b(?:curl|wget|fetch)\b[^|;]*\|\s*(?:sudo\s+)?(?:bash|sh|zsh|fish|ksh|dash)\b/.test(c),
|
|
56
|
+
message: "Piping remote content directly into a shell is blocked. Download the script, inspect it, then run it.",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "pkill",
|
|
60
|
+
test: (c) => /\bpkill\b/.test(c),
|
|
61
|
+
message: "`pkill` is blocked — it can terminate unrelated processes by name. Kill a specific PID instead.",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "kill-9",
|
|
65
|
+
test: (c) => /\bkill\s+(?:-[a-zA-Z]*\s+)*-9\b|\bkill\s+-s\s+(?:9|SIGKILL)\b|\bkill\s+-SIGKILL\b/.test(c),
|
|
66
|
+
message: "`kill -9` is blocked — it prevents cleanup. Try SIGTERM (default) first.",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "npm-publish",
|
|
70
|
+
test: (c) => /\bnpm\s+(?:publish|deprecate|unpublish)\b/.test(c),
|
|
71
|
+
message: "`npm publish`/`deprecate`/`unpublish` is blocked. Publishing should be done deliberately, outside of an agent session.",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "history-clear",
|
|
75
|
+
test: (c) => /\bhistory\s+-c\b/.test(c),
|
|
76
|
+
message: "`history -c` is blocked — erasing shell history hides what happened.",
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
async function readStdin() {
|
|
80
|
+
const chunks = [];
|
|
81
|
+
for await (const chunk of process.stdin)
|
|
82
|
+
chunks.push(chunk);
|
|
83
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
84
|
+
}
|
|
85
|
+
function deny(reason) {
|
|
86
|
+
const output = {
|
|
87
|
+
hookSpecificOutput: {
|
|
88
|
+
hookEventName: "PreToolUse",
|
|
89
|
+
permissionDecision: "deny",
|
|
90
|
+
permissionDecisionReason: reason,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
process.stdout.write(JSON.stringify(output));
|
|
94
|
+
process.exit(2);
|
|
95
|
+
}
|
|
96
|
+
async function main() {
|
|
97
|
+
let payload;
|
|
98
|
+
try {
|
|
99
|
+
const raw = await readStdin();
|
|
100
|
+
if (!raw.trim()) {
|
|
101
|
+
process.exit(0);
|
|
102
|
+
}
|
|
103
|
+
payload = JSON.parse(raw);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
const command = payload?.tool_input?.command;
|
|
109
|
+
if (typeof command !== "string" || command.length === 0) {
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
for (const rule of rules) {
|
|
113
|
+
if (rule.test(command)) {
|
|
114
|
+
deny(`[${rule.id}] ${rule.message}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
main().catch(() => process.exit(0));
|
|
120
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@schalkneethling/toolkit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for managing Claude Code hooks and skills across projects.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"toolkit": "./dist/index.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"hooks",
|
|
12
|
+
"!**/*.ts",
|
|
13
|
+
"skills"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"toolkit": "tsx src/index.ts",
|
|
23
|
+
"prepare": "vp pack && vp run build:hooks",
|
|
24
|
+
"build:hooks": "tsc --project tsconfig.hooks.json"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"vite-plus": "^0.1.18"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.19.17",
|
|
31
|
+
"tsx": "^4.21.0",
|
|
32
|
+
"typescript": "^5.9.3"
|
|
33
|
+
},
|
|
34
|
+
"packageManager": "pnpm@10.33.0"
|
|
35
|
+
}
|