claude-toolkit 0.9.0 → 0.10.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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 (2026-06-09)
4
+
5
+ One idempotent CLI command replaces `init`/`update`/`sync`, and `.claude/` now regenerates itself on toolkit upgrade.
6
+
7
+ - feat: `bunx claude-toolkit` does it all — creates the config from stack detection on the first run, then cleanly regenerates `.claude/` on every run. `--update` pulls newly-detected stacks into the config. `init`/`update`/`sync` remain as back-compat aliases (`sync` deprecated).
8
+ - feat: automatic regeneration on install — a `postinstall` hook rebuilds `.claude/` when the installed toolkit version changes, so an upgrade ships its updated skills without running anything. Never fails the consumer's install and never writes committed files.
9
+ - feat: clean rebuilds — generation removes toolkit-owned (`ct-` prefixed) skills, agents, commands, and hooks before regenerating, so a stack removed from the config leaves no stale skills behind; user-authored files in `.claude/` are preserved.
10
+ - feat: stricter CLI parsing — unknown flags and typo'd commands now error instead of silently doing nothing; added `--quiet`/`-q`.
11
+ - docs: README and reference docs updated for the single-command workflow and conventional-commit versioning.
12
+
3
13
  ## 0.9.0 (2026-06-08)
4
14
 
5
15
  Re-baselined from 0.1.x to reflect accumulated scope: 10 stack connectors, the full `init`/`update`/`sync` CLI, stack auto-detection with drift and monorepo/workspace support, and a complete skill/command/agent/hook system. Versioning is now conventional-commit-driven from this release onward.
package/README.md CHANGED
@@ -14,15 +14,16 @@ Whether you're starting a new project from scratch or consolidating an existing
14
14
  # Install as a dev dependency
15
15
  bun add -d claude-toolkit
16
16
 
17
- # Scaffold config and generate .claude/
18
- bunx claude-toolkit init
17
+ # Generate .claude/ — creates the config from detection on the first run
18
+ bunx claude-toolkit
19
19
  ```
20
20
 
21
21
  ## How It Works
22
22
 
23
- 1. You define a `claude-toolkit.config.ts` at your project root
24
- 2. The toolkit generates a `.claude/` directory with skills, hooks, commands, and agents
23
+ 1. Run `bunx claude-toolkit` — on the first run it creates a `claude-toolkit.config.ts` at your project root, pre-filled with the stacks it detects
24
+ 2. It generates a `.claude/` directory with skills, hooks, commands, and agents — regenerated cleanly each run, so stacks you remove leave no stale skills behind
25
25
  3. Claude Code picks up the generated config automatically
26
+ 4. `.claude/` regenerates automatically on install whenever the toolkit version changes, so upgrades land without running anything
26
27
 
27
28
  ```ts
28
29
  import { defineConfig } from "claude-toolkit";
@@ -45,18 +46,19 @@ export default defineConfig({
45
46
 
46
47
  ## CLI Commands
47
48
 
48
- | Command | Description |
49
- | ---------------------------- | ---------------------------------------------------- |
50
- | `bunx claude-toolkit init` | Scaffold config and generate `.claude/` (first run) |
51
- | `bunx claude-toolkit update` | Add newly detected stacks to config, then regenerate |
52
- | `bunx claude-toolkit sync` | Regenerate `.claude/` from the current config |
53
- | `bunx claude-toolkit help` | Show available commands |
49
+ | Command | Description |
50
+ | ------------------------------ | ----------------------------------------------------------------------- |
51
+ | `bunx claude-toolkit` | Create the config if missing, then (re)generate `.claude/`. Idempotent. |
52
+ | `bunx claude-toolkit --update` | Same, and also add newly-detected stacks to your config |
53
+ | `bunx claude-toolkit help` | Show available commands |
54
+
55
+ Aliases (back-compat): `init` is a friendly name for the bare command, `update` equals `--update`, and `sync` is deprecated — use the bare command. `.claude/` also regenerates automatically on install when the toolkit version changes.
54
56
 
55
57
  ## Stack Auto-Detection
56
58
 
57
- The toolkit automatically detects which stacks your project uses by scanning `package.json` dependencies, config files, and project structure.
59
+ The toolkit detects which stacks your project uses by scanning `package.json` dependencies, config files, and project structure.
58
60
 
59
- **On `init`** (new project), detected stacks are pre-filled in the generated config:
61
+ **First run** (no config yet) detected stacks are pre-filled into a new config:
60
62
 
61
63
  ```text
62
64
  Detected stacks:
@@ -65,36 +67,31 @@ Detected stacks:
65
67
  cloudflare — found wrangler.toml
66
68
 
67
69
  Created claude-toolkit.config.ts
70
+ Generated .claude/ with 3 stack(s) and 4 core skills
68
71
  ```
69
72
 
70
- **On `sync`** (existing config), the toolkit compares your configured stacks against what it detects and reports any drift:
73
+ **Later runs** (config exists) `bunx claude-toolkit` regenerates `.claude/` and reports drift between your config and what it detects, but never edits the config:
71
74
 
72
75
  ```text
73
76
  Stack drift detected:
74
- + playwright — found @playwright/test in dependencies (not in config)
75
- - rust-wasm — in config but not detected in project
77
+ + playwright — found @playwright/test in dependencies (detected, not in config)
78
+ - rust-wasm — in config, not detected
76
79
 
77
- Suggested update in claude-toolkit.config.ts:
78
- stacks: ["solidjs", "vite", "cloudflare", "playwright"]
79
-
80
- Run "claude-toolkit update" to add detected stacks automatically.
80
+ Run "bunx claude-toolkit --update" to add detected stacks to your config.
81
81
  ```
82
82
 
83
- `sync` is non-destructive it reports drift but never edits your config.
84
-
85
- **On `update`** (existing config), the toolkit adds any newly detected stacks to your config and regenerates `.claude/` in one step — use this when you add a new stack (e.g. Capacitor) to an existing project:
83
+ **Pulling in new stacks** — `bunx claude-toolkit --update` adds any newly-detected stacks to your config and regenerates in one step (use it when you add a stack, e.g. Capacitor):
86
84
 
87
85
  ```text
88
86
  Adding newly detected stacks to config:
89
87
  + capacitor — found @capacitor/core in dependencies
90
88
  Updated claude-toolkit.config.ts
91
- Generated .claude/ with 3 stack(s) and 4 core skills
92
- Update complete.
89
+ Generated .claude/ with 4 stack(s) and 4 core skills
93
90
  ```
94
91
 
95
- Stacks already in your config that are no longer detected are reported but left unchanged (remove them manually if intended). If no config exists yet, `update` tells you to run `init` first.
92
+ Stacks in your config that are no longer detected are reported but left unchanged (remove them manually if intended).
96
93
 
97
- This keeps your config aligned as your project evolves `init` for first-time setup, `update` to pull in new stacks, `sync` to regenerate from the current config.
94
+ **Automatic regeneration** when the installed toolkit version changes, `.claude/` is regenerated on install automatically (via a `postinstall` hook), so a toolkit upgrade ships its updated skills without you running anything. This only refreshes the generated `.claude/` — it never creates or edits committed files.
98
95
 
99
96
  ## Available Stacks
100
97
 
@@ -163,14 +160,12 @@ Full reference documentation for all skills, commands, and agents is available i
163
160
 
164
161
  ## Versioning
165
162
 
166
- The patch version auto-increments on every commit via a post-commit hook. `CHANGELOG.md` is updated automatically with the commit message.
163
+ Version bumps are derived from your commit messages (Conventional Commits) by a post-commit hook, and `CHANGELOG.md` is updated automatically:
167
164
 
168
- To bump major or minor versions manually:
165
+ - `feat:` → minor · `fix:` / `perf:` → patch · `feat!:` / `BREAKING CHANGE` → major (capped to minor while pre-1.0)
166
+ - `docs:` / `chore:` / `refactor:` / `style:` / `test:` / `ci:` / `build:` → no version change
169
167
 
170
- ```bash
171
- bun version major # 0.1.x → 1.0.0
172
- bun version minor # 0.1.x → 0.2.0
173
- ```
168
+ To set an exact version deliberately (a re-baseline, or `1.0.0`), edit `package.json` and prepend a `CHANGELOG.md` entry, then commit with `SKIP_POST_COMMIT=1` so the hook doesn't re-bump, and tag `vX.Y.Z`. Publishing to npm happens automatically when a GitHub Release is published (see `.github/workflows/publish.yml`).
174
169
 
175
170
  ## Development
176
171
 
package/bin/cli.ts CHANGED
@@ -3,10 +3,17 @@
3
3
  /**
4
4
  * claude-toolkit CLI
5
5
  *
6
- * Commands:
7
- * init — Scaffold config file and generate .claude/ (first-time setup)
8
- * update Add newly detected stacks to an existing config, then regenerate
9
- * sync — Regenerate .claude/ from existing config
6
+ * One idempotent command does it all:
7
+ * bunx claude-toolkit Create the config if missing, then (re)generate .claude/
8
+ * bunx claude-toolkit --update Also pull newly-detected stacks into the config
9
+ *
10
+ * Aliases / back-compat:
11
+ * init Friendly name for the first run (same as the bare command)
12
+ * update Same as `--update`
13
+ * sync Deprecated alias of the bare command
14
+ *
15
+ * .claude/ is also regenerated automatically on install when the installed
16
+ * toolkit version changes (see bin/postinstall.mjs).
10
17
  */
11
18
 
12
19
  import { existsSync } from "node:fs";
@@ -18,12 +25,12 @@ import type { ClaudeToolkitConfig } from "../src/types.js";
18
25
 
19
26
  const CONFIG_FILENAME = "claude-toolkit.config.ts";
20
27
 
21
- /** Build a `stacks: [...]` literal for injection into the config file */
28
+ /** Build a `stacks: [...]` literal for injection into the config file. */
22
29
  function buildStacksLiteral(stacks: string[]): string {
23
30
  return stacks.length > 0 ? `stacks: [${stacks.map((s) => `"${s}"`).join(", ")}]` : "stacks: []";
24
31
  }
25
32
 
26
- /** Rewrite the `stacks: [...]` array in an existing config file in place */
33
+ /** Rewrite the `stacks: [...]` array in an existing config file in place. */
27
34
  async function updateConfigStacks(configPath: string, stacks: string[]): Promise<void> {
28
35
  const content = await readFile(configPath, "utf-8");
29
36
  const stacksArray = /stacks:\s*\[[^\]]*\]/;
@@ -39,194 +46,223 @@ async function updateConfigStacks(configPath: string, stacks: string[]): Promise
39
46
  async function loadConfig(projectDir: string): Promise<ClaudeToolkitConfig> {
40
47
  const configPath = join(projectDir, CONFIG_FILENAME);
41
48
  if (!existsSync(configPath)) {
42
- throw new Error(`Config not found: ${configPath}\nRun "claude-toolkit init" first.`);
49
+ throw new Error(`Config not found: ${configPath}\nRun "bunx claude-toolkit" first.`);
43
50
  }
44
- const module = await import(configPath);
45
- return module.default as ClaudeToolkitConfig;
46
- }
47
-
48
- async function init(projectDir: string): Promise<void> {
49
- const configPath = join(projectDir, CONFIG_FILENAME);
50
-
51
- if (existsSync(configPath)) {
52
- console.log(`Config already exists: ${configPath}`);
53
- console.log("\nThis project is already initialized. Did you mean to:");
54
- console.log(
55
- " claude-toolkit update # detect & add new stacks to your config, then regenerate",
51
+ let mod: { default?: ClaudeToolkitConfig };
52
+ try {
53
+ mod = await import(configPath);
54
+ } catch (err) {
55
+ throw new Error(
56
+ `Failed to load ${CONFIG_FILENAME}. Make sure claude-toolkit is installed in this project ` +
57
+ `(e.g. "bun add -d claude-toolkit"), then run "bunx claude-toolkit" again.\n ${(err as Error).message}`,
56
58
  );
57
- console.log(" claude-toolkit sync # regenerate .claude/ from the current config");
58
- return;
59
59
  }
60
+ return mod.default as ClaudeToolkitConfig;
61
+ }
60
62
 
61
- // Detect stacks
62
- const detected = detectStacks(projectDir);
63
- if (detected.length > 0) {
64
- console.log("Detected stacks:");
65
- const maxLen = Math.max(...detected.map((d) => d.name.length));
66
- for (const d of detected) {
67
- console.log(` ${d.name.padEnd(maxLen)} — ${d.reason}`);
68
- }
69
- } else {
70
- console.log(`No stacks detected. You can add them manually in ${CONFIG_FILENAME}`);
71
- }
72
-
73
- // Build stacks literal for config injection
74
- const stacksLiteral = buildStacksLiteral(detected.map((d) => d.name));
75
-
76
- // Copy starter config with detected stacks injected
63
+ /** Create claude-toolkit.config.ts from the template with detected stacks injected. */
64
+ async function scaffoldConfig(configPath: string, stacks: string[]): Promise<void> {
65
+ const stacksLiteral = buildStacksLiteral(stacks);
77
66
  const templatePath = join(import.meta.dirname, "..", "templates", "claude-toolkit.config.ts");
67
+ let content: string;
78
68
  if (existsSync(templatePath)) {
79
- const template = await readFile(templatePath, "utf-8");
80
- const configContent = template.replace("stacks: []", stacksLiteral);
81
- await writeFile(configPath, configContent, "utf-8");
82
- console.log(`Created ${CONFIG_FILENAME}`);
69
+ content = (await readFile(templatePath, "utf-8")).replace("stacks: []", stacksLiteral);
83
70
  } else {
84
- // Inline fallback
85
- const defaultConfig = `import { defineConfig } from 'claude-toolkit'
71
+ content = `import { defineConfig } from "claude-toolkit";
86
72
 
87
73
  export default defineConfig({
88
- ${stacksLiteral},
89
- packageManager: 'bun',
90
- hooks: {
91
- formatter: 'bun run prettier --write',
92
- testRunner: 'bun run vitest run',
93
- typeCheck: 'bun run tsc --noEmit',
94
- },
95
- git: {
96
- branchPrefix: 'dev',
97
- protectedBranches: ['main'],
98
- },
99
- })
74
+ ${stacksLiteral},
75
+ packageManager: "bun",
76
+ hooks: {
77
+ formatter: "bun run prettier --write",
78
+ testRunner: "bun run vitest run",
79
+ typeCheck: "bun run tsc --noEmit",
80
+ },
81
+ git: {
82
+ branchPrefix: "dev",
83
+ protectedBranches: ["main"],
84
+ },
85
+ });
100
86
  `;
101
- await writeFile(configPath, defaultConfig, "utf-8");
102
- console.log(`Created ${CONFIG_FILENAME}`);
103
87
  }
88
+ await writeFile(configPath, content, "utf-8");
89
+ }
104
90
 
105
- // Generate
106
- return sync(projectDir);
91
+ interface RunOptions {
92
+ /** Also write newly-detected stacks into the config (the old `update`). */
93
+ update?: boolean;
94
+ /** Suppress informational output. */
95
+ quiet?: boolean;
107
96
  }
108
97
 
109
- async function sync(projectDir: string): Promise<void> {
110
- const config = await loadConfig(projectDir);
98
+ /**
99
+ * The one command. Idempotent:
100
+ * - no config yet -> create it from detection
101
+ * - config exists -> report drift (or, with `update`, merge detected stacks in)
102
+ * Always ends by cleanly regenerating .claude/.
103
+ */
104
+ async function run(projectDir: string, options: RunOptions = {}): Promise<void> {
105
+ const { update = false, quiet = false } = options;
106
+ const log = quiet ? (_msg = "") => {} : (msg = "") => console.log(msg);
107
+ const configPath = join(projectDir, CONFIG_FILENAME);
111
108
 
112
- // Compare detected stacks against config
113
- const detected = detectStacks(projectDir);
114
- const configuredNames = new Set(config.stacks);
115
- const detectedNames = new Set(detected.map((d) => d.name));
109
+ let config: ClaudeToolkitConfig;
116
110
 
117
- const missing = detected.filter((d) => !configuredNames.has(d.name));
118
- const stale = config.stacks.filter((s) => !detectedNames.has(s));
111
+ if (!existsSync(configPath)) {
112
+ // First run: create the config from what we detect.
113
+ const detected = detectStacks(projectDir);
114
+ if (detected.length > 0) {
115
+ log("Detected stacks:");
116
+ const pad = Math.max(...detected.map((d) => d.name.length));
117
+ for (const d of detected) log(` ${d.name.padEnd(pad)} — ${d.reason}`);
118
+ } else {
119
+ log(`No stacks detected. Add them manually in ${CONFIG_FILENAME}.`);
120
+ }
121
+ await scaffoldConfig(
122
+ configPath,
123
+ detected.map((d) => d.name),
124
+ );
125
+ log(`Created ${CONFIG_FILENAME}`);
126
+ // Build the config in memory (mirroring the scaffolded template defaults) so the
127
+ // first run never depends on importing the freshly-written config file — which
128
+ // imports "claude-toolkit" and would fail if the package isn't installed yet.
129
+ config = {
130
+ stacks: detected.map((d) => d.name),
131
+ packageManager: "bun",
132
+ hooks: {
133
+ formatter: "bun run prettier --write",
134
+ testRunner: "bun run vitest run",
135
+ typeCheck: "bun run tsc --noEmit",
136
+ },
137
+ git: { branchPrefix: "dev", protectedBranches: ["main"] },
138
+ };
139
+ } else {
140
+ config = await loadConfig(projectDir);
141
+ const detected = detectStacks(projectDir);
142
+ const configured = new Set(config.stacks);
143
+ const detectedNames = new Set(detected.map((d) => d.name));
144
+ const missing = detected.filter((d) => !configured.has(d.name));
145
+ const stale = config.stacks.filter((s) => !detectedNames.has(s));
119
146
 
120
- if (missing.length > 0 || stale.length > 0) {
121
- console.log("\nStack drift detected:");
122
- if (missing.length > 0) {
123
- const maxLen = Math.max(...missing.map((d) => d.name.length));
147
+ if (update) {
148
+ if (missing.length > 0) {
149
+ log("Adding newly detected stacks to config:");
150
+ const pad = Math.max(...missing.map((d) => d.name.length));
151
+ for (const d of missing) log(` + ${d.name.padEnd(pad)} — ${d.reason}`);
152
+ config.stacks = [...config.stacks, ...missing.map((d) => d.name)];
153
+ await updateConfigStacks(configPath, config.stacks);
154
+ log(`Updated ${CONFIG_FILENAME}`);
155
+ } else {
156
+ log("Config already includes all detected stacks.");
157
+ }
158
+ if (stale.length > 0) {
159
+ log("\nIn config but not detected (left unchanged):");
160
+ for (const s of stale) log(` - ${s}`);
161
+ log("Remove them from the config manually if they no longer apply.");
162
+ }
163
+ } else if (missing.length > 0 || stale.length > 0) {
164
+ log("\nStack drift detected:");
165
+ const pad = Math.max(1, ...missing.map((d) => d.name.length));
124
166
  for (const d of missing) {
125
- console.log(` + ${d.name.padEnd(maxLen)} — ${d.reason} (not in config)`);
167
+ log(` + ${d.name.padEnd(pad)} — ${d.reason} (detected, not in config)`);
126
168
  }
127
- }
128
- if (stale.length > 0) {
129
- for (const s of stale) {
130
- console.log(` - ${s} — in config but not detected in project`);
169
+ for (const s of stale) log(` - ${s} — in config, not detected`);
170
+ if (missing.length > 0) {
171
+ log(`\nRun "bunx claude-toolkit --update" to add detected stacks to your config.`);
131
172
  }
132
173
  }
133
- const suggested = [
134
- ...new Set([
135
- ...config.stacks.filter((s) => !stale.includes(s)),
136
- ...missing.map((d) => d.name),
137
- ]),
138
- ];
139
- console.log(`\nSuggested update in ${CONFIG_FILENAME}:`);
140
- console.log(` ${buildStacksLiteral(suggested)}`);
141
- if (missing.length > 0) {
142
- console.log('\nRun "claude-toolkit update" to add detected stacks automatically.\n');
143
- }
144
174
  }
145
175
 
146
- await generate(projectDir, config);
147
- console.log("Sync complete.");
176
+ await generate(projectDir, config, { quiet });
177
+ log("Done.");
148
178
  }
149
179
 
150
- /**
151
- * Update an existing config by adding newly detected stacks, then regenerate.
152
- * Detected-but-missing stacks are added; stacks in config that are no longer
153
- * detected are reported but left unchanged. Errors out if no config exists.
154
- */
155
- async function update(projectDir: string): Promise<void> {
156
- const configPath = join(projectDir, CONFIG_FILENAME);
157
- if (!existsSync(configPath)) {
158
- console.error(`No ${CONFIG_FILENAME} found in ${projectDir}.`);
159
- console.error(`Run "claude-toolkit init" to create one first.`);
160
- process.exit(1);
161
- }
162
-
180
+ /** postinstall: quietly regenerate from an existing config. Never creates one. */
181
+ async function postinstall(projectDir: string): Promise<void> {
182
+ if (!existsSync(join(projectDir, CONFIG_FILENAME))) return;
163
183
  const config = await loadConfig(projectDir);
164
- const detected = detectStacks(projectDir);
165
- const configuredNames = new Set(config.stacks);
166
- const detectedNames = new Set(detected.map((d) => d.name));
184
+ // scaffold:false never write committed project files (biome.json/tsconfig.json) on install.
185
+ await generate(projectDir, config, { quiet: true, scaffold: false });
186
+ console.log("[claude-toolkit] Regenerated .claude/ for the updated toolkit version.");
187
+ }
167
188
 
168
- const missing = detected.filter((d) => !configuredNames.has(d.name));
169
- const stale = config.stacks.filter((s) => !detectedNames.has(s));
189
+ const HELP = `
190
+ claude-toolkit Reusable Claude Code configuration
170
191
 
171
- if (missing.length === 0) {
172
- console.log("Config is already up to date all detected stacks are present.");
173
- } else {
174
- console.log("Adding newly detected stacks to config:");
175
- const maxLen = Math.max(...missing.map((d) => d.name.length));
176
- for (const d of missing) {
177
- console.log(` + ${d.name.padEnd(maxLen)} — ${d.reason}`);
178
- }
179
- const nextStacks = [...config.stacks, ...missing.map((d) => d.name)];
180
- await updateConfigStacks(configPath, nextStacks);
181
- config.stacks = nextStacks;
182
- console.log(`Updated ${CONFIG_FILENAME}`);
183
- }
192
+ Usage:
193
+ bunx claude-toolkit [project-dir] Create config if missing, then regenerate .claude/
194
+ bunx claude-toolkit --update [project-dir] Also add newly-detected stacks to the config
184
195
 
185
- if (stale.length > 0) {
186
- console.log("\nIn config but not detected (left unchanged):");
187
- for (const s of stale) {
188
- console.log(` - ${s}`);
189
- }
190
- console.log("Remove them from the config manually if they no longer apply.");
191
- }
196
+ Commands (aliases):
197
+ init First-run friendly name (same as the bare command)
198
+ update Same as --update
199
+ sync Deprecated — use the bare command
200
+ help Show this message
192
201
 
193
- await generate(projectDir, config);
194
- console.log("Update complete.");
195
- }
202
+ Flags:
203
+ --update, -u Add newly-detected stacks to the config before regenerating
204
+ --quiet, -q Suppress informational output
196
205
 
197
- // CLI entry
198
- const args = process.argv.slice(2);
199
- const command = args[0];
200
- const projectDir = resolve(args[1] ?? ".");
201
-
202
- switch (command) {
203
- case "init":
204
- await init(projectDir);
205
- break;
206
- case "sync":
207
- await sync(projectDir);
208
- break;
209
- case "update":
210
- await update(projectDir);
211
- break;
212
- case undefined:
213
- case "help":
214
- console.log(`
215
- claude-toolkit — Reusable Claude Code configuration
206
+ .claude/ also regenerates automatically on install when the toolkit version changes.
207
+ `;
216
208
 
217
- Commands:
218
- init Scaffold config file and generate .claude/ (first-time setup)
219
- update Add newly detected stacks to an existing config, then regenerate
220
- sync Regenerate .claude/ from the current config
221
- help Show this message
209
+ // ---- entry ----
210
+ const argv = process.argv.slice(2);
211
+ const flags = new Set(argv.filter((a) => a.startsWith("-")));
212
+ const positional = argv.filter((a) => !a.startsWith("-"));
213
+ const COMMANDS = new Set(["init", "update", "sync", "help", "postinstall"]);
214
+ const KNOWN_FLAGS = new Set(["--update", "-u", "--quiet", "-q"]);
222
215
 
223
- Usage:
224
- bunx claude-toolkit init [project-dir]
225
- bunx claude-toolkit update [project-dir]
226
- bunx claude-toolkit sync [project-dir]
227
- `);
228
- break;
229
- default:
230
- console.error(`Unknown command: ${command}`);
216
+ // Reject unknown flags so a typo'd flag (e.g. --updat) isn't silently ignored.
217
+ for (const f of flags) {
218
+ if (!KNOWN_FLAGS.has(f)) {
219
+ console.error(`Unknown flag: ${f}\nRun "bunx claude-toolkit help".`);
231
220
  process.exit(1);
221
+ }
222
+ }
223
+
224
+ const first = positional[0];
225
+ // A positional is treated as a project dir only when it's path-like (".", "..",
226
+ // "./x", or contains a separator) AND exists — so a typo'd command that happens to
227
+ // match a sibling dir name errors instead of silently generating in the wrong place.
228
+ const looksLikePath = (s: string) =>
229
+ s === "." || s === ".." || s.startsWith(".") || /[\\/]/.test(s);
230
+ let command: string | undefined;
231
+ let dirArg: string | undefined;
232
+ if (first && COMMANDS.has(first)) {
233
+ command = first;
234
+ dirArg = positional[1];
235
+ } else if (first && looksLikePath(first) && existsSync(resolve(first))) {
236
+ dirArg = first; // explicit project dir
237
+ } else if (first) {
238
+ console.error(`Unknown command: ${first}\nRun "bunx claude-toolkit help".`);
239
+ process.exit(1);
240
+ }
241
+
242
+ const projectDir = resolve(dirArg ?? ".");
243
+ const wantUpdate = command === "update" || flags.has("--update") || flags.has("-u");
244
+ const quiet = flags.has("--quiet") || flags.has("-q");
245
+
246
+ try {
247
+ switch (command) {
248
+ case "help":
249
+ console.log(HELP);
250
+ break;
251
+ case "postinstall":
252
+ await postinstall(projectDir);
253
+ break;
254
+ case "sync":
255
+ if (!quiet) {
256
+ console.log('Note: "sync" is deprecated — just run "bunx claude-toolkit".');
257
+ }
258
+ await run(projectDir, { update: wantUpdate, quiet });
259
+ break;
260
+ default:
261
+ // undefined (bare command), "init", or "update"
262
+ await run(projectDir, { update: wantUpdate, quiet });
263
+ break;
264
+ }
265
+ } catch (err) {
266
+ console.error(`Error: ${(err as Error).message}`);
267
+ process.exit(1);
232
268
  }
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * claude-toolkit postinstall
5
+ *
6
+ * Runs in the consumer project after install. If a claude-toolkit.config.ts
7
+ * exists, it regenerates .claude/ — but only when the installed toolkit version
8
+ * differs from the one that last generated it, so it fires on a toolkit update,
9
+ * not on every install.
10
+ *
11
+ * Guarantees:
12
+ * - Never fails the consumer's install (all errors swallowed; always exit 0).
13
+ * - Never runs inside the toolkit's own repo (dev install).
14
+ * - Never creates or edits the committed config — only regenerates .claude/.
15
+ * - Writes no committed files when there's no config (prints a one-line nudge).
16
+ */
17
+
18
+ import { spawnSync } from "node:child_process";
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { dirname, join, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ function readJsonSafe(path) {
24
+ try {
25
+ return JSON.parse(readFileSync(path, "utf8"));
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ try {
32
+ const toolkitDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
33
+ // INIT_CWD is set by npm/bun/pnpm/yarn to the dir where install was invoked.
34
+ const consumerRoot = resolve(process.env.INIT_CWD || process.cwd());
35
+
36
+ // 1. Skip when running inside the toolkit's own repo (dev install).
37
+ if (consumerRoot === toolkitDir) process.exit(0);
38
+ const consumerPkg = readJsonSafe(join(consumerRoot, "package.json"));
39
+ if (consumerPkg?.name === "claude-toolkit") process.exit(0);
40
+
41
+ // 2. No config -> stay hands-off; just nudge once. Never write committed files.
42
+ const hasConfig =
43
+ existsSync(join(consumerRoot, "claude-toolkit.config.ts")) ||
44
+ existsSync(join(consumerRoot, "claude-toolkit.config.js"));
45
+ if (!hasConfig) {
46
+ console.log(
47
+ '[claude-toolkit] Run "bunx claude-toolkit" to set up your Claude Code config (.claude/).',
48
+ );
49
+ process.exit(0);
50
+ }
51
+
52
+ // 3. Only regenerate when the toolkit version changed since the last generation.
53
+ const toolkitVersion = readJsonSafe(join(toolkitDir, "package.json"))?.version;
54
+ const markerPath = join(consumerRoot, ".claude", ".toolkit-version");
55
+ const lastVersion = existsSync(markerPath) ? readFileSync(markerPath, "utf8").trim() : null;
56
+ if (toolkitVersion && lastVersion === toolkitVersion) process.exit(0);
57
+
58
+ // 4. Regenerate via the CLI (reuses config loading + clean rebuild). Best-effort:
59
+ // spawnSync sets `.error` if bun is missing — we ignore it rather than fail.
60
+ spawnSync("bun", [join(toolkitDir, "bin", "cli.ts"), "postinstall"], {
61
+ cwd: consumerRoot,
62
+ stdio: "inherit",
63
+ env: { ...process.env, INIT_CWD: consumerRoot },
64
+ });
65
+ } catch {
66
+ // Never break the consumer's install over config regeneration.
67
+ }
68
+
69
+ process.exit(0);
package/docs/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  Complete reference for all skills, commands, and agents provided by claude-toolkit.
4
4
 
5
+ ## Setup
6
+
7
+ Run `bunx claude-toolkit` in your project — on the first run it creates `claude-toolkit.config.ts` (pre-filled from stack detection) and generates `.claude/`. Run it again anytime to regenerate; add `--update` to pull newly-detected stacks into the config. `.claude/` also regenerates automatically on install when the toolkit version changes. See the [README](../README.md#cli-commands) for the full CLI.
8
+
5
9
  ## Core Skills
6
10
 
7
11
  Skills that are always included regardless of stack configuration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-toolkit",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Reusable Claude Code configuration toolkit with stack-specific connectors",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,7 @@
25
25
  "typecheck": "tsc --noEmit",
26
26
  "lint": "biome check --write",
27
27
  "lint:check": "biome check",
28
+ "postinstall": "bun bin/postinstall.mjs || node bin/postinstall.mjs || exit 0",
28
29
  "prepare": "npx husky"
29
30
  },
30
31
  "lint-staged": {
package/src/generator.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { copyFile as fsCopyFile } from "node:fs/promises";
1
+ import { copyFile as fsCopyFile, readdir } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
  import type { ClaudeToolkitConfig, ResolvedConfig, StackPack } from "./types.js";
4
- import { copyDir, exists, readJson, writeFileEnsureDir } from "./utils.js";
4
+ import { copyDir, exists, readJson, removePath, writeFileEnsureDir } from "./utils.js";
5
5
 
6
6
  const TOOLKIT_ROOT = resolve(import.meta.dirname, "..");
7
7
 
@@ -48,11 +48,52 @@ async function resolveConfig(config: ClaudeToolkitConfig): Promise<ResolvedConfi
48
48
  return { config, skills, directoryMappings: allMappings, hooks, stacks };
49
49
  }
50
50
 
51
+ /** Remove only `ct-` prefixed entries in a directory (preserves user-authored files). */
52
+ async function removeCtPrefixed(dir: string): Promise<void> {
53
+ if (!exists(dir)) return;
54
+ const entries = await readdir(dir);
55
+ await Promise.all(
56
+ entries.filter((e) => e.startsWith("ct-")).map((e) => removePath(join(dir, e))),
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Remove toolkit-generated artifacts from .claude/ before a rebuild, so a stack
62
+ * removed from the config doesn't leave an orphaned skill behind.
63
+ *
64
+ * Scoped to what the toolkit owns: `ct-` prefixed skills/agents, the generated
65
+ * skills index, the `ct/` command namespace, and the toolkit's own hook files.
66
+ * `.claude/skills`, `/agents`, and `/hooks` are shared Claude Code namespaces, so
67
+ * any user-authored files there — and the user files at the .claude root
68
+ * (settings.local.json, user-team-info.json, tasks/) — are preserved.
69
+ * (settings.json and .gitignore are single generated files, overwritten by generate.)
70
+ */
71
+ async function removeGenerated(claudeDir: string): Promise<void> {
72
+ const coreHooks = join(TOOLKIT_ROOT, "core", "hooks");
73
+ const hookFiles = exists(coreHooks) ? await readdir(coreHooks) : [];
74
+ await Promise.all([
75
+ removeCtPrefixed(join(claudeDir, "skills")),
76
+ removeCtPrefixed(join(claudeDir, "agents")),
77
+ removePath(join(claudeDir, "skills", "README.md")),
78
+ removePath(join(claudeDir, "commands", "ct")),
79
+ // Toolkit hook files (copied from core/hooks) + the generated skill-rules.json.
80
+ ...hookFiles.map((f) => removePath(join(claudeDir, "hooks", f))),
81
+ removePath(join(claudeDir, "hooks", "skill-rules.json")),
82
+ ]);
83
+ }
84
+
51
85
  /** Generate the .claude/ directory from resolved config */
52
- export async function generate(projectDir: string, config: ClaudeToolkitConfig): Promise<void> {
86
+ export async function generate(
87
+ projectDir: string,
88
+ config: ClaudeToolkitConfig,
89
+ options: { quiet?: boolean; scaffold?: boolean } = {},
90
+ ): Promise<void> {
53
91
  const resolved = await resolveConfig(config);
54
92
  const claudeDir = join(projectDir, ".claude");
55
93
 
94
+ // 0. Clean previously-generated output so removed stacks don't leave stale skills.
95
+ await removeGenerated(claudeDir);
96
+
56
97
  // 1. Copy core hooks
57
98
  await copyDir(join(TOOLKIT_ROOT, "core", "hooks"), join(claudeDir, "hooks"));
58
99
 
@@ -92,18 +133,30 @@ export async function generate(projectDir: string, config: ClaudeToolkitConfig):
92
133
  "# Task-specific context",
93
134
  "tasks/",
94
135
  "",
136
+ "# Toolkit regeneration marker",
137
+ ".toolkit-version",
138
+ "",
95
139
  ].join("\n"),
96
140
  );
97
141
 
98
- // 9. Scaffold base configs
99
- await scaffoldConfigs(projectDir, resolved);
142
+ // 9. Scaffold base configs into the project root (committed files). Skipped for
143
+ // automatic/postinstall regeneration so an install never writes committed files.
144
+ if (options.scaffold !== false) {
145
+ await scaffoldConfigs(projectDir, resolved, options.quiet);
146
+ }
100
147
 
101
148
  // 10. Generate skills README
102
149
  await generateSkillsReadme(claudeDir, resolved);
103
150
 
104
- console.log(
105
- `Generated .claude/ with ${resolved.stacks.length} stack(s) and ${resolved.skills.length} core skills`,
106
- );
151
+ // Record the toolkit version that produced this output — drives auto-regen on update.
152
+ const { version } = await readJson<{ version: string }>(join(TOOLKIT_ROOT, "package.json"));
153
+ await writeFileEnsureDir(join(claudeDir, ".toolkit-version"), `${version}\n`);
154
+
155
+ if (!options.quiet) {
156
+ console.log(
157
+ `Generated .claude/ with ${resolved.stacks.length} stack(s) and ${resolved.skills.length} core skills`,
158
+ );
159
+ }
107
160
  }
108
161
 
109
162
  /** Generate skill-rules.json from resolved config */
@@ -271,7 +324,11 @@ async function generateSettings(claudeDir: string, resolved: ResolvedConfig): Pr
271
324
  }
272
325
 
273
326
  /** Scaffold base config files (biome.json, tsconfig.json) into the project */
274
- async function scaffoldConfigs(projectDir: string, resolved: ResolvedConfig): Promise<void> {
327
+ async function scaffoldConfigs(
328
+ projectDir: string,
329
+ resolved: ResolvedConfig,
330
+ quiet = false,
331
+ ): Promise<void> {
275
332
  if (resolved.config.scaffoldConfigs === false) return;
276
333
 
277
334
  const configsDir = join(TOOLKIT_ROOT, "templates", "configs");
@@ -283,10 +340,10 @@ async function scaffoldConfigs(projectDir: string, resolved: ResolvedConfig): Pr
283
340
  for (const { src, dest } of configs) {
284
341
  const destPath = join(projectDir, dest);
285
342
  if (exists(destPath)) {
286
- console.log(` Skipped ${dest} (already exists)`);
343
+ if (!quiet) console.log(` Skipped ${dest} (already exists)`);
287
344
  } else {
288
345
  await fsCopyFile(join(configsDir, src), destPath);
289
- console.log(` Scaffolded ${dest}`);
346
+ if (!quiet) console.log(` Scaffolded ${dest}`);
290
347
  }
291
348
  }
292
349
  }
package/src/utils.ts CHANGED
@@ -1,11 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import {
3
- copyFile,
4
- mkdir,
5
- readdir,
6
- readFile,
7
- writeFile,
8
- } from "node:fs/promises";
2
+ import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
9
3
  import { dirname, join } from "node:path";
10
4
 
11
5
  /** Recursively copy a directory */
@@ -24,11 +18,13 @@ export async function copyDir(src: string, dest: string): Promise<void> {
24
18
  }
25
19
  }
26
20
 
21
+ /** Remove a file or directory recursively. No-op if the path doesn't exist. */
22
+ export async function removePath(path: string): Promise<void> {
23
+ await rm(path, { recursive: true, force: true });
24
+ }
25
+
27
26
  /** Write a file, creating parent directories as needed */
28
- export async function writeFileEnsureDir(
29
- filePath: string,
30
- content: string,
31
- ): Promise<void> {
27
+ export async function writeFileEnsureDir(filePath: string, content: string): Promise<void> {
32
28
  await mkdir(dirname(filePath), { recursive: true });
33
29
  await writeFile(filePath, content, "utf-8");
34
30
  }
@@ -45,9 +41,6 @@ export function exists(filePath: string): boolean {
45
41
  }
46
42
 
47
43
  /** Simple template replacement: {{key}} → value */
48
- export function renderTemplate(
49
- template: string,
50
- vars: Record<string, string>,
51
- ): string {
44
+ export function renderTemplate(template: string, vars: Record<string, string>): string {
52
45
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
53
46
  }