From 43b4679e8b93bc7f76721c4d7496c353d3f8ec90 Mon Sep 17 00:00:00 2001 From: Olli-Pekka Heinisuo Date: Fri, 24 Apr 2026 13:41:37 +0300 Subject: [PATCH 1/9] feat: streamline signup flow Add interactive auto-login bootstrap for example, languages, and feedback. Refactor login output to support stderr-safe JSON flows and fix the build script on Windows. --- README.md | 28 +++- docs/implementation/auth.md | 45 ++++++- docs/implementation/cli-commands.md | 47 +++++++ docs/implementation/signup-flow.md | 163 ++++++++++++++++++++++ package.json | 2 +- src/cli.ts | 40 +++--- src/commands/login.test.ts | 46 ++++++- src/commands/login.ts | 41 ++++-- src/shared/auto-login.test.ts | 201 ++++++++++++++++++++++++++++ src/shared/auto-login.ts | 102 ++++++++++++++ 10 files changed, 686 insertions(+), 29 deletions(-) create mode 100644 docs/implementation/signup-flow.md create mode 100644 src/shared/auto-login.test.ts create mode 100644 src/shared/auto-login.ts diff --git a/README.md b/README.md index bd9f27dd..3b5922de 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,16 @@ Supported tools: Claude Code, Cursor, Windsurf, VS Code / Copilot, Cline, Claude If you are using a tool that is not listed above, use the manual MCP setup instructions near the end of this README. +For a one-shot first run without installing the CLI globally: + +```sh +npx -y githits@latest example "how to use express middleware" -l javascript +``` + +In an interactive terminal, `example`, `languages`, and `feedback` now open +the browser login flow automatically when no valid token is available, then +continue the original command after authentication. + ### Plugin Installation (Open Plugin standard) The npm package includes Open Plugin-compatible files: @@ -134,6 +144,12 @@ npx githits login Opens your browser for secure OAuth authentication. Tokens are stored in the system keychain by default and refreshed automatically on next use. If a refresh fails (e.g., after an extended idle period), run `githits login` again. +In interactive TTY sessions, `example`, `languages`, and `feedback` also +trigger this browser flow automatically on first use. For interactive +`--json` runs, login progress is written to stderr so stdout stays +machine-readable. Non-interactive runs, `auth status`, `logout`, `init`, and +`mcp` remain explicit-auth flows. + Useful flags: - `--no-browser` — prints a URL instead of opening a browser (for SSH sessions, CI, or headless environments) @@ -174,7 +190,17 @@ githits languages List or filter supported language names githits feedback Send feedback on a returned example ``` -These indexed package/source commands are also available: +Interactive first-run UX: + +- `githits example ...`, `githits languages ...`, and `githits feedback ...` + will open the browser login flow automatically when no valid token is + available. +- Interactive `--json` variants also auto-open the browser when needed, but + login progress is written to stderr so stdout remains clean JSON. +- Non-interactive runs still require `githits login` first or a + `GITHITS_API_TOKEN`. + +When package/source access is enabled for the current token, two extra command groups are also available: ``` githits search ... Unified indexed dependency/repository search diff --git a/docs/implementation/auth.md b/docs/implementation/auth.md index 2487e7d1..f60c0032 100644 --- a/docs/implementation/auth.md +++ b/docs/implementation/auth.md @@ -35,6 +35,31 @@ The login command (`src/commands/login.ts`) orchestrates a 9-step OAuth flow (ma The flow has a 5-minute timeout. The callback server must start before the browser opens so it's ready to receive the redirect. +### Automatic login bootstrap for interactive CLI commands + +Phase 1 of the streamlined signup flow adds a CLI-boundary bootstrap in +`src/cli.ts` for a small allowlist of interactive commands: + +- `githits example ...` +- `githits languages ...` +- `githits feedback ...` + +When one of those commands runs in an interactive TTY and no valid token is +available, the CLI calls the existing `loginFlow()` from `src/commands/login.ts` +before dispatching the command action. After authentication succeeds, the +original command continues and builds a fresh container with the newly saved +tokens. + +The bootstrap deliberately does **not** run for: + +- non-interactive/stdio-driven execution +- explicit auth and recovery surfaces such as `login`, `logout`, `auth status`, + `init`, and `mcp` + +For interactive `--json` invocations, the same bootstrap runs, but login +progress is written to stderr so the command's JSON payload can remain the only +stdout output. + ## Token Lifecycle Tokens are JWTs with a configurable expiration (typically 1 hour). The CLI handles expiration through a `TokenManager` (see `src/services/token-manager.ts`): @@ -167,9 +192,25 @@ Per API call (via RefreshingGitHitsService): The MCP server starts without a synchronous auth gate. Tool calls resolve tokens through the shared token provider and return per-tool auth errors when no valid token is available. +For `example`, `languages`, and `feedback`, there is now one extra step before +the action runs: + +``` +CLI preAction hook + └─ eligible interactive command? + ├─ no → run command normally + └─ yes → createContainer() + ├─ valid token available? → run command normally + └─ no valid token → loginFlow() + ├─ success → continue into the original command action + └─ failure → print login error and exit 1 +``` + ## Troubleshooting - **"Authentication required" from a command or MCP tool** — No valid token found. Run `githits login` or set `GITHITS_API_TOKEN`. +- **Browser did not open for a piped or redirected invocation** — Expected. Auto-login bootstrap still requires an interactive TTY. Authenticate first with `githits login` or use `GITHITS_API_TOKEN`. +- **Interactive `--json` printed login progress** — That progress should go to stderr only. If stdout contains login chatter, the login reporter wiring in `src/commands/login.ts` / `src/cli.ts` has regressed. - **"Already logged in."** — Token is still valid. Use `githits login --force` to re-authenticate. - **Port conflicts on login** — The callback server uses the port from the stored client registration. On first login, a random port (8000–9999) is chosen and saved. Use `--port ` to change it (triggers re-registration). - **Token refresh fails silently** — By design. The token manager first reloads storage in case another process refreshed credentials. If the expired token is still unchanged, it clears that stale token and later calls prompt re-login. @@ -182,8 +223,10 @@ The MCP server starts without a synchronous auth gate. Tool calls resolve tokens | File | What it demonstrates | |---|---| | `src/commands/login.ts` | Full OAuth PKCE flow orchestration | +| `src/cli.ts` | CLI pre-action bootstrap for phase-1 automatic login | | `src/commands/logout.ts` | Token and client removal and storage cleanup | -| `src/container.ts` | Dependency wiring and auth-command container without eager token refresh | +| `src/shared/auto-login.ts` | Command allowlist and auto-login decision logic | +| `src/container.ts` | Dependency wiring, keychain probe with fallback, and auth-command container without eager token refresh | | `src/services/token-manager.ts` | `TokenProvider` interface, `TokenManager` (proactive refresh, coalescing) | | `src/services/refreshing-githits-service.ts` | `GitHitsService` decorator with token refresh and 401 retry | | `src/services/execute-with-token-refresh.ts` | Shared helper for token-authenticated retry-on-refresh flows | diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 5b537d75..42e7e0ea 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -4,6 +4,14 @@ The CLI exposes `example`, `languages`, `feedback`, top-level indexed `search` / `search-status`, and the `code`, `docs`, and `pkg` command groups by default. All of these commands share business logic with the MCP tools through the same service interfaces and shared utilities, but format output for terminal consumption instead of MCP tool results. +Phase 1 of the streamlined signup flow adds automatic browser login bootstrap to +three interactive top-level commands: `example`, `languages`, and `feedback`. +When those commands run in an interactive TTY without a valid token, the CLI +launches the existing OAuth login flow first, then continues the original +command after auth succeeds. For interactive `--json` runs, login progress is +written to stderr so stdout stays machine-readable. The bootstrap remains +disabled for non-interactive execution. + ## Commands | Command | Required Args | Options | Description | @@ -52,6 +60,11 @@ githits example "react hooks patterns" -l typescript --json Default output is markdown (the API response). `--lang` is optional; when omitted, the backend infers the language from the query. With `--explain`, an AI-generated explanation is included alongside the code example. With `--json`, output is `{ "result": "", "solution_id": "" }` (`solution_id` is omitted only if the markdown lacks a solution URL — pass it back to `feedback`). The MCP `get_example` tool always sends `include_explanation: false` since LLMs don't need the extra context. +When run interactively without a valid token, `githits example` now triggers +the browser login flow automatically before performing the search. On +interactive `--json` runs, login progress goes to stderr so stdout remains the +JSON payload. + ### `githits search` ``` @@ -103,6 +116,9 @@ githits languages type --json # JSON output for piping Without a query, lists all languages. With a query, filters to top 5 matches using the same logic as the `search_language` MCP tool (case-insensitive substring match on name, display_name, and aliases). Default output uses colored terminal formatting. JSON output is `[{ "name": "...", "display_name": "...", "aliases": [...] }, ...]`. +Interactive runs without a valid token auto-trigger browser login before the +language lookup. On interactive `--json` runs, login progress goes to stderr. + ### `githits feedback` ``` @@ -113,6 +129,37 @@ githits feedback abc123 --accept --message "Solved my problem" --json `--accept` and `--reject` are mutually exclusive (enforced by Commander's `.conflicts()` API). At least one must be provided (validated in the action function). JSON output is `{ "success": true, "message": "..." }`. +Interactive runs without a valid token auto-trigger browser login before the +feedback submission. On interactive `--json` runs, login progress goes to +stderr. + +### `githits code search` + +This is now the older symbol-search surface. Prefer top-level `githits search --source symbol` for new flows unless you specifically need the legacy code-search UX or its exact JSON contract. + +``` +githits code search npm:express middleware +githits code search npm:express middleware --intent all +githits code search pypi:requests timeout --category callable --limit 10 +githits code search crates:serde Serialize --kind trait --limit 5 +githits code search npm:@types/node Buffer --file src/ --json +githits code search npm:express --keywords "router,handler" --match-mode and +``` + +Finds functions, classes, modules, and doc sections inside an indexed dependency by exact-token matches. Top-level `githits search --source symbol` is the preferred unified surface for symbol-shaped search. `code search` remains available for the older dedicated symbol-search UX and parity contract. + +**Package spec.** `:[@]`. Omit the registry to default to `npm`. Supported registries: `npm`, `pypi`, `hex`, `crates`, `nuget`, `maven`, `zig`, `vcpkg`, `packagist`. Scoped npm names are supported (`npm:@types/node`). + +**Filtering by symbol shape.** Prefer `--category` for broad filtering (`callable`, `type`, `module`, `data`, `documentation`) — it works across the full 27-value kind taxonomy without enumerating individual kinds. Reach for `--kind` when you want a specific construct, e.g. `--kind trait` (Rust) or `--kind namespace` (C#/C++/PHP). + +**Defaults.** `--intent production` filters to production source by default so top results are not dominated by tests, benchmarks, or examples. Use `--intent all` to include every file intent. `--wait` defaults to 20 seconds (above the p50 indexing time of ~11 s); first-time queries against an unindexed package may need `--wait 60` (the backend ceiling) to block until indexing completes. On an INDEXING error, the response message points out the retry options. + +**Output.** Default terminal output leads each entry with `path:startLine-endLine [kind]`, followed by the symbol name and a 3-line dedented snippet. `--json` emits the shared success/error envelope also produced by the MCP `search_symbols` tool — see [`mcp-cli-parity.md`](./mcp-cli-parity.md) for the wire contract. The command is registered as `code search` with `code search-symbols` as a Commander alias. + +**Capability gate.** The `code` group is registered only when package/source access is available for the current session, when `GITHITS_CODE_NAVIGATION=1` is set for local override, or when stored auth is expired and the CLI cannot reliably pre-classify access. + +**Troubleshooting.** Set `GITHITS_DEBUG=code-nav` to emit single-line JSON diagnostics to stderr on error paths. Include the output when filing an issue. Debug payloads never contain query text, tokens, or response bodies. + ### `githits pkg info` ``` diff --git a/docs/implementation/signup-flow.md b/docs/implementation/signup-flow.md new file mode 100644 index 00000000..d2c7c2d2 --- /dev/null +++ b/docs/implementation/signup-flow.md @@ -0,0 +1,163 @@ +# Streamlined Signup Flow + +## Purpose + +This document captures the implementation checklist for a smoother first-run +authentication experience in the CLI. The goal is to let an unauthenticated +user invoke an auth-required command, complete browser sign-in or sign-up, and +then continue the original command without re-running it manually. + +## Background + +The underlying OAuth pieces already exist: + +- `src/commands/login.ts` orchestrates the PKCE + browser flow. +- `src/services/auth-service.ts` serves the callback success page and exchanges + the auth code for tokens. +- `src/container.ts` resolves token state at startup. +- `src/shared/require-auth.ts` currently blocks individual command actions when + no valid token is present. + +Today, auth-required commands fail fast and tell the user to run `githits +login`. That works, but it adds friction to first-run adoption and makes the +recommended one-shot entry point less compelling. + +## Target Flow + +1. User runs an auth-required interactive command such as + `npx -y githits@latest example "..." -l typescript`. +2. CLI detects that no valid token is available. +3. CLI opens the browser to the sign-in / sign-up page. +4. User completes the GitHub-backed auth flow. +5. Browser shows the authenticated success page. +6. CLI resumes and executes the originally requested command. + +## Scope Boundaries + +The feature should not be phrased as "every command except help". Some commands +are informational, recovery-oriented, or host-driven, and auto-login would be +surprising or actively harmful. + +### Commands that should stay exempt + +| Command surface | Why exempt | +|---|---| +| `help`, `--help`, `-h` | Never trigger side effects for help output. | +| `login` | Already the explicit auth entry point. | +| `logout` | Must work even when auth is broken or absent. | +| `auth status` | Informational; should explain missing auth, not launch browser. | +| `init` | Already has its own login orchestration and fallback prompts. | +| `mcp` / `mcp start` | Agent hosts and stdio launches should not unexpectedly open a browser. | + +### Phase 1 candidates + +These are always-available, human-invoked commands where auto-login is a clear +UX improvement: + +- `example` +- `languages` +- `feedback` + +### Phase 2 candidates + +These need extra work because they are not always registered when no auth state +is available at startup: + +- `search` +- `search-status` +- `code ...` +- `pkg ...` + +## Decision Criteria + +Use these rules when implementing the feature: + +- Keep `requireAuth()` as the final action-level invariant even after adding + auto-login at the CLI boundary. +- Do not corrupt machine-readable output. Commands using `--json` or piping + must not mix login progress logs into stdout. +- Avoid launching the browser from non-interactive or host-driven contexts. +- Reuse `loginFlow()` rather than duplicating OAuth orchestration. +- Keep startup registration behavior explicit for capability-gated command + groups. + +## Implementation Checklist + +### Phase 1: interactive auto-login bootstrap + +- [x] Add a shared auth-bootstrap helper that checks current auth state and, + when appropriate, runs `loginFlow()` before dispatching the command. +- [x] Put the bootstrap decision at the CLI boundary in `src/cli.ts` rather + than duplicating it in every command handler. +- [x] Add an explicit command policy helper that identifies exempt commands and + auto-login-eligible commands. +- [x] Gate bootstrap on interactivity: no browser launch for non-TTY execution. +- [x] Keep automatic login progress off stdout when the invoked command + requests `--json`. +- [x] Keep the current `requireAuth()` checks in command actions as a final + invariant. +- [x] Wire the new bootstrap flow for `example`, `languages`, and `feedback`. + +### Phase 1.1: login output hygiene + +- [x] Refactor `src/commands/login.ts` so login progress can be reported via a + small output interface instead of always writing directly to stdout. +- [x] Ensure automatic login can either write to stderr or stay quiet when the + original command expects pipe-friendly stdout. +- [x] Preserve the current human-friendly standalone `githits login` output. + +### Phase 1.2: docs and product guidance + +- [x] Update `README.md` to show the promoted one-shot entry point: + `npx -y githits@latest example "..." -l `. +- [x] Update `src/cli.ts` help text if the product wants the one-shot example + flow visible in `githits --help`. +- [x] Update `docs/implementation/auth.md` to describe the auto-login + bootstrap path and its exemptions. +- [x] Update `docs/implementation/cli-commands.md` to explain which commands + auto-trigger login and which do not. + +### Phase 1.3: tests + +- [ ] Add CLI-level tests for exempt commands never triggering auto-login. +- [ ] Add CLI-level tests for eligible commands triggering login when no valid + token is available. +- [ ] Add tests for login failure preserving a clear error path. +- [ ] Add tests proving `--json` commands do not receive login chatter on + stdout. +- [ ] Keep existing command-level `AuthRequiredError` tests to verify the final + invariant still holds. + +### Phase 2: capability-gated command registration + +- [ ] Decide whether `search`, `code`, and `pkg` should be visible on a fresh + unauthenticated machine purely to allow auto-login on first use. +- [ ] If yes, relax the startup registration gates in `src/commands/search.ts`, + `src/commands/code/index.ts`, and `src/commands/pkg/index.ts` so the + command surfaces can parse before auth exists. +- [ ] Re-check help output and discoverability once those command groups are + visible without a token. +- [ ] Add tests covering first-run unauthenticated registration behavior. + +## Open Decisions + +- Should `mcp start` remain strictly manual-auth, or should there be a separate + device-code or non-browser bootstrap for host-driven setups? +- Should the promoted example command keep the explicit `-l/--lang` flag, or do + we want a separate feature for language inference/defaulting? + +## Key Reference Files + +| File | Why it matters | +|---|---| +| `src/cli.ts` | Best hook point for a single auto-login bootstrap policy. | +| `src/commands/login.ts` | Reusable login flow and current standalone login output. | +| `src/shared/require-auth.ts` | Final invariant for command actions after bootstrap. | +| `src/container.ts` | Startup token resolution and auth-state snapshot. | +| `src/commands/init/init.ts` | Existing example of composing `loginFlow()` into another command. | +| `src/commands/search.ts` | Top-level gated command registration. | +| `src/commands/code/index.ts` | `code` group registration gate. | +| `src/commands/pkg/index.ts` | `pkg` group registration gate. | +| `src/commands/example.ts` | Phase 1 command target. | +| `src/commands/languages.ts` | Phase 1 command target. | +| `src/commands/feedback.ts` | Phase 1 command target. | \ No newline at end of file diff --git a/package.json b/package.json index a98f9453..39fe428f 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "githits": "dist/cli.js" }, "scripts": { - "build": "bunup --dts --target node --packages=external --exports && chmod +x dist/cli.js", + "build": "bunup --dts --target node --packages=external --exports && node --input-type=module -e \"import { chmodSync } from 'node:fs'; if (process.platform !== 'win32') chmodSync('dist/cli.js', 0o755);\"", "dev": "bun run ./src/cli.ts", "inspector": "npx @modelcontextprotocol/inspector bun run dev mcp", "smoke:cli": "bun run scripts/cli-smoke.ts", diff --git a/src/cli.ts b/src/cli.ts index 07c60f83..7f137708 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,6 +22,12 @@ import { registerPkgCommandGroup, registerUnifiedSearchCommands, } from "./commands/index.js"; +import { loginFlow, stderrLoginOutput } from "./commands/login.js"; +import { createContainer } from "./container.js"; +import { + getCommandPath, + maybeAutoLoginBeforeCommand, +} from "./shared/auto-login.js"; import { FileSystemServiceImpl, NpmRegistryUpdateCheckService, @@ -79,7 +85,7 @@ program .description("Code examples from global open source for your AI assistant") .version(version) .option("--no-color", "Disable colored output") - .hook("preAction", (thisCommand, actionCommand) => { + .hook("preAction", async (thisCommand, actionCommand) => { if (thisCommand.opts().color === false) { process.env.NO_COLOR = "1"; } @@ -89,6 +95,18 @@ program command, startTelemetrySpan(getTelemetryCommandName(command)), ); + + const authResult = await maybeAutoLoginBeforeCommand(command, { + createContainer, + loginFlow: (options, deps) => loginFlow(options, deps, stderrLoginOutput), + }); + if (authResult.status !== "failed") { + return; + } + + console.error(`${authResult.message}\n`); + console.error("Run `githits login` to try again."); + process.exit(1); }) .hook("postAction", (_thisCommand, actionCommand) => { endTelemetrySpan(commandSpans.get(actionCommand)); @@ -97,10 +115,11 @@ program "after", ` Getting started: - githits init Set up MCP for your coding agents - githits login Authenticate with your GitHits account - githits mcp Show MCP setup instructions - githits example "query" Get code examples + githits init Set up MCP for your coding agents + githits login Authenticate with your GitHits account + githits mcp Start MCP server for your AI assistant + githits search "router middleware" --in npm:express Search dependency code/docs + npx -y githits@latest example "query" --lang python One-shot example search with browser login Learn more at https://githits.com Docs: https://app.githits.com/docs/ @@ -211,16 +230,7 @@ function isSearchHelpTarget(value: string | undefined): boolean { } function getTelemetryCommandName(command: Command): string { - const names: string[] = []; - let current: Command | null = command; - - while (current) { - const name = current.name(); - if (name && name !== "githits") { - names.unshift(name); - } - current = current.parent ?? null; - } + const names = getCommandPath(command); return `command.${names.join(".")}`; } diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index 6d5c63da..11f24236 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -5,7 +5,7 @@ import { createMockBrowserService, createValidTokenData, } from "../services/test-helpers.js"; -import { loginAction, loginFlow } from "./login.js"; +import { loginAction, loginFlow, silentLoginOutput } from "./login.js"; describe("loginAction", () => { const mcpUrl = "https://mcp.githits.com"; @@ -411,6 +411,32 @@ describe("loginFlow", () => { consoleSpy.mockRestore(); }); + it("routes progress through the provided reporter instead of stdout", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + const writes: string[] = []; + + const result = await loginFlow( + { port: 8080 }, + { + authService: createMockAuthService(), + authStorage: createMockAuthStorage(), + browserService: createMockBrowserService(), + mcpUrl, + }, + { + write: (message: string) => { + writes.push(message); + }, + }, + ); + + expect(result.status).toBe("success"); + expect(writes).toContain("Discovering OAuth endpoints..."); + expect(writes).toContain("Opening browser..."); + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + it("returns already_authenticated when valid tokens exist", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); @@ -436,6 +462,24 @@ describe("loginFlow", () => { consoleSpy.mockRestore(); }); + it("stays quiet when the silent reporter is used", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await loginFlow( + { port: 8080 }, + { + authService: createMockAuthService(), + authStorage: createMockAuthStorage(), + browserService: createMockBrowserService(), + mcpUrl, + }, + silentLoginOutput, + ); + + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + it("returns failed on invalid port", async () => { const result = await loginFlow( { port: -1 }, diff --git a/src/commands/login.ts b/src/commands/login.ts index 693afc8e..41d9a8b7 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -18,6 +18,26 @@ export interface LoginFlowResult { message: string; } +export interface LoginOutput { + write(message: string): void; +} + +export const stdoutLoginOutput: LoginOutput = { + write: (message: string) => { + console.log(message); + }, +}; + +export const stderrLoginOutput: LoginOutput = { + write: (message: string) => { + console.error(message); + }, +}; + +export const silentLoginOutput: LoginOutput = { + write: () => {}, +}; + const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes function randomPort(): number { @@ -69,6 +89,7 @@ async function preflightAuthPersistence( export async function loginFlow( options: LoginOptions, deps: LoginDependencies, + output: LoginOutput = stdoutLoginOutput, ): Promise { const { authService, authStorage, browserService, mcpUrl } = deps; @@ -91,9 +112,9 @@ export async function loginFlow( if (!isExpired) { return { status: "already_authenticated", message: "Already logged in." }; } - console.log("Token expired. Starting new login...\n"); + output.write("Token expired. Starting new login...\n"); } else if (existing && options.force) { - console.log("Re-authenticating (--force flag)...\n"); + output.write("Re-authenticating (--force flag)...\n"); } // If tokens were cleared (expired+refreshFailed or never existed) but a stale @@ -106,7 +127,7 @@ export async function loginFlow( if (persistenceError) return persistenceError; // Step 1: Discover OAuth endpoints - console.log("Discovering OAuth endpoints..."); + output.write("Discovering OAuth endpoints..."); const metadata = await authService.discoverEndpoints(mcpUrl); // Step 2: Load or register client via DCR @@ -121,7 +142,7 @@ export async function loginFlow( redirectUri = `http://127.0.0.1:${options.port}/callback`; if (redirectUri !== client.redirectUri) { // Port changed - need to re-register - console.log("Registering CLI client with new port..."); + output.write("Registering CLI client with new port..."); const registration = await authService.registerClient({ registrationEndpoint: metadata.registrationEndpoint, redirectUri, @@ -143,7 +164,7 @@ export async function loginFlow( } else { port = options.port ?? randomPort(); redirectUri = `http://127.0.0.1:${port}/callback`; - console.log("Registering CLI client..."); + output.write("Registering CLI client..."); const registration = await authService.registerClient({ registrationEndpoint: metadata.registrationEndpoint, redirectUri, @@ -173,14 +194,14 @@ export async function loginFlow( // Step 6: Open browser or show URL if (options.browser === false) { - console.log("Open this URL in your browser:\n"); - console.log(` ${authUrl}\n`); + output.write("Open this URL in your browser:\n"); + output.write(` ${authUrl}\n`); } else { - console.log("Opening browser..."); + output.write("Opening browser..."); await browserService.open(authUrl); } - console.log("Waiting for authentication...\n"); + output.write("Waiting for authentication...\n"); // Wait for callback with timeout let timeoutId: ReturnType | undefined; @@ -266,7 +287,7 @@ export async function loginAction( options: LoginOptions, deps: LoginDependencies, ): Promise { - const result = await loginFlow(options, deps); + const result = await loginFlow(options, deps, stdoutLoginOutput); if (result.status === "already_authenticated") { console.log("Already logged in.\n"); diff --git a/src/shared/auto-login.test.ts b/src/shared/auto-login.test.ts new file mode 100644 index 00000000..d219f116 --- /dev/null +++ b/src/shared/auto-login.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it, mock } from "bun:test"; +import type { LoginDependencies } from "../commands/login.js"; +import { + createMockAuthService, + createMockAuthStorage, + createMockBrowserService, +} from "../services/test-helpers.js"; +import { + type CommandLike, + getCommandPath, + isAutoLoginEligibleCommand, + maybeAutoLoginBeforeCommand, +} from "./auto-login.js"; + +function createCommand( + path: string[], + options: Record = {}, +): CommandLike { + let current: CommandLike = { + name: () => "githits", + opts: () => ({}), + parent: null, + }; + + for (let index = 0; index < path.length; index += 1) { + const segment = path[index]!; + const isLeaf = index === path.length - 1; + const parent = current; + current = { + name: () => segment, + opts: () => (isLeaf ? options : {}), + parent, + }; + } + + return current; +} + +function createLoginDeps( + overrides: Partial = {}, +): LoginDependencies & { hasValidToken: boolean } { + return { + authService: createMockAuthService(), + authStorage: createMockAuthStorage(), + browserService: createMockBrowserService(), + mcpUrl: "https://mcp.githits.com", + hasValidToken: false, + ...overrides, + }; +} + +describe("getCommandPath", () => { + it("returns the leaf command path without the root program", () => { + expect(getCommandPath(createCommand(["auth", "status"]))).toEqual([ + "auth", + "status", + ]); + }); +}); + +describe("isAutoLoginEligibleCommand", () => { + it("allows interactive example invocations", () => { + expect( + isAutoLoginEligibleCommand(createCommand(["example"]), { + stdinIsTTY: true, + stdoutIsTTY: true, + }), + ).toBe(true); + }); + + it("allows interactive --json invocations once login output is redirected", () => { + expect( + isAutoLoginEligibleCommand(createCommand(["example"], { json: true }), { + stdinIsTTY: true, + stdoutIsTTY: true, + }), + ).toBe(true); + }); + + it("skips eligible commands when stdout is not interactive", () => { + expect( + isAutoLoginEligibleCommand(createCommand(["languages"]), { + stdinIsTTY: true, + stdoutIsTTY: false, + }), + ).toBe(false); + }); + + it("skips exempt commands", () => { + expect( + isAutoLoginEligibleCommand(createCommand(["logout"]), { + stdinIsTTY: true, + stdoutIsTTY: true, + }), + ).toBe(false); + }); +}); + +describe("maybeAutoLoginBeforeCommand", () => { + it("skips bootstrap for ineligible commands", async () => { + const createContainer = mock(() => Promise.resolve(createLoginDeps())); + const login = mock(() => + Promise.resolve({ + status: "success" as const, + message: "Logged in successfully.", + }), + ); + + const result = await maybeAutoLoginBeforeCommand( + createCommand(["logout"]), + { + createContainer, + loginFlow: login, + stdinIsTTY: true, + stdoutIsTTY: true, + }, + ); + + expect(result).toEqual({ status: "skipped" }); + expect(createContainer).not.toHaveBeenCalled(); + expect(login).not.toHaveBeenCalled(); + }); + + it("skips login when the container already has a valid token", async () => { + const createContainer = mock(() => + Promise.resolve(createLoginDeps({ hasValidToken: true })), + ); + const login = mock(() => + Promise.resolve({ + status: "success" as const, + message: "Logged in successfully.", + }), + ); + + const result = await maybeAutoLoginBeforeCommand( + createCommand(["example"]), + { + createContainer, + loginFlow: login, + stdinIsTTY: true, + stdoutIsTTY: true, + }, + ); + + expect(result).toEqual({ status: "already-authenticated" }); + expect(createContainer).toHaveBeenCalledTimes(1); + expect(login).not.toHaveBeenCalled(); + }); + + it("runs login for eligible unauthenticated commands", async () => { + const container = createLoginDeps({ hasValidToken: false }); + const createContainer = mock(() => Promise.resolve(container)); + const login = mock(() => + Promise.resolve({ + status: "success" as const, + message: "Logged in successfully. Token expires in 1 hour.", + }), + ); + + const result = await maybeAutoLoginBeforeCommand( + createCommand(["feedback"]), + { + createContainer, + loginFlow: login, + stdinIsTTY: true, + stdoutIsTTY: true, + }, + ); + + expect(result).toEqual({ + status: "authenticated", + message: "Logged in successfully. Token expires in 1 hour.", + }); + expect(login).toHaveBeenCalledWith({}, container); + }); + + it("returns the login failure without continuing", async () => { + const createContainer = mock(() => Promise.resolve(createLoginDeps())); + const login = mock(() => + Promise.resolve({ + status: "failed" as const, + message: "Authentication timed out.", + }), + ); + + const result = await maybeAutoLoginBeforeCommand( + createCommand(["languages"]), + { + createContainer, + loginFlow: login, + stdinIsTTY: true, + stdoutIsTTY: true, + }, + ); + + expect(result).toEqual({ + status: "failed", + message: "Authentication timed out.", + }); + }); +}); diff --git a/src/shared/auto-login.ts b/src/shared/auto-login.ts new file mode 100644 index 00000000..bd8e01d1 --- /dev/null +++ b/src/shared/auto-login.ts @@ -0,0 +1,102 @@ +import type { + LoginDependencies, + LoginFlowResult, + LoginOptions, +} from "../commands/login.js"; + +const AUTO_LOGIN_ELIGIBLE_COMMANDS = new Set([ + "example", + "languages", + "feedback", +]); + +export interface CommandLike { + name(): string; + opts(): Record; + parent?: CommandLike | null; +} + +export interface AutoLoginBootstrapDependencies { + createContainer: () => Promise< + LoginDependencies & { hasValidToken: boolean } + >; + loginFlow: ( + options: LoginOptions, + deps: LoginDependencies, + ) => Promise; + stdinIsTTY?: boolean; + stdoutIsTTY?: boolean; +} + +export interface AutoLoginBootstrapResult { + status: "skipped" | "already-authenticated" | "authenticated" | "failed"; + message?: string; +} + +interface AutoLoginRuntime { + stdinIsTTY: boolean; + stdoutIsTTY: boolean; +} + +export function getCommandPath(command: CommandLike): string[] { + const names: string[] = []; + let current: CommandLike | null | undefined = command; + + while (current) { + const name = current.name(); + if (name && name !== "githits") { + names.unshift(name); + } + current = current.parent ?? null; + } + + return names; +} + +export function isAutoLoginEligibleCommand( + command: CommandLike, + runtime: AutoLoginRuntime = { + stdinIsTTY: Boolean(process.stdin.isTTY), + stdoutIsTTY: Boolean(process.stdout.isTTY), + }, +): boolean { + const commandPath = getCommandPath(command).join(" "); + if (!AUTO_LOGIN_ELIGIBLE_COMMANDS.has(commandPath)) { + return false; + } + + if (!runtime.stdinIsTTY || !runtime.stdoutIsTTY) { + return false; + } + + return true; +} + +export async function maybeAutoLoginBeforeCommand( + command: CommandLike, + deps: AutoLoginBootstrapDependencies, +): Promise { + if ( + !isAutoLoginEligibleCommand(command, { + stdinIsTTY: deps.stdinIsTTY ?? Boolean(process.stdin.isTTY), + stdoutIsTTY: deps.stdoutIsTTY ?? Boolean(process.stdout.isTTY), + }) + ) { + return { status: "skipped" }; + } + + const container = await deps.createContainer(); + if (container.hasValidToken) { + return { status: "already-authenticated" }; + } + + const result = await deps.loginFlow({}, container); + switch (result.status) { + case "success": + return { status: "authenticated", message: result.message }; + case "already_authenticated": + return { status: "already-authenticated", message: result.message }; + case "failed": + return { status: "failed", message: result.message }; + } +} From e99d6b933cdc2cbf50ddb78c66ab5c1b7c62c75a Mon Sep 17 00:00:00 2001 From: Olli-Pekka Heinisuo Date: Fri, 24 Apr 2026 14:17:59 +0300 Subject: [PATCH 2/9] fix: restore gated CLI command registration Extract the root CLI preAction so auto-login behavior can be tested directly. Keep example, languages, and feedback on the interactive auto-login path while restoring strict startup gating for search, code, and pkg based on resolved capability or local override. Update CLI/docs coverage to verify help output and registration stay aligned with the stricter gated surface. --- docs/implementation/auth.md | 5 + docs/implementation/cli-commands.md | 18 +- docs/implementation/signup-flow.md | 24 +-- src/cli.test.ts | 217 ++++++++++++++++++++- src/cli.ts | 59 +++--- src/commands/code/index.test.ts | 31 +++ src/commands/code/index.ts | 24 ++- src/commands/pkg/index.test.ts | 31 +++ src/commands/pkg/index.ts | 24 ++- src/commands/search-registration.test.ts | 46 ++++- src/commands/search.ts | 8 + src/container.ts | 34 ++++ src/services/code-navigation-capability.ts | 53 +++++ src/services/config.ts | 4 + src/services/index.ts | 3 + src/shared/code-navigation-cli-surface.ts | 12 ++ src/shared/index.ts | 4 + src/shared/root-cli-pre-action.ts | 38 ++++ 18 files changed, 584 insertions(+), 51 deletions(-) create mode 100644 src/services/code-navigation-capability.ts create mode 100644 src/shared/code-navigation-cli-surface.ts create mode 100644 src/shared/root-cli-pre-action.ts diff --git a/docs/implementation/auth.md b/docs/implementation/auth.md index f60c0032..cf3817ea 100644 --- a/docs/implementation/auth.md +++ b/docs/implementation/auth.md @@ -60,6 +60,11 @@ For interactive `--json` invocations, the same bootstrap runs, but login progress is written to stderr so the command's JSON payload can remain the only stdout output. +This bootstrap does not widen the package/source command surface. The gated +`search`, `code`, and `pkg` commands still rely on startup capability checks +for registration and remain hidden until capability is known to be open, or a +local CLI override forces them on. + ## Token Lifecycle Tokens are JWTs with a configurable expiration (typically 1 hour). The CLI handles expiration through a `TokenManager` (see `src/services/token-manager.ts`): diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 42e7e0ea..77aefb69 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -2,7 +2,7 @@ ## Purpose -The CLI exposes `example`, `languages`, `feedback`, top-level indexed `search` / `search-status`, and the `code`, `docs`, and `pkg` command groups by default. All of these commands share business logic with the MCP tools through the same service interfaces and shared utilities, but format output for terminal consumption instead of MCP tool results. +The CLI exposes four primary top-level commands: `example`, `languages`, and `feedback` are always available, while `search` and the `code` / `pkg` command groups remain capability-gated package/source surfaces. Those gated commands are only registered when the package/source endpoint is configured and the startup auth context resolves code-navigation capability as enabled, or when the local CLI override is enabled. All of these commands share business logic with the MCP tools through the same service interfaces and shared utilities, but format output for terminal consumption instead of MCP tool results. Phase 1 of the streamlined signup flow adds automatic browser login bootstrap to three interactive top-level commands: `example`, `languages`, and `feedback`. @@ -12,6 +12,10 @@ command after auth succeeds. For interactive `--json` runs, login progress is written to stderr so stdout stays machine-readable. The bootstrap remains disabled for non-interactive execution. +The gated package/source commands do not participate in that phase-1 bootstrap. +`search`, `code`, and `pkg` stay hidden unless the CLI can already prove the +capability is open at startup, or a local override forces the surface on. + ## Commands | Command | Required Args | Options | Description | @@ -95,6 +99,10 @@ The original unified-search plan envisaged hiding partial mode entirely in v1 to **Source-status surfacing.** The JSON `sourceStatus` block is always passed through verbatim for debugging. Human-readable output surfaces only the actionable subset: ignored / incompatible filters, ignored / incompatible query features, free-form `note`s, and an `INDEXING` indicator when a source is still indexing on a partial-result payload. `STALE` (served from a slightly old index while a fresh reindex runs) is intentionally not shown in human output — agents and users do not need to second-guess otherwise-correct results, but it remains in JSON for diagnostics. +**Registration.** `search` stays capability-gated at registration time. Unlike +`example`, `languages`, and `feedback`, it does not auto-surface on a fresh +unauthenticated machine just to trigger browser login. + ### `githits search-status` ``` @@ -156,7 +164,9 @@ Finds functions, classes, modules, and doc sections inside an indexed dependency **Output.** Default terminal output leads each entry with `path:startLine-endLine [kind]`, followed by the symbol name and a 3-line dedented snippet. `--json` emits the shared success/error envelope also produced by the MCP `search_symbols` tool — see [`mcp-cli-parity.md`](./mcp-cli-parity.md) for the wire contract. The command is registered as `code search` with `code search-symbols` as a Commander alias. -**Capability gate.** The `code` group is registered only when package/source access is available for the current session, when `GITHITS_CODE_NAVIGATION=1` is set for local override, or when stored auth is expired and the CLI cannot reliably pre-classify access. +**Registration.** The `code` group is registered only when the package/source +endpoint is configured and the startup capability gate is open for the CLI +surface, or when the local CLI override is enabled. **Troubleshooting.** Set `GITHITS_DEBUG=code-nav` to emit single-line JSON diagnostics to stderr on error paths. Include the output when filing an issue. Debug payloads never contain query text, tokens, or response bodies. @@ -179,6 +189,10 @@ Shows a concise overview for a single package: latest version, license, descript **Output envelope.** Success payload is hand-crafted for agent token efficiency: `{registry, name, version, description?, license?, homepage?, repository?, publishedAt?, downloads?, github?, install?, usage?, vulnerabilities?, recentChanges?}`. Omitted fields reflect backend nulls, not dropped data. Error envelope: `{error, code, retryable, details?}` — shared classifier family. Under `--json` the error envelope is written to **stderr** so stdout stays clean for `jq`. +**Registration.** Same as `code`: the command is visible only when the +package/source endpoint is configured and the startup capability gate is open +for the CLI surface, or when the local CLI override is enabled. + **Troubleshooting.** `GITHITS_DEBUG=pkg-intel` emits PII-safe classified-error diagnostics (area, event, code, error class, detail keys). Use `GITHITS_DEBUG=*` to enable all non-sensitive package/source diagnostics. ### `githits pkg vulns` diff --git a/docs/implementation/signup-flow.md b/docs/implementation/signup-flow.md index d2c7c2d2..f67d1378 100644 --- a/docs/implementation/signup-flow.md +++ b/docs/implementation/signup-flow.md @@ -119,25 +119,27 @@ Use these rules when implementing the feature: ### Phase 1.3: tests -- [ ] Add CLI-level tests for exempt commands never triggering auto-login. -- [ ] Add CLI-level tests for eligible commands triggering login when no valid +- [x] Add CLI-level tests for exempt commands never triggering auto-login. +- [x] Add CLI-level tests for eligible commands triggering login when no valid token is available. -- [ ] Add tests for login failure preserving a clear error path. -- [ ] Add tests proving `--json` commands do not receive login chatter on +- [x] Add tests for login failure preserving a clear error path. +- [x] Add tests proving `--json` commands do not receive login chatter on stdout. - [ ] Keep existing command-level `AuthRequiredError` tests to verify the final invariant still holds. ### Phase 2: capability-gated command registration -- [ ] Decide whether `search`, `code`, and `pkg` should be visible on a fresh +- [x] Decide whether `search`, `code`, and `pkg` should be visible on a fresh unauthenticated machine purely to allow auto-login on first use. -- [ ] If yes, relax the startup registration gates in `src/commands/search.ts`, - `src/commands/code/index.ts`, and `src/commands/pkg/index.ts` so the - command surfaces can parse before auth exists. -- [ ] Re-check help output and discoverability once those command groups are - visible without a token. -- [ ] Add tests covering first-run unauthenticated registration behavior. + Decision: no, gated package/source features should stay hidden until + capability is known to be open. +- [x] Keep the startup registration gates in `src/commands/search.ts`, + `src/commands/code/index.ts`, and `src/commands/pkg/index.ts` aligned + with that stricter capability policy. +- [x] Re-check help output and discoverability with the stricter gated-command + policy in place. +- [x] Add tests covering the hidden-unless-enabled registration behavior. ## Open Decisions diff --git a/src/cli.test.ts b/src/cli.test.ts index 4e31ba58..4c2dcd92 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,5 +1,20 @@ -import { afterEach, describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; import { Command } from "commander"; +import { + registerCodeCommandGroup, + registerExampleCommand, + registerFeedbackCommand, + registerLanguagesCommand, + registerPkgCommandGroup, + registerUnifiedSearchCommands, +} from "./commands/index.js"; +import type { LoginDependencies } from "./commands/login.js"; +import { + createMockAuthService, + createMockAuthStorage, + createMockBrowserService, +} from "./services/test-helpers.js"; +import { createRootCliPreAction } from "./shared/root-cli-pre-action.js"; describe("--no-color flag", () => { const origNoColor = process.env.NO_COLOR; @@ -56,3 +71,203 @@ describe("--no-color flag", () => { expect(captured).toBeUndefined(); }); }); + +function createLoginDeps( + overrides: Partial = {}, +): LoginDependencies & { hasValidToken: boolean } { + return { + authService: createMockAuthService(), + authStorage: createMockAuthStorage(), + browserService: createMockBrowserService(), + mcpUrl: "https://mcp.githits.com", + hasValidToken: false, + ...overrides, + }; +} + +function createProgramWithRootPreAction( + dependencies: Parameters[0], +): Command { + const program = new Command(); + program + .name("githits") + .option("--no-color", "Disable colored output") + .hook("preAction", createRootCliPreAction(dependencies)); + return program; +} + +async function createProgramForHelpSurface(options: { + codeNavigationUrl?: string; + overrideEnabled?: boolean; + capability?: "enabled" | "disabled" | "unknown"; +}): Promise { + const program = new Command(); + program.name("githits"); + + registerExampleCommand(program); + registerLanguagesCommand(program); + registerFeedbackCommand(program); + await registerUnifiedSearchCommands(program, options); + await registerCodeCommandGroup(program, options); + await registerPkgCommandGroup(program, options); + + return program; +} + +describe("root CLI preAction", () => { + it("does not trigger auto-login for exempt commands", async () => { + const createContainer = mock(() => Promise.resolve(createLoginDeps())); + const loginFlow = mock(() => + Promise.resolve({ + status: "success" as const, + message: "Logged in successfully.", + }), + ); + const program = createProgramWithRootPreAction({ + createContainer, + loginFlow, + }); + + let ran = false; + program.command("logout").action(() => { + ran = true; + }); + + await program.parseAsync(["node", "githits", "logout"]); + + expect(ran).toBe(true); + expect(createContainer).not.toHaveBeenCalled(); + expect(loginFlow).not.toHaveBeenCalled(); + }); + + it("triggers auto-login for eligible commands before the action runs", async () => { + const container = createLoginDeps({ hasValidToken: false }); + const createContainer = mock(() => Promise.resolve(container)); + const loginFlow = mock(() => + Promise.resolve({ + status: "success" as const, + message: "Logged in successfully.", + }), + ); + const program = createProgramWithRootPreAction({ + createContainer, + loginFlow, + }); + + let ran = false; + program.command("example").action(() => { + ran = true; + }); + + await program.parseAsync(["node", "githits", "example"]); + + expect(ran).toBe(true); + expect(createContainer).toHaveBeenCalledTimes(1); + expect(loginFlow).toHaveBeenCalledWith({}, container); + }); + + it("preserves a clear failure path when auto-login fails", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const createContainer = mock(() => Promise.resolve(createLoginDeps())); + const loginFlow = mock(() => + Promise.resolve({ + status: "failed" as const, + message: "Authentication timed out.", + }), + ); + const exit = mock(() => { + throw new Error("process.exit"); + }); + const program = createProgramWithRootPreAction({ + createContainer, + loginFlow, + exit, + }); + + let ran = false; + program.command("example").action(() => { + ran = true; + }); + + await expect( + program.parseAsync(["node", "githits", "example"]), + ).rejects.toThrow("process.exit"); + + expect(ran).toBe(false); + expect(errorSpy.mock.calls.map((call) => call[0])).toEqual([ + "Authentication timed out.\n", + "Run `githits login` to try again.", + ]); + errorSpy.mockRestore(); + }); + + it("keeps stdout clean for interactive --json auto-login flows", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const createContainer = mock(() => Promise.resolve(createLoginDeps())); + const loginFlow = mock(() => { + console.error("Opening browser..."); + console.error("Waiting for authentication...\n"); + return Promise.resolve({ + status: "success" as const, + message: "Logged in successfully.", + }); + }); + const program = createProgramWithRootPreAction({ + createContainer, + loginFlow, + }); + + program + .command("example") + .option("--json", "Output JSON") + .action((options: { json?: boolean }) => { + console.log(JSON.stringify({ ok: options.json === true })); + }); + + await program.parseAsync(["node", "githits", "example", "--json"]); + + expect(logSpy.mock.calls.map((call) => call[0])).toEqual([ + JSON.stringify({ ok: true }), + ]); + expect(errorSpy.mock.calls.map((call) => call[0])).toEqual([ + "Opening browser...", + "Waiting for authentication...\n", + ]); + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); +}); + +describe("CLI help surface", () => { + it("keeps gated commands out of root help when capability is closed", async () => { + const program = await createProgramForHelpSurface({ + codeNavigationUrl: "https://pkgseer.dev", + overrideEnabled: false, + capability: "disabled", + }); + + const help = program.helpInformation(); + + expect(help).toMatch(/^\s{2}example\b/m); + expect(help).toMatch(/^\s{2}languages\b/m); + expect(help).toMatch(/^\s{2}feedback\b/m); + expect(help).not.toMatch(/^\s{2}search\b/m); + expect(help).not.toMatch(/^\s{2}code\b/m); + expect(help).not.toMatch(/^\s{2}pkg\b/m); + }); + + it("shows gated commands in root help when capability is enabled", async () => { + const program = await createProgramForHelpSurface({ + codeNavigationUrl: "https://pkgseer.dev", + overrideEnabled: false, + capability: "enabled", + }); + + const help = program.helpInformation(); + + expect(help).toMatch(/^\s{2}search\b/m); + expect(help).toMatch(/^\s{2}code\b/m); + expect(help).toMatch(/^\s{2}pkg\b/m); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 7f137708..40ac0e32 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,16 +23,19 @@ import { registerUnifiedSearchCommands, } from "./commands/index.js"; import { loginFlow, stderrLoginOutput } from "./commands/login.js"; -import { createContainer } from "./container.js"; import { - getCommandPath, - maybeAutoLoginBeforeCommand, -} from "./shared/auto-login.js"; + createContainer, + resolveStartupCodeNavigationRegistrationState, +} from "./container.js"; import { FileSystemServiceImpl, + getCodeNavigationUrl, + isCodeNavigationCliOverrideEnabled, NpmRegistryUpdateCheckService, } from "./services/index.js"; +import { getCommandPath } from "./shared/auto-login.js"; import { + createRootCliPreAction, endTelemetrySpan, flushTelemetry, isTelemetryEnabled, @@ -80,33 +83,24 @@ if (isTelemetryEnabled()) { }); } +const rootCliPreAction = createRootCliPreAction({ + createContainer, + loginFlow: (options, deps) => loginFlow(options, deps, stderrLoginOutput), +}); + program .name("githits") .description("Code examples from global open source for your AI assistant") .version(version) .option("--no-color", "Disable colored output") .hook("preAction", async (thisCommand, actionCommand) => { - if (thisCommand.opts().color === false) { - process.env.NO_COLOR = "1"; - } - const command = actionCommand ?? thisCommand; commandSpans.set( command, startTelemetrySpan(getTelemetryCommandName(command)), ); - const authResult = await maybeAutoLoginBeforeCommand(command, { - createContainer, - loginFlow: (options, deps) => loginFlow(options, deps, stderrLoginOutput), - }); - if (authResult.status !== "failed") { - return; - } - - console.error(`${authResult.message}\n`); - console.error("Run `githits login` to try again."); - process.exit(1); + await rootCliPreAction(thisCommand, actionCommand); }) .hook("postAction", (_thisCommand, actionCommand) => { endTelemetrySpan(commandSpans.get(actionCommand)); @@ -142,19 +136,27 @@ registerLanguagesCommand(program); registerFeedbackCommand(program); const registrationArgv = stripRootRegistrationOptions(argv); +const shouldLoadCapabilityGatedCommands = + shouldEagerLoadSearchCommands(registrationArgv) || + shouldEagerLoadGatedCommandGroup(registrationArgv, "code") || + shouldEagerLoadGatedCommandGroup(registrationArgv, "pkg"); +const gatedCommandRegistrationOptions = shouldLoadCapabilityGatedCommands + ? await resolveGatedCommandRegistrationOptions() + : undefined; + if (shouldEagerLoadSearchCommands(registrationArgv)) { await withTelemetrySpan("cli.register.search", () => - registerUnifiedSearchCommands(program), + registerUnifiedSearchCommands(program, gatedCommandRegistrationOptions), ); } if (shouldEagerLoadGatedCommandGroup(registrationArgv, "code")) { await withTelemetrySpan("cli.register.code-group", () => - registerCodeCommandGroup(program), + registerCodeCommandGroup(program, gatedCommandRegistrationOptions), ); } if (shouldEagerLoadGatedCommandGroup(registrationArgv, "pkg")) { await withTelemetrySpan("cli.register.pkg-group", () => - registerPkgCommandGroup(program), + registerPkgCommandGroup(program, gatedCommandRegistrationOptions), ); } if (shouldEagerLoadGatedCommandGroup(registrationArgv, "docs")) { @@ -229,6 +231,19 @@ function isSearchHelpTarget(value: string | undefined): boolean { return value === "search" || value === "search-status"; } +async function resolveGatedCommandRegistrationOptions() { + const codeNavigationUrl = getCodeNavigationUrl(); + return { + codeNavigationUrl, + overrideEnabled: isCodeNavigationCliOverrideEnabled(), + capability: ( + await withTelemetrySpan("cli.resolve-code-nav-registration-state", () => + resolveStartupCodeNavigationRegistrationState(), + ) + ).capability, + }; +} + function getTelemetryCommandName(command: Command): string { const names = getCommandPath(command); diff --git a/src/commands/code/index.test.ts b/src/commands/code/index.test.ts index 409da831..ebb98f53 100644 --- a/src/commands/code/index.test.ts +++ b/src/commands/code/index.test.ts @@ -18,6 +18,7 @@ describe("registerCodeCommandGroup", () => { const program = new Command(); await registerCodeCommandGroup(program, { codeNavigationUrl: "https://nav.example.com", + capability: "enabled", }); const codeCommand = program.commands.find( @@ -28,4 +29,34 @@ describe("registerCodeCommandGroup", () => { codeCommand?.commands.some((command) => command.name() === "files"), ).toBe(true); }); + + it("registers the code command group when override and URL are set", async () => { + const program = new Command(); + await registerCodeCommandGroup(program, { + codeNavigationUrl: "https://nav.example.com", + overrideEnabled: true, + capability: "disabled", + }); + + const codeCommand = program.commands.find( + (command) => command.name() === "code", + ); + expect(codeCommand).toBeDefined(); + expect( + codeCommand?.commands.some((command) => command.name() === "files"), + ).toBe(true); + }); + + it("does not register the code command group when capability is unknown", async () => { + const program = new Command(); + await registerCodeCommandGroup(program, { + codeNavigationUrl: "https://nav.example.com", + overrideEnabled: false, + capability: "unknown", + }); + + expect(program.commands.some((command) => command.name() === "code")).toBe( + false, + ); + }); }); diff --git a/src/commands/code/index.ts b/src/commands/code/index.ts index 9ba76795..2ce45ee0 100644 --- a/src/commands/code/index.ts +++ b/src/commands/code/index.ts @@ -1,23 +1,33 @@ import type { Command } from "commander"; import { - type GatedCommandGroupOptions, - resolveGatedCommandGroupRegistrationState, -} from "../gated-command-group.js"; + type CodeNavigationCapability, + getCodeNavigationUrl, +} from "../../services/index.js"; +import { isCodeNavigationCliSurfaceOpen } from "../../shared/code-navigation-cli-surface.js"; import { registerCodeFilesCommand } from "./files.js"; import { registerCodeGrepCommand } from "./grep.js"; import { registerCodeReadCommand } from "./read.js"; -export interface CodeCommandGroupOptions extends GatedCommandGroupOptions {} +export interface CodeCommandGroupOptions { + codeNavigationUrl?: string; + overrideEnabled?: boolean; + capability?: CodeNavigationCapability; +} /** - * Registers the code-navigation command group. + * Registers the code-navigation command group only when the endpoint URL + * is configured and the capability gate is open for the CLI surface. */ export async function registerCodeCommandGroup( program: Command, options: CodeCommandGroupOptions = {}, ): Promise { - const registration = await resolveGatedCommandGroupRegistrationState(options); - if (!registration.shouldRegister) { + const codeNavigationUrl = options.codeNavigationUrl ?? getCodeNavigationUrl(); + if (!codeNavigationUrl) { + return; + } + + if (!isCodeNavigationCliSurfaceOpen(options)) { return; } diff --git a/src/commands/pkg/index.test.ts b/src/commands/pkg/index.test.ts index b782ecc3..7babfda2 100644 --- a/src/commands/pkg/index.test.ts +++ b/src/commands/pkg/index.test.ts @@ -18,6 +18,7 @@ describe("registerPkgCommandGroup", () => { const program = new Command(); await registerPkgCommandGroup(program, { codeNavigationUrl: "https://pkgseer.dev", + capability: "enabled", }); const pkgCommand = program.commands.find( @@ -34,4 +35,34 @@ describe("registerPkgCommandGroup", () => { pkgCommand?.commands.some((command) => command.name() === "deps"), ).toBe(true); }); + + it("registers the pkg command group when override and URL are set", async () => { + const program = new Command(); + await registerPkgCommandGroup(program, { + codeNavigationUrl: "https://pkgseer.dev", + overrideEnabled: true, + capability: "disabled", + }); + + const pkgCommand = program.commands.find( + (command) => command.name() === "pkg", + ); + expect(pkgCommand).toBeDefined(); + expect( + pkgCommand?.commands.some((command) => command.name() === "info"), + ).toBe(true); + }); + + it("does not register the pkg command group when capability is unknown", async () => { + const program = new Command(); + await registerPkgCommandGroup(program, { + codeNavigationUrl: "https://pkgseer.dev", + overrideEnabled: false, + capability: "unknown", + }); + + expect(program.commands.some((command) => command.name() === "pkg")).toBe( + false, + ); + }); }); diff --git a/src/commands/pkg/index.ts b/src/commands/pkg/index.ts index d0c161be..f4cd110b 100644 --- a/src/commands/pkg/index.ts +++ b/src/commands/pkg/index.ts @@ -1,24 +1,34 @@ import type { Command } from "commander"; import { - type GatedCommandGroupOptions, - resolveGatedCommandGroupRegistrationState, -} from "../gated-command-group.js"; + type CodeNavigationCapability, + getCodeNavigationUrl, +} from "../../services/index.js"; +import { isCodeNavigationCliSurfaceOpen } from "../../shared/code-navigation-cli-surface.js"; import { registerPkgChangelogCommand } from "./changelog.js"; import { registerPkgDepsCommand } from "./deps.js"; import { registerPkgInfoCommand } from "./info.js"; import { registerPkgVulnsCommand } from "./vulns.js"; -export interface PkgCommandGroupOptions extends GatedCommandGroupOptions {} +export interface PkgCommandGroupOptions { + codeNavigationUrl?: string; + overrideEnabled?: boolean; + capability?: CodeNavigationCapability; +} /** - * Registers the `pkg` command group. + * Registers the `pkg` command group only when the package/source endpoint + * is configured and the capability gate is open for the CLI surface. */ export async function registerPkgCommandGroup( program: Command, options: PkgCommandGroupOptions = {}, ): Promise { - const registration = await resolveGatedCommandGroupRegistrationState(options); - if (!registration.shouldRegister) { + const codeNavigationUrl = options.codeNavigationUrl ?? getCodeNavigationUrl(); + if (!codeNavigationUrl) { + return; + } + + if (!isCodeNavigationCliSurfaceOpen(options)) { return; } diff --git a/src/commands/search-registration.test.ts b/src/commands/search-registration.test.ts index 0780417c..6ad9ee50 100644 --- a/src/commands/search-registration.test.ts +++ b/src/commands/search-registration.test.ts @@ -17,10 +17,28 @@ describe("registerUnifiedSearchCommands", () => { ).toBe(false); }); - it("registers search commands when code navigation URL is configured", async () => { + it("does not register search commands when capability is disabled", async () => { const program = new Command(); await registerUnifiedSearchCommands(program, { codeNavigationUrl: "https://nav.example.com", + overrideEnabled: false, + capability: "disabled", + }); + + expect( + program.commands.some((command) => command.name() === "search"), + ).toBe(false); + expect( + program.commands.some((command) => command.name() === "search-status"), + ).toBe(false); + }); + + it("registers search commands when capability is enabled", async () => { + const program = new Command(); + await registerUnifiedSearchCommands(program, { + codeNavigationUrl: "https://nav.example.com", + overrideEnabled: false, + capability: "enabled", }); expect( @@ -30,4 +48,30 @@ describe("registerUnifiedSearchCommands", () => { program.commands.some((command) => command.name() === "search-status"), ).toBe(true); }); + + it("registers search commands when override is enabled", async () => { + const program = new Command(); + await registerUnifiedSearchCommands(program, { + codeNavigationUrl: "https://nav.example.com", + overrideEnabled: true, + capability: "disabled", + }); + + expect( + program.commands.some((command) => command.name() === "search"), + ).toBe(true); + }); + + it("does not register search commands when capability is unknown", async () => { + const program = new Command(); + await registerUnifiedSearchCommands(program, { + codeNavigationUrl: "https://nav.example.com", + overrideEnabled: false, + capability: "unknown", + }); + + expect( + program.commands.some((command) => command.name() === "search"), + ).toBe(false); + }); }); diff --git a/src/commands/search.ts b/src/commands/search.ts index c9812abd..becd5af5 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,9 +1,11 @@ import { type Command, Option } from "commander"; +import type { CodeNavigationCapability } from "../services/index.js"; import type { CodeNavigationService, UnifiedSearchSource, } from "../services/code-navigation-service.js"; import { getCodeNavigationUrl } from "../services/config.js"; +import { isCodeNavigationCliSurfaceOpen } from "../shared/code-navigation-cli-surface.js"; import { buildUnifiedSearchErrorPayload, buildUnifiedSearchParams, @@ -48,6 +50,8 @@ export interface SearchStatusCommandOptions { export interface SearchCommandRegistrationOptions { codeNavigationUrl?: string; + overrideEnabled?: boolean; + capability?: CodeNavigationCapability; } export interface SearchCommandDependencies { @@ -258,6 +262,10 @@ export async function registerUnifiedSearchCommands( return; } + if (!isCodeNavigationCliSurfaceOpen(options)) { + return; + } + registerSearchCommand(program); } diff --git a/src/container.ts b/src/container.ts index c33923f5..e2e3b979 100644 --- a/src/container.ts +++ b/src/container.ts @@ -8,12 +8,14 @@ import { type BrowserService, BrowserServiceImpl, ChunkingKeyringService, + type CodeNavigationCapability, type CodeNavigationService, CodeNavigationServiceImpl, type FileSystemService, FileSystemServiceImpl, GitHitsServiceImpl, getApiUrl, + getCodeNavigationCapability, getAuthFileStorageDir, getCodeNavigationUrl, getEnvApiToken, @@ -250,3 +252,35 @@ export async function createContainer(): Promise { }; }); } + +export interface StartupCodeNavigationRegistrationState { + capability: CodeNavigationCapability; + expiredStoredAuth: boolean; +} + +export async function resolveStartupCodeNavigationRegistrationState(): Promise { + return withTelemetrySpan( + "startup.resolve-code-nav-registration-state", + async () => { + const envToken = getEnvApiToken(); + if (envToken) { + return { + capability: getCodeNavigationCapability(envToken), + expiredStoredAuth: false, + }; + } + + const fileSystemService = new FileSystemServiceImpl(); + const authStorage = await createAuthStorage(fileSystemService); + const tokens = await authStorage.loadTokens(getMcpUrl()); + if (tokens?.expiresAt && new Date(tokens.expiresAt) < new Date()) { + return { capability: "unknown", expiredStoredAuth: true }; + } + + return { + capability: getCodeNavigationCapability(tokens?.accessToken), + expiredStoredAuth: false, + }; + }, + ); +} diff --git a/src/services/code-navigation-capability.ts b/src/services/code-navigation-capability.ts new file mode 100644 index 00000000..c5335170 --- /dev/null +++ b/src/services/code-navigation-capability.ts @@ -0,0 +1,53 @@ +export type CodeNavigationCapability = "enabled" | "disabled" | "unknown"; + +interface JwtPayload { + code_navigation?: unknown; + codeNavigation?: unknown; + capabilities?: unknown; +} + +export function getCodeNavigationCapability( + accessToken: string | undefined, +): CodeNavigationCapability { + if (!accessToken) { + return "unknown"; + } + + const payload = decodeJwtPayload(accessToken); + if (!payload) { + return "unknown"; + } + + if (payload.code_navigation === true || payload.codeNavigation === true) { + return "enabled"; + } + + if (payload.code_navigation === false || payload.codeNavigation === false) { + return "disabled"; + } + + if (Array.isArray(payload.capabilities)) { + return payload.capabilities.includes("code_navigation") + ? "enabled" + : "disabled"; + } + + return "unknown"; +} + +function decodeJwtPayload(accessToken: string): JwtPayload | undefined { + const [, encodedPayload] = accessToken.split("."); + if (!encodedPayload) { + return undefined; + } + + try { + const normalized = encodedPayload + .replaceAll("-", "+") + .replaceAll("_", "/") + .padEnd(Math.ceil(encodedPayload.length / 4) * 4, "="); + return JSON.parse(Buffer.from(normalized, "base64").toString("utf8")); + } catch { + return undefined; + } +} \ No newline at end of file diff --git a/src/services/config.ts b/src/services/config.ts index 6dc5225b..8a0e36b4 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -43,6 +43,10 @@ export function getCodeNavigationUrl(): string { return DEFAULT_CODE_NAV_URL; } +export function isCodeNavigationCliOverrideEnabled(): boolean { + return process.env.GITHITS_CODE_NAVIGATION === "1"; +} + /** * Get API token from environment variable (for CI/automation). */ diff --git a/src/services/index.ts b/src/services/index.ts index 981a9c5e..78f99f52 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -92,11 +92,14 @@ export { CodeNavigationVersionNotFoundError, MalformedCodeNavigationResponseError, } from "./code-navigation-service.js"; +export type { CodeNavigationCapability } from "./code-navigation-capability.js"; +export { getCodeNavigationCapability } from "./code-navigation-capability.js"; export { getApiUrl, getCodeNavigationUrl, getEnvApiToken, getMcpUrl, + isCodeNavigationCliOverrideEnabled, } from "./config.js"; export type { ExecResult, ExecService } from "./exec-service.js"; export { ExecServiceImpl } from "./exec-service.js"; diff --git a/src/shared/code-navigation-cli-surface.ts b/src/shared/code-navigation-cli-surface.ts new file mode 100644 index 00000000..4697aa2c --- /dev/null +++ b/src/shared/code-navigation-cli-surface.ts @@ -0,0 +1,12 @@ +import type { CodeNavigationCapability } from "../services/index.js"; + +export interface CodeNavigationCliSurfaceOptions { + overrideEnabled?: boolean; + capability?: CodeNavigationCapability; +} + +export function isCodeNavigationCliSurfaceOpen( + options: CodeNavigationCliSurfaceOptions, +): boolean { + return options.overrideEnabled === true || options.capability === "enabled"; +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 78b38115..045785f3 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -169,6 +169,10 @@ export { } from "./read-package-doc-response.js"; export { renderReadPackageDocText } from "./read-package-doc-text.js"; export { AuthRequiredError, requireAuth } from "./require-auth.js"; +export { + createRootCliPreAction, + type RootCliPreActionDependencies, +} from "./root-cli-pre-action.js"; export { endTelemetrySpan, flushTelemetry, diff --git a/src/shared/root-cli-pre-action.ts b/src/shared/root-cli-pre-action.ts new file mode 100644 index 00000000..b8afa982 --- /dev/null +++ b/src/shared/root-cli-pre-action.ts @@ -0,0 +1,38 @@ +import type { Command } from "commander"; +import type { + LoginDependencies, + LoginFlowResult, + LoginOptions, +} from "../commands/login.js"; +import { maybeAutoLoginBeforeCommand } from "./auto-login.js"; + +export interface RootCliPreActionDependencies { + createContainer: () => Promise< + LoginDependencies & { hasValidToken: boolean } + >; + loginFlow: ( + options: LoginOptions, + deps: LoginDependencies, + ) => Promise; + exit?: (code: number) => void; +} + +export function createRootCliPreAction( + deps: RootCliPreActionDependencies, +): (thisCommand: Command, actionCommand?: Command) => Promise { + return async (thisCommand: Command, actionCommand?: Command) => { + if (thisCommand.opts().color === false) { + process.env.NO_COLOR = "1"; + } + + const command = actionCommand ?? thisCommand; + const authResult = await maybeAutoLoginBeforeCommand(command, deps); + if (authResult.status !== "failed") { + return; + } + + console.error(`${authResult.message}\n`); + console.error("Run `githits login` to try again."); + (deps.exit ?? process.exit)(1); + }; +} From 955ca00291058a3b26ad59b71a10c6c300d31870 Mon Sep 17 00:00:00 2001 From: Olli-Pekka Heinisuo Date: Fri, 24 Apr 2026 14:30:08 +0300 Subject: [PATCH 3/9] test: stabilize root CLI preAction tests Pass injectable TTY flags through the root CLI preAction helper and use them in the CLI test seam so auto-login tests no longer depend on the runner's real stdio state. This keeps the CI root preAction coverage deterministic without changing production behavior. --- src/cli.test.ts | 9 ++++++++- src/shared/root-cli-pre-action.ts | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 4c2dcd92..6bb78ce7 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -92,7 +92,14 @@ function createProgramWithRootPreAction( program .name("githits") .option("--no-color", "Disable colored output") - .hook("preAction", createRootCliPreAction(dependencies)); + .hook( + "preAction", + createRootCliPreAction({ + stdinIsTTY: true, + stdoutIsTTY: true, + ...dependencies, + }), + ); return program; } diff --git a/src/shared/root-cli-pre-action.ts b/src/shared/root-cli-pre-action.ts index b8afa982..7fc62c11 100644 --- a/src/shared/root-cli-pre-action.ts +++ b/src/shared/root-cli-pre-action.ts @@ -14,6 +14,8 @@ export interface RootCliPreActionDependencies { options: LoginOptions, deps: LoginDependencies, ) => Promise; + stdinIsTTY?: boolean; + stdoutIsTTY?: boolean; exit?: (code: number) => void; } @@ -26,7 +28,11 @@ export function createRootCliPreAction( } const command = actionCommand ?? thisCommand; - const authResult = await maybeAutoLoginBeforeCommand(command, deps); + const authResult = await maybeAutoLoginBeforeCommand(command, { + ...deps, + stdinIsTTY: deps.stdinIsTTY, + stdoutIsTTY: deps.stdoutIsTTY, + }); if (authResult.status !== "failed") { return; } From ba3eefcd750d05cf8dcc9d47d5a004a4d96de190 Mon Sep 17 00:00:00 2001 From: Olli-Pekka Heinisuo Date: Fri, 24 Apr 2026 15:27:54 +0300 Subject: [PATCH 4/9] add fallback url printing --- src/commands/login.test.ts | 28 ++++++++++++++++++++++++++++ src/commands/login.ts | 9 ++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index 11f24236..b78059f9 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -437,6 +437,34 @@ describe("loginFlow", () => { consoleSpy.mockRestore(); }); + it("falls back to printing the URL when automatic browser launch fails", async () => { + const writes: string[] = []; + + const result = await loginFlow( + { port: 8080 }, + { + authService: createMockAuthService(), + authStorage: createMockAuthStorage(), + browserService: createMockBrowserService({ + open: mock(() => Promise.reject(new Error("launch failed"))), + }), + mcpUrl, + }, + { + write: (message: string) => { + writes.push(message); + }, + }, + ); + + expect(result.status).toBe("success"); + expect(writes).toContain("Opening browser..."); + expect(writes).toContain( + "Failed to open browser automatically. Open this URL manually:\n", + ); + expect(writes).toContain(" http://example.com/auth\n"); + }); + it("returns already_authenticated when valid tokens exist", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); diff --git a/src/commands/login.ts b/src/commands/login.ts index 41d9a8b7..350cd7e4 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -198,7 +198,14 @@ export async function loginFlow( output.write(` ${authUrl}\n`); } else { output.write("Opening browser..."); - await browserService.open(authUrl); + try { + await browserService.open(authUrl); + } catch { + output.write( + "Failed to open browser automatically. Open this URL manually:\n", + ); + output.write(` ${authUrl}\n`); + } } output.write("Waiting for authentication...\n"); From d2044016488b7abce0b1f9a938ffa668e0e14946 Mon Sep 17 00:00:00 2001 From: Olli-Pekka Heinisuo Date: Fri, 24 Apr 2026 15:49:47 +0300 Subject: [PATCH 5/9] fix: print a message that command is actually running after auth --- src/cli.test.ts | 64 +++++++++++++++++++++++++++++++ src/shared/root-cli-pre-action.ts | 22 ++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 6bb78ce7..b493e2fb 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -148,6 +148,7 @@ describe("root CLI preAction", () => { }); it("triggers auto-login for eligible commands before the action runs", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); const container = createLoginDeps({ hasValidToken: false }); const createContainer = mock(() => Promise.resolve(container)); const loginFlow = mock(() => @@ -171,6 +172,68 @@ describe("root CLI preAction", () => { expect(ran).toBe(true); expect(createContainer).toHaveBeenCalledTimes(1); expect(loginFlow).toHaveBeenCalledWith({}, container); + expect(errorSpy.mock.calls.map((call) => call[0])).toEqual([ + "Authentication complete. Running example search...", + ]); + errorSpy.mockRestore(); + }); + + it("prints the languages continuation message after successful auto-login", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const container = createLoginDeps({ hasValidToken: false }); + const createContainer = mock(() => Promise.resolve(container)); + const loginFlow = mock(() => + Promise.resolve({ + status: "success" as const, + message: "Logged in successfully.", + }), + ); + const program = createProgramWithRootPreAction({ + createContainer, + loginFlow, + }); + + let ran = false; + program.command("languages").action(() => { + ran = true; + }); + + await program.parseAsync(["node", "githits", "languages"]); + + expect(ran).toBe(true); + expect(errorSpy.mock.calls.map((call) => call[0])).toEqual([ + "Authentication complete. Loading supported languages...", + ]); + errorSpy.mockRestore(); + }); + + it("prints the feedback continuation message after successful auto-login", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const container = createLoginDeps({ hasValidToken: false }); + const createContainer = mock(() => Promise.resolve(container)); + const loginFlow = mock(() => + Promise.resolve({ + status: "success" as const, + message: "Logged in successfully.", + }), + ); + const program = createProgramWithRootPreAction({ + createContainer, + loginFlow, + }); + + let ran = false; + program.command("feedback").action(() => { + ran = true; + }); + + await program.parseAsync(["node", "githits", "feedback"]); + + expect(ran).toBe(true); + expect(errorSpy.mock.calls.map((call) => call[0])).toEqual([ + "Authentication complete. Submitting feedback...", + ]); + errorSpy.mockRestore(); }); it("preserves a clear failure path when auto-login fails", async () => { @@ -240,6 +303,7 @@ describe("root CLI preAction", () => { expect(errorSpy.mock.calls.map((call) => call[0])).toEqual([ "Opening browser...", "Waiting for authentication...\n", + "Authentication complete. Running example search...", ]); logSpy.mockRestore(); errorSpy.mockRestore(); diff --git a/src/shared/root-cli-pre-action.ts b/src/shared/root-cli-pre-action.ts index 7fc62c11..81d53b58 100644 --- a/src/shared/root-cli-pre-action.ts +++ b/src/shared/root-cli-pre-action.ts @@ -4,7 +4,7 @@ import type { LoginFlowResult, LoginOptions, } from "../commands/login.js"; -import { maybeAutoLoginBeforeCommand } from "./auto-login.js"; +import { getCommandPath, maybeAutoLoginBeforeCommand } from "./auto-login.js"; export interface RootCliPreActionDependencies { createContainer: () => Promise< @@ -33,6 +33,13 @@ export function createRootCliPreAction( stdinIsTTY: deps.stdinIsTTY, stdoutIsTTY: deps.stdoutIsTTY, }); + if (authResult.status === "authenticated") { + const continuationMessage = getPostLoginContinuationMessage(command); + if (continuationMessage) { + console.error(continuationMessage); + } + } + if (authResult.status !== "failed") { return; } @@ -42,3 +49,16 @@ export function createRootCliPreAction( (deps.exit ?? process.exit)(1); }; } + +function getPostLoginContinuationMessage(command: Command): string | undefined { + switch (getCommandPath(command).join(" ")) { + case "example": + return "Authentication complete. Running example search..."; + case "languages": + return "Authentication complete. Loading supported languages..."; + case "feedback": + return "Authentication complete. Submitting feedback..."; + default: + return undefined; + } +} From c85752d3832988f086971ab0ff19ea46096c9bc2 Mon Sep 17 00:00:00 2001 From: Olli-Pekka Heinisuo Date: Fri, 1 May 2026 13:26:10 +0300 Subject: [PATCH 6/9] fix: remove restored code navigation capability gate Keeps the rebased signup flow aligned with the mainline behavior where package/source CLI surfaces register from package-service configuration instead of JWT capability checks. Removes the obsolete startup capability parser and updates registration tests and docs accordingly. --- docs/implementation/auth.md | 7 ++- docs/implementation/cli-commands.md | 24 +++++----- docs/implementation/signup-flow.md | 23 +++++----- src/cli.test.ts | 12 ++--- src/cli.ts | 34 ++------------ src/commands/code/index.test.ts | 31 ------------- src/commands/code/index.ts | 24 +++------- src/commands/pkg/index.test.ts | 31 ------------- src/commands/pkg/index.ts | 24 +++------- src/commands/search-registration.test.ts | 46 +------------------ src/commands/search.ts | 8 ---- src/container.ts | 34 -------------- src/services/code-navigation-capability.ts | 53 ---------------------- src/services/config.ts | 4 -- src/services/index.ts | 3 -- src/shared/code-navigation-cli-surface.ts | 12 ----- 16 files changed, 47 insertions(+), 323 deletions(-) delete mode 100644 src/services/code-navigation-capability.ts delete mode 100644 src/shared/code-navigation-cli-surface.ts diff --git a/docs/implementation/auth.md b/docs/implementation/auth.md index cf3817ea..deb225d0 100644 --- a/docs/implementation/auth.md +++ b/docs/implementation/auth.md @@ -60,10 +60,9 @@ For interactive `--json` invocations, the same bootstrap runs, but login progress is written to stderr so the command's JSON payload can remain the only stdout output. -This bootstrap does not widen the package/source command surface. The gated -`search`, `code`, and `pkg` commands still rely on startup capability checks -for registration and remain hidden until capability is known to be open, or a -local CLI override forces them on. +This bootstrap does not widen the package/source command surface with automatic +browser login. Package/source commands still register through the normal +package-service configuration path and rely on their action-level auth checks. ## Token Lifecycle diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 77aefb69..090c1d24 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -2,7 +2,7 @@ ## Purpose -The CLI exposes four primary top-level commands: `example`, `languages`, and `feedback` are always available, while `search` and the `code` / `pkg` command groups remain capability-gated package/source surfaces. Those gated commands are only registered when the package/source endpoint is configured and the startup auth context resolves code-navigation capability as enabled, or when the local CLI override is enabled. All of these commands share business logic with the MCP tools through the same service interfaces and shared utilities, but format output for terminal consumption instead of MCP tool results. +The CLI exposes `example`, `languages`, `feedback`, top-level indexed `search` / `search-status`, and the `code`, `docs`, and `pkg` command groups by default when the package/source endpoint is configured. All of these commands share business logic with the MCP tools through the same service interfaces and shared utilities, but format output for terminal consumption instead of MCP tool results. Phase 1 of the streamlined signup flow adds automatic browser login bootstrap to three interactive top-level commands: `example`, `languages`, and `feedback`. @@ -12,9 +12,9 @@ command after auth succeeds. For interactive `--json` runs, login progress is written to stderr so stdout stays machine-readable. The bootstrap remains disabled for non-interactive execution. -The gated package/source commands do not participate in that phase-1 bootstrap. -`search`, `code`, and `pkg` stay hidden unless the CLI can already prove the -capability is open at startup, or a local override forces the surface on. +The package/source commands do not participate in that phase-1 bootstrap; they +keep their existing action-level auth checks instead of launching browser login +from the root pre-action hook. ## Commands @@ -99,9 +99,9 @@ The original unified-search plan envisaged hiding partial mode entirely in v1 to **Source-status surfacing.** The JSON `sourceStatus` block is always passed through verbatim for debugging. Human-readable output surfaces only the actionable subset: ignored / incompatible filters, ignored / incompatible query features, free-form `note`s, and an `INDEXING` indicator when a source is still indexing on a partial-result payload. `STALE` (served from a slightly old index while a fresh reindex runs) is intentionally not shown in human output — agents and users do not need to second-guess otherwise-correct results, but it remains in JSON for diagnostics. -**Registration.** `search` stays capability-gated at registration time. Unlike -`example`, `languages`, and `feedback`, it does not auto-surface on a fresh -unauthenticated machine just to trigger browser login. +**Registration.** `search` registers when the package/source endpoint is +configured. Unlike `example`, `languages`, and `feedback`, it does not trigger +browser login from the root pre-action hook. ### `githits search-status` @@ -164,9 +164,8 @@ Finds functions, classes, modules, and doc sections inside an indexed dependency **Output.** Default terminal output leads each entry with `path:startLine-endLine [kind]`, followed by the symbol name and a 3-line dedented snippet. `--json` emits the shared success/error envelope also produced by the MCP `search_symbols` tool — see [`mcp-cli-parity.md`](./mcp-cli-parity.md) for the wire contract. The command is registered as `code search` with `code search-symbols` as a Commander alias. -**Registration.** The `code` group is registered only when the package/source -endpoint is configured and the startup capability gate is open for the CLI -surface, or when the local CLI override is enabled. +**Registration.** The `code` group registers when the package/source endpoint is +configured. **Troubleshooting.** Set `GITHITS_DEBUG=code-nav` to emit single-line JSON diagnostics to stderr on error paths. Include the output when filing an issue. Debug payloads never contain query text, tokens, or response bodies. @@ -189,9 +188,8 @@ Shows a concise overview for a single package: latest version, license, descript **Output envelope.** Success payload is hand-crafted for agent token efficiency: `{registry, name, version, description?, license?, homepage?, repository?, publishedAt?, downloads?, github?, install?, usage?, vulnerabilities?, recentChanges?}`. Omitted fields reflect backend nulls, not dropped data. Error envelope: `{error, code, retryable, details?}` — shared classifier family. Under `--json` the error envelope is written to **stderr** so stdout stays clean for `jq`. -**Registration.** Same as `code`: the command is visible only when the -package/source endpoint is configured and the startup capability gate is open -for the CLI surface, or when the local CLI override is enabled. +**Registration.** Same as `code`: the command is visible when the package/source +endpoint is configured. **Troubleshooting.** `GITHITS_DEBUG=pkg-intel` emits PII-safe classified-error diagnostics (area, event, code, error class, detail keys). Use `GITHITS_DEBUG=*` to enable all non-sensitive package/source diagnostics. diff --git a/docs/implementation/signup-flow.md b/docs/implementation/signup-flow.md index f67d1378..af3d0b32 100644 --- a/docs/implementation/signup-flow.md +++ b/docs/implementation/signup-flow.md @@ -78,8 +78,8 @@ Use these rules when implementing the feature: must not mix login progress logs into stdout. - Avoid launching the browser from non-interactive or host-driven contexts. - Reuse `loginFlow()` rather than duplicating OAuth orchestration. -- Keep startup registration behavior explicit for capability-gated command - groups. +- Keep startup registration behavior explicit for package/source command + groups. ## Implementation Checklist @@ -128,18 +128,17 @@ Use these rules when implementing the feature: - [ ] Keep existing command-level `AuthRequiredError` tests to verify the final invariant still holds. -### Phase 2: capability-gated command registration +### Phase 2: package/source command registration -- [x] Decide whether `search`, `code`, and `pkg` should be visible on a fresh - unauthenticated machine purely to allow auto-login on first use. - Decision: no, gated package/source features should stay hidden until - capability is known to be open. -- [x] Keep the startup registration gates in `src/commands/search.ts`, +- [x] Decide whether `search`, `code`, and `pkg` should trigger browser login + on first use. Decision: no, package/source commands keep their existing + action-level auth checks. +- [x] Keep startup registration in `src/commands/search.ts`, `src/commands/code/index.ts`, and `src/commands/pkg/index.ts` aligned - with that stricter capability policy. -- [x] Re-check help output and discoverability with the stricter gated-command - policy in place. -- [x] Add tests covering the hidden-unless-enabled registration behavior. + with the package-service URL configuration path. +- [x] Re-check help output and discoverability with the package/source command + registration policy in place. +- [x] Add tests covering URL-configured registration behavior. ## Open Decisions diff --git a/src/cli.test.ts b/src/cli.test.ts index b493e2fb..7383bcdd 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -105,8 +105,6 @@ function createProgramWithRootPreAction( async function createProgramForHelpSurface(options: { codeNavigationUrl?: string; - overrideEnabled?: boolean; - capability?: "enabled" | "disabled" | "unknown"; }): Promise { const program = new Command(); program.name("githits"); @@ -311,11 +309,9 @@ describe("root CLI preAction", () => { }); describe("CLI help surface", () => { - it("keeps gated commands out of root help when capability is closed", async () => { + it("keeps package/source commands out of root help when URL is disabled", async () => { const program = await createProgramForHelpSurface({ - codeNavigationUrl: "https://pkgseer.dev", - overrideEnabled: false, - capability: "disabled", + codeNavigationUrl: "", }); const help = program.helpInformation(); @@ -328,11 +324,9 @@ describe("CLI help surface", () => { expect(help).not.toMatch(/^\s{2}pkg\b/m); }); - it("shows gated commands in root help when capability is enabled", async () => { + it("shows package/source commands in root help when URL is configured", async () => { const program = await createProgramForHelpSurface({ codeNavigationUrl: "https://pkgseer.dev", - overrideEnabled: false, - capability: "enabled", }); const help = program.helpInformation(); diff --git a/src/cli.ts b/src/cli.ts index 40ac0e32..17570c84 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,14 +23,9 @@ import { registerUnifiedSearchCommands, } from "./commands/index.js"; import { loginFlow, stderrLoginOutput } from "./commands/login.js"; -import { - createContainer, - resolveStartupCodeNavigationRegistrationState, -} from "./container.js"; +import { createContainer } from "./container.js"; import { FileSystemServiceImpl, - getCodeNavigationUrl, - isCodeNavigationCliOverrideEnabled, NpmRegistryUpdateCheckService, } from "./services/index.js"; import { getCommandPath } from "./shared/auto-login.js"; @@ -136,27 +131,19 @@ registerLanguagesCommand(program); registerFeedbackCommand(program); const registrationArgv = stripRootRegistrationOptions(argv); -const shouldLoadCapabilityGatedCommands = - shouldEagerLoadSearchCommands(registrationArgv) || - shouldEagerLoadGatedCommandGroup(registrationArgv, "code") || - shouldEagerLoadGatedCommandGroup(registrationArgv, "pkg"); -const gatedCommandRegistrationOptions = shouldLoadCapabilityGatedCommands - ? await resolveGatedCommandRegistrationOptions() - : undefined; - if (shouldEagerLoadSearchCommands(registrationArgv)) { await withTelemetrySpan("cli.register.search", () => - registerUnifiedSearchCommands(program, gatedCommandRegistrationOptions), + registerUnifiedSearchCommands(program), ); } if (shouldEagerLoadGatedCommandGroup(registrationArgv, "code")) { await withTelemetrySpan("cli.register.code-group", () => - registerCodeCommandGroup(program, gatedCommandRegistrationOptions), + registerCodeCommandGroup(program), ); } if (shouldEagerLoadGatedCommandGroup(registrationArgv, "pkg")) { await withTelemetrySpan("cli.register.pkg-group", () => - registerPkgCommandGroup(program, gatedCommandRegistrationOptions), + registerPkgCommandGroup(program), ); } if (shouldEagerLoadGatedCommandGroup(registrationArgv, "docs")) { @@ -231,19 +218,6 @@ function isSearchHelpTarget(value: string | undefined): boolean { return value === "search" || value === "search-status"; } -async function resolveGatedCommandRegistrationOptions() { - const codeNavigationUrl = getCodeNavigationUrl(); - return { - codeNavigationUrl, - overrideEnabled: isCodeNavigationCliOverrideEnabled(), - capability: ( - await withTelemetrySpan("cli.resolve-code-nav-registration-state", () => - resolveStartupCodeNavigationRegistrationState(), - ) - ).capability, - }; -} - function getTelemetryCommandName(command: Command): string { const names = getCommandPath(command); diff --git a/src/commands/code/index.test.ts b/src/commands/code/index.test.ts index ebb98f53..409da831 100644 --- a/src/commands/code/index.test.ts +++ b/src/commands/code/index.test.ts @@ -18,7 +18,6 @@ describe("registerCodeCommandGroup", () => { const program = new Command(); await registerCodeCommandGroup(program, { codeNavigationUrl: "https://nav.example.com", - capability: "enabled", }); const codeCommand = program.commands.find( @@ -29,34 +28,4 @@ describe("registerCodeCommandGroup", () => { codeCommand?.commands.some((command) => command.name() === "files"), ).toBe(true); }); - - it("registers the code command group when override and URL are set", async () => { - const program = new Command(); - await registerCodeCommandGroup(program, { - codeNavigationUrl: "https://nav.example.com", - overrideEnabled: true, - capability: "disabled", - }); - - const codeCommand = program.commands.find( - (command) => command.name() === "code", - ); - expect(codeCommand).toBeDefined(); - expect( - codeCommand?.commands.some((command) => command.name() === "files"), - ).toBe(true); - }); - - it("does not register the code command group when capability is unknown", async () => { - const program = new Command(); - await registerCodeCommandGroup(program, { - codeNavigationUrl: "https://nav.example.com", - overrideEnabled: false, - capability: "unknown", - }); - - expect(program.commands.some((command) => command.name() === "code")).toBe( - false, - ); - }); }); diff --git a/src/commands/code/index.ts b/src/commands/code/index.ts index 2ce45ee0..9ba76795 100644 --- a/src/commands/code/index.ts +++ b/src/commands/code/index.ts @@ -1,33 +1,23 @@ import type { Command } from "commander"; import { - type CodeNavigationCapability, - getCodeNavigationUrl, -} from "../../services/index.js"; -import { isCodeNavigationCliSurfaceOpen } from "../../shared/code-navigation-cli-surface.js"; + type GatedCommandGroupOptions, + resolveGatedCommandGroupRegistrationState, +} from "../gated-command-group.js"; import { registerCodeFilesCommand } from "./files.js"; import { registerCodeGrepCommand } from "./grep.js"; import { registerCodeReadCommand } from "./read.js"; -export interface CodeCommandGroupOptions { - codeNavigationUrl?: string; - overrideEnabled?: boolean; - capability?: CodeNavigationCapability; -} +export interface CodeCommandGroupOptions extends GatedCommandGroupOptions {} /** - * Registers the code-navigation command group only when the endpoint URL - * is configured and the capability gate is open for the CLI surface. + * Registers the code-navigation command group. */ export async function registerCodeCommandGroup( program: Command, options: CodeCommandGroupOptions = {}, ): Promise { - const codeNavigationUrl = options.codeNavigationUrl ?? getCodeNavigationUrl(); - if (!codeNavigationUrl) { - return; - } - - if (!isCodeNavigationCliSurfaceOpen(options)) { + const registration = await resolveGatedCommandGroupRegistrationState(options); + if (!registration.shouldRegister) { return; } diff --git a/src/commands/pkg/index.test.ts b/src/commands/pkg/index.test.ts index 7babfda2..b782ecc3 100644 --- a/src/commands/pkg/index.test.ts +++ b/src/commands/pkg/index.test.ts @@ -18,7 +18,6 @@ describe("registerPkgCommandGroup", () => { const program = new Command(); await registerPkgCommandGroup(program, { codeNavigationUrl: "https://pkgseer.dev", - capability: "enabled", }); const pkgCommand = program.commands.find( @@ -35,34 +34,4 @@ describe("registerPkgCommandGroup", () => { pkgCommand?.commands.some((command) => command.name() === "deps"), ).toBe(true); }); - - it("registers the pkg command group when override and URL are set", async () => { - const program = new Command(); - await registerPkgCommandGroup(program, { - codeNavigationUrl: "https://pkgseer.dev", - overrideEnabled: true, - capability: "disabled", - }); - - const pkgCommand = program.commands.find( - (command) => command.name() === "pkg", - ); - expect(pkgCommand).toBeDefined(); - expect( - pkgCommand?.commands.some((command) => command.name() === "info"), - ).toBe(true); - }); - - it("does not register the pkg command group when capability is unknown", async () => { - const program = new Command(); - await registerPkgCommandGroup(program, { - codeNavigationUrl: "https://pkgseer.dev", - overrideEnabled: false, - capability: "unknown", - }); - - expect(program.commands.some((command) => command.name() === "pkg")).toBe( - false, - ); - }); }); diff --git a/src/commands/pkg/index.ts b/src/commands/pkg/index.ts index f4cd110b..d0c161be 100644 --- a/src/commands/pkg/index.ts +++ b/src/commands/pkg/index.ts @@ -1,34 +1,24 @@ import type { Command } from "commander"; import { - type CodeNavigationCapability, - getCodeNavigationUrl, -} from "../../services/index.js"; -import { isCodeNavigationCliSurfaceOpen } from "../../shared/code-navigation-cli-surface.js"; + type GatedCommandGroupOptions, + resolveGatedCommandGroupRegistrationState, +} from "../gated-command-group.js"; import { registerPkgChangelogCommand } from "./changelog.js"; import { registerPkgDepsCommand } from "./deps.js"; import { registerPkgInfoCommand } from "./info.js"; import { registerPkgVulnsCommand } from "./vulns.js"; -export interface PkgCommandGroupOptions { - codeNavigationUrl?: string; - overrideEnabled?: boolean; - capability?: CodeNavigationCapability; -} +export interface PkgCommandGroupOptions extends GatedCommandGroupOptions {} /** - * Registers the `pkg` command group only when the package/source endpoint - * is configured and the capability gate is open for the CLI surface. + * Registers the `pkg` command group. */ export async function registerPkgCommandGroup( program: Command, options: PkgCommandGroupOptions = {}, ): Promise { - const codeNavigationUrl = options.codeNavigationUrl ?? getCodeNavigationUrl(); - if (!codeNavigationUrl) { - return; - } - - if (!isCodeNavigationCliSurfaceOpen(options)) { + const registration = await resolveGatedCommandGroupRegistrationState(options); + if (!registration.shouldRegister) { return; } diff --git a/src/commands/search-registration.test.ts b/src/commands/search-registration.test.ts index 6ad9ee50..0780417c 100644 --- a/src/commands/search-registration.test.ts +++ b/src/commands/search-registration.test.ts @@ -17,28 +17,10 @@ describe("registerUnifiedSearchCommands", () => { ).toBe(false); }); - it("does not register search commands when capability is disabled", async () => { + it("registers search commands when code navigation URL is configured", async () => { const program = new Command(); await registerUnifiedSearchCommands(program, { codeNavigationUrl: "https://nav.example.com", - overrideEnabled: false, - capability: "disabled", - }); - - expect( - program.commands.some((command) => command.name() === "search"), - ).toBe(false); - expect( - program.commands.some((command) => command.name() === "search-status"), - ).toBe(false); - }); - - it("registers search commands when capability is enabled", async () => { - const program = new Command(); - await registerUnifiedSearchCommands(program, { - codeNavigationUrl: "https://nav.example.com", - overrideEnabled: false, - capability: "enabled", }); expect( @@ -48,30 +30,4 @@ describe("registerUnifiedSearchCommands", () => { program.commands.some((command) => command.name() === "search-status"), ).toBe(true); }); - - it("registers search commands when override is enabled", async () => { - const program = new Command(); - await registerUnifiedSearchCommands(program, { - codeNavigationUrl: "https://nav.example.com", - overrideEnabled: true, - capability: "disabled", - }); - - expect( - program.commands.some((command) => command.name() === "search"), - ).toBe(true); - }); - - it("does not register search commands when capability is unknown", async () => { - const program = new Command(); - await registerUnifiedSearchCommands(program, { - codeNavigationUrl: "https://nav.example.com", - overrideEnabled: false, - capability: "unknown", - }); - - expect( - program.commands.some((command) => command.name() === "search"), - ).toBe(false); - }); }); diff --git a/src/commands/search.ts b/src/commands/search.ts index becd5af5..c9812abd 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,11 +1,9 @@ import { type Command, Option } from "commander"; -import type { CodeNavigationCapability } from "../services/index.js"; import type { CodeNavigationService, UnifiedSearchSource, } from "../services/code-navigation-service.js"; import { getCodeNavigationUrl } from "../services/config.js"; -import { isCodeNavigationCliSurfaceOpen } from "../shared/code-navigation-cli-surface.js"; import { buildUnifiedSearchErrorPayload, buildUnifiedSearchParams, @@ -50,8 +48,6 @@ export interface SearchStatusCommandOptions { export interface SearchCommandRegistrationOptions { codeNavigationUrl?: string; - overrideEnabled?: boolean; - capability?: CodeNavigationCapability; } export interface SearchCommandDependencies { @@ -262,10 +258,6 @@ export async function registerUnifiedSearchCommands( return; } - if (!isCodeNavigationCliSurfaceOpen(options)) { - return; - } - registerSearchCommand(program); } diff --git a/src/container.ts b/src/container.ts index e2e3b979..c33923f5 100644 --- a/src/container.ts +++ b/src/container.ts @@ -8,14 +8,12 @@ import { type BrowserService, BrowserServiceImpl, ChunkingKeyringService, - type CodeNavigationCapability, type CodeNavigationService, CodeNavigationServiceImpl, type FileSystemService, FileSystemServiceImpl, GitHitsServiceImpl, getApiUrl, - getCodeNavigationCapability, getAuthFileStorageDir, getCodeNavigationUrl, getEnvApiToken, @@ -252,35 +250,3 @@ export async function createContainer(): Promise { }; }); } - -export interface StartupCodeNavigationRegistrationState { - capability: CodeNavigationCapability; - expiredStoredAuth: boolean; -} - -export async function resolveStartupCodeNavigationRegistrationState(): Promise { - return withTelemetrySpan( - "startup.resolve-code-nav-registration-state", - async () => { - const envToken = getEnvApiToken(); - if (envToken) { - return { - capability: getCodeNavigationCapability(envToken), - expiredStoredAuth: false, - }; - } - - const fileSystemService = new FileSystemServiceImpl(); - const authStorage = await createAuthStorage(fileSystemService); - const tokens = await authStorage.loadTokens(getMcpUrl()); - if (tokens?.expiresAt && new Date(tokens.expiresAt) < new Date()) { - return { capability: "unknown", expiredStoredAuth: true }; - } - - return { - capability: getCodeNavigationCapability(tokens?.accessToken), - expiredStoredAuth: false, - }; - }, - ); -} diff --git a/src/services/code-navigation-capability.ts b/src/services/code-navigation-capability.ts deleted file mode 100644 index c5335170..00000000 --- a/src/services/code-navigation-capability.ts +++ /dev/null @@ -1,53 +0,0 @@ -export type CodeNavigationCapability = "enabled" | "disabled" | "unknown"; - -interface JwtPayload { - code_navigation?: unknown; - codeNavigation?: unknown; - capabilities?: unknown; -} - -export function getCodeNavigationCapability( - accessToken: string | undefined, -): CodeNavigationCapability { - if (!accessToken) { - return "unknown"; - } - - const payload = decodeJwtPayload(accessToken); - if (!payload) { - return "unknown"; - } - - if (payload.code_navigation === true || payload.codeNavigation === true) { - return "enabled"; - } - - if (payload.code_navigation === false || payload.codeNavigation === false) { - return "disabled"; - } - - if (Array.isArray(payload.capabilities)) { - return payload.capabilities.includes("code_navigation") - ? "enabled" - : "disabled"; - } - - return "unknown"; -} - -function decodeJwtPayload(accessToken: string): JwtPayload | undefined { - const [, encodedPayload] = accessToken.split("."); - if (!encodedPayload) { - return undefined; - } - - try { - const normalized = encodedPayload - .replaceAll("-", "+") - .replaceAll("_", "/") - .padEnd(Math.ceil(encodedPayload.length / 4) * 4, "="); - return JSON.parse(Buffer.from(normalized, "base64").toString("utf8")); - } catch { - return undefined; - } -} \ No newline at end of file diff --git a/src/services/config.ts b/src/services/config.ts index 8a0e36b4..6dc5225b 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -43,10 +43,6 @@ export function getCodeNavigationUrl(): string { return DEFAULT_CODE_NAV_URL; } -export function isCodeNavigationCliOverrideEnabled(): boolean { - return process.env.GITHITS_CODE_NAVIGATION === "1"; -} - /** * Get API token from environment variable (for CI/automation). */ diff --git a/src/services/index.ts b/src/services/index.ts index 78f99f52..981a9c5e 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -92,14 +92,11 @@ export { CodeNavigationVersionNotFoundError, MalformedCodeNavigationResponseError, } from "./code-navigation-service.js"; -export type { CodeNavigationCapability } from "./code-navigation-capability.js"; -export { getCodeNavigationCapability } from "./code-navigation-capability.js"; export { getApiUrl, getCodeNavigationUrl, getEnvApiToken, getMcpUrl, - isCodeNavigationCliOverrideEnabled, } from "./config.js"; export type { ExecResult, ExecService } from "./exec-service.js"; export { ExecServiceImpl } from "./exec-service.js"; diff --git a/src/shared/code-navigation-cli-surface.ts b/src/shared/code-navigation-cli-surface.ts deleted file mode 100644 index 4697aa2c..00000000 --- a/src/shared/code-navigation-cli-surface.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { CodeNavigationCapability } from "../services/index.js"; - -export interface CodeNavigationCliSurfaceOptions { - overrideEnabled?: boolean; - capability?: CodeNavigationCapability; -} - -export function isCodeNavigationCliSurfaceOpen( - options: CodeNavigationCliSurfaceOptions, -): boolean { - return options.overrideEnabled === true || options.capability === "enabled"; -} From 03e095dcc870061176bb46d5589498acb3e7f287 Mon Sep 17 00:00:00 2001 From: Olli-Pekka Heinisuo Date: Fri, 1 May 2026 13:30:55 +0300 Subject: [PATCH 7/9] fix: trigger signup flow for onboarding commands Expands the interactive auto-login policy to cover the advertised init onboarding path and auth-required package/source commands, while preserving explicit opt-outs such as init --skip-login and host-driven MCP commands. --- docs/implementation/auth.md | 14 ++++--- docs/implementation/cli-commands.md | 23 +++++------ docs/implementation/signup-flow.md | 19 +++++---- src/cli.test.ts | 63 +++++++++++++++++++++++++++++ src/shared/auto-login.test.ts | 61 +++++++++++++++++++++++++--- src/shared/auto-login.ts | 16 ++++++++ src/shared/root-cli-pre-action.ts | 14 +++++++ 7 files changed, 178 insertions(+), 32 deletions(-) diff --git a/docs/implementation/auth.md b/docs/implementation/auth.md index deb225d0..956ab1a4 100644 --- a/docs/implementation/auth.md +++ b/docs/implementation/auth.md @@ -54,15 +54,17 @@ The bootstrap deliberately does **not** run for: - non-interactive/stdio-driven execution - explicit auth and recovery surfaces such as `login`, `logout`, `auth status`, - `init`, and `mcp` + and `mcp` For interactive `--json` invocations, the same bootstrap runs, but login progress is written to stderr so the command's JSON payload can remain the only stdout output. -This bootstrap does not widen the package/source command surface with automatic -browser login. Package/source commands still register through the normal -package-service configuration path and rely on their action-level auth checks. +The bootstrap applies to the advertised onboarding path (`init`) and to +interactive user-facing commands that require authentication. `init --skip-login` +honors the explicit skip and bypasses the bootstrap. Package/source commands +still register through the normal package-service configuration path, and all +command actions keep their action-level auth checks as the final invariant. ## Token Lifecycle @@ -196,8 +198,8 @@ Per API call (via RefreshingGitHitsService): The MCP server starts without a synchronous auth gate. Tool calls resolve tokens through the shared token provider and return per-tool auth errors when no valid token is available. -For `example`, `languages`, and `feedback`, there is now one extra step before -the action runs: +For `init` and interactive user-facing commands that require authentication, +there is now one extra step before the action runs: ``` CLI preAction hook diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 090c1d24..6691ed72 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -4,17 +4,14 @@ The CLI exposes `example`, `languages`, `feedback`, top-level indexed `search` / `search-status`, and the `code`, `docs`, and `pkg` command groups by default when the package/source endpoint is configured. All of these commands share business logic with the MCP tools through the same service interfaces and shared utilities, but format output for terminal consumption instead of MCP tool results. -Phase 1 of the streamlined signup flow adds automatic browser login bootstrap to -three interactive top-level commands: `example`, `languages`, and `feedback`. -When those commands run in an interactive TTY without a valid token, the CLI -launches the existing OAuth login flow first, then continues the original -command after auth succeeds. For interactive `--json` runs, login progress is -written to stderr so stdout stays machine-readable. The bootstrap remains -disabled for non-interactive execution. - -The package/source commands do not participate in that phase-1 bootstrap; they -keep their existing action-level auth checks instead of launching browser login -from the root pre-action hook. +The streamlined signup flow adds automatic browser login bootstrap to `init` +and interactive user-facing commands that require authentication. When those +commands run in an interactive TTY without a valid token, the CLI launches the +existing OAuth login flow first, then continues the original command after auth +succeeds. `init --skip-login` honors the explicit skip. For interactive `--json` +runs, login progress is written to stderr so stdout stays machine-readable. The +bootstrap remains disabled for non-interactive execution, and explicit +auth/recovery plus MCP host commands remain exempt. ## Commands @@ -100,8 +97,8 @@ The original unified-search plan envisaged hiding partial mode entirely in v1 to **Source-status surfacing.** The JSON `sourceStatus` block is always passed through verbatim for debugging. Human-readable output surfaces only the actionable subset: ignored / incompatible filters, ignored / incompatible query features, free-form `note`s, and an `INDEXING` indicator when a source is still indexing on a partial-result payload. `STALE` (served from a slightly old index while a fresh reindex runs) is intentionally not shown in human output — agents and users do not need to second-guess otherwise-correct results, but it remains in JSON for diagnostics. **Registration.** `search` registers when the package/source endpoint is -configured. Unlike `example`, `languages`, and `feedback`, it does not trigger -browser login from the root pre-action hook. +configured. Interactive unauthenticated runs trigger the root browser-login +bootstrap before the action executes. ### `githits search-status` diff --git a/docs/implementation/signup-flow.md b/docs/implementation/signup-flow.md index af3d0b32..8cfafdd9 100644 --- a/docs/implementation/signup-flow.md +++ b/docs/implementation/signup-flow.md @@ -46,17 +46,21 @@ surprising or actively harmful. | `login` | Already the explicit auth entry point. | | `logout` | Must work even when auth is broken or absent. | | `auth status` | Informational; should explain missing auth, not launch browser. | -| `init` | Already has its own login orchestration and fallback prompts. | | `mcp` / `mcp start` | Agent hosts and stdio launches should not unexpectedly open a browser. | ### Phase 1 candidates -These are always-available, human-invoked commands where auto-login is a clear -UX improvement: +These are human-invoked commands where auto-login is a clear UX improvement: +- `init` +- `init --skip-login` remains an explicit opt-out - `example` - `languages` - `feedback` +- `search` / `search-status` +- `code files` / `code read` / `code grep` +- `docs list` / `docs read` +- `pkg info` / `pkg vulns` / `pkg deps` / `pkg changelog` ### Phase 2 candidates @@ -96,7 +100,8 @@ Use these rules when implementing the feature: requests `--json`. - [x] Keep the current `requireAuth()` checks in command actions as a final invariant. -- [x] Wire the new bootstrap flow for `example`, `languages`, and `feedback`. +- [x] Wire the new bootstrap flow for `init` and interactive user-facing + commands that require authentication. ### Phase 1.1: login output hygiene @@ -130,9 +135,9 @@ Use these rules when implementing the feature: ### Phase 2: package/source command registration -- [x] Decide whether `search`, `code`, and `pkg` should trigger browser login - on first use. Decision: no, package/source commands keep their existing - action-level auth checks. +- [x] Decide whether `search`, `code`, `docs`, and `pkg` should trigger browser + login on first use. Decision: yes for interactive CLI use, while keeping + action-level auth checks as the final invariant. - [x] Keep startup registration in `src/commands/search.ts`, `src/commands/code/index.ts`, and `src/commands/pkg/index.ts` aligned with the package-service URL configuration path. diff --git a/src/cli.test.ts b/src/cli.test.ts index 7383bcdd..5444552f 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -234,6 +234,69 @@ describe("root CLI preAction", () => { errorSpy.mockRestore(); }); + it("triggers auto-login for init before setup runs", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const container = createLoginDeps({ hasValidToken: false }); + const createContainer = mock(() => Promise.resolve(container)); + const loginFlow = mock(() => + Promise.resolve({ + status: "success" as const, + message: "Logged in successfully.", + }), + ); + const program = createProgramWithRootPreAction({ + createContainer, + loginFlow, + }); + + let ran = false; + program.command("init").action(() => { + ran = true; + }); + + await program.parseAsync(["node", "githits", "init"]); + + expect(ran).toBe(true); + expect(createContainer).toHaveBeenCalledTimes(1); + expect(loginFlow).toHaveBeenCalledWith({}, container); + expect(errorSpy.mock.calls.map((call) => call[0])).toEqual([ + "Authentication complete. Continuing setup...", + ]); + errorSpy.mockRestore(); + }); + + it("triggers auto-login for nested package/source commands", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const container = createLoginDeps({ hasValidToken: false }); + const createContainer = mock(() => Promise.resolve(container)); + const loginFlow = mock(() => + Promise.resolve({ + status: "success" as const, + message: "Logged in successfully.", + }), + ); + const program = createProgramWithRootPreAction({ + createContainer, + loginFlow, + }); + + let ran = false; + const pkgCommand = program.command("pkg"); + pkgCommand.command("info").action(() => { + ran = true; + }); + + await program.parseAsync(["node", "githits", "pkg", "info"]); + + expect(ran).toBe(true); + expect(createContainer).toHaveBeenCalledTimes(1); + expect(loginFlow).toHaveBeenCalledWith({}, container); + expect(errorSpy.mock.calls.map((call) => call[0])).toEqual([ + "Authentication complete. Running command...", + ]); + errorSpy.mockRestore(); + }); + it("preserves a clear failure path when auto-login fails", async () => { const errorSpy = spyOn(console, "error").mockImplementation(() => {}); const createContainer = mock(() => Promise.resolve(createLoginDeps())); diff --git a/src/shared/auto-login.test.ts b/src/shared/auto-login.test.ts index d219f116..8b1e87d9 100644 --- a/src/shared/auto-login.test.ts +++ b/src/shared/auto-login.test.ts @@ -68,6 +68,47 @@ describe("isAutoLoginEligibleCommand", () => { ).toBe(true); }); + it("allows interactive init invocations for first-run onboarding", () => { + expect( + isAutoLoginEligibleCommand(createCommand(["init"]), { + stdinIsTTY: true, + stdoutIsTTY: true, + }), + ).toBe(true); + }); + + it("honors init --skip-login", () => { + expect( + isAutoLoginEligibleCommand(createCommand(["init"], { skipLogin: true }), { + stdinIsTTY: true, + stdoutIsTTY: true, + }), + ).toBe(false); + }); + + it("allows interactive package/source command invocations", () => { + for (const path of [ + ["search"], + ["search-status"], + ["code", "files"], + ["code", "read"], + ["code", "grep"], + ["docs", "list"], + ["docs", "read"], + ["pkg", "info"], + ["pkg", "vulns"], + ["pkg", "deps"], + ["pkg", "changelog"], + ]) { + expect( + isAutoLoginEligibleCommand(createCommand(path), { + stdinIsTTY: true, + stdoutIsTTY: true, + }), + ).toBe(true); + } + }); + it("allows interactive --json invocations once login output is redirected", () => { expect( isAutoLoginEligibleCommand(createCommand(["example"], { json: true }), { @@ -87,12 +128,20 @@ describe("isAutoLoginEligibleCommand", () => { }); it("skips exempt commands", () => { - expect( - isAutoLoginEligibleCommand(createCommand(["logout"]), { - stdinIsTTY: true, - stdoutIsTTY: true, - }), - ).toBe(false); + for (const path of [ + ["login"], + ["logout"], + ["auth", "status"], + ["mcp"], + ["mcp", "start"], + ]) { + expect( + isAutoLoginEligibleCommand(createCommand(path), { + stdinIsTTY: true, + stdoutIsTTY: true, + }), + ).toBe(false); + } }); }); diff --git a/src/shared/auto-login.ts b/src/shared/auto-login.ts index bd8e01d1..5ee7119b 100644 --- a/src/shared/auto-login.ts +++ b/src/shared/auto-login.ts @@ -5,9 +5,21 @@ import type { } from "../commands/login.js"; const AUTO_LOGIN_ELIGIBLE_COMMANDS = new Set([ + "init", "example", "languages", "feedback", + "search", + "search-status", + "code files", + "code read", + "code grep", + "docs list", + "docs read", + "pkg info", + "pkg vulns", + "pkg deps", + "pkg changelog", ]); export interface CommandLike { @@ -61,6 +73,10 @@ export function isAutoLoginEligibleCommand( }, ): boolean { const commandPath = getCommandPath(command).join(" "); + if (commandPath === "init" && command.opts().skipLogin === true) { + return false; + } + if (!AUTO_LOGIN_ELIGIBLE_COMMANDS.has(commandPath)) { return false; } diff --git a/src/shared/root-cli-pre-action.ts b/src/shared/root-cli-pre-action.ts index 81d53b58..c9a90983 100644 --- a/src/shared/root-cli-pre-action.ts +++ b/src/shared/root-cli-pre-action.ts @@ -52,12 +52,26 @@ export function createRootCliPreAction( function getPostLoginContinuationMessage(command: Command): string | undefined { switch (getCommandPath(command).join(" ")) { + case "init": + return "Authentication complete. Continuing setup..."; case "example": return "Authentication complete. Running example search..."; case "languages": return "Authentication complete. Loading supported languages..."; case "feedback": return "Authentication complete. Submitting feedback..."; + case "search": + case "search-status": + case "code files": + case "code read": + case "code grep": + case "docs list": + case "docs read": + case "pkg info": + case "pkg vulns": + case "pkg deps": + case "pkg changelog": + return "Authentication complete. Running command..."; default: return undefined; } From 97381ae5440715b033a7407a394acf5d6f02be17 Mon Sep 17 00:00:00 2001 From: Olli-Pekka Heinisuo Date: Fri, 1 May 2026 13:33:09 +0300 Subject: [PATCH 8/9] docs: update signup flow registration notes Removes stale gated-command wording from the signup-flow implementation notes now that package/source commands use URL-configured registration and participate in the interactive signup bootstrap. --- docs/implementation/signup-flow.md | 32 ++++++++++++++---------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/docs/implementation/signup-flow.md b/docs/implementation/signup-flow.md index 8cfafdd9..ee6be885 100644 --- a/docs/implementation/signup-flow.md +++ b/docs/implementation/signup-flow.md @@ -62,15 +62,12 @@ These are human-invoked commands where auto-login is a clear UX improvement: - `docs list` / `docs read` - `pkg info` / `pkg vulns` / `pkg deps` / `pkg changelog` -### Phase 2 candidates +### Package/source registration note -These need extra work because they are not always registered when no auth state -is available at startup: - -- `search` -- `search-status` -- `code ...` -- `pkg ...` +Package/source commands (`search`, `search-status`, `code`, `docs`, and `pkg`) +register through the normal package-service URL configuration path. The signup +bootstrap can therefore run for their interactive invocations before the command +action reaches the final `requireAuth()` check. ## Decision Criteria @@ -82,8 +79,8 @@ Use these rules when implementing the feature: must not mix login progress logs into stdout. - Avoid launching the browser from non-interactive or host-driven contexts. - Reuse `loginFlow()` rather than duplicating OAuth orchestration. -- Keep startup registration behavior explicit for package/source command - groups. +- Keep startup registration behavior explicit for package/source command groups: + registration follows package-service URL configuration. ## Implementation Checklist @@ -160,10 +157,11 @@ Use these rules when implementing the feature: | `src/commands/login.ts` | Reusable login flow and current standalone login output. | | `src/shared/require-auth.ts` | Final invariant for command actions after bootstrap. | | `src/container.ts` | Startup token resolution and auth-state snapshot. | -| `src/commands/init/init.ts` | Existing example of composing `loginFlow()` into another command. | -| `src/commands/search.ts` | Top-level gated command registration. | -| `src/commands/code/index.ts` | `code` group registration gate. | -| `src/commands/pkg/index.ts` | `pkg` group registration gate. | -| `src/commands/example.ts` | Phase 1 command target. | -| `src/commands/languages.ts` | Phase 1 command target. | -| `src/commands/feedback.ts` | Phase 1 command target. | \ No newline at end of file +| `src/commands/init/init.ts` | Advertised onboarding command; `--skip-login` remains the explicit opt-out. | +| `src/commands/search.ts` | Top-level package/source registration and auth-required action. | +| `src/commands/code/index.ts` | `code` group package-service URL registration. | +| `src/commands/docs/index.ts` | `docs` group package-service URL registration. | +| `src/commands/pkg/index.ts` | `pkg` group package-service URL registration. | +| `src/commands/example.ts` | Auth-required command action still protected by `requireAuth()`. | +| `src/commands/languages.ts` | Auth-required command action still protected by `requireAuth()`. | +| `src/commands/feedback.ts` | Auth-required command action still protected by `requireAuth()`. | \ No newline at end of file From 5a9b14871c5cfa75548787b3f07e2ae2585e428b Mon Sep 17 00:00:00 2001 From: Olli-Pekka Heinisuo Date: Fri, 1 May 2026 13:42:40 +0300 Subject: [PATCH 9/9] fix: trim signup flow diff --- README.md | 28 +------------- docs/implementation/auth.md | 51 +------------------------ docs/implementation/cli-commands.md | 58 +---------------------------- docs/implementation/signup-flow.md | 14 ++----- package.json | 2 +- src/cli.ts | 21 +++++++---- src/commands/login.test.ts | 28 -------------- src/commands/login.ts | 9 +---- 8 files changed, 22 insertions(+), 189 deletions(-) diff --git a/README.md b/README.md index 3b5922de..bd9f27dd 100644 --- a/README.md +++ b/README.md @@ -16,16 +16,6 @@ Supported tools: Claude Code, Cursor, Windsurf, VS Code / Copilot, Cline, Claude If you are using a tool that is not listed above, use the manual MCP setup instructions near the end of this README. -For a one-shot first run without installing the CLI globally: - -```sh -npx -y githits@latest example "how to use express middleware" -l javascript -``` - -In an interactive terminal, `example`, `languages`, and `feedback` now open -the browser login flow automatically when no valid token is available, then -continue the original command after authentication. - ### Plugin Installation (Open Plugin standard) The npm package includes Open Plugin-compatible files: @@ -144,12 +134,6 @@ npx githits login Opens your browser for secure OAuth authentication. Tokens are stored in the system keychain by default and refreshed automatically on next use. If a refresh fails (e.g., after an extended idle period), run `githits login` again. -In interactive TTY sessions, `example`, `languages`, and `feedback` also -trigger this browser flow automatically on first use. For interactive -`--json` runs, login progress is written to stderr so stdout stays -machine-readable. Non-interactive runs, `auth status`, `logout`, `init`, and -`mcp` remain explicit-auth flows. - Useful flags: - `--no-browser` — prints a URL instead of opening a browser (for SSH sessions, CI, or headless environments) @@ -190,17 +174,7 @@ githits languages List or filter supported language names githits feedback Send feedback on a returned example ``` -Interactive first-run UX: - -- `githits example ...`, `githits languages ...`, and `githits feedback ...` - will open the browser login flow automatically when no valid token is - available. -- Interactive `--json` variants also auto-open the browser when needed, but - login progress is written to stderr so stdout remains clean JSON. -- Non-interactive runs still require `githits login` first or a - `GITHITS_API_TOKEN`. - -When package/source access is enabled for the current token, two extra command groups are also available: +These indexed package/source commands are also available: ``` githits search ... Unified indexed dependency/repository search diff --git a/docs/implementation/auth.md b/docs/implementation/auth.md index 956ab1a4..2487e7d1 100644 --- a/docs/implementation/auth.md +++ b/docs/implementation/auth.md @@ -35,37 +35,6 @@ The login command (`src/commands/login.ts`) orchestrates a 9-step OAuth flow (ma The flow has a 5-minute timeout. The callback server must start before the browser opens so it's ready to receive the redirect. -### Automatic login bootstrap for interactive CLI commands - -Phase 1 of the streamlined signup flow adds a CLI-boundary bootstrap in -`src/cli.ts` for a small allowlist of interactive commands: - -- `githits example ...` -- `githits languages ...` -- `githits feedback ...` - -When one of those commands runs in an interactive TTY and no valid token is -available, the CLI calls the existing `loginFlow()` from `src/commands/login.ts` -before dispatching the command action. After authentication succeeds, the -original command continues and builds a fresh container with the newly saved -tokens. - -The bootstrap deliberately does **not** run for: - -- non-interactive/stdio-driven execution -- explicit auth and recovery surfaces such as `login`, `logout`, `auth status`, - and `mcp` - -For interactive `--json` invocations, the same bootstrap runs, but login -progress is written to stderr so the command's JSON payload can remain the only -stdout output. - -The bootstrap applies to the advertised onboarding path (`init`) and to -interactive user-facing commands that require authentication. `init --skip-login` -honors the explicit skip and bypasses the bootstrap. Package/source commands -still register through the normal package-service configuration path, and all -command actions keep their action-level auth checks as the final invariant. - ## Token Lifecycle Tokens are JWTs with a configurable expiration (typically 1 hour). The CLI handles expiration through a `TokenManager` (see `src/services/token-manager.ts`): @@ -198,25 +167,9 @@ Per API call (via RefreshingGitHitsService): The MCP server starts without a synchronous auth gate. Tool calls resolve tokens through the shared token provider and return per-tool auth errors when no valid token is available. -For `init` and interactive user-facing commands that require authentication, -there is now one extra step before the action runs: - -``` -CLI preAction hook - └─ eligible interactive command? - ├─ no → run command normally - └─ yes → createContainer() - ├─ valid token available? → run command normally - └─ no valid token → loginFlow() - ├─ success → continue into the original command action - └─ failure → print login error and exit 1 -``` - ## Troubleshooting - **"Authentication required" from a command or MCP tool** — No valid token found. Run `githits login` or set `GITHITS_API_TOKEN`. -- **Browser did not open for a piped or redirected invocation** — Expected. Auto-login bootstrap still requires an interactive TTY. Authenticate first with `githits login` or use `GITHITS_API_TOKEN`. -- **Interactive `--json` printed login progress** — That progress should go to stderr only. If stdout contains login chatter, the login reporter wiring in `src/commands/login.ts` / `src/cli.ts` has regressed. - **"Already logged in."** — Token is still valid. Use `githits login --force` to re-authenticate. - **Port conflicts on login** — The callback server uses the port from the stored client registration. On first login, a random port (8000–9999) is chosen and saved. Use `--port ` to change it (triggers re-registration). - **Token refresh fails silently** — By design. The token manager first reloads storage in case another process refreshed credentials. If the expired token is still unchanged, it clears that stale token and later calls prompt re-login. @@ -229,10 +182,8 @@ CLI preAction hook | File | What it demonstrates | |---|---| | `src/commands/login.ts` | Full OAuth PKCE flow orchestration | -| `src/cli.ts` | CLI pre-action bootstrap for phase-1 automatic login | | `src/commands/logout.ts` | Token and client removal and storage cleanup | -| `src/shared/auto-login.ts` | Command allowlist and auto-login decision logic | -| `src/container.ts` | Dependency wiring, keychain probe with fallback, and auth-command container without eager token refresh | +| `src/container.ts` | Dependency wiring and auth-command container without eager token refresh | | `src/services/token-manager.ts` | `TokenProvider` interface, `TokenManager` (proactive refresh, coalescing) | | `src/services/refreshing-githits-service.ts` | `GitHitsService` decorator with token refresh and 401 retry | | `src/services/execute-with-token-refresh.ts` | Shared helper for token-authenticated retry-on-refresh flows | diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 6691ed72..5b537d75 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -2,16 +2,7 @@ ## Purpose -The CLI exposes `example`, `languages`, `feedback`, top-level indexed `search` / `search-status`, and the `code`, `docs`, and `pkg` command groups by default when the package/source endpoint is configured. All of these commands share business logic with the MCP tools through the same service interfaces and shared utilities, but format output for terminal consumption instead of MCP tool results. - -The streamlined signup flow adds automatic browser login bootstrap to `init` -and interactive user-facing commands that require authentication. When those -commands run in an interactive TTY without a valid token, the CLI launches the -existing OAuth login flow first, then continues the original command after auth -succeeds. `init --skip-login` honors the explicit skip. For interactive `--json` -runs, login progress is written to stderr so stdout stays machine-readable. The -bootstrap remains disabled for non-interactive execution, and explicit -auth/recovery plus MCP host commands remain exempt. +The CLI exposes `example`, `languages`, `feedback`, top-level indexed `search` / `search-status`, and the `code`, `docs`, and `pkg` command groups by default. All of these commands share business logic with the MCP tools through the same service interfaces and shared utilities, but format output for terminal consumption instead of MCP tool results. ## Commands @@ -61,11 +52,6 @@ githits example "react hooks patterns" -l typescript --json Default output is markdown (the API response). `--lang` is optional; when omitted, the backend infers the language from the query. With `--explain`, an AI-generated explanation is included alongside the code example. With `--json`, output is `{ "result": "", "solution_id": "" }` (`solution_id` is omitted only if the markdown lacks a solution URL — pass it back to `feedback`). The MCP `get_example` tool always sends `include_explanation: false` since LLMs don't need the extra context. -When run interactively without a valid token, `githits example` now triggers -the browser login flow automatically before performing the search. On -interactive `--json` runs, login progress goes to stderr so stdout remains the -JSON payload. - ### `githits search` ``` @@ -96,10 +82,6 @@ The original unified-search plan envisaged hiding partial mode entirely in v1 to **Source-status surfacing.** The JSON `sourceStatus` block is always passed through verbatim for debugging. Human-readable output surfaces only the actionable subset: ignored / incompatible filters, ignored / incompatible query features, free-form `note`s, and an `INDEXING` indicator when a source is still indexing on a partial-result payload. `STALE` (served from a slightly old index while a fresh reindex runs) is intentionally not shown in human output — agents and users do not need to second-guess otherwise-correct results, but it remains in JSON for diagnostics. -**Registration.** `search` registers when the package/source endpoint is -configured. Interactive unauthenticated runs trigger the root browser-login -bootstrap before the action executes. - ### `githits search-status` ``` @@ -121,9 +103,6 @@ githits languages type --json # JSON output for piping Without a query, lists all languages. With a query, filters to top 5 matches using the same logic as the `search_language` MCP tool (case-insensitive substring match on name, display_name, and aliases). Default output uses colored terminal formatting. JSON output is `[{ "name": "...", "display_name": "...", "aliases": [...] }, ...]`. -Interactive runs without a valid token auto-trigger browser login before the -language lookup. On interactive `--json` runs, login progress goes to stderr. - ### `githits feedback` ``` @@ -134,38 +113,6 @@ githits feedback abc123 --accept --message "Solved my problem" --json `--accept` and `--reject` are mutually exclusive (enforced by Commander's `.conflicts()` API). At least one must be provided (validated in the action function). JSON output is `{ "success": true, "message": "..." }`. -Interactive runs without a valid token auto-trigger browser login before the -feedback submission. On interactive `--json` runs, login progress goes to -stderr. - -### `githits code search` - -This is now the older symbol-search surface. Prefer top-level `githits search --source symbol` for new flows unless you specifically need the legacy code-search UX or its exact JSON contract. - -``` -githits code search npm:express middleware -githits code search npm:express middleware --intent all -githits code search pypi:requests timeout --category callable --limit 10 -githits code search crates:serde Serialize --kind trait --limit 5 -githits code search npm:@types/node Buffer --file src/ --json -githits code search npm:express --keywords "router,handler" --match-mode and -``` - -Finds functions, classes, modules, and doc sections inside an indexed dependency by exact-token matches. Top-level `githits search --source symbol` is the preferred unified surface for symbol-shaped search. `code search` remains available for the older dedicated symbol-search UX and parity contract. - -**Package spec.** `:[@]`. Omit the registry to default to `npm`. Supported registries: `npm`, `pypi`, `hex`, `crates`, `nuget`, `maven`, `zig`, `vcpkg`, `packagist`. Scoped npm names are supported (`npm:@types/node`). - -**Filtering by symbol shape.** Prefer `--category` for broad filtering (`callable`, `type`, `module`, `data`, `documentation`) — it works across the full 27-value kind taxonomy without enumerating individual kinds. Reach for `--kind` when you want a specific construct, e.g. `--kind trait` (Rust) or `--kind namespace` (C#/C++/PHP). - -**Defaults.** `--intent production` filters to production source by default so top results are not dominated by tests, benchmarks, or examples. Use `--intent all` to include every file intent. `--wait` defaults to 20 seconds (above the p50 indexing time of ~11 s); first-time queries against an unindexed package may need `--wait 60` (the backend ceiling) to block until indexing completes. On an INDEXING error, the response message points out the retry options. - -**Output.** Default terminal output leads each entry with `path:startLine-endLine [kind]`, followed by the symbol name and a 3-line dedented snippet. `--json` emits the shared success/error envelope also produced by the MCP `search_symbols` tool — see [`mcp-cli-parity.md`](./mcp-cli-parity.md) for the wire contract. The command is registered as `code search` with `code search-symbols` as a Commander alias. - -**Registration.** The `code` group registers when the package/source endpoint is -configured. - -**Troubleshooting.** Set `GITHITS_DEBUG=code-nav` to emit single-line JSON diagnostics to stderr on error paths. Include the output when filing an issue. Debug payloads never contain query text, tokens, or response bodies. - ### `githits pkg info` ``` @@ -185,9 +132,6 @@ Shows a concise overview for a single package: latest version, license, descript **Output envelope.** Success payload is hand-crafted for agent token efficiency: `{registry, name, version, description?, license?, homepage?, repository?, publishedAt?, downloads?, github?, install?, usage?, vulnerabilities?, recentChanges?}`. Omitted fields reflect backend nulls, not dropped data. Error envelope: `{error, code, retryable, details?}` — shared classifier family. Under `--json` the error envelope is written to **stderr** so stdout stays clean for `jq`. -**Registration.** Same as `code`: the command is visible when the package/source -endpoint is configured. - **Troubleshooting.** `GITHITS_DEBUG=pkg-intel` emits PII-safe classified-error diagnostics (area, event, code, error class, detail keys). Use `GITHITS_DEBUG=*` to enable all non-sensitive package/source diagnostics. ### `githits pkg vulns` diff --git a/docs/implementation/signup-flow.md b/docs/implementation/signup-flow.md index ee6be885..470db979 100644 --- a/docs/implementation/signup-flow.md +++ b/docs/implementation/signup-flow.md @@ -25,7 +25,7 @@ recommended one-shot entry point less compelling. ## Target Flow 1. User runs an auth-required interactive command such as - `npx -y githits@latest example "..." -l typescript`. + `npx -y githits@latest init`. 2. CLI detects that no valid token is available. 3. CLI opens the browser to the sign-in / sign-up page. 4. User completes the GitHub-backed auth flow. @@ -110,14 +110,8 @@ Use these rules when implementing the feature: ### Phase 1.2: docs and product guidance -- [x] Update `README.md` to show the promoted one-shot entry point: - `npx -y githits@latest example "..." -l `. -- [x] Update `src/cli.ts` help text if the product wants the one-shot example - flow visible in `githits --help`. -- [x] Update `docs/implementation/auth.md` to describe the auto-login - bootstrap path and its exemptions. -- [x] Update `docs/implementation/cli-commands.md` to explain which commands - auto-trigger login and which do not. +- [x] Document the signup bootstrap behavior and command scope in this + implementation note. ### Phase 1.3: tests @@ -146,8 +140,6 @@ Use these rules when implementing the feature: - Should `mcp start` remain strictly manual-auth, or should there be a separate device-code or non-browser bootstrap for host-driven setups? -- Should the promoted example command keep the explicit `-l/--lang` flag, or do - we want a separate feature for language inference/defaulting? ## Key Reference Files diff --git a/package.json b/package.json index 39fe428f..a98f9453 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "githits": "dist/cli.js" }, "scripts": { - "build": "bunup --dts --target node --packages=external --exports && node --input-type=module -e \"import { chmodSync } from 'node:fs'; if (process.platform !== 'win32') chmodSync('dist/cli.js', 0o755);\"", + "build": "bunup --dts --target node --packages=external --exports && chmod +x dist/cli.js", "dev": "bun run ./src/cli.ts", "inspector": "npx @modelcontextprotocol/inspector bun run dev mcp", "smoke:cli": "bun run scripts/cli-smoke.ts", diff --git a/src/cli.ts b/src/cli.ts index 17570c84..3cffc139 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,7 +28,6 @@ import { FileSystemServiceImpl, NpmRegistryUpdateCheckService, } from "./services/index.js"; -import { getCommandPath } from "./shared/auto-login.js"; import { createRootCliPreAction, endTelemetrySpan, @@ -104,11 +103,10 @@ program "after", ` Getting started: - githits init Set up MCP for your coding agents - githits login Authenticate with your GitHits account - githits mcp Start MCP server for your AI assistant - githits search "router middleware" --in npm:express Search dependency code/docs - npx -y githits@latest example "query" --lang python One-shot example search with browser login + githits init Set up MCP for your coding agents + githits login Authenticate with your GitHits account + githits mcp Show MCP setup instructions + githits example "query" Get code examples Learn more at https://githits.com Docs: https://app.githits.com/docs/ @@ -219,7 +217,16 @@ function isSearchHelpTarget(value: string | undefined): boolean { } function getTelemetryCommandName(command: Command): string { - const names = getCommandPath(command); + const names: string[] = []; + let current: Command | null = command; + + while (current) { + const name = current.name(); + if (name && name !== "githits") { + names.unshift(name); + } + current = current.parent ?? null; + } return `command.${names.join(".")}`; } diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index b78059f9..11f24236 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -437,34 +437,6 @@ describe("loginFlow", () => { consoleSpy.mockRestore(); }); - it("falls back to printing the URL when automatic browser launch fails", async () => { - const writes: string[] = []; - - const result = await loginFlow( - { port: 8080 }, - { - authService: createMockAuthService(), - authStorage: createMockAuthStorage(), - browserService: createMockBrowserService({ - open: mock(() => Promise.reject(new Error("launch failed"))), - }), - mcpUrl, - }, - { - write: (message: string) => { - writes.push(message); - }, - }, - ); - - expect(result.status).toBe("success"); - expect(writes).toContain("Opening browser..."); - expect(writes).toContain( - "Failed to open browser automatically. Open this URL manually:\n", - ); - expect(writes).toContain(" http://example.com/auth\n"); - }); - it("returns already_authenticated when valid tokens exist", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); diff --git a/src/commands/login.ts b/src/commands/login.ts index 350cd7e4..41d9a8b7 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -198,14 +198,7 @@ export async function loginFlow( output.write(` ${authUrl}\n`); } else { output.write("Opening browser..."); - try { - await browserService.open(authUrl); - } catch { - output.write( - "Failed to open browser automatically. Open this URL manually:\n", - ); - output.write(` ${authUrl}\n`); - } + await browserService.open(authUrl); } output.write("Waiting for authentication...\n");