@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 +8 -1
- package/dist/base/agent-user.js +1 -1
- package/dist/base/ai-loop-identity.js +80 -0
- package/dist/base/fixers.js +17 -0
- package/dist/base/github-settings.js +2 -1
- package/dist/cli/commands/fix.js +20 -12
- package/dist/cli/commands/loop-guard.js +1 -1
- package/dist/cli/generators/git.js +3 -3
- package/dist/cli/index.js +2 -0
- package/package.json +1 -1
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
|
|
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
|
|
package/dist/base/agent-user.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/base/fixers.js
CHANGED
|
@@ -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 = '';
|
package/dist/cli/commands/fix.js
CHANGED
|
@@ -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
|
-
|
|
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 } =
|
|
349
|
-
{
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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