Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <port>` or `login --port <port>` selects the local callback port
- `login --force` re-authenticates even if you are already logged in
- `login --port <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
Expand Down
49 changes: 47 additions & 2 deletions docs/implementation/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ids>`, `--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 <port>`, `--project`, `--detect-agents`, `--install-agents <ids>`, `--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 <port>`, `--force` | Authenticate with browser OAuth; `--no-browser` suppresses browser launching but does not move the loopback callback listener |
| `example <query>` | `<query>` | `-l, --lang <language>`, `--license <mode>`, `--explain`, `--json` | Search for code examples |
| `search <query>` | `--in <target>` | `--source <source>`, `--kind <kind>`, `--category <category>`, `--path-prefix <prefix>`, `--intent <intent>`, `--public`, `--name <name>`, `--lang <language>`, `--allow-partial`, `--limit <n>`, `--offset <n>`, `--wait <seconds>`, `--json` | Unified indexed search across dependency/repository code, docs, and symbols. Defaults to 10 results. |
| `search-status <search-ref>` | `<search-ref>` | `--json` | Check progress, fetch partial hits, or fetch final results for a prior unified search |
Expand All @@ -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
Expand All @@ -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.

Expand Down
47 changes: 47 additions & 0 deletions docs/implementation/remote-authentication.md
Original file line number Diff line number Diff line change
@@ -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 <port>`. The
user can prepare SSH local forwarding for that port, start GitHits remotely
with `--no-browser --port <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.
28 changes: 28 additions & 0 deletions scripts/cli-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,34 @@ async function assertUnauthenticatedBehavior(): Promise<void> {
);
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 <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(
Expand Down
15 changes: 13 additions & 2 deletions src/commands/init/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3316,20 +3316,21 @@ 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",
}),
);

await initAction(
{ browser: false },
{ browser: false, port: 8765 },
{
fileSystemService: fs,
promptService,
Expand All @@ -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);
Expand All @@ -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();
});

Expand Down Expand Up @@ -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", () => {
Expand Down
18 changes: 16 additions & 2 deletions src/commands/init/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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>",
"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")
Expand Down
Loading