@rtorcato/repo-tooling 3.8.7 → 3.9.1

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/AGENTS.md CHANGED
@@ -100,7 +100,7 @@ npx @rtorcato/repo-tooling fix bun --yes --json # Bun runtime/test confi
100
100
 
101
101
  ## Conventions in this repo
102
102
 
103
- - Conventional commits enforced via commitlint; header max 72 chars
103
+ - Conventional commits enforced via commitlint; header max 100 chars, body/footer line length unenforced
104
104
  - Biome for lint + format (run via `pnpm exec biome check --config-path=tooling/biome/biome.json src scripts`)
105
105
  - Tests live alongside source in `tests/`; vitest with no separate config
106
106
  - semantic-release runs on push to `main`; `fix:` → patch, `feat:` → minor, `chore:` / `docs:` → no release
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <picture>
2
- <source media="(max-width: 640px)" srcset="./banner-mobile.png">
3
- <img src="./banner.png" alt="repo-tooling banner" width="1600">
2
+ <source media="(max-width: 640px)" srcset="./brand/banner-mobile.png">
3
+ <img src="./brand/banner.png" alt="repo-tooling banner" width="1600">
4
4
  </picture>
5
5
 
6
6
  <br>
@@ -99,6 +99,58 @@ export async function checkCommunityHealth(dir) {
99
99
  hint: 'Run `npx @rtorcato/repo-tooling fix community-health` to scaffold them',
100
100
  };
101
101
  }
102
+ const BRAND_HINT = 'Run `npx @rtorcato/repo-tooling fix brand` to scaffold brand/ (SVG sources + render.sh), then run `brand/render.sh`';
103
+ /** The two banners the README consumes — the pair `brand/` exists to keep regenerable. */
104
+ const BANNERS = ['banner', 'banner-mobile'];
105
+ /**
106
+ * Brand assets (#395). A committed PNG with no SVG beside it can't be
107
+ * recoloured, retitled or resized — which was the state of every repo in the
108
+ * family but one. Three signals, all language-agnostic:
109
+ *
110
+ * 1. a rendered banner with no `brand/<name>.svg` source
111
+ * 2. a `brand/` folder with no `render.sh` — sources nobody can render
112
+ * 3. a README still pointing at the pre-amendment root-level `./banner.png`
113
+ *
114
+ * A repo with no brand images at all is `optional-missing`, not a finding:
115
+ * there's nothing broken to fix, and banners are opt-in.
116
+ */
117
+ export async function checkBrand(dir) {
118
+ const check = 'Brand assets';
119
+ const has = (rel) => fs.pathExists(path.join(dir, rel));
120
+ const problems = [];
121
+ const orphans = [];
122
+ for (const name of BANNERS) {
123
+ const rendered = (await has(`${name}.png`)) || (await has(`brand/${name}.png`));
124
+ if (rendered && !(await has(`brand/${name}.svg`)))
125
+ orphans.push(`${name}.png`);
126
+ }
127
+ if (orphans.length > 0) {
128
+ problems.push(`${orphans.join(' + ')} committed with no brand/*.svg source`);
129
+ }
130
+ const brandDir = await has('brand');
131
+ if (brandDir && !(await has('brand/render.sh'))) {
132
+ problems.push('brand/ has no render.sh — the PNGs cannot be regenerated');
133
+ }
134
+ const readmePath = path.join(dir, 'README.md');
135
+ if (await fs.pathExists(readmePath)) {
136
+ const readme = await fs.readFile(readmePath, 'utf-8');
137
+ if (/(?<!brand\/)banner(?:-mobile)?\.png/.test(readme)) {
138
+ problems.push('README points at root-level banner PNGs (the spec moved them to brand/)');
139
+ }
140
+ }
141
+ if (problems.length > 0) {
142
+ return { check, status: 'drift', detail: problems.join('; '), hint: BRAND_HINT };
143
+ }
144
+ if (!brandDir) {
145
+ return {
146
+ check,
147
+ status: 'optional-missing',
148
+ detail: 'no brand/ folder — the repo ships no regenerable brand sources',
149
+ hint: BRAND_HINT,
150
+ };
151
+ }
152
+ return { check, status: 'ok', detail: 'brand/ holds the SVG sources and render.sh' };
153
+ }
102
154
  /**
103
155
  * `org/action@vN`, the only form the generators emit. Same shape as the scan in
104
156
  * tests/cli/generators/action-pins.test.ts — major-only, because that's the
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import chalk from 'chalk';
15
15
  import { installAgentRules, installAiSetup } from '../cli/generators/agent-rules.js';
16
+ import { generateBrand } from '../cli/generators/brand.js';
16
17
  import { generateCommunityHealth } from '../cli/generators/community-health.js';
17
18
  import { generateCommitlintConfig } from '../cli/generators/git.js';
18
19
  import { generateCodeowners, generateEditorConfig } from '../cli/generators/misc.js';
@@ -21,6 +22,7 @@ import { copyPreset } from '../cli/utils/copy-preset.js';
21
22
  import { detectLanguage } from '../cli/utils/detect-language.js';
22
23
  import { resolveLanguageModule } from '../languages/registry.js';
23
24
  import { applyGithubSettings } from './github-settings.js';
25
+ import { closeCompletedMilestones } from './milestones.js';
24
26
  /** The repo's language module, resolved from its marker files. */
25
27
  async function moduleFor(targetDir) {
26
28
  return resolveLanguageModule(await detectLanguage(targetDir));
@@ -114,6 +116,19 @@ export const BASE_FIXERS = [
114
116
  return { filesWritten: await applyGithubSettings(targetDir) };
115
117
  },
116
118
  },
119
+ {
120
+ target: 'milestones',
121
+ description: 'Close 100%-complete open milestones on GitHub via gh api (mutates the remote repo, not files). Never deletes or creates one',
122
+ appliesTo: ['Milestones'],
123
+ outputs: ['GitHub milestones (remote, via gh api)'],
124
+ // safe-add for the same reason github-settings is: it exempts this fixer
125
+ // from the `--diff` shadow-run, which executes run() for a mere preview.
126
+ riskLevel: 'safe-add',
127
+ canFixDrift: true,
128
+ async run({ targetDir }) {
129
+ return { filesWritten: await closeCompletedMilestones(targetDir) };
130
+ },
131
+ },
117
132
  {
118
133
  target: 'codeowners',
119
134
  description: 'Scaffold .github/CODEOWNERS with commented examples',
@@ -144,10 +159,33 @@ export const BASE_FIXERS = [
144
159
  return { filesWritten };
145
160
  },
146
161
  },
162
+ {
163
+ target: 'brand',
164
+ description: 'Scaffold brand/ — banner, mobile-banner and social-card SVG sources + render.sh, and repoint a README still on root-level banner paths',
165
+ appliesTo: ['Brand assets'],
166
+ outputs: [
167
+ 'brand/banner.svg',
168
+ 'brand/banner-mobile.svg',
169
+ 'brand/social-card.svg',
170
+ 'brand/render.sh',
171
+ 'README.md',
172
+ ],
173
+ // Every SVG is written only when absent and the README edit rewrites two
174
+ // image paths — hand-edited art is never clobbered.
175
+ riskLevel: 'safe-merge',
176
+ canFixDrift: true,
177
+ async run({ targetDir, pkg }) {
178
+ const filesWritten = await generateBrand(pkg, targetDir);
179
+ if (filesWritten.some((f) => f.endsWith('.svg'))) {
180
+ console.error(chalk.dim(' next: run `brand/render.sh` to render the PNGs (needs librsvg — `brew install librsvg`)'));
181
+ }
182
+ return { filesWritten };
183
+ },
184
+ },
147
185
  {
148
186
  target: 'ai',
149
187
  description: 'Install all AI agent files at once (AGENTS.md, CLAUDE.md, Cursor, Copilot, Claude skill, MCP example)',
150
- appliesTo: ['AI setup'],
188
+ appliesTo: ['AI setup', 'Claude worktree settings'],
151
189
  outputs: [
152
190
  'AGENTS.md',
153
191
  'CLAUDE.md',
@@ -155,6 +193,8 @@ export const BASE_FIXERS = [
155
193
  '.github/copilot-instructions.md',
156
194
  '.claude/skills/repo-tooling.md',
157
195
  '.mcp.json.example',
196
+ // Only written for a repo with a package.json — nothing to symlink otherwise.
197
+ '.claude/settings.json',
158
198
  // Only written when the repo ships its own skills/<name>/SKILL.md.
159
199
  'README.md',
160
200
  ],
@@ -0,0 +1,141 @@
1
+ import path from 'node:path';
2
+ import chalk from 'chalk';
3
+ import fs from 'fs-extra';
4
+ import { realGhExec } from './github-settings.js';
5
+ /**
6
+ * Milestone hygiene (#397). `doctor` already audits GitHub-side state, and an
7
+ * open milestone at 100% is the same class of drift: "open" stops meaning "in
8
+ * flight", so the milestone list carries no signal.
9
+ *
10
+ * Read-only, on the same `gh` seam as github-settings.ts. No repo probe is
11
+ * needed — `gh` expands `{owner}/{repo}` from the remote itself.
12
+ */
13
+ const CHECK = 'Milestones';
14
+ /**
15
+ * A milestone with no completion criterion can never close, so it is
16
+ * structurally a label. Warned about, never fixed — the call is the
17
+ * maintainer's.
18
+ */
19
+ const CATCH_ALL_TITLE = /backlog|post-\d|someday/i;
20
+ const skip = (reason) => ({
21
+ check: CHECK,
22
+ status: 'ok',
23
+ detail: `skipped — ${reason}`,
24
+ });
25
+ const titles = (ms) => ms.map((m) => `"${m.title}"`).join(', ');
26
+ async function readMilestones(gh) {
27
+ // `-X GET` is load-bearing: `-f`/`-F` without an explicit method make `gh`
28
+ // switch to POST, which would hit the *create* milestone endpoint. With it,
29
+ // the fields land in the query string and the path keeps gh's
30
+ // {owner}/{repo} placeholders intact.
31
+ const r = await gh([
32
+ 'api',
33
+ '-X',
34
+ 'GET',
35
+ 'repos/{owner}/{repo}/milestones',
36
+ '-f',
37
+ 'state=all',
38
+ '-F',
39
+ 'per_page=100',
40
+ ]);
41
+ if (!r.ok)
42
+ return null;
43
+ try {
44
+ const parsed = JSON.parse(r.stdout);
45
+ return Array.isArray(parsed) ? parsed : null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ function classify(milestones) {
52
+ const open = milestones.filter((m) => m.state === 'open');
53
+ return {
54
+ // The number guard is the injection boundary: it is interpolated into the
55
+ // PATCH path by the fixer below.
56
+ complete: open.filter((m) => m.open_issues === 0 && m.closed_issues > 0 && Number.isInteger(m.number)),
57
+ // GitHub renders the bar as closed/total, so an empty milestone is a
58
+ // permanent 0% that can never move.
59
+ empty: open.filter((m) => m.open_issues === 0 && m.closed_issues === 0),
60
+ catchAll: open.filter((m) => CATCH_ALL_TITLE.test(m.title)),
61
+ };
62
+ }
63
+ /**
64
+ * The catch-all warning rides in `detail` rather than being its own finding:
65
+ * `fix` deliberately can't resolve it (converting a milestone to a label is a
66
+ * planning decision), and a permanently-unfixable drift row would nag forever.
67
+ */
68
+ const catchAllNote = (catchAll) => catchAll.length === 0
69
+ ? ''
70
+ : ` — note: ${titles(catchAll)} has no completion criterion so it can never close; that is a label, not a milestone`;
71
+ export async function checkMilestones(dir, exec) {
72
+ // Cheap gate first: no .git → never spawn (keeps tmp-dir doctor runs offline).
73
+ if (!(await fs.pathExists(path.join(dir, '.git'))))
74
+ return skip('not a git repository');
75
+ const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
76
+ const milestones = await readMilestones(gh);
77
+ if (!milestones)
78
+ return skip('could not read milestones');
79
+ // Using no milestones at all is a legitimate choice, not drift.
80
+ if (milestones.length === 0)
81
+ return skip('repo uses no milestones');
82
+ const { complete, empty, catchAll } = classify(milestones);
83
+ const deltas = [];
84
+ if (complete.length)
85
+ deltas.push(`100% complete but still open: ${titles(complete)}`);
86
+ if (empty.length)
87
+ deltas.push(`no issues, so a permanent 0%: ${titles(empty)}`);
88
+ const note = catchAllNote(catchAll);
89
+ if (deltas.length)
90
+ return {
91
+ check: CHECK,
92
+ status: 'drift',
93
+ detail: deltas.join('; ') + note,
94
+ hint: 'Run `npx @rtorcato/repo-tooling fix milestones` to close the 100%-complete ones. Empty milestones are left alone — file their issues or delete them by hand',
95
+ };
96
+ return {
97
+ check: CHECK,
98
+ status: 'ok',
99
+ detail: `${milestones.length} milestone(s); none complete-but-open or empty${note}`,
100
+ };
101
+ }
102
+ /**
103
+ * Closes every open milestone that is 100% complete. Deliberately the only
104
+ * mutation: deleting an empty milestone or creating a missing one would destroy
105
+ * or invent planning intent. Idempotent — a clean repo is a no-op.
106
+ *
107
+ * Advisories go to `console.error`; stdout carries the `--json` payload (#357).
108
+ */
109
+ export async function closeCompletedMilestones(dir, exec) {
110
+ if (!(await fs.pathExists(path.join(dir, '.git')))) {
111
+ console.error(chalk.gray(' skipped — not a git repository'));
112
+ return [];
113
+ }
114
+ const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
115
+ const milestones = await readMilestones(gh);
116
+ if (!milestones) {
117
+ console.error(chalk.gray(' skipped — could not read milestones'));
118
+ return [];
119
+ }
120
+ const { complete, empty } = classify(milestones);
121
+ const closed = [];
122
+ for (const m of complete) {
123
+ const r = await gh([
124
+ 'api',
125
+ '-X',
126
+ 'PATCH',
127
+ `repos/{owner}/{repo}/milestones/${m.number}`,
128
+ '-f',
129
+ 'state=closed',
130
+ ]);
131
+ if (r.ok)
132
+ closed.push(`closed milestone "${m.title}"`);
133
+ else
134
+ console.error(chalk.yellow(` could not close "${m.title}": ${r.stderr.trim() || 'gh error'}`));
135
+ }
136
+ if (empty.length)
137
+ console.error(chalk.gray(` left ${empty.length} empty milestone(s) alone — file their issues or delete them by hand`));
138
+ if (closed.length === 0)
139
+ console.error(chalk.gray(' no 100%-complete open milestones to close'));
140
+ return closed;
141
+ }
@@ -13,11 +13,12 @@ import { SWIFT_GIT_HOOKS, runSwiftChecks } from '../../languages/swift/checks.js
13
13
  import { readSwiftPackage, renderSwiftWorkflow } from '../../languages/swift/ci.js';
14
14
  import { detectLanguage } from '../utils/detect-language.js';
15
15
  import { checkGitHubSettings } from '../../base/github-settings.js';
16
+ import { checkMilestones } from '../../base/milestones.js';
16
17
  import { checkGitIdentity } from '../../base/git-identity.js';
17
18
  import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
18
19
  import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
19
- import { checkAiSetup, checkCodeowners, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
20
- import { allDeps, checkAreTheTypesWrong, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
20
+ import { checkAiSetup, checkBrand, checkCodeowners, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
21
+ import { allDeps, checkAreTheTypesWrong, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
21
22
  export { evaluateNodeVersion };
22
23
  const PACKAGE = '@rtorcato/repo-tooling';
23
24
  // Detects the broken-release-on-protected-main footgun: a workflow that runs
@@ -163,9 +164,12 @@ async function runBaseChecks(dir, lock, opts) {
163
164
  // GitHub repo-settings drift (branch protection, merge settings, workflow
164
165
  // permissions). Read-only; self-skips as `ok` outside a live GitHub repo.
165
166
  results.push(...(await checkGitHubSettings(dir)));
167
+ // Milestone hygiene (#397) — same seam, same self-skip.
168
+ results.push(await checkMilestones(dir));
166
169
  results.push(await checkGitLabCI(dir));
167
170
  results.push(await checkCodeowners(dir));
168
171
  results.push(await checkCommunityHealth(dir));
172
+ results.push(await checkBrand(dir));
169
173
  results.push(await checkAiSetup(dir));
170
174
  results.push(await checkReadmeBadges(dir, opts.badges.audience, opts.badges.fixTarget));
171
175
  results.push(await checkCoverageUpload(dir));
@@ -304,6 +308,7 @@ export async function runDoctor(dir) {
304
308
  results.push(await checkTreeshakeSetup(targetDir, pkg));
305
309
  results.push(await checkPnpmWorkspace(targetDir, pkg));
306
310
  results.push(await checkBuildApprovals(targetDir, pkg));
311
+ results.push(await checkClaudeWorktreeSettings(targetDir));
307
312
  // Turborepo is monorepo-only — only surface the check when a workspace exists.
308
313
  if (await fs.pathExists(path.join(targetDir, 'pnpm-workspace.yaml'))) {
309
314
  results.push(await checkTurborepo(targetDir));
@@ -28,6 +28,7 @@ export const FIX_TARGETS = {
28
28
  'Merge settings': 'github-settings',
29
29
  'Workflow permissions': 'github-settings',
30
30
  'Code-scanning gate': 'github-settings',
31
+ Milestones: 'milestones',
31
32
  CODEOWNERS: 'codeowners',
32
33
  'GitLab CI': 'gitlab-ci',
33
34
  Turborepo: 'turborepo',
@@ -41,8 +42,10 @@ export const FIX_TARGETS = {
41
42
  'are-the-types-wrong': 'attw',
42
43
  publint: 'publint',
43
44
  'README badges': 'badges',
45
+ 'Brand assets': 'brand',
44
46
  TypeDoc: 'typedoc',
45
47
  'AI setup': 'ai',
48
+ 'Claude worktree settings': 'ai',
46
49
  };
47
50
  /**
48
51
  * Where the Swift module's fixers shadow (or extend) the JS-named defaults
@@ -154,6 +157,9 @@ export function declinedInLock(lock, checkName) {
154
157
  case 'README badges':
155
158
  return c.badges === false;
156
159
  case 'AI setup':
160
+ // The same recorded choice: the worktree settings are part of what
161
+ // `fix ai` writes, so a repo that declined AI setup declined them too.
162
+ case 'Claude worktree settings':
157
163
  return c.aiSetup === false;
158
164
  case 'Turborepo':
159
165
  return c.turborepo === false;
@@ -273,7 +273,7 @@ export function computeFileList(config) {
273
273
  // so it's written for every JS scaffold.
274
274
  files.push('pnpm-workspace.yaml');
275
275
  if (config.aiSetup) {
276
- files.push('AGENTS.md', 'CLAUDE.md', '.cursor/rules/repo-tooling.mdc', '.github/copilot-instructions.md', '.claude/skills/repo-tooling.md', '.mcp.json.example');
276
+ files.push('AGENTS.md', 'CLAUDE.md', '.cursor/rules/repo-tooling.mdc', '.github/copilot-instructions.md', '.claude/skills/repo-tooling.md', '.mcp.json.example', '.claude/settings.json');
277
277
  }
278
278
  if (config.turborepo)
279
279
  files.push('turbo.json');
@@ -28,6 +28,60 @@ export function hasStaleAgentBlock(content) {
28
28
  const end = content.indexOf(BLOCK_END, bodyStart);
29
29
  return content.slice(bodyStart, end === -1 ? undefined : end).includes(LEGACY_TOOL_NAME);
30
30
  }
31
+ export const CLAUDE_SETTINGS_FILE = path.join('.claude', 'settings.json');
32
+ /**
33
+ * Directories Claude Code symlinks from the main checkout into a fresh
34
+ * worktree instead of leaving the agent to reinstall them (#396). An agent
35
+ * working a per-issue worktree can typecheck immediately rather than paying a
36
+ * full `pnpm install` first, on every issue.
37
+ */
38
+ export const WORKTREE_SYMLINK_DIRS = ['node_modules'];
39
+ function asObject(value) {
40
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
41
+ ? value
42
+ : null;
43
+ }
44
+ /** Parsed `.claude/settings.json`, or null when absent, malformed, or not an object. */
45
+ export async function readClaudeSettings(targetDir) {
46
+ try {
47
+ return asObject(await fs.readJson(path.join(targetDir, CLAUDE_SETTINGS_FILE)));
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /** The `worktree.symlinkDirectories` entries already recorded, ignoring junk. */
54
+ export function worktreeSymlinkDirs(settings) {
55
+ const dirs = asObject(settings?.worktree)?.symlinkDirectories;
56
+ return Array.isArray(dirs) ? dirs.filter((d) => typeof d === 'string') : [];
57
+ }
58
+ /**
59
+ * Upsert `worktree.symlinkDirectories` into `.claude/settings.json`. Repos
60
+ * already keep `hooks` and `permissions` in that file, so only the one key is
61
+ * merged and everything else survives — the same contract the AGENTS.md and
62
+ * copilot-instructions blocks give.
63
+ *
64
+ * Returns the written path, or null when there is nothing to write: a repo with
65
+ * no package.json has no node_modules to symlink (Swift, Python, Perl), and a
66
+ * file that doesn't parse is left for a human rather than clobbered.
67
+ */
68
+ export async function installClaudeSettings(targetDir) {
69
+ if (!(await fs.pathExists(path.join(targetDir, 'package.json'))))
70
+ return null;
71
+ const file = path.join(targetDir, CLAUDE_SETTINGS_FILE);
72
+ const exists = await fs.pathExists(file);
73
+ const settings = exists ? await readClaudeSettings(targetDir) : {};
74
+ if (!settings)
75
+ return null;
76
+ const existing = worktreeSymlinkDirs(settings);
77
+ const symlinkDirectories = [
78
+ ...existing,
79
+ ...WORKTREE_SYMLINK_DIRS.filter((d) => !existing.includes(d)),
80
+ ];
81
+ const worktree = { ...asObject(settings.worktree), symlinkDirectories };
82
+ await fs.outputJson(file, { ...settings, worktree }, { spaces: 2 });
83
+ return CLAUDE_SETTINGS_FILE;
84
+ }
31
85
  /** Read the shipped skill and split its frontmatter from the markdown body. */
32
86
  async function readSkill() {
33
87
  const raw = await fs.readFile(path.join(getPackageRoot(), SOURCE), 'utf8');
@@ -86,6 +140,10 @@ export async function installAiSetup(targetDir) {
86
140
  written.push(await installAgentRules(targetDir, 'copilot'));
87
141
  written.push((await copyPreset('claude-skill', targetDir)).target);
88
142
  written.push((await copyPreset('mcp-example', targetDir)).target);
143
+ // Only for repos that have node_modules to symlink — see installClaudeSettings.
144
+ const claudeSettings = await installClaudeSettings(targetDir);
145
+ if (claudeSettings)
146
+ written.push(claudeSettings);
89
147
  // If this repo ships its own skills (skills/<name>/SKILL.md), document their
90
148
  // one-command `npx skills add` install in README.md. No-op for repos without.
91
149
  const skillsDoc = await installSkillsInstallDocs(targetDir);
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Brand generator (#395) — the sources half of the brand-asset spec (#318).
3
+ *
4
+ * #318 standardised *which* images a repo ships; this writes **where they come
5
+ * from**: `brand/` holds the SVG sources and `brand/render.sh` renders them to
6
+ * PNG, so a banner can be recoloured, retitled or resized instead of being a
7
+ * committed binary nobody can regenerate.
8
+ *
9
+ * Everything in the emitted SVGs is derived from the consuming repo — name and
10
+ * tagline from its package.json, accent from its own docs theme or favicon —
11
+ * and falls back to a neutral grey. Nothing about any particular org is baked
12
+ * in; the templates are meant to be hand-edited afterwards.
13
+ */
14
+ import path from 'node:path';
15
+ import fs from 'fs-extra';
16
+ /** Grey, so an unbranded repo reads as unbranded rather than borrowing a colour. */
17
+ const NEUTRAL_ACCENT = '#8b95a7';
18
+ /** The cool counter-glow in the corner opposite the accent one. Fixed — it reads as depth, not brand. */
19
+ const COUNTER_GLOW = '#6e7bff';
20
+ const INK = '#0A0E16';
21
+ const TEXT = '#e6edf3';
22
+ const MUTED = '#9ba6b8';
23
+ function esc(s) {
24
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
25
+ }
26
+ /**
27
+ * Greedy word wrap to a character budget. Character-budgeted rather than
28
+ * measured because there is no text metric available here — the templates are
29
+ * meant to be nudged by hand once rendered.
30
+ */
31
+ export function wrapText(text, maxChars, maxLines) {
32
+ const lines = [];
33
+ let line = '';
34
+ for (const word of text.split(/\s+/).filter(Boolean)) {
35
+ const next = line ? `${line} ${word}` : word;
36
+ if (next.length > maxChars && line) {
37
+ lines.push(line);
38
+ line = word;
39
+ if (lines.length === maxLines)
40
+ break;
41
+ }
42
+ else {
43
+ line = next;
44
+ }
45
+ }
46
+ if (lines.length < maxLines && line)
47
+ lines.push(line);
48
+ // Anything that didn't fit is dropped rather than overflowing the canvas.
49
+ if (lines.length === maxLines && text.length > lines.join(' ').length) {
50
+ lines[maxLines - 1] = `${lines[maxLines - 1]}…`;
51
+ }
52
+ return lines;
53
+ }
54
+ /** Near-black and near-white are background, not brand — skip them when sniffing a favicon. */
55
+ function isBackgroundColour(hex) {
56
+ const n = Number.parseInt(hex.slice(1), 16);
57
+ const r = (n >> 16) & 0xff;
58
+ const g = (n >> 8) & 0xff;
59
+ const b = n & 0xff;
60
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
61
+ return luminance < 60 || luminance > 225;
62
+ }
63
+ /**
64
+ * The docs site's own accent, which is the most deliberate colour choice a repo
65
+ * makes. Prefers the dark-mode value: these banners sit on a dark canvas.
66
+ */
67
+ async function accentFromDocsTheme(targetDir) {
68
+ const file = path.join(targetDir, 'apps', 'docs', 'src', 'css', 'custom.css');
69
+ if (!(await fs.pathExists(file)))
70
+ return null;
71
+ const css = await fs.readFile(file, 'utf-8');
72
+ const dark = css.match(/\[data-theme=["']dark["']\][\s\S]*?--ifm-color-primary:\s*(#[0-9a-fA-F]{6})/);
73
+ if (dark?.[1])
74
+ return dark[1];
75
+ return css.match(/--ifm-color-primary:\s*(#[0-9a-fA-F]{6})/)?.[1] ?? null;
76
+ }
77
+ /** Failing that, the favicon's own ink — the other place a repo commits its colour. */
78
+ async function accentFromFavicon(targetDir) {
79
+ const candidates = [path.join('apps', 'docs', 'static', 'img', 'favicon.svg'), 'favicon.svg'];
80
+ for (const rel of candidates) {
81
+ const file = path.join(targetDir, rel);
82
+ if (!(await fs.pathExists(file)))
83
+ continue;
84
+ const svg = await fs.readFile(file, 'utf-8');
85
+ for (const [hex] of svg.matchAll(/#[0-9a-fA-F]{6}\b/g)) {
86
+ if (!isBackgroundColour(hex))
87
+ return hex;
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+ export async function resolveBrandMeta(pkg, targetDir) {
93
+ const pkgName = typeof pkg?.name === 'string' ? pkg.name : undefined;
94
+ const name = pkgName?.split('/').pop() ?? path.basename(path.resolve(targetDir));
95
+ const description = typeof pkg?.description === 'string' ? pkg.description : '';
96
+ const accent = (await accentFromDocsTheme(targetDir)) ?? (await accentFromFavicon(targetDir)) ?? NEUTRAL_ACCENT;
97
+ return {
98
+ name,
99
+ tagline: description || 'Add a one-line tagline to package.json "description".',
100
+ accent,
101
+ install: pkgName && pkg?.private !== true ? pkgName : null,
102
+ };
103
+ }
104
+ /**
105
+ * The logo mark: a rounded square in the accent carrying the project's initial.
106
+ * Authored on a 32 viewBox so it matches the favicon's geometry and can be
107
+ * swapped for the real favicon glyph verbatim.
108
+ */
109
+ function mark(meta, translate, scale) {
110
+ const initial = esc((meta.name[0] ?? '?').toUpperCase());
111
+ return ` <!-- Logo mark: a 32 viewBox, so the real favicon glyph can be pasted in over it. -->
112
+ <g transform="translate(${translate}) scale(${scale})">
113
+ <rect width="32" height="32" rx="8" fill="${meta.accent}"/>
114
+ <text x="16" y="23" text-anchor="middle" font-family="Avenir Next" font-weight="800" font-size="19" fill="${INK}">${initial}</text>
115
+ </g>`;
116
+ }
117
+ /** `repo-tooling` renders as a muted `repo-` and an accented `tooling`. */
118
+ function wordmark(meta) {
119
+ const i = meta.name.lastIndexOf('-');
120
+ if (i <= 0)
121
+ return `<tspan fill="${meta.accent}">${esc(meta.name)}</tspan>`;
122
+ return `<tspan fill="${TEXT}">${esc(meta.name.slice(0, i + 1))}</tspan><tspan fill="${meta.accent}">${esc(meta.name.slice(i + 1))}</tspan>`;
123
+ }
124
+ function taglineBlock(meta, opts) {
125
+ // Three lines: every canvas has room for a third, and cutting a real tagline
126
+ // short is worse than one extra line of copy.
127
+ const lines = wrapText(meta.tagline, opts.maxChars, 3);
128
+ // librsvg does not reset x on a y-only tspan, so every line repeats x.
129
+ const tspans = lines
130
+ .map((l, i) => `\t\t<tspan x="${opts.x}" y="${opts.y + i * opts.step}">${esc(l)}</tspan>`)
131
+ .join('\n');
132
+ const anchor = opts.centred ? ' text-anchor="middle"' : '';
133
+ return ` <text${anchor} font-family="Avenir Next" font-weight="500" font-size="${opts.size}" fill="${MUTED}">
134
+ ${tspans}
135
+ </text>`;
136
+ }
137
+ /** The install pill. Omitted entirely for a repo with nothing to `npm i`. */
138
+ function installPanel(meta, opts) {
139
+ if (!meta.install)
140
+ return '';
141
+ return `
142
+ <rect x="${opts.x}" y="${opts.y}" width="${opts.w}" height="${opts.h}" rx="14" fill="#11151d" stroke="#232936" stroke-width="1"/>
143
+ <text xml:space="preserve" x="${opts.x + opts.w / 2}" y="${opts.y + opts.h / 2 + opts.size / 3}" text-anchor="middle" font-family="Menlo" font-size="${opts.size}"><tspan fill="${meta.accent}">npm i </tspan><tspan fill="${TEXT}">${esc(meta.install)}</tspan></text>`;
144
+ }
145
+ function canvas(meta, w, h, glow) {
146
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" role="img" aria-label="${esc(meta.name)} — ${esc(meta.tagline)}">
147
+ <defs>
148
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
149
+ <stop offset="0" stop-color="#0d1117"/>
150
+ <stop offset="1" stop-color="#090c13"/>
151
+ </linearGradient>
152
+ <radialGradient id="glow" cx="${glow.cx}" cy="${glow.cy}" r="0.55">
153
+ <stop offset="0" stop-color="${meta.accent}" stop-opacity="0.15"/>
154
+ <stop offset="1" stop-color="${meta.accent}" stop-opacity="0"/>
155
+ </radialGradient>
156
+ <radialGradient id="glow2" cx="0.92" cy="1" r="0.5">
157
+ <stop offset="0" stop-color="${COUNTER_GLOW}" stop-opacity="0.12"/>
158
+ <stop offset="1" stop-color="${COUNTER_GLOW}" stop-opacity="0"/>
159
+ </radialGradient>
160
+ </defs>
161
+
162
+ <rect width="${w}" height="${h}" fill="url(#bg)"/>
163
+ <rect width="${w}" height="${h}" fill="url(#glow)"/>
164
+ <rect width="${w}" height="${h}" fill="url(#glow2)"/>
165
+ `;
166
+ }
167
+ /** 1280×320 README banner — left-aligned lockup, install pill on the right. */
168
+ export function bannerSvg(meta) {
169
+ return `${canvas(meta, 1280, 320, { cx: 0.16, cy: 0 })}
170
+ ${mark(meta, '60 88', 2.25)}
171
+
172
+ <text x="156" y="150" font-family="Avenir Next" font-weight="800" font-size="62" letter-spacing="-1.5">${wordmark(meta)}</text>
173
+
174
+ ${taglineBlock(meta, { x: 62, y: 198, step: 28, size: 20, centred: false, maxChars: 44 })}
175
+ ${installPanel(meta, { x: 845, y: 118, w: 378, h: 84, size: 20 })}
176
+ </svg>
177
+ `;
178
+ }
179
+ /** 1280×786 mobile banner — the same content stacked so it stays legible on a phone. */
180
+ export function bannerMobileSvg(meta) {
181
+ return `${canvas(meta, 1280, 786, { cx: 0.12, cy: 0.05 })}
182
+ ${mark(meta, '565 104', 4.6875)}
183
+
184
+ <text x="640" y="360" text-anchor="middle" font-family="Avenir Next" font-weight="800" font-size="76" letter-spacing="-1.8">${wordmark(meta)}</text>
185
+
186
+ ${taglineBlock(meta, { x: 640, y: 510, step: 44, size: 30, centred: true, maxChars: 42 })}
187
+ ${installPanel(meta, { x: 427, y: 650, w: 426, h: 78, size: 24 })}
188
+ </svg>
189
+ `;
190
+ }
191
+ /** 1280×640 Open Graph / GitHub social card. Keep content inside an ~8% safe inset. */
192
+ export function socialCardSvg(meta) {
193
+ return `${canvas(meta, 1280, 640, { cx: 0.1, cy: 0.05 })}
194
+ ${mark(meta, '590 120', 3.125)}
195
+
196
+ <text x="640" y="300" text-anchor="middle" font-family="Avenir Next" font-weight="800" font-size="76" letter-spacing="-1.8">${wordmark(meta)}</text>
197
+
198
+ ${taglineBlock(meta, { x: 640, y: 372, step: 42, size: 28, centred: true, maxChars: 44 })}
199
+ ${installPanel(meta, { x: 427, y: 470, w: 426, h: 78, size: 24 })}
200
+ </svg>
201
+ `;
202
+ }
203
+ /**
204
+ * The render script. Sizes come from the #318 spec. It self-skips outputs whose
205
+ * source or destination doesn't exist, so the same script works in a repo with
206
+ * no docs site.
207
+ */
208
+ export const RENDER_SH = `#!/usr/bin/env bash
209
+ # Render the committed brand PNGs from their SVG sources.
210
+ # Sizes come from the brand-asset spec: 1280x320 banner, 1280x786 mobile,
211
+ # 1280x640 social card, 512x512 PWA icon.
212
+ set -euo pipefail
213
+ cd "$(dirname "$0")/.."
214
+
215
+ if ! command -v rsvg-convert >/dev/null 2>&1; then
216
+ echo "brand/render.sh needs librsvg — install it with: brew install librsvg" >&2
217
+ echo "(apt: apt-get install librsvg2-bin)" >&2
218
+ exit 1
219
+ fi
220
+
221
+ rsvg-convert -w 1280 -h 320 brand/banner.svg -o brand/banner.png
222
+ rsvg-convert -w 1280 -h 786 brand/banner-mobile.svg -o brand/banner-mobile.png
223
+ echo "rendered: brand/banner.png brand/banner-mobile.png"
224
+
225
+ # The docs-site assets, rendered only when the site exists to hold them.
226
+ img=apps/docs/static/img
227
+ if [ -d "$img" ]; then
228
+ rsvg-convert -w 1280 -h 640 brand/social-card.svg -o "$img/social-card.png"
229
+ echo "rendered: $img/social-card.png"
230
+ if [ -f "$img/favicon.svg" ]; then
231
+ rsvg-convert -w 512 -h 512 "$img/favicon.svg" -o "$img/favicon-512.png"
232
+ echo "rendered: $img/favicon-512.png"
233
+ fi
234
+ fi
235
+ `;
236
+ /** Write `contents` at `rel` only when absent, so re-running never clobbers hand-edited art. */
237
+ async function writeIfMissing(targetDir, rel, contents, mode) {
238
+ const file = path.join(targetDir, rel);
239
+ if (await fs.pathExists(file))
240
+ return null;
241
+ await fs.ensureDir(path.dirname(file));
242
+ await fs.writeFile(file, contents, mode ? { mode } : undefined);
243
+ return rel;
244
+ }
245
+ /**
246
+ * Repoint a README still using the pre-amendment root-level banner paths at
247
+ * `brand/`. Only the two banner `srcset`/`src` values move — nothing else in the
248
+ * README is touched.
249
+ */
250
+ export async function repointReadmeBanners(targetDir) {
251
+ const file = path.join(targetDir, 'README.md');
252
+ if (!(await fs.pathExists(file)))
253
+ return null;
254
+ const readme = await fs.readFile(file, 'utf-8');
255
+ const next = readme.replace(/(?<!brand\/)(?:\.\/)?(banner(?:-mobile)?\.png)/g, './brand/$1');
256
+ if (next === readme)
257
+ return null;
258
+ await fs.writeFile(file, next);
259
+ return 'README.md';
260
+ }
261
+ /**
262
+ * Scaffold `brand/`: three SVG sources + the render script, then repoint a
263
+ * README still on the old root-level paths. Every file is written only when
264
+ * absent, so `fix brand` is idempotent.
265
+ */
266
+ export async function generateBrand(pkg, targetDir) {
267
+ const meta = await resolveBrandMeta(pkg, targetDir);
268
+ const written = [];
269
+ const files = [
270
+ ['brand/banner.svg', bannerSvg(meta)],
271
+ ['brand/banner-mobile.svg', bannerMobileSvg(meta)],
272
+ ['brand/social-card.svg', socialCardSvg(meta)],
273
+ ['brand/render.sh', RENDER_SH, 0o755],
274
+ ];
275
+ for (const [rel, contents, mode] of files) {
276
+ const w = await writeIfMissing(targetDir, rel, contents, mode);
277
+ if (w)
278
+ written.push(w);
279
+ }
280
+ const readme = await repointReadmeBanners(targetDir);
281
+ if (readme)
282
+ written.push(readme);
283
+ return written;
284
+ }
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
3
  import { hookHasUncommented } from '../../base/checks.js';
4
+ import { CLAUDE_SETTINGS_FILE, readClaudeSettings, worktreeSymlinkDirs, } from '../../cli/generators/agent-rules.js';
4
5
  import { WORKSPACE_FILE, dependsOnEsbuild, familyGlob, missingPnpmSettings, } from '../../cli/generators/pnpm-workspace.js';
5
6
  const PACKAGE = '@rtorcato/repo-tooling';
6
7
  const NODE_MIN_MAJOR = 22;
@@ -951,6 +952,44 @@ export async function checkTreeshakeSetup(dir, pkg) {
951
952
  hint: 'Run `npx @rtorcato/repo-tooling fix treeshake-check` to scaffold an esbuild metafile assertion',
952
953
  };
953
954
  }
955
+ /**
956
+ * A Claude Code worktree starts with no node_modules, so every agent working
957
+ * one pays a full install before it can typecheck, lint or test — unless
958
+ * `.claude/settings.json` tells Claude to symlink the directory from the main
959
+ * checkout (#396). JS-only: the other language modules have nothing to symlink.
960
+ */
961
+ export async function checkClaudeWorktreeSettings(dir) {
962
+ const check = 'Claude worktree settings';
963
+ const hint = `Run \`npx ${PACKAGE} fix ai\` to merge worktree.symlinkDirectories into ${CLAUDE_SETTINGS_FILE}`;
964
+ if (!(await fs.pathExists(path.join(dir, CLAUDE_SETTINGS_FILE)))) {
965
+ return {
966
+ check,
967
+ status: 'optional-missing',
968
+ detail: `no ${CLAUDE_SETTINGS_FILE} — agent worktrees reinstall node_modules from scratch`,
969
+ hint,
970
+ };
971
+ }
972
+ const settings = await readClaudeSettings(dir);
973
+ if (!settings) {
974
+ return {
975
+ check,
976
+ status: 'drift',
977
+ detail: `${CLAUDE_SETTINGS_FILE} is not a readable JSON object`,
978
+ // `fix ai` deliberately skips an unparseable file rather than clobbering
979
+ // hand-written hooks/permissions, so this one is on the human.
980
+ hint: `Repair the JSON in ${CLAUDE_SETTINGS_FILE} by hand — \`fix ai\` refuses to overwrite it`,
981
+ };
982
+ }
983
+ if (worktreeSymlinkDirs(settings).includes('node_modules')) {
984
+ return { check, status: 'ok', detail: 'worktree.symlinkDirectories carries node_modules' };
985
+ }
986
+ return {
987
+ check,
988
+ status: 'optional-missing',
989
+ detail: `${CLAUDE_SETTINGS_FILE} has no worktree.symlinkDirectories entry for node_modules`,
990
+ hint,
991
+ };
992
+ }
954
993
  export async function checkTurborepo(dir) {
955
994
  const hasTurbo = await fs.pathExists(path.join(dir, 'turbo.json'));
956
995
  const hasNx = await fs.pathExists(path.join(dir, 'nx.json'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.8.7",
3
+ "version": "3.9.1",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -2,11 +2,10 @@ export default {
2
2
  extends: ["@commitlint/config-conventional"],
3
3
  ignores: [
4
4
  (commit) => commit.includes("[skip ci]"),
5
- // Bot commit bodies are machine-written blocks (dependabot's
6
- // `- dependency-name: …` YAML) that never wrap at 72, so every bot PR
7
- // failed body-max-line-length. commitlint can't relax a single rule per
8
- // commit, so skip bot commits wholesale — squash-merge uses the PR
9
- // title, which is still linted on the merge commit.
5
+ // Bot commits are machine-written end to end, including headers long
6
+ // enough to trip header-max-length. Skipping them wholesale keeps bot
7
+ // PRs green — squash-merge uses the PR title, which is still linted on
8
+ // the merge commit.
10
9
  // ponytail: matches the trailer, not a bare "[bot]", so a human commit
11
10
  // that merely mentions a bot is still linted.
12
11
  (commit) => /^Signed-off-by: .*\[bot\]/m.test(commit),
@@ -32,10 +31,15 @@ export default {
32
31
  ],
33
32
  // 100 is the conventional-commits/semantic-release default. 72 was too
34
33
  // tight: GitHub appends " (#NN)" to squash commits, overflowing the
35
- // header on main and skipping the release. Body/footer stay at 72.
34
+ // header on main and skipping the release.
36
35
  "header-max-length": [2, "always", 100],
37
- "body-max-line-length": [2, "always", 72],
38
- "footer-max-line-length": [2, "always", 72],
36
+ // Body/footer length is unenforced. Machine-written commits (agents,
37
+ // bots) don't wrap, and a `BREAKING CHANGE:` footer — the input
38
+ // semantic-release reads to cut a major — is the worst thing to make
39
+ // someone hand-rewrap. The subject rules below decide the release
40
+ // type and stay enforced.
41
+ "body-max-line-length": [0],
42
+ "footer-max-line-length": [0],
39
43
  // Enforce case rules (allow common patterns)
40
44
  "subject-case": [0], // Disable case enforcement to allow flexibility
41
45
  "type-case": [2, "always", "lower-case"],