@rtorcato/repo-tooling 3.38.2 → 3.39.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
@@ -90,13 +90,22 @@ npx @rtorcato/repo-tooling fix bun --yes --json # Bun runtime/test confi
90
90
  # Installs the ai-issue-loop skill to ~/.claude/skills. Override with --skills-dir,
91
91
  # which is required alongside --yes/--json when that directory doesn't exist.
92
92
  npx @rtorcato/repo-tooling fix claude-skills --yes --json
93
+
94
+ # Opt-in only. Points this checkout's Claude sessions at a gh profile signed in as
95
+ # rules.aiLoop.agentUser (~/.config/gh-<agentUser>, or --gh-config-dir) by merging
96
+ # env.GH_CONFIG_DIR into the gitignored .claude/settings.local.json. Every session
97
+ # in the checkout then runs as the agent, hands-on ones included. Writes nothing
98
+ # and prints the `gh auth login` command if the profile isn't that account.
99
+ npx @rtorcato/repo-tooling fix ai-loop-identity --yes --json
93
100
  ```
94
101
 
95
102
  ## Drift policy (important)
96
103
 
97
104
  `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 (`biome`, `engines`, `husky`, `package-json`) never overwrite — they add/merge — and use friendlier prompt wording. `fix --json` implies `--yes` (prompts would corrupt JSON output).
98
105
 
99
- 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.
106
+ 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` (writes outside the repo), `release-environment` (changes what a merge does), and `ai-loop-identity` (changes which account every session in the checkout acts as).
107
+
108
+ The same goes for every `optional-missing` finding: a bulk `fix` records it `skipped`, because optional tools are often mutually exclusive (Biome / ESLint / Prettier / Oxlint, semantic-release / Changesets / Release Please) and installing them all is never the intent (#630). Name the one you want — `fix editorconfig --yes`.
100
109
 
101
110
  A fixer may also **refuse** — the target file holds something the generator cannot reproduce, so overwriting would destroy it. Today that is `dependabot` against a config with repo-local `ignore:` rules (#422), and `biome` against a `biome.json` that does not parse, whose settings cannot be merged (#587). A targeted `fix dependabot` then exits 1 with `error: dependabot-ignore-rules`; a bulk `fix --yes` records it `skipped` and carries on with the rest. Neither `--yes` nor `--json` overrides it — resolve the named rules by hand and re-run.
102
111
 
@@ -18,7 +18,7 @@ const CHECK = 'AI loop agent';
18
18
  * or trailing hyphen. The injection boundary — the login is interpolated into
19
19
  * the API path below.
20
20
  */
21
- const LOGIN = /^[a-zA-Z0-9](?:-?[a-zA-Z0-9]){0,38}$/;
21
+ export const LOGIN = /^[a-zA-Z0-9](?:-?[a-zA-Z0-9]){0,38}$/;
22
22
  const skip = (reason) => ({
23
23
  check: CHECK,
24
24
  status: 'ok',
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `fix ai-loop-identity` (#638): point this checkout's Claude sessions at a gh
3
+ * profile signed in as `rules.aiLoop.agentUser`, so `loop guard` stops halting
4
+ * on `identity: mismatch`. `.repo-tooling.json` (committed) says *who* the agent
5
+ * is; the profile path is machine-specific, so it lands in the gitignored,
6
+ * per-checkout `.claude/settings.local.json`.
7
+ */
8
+ import path from 'node:path';
9
+ import fs from 'fs-extra';
10
+ import { configuredAgentUser } from '../cli/commands/loop-guard.js';
11
+ import { LOGIN } from './agent-user.js';
12
+ import { FixerAbort } from './fixers.js';
13
+ import { realGhExec } from './github-settings.js';
14
+ export const SETTINGS_LOCAL = '.claude/settings.local.json';
15
+ /** The profile dir the fixer looks in when `--gh-config-dir` is not given. */
16
+ export function defaultGhConfigDir(agentUser, home) {
17
+ return path.join(home, '.config', `gh-${agentUser}`);
18
+ }
19
+ /**
20
+ * Returns the files written. Writes nothing — and aborts with the `gh auth
21
+ * login` command as the hint — unless the profile really is the agent account.
22
+ */
23
+ export async function setupAgentIdentity(targetDir, opts) {
24
+ const agentUser = await configuredAgentUser(targetDir);
25
+ if (!agentUser) {
26
+ console.error(' nothing to do — no rules.aiLoop.agentUser in .repo-tooling.json');
27
+ return [];
28
+ }
29
+ // The login comes from a committed file and becomes a path segment below.
30
+ if (!LOGIN.test(agentUser)) {
31
+ throw new FixerAbort('invalid-agent-user', `rules.aiLoop.agentUser "${agentUser}" is not a valid GitHub login`, 'Fix or remove rules.aiLoop.agentUser in .repo-tooling.json');
32
+ }
33
+ const dir = path.resolve(opts.ghConfigDir ?? defaultGhConfigDir(agentUser, opts.home));
34
+ const gh = opts.gh ?? ((args, env) => realGhExec(args, undefined, targetDir, env));
35
+ const login = (await fs.pathExists(dir))
36
+ ? await gh(['api', 'user', '--jq', '.login'], { GH_CONFIG_DIR: dir }).then((r) => r.ok ? r.stdout.trim() : '')
37
+ : '';
38
+ // GitHub logins are case-insensitive, so a case difference is one account.
39
+ if (login.toLowerCase() !== agentUser.toLowerCase()) {
40
+ throw new FixerAbort('agent-identity-unavailable', `${dir} is ${login ? `signed in as ${login}, not ${agentUser}` : `not signed in to gh as ${agentUser}`} — nothing written`, `Sign in once, in a browser logged in as ${agentUser}, then re-run:\n GH_CONFIG_DIR=${dir} gh auth login --hostname github.com --web --scopes repo`);
41
+ }
42
+ const written = [];
43
+ const settingsPath = path.join(targetDir, SETTINGS_LOCAL);
44
+ let settings = {};
45
+ if (await fs.pathExists(settingsPath)) {
46
+ try {
47
+ settings = await fs.readJson(settingsPath);
48
+ }
49
+ catch {
50
+ throw new FixerAbort('settings-local-unparseable', `${SETTINGS_LOCAL} is not valid JSON — not overwriting it`, `Fix the file by hand, or add "env": {"GH_CONFIG_DIR": "${dir}"} to it yourself`);
51
+ }
52
+ }
53
+ const env = (settings.env ?? {});
54
+ if (env.GH_CONFIG_DIR !== dir) {
55
+ await fs.outputJson(settingsPath, { ...settings, env: { ...env, GH_CONFIG_DIR: dir } }, { spaces: 2 });
56
+ written.push(SETTINGS_LOCAL);
57
+ }
58
+ if (await ensureIgnored(targetDir))
59
+ written.push('.gitignore');
60
+ return written;
61
+ }
62
+ // ponytail: exact-line match on the usual spellings; a glob that happens to
63
+ // cover the file gets a redundant (harmless) line. `git check-ignore` if that bites.
64
+ const IGNORE_LINES = new Set([
65
+ SETTINGS_LOCAL,
66
+ `/${SETTINGS_LOCAL}`,
67
+ '.claude',
68
+ '.claude/',
69
+ '/.claude',
70
+ '/.claude/',
71
+ ]);
72
+ async function ensureIgnored(targetDir) {
73
+ const file = path.join(targetDir, '.gitignore');
74
+ const existing = (await fs.pathExists(file)) ? await fs.readFile(file, 'utf-8') : '';
75
+ if (existing.split('\n').some((l) => IGNORE_LINES.has(l.trim())))
76
+ return false;
77
+ const separator = existing === '' || existing.endsWith('\n') ? '' : '\n';
78
+ await fs.writeFile(file, `${existing}${separator}${SETTINGS_LOCAL}\n`);
79
+ return true;
80
+ }
@@ -27,6 +27,7 @@ import { classifyCopiedAssets } from '../cli/utils/copied-assets.js';
27
27
  import { copyPreset } from '../cli/utils/copy-preset.js';
28
28
  import { detectAuditLanguage } from '../cli/utils/detect-language.js';
29
29
  import { resolveLanguageModule } from '../languages/registry.js';
30
+ import { SETTINGS_LOCAL, setupAgentIdentity } from './ai-loop-identity.js';
30
31
  import { applyGithubSettings, applyReleaseEnvironment, RELEASE_ENV_CHECK, RELEASE_GATE_CHECK, } from './github-settings.js';
31
32
  import { applyLoopLabels } from './labels.js';
32
33
  import { closeCompletedMilestones } from './milestones.js';
@@ -408,6 +409,22 @@ export const BASE_FIXERS = [
408
409
  return { filesWritten };
409
410
  },
410
411
  },
412
+ {
413
+ target: 'ai-loop-identity',
414
+ description: "Point this checkout's Claude sessions at a gh profile signed in as rules.aiLoop.agentUser (~/.config/gh-<agentUser>, or --gh-config-dir) via .claude/settings.local.json. Every session in the checkout then runs as the agent",
415
+ appliesTo: ['AI loop identity'],
416
+ outputs: [SETTINGS_LOCAL, '.gitignore'],
417
+ // safe-add keeps it out of the `--diff` shadow-run, which would spawn gh;
418
+ // explicitOnly because it changes who every session here acts as.
419
+ riskLevel: 'safe-add',
420
+ explicitOnly: true,
421
+ canFixDrift: true,
422
+ async run({ targetDir, ghConfigDir }) {
423
+ return {
424
+ filesWritten: await setupAgentIdentity(targetDir, { ghConfigDir, home: os.homedir() }),
425
+ };
426
+ },
427
+ },
411
428
  {
412
429
  target: 'cursor-rules',
413
430
  description: 'Install the repo-tooling rules for Cursor (.cursor/rules/repo-tooling.mdc)',
@@ -11,7 +11,7 @@ const GH_TIMEOUT_MS = 10_000;
11
11
  * `: GhExec` so the optional cwd stays callable; still assignable where GhExec
12
12
  * is expected.
13
13
  */
14
- export const realGhExec = (args, stdin, cwd) => new Promise((resolve) => {
14
+ export const realGhExec = (args, stdin, cwd, env) => new Promise((resolve) => {
15
15
  let settled = false;
16
16
  const done = (r) => {
17
17
  if (settled)
@@ -24,6 +24,7 @@ export const realGhExec = (args, stdin, cwd) => new Promise((resolve) => {
24
24
  // shell:false + an args array keeps this injection-safe.
25
25
  const child = spawn('gh', args, {
26
26
  cwd,
27
+ env: env && { ...process.env, ...env },
27
28
  stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
28
29
  });
29
30
  let stdout = '';
@@ -275,9 +275,13 @@ function promptMessageFor(fixer, result) {
275
275
  }
276
276
  return { message: `Apply ${fixer.description}?`, default: true };
277
277
  }
278
- async function confirmApply(fixer, result, assumeYes) {
279
- if (assumeYes)
278
+ async function confirmApply(fixer, result, assumeYes, dryRun) {
279
+ // A dry run writes nothing, so there is nothing to confirm (#637).
280
+ if (assumeYes || dryRun)
280
281
  return true;
282
+ // No TTY means no one can answer: inquirer would throw ExitPromptError. Treat it as "no".
283
+ if (!process.stdin.isTTY)
284
+ return false;
281
285
  const { message, default: defaultValue } = promptMessageFor(fixer, result);
282
286
  const { confirm } = await inquirer.prompt([
283
287
  { type: 'confirm', name: 'confirm', message, default: defaultValue },
@@ -345,14 +349,16 @@ export async function fixCommand(target, options = {}) {
345
349
  return;
346
350
  }
347
351
  if (!assumeYes) {
348
- const { confirm } = await inquirer.prompt([
349
- {
350
- type: 'confirm',
351
- name: 'confirm',
352
- message: `Re-scaffold ${files.length} file(s) from ${LOCKFILE_NAME}? Generators preserve existing customizations where possible, but README.md will be rewritten.`,
353
- default: false,
354
- },
355
- ]);
352
+ const { confirm } = !process.stdin.isTTY
353
+ ? { confirm: false }
354
+ : await inquirer.prompt([
355
+ {
356
+ type: 'confirm',
357
+ name: 'confirm',
358
+ message: `Re-scaffold ${files.length} file(s) from ${LOCKFILE_NAME}? Generators preserve existing customizations where possible, but README.md will be rewritten.`,
359
+ default: false,
360
+ },
361
+ ]);
356
362
  if (!confirm) {
357
363
  console.log(chalk.gray(' skipped\n'));
358
364
  return;
@@ -440,7 +446,7 @@ export async function fixCommand(target, options = {}) {
440
446
  const previews = await previewFixer(fixer, effectiveResult, targetDir, pkg, lock);
441
447
  printPreviews(previews);
442
448
  }
443
- const ok = await confirmApply(fixer, effectiveResult, assumeYes);
449
+ const ok = await confirmApply(fixer, effectiveResult, assumeYes, dryRun);
444
450
  if (!ok) {
445
451
  actions.push(recordFor(fixer.target, result.check, effectiveResult.status, 'skipped', [], conflict));
446
452
  if (json)
@@ -454,6 +460,7 @@ export async function fixCommand(target, options = {}) {
454
460
  const outcome = await applyFixer(fixer, effectiveResult, targetDir, pkg, lock, dryRun, silent, {
455
461
  skillsDir: options.skillsDir,
456
462
  forceSkills: options.forceSkills,
463
+ ghConfigDir: options.ghConfigDir,
457
464
  assumeYes,
458
465
  }).catch((err) => {
459
466
  if (!(err instanceof FixerAbort))
@@ -502,8 +509,10 @@ export async function fixCommand(target, options = {}) {
502
509
  if (!silent) {
503
510
  console.log(` ${chalk.bold(result.check)} (${result.status}) → ${fixer.target}`);
504
511
  }
505
- // Opt-in only — `--yes` must not sweep in a fixer that writes outside the repo.
506
- if (fixer.explicitOnly) {
512
+ // Opt-in only — `--yes` must not sweep in a fixer that writes outside the repo,
513
+ // nor an optional tool: optional alternatives (Biome/ESLint/Prettier/Oxlint,
514
+ // semantic-release/Changesets/Release Please) are mutually exclusive (#630).
515
+ if (fixer.explicitOnly || result.status === 'optional-missing') {
507
516
  actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', []));
508
517
  if (!silent) {
509
518
  console.log(chalk.gray(` skipped — run \`fix ${fixer.target}\` explicitly`));
@@ -516,7 +525,7 @@ export async function fixCommand(target, options = {}) {
516
525
  const previews = await previewFixer(fixer, result, targetDir, pkg, lock);
517
526
  printPreviews(previews);
518
527
  }
519
- const ok = await confirmApply(fixer, result, assumeYes);
528
+ const ok = await confirmApply(fixer, result, assumeYes, dryRun);
520
529
  if (!ok) {
521
530
  actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', [], conflict));
522
531
  if (!silent)
@@ -534,6 +543,7 @@ export async function fixCommand(target, options = {}) {
534
543
  outcome = await applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent, {
535
544
  skillsDir: options.skillsDir,
536
545
  forceSkills: options.forceSkills,
546
+ ghConfigDir: options.ghConfigDir,
537
547
  assumeYes,
538
548
  });
539
549
  }
@@ -71,7 +71,7 @@ export async function checkAgentIdentity(configured, gh) {
71
71
  }
72
72
  return {
73
73
  verdict: 'mismatch',
74
- message: `⚠ agentUser is ${configured} but gh authenticates as ${effective || '(gh could not say — unauthenticated or missing)'} — the tick would commit, push and review as the wrong account`,
74
+ message: `⚠ agentUser is ${configured} but gh authenticates as ${effective || '(gh could not say — unauthenticated or missing)'} — the tick would commit, push and review as the wrong account. Run \`npx @rtorcato/repo-tooling fix ai-loop-identity\` in this checkout, then relaunch the Claude session`,
75
75
  };
76
76
  }
77
77
  /**
@@ -74,9 +74,9 @@ async function ensureHookRuns(hookPath, runs, fresh, line = fresh) {
74
74
  }
75
75
  else {
76
76
  const existing = await fs.readFile(hookPath, 'utf-8');
77
- if (hookHasUncommented(existing, runs))
78
- return;
79
- await fs.writeFile(hookPath, `${existing.replace(/\n*$/, '\n')}${line}`);
77
+ if (!hookHasUncommented(existing, runs)) {
78
+ await fs.writeFile(hookPath, `${existing.replace(/\n*$/, '\n')}${line}`);
79
+ }
80
80
  }
81
81
  await fs.chmod(hookPath, 0o755);
82
82
  }
package/dist/cli/index.js CHANGED
@@ -341,6 +341,7 @@ program
341
341
  // Deliberately not folded into --yes: unattended runs pass --yes, and this is
342
342
  // the one overwrite that destroys work living outside the repo (#480).
343
343
  .option('--force-skills', 'Let `fix claude-skills` overwrite a locally modified skill instead of refusing')
344
+ .option('--gh-config-dir <path>', 'gh profile signed in as rules.aiLoop.agentUser, for `fix ai-loop-identity` (default: ~/.config/gh-<agentUser>)')
344
345
  .action((target, options) => fixCommand(target, {
345
346
  directory: options.directory,
346
347
  yes: options.yes,
@@ -351,6 +352,7 @@ program
351
352
  diff: options.diff,
352
353
  skillsDir: options.skillsDir,
353
354
  forceSkills: options.forceSkills,
355
+ ghConfigDir: options.ghConfigDir,
354
356
  }));
355
357
  // Mechanics the ai-issue-loop skill used to describe in prose-with-shell (#519).
356
358
  // Deliberately outside the isSelfRepo hook below: the loop runs against this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.38.2",
3
+ "version": "3.39.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": [