From 4501e13e768d4af52fc35c681cd3533e6f85a915 Mon Sep 17 00:00:00 2001 From: microHoffman Date: Wed, 22 Jul 2026 03:15:21 +0200 Subject: [PATCH] fix: support deterministic SSH auth callbacks Add strict shared callback-port parsing to login and init so remote users can prepare SSH forwarding before starting OAuth. Clarify loopback behavior, headless guidance, and non-interactive token usage with unit and smoke coverage. --- README.md | 30 +++++++- docs/implementation/cli-commands.md | 49 +++++++++++- docs/implementation/remote-authentication.md | 47 ++++++++++++ scripts/cli-smoke.ts | 28 +++++++ src/commands/init/init.test.ts | 15 +++- src/commands/init/init.ts | 18 ++++- src/commands/login.test.ts | 81 ++++++++++++++++---- src/commands/login.ts | 39 ++++++++-- src/shared/cli-options.test.ts | 35 +++++++++ src/shared/cli-options.ts | 22 ++++++ src/shared/index.ts | 2 +- 11 files changed, 337 insertions(+), 29 deletions(-) create mode 100644 docs/implementation/remote-authentication.md create mode 100644 src/shared/cli-options.test.ts diff --git a/README.md b/README.md index 60cb25d3..7649e457 100644 --- a/README.md +++ b/README.md @@ -163,16 +163,38 @@ npx githits@latest login Browser OAuth is recommended for local development. Credentials are stored in the system keychain by default and refreshed automatically. Useful flags: -- `init --no-browser` or `login --no-browser` prints a login URL for SSH, containers, or headless sessions +- `init --no-browser` or `login --no-browser` prints the login URL instead of launching a browser +- `init --port ` or `login --port ` selects the local callback port - `login --force` re-authenticates even if you are already logged in -- `login --port ` uses a specific local callback port -For CI or non-interactive environments, use an API token: +`--no-browser` controls browser launching only. The OAuth callback listener +still binds to `127.0.0.1` on the machine running GitHits. If the browser is on +another computer, its `127.0.0.1` is a different machine and the callback must +be forwarded. + +For example, choose a port and start a local-forwarding tunnel from the +computer with the browser: ```sh -export GITHITS_API_TOKEN=ghi-your-token-here +ssh -N -L 8765:127.0.0.1:8765 user@remote-host ``` +In a shell on the remote machine, keep the tunnel running and start GitHits +with the same port: + +```sh +npx githits@latest init --no-browser --port 8765 +``` + +Then open the printed login URL on the computer where the tunnel started. +Replace `user@remote-host` with the destination used for the SSH connection. +The same workflow works with `githits login` when setup is already complete. + +For CI, containers without an interactive SSH session, and other genuinely +non-interactive environments, provide an API token through the +`GITHITS_API_TOKEN` environment variable using the environment's secret +manager. These environments should not wait for browser OAuth callbacks. + Inspect auth and runtime state with: ```sh diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index d2cab1df..4f236fb7 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -8,8 +8,9 @@ The CLI exposes setup/auth commands, `doctor`, `example`, `languages`, `feedback | Command | Required Args | Options | Description | |---|---|---|---| -| `init` | — | `-y, --yes`, `--skip-login`, `--no-browser`, `--project`, `--detect-agents`, `--install-agents `, `--json` | Authenticate and set up MCP server for coding agents; interactive setup asks whether to configure user-level or project-level MCP where supported; staged flags support agent-safe non-interactive onboarding | +| `init` | — | `-y, --yes`, `--skip-login`, `--no-browser`, `--port `, `--project`, `--detect-agents`, `--install-agents `, `--json` | Authenticate and set up MCP server for coding agents; interactive setup asks whether to configure user-level or project-level MCP where supported; staged flags support agent-safe non-interactive onboarding | | `init uninstall` | — | `-y, --yes`, `--project` | Remove GitHits MCP server configuration from coding agents or supported project-local MCP files | +| `login` | — | `--no-browser`, `--port `, `--force` | Authenticate with browser OAuth; `--no-browser` suppresses browser launching but does not move the loopback callback listener | | `example ` | `` | `-l, --lang `, `--license `, `--explain`, `--json` | Search for code examples | | `search ` | `--in ` | `--source `, `--kind `, `--category `, `--path-prefix `, `--intent `, `--public`, `--name `, `--lang `, `--allow-partial`, `--limit `, `--offset `, `--wait `, `--json` | Unified indexed search across dependency/repository code, docs, and symbols. Defaults to 10 results. | | `search-status ` | `` | `--json` | Check progress, fetch partial hits, or fetch final results for a prior unified search | @@ -34,6 +35,7 @@ githits init # Interactive: authenticate, s githits init --yes # Interactive shortcut: configure all detected unconfigured agents githits init --skip-login # Skip authentication, configure tools only githits init --no-browser # Print sign-in URL instead of opening a browser +githits init --no-browser --port 8765 # Use a deterministic callback port for SSH forwarding npx -y githits@latest init --detect-agents # Agent-safe discovery: scan and print detected agent IDs/statuses npx -y githits@latest init --detect-agents --json # Machine-readable discovery output for agents npx -y githits@latest init --install-agents cursor # Agent-safe install: configure only explicit detected IDs @@ -45,7 +47,50 @@ githits init uninstall --project # Explicit project-level unins githits init uninstall --project --yes # Non-interactive project-level uninstall ``` -Authenticates with GitHits (via OAuth in the browser), then scans for available coding agents, checks which are already configured, and sets up unconfigured ones with your confirmation. Use `--no-browser` in SSH or display-less sessions to print the sign-in URL instead of opening a browser on the current machine. All agents are pre-checked before any setup begins, so the status display is fully resolved. CLI agents are considered available only when their executable is on `PATH`; related dot-directories alone do not count. Config-file agents remain filesystem-detected using their known app/config directories. If already authenticated, the login step is skipped automatically. If login fails, the user is prompted to continue with tool setup anyway. If all detected agents are already configured, exits early with a summary. +Authenticates with GitHits (via OAuth in the browser), then scans for available coding agents, checks which are already configured, and sets up unconfigured ones with your confirmation. `--no-browser` prints the sign-in URL instead of opening a browser; it does not change the callback protocol or listener location. `--port` selects the loopback callback port and is useful when an SSH tunnel must be prepared before `init` starts. All agents are pre-checked before any setup begins, so the status display is fully resolved. CLI agents are considered available only when their executable is on `PATH`; related dot-directories alone do not count. Config-file agents remain filesystem-detected using their known app/config directories. If already authenticated, the login step is skipped automatically. If login fails, the user is prompted to continue with tool setup anyway. If all detected agents are already configured, exits early with a summary. + +### Browser OAuth in SSH and headless environments + +The callback server listens on `127.0.0.1` on the machine running GitHits. A +browser on that same machine can use the callback directly. A browser on a +different computer cannot: its loopback address refers to the browser's +computer. + +For an SSH session, select a deterministic port. On the computer with the +browser, start local forwarding and keep it running: + +```sh +ssh -N -L 8765:127.0.0.1:8765 user@remote-host +``` + +Then run one of these commands on the remote machine: + +```sh +githits init --no-browser --port 8765 +githits login --no-browser --port 8765 +``` + +Open the printed login URL on the computer that owns the forwarding tunnel. +The selected port must be available on both computers. `--no-browser` is also +useful when browser launching is unavailable but the browser and callback +listener share a network namespace; no tunnel is required in that case. + +For CI and other non-interactive environments, use `GITHITS_API_TOKEN` from a +secret manager instead of waiting for an interactive OAuth callback. + +### `githits login` + +```sh +githits login # Open a local browser and use a loopback callback +githits login --no-browser # Print the URL and callback-access instructions +githits login --no-browser --port 8765 # Use a fixed callback port for SSH forwarding +githits login --force # Re-authenticate an existing session +``` + +When no port is supplied, a fresh client registration chooses a random port in +the 8000–9999 range. A stored client registration reuses its registered +redirect URI. Explicit `--port` values must be integers from 1 through 65535; +changing the port causes the CLI to register a matching redirect URI. Interactive MCP setup asks where GitHits should be configured. User-level setup preserves the existing global/user config behavior for all supported tools. Project-level setup is partial because MCP project config conventions differ by tool; GitHits only offers project setup for tools with verified project-local MCP support: Claude Code (`.mcp.json`), Cursor (`.cursor/mcp.json`), VS Code / Copilot (`.vscode/mcp.json` with `type = "stdio"` server entries), Codex CLI (`.codex/config.toml`), Pi (`.mcp.json` with `pi-mcp-adapter` installed when needed), Gemini CLI (`.gemini/settings.json`), and OpenCode (`opencode.json`). Detected tools without verified project-local MCP support are shown as skipped with a reason. Project config contains no secrets, but it may be committed to source control like other project tooling configuration. Gemini may ignore project settings in untrusted workspaces. diff --git a/docs/implementation/remote-authentication.md b/docs/implementation/remote-authentication.md new file mode 100644 index 00000000..87433df7 --- /dev/null +++ b/docs/implementation/remote-authentication.md @@ -0,0 +1,47 @@ +# Remote Authentication + +## Current CLI contract + +GitHits uses an OAuth authorization-code flow with PKCE and a callback listener +bound to `127.0.0.1` on the machine running the CLI. This is a local native-app +flow: `--no-browser` suppresses browser launching, but it does not make the +callback reachable from a browser on another computer. + +For interactive SSH use, both `init` and `login` accept `--port `. The +user can prepare SSH local forwarding for that port, start GitHits remotely +with `--no-browser --port `, and open the printed URL locally. When no +port is supplied, existing random-port and stored-client behavior is preserved. + +For non-interactive environments, `GITHITS_API_TOKEN` is the supported path. +OAuth callback flows should not be used for unattended processes. + +## Hosted documentation handoff + +The separately hosted authentication and command-reference pages should state +the following explicitly: + +- `--no-browser` changes browser launching only. +- The callback listener remains on the GitHits host's loopback interface. +- A different browser computer requires SSH local forwarding using the same + port passed to `init` or `login`. +- API tokens are intended for CI and genuinely non-interactive environments. + +The README and CLI command reference in this repository contain the canonical +wording and forwarding example for that update. + +## Tunnel-free authentication + +A tunnel-free remote flow requires server support and is outside this +repository. The preferred follow-up is the OAuth 2.0 Device Authorization Grant +(RFC 8628), not a custom copy/paste or polling protocol. + +Required server capabilities include: + +- a device-authorization endpoint and OAuth discovery metadata; +- a user verification page with short-lived user and device codes; +- token-endpoint polling with pending, slowdown, denial, and expiry outcomes; +- rate limiting, code entropy, expiry, and phishing protections. + +After those capabilities exist, the CLI can add an explicit `--device-code` +mode to `login` and `init`. `--no-browser` should remain a browser-launch option +so the CLI continues to distinguish launch behavior from the OAuth protocol. diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index a49e05df..03c7cd9e 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -477,6 +477,34 @@ async function assertUnauthenticatedBehavior(): Promise { ); assertRootHelpStructure(helpResult.stdout); + for (const command of ["init", "login"] as const) { + const commandHelp = await runCliWithEnv([command, "--help"], env); + assert(commandHelp.exitCode === 0, `${command} help should succeed`); + assert( + commandHelp.stdout.includes("--port "), + `${command} help should expose --port`, + ); + assert( + commandHelp.stdout.includes("--no-browser"), + `${command} help should expose --no-browser`, + ); + + const invalidPort = await runCliWithEnv( + [command, "--port", "8080junk"], + env, + ); + assert( + invalidPort.exitCode !== 0, + `${command} should reject a partially numeric port`, + ); + assert( + `${invalidPort.stderr}\n${invalidPort.stdout}`.includes( + "Port must be an integer between 1 and 65535.", + ), + `${command} invalid-port error should explain the accepted range`, + ); + } + const result = await runCliWithEnv(["languages", "python", "--json"], env); assert(result.exitCode !== 0, "unauthenticated languages should fail"); assert( diff --git a/src/commands/init/init.test.ts b/src/commands/init/init.test.ts index d0c2e1b8..d51e3005 100644 --- a/src/commands/init/init.test.ts +++ b/src/commands/init/init.test.ts @@ -3316,12 +3316,13 @@ describe("initAction", () => { it("prints login URL instead of opening browser with --no-browser", async () => { const fs = createFsWithDetection(["/home/test/.cursor"]); const browserService = createMockBrowserService(); + const authService = createMockAuthService(); const promptService = createMockPromptService({ confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), }); const createLoginDeps = mock(() => Promise.resolve({ - authService: createMockAuthService(), + authService, authStorage: createMockAuthStorage(), browserService, mcpUrl: "https://mcp.githits.com", @@ -3329,7 +3330,7 @@ describe("initAction", () => { ); await initAction( - { browser: false }, + { browser: false, port: 8765 }, { fileSystemService: fs, promptService, @@ -3340,6 +3341,10 @@ describe("initAction", () => { const logCalls = getLogOutput(); expect(browserService.open).not.toHaveBeenCalled(); + expect(authService.startCallbackServer).toHaveBeenCalledWith( + 8765, + "test-state", + ); expect( logCalls.some((msg) => msg.includes("We'll print a sign-in URL")), ).toBe(true); @@ -3351,6 +3356,11 @@ describe("initAction", () => { msg.includes("https://accounts.githits.com/oauth/authorize"), ), ).toBe(true); + expect( + logCalls.some((msg) => + msg.includes("ssh -N -L 8765:127.0.0.1:8765 user@remote-host"), + ), + ).toBe(true); expect(fs.atomicWriteFile).toHaveBeenCalled(); }); @@ -5869,6 +5879,7 @@ describe("registerInitCommand", () => { expect(optionLongNames).toContain("--guidance"); expect(optionLongNames).toContain("--no-guidance"); expect(optionLongNames).toContain("--no-browser"); + expect(optionLongNames).toContain("--port"); }); it("registers uninstall options as boolean options", () => { diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index 0fde5ffd..895daf50 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -22,6 +22,7 @@ import type { SelectChoice, } from "../../services/prompt-service.js"; import { PromptServiceImpl } from "../../services/prompt-service.js"; +import { parsePortCliOption } from "../../shared/cli-options.js"; import type { LoginDependencies, LoginFlowResult, @@ -86,6 +87,8 @@ export interface InitOptions { skipLogin?: boolean; /** Print the login URL instead of opening a browser */ browser?: boolean; + /** Port for the local OAuth callback server */ + port?: number; /** Scan supported agents without installing anything */ detectAgents?: boolean; /** Comma-separated agent IDs to install non-interactively */ @@ -2003,7 +2006,10 @@ async function runInitAuthentication( } } - const loginOptions = options.browser === false ? { browser: false } : {}; + const loginOptions = { + browser: options.browser, + port: options.port, + }; loginResult = await loginFlow( loginOptions, loginDeps, @@ -4014,7 +4020,15 @@ export function registerInitCommand(program: Command) { .description(INIT_DESCRIPTION) .option("-y, --yes", "Skip prompts, configure all detected tools") .option("--skip-login", "Skip authentication step") - .option("--no-browser", "Print sign-in URL instead of opening browser") + .option( + "--no-browser", + "Print sign-in URL and remote callback instructions", + ) + .option( + "--port ", + "Port for local sign-in callback server", + parsePortCliOption, + ) .option("--project", "Configure project-level MCP in the current directory") .option("--guidance", "Install supporting GitHits skill and instructions") .option("--no-guidance", "Install plain MCP without supporting guidance") diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index 6166ee5c..45f349f1 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -127,6 +127,13 @@ describe("loginAction", () => { ); expect(browserService.open).not.toHaveBeenCalled(); + const output = consoleSpy.mock.calls + .map((call) => String(call[0])) + .join("\n"); + expect(output).toContain( + "The callback listener is on 127.0.0.1:8080 on the machine running GitHits.", + ); + expect(output).toContain("ssh -N -L 8080:127.0.0.1:8080 user@remote-host"); consoleSpy.mockRestore(); }); @@ -409,6 +416,39 @@ describe("loginAction", () => { describe("loginFlow", () => { const mcpUrl = "https://mcp.githits.com"; + it("uses a random callback port when no port or stored client is available", async () => { + const randomSpy = spyOn(Math, "random").mockReturnValue(0.5); + const authService = createMockAuthService(); + const browserService = createMockBrowserService(); + + try { + const result = await loginFlow( + {}, + { + authService, + authStorage: createMockAuthStorage(), + browserService, + mcpUrl, + }, + silentLoginOutput, + ); + + expect(result.status).toBe("success"); + expect(authService.registerClient).toHaveBeenCalledWith( + expect.objectContaining({ + redirectUri: "http://127.0.0.1:9000/callback", + }), + ); + expect(authService.startCallbackServer).toHaveBeenCalledWith( + 9000, + "test-state", + ); + expect(browserService.open).toHaveBeenCalled(); + } finally { + randomSpy.mockRestore(); + } + }); + it("returns success after completing OAuth flow", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); const close = mock(() => Promise.resolve()); @@ -680,6 +720,9 @@ describe("loginFlow", () => { message.startsWith(" https://accounts.githits.com/oauth/authorize?"), ), ).toBe(true); + expect(writes).toContain( + "If the browser is on another computer, stop this command and rerun with --no-browser --port 8080 for SSH forwarding instructions.\n", + ); }); it("closes callback server and clears fresh client when authentication wait fails", async () => { @@ -905,20 +948,32 @@ describe("loginFlow", () => { consoleSpy.mockRestore(); }); - it("returns failed on invalid port", async () => { - const result = await loginFlow( - { port: -1 }, - { - authService: createMockAuthService(), - authStorage: createMockAuthStorage(), - browserService: createMockBrowserService(), - mcpUrl, - }, - ); + for (const port of [ + -1, + 0, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + 65536, + ]) { + it(`returns failed on invalid programmatic port ${port}`, async () => { + const authService = createMockAuthService(); + const result = await loginFlow( + { port }, + { + authService, + authStorage: createMockAuthStorage(), + browserService: createMockBrowserService(), + mcpUrl, + }, + silentLoginOutput, + ); - expect(result.status).toBe("failed"); - expect(result.message).toContain("Invalid port"); - }); + expect(result.status).toBe("failed"); + expect(result.message).toContain("Invalid port"); + expect(authService.discoverEndpoints).not.toHaveBeenCalled(); + }); + } it("returns failed when token exchange throws", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); diff --git a/src/commands/login.ts b/src/commands/login.ts index 189b4a72..967e89db 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -7,6 +7,7 @@ import { createAuthCommandDependencies } from "../container.js"; import type { AuthService } from "../services/auth-service.js"; import type { AuthStorage } from "../services/auth-storage.js"; import type { BrowserService } from "../services/browser-service.js"; +import { parsePortCliOption } from "../shared/cli-options.js"; export interface LoginOptions { browser?: boolean; @@ -105,7 +106,9 @@ export async function loginFlow( // Validate port if provided if ( options.port !== undefined && - (Number.isNaN(options.port) || options.port < 1 || options.port > 65535) + (!Number.isInteger(options.port) || + options.port < 1 || + options.port > 65535) ) { return { status: "failed", @@ -239,16 +242,33 @@ export async function loginFlow( if (options.browser === false) { output.write("Open this URL in your browser:\n"); output.write(` ${authUrl}\n`); + output.write( + `The callback listener is on 127.0.0.1:${port} on the machine running GitHits.\n`, + ); + output.write( + "If the browser is on another computer and you connected with SSH, run this on that computer first:\n", + ); + output.write(` ssh -N -L ${port}:127.0.0.1:${port} user@remote-host\n`); + output.write( + "Keep the tunnel and GitHits running while you open the URL. Replace user@remote-host with your SSH destination.\n", + ); } else { output.write("Opening browser for GitHits sign-in...\n"); + let browserOpenFailed = false; try { await browserService.open(authUrl); } catch (error) { + browserOpenFailed = true; const msg = error instanceof Error ? error.message : String(error); output.write(`Could not open browser automatically: ${msg}\n`); } output.write("If the browser did not open, open this URL:\n"); output.write(` ${authUrl}\n`); + if (browserOpenFailed) { + output.write( + `If the browser is on another computer, stop this command and rerun with --no-browser --port ${port} for SSH forwarding instructions.\n`, + ); + } } output.write("Waiting for sign-in to finish...\n"); @@ -466,8 +486,10 @@ OAuth credentials are stored in the system keychain by default. If your machine has no usable keychain, use GITHITS_API_TOKEN or explicitly configure auth.storage = "file". File storage is plaintext on disk. -Use --no-browser in environments without a display (CI, SSH sessions) -to get a URL you can open on another device.`; +Use --no-browser to print the sign-in URL instead of launching a browser. +The callback listener still runs on this machine. When the browser is on a +different computer, choose a fixed --port and forward that port over SSH. +For non-interactive automation, use GITHITS_API_TOKEN instead.`; /** * Register the login command on the given program. @@ -478,8 +500,15 @@ export function registerLoginCommand(program: Command) { .command("login") .summary("Sign in to your GitHits account") .description(LOGIN_DESCRIPTION) - .option("--no-browser", "Print URL instead of opening browser") - .option("--port ", "Port for local callback server", parseInt) + .option( + "--no-browser", + "Print URL and remote callback instructions instead of opening browser", + ) + .option( + "--port ", + "Port for local callback server", + parsePortCliOption, + ) .option("--force", "Re-authenticate even if already logged in") .action(async (options: LoginOptions) => { const deps = await createAuthCommandDependencies(); diff --git a/src/shared/cli-options.test.ts b/src/shared/cli-options.test.ts new file mode 100644 index 00000000..25630c5c --- /dev/null +++ b/src/shared/cli-options.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; +import { InvalidArgumentError } from "commander"; +import { parsePortCliOption } from "./cli-options.js"; + +describe("parsePortCliOption", () => { + for (const [raw, expected] of [ + ["1", 1], + ["8765", 8765], + ["65535", 65535], + [" 8080 ", 8080], + ] as const) { + it(`parses ${JSON.stringify(raw)}`, () => { + expect(parsePortCliOption(raw)).toBe(expected); + }); + } + + for (const raw of [ + "", + "0", + "-1", + "65536", + "1.5", + "8080junk", + "1e3", + "NaN", + "Infinity", + ]) { + it(`rejects ${JSON.stringify(raw)}`, () => { + expect(() => parsePortCliOption(raw)).toThrow(InvalidArgumentError); + expect(() => parsePortCliOption(raw)).toThrow( + "Port must be an integer between 1 and 65535.", + ); + }); + } +}); diff --git a/src/shared/cli-options.ts b/src/shared/cli-options.ts index f4908a49..1e62f3b4 100644 --- a/src/shared/cli-options.ts +++ b/src/shared/cli-options.ts @@ -1,4 +1,8 @@ import { InvalidPackageSpecError } from "@githits/mcp/internal"; +import { InvalidArgumentError } from "commander"; + +const MIN_PORT = 1; +const MAX_PORT = 65535; /** * Parse an optional CLI integer string exactly. `parseInt` is deliberately @@ -24,3 +28,21 @@ export function parseIntCliOption( } return parsed; } + +/** Parse a callback-server port without accepting partial numeric strings. */ +export function parsePortCliOption(raw: string): number { + const normalized = raw.trim(); + if (!/^\d+$/.test(normalized)) { + throw new InvalidArgumentError( + `Port must be an integer between ${MIN_PORT} and ${MAX_PORT}.`, + ); + } + + const port = Number(normalized); + if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) { + throw new InvalidArgumentError( + `Port must be an integer between ${MIN_PORT} and ${MAX_PORT}.`, + ); + } + return port; +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 20dde863..30861d05 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -196,7 +196,7 @@ export { vulnSeverityLabel, warning, } from "@githits/mcp/internal"; -export { parseIntCliOption } from "./cli-options.js"; +export { parseIntCliOption, parsePortCliOption } from "./cli-options.js"; export { InvalidKeywordsError, normaliseKeywords,