@rtorcato/repo-tooling 3.39.0 → 3.41.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
@@ -27,6 +27,7 @@ Every command supports `--json` and a non-interactive mode. Combine with `--yes`
27
27
  | `list --json` | ✅ | ✅ | Enumerate the library's surface area. Each entry has `{ name, description, exports, fixTarget }`. |
28
28
  | `copy <name>` | ✅ | text only | Copy a single preset (`biome`, `tsconfig`) into the current directory. |
29
29
  | `loop guard --root <path>` | ✅ | ✅ | Guard an `ai-issue-loop` tick: repair a wrongly-bare main checkout, gate the `node_modules` rebuild (`--removed`), and assert `gh` authenticates as the declared `rules.aiLoop.agentUser`. Exit `0` continue, `1` repair failed, `2` root is not a repairable checkout or the agent identity is wrong — both non-zero halt the tick. |
30
+ | `loop env` | ✅ | ✅ | Resolve an `ai-issue-loop` tick's variables once: `root` (main checkout, correct from inside a worktree), `worktreeRoot`, `ownerRepo`, `agentUser` (empty unless an assignable collaborator), `humanUser` (empty for org repos), `me`. Without `--json`, prints `KEY='value'` lines for `eval`. Exit `1` when the checkout or its GitHub repo cannot be resolved. |
30
31
 
31
32
  ## Recommended workflows
32
33
 
@@ -117,6 +118,7 @@ A fixer may also **refuse** — the target file holds something the generator ca
117
118
  - `src/cli/commands/fix.ts` — `Fixer` interface, fixer registry, `fixCommand`
118
119
  - `src/cli/commands/fix-targets.ts` — shared check → fix target map (used by both doctor's footer and fix's lookup)
119
120
  - `src/cli/commands/loop-guard.ts` — `loop guard`: the `--is-inside-work-tree` / `.git` invariant table and the `node_modules` rebuild gate, drained out of the ai-issue-loop skill's prose (#519)
121
+ - `src/cli/commands/loop-env.ts` — `loop env`: the skill's Pass 0 variables resolved in one call (#615)
120
122
  - `src/cli/generators/` — one file per concern (linting, testing, build, git, github-actions, security, misc)
121
123
  - `tooling/` — every shipped preset, mirrored 1:1 with `package.json` `exports`
122
124
 
@@ -92,7 +92,7 @@ function repoScopedEnv() {
92
92
  return env;
93
93
  }
94
94
  /** Never rejects; a missing or failing git resolves to null. */
95
- export const realGitExec = (args, cwd) => new Promise((resolve) => {
95
+ export const realGitExec = (args, cwd, timeoutMs = GIT_TIMEOUT_MS) => new Promise((resolve) => {
96
96
  let settled = false;
97
97
  const done = (v) => {
98
98
  if (settled)
@@ -112,7 +112,7 @@ export const realGitExec = (args, cwd) => new Promise((resolve) => {
112
112
  const timer = setTimeout(() => {
113
113
  child.kill();
114
114
  done(null);
115
- }, GIT_TIMEOUT_MS);
115
+ }, timeoutMs);
116
116
  child.stdout?.on('data', (d) => {
117
117
  stdout += d;
118
118
  });
@@ -0,0 +1,100 @@
1
+ import path from 'node:path';
2
+ import chalk from 'chalk';
3
+ import { realGitExec } from '../../base/git-identity.js';
4
+ import { realGhExec } from '../../base/github-settings.js';
5
+ import { defaultWorktreeRoot, findLive } from './loop-guard.js';
6
+ /** How far back to look for the squash subject. */
7
+ const SQUASH_WINDOW = 200;
8
+ async function findPr(gh, heads) {
9
+ for (const head of heads) {
10
+ const r = await gh(['pr', 'list', '--head', head, '--state', 'all', '--json', 'number,state']);
11
+ if (!r.ok)
12
+ continue;
13
+ const [pr] = JSON.parse(r.stdout || '[]');
14
+ if (pr)
15
+ return pr;
16
+ }
17
+ return null;
18
+ }
19
+ export async function runLoopCleanup(options = {}) {
20
+ const root = path.resolve(options.root ?? process.cwd());
21
+ const worktreeRoot = options.worktreeRoot
22
+ ? path.resolve(options.worktreeRoot)
23
+ : defaultWorktreeRoot(root);
24
+ // Fetch and `worktree remove` outlast the 5s probe timeout on a real repo.
25
+ const git = options.git ?? ((args) => realGitExec(args, root, 120_000));
26
+ const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, root));
27
+ const dirs = await findLive([worktreeRoot, path.join(root, '.claude', 'worktrees')]);
28
+ let subjects = null;
29
+ const worktrees = [];
30
+ for (const dir of dirs) {
31
+ const slug = path.basename(dir);
32
+ const issue = Number(slug.match(/^ai-(\d+)-/)?.[1]) || null;
33
+ // `worktree-` is the prefix EnterWorktree once gave branches — legacy.
34
+ const heads = [slug, `worktree-${slug}`];
35
+ const branch = (await git(['branch', '--list', ...heads, '--format=%(refname:short)']))
36
+ ?.split('\n')[0]
37
+ ?.trim() || null;
38
+ const pr = await findPr(gh, heads);
39
+ const entry = {
40
+ path: dir,
41
+ issue,
42
+ branch,
43
+ pr: pr?.number ?? null,
44
+ prState: pr?.state ?? null,
45
+ action: 'kept',
46
+ reason: '',
47
+ };
48
+ worktrees.push(entry);
49
+ if (!pr || pr.state === 'OPEN') {
50
+ entry.reason = pr ? 'PR still open' : 'no PR';
51
+ continue;
52
+ }
53
+ if (pr.state === 'MERGED') {
54
+ if (subjects === null) {
55
+ // Best-effort: a failed fetch leaves origin/main stale, which can
56
+ // only keep a worktree, never remove one wrongly.
57
+ await git(['fetch', '--prune', 'origin']);
58
+ const log = await git(['log', 'origin/main', `-n${SQUASH_WINDOW}`, '--format=%s']);
59
+ subjects = log ? log.split('\n') : [];
60
+ }
61
+ if (!subjects.some((s) => s.trimEnd().endsWith(`(#${pr.number})`))) {
62
+ entry.reason = `squash for #${pr.number} not on origin/main`;
63
+ continue;
64
+ }
65
+ }
66
+ if ((await git(['worktree', 'remove', '--force', dir])) === null) {
67
+ entry.action = 'remove-failed';
68
+ entry.reason = 'git worktree remove failed';
69
+ continue;
70
+ }
71
+ if (branch)
72
+ await git(['branch', '-D', branch]);
73
+ entry.action = 'removed';
74
+ entry.reason = pr.state === 'MERGED' ? `#${pr.number} landed on main` : `#${pr.number} closed`;
75
+ }
76
+ return {
77
+ root,
78
+ worktreeRoot,
79
+ removed: worktrees.some((w) => w.action === 'removed'),
80
+ worktrees,
81
+ exitCode: worktrees.some((w) => w.action === 'remove-failed') ? 1 : 0,
82
+ };
83
+ }
84
+ export async function loopCleanupCommand(options) {
85
+ const result = await runLoopCleanup(options);
86
+ if (options.json) {
87
+ console.log(JSON.stringify(result, null, 2));
88
+ }
89
+ else {
90
+ console.log();
91
+ if (result.worktrees.length === 0)
92
+ console.log(chalk.gray(' no ai-* worktrees'));
93
+ for (const w of result.worktrees) {
94
+ const line = `${w.action.padEnd(13)} ${path.basename(w.path)} — ${w.reason}`;
95
+ console.log(` ${w.action === 'remove-failed' ? chalk.yellow(line) : chalk.gray(line)}`);
96
+ }
97
+ console.log();
98
+ }
99
+ process.exitCode = result.exitCode;
100
+ }
@@ -0,0 +1,76 @@
1
+ import path from 'node:path';
2
+ import { LOGIN } from '../../base/agent-user.js';
3
+ import { realGitExec } from '../../base/git-identity.js';
4
+ import { realGhExec } from '../../base/github-settings.js';
5
+ import { configuredAgentUser, defaultWorktreeRoot } from './loop-guard.js';
6
+ async function ghOut(gh, args) {
7
+ const r = await gh(args);
8
+ return r.ok ? r.stdout.trim() : '';
9
+ }
10
+ export async function resolveLoopEnv(options = {}) {
11
+ const dir = path.resolve(options.dir ?? process.cwd());
12
+ const git = options.git ?? ((args) => realGitExec(args, dir));
13
+ const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, dir));
14
+ const env = options.env ?? process.env;
15
+ const warnings = [];
16
+ // Absolute, so the common dir is `<main checkout>/.git` from anywhere.
17
+ const common = (await git(['rev-parse', '--path-format=absolute', '--git-common-dir']))?.trim();
18
+ const root = common ? path.resolve(common, '..') : '';
19
+ if (!root)
20
+ warnings.push('not a git repository');
21
+ const worktreeRoot = root ? defaultWorktreeRoot(root) : '';
22
+ const ownerRepo = await ghOut(gh, [
23
+ 'repo',
24
+ 'view',
25
+ '--json',
26
+ 'nameWithOwner',
27
+ '--jq',
28
+ '.nameWithOwner',
29
+ ]);
30
+ if (!ownerRepo)
31
+ warnings.push('could not resolve the GitHub repo from the working directory');
32
+ let agentUser = env.AI_LOOP_AGENT?.trim() || (root ? await configuredAgentUser(root) : '') || '';
33
+ // LOGIN is the injection boundary — the login goes into an API path.
34
+ if (agentUser &&
35
+ (!ownerRepo ||
36
+ !LOGIN.test(agentUser) ||
37
+ !(await gh(['api', `repos/${ownerRepo}/assignees/${agentUser}`, '--silent'])).ok)) {
38
+ warnings.push(`agentUser '${agentUser}' is not an assignable collaborator — assigning nothing`);
39
+ agentUser = '';
40
+ }
41
+ const humanUser = ownerRepo
42
+ ? await ghOut(gh, [
43
+ 'api',
44
+ `repos/${ownerRepo}`,
45
+ '--jq',
46
+ 'if .owner.type == "User" then .owner.login else "" end',
47
+ ])
48
+ : '';
49
+ const me = await ghOut(gh, ['api', 'user', '--jq', '.login']);
50
+ return { root, worktreeRoot, ownerRepo, agentUser, humanUser, me, warnings };
51
+ }
52
+ const VARS = [
53
+ ['ROOT', 'root'],
54
+ ['WT_ROOT', 'worktreeRoot'],
55
+ ['OWNER_REPO', 'ownerRepo'],
56
+ ['AGENT_USER', 'agentUser'],
57
+ ['HUMAN_USER', 'humanUser'],
58
+ ['ME', 'me'],
59
+ ];
60
+ /** `KEY='value'` lines, single-quoted so `eval "$(… loop env)"` is safe. */
61
+ export function toShell(env) {
62
+ return VARS.map(([name, key]) => `${name}='${env[key].replaceAll("'", `'\\''`)}'`).join('\n');
63
+ }
64
+ export async function loopEnvCommand(options) {
65
+ const env = await resolveLoopEnv(options);
66
+ if (options.json) {
67
+ console.log(JSON.stringify(env, null, 2));
68
+ }
69
+ else {
70
+ for (const w of env.warnings)
71
+ console.error(`⚠ ${w}`);
72
+ console.log(toShell(env));
73
+ }
74
+ // Nothing downstream works without both — the skill bails on either.
75
+ process.exitCode = env.root && env.ownerRepo ? 0 : 1;
76
+ }
@@ -107,7 +107,7 @@ async function gitEntryKind(root) {
107
107
  }
108
108
  }
109
109
  /** `ai-*` directories one level down, in either place worktrees are kept. */
110
- async function findLive(dirs) {
110
+ export async function findLive(dirs) {
111
111
  const live = [];
112
112
  for (const dir of dirs) {
113
113
  // A missing worktree root is the normal case, not an error.
package/dist/cli/index.js CHANGED
@@ -5,6 +5,8 @@ import { Command } from 'commander';
5
5
  import fs from 'fs-extra';
6
6
  import { doctorCommand } from './commands/doctor.js';
7
7
  import { fixCommand } from './commands/fix.js';
8
+ import { loopCleanupCommand } from './commands/loop-cleanup.js';
9
+ import { loopEnvCommand } from './commands/loop-env.js';
8
10
  import { loopGuardCommand } from './commands/loop-guard.js';
9
11
  import { setupProject } from './commands/setup.js';
10
12
  import { copyPreset, PRESETS } from './utils/copy-preset.js';
@@ -370,6 +372,23 @@ loop
370
372
  ' 1 repair was attempted and failed; the root is still bare — halt the tick\n' +
371
373
  ' 2 root is not a repairable main checkout (bare clone, linked worktree, or not a repo) — halt the tick\n')
372
374
  .action(loopGuardCommand);
375
+ loop
376
+ .command('env')
377
+ .description('🧭 Resolve ROOT, WT_ROOT, OWNER_REPO, AGENT_USER, HUMAN_USER and ME once')
378
+ .option('-d, --dir <path>', 'Directory to resolve from — main checkout or any worktree', process.cwd())
379
+ .option('--json', 'Emit machine-readable JSON output')
380
+ .addHelpText('after', "\nWithout --json, prints KEY='value' lines for eval. Empty means none.\n" +
381
+ 'Exits 1 when the checkout or its GitHub repo cannot be resolved.\n')
382
+ .action(loopEnvCommand);
383
+ loop
384
+ .command('cleanup')
385
+ .description('🧹 Remove ai-* worktrees whose PR landed on main or was closed')
386
+ .option('--root <path>', 'Main checkout the loop branches worktrees from', process.cwd())
387
+ .option('--worktree-root <path>', 'Where ai-* worktrees live (default: <root>-worktrees)')
388
+ .option('--json', 'Emit machine-readable JSON output')
389
+ .addHelpText('after', '\nPass `removed` from --json to `loop guard --removed`.\n' +
390
+ 'Exits 1 when a worktree removal failed.\n')
391
+ .action(loopCleanupCommand);
373
392
  program.hook('preAction', async (_, actionCommand) => {
374
393
  const name = actionCommand.name();
375
394
  if (name === 'setup' || name === 'doctor' || name === 'fix') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.39.0",
3
+ "version": "3.41.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": [