claude-toolkit 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +54 -13
  3. package/bin/cli.ts +75 -19
  4. package/package.json +6 -7
  5. package/src/generator.ts +64 -26
  6. package/src/refresh-hint.ts +95 -0
  7. package/bin/postinstall.mjs +0 -69
  8. package/docs/README.md +0 -59
  9. package/docs/agents/code-reviewer.md +0 -76
  10. package/docs/agents/github-workflow.md +0 -98
  11. package/docs/best-practices/capacitor/README.md +0 -64
  12. package/docs/best-practices/capacitor/capacitor-8.md +0 -60
  13. package/docs/best-practices/capacitor/capgo-setup.md +0 -151
  14. package/docs/best-practices/capacitor/channels-and-rollouts.md +0 -101
  15. package/docs/best-practices/capacitor/live-updates-ota.md +0 -75
  16. package/docs/best-practices/capacitor/security-encryption.md +0 -68
  17. package/docs/best-practices/solidjs/README.md +0 -43
  18. package/docs/best-practices/solidjs/anti-patterns.md +0 -166
  19. package/docs/best-practices/solidjs/component-patterns.md +0 -131
  20. package/docs/best-practices/solidjs/context-and-global-state.md +0 -131
  21. package/docs/best-practices/solidjs/control-flow.md +0 -124
  22. package/docs/best-practices/solidjs/data-fetching.md +0 -205
  23. package/docs/best-practices/solidjs/effects-and-lifecycle.md +0 -113
  24. package/docs/best-practices/solidjs/performance.md +0 -100
  25. package/docs/best-practices/solidjs/props-patterns.md +0 -100
  26. package/docs/best-practices/solidjs/reactivity-model.md +0 -104
  27. package/docs/best-practices/solidjs/signals-and-state.md +0 -78
  28. package/docs/best-practices/solidjs/stores-and-nested-state.md +0 -111
  29. package/docs/best-practices/solidjs/typescript-integration.md +0 -186
  30. package/docs/best-practices/testing/README.md +0 -84
  31. package/docs/best-practices/testing/playwright-e2e.md +0 -649
  32. package/docs/best-practices/testing/storybook-interaction.md +0 -455
  33. package/docs/best-practices/testing/vitest-unit.md +0 -451
  34. package/docs/best-practices/typescript/README.md +0 -45
  35. package/docs/best-practices/typescript/any-and-unknown.md +0 -73
  36. package/docs/best-practices/typescript/deriving-vs-decoupling.md +0 -83
  37. package/docs/best-practices/typescript/discriminated-unions.md +0 -75
  38. package/docs/best-practices/typescript/enums-alternatives.md +0 -72
  39. package/docs/best-practices/typescript/essential-patterns.md +0 -119
  40. package/docs/best-practices/typescript/generics-patterns.md +0 -105
  41. package/docs/best-practices/typescript/micro-opinions.md +0 -87
  42. package/docs/best-practices/typescript/runtime-validation.md +0 -62
  43. package/docs/best-practices/typescript/satisfies-operator.md +0 -100
  44. package/docs/best-practices/typescript/tsconfig-cheat-sheet.md +0 -160
  45. package/docs/best-practices/typescript/type-organization.md +0 -64
  46. package/docs/best-practices/typescript/type-vs-interface.md +0 -80
  47. package/docs/commands/code-quality.md +0 -36
  48. package/docs/commands/onboard.md +0 -63
  49. package/docs/commands/pr-review.md +0 -91
  50. package/docs/commands/pr-summary.md +0 -50
  51. package/docs/commands/proto-check.md +0 -59
  52. package/docs/commands/ticket.md +0 -56
  53. package/docs/skills/code-style.md +0 -26
  54. package/docs/skills/systematic-debugging.md +0 -70
  55. package/docs/skills/testing-patterns.md +0 -140
  56. package/docs/skills/typescript-conventions.md +0 -283
  57. package/docs/skills/verification-before-completion.md +0 -103
  58. package/docs/stacks/capacitor-ota.md +0 -145
  59. package/docs/stacks/cloudflare-d1-kv.md +0 -144
  60. package/docs/stacks/esnext-idioms.md +0 -25
  61. package/docs/stacks/i18n-typesafe.md +0 -141
  62. package/docs/stacks/playwright-patterns.md +0 -179
  63. package/docs/stacks/protobuf-contracts.md +0 -85
  64. package/docs/stacks/rust-wasm-patterns.md +0 -106
  65. package/docs/stacks/solidjs-patterns.md +0 -110
  66. package/docs/stacks/storybook-patterns.md +0 -177
  67. package/docs/stacks/vanilla-extract-patterns.md +0 -115
  68. package/docs/stacks/vite-vitest-patterns.md +0 -485
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0 (2026-09-28)
4
+
5
+ Nothing runs at install time any more. The `postinstall` script that regenerated `.claude/` is gone; projects opt in with one line in their own `package.json`, and the new `claude-toolkit refresh` does the work. The package also stops shipping `docs/`, and releases move to npm trusted publishing behind a tarball guard.
6
+
7
+ **BREAKING:** installing or upgrading claude-toolkit no longer regenerates `.claude/`. After upgrading, run `bunx claude-toolkit` once, then add to your `package.json` scripts:
8
+
9
+ "prepare": "claude-toolkit refresh || exit 0"
10
+
11
+ This line is **required** when `.claude/` is gitignored (the README's recommended setup): without it a fresh clone has no `.claude/` until someone runs `bunx claude-toolkit`. Yarn 2+ never runs `prepare` for your own project — use `"postinstall"` there. npm runs `prepare` only on a plain `npm install` and pnpm skips it on `pnpm add`; after those, run `bunx claude-toolkit refresh`. If you added `claude-toolkit` to Bun's `trustedDependencies` or pnpm's `onlyBuiltDependencies`, remove it.
12
+
13
+ - feat!: remove the install-time `postinstall` script (`bin/postinstall.mjs`) and the internal `postinstall` command. The package now ships no install scripts and no `child_process` or `process.env` use.
14
+ - feat: `claude-toolkit refresh [dir]` regenerates `.claude/` only when the installed toolkit version differs from `.claude/.toolkit-version`; the up-to-date path never imports the config. It never creates the config and never scaffolds `biome.json`/`tsconfig.json`; a config that fails to load exits 1. Accepts `claude-toolkit.config.ts` or `claude-toolkit.config.js`.
15
+ - feat: every non-quiet `bunx claude-toolkit` run ends with the `prepare` line until `package.json` has a script running `claude-toolkit refresh` — with a named-script variant when `prepare` is already taken, `postinstall` for Yarn 2+ (`.yarnrc.yml`), the npm and pnpm cases where `prepare` does not run, and a leading line when upgrading from 0.16 or earlier. The CLI never edits `package.json`.
16
+ - fix(settings): the protected-branch guard blocked every edit on every branch when two or more `git.protectedBranches` were configured (`[` does not accept `&&`). It is now a `case` statement; branch names are validated against `^[A-Za-z0-9._/][A-Za-z0-9._/-]*$`, and an invalid name fails generation with an error naming it. An empty list now generates no guard instead of blocking every edit.
17
+ - build: stop shipping `docs/` (about 270 KB); the README links the docs on GitHub. `prepare` runs the husky 9 binary instead of `npx husky`. Dropped the dead pre-0.11 `skill-eval.sh` cleanup.
18
+ - ci: publish with npm trusted publishing (OIDC, automatic provenance, no token secret); prerelease versions go to the `next` dist-tag, and the workflow can be re-run by hand on a tag. Before publishing it runs typecheck, lint and tests, strips `prepare`, and runs `check:pack --publish` (no install scripts, no shell or environment access in shipped code, no `docs/`) and `check:smoke` (installs the packed tarball and runs `refresh`). A new CI workflow runs the checks on every push and pull request.
19
+ - build: the post-commit version hook no longer bumps prerelease versions (it turned `0.17.0-rc.0` into `0.17.NaN`); `@types/bun` is pinned to `^1.3.11`.
20
+ - docs: README documents `refresh`, the `prepare` opt-in per package manager, upgrading from 0.16, and the release-candidate flow; the auto-format, auto-test and type-check hooks are marked not yet functional (fixed in 0.18.0). CLAUDE.md drops the postinstall description and corrects the post-commit and `core/hooks` notes.
21
+ - fix(package): `bin` is now `bin/cli.ts` without a leading `./`. npm 11.10 and later silently drop a `./`-prefixed `bin` entry at publish time (`npm pack` keeps it), which would have shipped 0.17.0 without its CLI; `check:pack` now rejects such paths. `repository.url` uses the `git+https` form npm normalizes to.
22
+
3
23
  ## 0.16.0 (2026-08-08)
4
24
 
5
25
  `ct-typescript-conventions` now selects a compiler as well as types: prefer the TypeScript 7 native compiler where a project already ships it, fall back cleanly where it does not, and never upgrade a project unprompted. Availability-aware in the same spirit as `ct-esnext-idioms`.
package/README.md CHANGED
@@ -18,12 +18,31 @@ bun add -d claude-toolkit
18
18
  bunx claude-toolkit
19
19
  ```
20
20
 
21
+ Then add one line to your `package.json` so `.claude/` is rebuilt after toolkit upgrades and on fresh clones. It is **required** when `.claude/` is gitignored (the setup recommended below):
22
+
23
+ ```json
24
+ {
25
+ "scripts": {
26
+ "prepare": "claude-toolkit refresh || exit 0"
27
+ }
28
+ }
29
+ ```
30
+
31
+ claude-toolkit itself runs nothing at install time: this is your project's own script, and `|| exit 0` keeps installs working where bun is missing. If `prepare` is already taken, add `"claude-toolkit:refresh": "claude-toolkit refresh || exit 0"` and append `&& bun run claude-toolkit:refresh` (or `npm run`/`pnpm run`/`yarn run`) to your `prepare`. Until one of these is in place, `bunx claude-toolkit` prints the exact line for your project.
32
+
33
+ When your package manager runs it:
34
+
35
+ - **bun** runs `prepare` on `bun install`, `bun add` and `bun update`.
36
+ - **npm** runs it only on a plain `npm install`. After `npm install <pkg>` or `npm update`, run `bunx claude-toolkit refresh`.
37
+ - **pnpm** runs it on `pnpm install` and `pnpm update`, not on `pnpm add`. After `pnpm add`, run `bunx claude-toolkit refresh`.
38
+ - **Yarn 2+** never runs `prepare` for your own project. Use `"postinstall": "claude-toolkit refresh || exit 0"` instead; it runs whenever your dependencies change. If you publish the package, don't ship that `postinstall` — run `bunx claude-toolkit refresh` by hand instead.
39
+
21
40
  ## How It Works
22
41
 
23
42
  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
43
  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
44
  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
45
+ 4. After a toolkit upgrade, the `prepare` line from Quick Start runs `claude-toolkit refresh`, which rebuilds `.claude/` only when the installed version differs from the one that generated it. Nothing else runs at install time
27
46
 
28
47
  ```ts
29
48
  import { defineConfig } from "claude-toolkit";
@@ -46,13 +65,14 @@ export default defineConfig({
46
65
 
47
66
  ## CLI Commands
48
67
 
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 |
68
+ | Command | Description |
69
+ | ----------------------------------- | ---------------------------------------------------------------------------------------------------- |
70
+ | `bunx claude-toolkit` | Create the config if missing, then (re)generate `.claude/`. Idempotent. |
71
+ | `bunx claude-toolkit --update` | Same, and also add newly-detected stacks to your config |
72
+ | `bunx claude-toolkit refresh [dir]` | Regenerate `.claude/` only if the toolkit version changed. Never creates the config or other files. |
73
+ | `bunx claude-toolkit help` | Show available commands |
54
74
 
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.
75
+ Aliases (back-compat): `init` is a friendly name for the bare command, `update` equals `--update`, and `sync` is deprecated — use the bare command. `refresh` is what your `prepare` script runs: with no config it prints one setup line and exits 0, a config that fails to load exits 1, and `--quiet` silences it.
56
76
 
57
77
  ## Stack Auto-Detection
58
78
 
@@ -91,7 +111,7 @@ Generated .claude/ with 4 stack(s) and 4 core skills
91
111
 
92
112
  Stacks in your config that are no longer detected are reported but left unchanged (remove them manually if intended).
93
113
 
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.
114
+ **Keeping `.claude/` in sync** — `claude-toolkit refresh` compares the installed toolkit version with `.claude/.toolkit-version` and regenerates only when they differ; when they match it returns without loading your config. It never creates the config and never scaffolds `biome.json`/`tsconfig.json`. Run it from your `prepare` script (see Quick Start).
95
115
 
96
116
  ## Available Stacks
97
117
 
@@ -113,9 +133,9 @@ Stacks in your config that are no longer detected are reported but left unchange
113
133
 
114
134
  - **Skill Evaluation Engine** — Analyzes prompts and suggests relevant skills
115
135
  - **Main Branch Protection** — Prevents accidental edits on protected branches
116
- - **Auto-formatting** — Formats files after edits
117
- - **Auto-testing** — Runs related tests when test files change
118
- - **Type checking** — Checks TypeScript types on save
136
+ - **Auto-formatting** — Formats files after edits _(not yet functional — fixed in 0.18.0)_
137
+ - **Auto-testing** — Runs related tests when test files change _(not yet functional — fixed in 0.18.0)_
138
+ - **Type checking** — Checks TypeScript types on save _(not yet functional — fixed in 0.18.0)_
119
139
 
120
140
  ### Core Skills
121
141
 
@@ -149,6 +169,8 @@ Add `.claude/` to your `.gitignore` (it's generated, not tracked):
149
169
  CLAUDE.local.md
150
170
  ```
151
171
 
172
+ With `.claude/` gitignored, a fresh clone has no `.claude/` until something generates it, so the `prepare` line from Quick Start is **required** in this setup.
173
+
152
174
  Track the config file and CLAUDE.md:
153
175
 
154
176
  ```
@@ -156,9 +178,17 @@ claude-toolkit.config.ts # tracked — your project's Claude config
156
178
  CLAUDE.md # tracked — project-specific documentation
157
179
  ```
158
180
 
181
+ ## Upgrading from 0.16 or earlier
182
+
183
+ 0.17.0 removed the install-time `postinstall` script: installing or upgrading claude-toolkit no longer runs anything. After upgrading:
184
+
185
+ 1. Run `bunx claude-toolkit` once to regenerate `.claude/`.
186
+ 2. Add `"prepare": "claude-toolkit refresh || exit 0"` to your `package.json` scripts (the CLI prints the exact line for your project, including the Yarn 2+ and existing-`prepare` variants).
187
+ 3. If you added `claude-toolkit` to Bun's `trustedDependencies` or pnpm's `onlyBuiltDependencies` so its install script could run, remove it — there is no install script any more.
188
+
159
189
  ## Documentation
160
190
 
161
- Full reference documentation for all skills, commands, and agents is available in the [`docs/`](docs/README.md) directory.
191
+ Full reference documentation for all skills, commands, and agents lives in the [`docs/` directory on GitHub](https://github.com/william-queant/claude-toolkit/blob/main/docs/README.md). It is not included in the npm package.
162
192
 
163
193
  ## Versioning
164
194
 
@@ -167,7 +197,15 @@ Version bumps are derived from your commit messages (Conventional Commits) by a
167
197
  - `feat:` → minor · `fix:` / `perf:` → patch · `feat!:` / `BREAKING CHANGE` → major (capped to minor while pre-1.0)
168
198
  - `docs:` / `chore:` / `refactor:` / `style:` / `test:` / `ci:` / `build:` → no version change
169
199
 
170
- 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`).
200
+ To set an exact version deliberately (a re-baseline, a release candidate, or `1.0.0`), edit `package.json` and the `CHANGELOG.md` entry, then commit with `SKIP_POST_COMMIT=1` so the hook doesn't re-bump. While `package.json` holds a prerelease version (for example `0.17.0-rc.0`), the hook never bumps.
201
+
202
+ Releases go through a release candidate:
203
+
204
+ 1. Set `X.Y.0-rc.N`, commit with `SKIP_POST_COMMIT=1`, and publish a GitHub **pre-release** for tag `vX.Y.0-rc.N`. `.github/workflows/publish.yml` publishes it to the `next` dist-tag.
205
+ 2. Verify the published candidate; fix and cut `rc.N+1` if needed.
206
+ 3. Set `X.Y.0`, commit with `SKIP_POST_COMMIT=1`, and publish a normal GitHub Release for `vX.Y.0`. It goes to `latest`.
207
+
208
+ Publishing uses npm trusted publishing (OIDC): there is no npm token secret, and provenance is automatic. Before publishing, the workflow runs typecheck, lint and tests, strips the dev-only `prepare` script, then runs `bun run check:pack --publish` (no install scripts, no shell or environment access in shipped code, no `docs/`) and `bun run check:smoke` (installs the packed tarball and runs `claude-toolkit refresh`). `.github/workflows/ci.yml` runs typecheck, lint, tests, `check:pack` and `check:smoke` on every push and pull request.
171
209
 
172
210
  ## Development
173
211
 
@@ -175,4 +213,7 @@ To set an exact version deliberately (a re-baseline, or `1.0.0`), edit `package.
175
213
  git clone https://github.com/william-queant/claude-toolkit.git
176
214
  cd claude-toolkit
177
215
  bun install
216
+ bun test # test suite
217
+ bun run check:pack # tarball guard
218
+ bun run check:smoke # pack, install into a temp project, run refresh
178
219
  ```
package/bin/cli.ts CHANGED
@@ -6,24 +6,34 @@
6
6
  * One idempotent command does it all:
7
7
  * bunx claude-toolkit Create the config if missing, then (re)generate .claude/
8
8
  * bunx claude-toolkit --update Also pull newly-detected stacks into the config
9
+ * bunx claude-toolkit refresh Regenerate .claude/ only if the toolkit version changed
9
10
  *
10
11
  * Aliases / back-compat:
11
12
  * init Friendly name for the first run (same as the bare command)
12
13
  * update Same as `--update`
13
14
  * sync Deprecated alias of the bare command
14
15
  *
15
- * .claude/ is also regenerated automatically on install when the installed
16
- * toolkit version changes (see bin/postinstall.mjs).
16
+ * Nothing runs at install time. Consumers keep .claude/ in sync by adding
17
+ * "prepare": "claude-toolkit refresh || exit 0" to their own package.json scripts;
18
+ * the bare command prints that hint until they do.
17
19
  */
18
20
 
19
21
  import { existsSync } from "node:fs";
20
22
  import { readFile, writeFile } from "node:fs/promises";
21
- import { join, resolve } from "node:path";
23
+ import { basename, join, resolve } from "node:path";
22
24
  import { detectStacks } from "../src/detect.js";
23
- import { generate } from "../src/generator.js";
25
+ import { generate, readMarker, readToolkitVersion } from "../src/generator.js";
26
+ import { buildRefreshHint } from "../src/refresh-hint.js";
24
27
  import type { ClaudeToolkitConfig } from "../src/types.js";
25
28
 
26
29
  const CONFIG_FILENAME = "claude-toolkit.config.ts";
30
+ /** Config files `loadConfig` and `refresh` accept, in lookup order. */
31
+ const CONFIG_FILENAMES = [CONFIG_FILENAME, "claude-toolkit.config.js"];
32
+
33
+ /** Path of the project's config file, or null when there is none. */
34
+ function findConfigPath(projectDir: string): string | null {
35
+ return CONFIG_FILENAMES.map((name) => join(projectDir, name)).find((p) => existsSync(p)) ?? null;
36
+ }
27
37
 
28
38
  /** Build a `stacks: [...]` literal for injection into the config file. */
29
39
  function buildStacksLiteral(stacks: string[]): string {
@@ -44,16 +54,16 @@ async function updateConfigStacks(configPath: string, stacks: string[]): Promise
44
54
  }
45
55
 
46
56
  async function loadConfig(projectDir: string): Promise<ClaudeToolkitConfig> {
47
- const configPath = join(projectDir, CONFIG_FILENAME);
48
- if (!existsSync(configPath)) {
49
- throw new Error(`Config not found: ${configPath}\nRun "bunx claude-toolkit" first.`);
57
+ const configPath = findConfigPath(projectDir);
58
+ if (configPath === null) {
59
+ throw new Error(`Config not found in ${projectDir}\nRun "bunx claude-toolkit" first.`);
50
60
  }
51
61
  let mod: { default?: ClaudeToolkitConfig };
52
62
  try {
53
63
  mod = await import(configPath);
54
64
  } catch (err) {
55
65
  throw new Error(
56
- `Failed to load ${CONFIG_FILENAME}. Make sure claude-toolkit is installed in this project ` +
66
+ `Failed to load ${basename(configPath)}. Make sure claude-toolkit is installed in this project ` +
57
67
  `(e.g. "bun add -d claude-toolkit"), then run "bunx claude-toolkit" again.\n ${(err as Error).message}`,
58
68
  );
59
69
  }
@@ -104,11 +114,14 @@ interface RunOptions {
104
114
  async function run(projectDir: string, options: RunOptions = {}): Promise<void> {
105
115
  const { update = false, quiet = false } = options;
106
116
  const log = quiet ? (_msg = "") => {} : (msg = "") => console.log(msg);
107
- const configPath = join(projectDir, CONFIG_FILENAME);
117
+ const existingConfig = findConfigPath(projectDir);
118
+ const configPath = existingConfig ?? join(projectDir, CONFIG_FILENAME);
119
+ // Read before generate() rewrites it: a pre-0.17 marker means this run is an upgrade.
120
+ const previousVersion = await readMarker(projectDir);
108
121
 
109
122
  let config: ClaudeToolkitConfig;
110
123
 
111
- if (!existsSync(configPath)) {
124
+ if (existingConfig === null) {
112
125
  // First run: create the config from what we detect.
113
126
  const detected = detectStacks(projectDir);
114
127
  if (detected.length > 0) {
@@ -175,15 +188,52 @@ async function run(projectDir: string, options: RunOptions = {}): Promise<void>
175
188
 
176
189
  await generate(projectDir, config, { quiet });
177
190
  log("Done.");
191
+ if (!quiet) await printRefreshHint(projectDir, config.packageManager, previousVersion);
178
192
  }
179
193
 
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;
194
+ /**
195
+ * Print the "keep .claude/ in sync" hint unless package.json already runs
196
+ * `claude-toolkit refresh`. Only prints: package.json is a committed file.
197
+ */
198
+ async function printRefreshHint(
199
+ projectDir: string,
200
+ packageManager: ClaudeToolkitConfig["packageManager"],
201
+ previousVersion: string | null,
202
+ ): Promise<void> {
203
+ const pkgPath = join(projectDir, "package.json");
204
+ if (!existsSync(pkgPath)) return;
205
+ let scripts: Record<string, unknown>;
206
+ try {
207
+ scripts = JSON.parse(await readFile(pkgPath, "utf-8")).scripts ?? {};
208
+ } catch {
209
+ return; // an unparsable package.json is not worth failing the run over
210
+ }
211
+ const lines = buildRefreshHint({
212
+ scripts,
213
+ packageManager,
214
+ yarnBerry: existsSync(join(projectDir, ".yarnrc.yml")),
215
+ previousVersion,
216
+ });
217
+ if (lines.length > 0) console.log(`\n${lines.join("\n")}`);
218
+ }
219
+
220
+ /**
221
+ * refresh: regenerate .claude/ when the installed toolkit version differs from the
222
+ * one recorded in .claude/.toolkit-version. Built for the consumer's own "prepare"
223
+ * script: it never creates the config, never scaffolds committed files, and the
224
+ * up-to-date path returns before the config is imported.
225
+ */
226
+ async function refresh(projectDir: string, quiet: boolean): Promise<void> {
227
+ const log = quiet ? (_msg = "") => {} : (msg = "") => console.log(msg);
228
+ if (findConfigPath(projectDir) === null) {
229
+ log('[claude-toolkit] No config found — run "bunx claude-toolkit" to set up .claude/.');
230
+ return;
231
+ }
232
+ const version = await readToolkitVersion();
233
+ if ((await readMarker(projectDir)) === version) return;
183
234
  const config = await loadConfig(projectDir);
184
- // scaffold:false — never write committed project files (biome.json/tsconfig.json) on install.
185
235
  await generate(projectDir, config, { quiet: true, scaffold: false });
186
- console.log("[claude-toolkit] Regenerated .claude/ for the updated toolkit version.");
236
+ log(`[claude-toolkit] Regenerated .claude/ for toolkit ${version}.`);
187
237
  }
188
238
 
189
239
  const HELP = `
@@ -193,6 +243,10 @@ Usage:
193
243
  bunx claude-toolkit [project-dir] Create config if missing, then regenerate .claude/
194
244
  bunx claude-toolkit --update [project-dir] Also add newly-detected stacks to the config
195
245
 
246
+ Commands:
247
+ refresh [project-dir] Regenerate .claude/ only if the toolkit version changed.
248
+ Never creates the config; meant for a "prepare" script.
249
+
196
250
  Commands (aliases):
197
251
  init First-run friendly name (same as the bare command)
198
252
  update Same as --update
@@ -203,14 +257,16 @@ Flags:
203
257
  --update, -u Add newly-detected stacks to the config before regenerating
204
258
  --quiet, -q Suppress informational output
205
259
 
206
- .claude/ also regenerates automatically on install when the toolkit version changes.
260
+ Nothing runs at install time. To keep .claude/ in sync after upgrades, add to
261
+ your package.json "scripts":
262
+ "prepare": "claude-toolkit refresh || exit 0"
207
263
  `;
208
264
 
209
265
  // ---- entry ----
210
266
  const argv = process.argv.slice(2);
211
267
  const flags = new Set(argv.filter((a) => a.startsWith("-")));
212
268
  const positional = argv.filter((a) => !a.startsWith("-"));
213
- const COMMANDS = new Set(["init", "update", "sync", "help", "postinstall"]);
269
+ const COMMANDS = new Set(["init", "update", "sync", "help", "refresh"]);
214
270
  const KNOWN_FLAGS = new Set(["--update", "-u", "--quiet", "-q"]);
215
271
 
216
272
  // Reject unknown flags so a typo'd flag (e.g. --updat) isn't silently ignored.
@@ -248,8 +304,8 @@ try {
248
304
  case "help":
249
305
  console.log(HELP);
250
306
  break;
251
- case "postinstall":
252
- await postinstall(projectDir);
307
+ case "refresh":
308
+ await refresh(projectDir, quiet);
253
309
  break;
254
310
  case "sync":
255
311
  if (!quiet) {
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "claude-toolkit",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Reusable Claude Code configuration toolkit with stack-specific connectors",
5
5
  "type": "module",
6
6
  "bin": {
7
- "claude-toolkit": "./bin/cli.ts"
7
+ "claude-toolkit": "bin/cli.ts"
8
8
  },
9
9
  "exports": {
10
10
  ".": {
@@ -18,7 +18,6 @@
18
18
  "core/",
19
19
  "stacks/",
20
20
  "templates/",
21
- "docs/",
22
21
  "CHANGELOG.md"
23
22
  ],
24
23
  "scripts": {
@@ -26,8 +25,8 @@
26
25
  "lint": "biome check --write",
27
26
  "lint:check": "biome check",
28
27
  "test": "bun test",
29
- "postinstall": "bun bin/postinstall.mjs || node bin/postinstall.mjs || exit 0",
30
- "prepare": "npx husky"
28
+ "check:pack": "bun scripts/check-pack.ts",
29
+ "check:smoke": "bun scripts/check-smoke.ts"
31
30
  },
32
31
  "lint-staged": {
33
32
  "*.{ts,tsx,js,jsx,json,css}": "biome check --write --no-errors-on-unmatched"
@@ -44,11 +43,11 @@
44
43
  "license": "MIT",
45
44
  "repository": {
46
45
  "type": "git",
47
- "url": "https://github.com/william-queant/claude-toolkit.git"
46
+ "url": "git+https://github.com/william-queant/claude-toolkit.git"
48
47
  },
49
48
  "devDependencies": {
50
49
  "@biomejs/biome": "^2.4.10",
51
- "@types/bun": "latest",
50
+ "@types/bun": "^1.3.11",
52
51
  "husky": "^9.1.7",
53
52
  "lint-staged": "^16.4.0",
54
53
  "typescript": "^6.0.2"
package/src/generator.ts CHANGED
@@ -1,12 +1,58 @@
1
- import { copyFile as fsCopyFile, readdir } from "node:fs/promises";
1
+ import { copyFile as fsCopyFile, readdir, readFile } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
  import type { ClaudeToolkitConfig, ResolvedConfig, StackPack } from "./types.js";
4
4
  import { copyDir, exists, readJson, removePath, writeFileEnsureDir } from "./utils.js";
5
5
 
6
6
  const TOOLKIT_ROOT = resolve(import.meta.dirname, "..");
7
7
 
8
+ /** Branches protected when the config sets no `git.protectedBranches`. */
9
+ const DEFAULT_PROTECTED_BRANCHES = ["main"];
10
+ /** A protected branch name must match this before it is quoted into the guard's `case` pattern. */
11
+ const BRANCH_NAME_PATTERN = /^[A-Za-z0-9._/][A-Za-z0-9._/-]*$/;
12
+
13
+ /** Throw a clear error naming the first protected branch that fails BRANCH_NAME_PATTERN. */
14
+ function assertValidBranchNames(branches: string[]): void {
15
+ const invalid = branches.find((b) => !BRANCH_NAME_PATTERN.test(b));
16
+ if (invalid === undefined) return;
17
+ throw new Error(
18
+ `Invalid branch name in git.protectedBranches: ${JSON.stringify(invalid)}. ` +
19
+ 'Use only letters, digits, ".", "_", "/" and "-", and do not start with "-".',
20
+ );
21
+ }
22
+
23
+ /**
24
+ * The PreToolUse command that blocks edits on a protected branch, or null when no
25
+ * branch is protected. Names are validated first, then single-quoted into a `case`
26
+ * pattern, so each name is matched literally and never parsed by the shell.
27
+ */
28
+ function buildBranchGuard(branches: string[]): string | null {
29
+ assertValidBranchNames(branches);
30
+ if (branches.length === 0) return null;
31
+ const patterns = branches.map((b) => `'${b}'`).join("|");
32
+ return `# Prevent editing on protected branches\ncase "$(git branch --show-current)" in ${patterns}) echo '{"block": true, "message": "Cannot edit files on a protected branch. Create a feature branch first."}' >&2; exit 2;; esac`;
33
+ }
34
+
35
+ /** File in .claude/ recording the toolkit version that last generated it. */
36
+ const MARKER_FILE = ".toolkit-version";
37
+
38
+ /** The installed toolkit version, read from the toolkit's own package.json. */
39
+ export async function readToolkitVersion(): Promise<string> {
40
+ const { version } = await readJson<{ version: string }>(join(TOOLKIT_ROOT, "package.json"));
41
+ return version;
42
+ }
43
+
44
+ /** The version recorded in `<projectDir>/.claude/.toolkit-version`, or null when absent. */
45
+ export async function readMarker(projectDir: string): Promise<string | null> {
46
+ const markerPath = join(projectDir, ".claude", MARKER_FILE);
47
+ if (!exists(markerPath)) return null;
48
+ return (await readFile(markerPath, "utf-8")).trim();
49
+ }
50
+
8
51
  /** Resolve the full configuration by merging core + stacks + project config */
9
52
  async function resolveConfig(config: ClaudeToolkitConfig): Promise<ResolvedConfig> {
53
+ // Fail before anything is written when a branch name is unsafe to quote into a hook.
54
+ assertValidBranchNames(config.git?.protectedBranches ?? DEFAULT_PROTECTED_BRANCHES);
55
+
10
56
  const stacks: StackPack[] = [];
11
57
  const allMappings: Record<string, string> = {};
12
58
 
@@ -79,10 +125,8 @@ async function removeGenerated(claudeDir: string): Promise<void> {
79
125
  // Toolkit hook files (copied from core/hooks) + the generated skill-rules.json.
80
126
  ...hookFiles.map((f) => removePath(join(claudeDir, "hooks", f))),
81
127
  removePath(join(claudeDir, "hooks", "skill-rules.json")),
82
- // Stale files from prior installs: skill-eval.js was renamed to .cjs, and the
83
- // skill-eval.sh wrapper was dropped in favour of exec-form node registration.
128
+ // Stale file from prior installs: skill-eval.js was renamed to .cjs.
84
129
  removePath(join(claudeDir, "hooks", "skill-eval.js")),
85
- removePath(join(claudeDir, "hooks", "skill-eval.sh")),
86
130
  ]);
87
131
  }
88
132
 
@@ -153,8 +197,8 @@ export async function generate(
153
197
  ].join("\n"),
154
198
  );
155
199
 
156
- // 9. Scaffold base configs into the project root (committed files). Skipped for
157
- // automatic/postinstall regeneration so an install never writes committed files.
200
+ // 9. Scaffold base configs into the project root (committed files). Skipped by
201
+ // `refresh` (scaffold: false), which must never write committed files.
158
202
  if (options.scaffold !== false) {
159
203
  await scaffoldConfigs(projectDir, resolved, options.quiet);
160
204
  }
@@ -162,9 +206,9 @@ export async function generate(
162
206
  // 10. Generate skills README
163
207
  await generateSkillsReadme(claudeDir, resolved);
164
208
 
165
- // Record the toolkit version that produced this output — drives auto-regen on update.
166
- const { version } = await readJson<{ version: string }>(join(TOOLKIT_ROOT, "package.json"));
167
- await writeFileEnsureDir(join(claudeDir, ".toolkit-version"), `${version}\n`);
209
+ // Record the toolkit version that produced this output — `refresh` compares against it.
210
+ const version = await readToolkitVersion();
211
+ await writeFileEnsureDir(join(claudeDir, MARKER_FILE), `${version}\n`);
168
212
 
169
213
  if (!options.quiet) {
170
214
  console.log(
@@ -230,11 +274,7 @@ async function generateSkillRules(claudeDir: string, resolved: ResolvedConfig):
230
274
  /** Generate settings.json with hooks */
231
275
  async function generateSettings(claudeDir: string, resolved: ResolvedConfig): Promise<void> {
232
276
  const { hooks, config } = resolved;
233
- const protectedBranches = config.git?.protectedBranches ?? ["main"];
234
-
235
- const branchCheck = protectedBranches
236
- .map((b) => `"$(git branch --show-current)" != "${b}"`)
237
- .join(" && ");
277
+ const branchGuard = buildBranchGuard(config.git?.protectedBranches ?? DEFAULT_PROTECTED_BRANCHES);
238
278
 
239
279
  const postToolUseHooks: unknown[] = [];
240
280
 
@@ -313,18 +353,16 @@ async function generateSettings(claudeDir: string, resolved: ResolvedConfig): Pr
313
353
  ],
314
354
  },
315
355
  ],
316
- PreToolUse: [
317
- {
318
- matcher: "Edit|MultiEdit|Write",
319
- hooks: [
320
- {
321
- type: "command",
322
- command: `# Prevent editing on protected branches\n[ ${branchCheck} ] || { echo '{"block": true, "message": "Cannot edit files on a protected branch. Create a feature branch first."}' >&2; exit 2; }`,
323
- timeout: 5,
324
- },
325
- ],
326
- },
327
- ],
356
+ ...(branchGuard === null
357
+ ? {}
358
+ : {
359
+ PreToolUse: [
360
+ {
361
+ matcher: "Edit|MultiEdit|Write",
362
+ hooks: [{ type: "command", command: branchGuard, timeout: 5 }],
363
+ },
364
+ ],
365
+ }),
328
366
  PostToolUse: postToolUseHooks.map((hook) => ({
329
367
  matcher: "Edit|MultiEdit|Write",
330
368
  hooks: [hook],
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The "keep .claude/ in sync" hint printed after a bare CLI run. The toolkit never
3
+ * edits the consumer's package.json (a committed file); it only says what to add.
4
+ * Wording rule: no URLs and no filename literals with a TLD-like extension, since
5
+ * Socket's URL-strings detector reads both as links.
6
+ */
7
+
8
+ /** Script body a consumer adds. `|| exit 0` keeps an install working when bun is missing. */
9
+ export const REFRESH_COMMAND = "claude-toolkit refresh || exit 0";
10
+ /** Script name suggested when the lifecycle script already exists. */
11
+ export const REFRESH_SCRIPT_NAME = "claude-toolkit:refresh";
12
+ /** First release that no longer regenerates .claude/ from a dependency install script. */
13
+ export const INSTALL_SCRIPT_REMOVED_IN = "0.17.0";
14
+
15
+ export type PackageManager = "bun" | "npm" | "pnpm" | "yarn";
16
+
17
+ export interface RefreshHintInput {
18
+ /** `scripts` from the project's package.json ({} when it has none). */
19
+ scripts: Record<string, unknown>;
20
+ /** `packageManager` from the toolkit config. */
21
+ packageManager: PackageManager;
22
+ /** True when the project uses Yarn 2+ (a `.yarnrc.yml` exists). */
23
+ yarnBerry: boolean;
24
+ /** `.claude/.toolkit-version` as it was before this run, or null. */
25
+ previousVersion: string | null;
26
+ }
27
+
28
+ /**
29
+ * When each package manager runs the project's own root lifecycle scripts, verified
30
+ * 2026-09-28 with a root project whose `prepare` and `postinstall` append to a file:
31
+ * bun 1.3.11 prepare on install, add and update
32
+ * npm 10.9.3, 11.20.0, 12.1.0 prepare on a plain install only (not install <pkg>, not update)
33
+ * pnpm 10.34.5, 11.28.0, 12.6.0 prepare on install and update, not on add
34
+ * Yarn 4.18.1 never prepare; postinstall whenever the dependency tree changes
35
+ */
36
+ const CAVEATS: Record<PackageManager | "yarn-berry", string | null> = {
37
+ bun: null,
38
+ npm: 'npm runs "prepare" only on a plain "npm install"; after "npm install <pkg>" or "npm update", run "bunx claude-toolkit refresh".',
39
+ pnpm: 'pnpm skips "prepare" on "pnpm add"; after upgrading claude-toolkit that way, run "bunx claude-toolkit refresh".',
40
+ yarn: null,
41
+ "yarn-berry":
42
+ 'Yarn 2+ never runs "prepare" for your own project; "postinstall" runs whenever your dependencies change.',
43
+ };
44
+
45
+ /** Compare x.y.z numerically. A prerelease counts as its release: 0.17.0-rc.0 is not before 0.17.0. */
46
+ export function isBefore(version: string, floor: string): boolean {
47
+ const parts = (v: string) =>
48
+ (v.split("-")[0] ?? "").split(".").map((n) => Number.parseInt(n, 10) || 0);
49
+ const a = parts(version);
50
+ const b = parts(floor);
51
+ for (let i = 0; i < 3; i++) {
52
+ const x = a[i] ?? 0;
53
+ const y = b[i] ?? 0;
54
+ if (x !== y) return x < y;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ /** True when some package.json script already runs `claude-toolkit refresh`. */
60
+ export function hasRefreshScript(scripts: Record<string, unknown>): boolean {
61
+ return Object.values(scripts).some(
62
+ (s) => typeof s === "string" && s.includes("claude-toolkit refresh"),
63
+ );
64
+ }
65
+
66
+ /** Lines of the hint, or [] when a script already runs `claude-toolkit refresh`. */
67
+ export function buildRefreshHint(input: RefreshHintInput): string[] {
68
+ const { scripts, packageManager, yarnBerry, previousVersion } = input;
69
+ const lifecycle = yarnBerry ? "postinstall" : "prepare";
70
+ // Yarn 2+ never runs "prepare", so only a refresh reachable from "postinstall" counts there.
71
+ const satisfied = yarnBerry
72
+ ? /claude-toolkit[ :]refresh/.test(String(scripts.postinstall ?? ""))
73
+ : hasRefreshScript(scripts);
74
+ if (satisfied) return [];
75
+
76
+ const runner = yarnBerry ? "yarn" : packageManager;
77
+ const lines: string[] = [];
78
+ if (previousVersion !== null && isBefore(previousVersion, INSTALL_SCRIPT_REMOVED_IN)) {
79
+ lines.push(
80
+ `Automatic regeneration on install was removed in ${INSTALL_SCRIPT_REMOVED_IN} (it relied on a dependency install script).`,
81
+ );
82
+ }
83
+ lines.push('Keep .claude/ in sync after toolkit upgrades — add to your package.json "scripts":');
84
+ if (scripts[lifecycle] === undefined) {
85
+ lines.push(` "${lifecycle}": "${REFRESH_COMMAND}"`);
86
+ } else {
87
+ lines.push(` "${REFRESH_SCRIPT_NAME}": "${REFRESH_COMMAND}"`);
88
+ lines.push(
89
+ `and append "&& ${runner} run ${REFRESH_SCRIPT_NAME}" to your existing "${lifecycle}" script.`,
90
+ );
91
+ }
92
+ const caveat = CAVEATS[yarnBerry ? "yarn-berry" : packageManager];
93
+ if (caveat !== null) lines.push(caveat);
94
+ return lines;
95
+ }