@rtorcato/repo-tooling 3.9.2 → 3.10.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 +7 -0
- package/README.md +15 -3
- package/dist/base/checks.js +38 -0
- package/dist/base/fixers.js +60 -0
- package/dist/cli/commands/doctor.js +3 -1
- package/dist/cli/commands/fix-targets.js +1 -0
- package/dist/cli/commands/fix.js +21 -5
- package/dist/cli/generators/agent-rules.js +60 -4
- package/dist/cli/generators/claude-skills.js +144 -0
- package/dist/cli/generators/misc.js +1 -1
- package/dist/cli/index.js +8 -0
- package/dist/languages/js/checks.js +11 -4
- package/package.json +2 -1
- package/skills/ai-issue-loop/SKILL.md +814 -0
- package/skills/npm-publish/SKILL.md +47 -0
- package/skills/repo-tooling/SKILL.md +69 -0
- package/tooling/claude/repo-tooling.md +6 -3
package/AGENTS.md
CHANGED
|
@@ -82,12 +82,19 @@ npx @rtorcato/repo-tooling fix dependabot --yes --json
|
|
|
82
82
|
npx @rtorcato/repo-tooling fix engines --yes --json
|
|
83
83
|
npx @rtorcato/repo-tooling fix docs-site --yes --json # scaffold a Docusaurus docs site under apps/docs
|
|
84
84
|
npx @rtorcato/repo-tooling fix bun --yes --json # Bun runtime/test config
|
|
85
|
+
|
|
86
|
+
# Opt-in only — writes user-global state, so a bare `fix` / `fix --yes` skips it.
|
|
87
|
+
# Installs the ai-issue-loop skill to ~/.claude/skills. Override with --skills-dir,
|
|
88
|
+
# which is required alongside --yes/--json when that directory doesn't exist.
|
|
89
|
+
npx @rtorcato/repo-tooling fix claude-skills --yes --json
|
|
85
90
|
```
|
|
86
91
|
|
|
87
92
|
## Drift policy (important)
|
|
88
93
|
|
|
89
94
|
`fix` defaults the confirm prompt to **No** for drift cases (existing file that doesn't extend our preset). The `--yes` flag is required to overwrite drift. Safe-merge fixers (`engines`, `husky`, `package-json`) never overwrite — they add/merge — and use friendlier prompt wording. `fix --json` implies `--yes` (prompts would corrupt JSON output).
|
|
90
95
|
|
|
96
|
+
Fixers marked `explicitOnly` are exempt from `fix` all *and* from `fix --yes` — they only run when named as the target. Today that is `claude-skills`, the one fixer whose blast radius is outside the repo.
|
|
97
|
+
|
|
91
98
|
## Source-of-truth files in the repo
|
|
92
99
|
|
|
93
100
|
- `src/cli/commands/setup.ts` — `ProjectConfig` interface and the setup orchestrator
|
package/README.md
CHANGED
|
@@ -174,14 +174,25 @@ ln -sf ../../node_modules/@rtorcato/repo-tooling/tooling/claude/repo-tooling.md
|
|
|
174
174
|
### Use with Claude Code (plugin)
|
|
175
175
|
|
|
176
176
|
This repo is also a self-hosted Claude Code marketplace. Install the plugin to
|
|
177
|
-
get
|
|
178
|
-
`npm-publish` (the family's release rules)
|
|
177
|
+
get three skills — `repo-tooling` (adopt/audit the presets via the CLI),
|
|
178
|
+
`npm-publish` (the family's release rules) and `ai-issue-loop` (the label-driven
|
|
179
|
+
issue → PR pipeline) — in any session:
|
|
179
180
|
|
|
180
181
|
```
|
|
181
182
|
/plugin marketplace add rtorcato/repo-tooling
|
|
182
183
|
/plugin install repo-tooling@repo-tooling
|
|
183
184
|
```
|
|
184
185
|
|
|
186
|
+
`ai-issue-loop` also installs on its own, user-globally, so every repo on the
|
|
187
|
+
machine shares one copy:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
npx @rtorcato/repo-tooling fix claude-skills # → ~/.claude/skills/ai-issue-loop/SKILL.md
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
It writes outside the repo, so it is opt-in: a bare `fix` skips it. See the
|
|
194
|
+
[AI Issue Loop guide](https://rtorcato.github.io/repo-tooling/guides/ai-issue-loop/).
|
|
195
|
+
|
|
185
196
|
### Use with other AI tools (Cursor / Copilot / Codex)
|
|
186
197
|
|
|
187
198
|
[`AGENTS.md`](AGENTS.md) at the repo root carries the same guidance in the
|
|
@@ -217,7 +228,8 @@ MIT — see [LICENSE](LICENSE).
|
|
|
217
228
|
Any agent that supports the [`skills`](https://www.npmjs.com/package/skills) CLI can install this repo's skills straight from GitHub — no clone, no package install:
|
|
218
229
|
|
|
219
230
|
```bash
|
|
220
|
-
npx skills add https://github.com/rtorcato/repo-tooling --skill
|
|
231
|
+
npx skills add https://github.com/rtorcato/repo-tooling --skill ai-issue-loop
|
|
221
232
|
npx skills add https://github.com/rtorcato/repo-tooling --skill npm-publish
|
|
233
|
+
npx skills add https://github.com/rtorcato/repo-tooling --skill repo-tooling
|
|
222
234
|
```
|
|
223
235
|
<!-- js-tooling:skills:end -->
|
package/dist/base/checks.js
CHANGED
|
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import { BLOCK_START, hasStaleAgentBlock } from '../cli/generators/agent-rules.js';
|
|
4
4
|
import { BADGE_START, hasPublicOnlyBadges } from '../cli/generators/badges.js';
|
|
5
|
+
import { claudeSkillStatus, SHIPPED_SKILL } from '../cli/generators/claude-skills.js';
|
|
5
6
|
import { detectNestedLanguages } from '../cli/utils/detect-language.js';
|
|
6
7
|
/**
|
|
7
8
|
* Root-only detection is the decision (#317), not an oversight — but it used to
|
|
@@ -428,6 +429,43 @@ export async function checkAiSetup(dir) {
|
|
|
428
429
|
hint: 'Run `npx @rtorcato/repo-tooling fix ai` to scaffold agent rules for every AI tool',
|
|
429
430
|
};
|
|
430
431
|
}
|
|
432
|
+
/**
|
|
433
|
+
* The user-global agent skills this package ships (#404).
|
|
434
|
+
*
|
|
435
|
+
* The only check that reports on state *outside* the repo being audited, which
|
|
436
|
+
* is why it never returns `drift` or `missing`: an out-of-date `~/.claude/skills`
|
|
437
|
+
* is not a defect in this repo, and either of those statuses would fail its CI
|
|
438
|
+
* over a file CI has never seen. `optional-missing` is the honest verdict — an
|
|
439
|
+
* opt-in workflow tool that isn't configured — and it leaves the exit code alone.
|
|
440
|
+
*/
|
|
441
|
+
export async function checkClaudeSkills() {
|
|
442
|
+
const check = 'Claude skills';
|
|
443
|
+
const hint = `Run \`npx @rtorcato/repo-tooling fix claude-skills\` to install the ${SHIPPED_SKILL} skill (writes outside the repo; opt-in, so \`fix\` alone skips it)`;
|
|
444
|
+
const status = await claudeSkillStatus();
|
|
445
|
+
if (!status.installed) {
|
|
446
|
+
return {
|
|
447
|
+
check,
|
|
448
|
+
status: 'optional-missing',
|
|
449
|
+
detail: status.file
|
|
450
|
+
? `${SHIPPED_SKILL} skill is not installed`
|
|
451
|
+
: `no ~/.claude/skills — the ${SHIPPED_SKILL} skill is not installed`,
|
|
452
|
+
hint,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
if (status.needsInstall) {
|
|
456
|
+
return {
|
|
457
|
+
check,
|
|
458
|
+
status: 'optional-missing',
|
|
459
|
+
detail: `${SHIPPED_SKILL} skill is at ${status.installedVersion ?? 'an unstamped version'}; this package ships ${status.shippedVersion}`,
|
|
460
|
+
hint,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
check,
|
|
465
|
+
status: 'ok',
|
|
466
|
+
detail: `${SHIPPED_SKILL} skill installed at ${status.installedVersion}`,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
431
469
|
/**
|
|
432
470
|
* Conventional Commits is a repo convention, not a JavaScript one — the config
|
|
433
471
|
* file is the same in any repo that has node available to run commitlint, so
|
package/dist/base/fixers.js
CHANGED
|
@@ -11,8 +11,12 @@
|
|
|
11
11
|
* language-shaped (CI steps, recorded tool choices), so each module ships its
|
|
12
12
|
* own — see src/base/ci.ts for the shell they share.
|
|
13
13
|
*/
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
14
16
|
import chalk from 'chalk';
|
|
17
|
+
import inquirer from 'inquirer';
|
|
15
18
|
import { installAgentRules, installAiSetup } from '../cli/generators/agent-rules.js';
|
|
19
|
+
import { installClaudeSkill, resolveSkillsDir, SHIPPED_SKILL, } from '../cli/generators/claude-skills.js';
|
|
16
20
|
import { generateBrand } from '../cli/generators/brand.js';
|
|
17
21
|
import { generateCommunityHealth } from '../cli/generators/community-health.js';
|
|
18
22
|
import { generateCommitlintConfig } from '../cli/generators/git.js';
|
|
@@ -27,6 +31,31 @@ import { closeCompletedMilestones } from './milestones.js';
|
|
|
27
31
|
async function moduleFor(targetDir) {
|
|
28
32
|
return resolveLanguageModule(await detectLanguage(targetDir));
|
|
29
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Where to install user-global skills, asking only when nothing resolves. Under
|
|
36
|
+
* `--yes` / `--json` a prompt is not available — `--json` would have its payload
|
|
37
|
+
* corrupted by one — so an unresolvable destination becomes an advisory and a
|
|
38
|
+
* no-op rather than a guess at a directory the user never mentioned.
|
|
39
|
+
*/
|
|
40
|
+
async function resolveInstallDir(explicit, assumeYes) {
|
|
41
|
+
const { dir } = await resolveSkillsDir(explicit);
|
|
42
|
+
if (dir)
|
|
43
|
+
return dir;
|
|
44
|
+
if (assumeYes) {
|
|
45
|
+
console.error(chalk.yellow(' skipped — no ~/.claude/skills found; pass --skills-dir <path>'));
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const { answer } = await inquirer.prompt([
|
|
49
|
+
{
|
|
50
|
+
type: 'input',
|
|
51
|
+
name: 'answer',
|
|
52
|
+
message: 'Install agent skills where?',
|
|
53
|
+
default: path.join(os.homedir(), '.claude', 'skills'),
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
const trimmed = typeof answer === 'string' ? answer.trim() : '';
|
|
57
|
+
return trimmed ? path.resolve(trimmed) : null;
|
|
58
|
+
}
|
|
30
59
|
export const BASE_FIXERS = [
|
|
31
60
|
{
|
|
32
61
|
target: 'editorconfig',
|
|
@@ -219,6 +248,37 @@ export const BASE_FIXERS = [
|
|
|
219
248
|
return { filesWritten: [result.target] };
|
|
220
249
|
},
|
|
221
250
|
},
|
|
251
|
+
{
|
|
252
|
+
target: 'claude-skills',
|
|
253
|
+
description: `Install the ${SHIPPED_SKILL} Claude Code skill into the user-level skills dir (~/.claude/skills, or --skills-dir). Writes outside the repo`,
|
|
254
|
+
appliesTo: ['Claude skills'],
|
|
255
|
+
outputs: [`~/.claude/skills/${SHIPPED_SKILL}/SKILL.md`],
|
|
256
|
+
// safe-add is load-bearing for the same reason it is on github-settings:
|
|
257
|
+
// it exempts this fixer from the `--diff` shadow-run, which copies the repo
|
|
258
|
+
// to tmp and *executes* run() — here that would write to the real home dir
|
|
259
|
+
// during what the user asked to be a preview.
|
|
260
|
+
riskLevel: 'safe-add',
|
|
261
|
+
explicitOnly: true,
|
|
262
|
+
canFixDrift: true,
|
|
263
|
+
async run({ skillsDir, assumeYes }) {
|
|
264
|
+
const dir = await resolveInstallDir(skillsDir, assumeYes);
|
|
265
|
+
if (!dir)
|
|
266
|
+
return { filesWritten: [] };
|
|
267
|
+
const result = await installClaudeSkill(dir);
|
|
268
|
+
if (result.status === 'declined-downgrade') {
|
|
269
|
+
console.error(chalk.yellow(` skipped — ${result.file} is at ${result.installedVersion}, newer than the ${result.shippedVersion} this package ships`));
|
|
270
|
+
return { filesWritten: [] };
|
|
271
|
+
}
|
|
272
|
+
if (result.status === 'up-to-date')
|
|
273
|
+
return { filesWritten: [] };
|
|
274
|
+
// Report the resolved real path when the skill is a stow symlink: the bytes
|
|
275
|
+
// landed in a dotfiles checkout, and that is where the user has to commit them.
|
|
276
|
+
if (result.viaSymlink) {
|
|
277
|
+
console.error(chalk.dim(` wrote through a symlink — commit ${result.realFile}`));
|
|
278
|
+
}
|
|
279
|
+
return { filesWritten: [result.realFile] };
|
|
280
|
+
},
|
|
281
|
+
},
|
|
222
282
|
{
|
|
223
283
|
target: 'cursor-rules',
|
|
224
284
|
description: 'Install the repo-tooling rules for Cursor (.cursor/rules/repo-tooling.mdc)',
|
|
@@ -17,7 +17,7 @@ import { checkMilestones } from '../../base/milestones.js';
|
|
|
17
17
|
import { checkGitIdentity } from '../../base/git-identity.js';
|
|
18
18
|
import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
|
|
19
19
|
import { declinedInLock, getFixTargetForCheck } from './fix-targets.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';
|
|
20
|
+
import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
|
|
21
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';
|
|
22
22
|
export { evaluateNodeVersion };
|
|
23
23
|
const PACKAGE = '@rtorcato/repo-tooling';
|
|
@@ -171,6 +171,8 @@ async function runBaseChecks(dir, lock, opts) {
|
|
|
171
171
|
results.push(await checkCommunityHealth(dir));
|
|
172
172
|
results.push(await checkBrand(dir));
|
|
173
173
|
results.push(await checkAiSetup(dir));
|
|
174
|
+
// User-global, not repo state — see checkClaudeSkills on why it never returns drift.
|
|
175
|
+
results.push(await checkClaudeSkills());
|
|
174
176
|
results.push(await checkReadmeBadges(dir, opts.badges.audience, opts.badges.fixTarget));
|
|
175
177
|
results.push(await checkCoverageUpload(dir));
|
|
176
178
|
return results;
|
package/dist/cli/commands/fix.js
CHANGED
|
@@ -132,7 +132,8 @@ async function previewFixer(fixer, result, targetDir, pkg, lock) {
|
|
|
132
132
|
return first !== 'node_modules' && first !== 'dist' && first !== 'build' && first !== '.git';
|
|
133
133
|
},
|
|
134
134
|
});
|
|
135
|
-
|
|
135
|
+
// assumeYes: a preview must never prompt.
|
|
136
|
+
await fixer.run({ targetDir: tmpDir, pkg, result, lock, assumeYes: true });
|
|
136
137
|
const previews = [];
|
|
137
138
|
const seen = new Set();
|
|
138
139
|
for (const output of fixer.outputs) {
|
|
@@ -186,14 +187,14 @@ function printPreviews(previews) {
|
|
|
186
187
|
}
|
|
187
188
|
}
|
|
188
189
|
}
|
|
189
|
-
async function applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent) {
|
|
190
|
+
async function applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent, opts) {
|
|
190
191
|
if (dryRun) {
|
|
191
192
|
if (!silent) {
|
|
192
193
|
console.log(chalk.cyan(` [dry-run] would write: ${fixer.outputs.join(', ')}`));
|
|
193
194
|
}
|
|
194
195
|
return { filesWritten: [], dryRun: true };
|
|
195
196
|
}
|
|
196
|
-
const { filesWritten } = await fixer.run({ targetDir, pkg, result, lock });
|
|
197
|
+
const { filesWritten } = await fixer.run({ targetDir, pkg, result, lock, ...opts });
|
|
197
198
|
if (!silent && filesWritten.length > 0) {
|
|
198
199
|
console.log(chalk.green(` ✅ wrote ${filesWritten.join(', ')}`));
|
|
199
200
|
}
|
|
@@ -397,7 +398,10 @@ export async function fixCommand(target, options = {}) {
|
|
|
397
398
|
console.log(chalk.gray(' skipped\n'));
|
|
398
399
|
return;
|
|
399
400
|
}
|
|
400
|
-
const outcome = await applyFixer(fixer, effectiveResult, targetDir, pkg, lock, dryRun, silent
|
|
401
|
+
const outcome = await applyFixer(fixer, effectiveResult, targetDir, pkg, lock, dryRun, silent, {
|
|
402
|
+
skillsDir: options.skillsDir,
|
|
403
|
+
assumeYes,
|
|
404
|
+
});
|
|
401
405
|
actions.push(recordFor(fixer.target, result.check, effectiveResult.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten, conflict));
|
|
402
406
|
if (json)
|
|
403
407
|
return emitJson(fixer.target);
|
|
@@ -429,6 +433,15 @@ export async function fixCommand(target, options = {}) {
|
|
|
429
433
|
if (!silent) {
|
|
430
434
|
console.log(` ${chalk.bold(result.check)} (${result.status}) → ${fixer.target}`);
|
|
431
435
|
}
|
|
436
|
+
// Opt-in only — `--yes` must not sweep in a fixer that writes outside the repo.
|
|
437
|
+
if (fixer.explicitOnly) {
|
|
438
|
+
actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', []));
|
|
439
|
+
if (!silent) {
|
|
440
|
+
console.log(chalk.gray(` skipped — run \`fix ${fixer.target}\` explicitly`));
|
|
441
|
+
}
|
|
442
|
+
skippedCount++;
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
432
445
|
const conflict = noteLockConflict(result.check);
|
|
433
446
|
if (showDiff && (fixer.riskLevel ?? 'destructive') !== 'safe-add') {
|
|
434
447
|
const previews = await previewFixer(fixer, result, targetDir, pkg, lock);
|
|
@@ -442,7 +455,10 @@ export async function fixCommand(target, options = {}) {
|
|
|
442
455
|
skippedCount++;
|
|
443
456
|
continue;
|
|
444
457
|
}
|
|
445
|
-
const outcome = await applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent
|
|
458
|
+
const outcome = await applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent, {
|
|
459
|
+
skillsDir: options.skillsDir,
|
|
460
|
+
assumeYes,
|
|
461
|
+
});
|
|
446
462
|
actions.push(recordFor(fixer.target, result.check, result.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten, conflict));
|
|
447
463
|
appliedCount++;
|
|
448
464
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { glob } from 'node:fs/promises';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import fs from 'fs-extra';
|
|
3
4
|
import { copyPreset, getPackageRoot } from '../utils/copy-preset.js';
|
|
4
5
|
import { LEGACY_TOOL_NAME } from '../utils/lockfile.js';
|
|
6
|
+
import { parseWorkspacePackages } from './misc.js';
|
|
5
7
|
import { installSkillsInstallDocs } from './skills-install.js';
|
|
6
8
|
/**
|
|
7
9
|
* All agent rule files are generated from one source of truth — the shipped
|
|
@@ -41,6 +43,62 @@ function asObject(value) {
|
|
|
41
43
|
? value
|
|
42
44
|
: null;
|
|
43
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* True when `rel` resolves to something at or under `targetDir`. The entries
|
|
48
|
+
* this guards end up in `worktree.symlinkDirectories`, which Claude Code reads
|
|
49
|
+
* as symlink targets — an escaping path would point a worktree symlink outside
|
|
50
|
+
* the repo, so a workspace glob is never trusted to stay inside on its own.
|
|
51
|
+
*/
|
|
52
|
+
function isInside(targetDir, rel) {
|
|
53
|
+
const outward = path.relative(path.resolve(targetDir), path.resolve(targetDir, rel));
|
|
54
|
+
return outward !== '' && !outward.startsWith('..') && !path.isAbsolute(outward);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The repo's own workspace globs, from `pnpm-workspace.yaml` and/or
|
|
58
|
+
* package.json `workspaces` (both array and `{ packages: [] }` forms). Both
|
|
59
|
+
* are read because a pnpm repo can keep its globs in either file.
|
|
60
|
+
*
|
|
61
|
+
* Negations (`!apps/x`) are dropped, and so is anything absolute or containing
|
|
62
|
+
* a `..` segment: a `packages: - '../shared-lib'` entry reads as plausible in a
|
|
63
|
+
* monorepo but would write an out-of-repo symlink target into settings.json.
|
|
64
|
+
*/
|
|
65
|
+
async function workspaceGlobs(targetDir) {
|
|
66
|
+
const yamlFile = path.join(targetDir, 'pnpm-workspace.yaml');
|
|
67
|
+
const yaml = (await fs.pathExists(yamlFile)) ? await fs.readFile(yamlFile, 'utf-8') : '';
|
|
68
|
+
const pkg = asObject(await fs.readJson(path.join(targetDir, 'package.json')).catch(() => null));
|
|
69
|
+
const field = Array.isArray(pkg?.workspaces)
|
|
70
|
+
? pkg.workspaces
|
|
71
|
+
: asObject(pkg?.workspaces)?.packages;
|
|
72
|
+
const declared = Array.isArray(field) ? field : [];
|
|
73
|
+
return [...parseWorkspacePackages(yaml), ...declared].filter((g) => typeof g === 'string' &&
|
|
74
|
+
!g.startsWith('!') &&
|
|
75
|
+
!path.isAbsolute(g) &&
|
|
76
|
+
!g.split(/[\\/]/).includes('..'));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The nested `<workspace>/node_modules` to symlink alongside the root one
|
|
80
|
+
* (#406). Without them a workspace-scoped command in a fresh worktree fails on
|
|
81
|
+
* a missing binary — `pnpm --filter <pkg> build` can't find its bin shims —
|
|
82
|
+
* which reads as a broken toolchain rather than a missing symlink.
|
|
83
|
+
*
|
|
84
|
+
* Derived from the repo's own workspace globs, never a hardcoded layout: this
|
|
85
|
+
* is a public CLI and every consumer nests its packages differently.
|
|
86
|
+
* Workspaces with no `node_modules` in the main checkout are skipped, because
|
|
87
|
+
* an entry pointing at nothing warns on every worktree creation.
|
|
88
|
+
*/
|
|
89
|
+
export async function workspaceSymlinkDirs(targetDir) {
|
|
90
|
+
const globs = await workspaceGlobs(targetDir);
|
|
91
|
+
if (globs.length === 0)
|
|
92
|
+
return [];
|
|
93
|
+
const found = [];
|
|
94
|
+
for await (const match of glob(globs.map((g) => `${g}/node_modules`), { cwd: targetDir })) {
|
|
95
|
+
// Belt and braces: the glob result is what actually gets written, so it is
|
|
96
|
+
// re-checked for containment even though the input globs were filtered.
|
|
97
|
+
if (isInside(targetDir, match))
|
|
98
|
+
found.push(match.split(path.sep).join('/'));
|
|
99
|
+
}
|
|
100
|
+
return found.sort();
|
|
101
|
+
}
|
|
44
102
|
/** Parsed `.claude/settings.json`, or null when absent, malformed, or not an object. */
|
|
45
103
|
export async function readClaudeSettings(targetDir) {
|
|
46
104
|
try {
|
|
@@ -74,10 +132,8 @@ export async function installClaudeSettings(targetDir) {
|
|
|
74
132
|
if (!settings)
|
|
75
133
|
return null;
|
|
76
134
|
const existing = worktreeSymlinkDirs(settings);
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
...WORKTREE_SYMLINK_DIRS.filter((d) => !existing.includes(d)),
|
|
80
|
-
];
|
|
135
|
+
const wanted = [...WORKTREE_SYMLINK_DIRS, ...(await workspaceSymlinkDirs(targetDir))];
|
|
136
|
+
const symlinkDirectories = [...existing, ...wanted.filter((d) => !existing.includes(d))];
|
|
81
137
|
const worktree = { ...asObject(settings.worktree), symlinkDirectories };
|
|
82
138
|
await fs.outputJson(file, { ...settings, worktree }, { spaces: 2 });
|
|
83
139
|
return CLAUDE_SETTINGS_FILE;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-global agent skills (#404). Every other generator writes inside the repo;
|
|
3
|
+
* this one writes to `~/.claude/skills/<name>/SKILL.md`, which is shared by every
|
|
4
|
+
* project on the machine. That difference drives all three rules below — the
|
|
5
|
+
* version stamp, the symlink handling, and the fixer's opt-in `explicitOnly`.
|
|
6
|
+
*/
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import fs from 'fs-extra';
|
|
10
|
+
import { getPackageRoot } from '../utils/copy-preset.js';
|
|
11
|
+
/** Skills this package owns the content of and keeps up to date. */
|
|
12
|
+
export const SHIPPED_SKILL = 'ai-issue-loop';
|
|
13
|
+
/**
|
|
14
|
+
* Stamped into the installed copy's frontmatter so a second repo pinned to an
|
|
15
|
+
* older release can tell it would be a downgrade and skip. Without it two repos
|
|
16
|
+
* on different versions overwrite each other's skill on every `fix`, and neither
|
|
17
|
+
* is wrong to do so.
|
|
18
|
+
*/
|
|
19
|
+
export const VERSION_KEY = 'repo-tooling-version';
|
|
20
|
+
const FRONTMATTER = /^---\n([\s\S]*?)\n---\n/;
|
|
21
|
+
/**
|
|
22
|
+
* Where to install. `explicit` is `--skills-dir`; otherwise the user-level
|
|
23
|
+
* `~/.claude/skills` when it already exists. A machine with neither resolves to
|
|
24
|
+
* null rather than creating `~/.claude` uninvited.
|
|
25
|
+
*
|
|
26
|
+
* There is deliberately no separate "symlinked" case: a stow-managed
|
|
27
|
+
* `SKILL.md` symlink lives *inside* that same directory, and writing through it
|
|
28
|
+
* is what `installClaudeSkill` already does. See its note.
|
|
29
|
+
*/
|
|
30
|
+
export async function resolveSkillsDir(explicit, home = os.homedir()) {
|
|
31
|
+
if (explicit)
|
|
32
|
+
return { dir: path.resolve(explicit), source: 'explicit' };
|
|
33
|
+
const userDir = path.join(home, '.claude', 'skills');
|
|
34
|
+
if (await fs.pathExists(userDir))
|
|
35
|
+
return { dir: userDir, source: 'user' };
|
|
36
|
+
return { dir: null, source: 'none' };
|
|
37
|
+
}
|
|
38
|
+
/** The version recorded in an installed copy, or null if it predates the stamp. */
|
|
39
|
+
export function readSkillVersion(content) {
|
|
40
|
+
return content.match(new RegExp(`^${VERSION_KEY}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Replace (or add) the version line in the frontmatter. Appending it last is
|
|
44
|
+
* safe even after a multi-line `description: |` block: an unindented key ends
|
|
45
|
+
* the block scalar, which is exactly what this line is.
|
|
46
|
+
*/
|
|
47
|
+
export function stampSkillVersion(content, version) {
|
|
48
|
+
const stamp = `${VERSION_KEY}: ${version}`;
|
|
49
|
+
const match = content.match(FRONTMATTER);
|
|
50
|
+
if (!match)
|
|
51
|
+
return `---\n${stamp}\n---\n\n${content}`;
|
|
52
|
+
const fields = (match[1] ?? '')
|
|
53
|
+
.split('\n')
|
|
54
|
+
.filter((line) => !line.startsWith(`${VERSION_KEY}:`))
|
|
55
|
+
.join('\n');
|
|
56
|
+
return `---\n${fields}\n${stamp}\n---\n${content.slice(match[0].length)}`;
|
|
57
|
+
}
|
|
58
|
+
function versionParts(version) {
|
|
59
|
+
return version.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
60
|
+
}
|
|
61
|
+
/** True when `a` is a strictly higher version than `b`. Prerelease tags are ignored. */
|
|
62
|
+
export function isNewerVersion(a, b) {
|
|
63
|
+
const [left, right] = [versionParts(a), versionParts(b)];
|
|
64
|
+
for (let i = 0; i < 3; i++) {
|
|
65
|
+
const [x, y] = [left[i] ?? 0, right[i] ?? 0];
|
|
66
|
+
if (x !== y)
|
|
67
|
+
return x > y;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
/** The skill source and the package version that will be stamped into it. */
|
|
72
|
+
export async function readShippedSkill(name = SHIPPED_SKILL) {
|
|
73
|
+
const root = getPackageRoot();
|
|
74
|
+
const content = await fs.readFile(path.join(root, 'skills', name, 'SKILL.md'), 'utf8');
|
|
75
|
+
const pkg = await fs.readJson(path.join(root, 'package.json'));
|
|
76
|
+
return { content, version: String(pkg.version) };
|
|
77
|
+
}
|
|
78
|
+
/** Whether `file` is itself a symlink, as opposed to merely resolving through one. */
|
|
79
|
+
async function isSymlink(file) {
|
|
80
|
+
return await fs
|
|
81
|
+
.lstat(file)
|
|
82
|
+
.then((stat) => stat.isSymbolicLink())
|
|
83
|
+
.catch(() => false);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Install (or refresh) one shipped skill under `skillsDir`.
|
|
87
|
+
*
|
|
88
|
+
* **Writes through a symlink on purpose.** stow symlinks dotfiles at *file*
|
|
89
|
+
* level, so `~/.claude/skills/ai-issue-loop/SKILL.md` is routinely a link into a
|
|
90
|
+
* dotfiles checkout while its parent directories are real. `fs.writeFile`
|
|
91
|
+
* follows the link and updates the dotfiles copy in place, which is the whole
|
|
92
|
+
* point — the skill stays version-controlled with the rest of the Claude config.
|
|
93
|
+
* Never swap this for an atomic-rename helper (`write-file-atomic` and friends):
|
|
94
|
+
* rename *replaces* the symlink with a real file, orphaning the dotfiles copy
|
|
95
|
+
* with no error at all, which is the split-brain this feature exists to end.
|
|
96
|
+
*/
|
|
97
|
+
export async function installClaudeSkill(skillsDir, name = SHIPPED_SKILL) {
|
|
98
|
+
const shipped = await readShippedSkill(name);
|
|
99
|
+
const file = path.join(skillsDir, name, 'SKILL.md');
|
|
100
|
+
const existing = (await fs.pathExists(file)) ? await fs.readFile(file, 'utf8') : null;
|
|
101
|
+
const installedVersion = existing ? readSkillVersion(existing) : null;
|
|
102
|
+
const viaSymlink = await isSymlink(file);
|
|
103
|
+
const base = {
|
|
104
|
+
name,
|
|
105
|
+
file,
|
|
106
|
+
viaSymlink,
|
|
107
|
+
realFile: viaSymlink ? await fs.realpath(file) : file,
|
|
108
|
+
installedVersion,
|
|
109
|
+
shippedVersion: shipped.version,
|
|
110
|
+
};
|
|
111
|
+
if (installedVersion && isNewerVersion(installedVersion, shipped.version)) {
|
|
112
|
+
return { ...base, status: 'declined-downgrade' };
|
|
113
|
+
}
|
|
114
|
+
const next = stampSkillVersion(shipped.content, shipped.version);
|
|
115
|
+
if (existing === next)
|
|
116
|
+
return { ...base, status: 'up-to-date' };
|
|
117
|
+
await fs.ensureDir(path.dirname(file));
|
|
118
|
+
await fs.writeFile(file, next);
|
|
119
|
+
return { ...base, status: existing === null ? 'installed' : 'updated' };
|
|
120
|
+
}
|
|
121
|
+
/** Read-only counterpart of `installClaudeSkill`, for doctor. */
|
|
122
|
+
export async function claudeSkillStatus(name = SHIPPED_SKILL, explicit) {
|
|
123
|
+
const { version } = await readShippedSkill(name);
|
|
124
|
+
const { dir } = await resolveSkillsDir(explicit);
|
|
125
|
+
const absent = {
|
|
126
|
+
installed: false,
|
|
127
|
+
installedVersion: null,
|
|
128
|
+
shippedVersion: version,
|
|
129
|
+
needsInstall: true,
|
|
130
|
+
};
|
|
131
|
+
if (!dir)
|
|
132
|
+
return { file: null, ...absent };
|
|
133
|
+
const file = path.join(dir, name, 'SKILL.md');
|
|
134
|
+
if (!(await fs.pathExists(file)))
|
|
135
|
+
return { file, ...absent };
|
|
136
|
+
const installedVersion = readSkillVersion(await fs.readFile(file, 'utf8'));
|
|
137
|
+
return {
|
|
138
|
+
file,
|
|
139
|
+
installed: true,
|
|
140
|
+
installedVersion,
|
|
141
|
+
shippedVersion: version,
|
|
142
|
+
needsInstall: installedVersion === null || isNewerVersion(version, installedVersion),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -256,7 +256,7 @@ export async function ensurePackageManager(targetDir, version = detectPnpmVersio
|
|
|
256
256
|
* YAML dependency. Matches `- 'apps/*'` / `- "apps/*"` / `- apps/*` entries
|
|
257
257
|
* under the `packages:` key, stopping at the next top-level key.
|
|
258
258
|
*/
|
|
259
|
-
function parseWorkspacePackages(yaml) {
|
|
259
|
+
export function parseWorkspacePackages(yaml) {
|
|
260
260
|
const globs = [];
|
|
261
261
|
let inPackages = false;
|
|
262
262
|
for (const line of yaml.split('\n')) {
|
package/dist/cli/index.js
CHANGED
|
@@ -287,6 +287,12 @@ const TOOL_CATALOG = [
|
|
|
287
287
|
exports: [],
|
|
288
288
|
fixTarget: 'ai',
|
|
289
289
|
},
|
|
290
|
+
{
|
|
291
|
+
name: 'Claude skills',
|
|
292
|
+
description: 'Install the ai-issue-loop skill into ~/.claude/skills (user-global, opt-in — see --skills-dir)',
|
|
293
|
+
exports: [],
|
|
294
|
+
fixTarget: 'claude-skills',
|
|
295
|
+
},
|
|
290
296
|
];
|
|
291
297
|
program
|
|
292
298
|
.command('list')
|
|
@@ -321,6 +327,7 @@ program
|
|
|
321
327
|
.option('--list', 'List all registered fix targets and exit')
|
|
322
328
|
.option('--resync', 'Re-scaffold every file recorded in .repo-tooling.json')
|
|
323
329
|
.option('--diff', 'Show a unified diff of each change before confirming')
|
|
330
|
+
.option('--skills-dir <path>', 'Where `fix claude-skills` installs user-global agent skills (default: ~/.claude/skills). Required with --yes/--json when that directory does not exist')
|
|
324
331
|
.action((target, options) => fixCommand(target, {
|
|
325
332
|
directory: options.directory,
|
|
326
333
|
yes: options.yes,
|
|
@@ -329,6 +336,7 @@ program
|
|
|
329
336
|
list: options.list,
|
|
330
337
|
resync: options.resync,
|
|
331
338
|
diff: options.diff,
|
|
339
|
+
skillsDir: options.skillsDir,
|
|
332
340
|
}));
|
|
333
341
|
program.hook('preAction', async (_, actionCommand) => {
|
|
334
342
|
const name = actionCommand.name();
|
|
@@ -1,7 +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
|
+
import { CLAUDE_SETTINGS_FILE, readClaudeSettings, workspaceSymlinkDirs, worktreeSymlinkDirs, } from '../../cli/generators/agent-rules.js';
|
|
5
5
|
import { WORKSPACE_FILE, dependsOnEsbuild, familyGlob, missingPnpmSettings, } from '../../cli/generators/pnpm-workspace.js';
|
|
6
6
|
const PACKAGE = '@rtorcato/repo-tooling';
|
|
7
7
|
const NODE_MIN_MAJOR = 22;
|
|
@@ -980,13 +980,20 @@ export async function checkClaudeWorktreeSettings(dir) {
|
|
|
980
980
|
hint: `Repair the JSON in ${CLAUDE_SETTINGS_FILE} by hand — \`fix ai\` refuses to overwrite it`,
|
|
981
981
|
};
|
|
982
982
|
}
|
|
983
|
-
|
|
984
|
-
|
|
983
|
+
const recorded = worktreeSymlinkDirs(settings);
|
|
984
|
+
const wanted = ['node_modules', ...(await workspaceSymlinkDirs(dir))];
|
|
985
|
+
const missing = wanted.filter((d) => !recorded.includes(d));
|
|
986
|
+
if (missing.length === 0) {
|
|
987
|
+
return {
|
|
988
|
+
check,
|
|
989
|
+
status: 'ok',
|
|
990
|
+
detail: `worktree.symlinkDirectories carries ${wanted.join(', ')}`,
|
|
991
|
+
};
|
|
985
992
|
}
|
|
986
993
|
return {
|
|
987
994
|
check,
|
|
988
995
|
status: 'optional-missing',
|
|
989
|
-
detail: `${CLAUDE_SETTINGS_FILE} has no worktree.symlinkDirectories entry for
|
|
996
|
+
detail: `${CLAUDE_SETTINGS_FILE} has no worktree.symlinkDirectories entry for ${missing.join(', ')}`,
|
|
990
997
|
hint,
|
|
991
998
|
};
|
|
992
999
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rtorcato/repo-tooling",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.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": [
|
|
@@ -108,6 +108,7 @@
|
|
|
108
108
|
"tooling/perl/perlcriticrc",
|
|
109
109
|
"tooling/perl/perltidyrc",
|
|
110
110
|
"tooling/github-actions/workflows/*.yml",
|
|
111
|
+
"skills/*/SKILL.md",
|
|
111
112
|
"README.md",
|
|
112
113
|
"AGENTS.md"
|
|
113
114
|
],
|