@rtorcato/repo-tooling 3.38.3 → 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,20 @@ 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).
100
107
 
101
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`.
102
109
 
@@ -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))
@@ -518,7 +525,7 @@ export async function fixCommand(target, options = {}) {
518
525
  const previews = await previewFixer(fixer, result, targetDir, pkg, lock);
519
526
  printPreviews(previews);
520
527
  }
521
- const ok = await confirmApply(fixer, result, assumeYes);
528
+ const ok = await confirmApply(fixer, result, assumeYes, dryRun);
522
529
  if (!ok) {
523
530
  actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', [], conflict));
524
531
  if (!silent)
@@ -536,6 +543,7 @@ export async function fixCommand(target, options = {}) {
536
543
  outcome = await applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent, {
537
544
  skillsDir: options.skillsDir,
538
545
  forceSkills: options.forceSkills,
546
+ ghConfigDir: options.ghConfigDir,
539
547
  assumeYes,
540
548
  });
541
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.3",
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": [