Skip to content
Merged
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
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,16 +163,31 @@ 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>` fixes the loopback 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:
The OAuth callback always listens on the machine where GitHits is running.
When GitHits runs over SSH and the browser runs locally, forward the selected
port from the browser machine:

```sh
export GITHITS_API_TOKEN=ghi-your-token-here
ssh -N -L 8765:127.0.0.1:8765 user@remote-host
```

With that tunnel open, run GitHits on the remote machine using the same port:

```sh
npx githits@latest init --no-browser --port 8765
```

Open the URL printed by GitHits in the local browser. Replace
`user@remote-host` with the SSH destination you normally use. The same flags
work with `githits login` after setup.

Browser OAuth is interactive. For CI and other unattended environments, supply
`GITHITS_API_TOKEN` through the environment's secret manager.

Inspect auth and runtime state with:

```sh
Expand Down
35 changes: 33 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 using a loopback callback on the machine running GitHits |
| `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 # Fix the callback port for an SSH tunnel
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,36 @@ 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` suppresses browser launching but leaves the OAuth callback on the GitHits machine. `--port` selects that loopback callback port so an SSH tunnel can be prepared before authentication 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 authentication over SSH

The callback listener binds to `127.0.0.1` on the host running GitHits. A
browser on a different host cannot reach that listener through its own
loopback address. Use the same fixed port on both sides of an SSH local
forward.

On the computer that has the browser:

```sh
ssh -N -L 8765:127.0.0.1:8765 user@remote-host
```

On the remote GitHits host:

```sh
githits init --no-browser --port 8765
# or, after setup:
githits login --no-browser --port 8765
```

Keep the tunnel open until sign-in completes. Without an explicit port, the
login flow preserves its existing stored-client or random-port behavior and
prints forwarding instructions for the selected port. CLI port values are
strict integers from 1 to 65535.

This callback flow requires a person to complete sign-in. CI and other
unattended workloads should use `GITHITS_API_TOKEN` from a secret manager.

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
41 changes: 41 additions & 0 deletions docs/implementation/remote-authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Remote Browser Authentication

## CLI behavior

GitHits authenticates native CLI sessions with an OAuth authorization-code
flow and PKCE. Its temporary callback server is bound to `127.0.0.1` on the
host running the CLI.

The `init` and `login` commands share one browser-auth option registration and
one login flow:

- `--no-browser` prevents automatic browser launching.
- `--port <port>` selects the loopback callback port.
- invalid CLI and programmatic port values are rejected by shared validation;
- manual-browser output identifies the listener and prints an SSH
local-forward command using the effective port.

When no port is supplied, the login flow continues to reuse a stored client's
redirect URI or select a port in its existing random range.

## Documentation handoff

Hosted authentication and command-reference content should distinguish three
cases:

1. A browser on the GitHits host can use the loopback callback directly.
2. A browser on another computer needs an SSH local forward and the same
explicit port on `init` or `login`.
3. An unattended process should authenticate with `GITHITS_API_TOKEN`, not an
interactive callback.

The README and CLI command reference in this repository provide the current
commands and wording to carry into hosted documentation.

## Future tunnel-free flow

Avoiding SSH forwarding requires an OAuth server flow designed for devices
without a local browser. The standards-based direction is the OAuth 2.0 Device
Authorization Grant (RFC 8628). That work requires authorization-server
endpoints, verification UI, expiring device/user codes, polling semantics, and
abuse controls before the CLI can support it.
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 the callback port option`,
);
assert(
commandHelp.stdout.includes("--no-browser"),
`${command} help should expose the manual browser option`,
);

const invalidPort = await runCliWithEnv(
[command, "--port", "8765extra"],
env,
);
assert(
invalidPort.exitCode !== 0,
`${command} should reject a partially numeric callback port`,
);
assert(
`${invalidPort.stderr}\n${invalidPort.stdout}`.includes(
"Port must be an integer between 1 and 65535.",
),
`${command} should explain the valid callback port 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: 12 additions & 6 deletions src/commands/init/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import type {
LoginOutput,
} from "../login.js";
import { loginFlow } from "../login.js";
import {
addOAuthCallbackOptions,
type OAuthCallbackOptions,
} from "../oauth-callback-options.js";
import {
type AgentDefinition,
agentDefinitions,
Expand Down Expand Up @@ -79,13 +83,11 @@ import {
} from "./setup-handlers.js";

/** Options for the init command */
export interface InitOptions {
export interface InitOptions extends OAuthCallbackOptions {
/** Skip all prompts, configure all detected agents */
yes?: boolean;
/** Skip the login step */
skipLogin?: boolean;
/** Print the login URL instead of opening a browser */
browser?: boolean;
/** Scan supported agents without installing anything */
detectAgents?: boolean;
/** Comma-separated agent IDs to install non-interactively */
Expand Down Expand Up @@ -2003,7 +2005,10 @@ async function runInitAuthentication(
}
}

const loginOptions = options.browser === false ? { browser: false } : {};
const loginOptions: OAuthCallbackOptions = {
...(options.browser !== undefined ? { browser: options.browser } : {}),
...(options.port !== undefined ? { port: options.port } : {}),
};
loginResult = await loginFlow(
loginOptions,
loginDeps,
Expand Down Expand Up @@ -4013,8 +4018,9 @@ export function registerInitCommand(program: Command) {
.summary("Connect GitHits to your coding agents")
.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("--skip-login", "Skip authentication step");

addOAuthCallbackOptions(initCommand)
.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
47 changes: 34 additions & 13 deletions src/commands/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 sign-in callback is listening on 127.0.0.1:8080 on this machine.",
);
expect(output).toContain("ssh -N -L 8080:127.0.0.1:8080 user@remote-host");
consoleSpy.mockRestore();
});

Expand Down Expand Up @@ -905,20 +912,34 @@ 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,
65_536,
Number.NaN,
Number.POSITIVE_INFINITY,
]) {
it(`returns failed before authentication for programmatic port ${port}`, async () => {
const authStorage = createMockAuthStorage();
const authService = createMockAuthService();
const result = await loginFlow(
{ port },
{
authService,
authStorage,
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(authStorage.loadTokens).not.toHaveBeenCalled();
expect(authService.discoverEndpoints).not.toHaveBeenCalled();
});
}

it("returns failed when token exchange throws", async () => {
const consoleSpy = spyOn(console, "log").mockImplementation(() => {});
Expand Down
Loading