forge-workflow 0.1.0-beta.7 → 0.1.0-beta.8

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.
@@ -4,22 +4,38 @@ const { execFileSync } = require('node:child_process');
4
4
  const { constants } = require('node:os');
5
5
  const spawn = require('cross-spawn');
6
6
  const {
7
- createGithubContext, prepareGithubAccount, readGithubAccount,
8
- writeGithubAccount, unsetGithubAccount, validateGithubLogin,
7
+ assertGithubAutoAvailable, createGithubContext, disableGithubAuto, enableGithubAuto,
8
+ hasGithubAutoCredentialHelper, prepareGithubAccount, readGithubAccount, readGithubAuto, writeGithubAccount,
9
+ unsetGithubAccount, validateGithubLogin,
9
10
  } = require('../github-context');
11
+ const { assertGithubRouterCloneRegistered, getGithubRouterStatus, installGithubRouter,
12
+ isManagedCredentialHelperValue, isOwnedCredentialHelperValue, registerGithubRouterClone, unregisterGithubRouterClone,
13
+ uninstallGithubRouter } = require('../github-router');
10
14
  const { stripGlobalFlags } = require('../global-flags');
11
15
  const { defaultResolveLocalRepository } = require('./merge');
12
16
 
13
- const usage = 'forge github use <login> | status [--json] | unset | run -- <program> [args...]';
17
+ const usage = 'forge github use <login> [--auto] | auto [--disable] | router --uninstall --force | status [--json] | unset | run -- <program> [args...]';
14
18
  const ERROR_MESSAGES = {
15
19
  INVALID_GITHUB_LOGIN: 'GitHub login must be 1-39 letters, numbers, or internal hyphens.',
16
20
  GIT_CONFIG_FAILED: 'Unable to access this clone-local GitHub account binding.',
21
+ GIT_CREDENTIAL_HELPER_CONFLICT: 'This clone already has a custom GitHub credential helper. Inspect it and remove only the conflicting local entry deliberately.',
22
+ GITHUB_ROUTER_CONFLICT: 'A non-Forge gh launcher already exists beside Forge. Forge left it unchanged.',
23
+ GITHUB_ROUTER_SHADOWED: 'GitHub CLI appears before Forge on PATH. Put Forge first, then retry automatic routing.',
24
+ GITHUB_ROUTER_UNAVAILABLE: 'Install Forge on PATH before enabling automatic GitHub routing.',
25
+ GITHUB_ROUTER_PERMISSION: 'Forge cannot update its launcher directory. Check directory permissions, then retry.',
26
+ GITHUB_ROUTER_BUSY: 'Another automatic-routing update is in progress. Retry after it finishes.',
27
+ GITHUB_ROUTER_REGISTRY_INVALID: 'Forge cannot verify the machine routing registry. Repair the registered clone state, then retry.',
28
+ GITHUB_CREDENTIAL_HELPER_UNAVAILABLE: 'Automatic HTTPS routing requires the Forge-owned credential helper. Run forge github auto to restore it.',
29
+ GITHUB_AUTO_INVALID: 'This clone has an invalid automatic-routing value. Disable or enable automatic routing to repair it.',
30
+ GITHUB_ACCOUNT_REQUIRED: 'Automatic routing requires a clone account. Select one with forge github use <login> --auto, or disable routing.',
31
+ GITHUB_ROUTER_CONFIRMATION: 'Router files are shared by every opted-in clone. Disable automatic routing in every clone, then rerun with --force.',
32
+ GITHUB_ROUTER_IN_USE: 'Automatic routing remains enabled in a registered clone. Disable it there before removing the shared router.',
17
33
  GITHUB_NOT_AUTHENTICATED: 'Account is not authenticated. Run gh auth login --hostname github.com for the account, then retry.',
18
34
  GITHUB_AUTH_FAILED: 'Unable to verify the account. Run gh auth login --hostname github.com, then retry.',
19
35
  GITHUB_CLI_UNSUPPORTED: 'Upgrade GitHub CLI to support gh auth token --hostname github.com --user <login>.',
20
36
  GITHUB_ACCOUNT_MISMATCH: 'The authenticated GitHub account does not match this clone binding.',
21
37
  GITHUB_COMMAND_FAILED: 'The selected account cannot view this repository. Check repository permissions and organization SSO.',
22
- GITHUB_REPOSITORY_INVALID: 'Origin must identify a GitHub.com repository. Check its URL and SSH alias configuration.',
38
+ GITHUB_REPOSITORY_INVALID: 'A Git remote must identify a GitHub.com repository. Check its URL and SSH alias configuration.',
23
39
  };
24
40
 
25
41
  function safeFailure(error) {
@@ -27,38 +43,77 @@ function safeFailure(error) {
27
43
  return { success: false, code, error: ERROR_MESSAGES[code] || 'GitHub account operation failed. Run forge github status for diagnostics.' };
28
44
  }
29
45
 
30
- function isGithubOrigin(remote, root, options) {
46
+ function requiredGithubAutoState(projectRoot, options) {
47
+ const automatic = readGithubAuto(projectRoot, options);
48
+ if (automatic === null) throw Object.assign(new Error('Invalid automatic routing state.'), { code: 'GITHUB_AUTO_INVALID' });
49
+ return automatic;
50
+ }
51
+
52
+ function resolveGithubRemote(remote, root, options = {}) {
53
+ const repository = defaultResolveLocalRepository({ projectRoot: root, git: () => remote });
54
+ if (!repository) return null;
31
55
  try {
32
56
  const scpHost = /^[A-Za-z]:/.test(remote) || /^(?:git|https?|ssh|file):/i.test(remote) ? null
33
- : /^(?:[A-Za-z0-9._-]+@)?([A-Za-z0-9][A-Za-z0-9._-]*):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.exec(remote);
57
+ : /^(?:[A-Za-z0-9._-]+@)?([A-Za-z0-9][A-Za-z0-9._-]*):/.exec(remote);
34
58
  const parsed = scpHost ? null : new URL(remote);
35
- if (parsed && !['https:', 'ssh:'].includes(parsed.protocol)) return false;
59
+ if (parsed && !['https:', 'ssh:'].includes(parsed.protocol)) return null;
60
+ if (parsed?.protocol === 'https:' && parsed.port) return null;
36
61
  const host = scpHost ? scpHost[1] : parsed.hostname;
37
- if (host.toLowerCase() === 'github.com') return true;
38
- if ((!scpHost && parsed.protocol !== 'ssh:') || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(host)) return false;
39
- // Resolve configured SSH aliases locally; -G does not open a connection.
40
- const run = options.runner || execFileSync;
62
+ if (host.toLowerCase() === 'github.com' || (!scpHost && parsed.protocol !== 'ssh:')) {
63
+ return { hostname: host.toLowerCase(), repository };
64
+ }
65
+ const run = options.runner || options.execFileSync || execFileSync;
41
66
  const config = String(run('ssh', ['-G', host], {
42
67
  cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 3000,
43
68
  }));
44
- return /^hostname\s+github\.com\s*$/im.test(config);
45
- } catch { return false; }
69
+ const hostname = /^hostname\s+(\S+)\s*$/im.exec(config)?.[1];
70
+ return hostname ? { hostname: hostname.toLowerCase(), repository } : null;
71
+ } catch { return null; }
46
72
  }
47
73
 
48
- function repositoryAccess(context, root, options) {
49
- const remote = readGit(root, ['remote', 'get-url', 'origin'], options);
50
- const repository = defaultResolveLocalRepository({ projectRoot: root, git: () => remote });
51
- if (!repository || !isGithubOrigin(remote, root, options)) {
74
+ function runGit(root, args, options) {
75
+ const run = options.runner || options.execFileSync || execFileSync;
76
+ return String(run('git', args, {
77
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 3000,
78
+ }) || '').trim();
79
+ }
80
+
81
+ function resolveLocalGithubTarget(root, options = {}, requestedRemoteName) {
82
+ if (options.resolveLocalTarget) return options.resolveLocalTarget(root, requestedRemoteName);
83
+ const git = args => runGit(root, args, options);
84
+ let remoteName = requestedRemoteName || 'origin';
85
+ if (!requestedRemoteName) {
86
+ try {
87
+ const resolved = git(['config', '--local', '--get-regexp', '^remote\\..*\\.gh-resolved$'])
88
+ .split(/\r?\n/).map(line => /^remote\.(.+)\.gh-resolved\s+(?:base|true)$/i.exec(line)?.[1]).filter(Boolean);
89
+ if (resolved.length > 1) throw new Error('Multiple GitHub CLI default remotes are configured.');
90
+ if (resolved.length === 1) remoteName = resolved[0];
91
+ } catch (error) {
92
+ if (error?.status !== 1) throw error;
93
+ }
94
+ }
95
+ let remote;
96
+ try {
97
+ remote = git(['remote', 'get-url', remoteName]);
98
+ } catch (error) {
99
+ if (requestedRemoteName || remoteName !== 'origin' || error?.status !== 2) throw error;
100
+ const remotes = git(['remote']).split(/\r?\n/).filter(Boolean);
101
+ if (remotes.length !== 1) throw error;
102
+ remote = git(['remote', 'get-url', remotes[0]]);
103
+ }
104
+ const target = resolveGithubRemote(remote, root, options);
105
+ return target ? { ...target, remote } : null;
106
+ }
107
+
108
+ function repositoryAccess(context, root, options, target = resolveLocalGithubTarget(root, options)) {
109
+ if (target?.hostname !== 'github.com') {
52
110
  throw Object.assign(new Error(ERROR_MESSAGES.GITHUB_REPOSITORY_INVALID), { code: 'GITHUB_REPOSITORY_INVALID' });
53
111
  }
54
- context.runGh(['repo', 'view', `github.com/${repository}`, '--json', 'nameWithOwner'], { cwd: root, timeout: 30000 });
112
+ context.runGh(['repo', 'view', `github.com/${target.repository}`, '--json', 'nameWithOwner'], { cwd: root, timeout: 30000 });
55
113
  }
56
114
 
57
115
  function readGit(root, args, options) {
58
- try {
59
- const run = options.runner || execFileSync;
60
- return String(run('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 3000 }) || '').trim();
61
- } catch { return ''; }
116
+ try { return runGit(root, args, options); } catch { return ''; }
62
117
  }
63
118
 
64
119
  function boundedText(value) {
@@ -76,21 +131,29 @@ function transportOf(remote) {
76
131
  return 'other';
77
132
  }
78
133
 
79
- function helperOf(helper) {
134
+ function helperOf(helper, options) {
80
135
  if (!helper) return 'none';
136
+ const values = helper.split(/\r?\n/).map(value => value.trim());
137
+ const ownsHelper = options.isOwnedCredentialHelper || isOwnedCredentialHelperValue;
138
+ if (values.some(value => ownsHelper(value))) return 'forge';
139
+ const managesHelper = options.isManagedCredentialHelper || isManagedCredentialHelperValue;
140
+ if (values.some(value => managesHelper(value, options))) return 'forge-stale';
81
141
  if (/\bgh(?:\.exe)?\s+auth\s+git-credential\b/i.test(helper)) return 'github-cli';
82
142
  if (/\bmanager(?:-core)?\b/i.test(helper)) return 'credential-manager';
83
143
  return 'other';
84
144
  }
85
145
 
86
146
  function localDiagnostics(root, options) {
147
+ let target;
148
+ try { target = resolveLocalGithubTarget(root, options); } catch { /* report the remaining local state */ }
87
149
  return {
150
+ target: target || null,
88
151
  author: {
89
152
  name: boundedText(readGit(root, ['config', '--get', 'user.name'], options)),
90
153
  email: boundedText(readGit(root, ['config', '--get', 'user.email'], options)),
91
154
  },
92
- transport: transportOf(readGit(root, ['remote', 'get-url', 'origin'], options)),
93
- credentialHelper: helperOf(readGit(root, ['config', '--get-all', 'credential.helper'], options)),
155
+ transport: transportOf(target?.remote || readGit(root, ['remote', 'get-url', 'origin'], options)),
156
+ credentialHelper: helperOf(readGit(root, ['config', '--get-urlmatch', 'credential.helper', 'https://github.com'], options), options),
94
157
  };
95
158
  }
96
159
 
@@ -98,14 +161,23 @@ function renderStatus(status) {
98
161
  const lines = [
99
162
  `GitHub: ${status.state}`,
100
163
  `Clone account: ${status.account || 'not selected'}`,
164
+ `Automatic routing: ${status.automatic ? 'on' : 'off'}`,
165
+ `Router: ${status.router}`,
101
166
  `Verified login: ${status.login || 'unavailable'}`,
102
167
  `Commit author: ${status.author.name || 'unset'} <${status.author.email || 'unset'}>`,
103
- `Origin transport: ${status.transport}; credential helper: ${status.credentialHelper}`,
168
+ `Git transport: ${status.transport}; credential helper: ${status.credentialHelper}`,
104
169
  ];
105
170
  if (status.error) lines.push(status.error);
171
+ if (status.automatic && status.router !== 'ready') lines.push('Automatic gh routing is not first on PATH; rerun setup after fixing PATH order.');
106
172
  if (status.state === 'unbound') lines.push('Optional: authenticate with gh auth login, then select an account with forge github use <login>.');
107
173
  if (status.transport === 'ssh') lines.push('SSH key selection is controlled by your SSH configuration.');
108
- if (status.transport === 'https' && status.credentialHelper === 'github-cli') {
174
+ if (status.code === 'GITHUB_CREDENTIAL_HELPER_UNAVAILABLE') {
175
+ lines.push('The Forge HTTPS credential helper is missing or unowned, or another helper conflicts; run forge github auto to restore it.');
176
+ } else if (status.transport === 'https' && status.credentialHelper === 'forge') {
177
+ lines.push('HTTPS Git credentials use this clone\'s Forge-selected GitHub account.');
178
+ } else if (status.transport === 'https' && status.credentialHelper === 'forge-stale') {
179
+ lines.push('The Forge HTTPS credential helper is missing or unowned; restore Forge on PATH, then run forge github auto.');
180
+ } else if (status.transport === 'https' && status.credentialHelper === 'github-cli') {
109
181
  lines.push('HTTPS uses the GitHub CLI helper; a launched session may also use its selected token for Git transport.');
110
182
  } else if (status.transport === 'https') {
111
183
  lines.push('HTTPS Git credentials are selected separately by your credential helper.');
@@ -114,21 +186,39 @@ function renderStatus(status) {
114
186
  }
115
187
 
116
188
  function accountStatus(root, options) {
117
- const status = { state: 'unbound', account: null, source: null, login: null, repositoryAccess: null, ...localDiagnostics(root, options) };
189
+ const automatic = requiredGithubAutoState(root, options);
190
+ const { target, ...diagnostics } = localDiagnostics(root, options);
191
+ const status = { state: 'unbound', account: null, source: null, login: null, repositoryAccess: null,
192
+ automatic, router: automatic ? (options.routerStatus || getGithubRouterStatus)(options) : 'disabled', ...diagnostics };
118
193
  try {
119
194
  const account = readGithubAccount(root, options);
120
- if (!account) return status;
195
+ if (!account) {
196
+ if (automatic) throw Object.assign(new Error('Automatic routing has no account binding.'), { code: 'GITHUB_ACCOUNT_REQUIRED' });
197
+ return status;
198
+ }
121
199
  status.source = 'clone-local';
122
200
  status.account = validateGithubLogin(account) ? account : null;
201
+ if (automatic) {
202
+ (options.assertRegistered || assertGithubRouterCloneRegistered)(root, options);
203
+ if (status.router !== 'ready') {
204
+ const code = status.router === 'shadowed' ? 'GITHUB_ROUTER_SHADOWED' : 'GITHUB_ROUTER_UNAVAILABLE';
205
+ throw Object.assign(new Error('Automatic GitHub routing is unavailable.'), { code });
206
+ }
207
+ if (status.transport === 'https' && !hasGithubAutoCredentialHelper(root, options)) {
208
+ if (status.credentialHelper === 'forge') status.credentialHelper = 'forge-conflict';
209
+ throw Object.assign(new Error('Automatic HTTPS routing helper is unavailable.'), { code: 'GITHUB_CREDENTIAL_HELPER_UNAVAILABLE' });
210
+ }
211
+ }
123
212
  const context = prepareGithubAccount(root, account, options);
124
213
  status.login = context.login;
125
214
  status.repositoryAccess = false;
126
- repositoryAccess(context, root, options);
215
+ repositoryAccess(context, root, options, target);
127
216
  status.repositoryAccess = true;
128
217
  status.state = 'ready';
129
218
  } catch (error) {
130
219
  const failure = safeFailure(error);
131
- status.state = status.login ? 'no_repository_access' : 'unauthenticated';
220
+ status.state = failure.code.startsWith('GITHUB_ROUTER_') || failure.code === 'GITHUB_CREDENTIAL_HELPER_UNAVAILABLE'
221
+ ? 'router_error' : (status.login ? 'no_repository_access' : 'unauthenticated');
132
222
  if (failure.code === 'GITHUB_ACCOUNT_MISMATCH') {
133
223
  status.state = 'mismatch';
134
224
  const match = /^GitHub account mismatch: expected [A-Za-z0-9-]+, received ([A-Za-z0-9-]+)\.$/.exec(error.message);
@@ -178,6 +268,108 @@ function githubStatusResult(projectRoot, flags, args, options) {
178
268
  ...(status.error ? { error: status.error } : {}) };
179
269
  }
180
270
 
271
+ function usageFailure() {
272
+ return { success: false, error: `Usage: ${usage}` };
273
+ }
274
+
275
+ function writeAccountAndAuto(projectRoot, account, automatic, router, previousAccount, options) {
276
+ if (!automatic) return writeGithubAccount(projectRoot, account, options);
277
+ try {
278
+ writeGithubAccount(projectRoot, account, options);
279
+ enableGithubAuto(projectRoot, { ...options, credentialHelperValue: router.credentialHelperValue });
280
+ } catch (error) {
281
+ try {
282
+ if (previousAccount) writeGithubAccount(projectRoot, previousAccount, options);
283
+ else unsetGithubAccount(projectRoot, options);
284
+ } catch { /* best effort rollback */ }
285
+ try { router.rollback?.(); } catch { /* best effort rollback */ }
286
+ throw error;
287
+ }
288
+ return router.commit?.();
289
+ }
290
+
291
+ function handleUse(ownArgs, delimiter, projectRoot, options) {
292
+ if (!((ownArgs.length === 2 || (ownArgs.length === 3 && ownArgs[2] === '--auto')) && delimiter < 0)) return usageFailure();
293
+ const enableAutomatic = ownArgs[2] === '--auto';
294
+ if (enableAutomatic) assertGithubAutoAvailable(projectRoot, options);
295
+ const context = prepareGithubAccount(projectRoot, ownArgs[1], options);
296
+ repositoryAccess(context, projectRoot, options);
297
+ const automatic = enableAutomatic || requiredGithubAutoState(projectRoot, options);
298
+ const previousAccount = enableAutomatic ? readGithubAccount(projectRoot, options) : null;
299
+ const registration = automatic && !enableAutomatic
300
+ ? (options.registerRouterClone || registerGithubRouterClone)(projectRoot, options)
301
+ : null;
302
+ const router = enableAutomatic
303
+ ? (options.installRouter || installGithubRouter)({ ...options, projectRoot })
304
+ : null;
305
+ const finalization = writeAccountAndAuto(projectRoot, ownArgs[1], enableAutomatic, router, previousAccount, options);
306
+ const warning = finalization?.warning || registration?.warning;
307
+ return { success: true, account: context.account, automatic,
308
+ ...(warning ? { warning } : {}),
309
+ output: `This clone uses GitHub account ${context.account}${automatic ? ' with automatic routing' : ''}.${warning ? ` ${warning}` : ''}` };
310
+ }
311
+
312
+ function handleAuto(ownArgs, delimiter, projectRoot, options) {
313
+ if (!((ownArgs.length === 1 || (ownArgs.length === 2 && ownArgs[1] === '--disable')) && delimiter < 0)) return usageFailure();
314
+ if (ownArgs[1] === '--disable') {
315
+ disableGithubAuto(projectRoot, options);
316
+ const cleanup = (options.unregisterRouterClone || unregisterGithubRouterClone)(projectRoot, options);
317
+ return { success: true, automatic: false, ...(cleanup?.warning ? { warning: cleanup.warning } : {}),
318
+ output: `Automatic GitHub routing disabled for this clone.${cleanup?.warning ? ` ${cleanup.warning}` : ''}` };
319
+ }
320
+ assertGithubAutoAvailable(projectRoot, options);
321
+ const context = createGithubContext(projectRoot, options);
322
+ if (!context.bound) throw Object.assign(new Error('No clone binding.'), { code: 'GITHUB_NOT_AUTHENTICATED' });
323
+ repositoryAccess(context, projectRoot, options);
324
+ const router = (options.installRouter || installGithubRouter)({ ...options, projectRoot });
325
+ try {
326
+ enableGithubAuto(projectRoot, { ...options, credentialHelperValue: router.credentialHelperValue });
327
+ } catch (error) {
328
+ try { router.rollback?.(); } catch { /* best effort rollback */ }
329
+ throw error;
330
+ }
331
+ const warning = router.commit?.()?.warning;
332
+ return { success: true, account: context.account, automatic: true,
333
+ ...(warning ? { warning } : {}),
334
+ output: `Automatic GitHub routing enabled for ${context.account}.${warning ? ` ${warning}` : ''}` };
335
+ }
336
+
337
+ function handleRouterUninstall(ownArgs, delimiter, projectRoot, flags, options) {
338
+ if (ownArgs.length === 2 && ownArgs[1] === '--uninstall' && delimiter < 0 && !flags.force) {
339
+ throw Object.assign(new Error('Explicit confirmation required.'), { code: 'GITHUB_ROUTER_CONFIRMATION' });
340
+ }
341
+ if (!(ownArgs.length === 2 && ownArgs[1] === '--uninstall' && delimiter < 0 && flags.force)) return usageFailure();
342
+ const automatic = options.readAuto ? options.readAuto(projectRoot, options) : requiredGithubAutoState(projectRoot, options);
343
+ if (automatic === null) throw Object.assign(new Error('Invalid automatic routing state.'), { code: 'GITHUB_AUTO_INVALID' });
344
+ if (automatic) {
345
+ throw Object.assign(new Error('Automatic routing is enabled.'), { code: 'GITHUB_ROUTER_IN_USE' });
346
+ }
347
+ const result = (options.uninstallRouter || uninstallGithubRouter)({ ...options, projectRoot });
348
+ const output = result.removed.length ? `Removed ${result.removed.length} Forge GitHub router file(s).` : 'No Forge GitHub router files found.';
349
+ return { success: true, removed: result.removed, ...(result.warning ? { warning: result.warning } : {}),
350
+ output: `${output}${result.warning ? ` ${result.warning}` : ''}` };
351
+ }
352
+
353
+ function handleStatus(ownArgs, delimiter, projectRoot, flags, args, options) {
354
+ if (!(ownArgs.slice(1).every(arg => arg === '--json') && delimiter < 0)) return usageFailure();
355
+ return githubStatusResult(projectRoot, flags, args, options);
356
+ }
357
+
358
+ function handleUnset(ownArgs, delimiter, projectRoot, options) {
359
+ if (!(ownArgs.length === 1 && delimiter < 0)) return usageFailure();
360
+ disableGithubAuto(projectRoot, options);
361
+ unsetGithubAccount(projectRoot, options);
362
+ const cleanup = (options.unregisterRouterClone || unregisterGithubRouterClone)(projectRoot, options);
363
+ return { success: true, ...(cleanup?.warning ? { warning: cleanup.warning } : {}),
364
+ output: `Clone GitHub account binding removed; native account selection applies.${cleanup?.warning ? ` ${cleanup.warning}` : ''}` };
365
+ }
366
+
367
+ async function handleRun(ownArgs, delimiter, args, projectRoot, options) {
368
+ if (!(ownArgs.length === 1 && delimiter >= 0 && args[delimiter + 1])) return usageFailure();
369
+ const context = createGithubContext(projectRoot, { ...options, childRunner: options.childRunner || spawn });
370
+ return await launch(context, args[delimiter + 1], args.slice(delimiter + 2), projectRoot, options);
371
+ }
372
+
181
373
  async function handler(args = [], flags = {}, projectRoot = process.cwd(), options = {}) {
182
374
  const delimiter = args.indexOf('--');
183
375
  const ownArgs = stripGlobalFlags(delimiter < 0 ? args : args.slice(0, delimiter));
@@ -186,30 +378,23 @@ async function handler(args = [], flags = {}, projectRoot = process.cwd(), optio
186
378
  return { success: true, output: usage };
187
379
  }
188
380
  try {
189
- if (verb === 'use' && ownArgs.length === 2 && delimiter < 0) {
190
- const context = prepareGithubAccount(projectRoot, ownArgs[1], options);
191
- repositoryAccess(context, projectRoot, options);
192
- writeGithubAccount(projectRoot, ownArgs[1], options);
193
- return { success: true, account: context.account, output: `This clone uses GitHub account ${context.account}.` };
194
- }
195
- if (verb === 'status' && ownArgs.slice(1).every(arg => arg === '--json') && delimiter < 0) {
196
- return githubStatusResult(projectRoot, flags, args, options);
197
- }
198
- if (verb === 'unset' && ownArgs.length === 1 && delimiter < 0) {
199
- unsetGithubAccount(projectRoot, options);
200
- return { success: true, output: 'Clone GitHub account binding removed; native account selection applies.' };
201
- }
202
- if (verb === 'run' && ownArgs.length === 1 && delimiter >= 0 && args[delimiter + 1]) {
203
- const context = createGithubContext(projectRoot, { ...options, childRunner: options.childRunner || spawn });
204
- return await launch(context, args[delimiter + 1], args.slice(delimiter + 2), projectRoot, options);
381
+ switch (verb) {
382
+ case 'use': return handleUse(ownArgs, delimiter, projectRoot, options);
383
+ case 'auto': return handleAuto(ownArgs, delimiter, projectRoot, options);
384
+ case 'router': return handleRouterUninstall(ownArgs, delimiter, projectRoot, flags, options);
385
+ case 'status': return handleStatus(ownArgs, delimiter, projectRoot, flags, args, options);
386
+ case 'unset': return handleUnset(ownArgs, delimiter, projectRoot, options);
387
+ case 'run': return await handleRun(ownArgs, delimiter, args, projectRoot, options);
388
+ default: return usageFailure();
205
389
  }
206
- return { success: false, error: `Usage: ${usage}` };
207
390
  } catch (error) { return safeFailure(error); }
208
391
  }
209
392
 
210
393
  module.exports = {
211
394
  name: 'github',
212
- description: 'Optional clone-local GitHub account selection and isolated session launch',
395
+ description: 'Optional clone-local GitHub account selection, automatic routing, and isolated launch',
213
396
  usage,
214
397
  handler,
398
+ resolveLocalGithubTarget,
399
+ resolveGithubRemote,
215
400
  };