@rtorcato/repo-tooling 3.9.1 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 two skills — `repo-tooling` (adopt/audit the presets via the CLI) and
178
- `npm-publish` (the family's release rules) — in any session:
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 repo-tooling
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 -->
@@ -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
@@ -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;
@@ -46,6 +46,7 @@ export const FIX_TARGETS = {
46
46
  TypeDoc: 'typedoc',
47
47
  'AI setup': 'ai',
48
48
  'Claude worktree settings': 'ai',
49
+ 'Claude skills': 'claude-skills',
49
50
  };
50
51
  /**
51
52
  * Where the Swift module's fixers shadow (or extend) the JS-named defaults
@@ -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
- await fixer.run({ targetDir: tmpDir, pkg, result, lock });
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
  }
@@ -20,8 +20,13 @@ const COUNTER_GLOW = '#6e7bff';
20
20
  const INK = '#0A0E16';
21
21
  const TEXT = '#e6edf3';
22
22
  const MUTED = '#9ba6b8';
23
+ /** `"` matters because this output also lands in double-quoted attributes (the `aria-label` below). */
23
24
  function esc(s) {
24
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
25
+ return s
26
+ .replace(/&/g, '&amp;')
27
+ .replace(/</g, '&lt;')
28
+ .replace(/>/g, '&gt;')
29
+ .replace(/"/g, '&quot;');
25
30
  }
26
31
  /**
27
32
  * Greedy word wrap to a character budget. Character-budgeted rather than
@@ -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
+ }
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.9.1",
3
+ "version": "3.10.0",
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
  ],