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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 10 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ A local, terminal-first client for the Glean Client REST API. Inspired by Claude
- **Indexing from local files** — `/index.document --path file.md` and `/index.bulk-documents --path ./docs/` walk a file or folder and synthesize the request body for you, with `--dry-run` to inspect it first
- **Natural-language planner** — type `?login into acme-be.glean.com and search for "Q2 plan"` and Glean Assistant translates it into slash commands, validated locally and gated behind a single confirm for anything destructive
- **Offline by default** — a real mock corpus of interlinked documents across five faux datasources, so every command is explorable without credentials. See [docs/MOCK_CORPUS.md](docs/MOCK_CORPUS.md)
- **Browser SSO or API token** — `/auth login` runs OAuth 2.1 + PKCE against your instance, or paste a Glean-issued token. Secure refs keep real secrets in environment variables, never on disk
- **Browser SSO or API token** — `/login <hostname-or-instance-id>` starts OAuth 2.1 + PKCE, or `/login --token ...` uses a Glean-issued token. Secure refs keep real secrets in environment variables, never on disk
- **MCP server** (`glean_mcp.py`) for Claude Code, Claude Desktop, and Cursor
- Terminal niceties: `/help <command>` for every command, tab completion that cycles matches, a powerline-style status bar, and `/scaffold` to generate stdlib-only starter projects

Expand Down Expand Up @@ -116,27 +116,16 @@ alias glean="PYTHONPATH=<YOUR_PATH>/glean-code-cli python3 -m glean_code"

### First run

**Browser SSO (no API token to paste)** — opens your browser for Glean → your company IdP, then stores OAuth tokens in `~/.gleancode/auth.json`:
**Sign in with Glean OAuth** using a backend hostname or instance ID:

```text
/auth login --instance acme-be.glean.com
/login <hostname-or-instance-id>
/status
/search "quarterly planning"
/chat "summarise the Q2 plan"
```

Details: [docs/SSO_OAUTH.md](docs/SSO_OAUTH.md).

**API token** — paste a Glean-issued Client API token (same live API, different auth path):

```text
/login --instance acme-be.glean.com --token <bearer_token>
/status
/search "quarterly planning"
/chat "summarise the Q2 plan"
```

Without Client API credentials (no `/auth` session and no `/login` token) the CLI runs in **mock** mode. After `/auth login` or `/login`, it switches to live calls against `https://<instance>/rest/api/v1`.
For example, `/login acme` uses the instance ID `acme`. Run `/help login` for other login options. Without login, the CLI runs in **mock** mode.

## Coming soon

Expand Down Expand Up @@ -208,7 +197,7 @@ Three ways to authenticate, in order of preference:

| Method | How | Notes |
| --- | --- | --- |
| Browser SSO | `/auth login --instance <host>` | OAuth 2.1 + PKCE, same SSO path as the web app. Tokens in `~/.gleancode/auth.json`. See [docs/SSO_OAUTH.md](docs/SSO_OAUTH.md) |
| Browser SSO | `/login <hostname-or-instance-id>` | OAuth 2.1 + PKCE with DCR, same SSO path as the web app. IDs map to `<id>-be.glean.com`; tokens live in `~/.gleancode/auth.json`. See [docs/SSO_OAUTH.md](docs/SSO_OAUTH.md) |
| Secure ref | `/login --token token.secure.client` | Config stores the reference name; the real secret resolves from `$GLEAN_CLIENT_TOKEN` at request time. See [docs/SECURE_TOKENS.md](docs/SECURE_TOKENS.md) |
| Literal token | `/login --token <bearer_token>` | Written to `~/.gleancode/config.json` with `0o600` perms, masked to `***1234` everywhere it displays |

Expand All @@ -220,11 +209,15 @@ Tokens are stripped from the in-memory history buffer and masked on every displa

| Key | Description | Values |
| --- | --- | --- |
| `instance` | Glean backend host | e.g. `acme-be.glean.com` |
| `instance` | Glean backend hostname or instance ID | e.g. `acme-be.glean.com` or `acme` |
| `api_token` | Client API bearer token | Glean-issued token, or a secure ref like `token.secure.client` |
| `indexing_token` | Indexing API token | Glean-issued token, or `token.secure.indexing` |
| `act_as` | Impersonate a user via `X-Glean-ActAs` | Email address |
| `base_url` | Override the computed base URL | Full URL |
| `oauth_client_id` | Static OAuth client ID, or a DCR-generated ID | Optional; DCR is used when unset |
| `oauth_client_instance` | Instance bound to a DCR-generated client ID | Managed automatically |
| `oauth_scopes` | Space-separated OAuth scopes | Optional; defaults to Client API scopes |
| `redirect_port` | Fixed localhost callback port | Optional |
| `mode` | API mode | `auto` (default), `live`, `mock` |
| `theme` | Terminal colour theme | `glean` (default), `mono`, `neon` |
| `default_page_size` | Default result count for search and entities | Integer, default `10` |
Expand Down
22 changes: 14 additions & 8 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,26 +67,32 @@ Run a full health check: validates config, tests DNS resolution, TCP connectivit

#### /login

Store a Glean instance host and API token. Writes to `~/.gleancode/config.json` and immediately switches the session to live mode.
Sign in to Glean with browser OAuth, or store an API token. OAuth uses Authorization Code + PKCE and Dynamic Client Registration when no static client ID is configured.

```text
/login --instance <host> --token <token> [--act-as <email>]
/login <hostname-or-instance-id> [--client-id <id>] [--port <n>] [--no-browser] [--act-as <email>]
```

| Parameter | Description |
| --- | --- |
| `--instance` | Full Glean backend host, e.g. `acme-be.glean.com`. Include the `-be` suffix — nothing is appended automatically. |
| `--token` | A Glean Client API token with the required scopes. |
| `hostname-or-instance-id` | A backend hostname such as `acme-be.glean.com`, or an instance ID such as `acme`. IDs map to `<id>-be.glean.com`. |
| `--instance` | Alternative named form of the hostname or instance ID. |
| `--client-id` | Optional static OAuth client ID. DCR is used when omitted. |
| `--port` | Optional fixed localhost callback port for redirect-URI allowlisting. |
| `--no-browser` | Print the OAuth authorize URL instead of opening a browser. |
| `--token` | Legacy API-token login. Supplying it skips OAuth. |
| `--act-as` | Optional. Email address to impersonate via `X-Glean-ActAs`. |

```text
/login acme
/login acme-be.glean.com
/login --instance acme-be.glean.com --no-browser
/login --instance acme-be.glean.com --token glean_tok_xxx
/login --instance acme-be.glean.com --token glean_tok_xxx --act-as jane@acme.com
```

**Output** — Confirms credentials saved and shows the resolved base URL.
**Output** — OAuth opens the browser and waits for the localhost callback, then stores OAuth tokens in `~/.gleancode/auth.json`. Token login stores the API token in `~/.gleancode/config.json`.

**Endpoint** — `(local, affects Authorization header)`
**Endpoint** — `(local, OAuth Authorization Code + PKCE or Authorization header)`

---

Expand All @@ -104,7 +110,7 @@ Clear stored credentials and revert to mock mode.

**Output** — Confirms credentials removed.

**Mock mode** — This is what `/logout` reverts *to*: with no token, searches answer from the [mock corpus](MOCK_CORPUS.md). OAuth tokens from `/auth login` are cleared separately with `/auth logout`.
**Mock mode** — This is what `/logout` reverts *to*: with no token, searches answer from the [mock corpus](MOCK_CORPUS.md). It clears both API-token and OAuth credentials.

**Endpoint** — `(local)`

Expand Down
14 changes: 9 additions & 5 deletions docs/SSO_OAUTH.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@ The CLI can sign in the same way as the Glean web app: **OAuth 2.1 authorization
## Usage

```text
/auth login --instance acme-be.glean.com
/login acme
/auth status
/search "quarterly planning"
/auth logout
```

`/auth login` flags:
The short form accepts a Glean instance ID (`acme`) or a backend hostname
(`acme-be.glean.com`). An ID resolves to `https://<id>-be.glean.com`.
`/auth login` remains available as the explicit OAuth command.

`/login` OAuth flags:

| Flag | Purpose |
| --- | --- |
| `--instance <host>` | Glean backend host (saved in config) |
| `--instance <host-or-id>` | Glean backend hostname or instance ID (saved in config) |
| `--client-id <id>` | Static OAuth client id (optional if the tenant supports DCR) |
| `--port <n>` | Fixed localhost callback port for redirect URI allowlisting |
| `--no-browser` | Print the authorize URL instead of opening a browser |
Expand All @@ -30,14 +34,14 @@ The CLI can sign in the same way as the Glean web app: **OAuth 2.1 authorization

| Location | Content |
| --- | --- |
| `~/.gleancode/config.json` | `oauth_client_id`, `oauth_scopes`, `redirect_port`, optional `oauth_*_url` overrides — never the access/refresh tokens |
| `~/.gleancode/config.json` | `oauth_client_id`, DCR instance binding, `oauth_scopes`, `redirect_port`, optional `oauth_*_url` overrides — never the access/refresh tokens |
| `~/.gleancode/auth.json` | OAuth tokens only (0600, written atomically) |

## Scope errors (e.g. “not allowed to request scope 'AGENT'”)

OAuth clients are often restricted to a fixed set of scopes. If authorization fails with an invalid / disallowed scope:

1. **Use a smaller scope string** in config, then run `/auth login` again (you may need `/auth logout` first if a half-login left state around):
1. **Use a smaller scope string** in config, then run `/login <instance-id>` again (you may need `/auth logout` first if a half-login left state around):

```text
/config set oauth_scopes "SEARCH CHAT DOCUMENTS TOOLS ENTITIES offline_access"
Expand Down
25 changes: 13 additions & 12 deletions glean_code/auth/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from . import oauth
from . import pkce as _pkce
from . import token_store
from ..config import normalize_instance_host
from .callback_server import start_callback_server

# Default scopes for Client API use. `AGENT` is omitted: many OAuth clients are
Expand Down Expand Up @@ -51,15 +52,8 @@ def _server_root_from_instance(instance: Optional[str]) -> Optional[str]:
Mirrors Config.effective_base_url's host handling, but returns just the
root (no /rest/api/v1), since OAuth metadata lives at the host root.
"""
if not instance:
return None
host = instance.strip().rstrip("/")
if "://" in host:
host = host.split("://", 1)[1]
host = host.split("/", 1)[0]
if not host:
return None
return f"https://{host}"
host = normalize_instance_host(instance)
return f"https://{host}" if host else None


def _iso(ts: Optional[float]) -> Optional[str]:
Expand Down Expand Up @@ -91,7 +85,7 @@ def login(
server_root = _server_root_from_instance(self.config.instance)
if not server_root:
raise AuthError(
"No instance configured. Run: /auth login --instance <host>"
"No instance configured. Run: /login <hostname-or-instance-id>"
)

scopes = getattr(self.config, "oauth_scopes", None) or DEFAULT_SCOPES
Expand Down Expand Up @@ -198,7 +192,9 @@ def current_access_token(self, allow_refresh: bool = True) -> Optional[str]:
try:
endpoints = self._resolve_endpoints(server_root)
client_id = getattr(self.config, "oauth_client_id", None)
if not client_id:
client_instance = getattr(self.config, "oauth_client_instance", None)
current_instance = normalize_instance_host(self.config.instance)
if not client_id or (client_instance and client_instance != current_instance):
return None
scopes = getattr(self.config, "oauth_scopes", None) or DEFAULT_SCOPES
payload = oauth.refresh_tokens(
Expand Down Expand Up @@ -248,8 +244,12 @@ def _resolve_endpoints(self, server_root: str) -> oauth.Endpoints:

def _resolve_client_id(self, endpoints: oauth.Endpoints, redirect_uri: str, scopes: str) -> str:
client_id = getattr(self.config, "oauth_client_id", None)
if client_id:
client_instance = getattr(self.config, "oauth_client_instance", None)
current_instance = normalize_instance_host(self.config.instance)
if client_id and (not client_instance or client_instance == current_instance):
return client_id
if client_instance and client_instance != current_instance:
client_id = None
if not endpoints.registration_endpoint:
raise AuthError(
"No oauth_client_id configured and the tenant did not advertise a "
Expand All @@ -262,6 +262,7 @@ def _resolve_client_id(self, endpoints: oauth.Endpoints, redirect_uri: str, scop
# Persist the dynamically registered client so future logins reuse it.
try:
self.config.oauth_client_id = client_id
self.config.oauth_client_instance = normalize_instance_host(self.config.instance)
self.config.save()
except Exception:
pass
Expand Down
41 changes: 30 additions & 11 deletions glean_code/auth_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,25 @@
from . import ui
from .commands import register
from .auth import AuthManager, AuthError
from .config import normalize_instance_host

try:
# Make `/help auth` and tab-completion aware of the new command.
from .help_docs import DOCS
DOCS["auth"] = {
"summary": "Sign in to Glean via your browser/SSO using OAuth (Authorization Code + PKCE).",
"usage": "/auth <login|status|logout> [--instance <host>] [--client-id <id>] [--port <n>] [--no-browser]",
"usage": "/auth <login|status|logout> [<hostname-or-instance-id>] [--instance <host>] [--client-id <id>] [--port <n>] [--no-browser]",
"params": [
("login", "Open the browser and sign in through Glean -> your company SSO."),
("status", "Show authentication state, instance, and token expiry."),
("logout", "Delete the locally stored OAuth tokens."),
("--instance", "Glean backend host, e.g. acme-be.glean.com (stored in config)."),
("--instance", "Glean backend hostname or instance ID, e.g. acme-be.glean.com or acme (stored in config)."),
("--client-id", "Static OAuth client id (optional if the tenant supports DCR)."),
("--port", "Fixed localhost callback port (for redirect-URI allowlisting)."),
("--no-browser", "Print the authorize URL instead of opening a browser."),
],
"examples": [
"/auth login acme",
"/auth login --instance acme-be.glean.com",
"/auth login --instance acme-be.glean.com --client-id glean-code-cli --port 33389",
"/auth status",
Expand All @@ -42,21 +44,33 @@
pass


def _apply_login_flags(s, flags) -> None:
def _apply_login_flags(s, flags) -> bool:
"""Persist any connection settings passed on the /auth login line."""
instance = flags.get("instance")
if not instance:
instance = flags.get("hostname")
client_id = flags.get("client-id") or flags.get("client_id")
port = flags.get("port")
act_as = flags.get("act-as") or flags.get("act_as")
changed = False
if instance:
raw = str(instance).strip().rstrip("/")
if "://" in raw:
raw = raw.split("://", 1)[1]
raw = raw.split("/", 1)[0]
raw = normalize_instance_host(str(instance))
if not raw:
ui.print_err(f"That does not look like a Glean hostname or instance ID: '{instance}'")
return False
previous = normalize_instance_host(s.config.instance)
if previous != raw and getattr(s.config, "oauth_client_instance", None) == previous:
s.config.oauth_client_id = None
s.config.oauth_client_instance = None
s.config.instance = raw
s.config.base_url = None
changed = True
if act_as and act_as is not True:
s.config.act_as = str(act_as)
changed = True
if client_id and client_id is not True:
s.config.oauth_client_id = str(client_id)
s.config.oauth_client_instance = None
changed = True
if port and port is not True:
try:
Expand All @@ -66,6 +80,7 @@ def _apply_login_flags(s, flags) -> None:
ui.print_err("--port must be an integer")
if changed:
s.config.save()
return True


def _print_status(s) -> None:
Expand All @@ -83,7 +98,7 @@ def _print_status(s) -> None:
rows = [
("state", ui.style("Not logged in", ui.C.YELLOW)),
("instance", status.server_url or ui.style("(unset)", ui.C.GREY)),
("hint", "Run /auth login --instance <host>"),
("hint", "Run /login <hostname-or-instance-id>"),
]
print(ui.kv_table(rows))
print(ui.rule())
Expand All @@ -104,10 +119,14 @@ def cmd_auth(s, pos, flags):
return

if sub == "login":
_apply_login_flags(s, flags)
login_flags = dict(flags)
if len(pos) > 1 and not login_flags.get("instance"):
login_flags["instance"] = pos[1]
if not _apply_login_flags(s, login_flags):
return
if not s.config.instance:
ui.print_err("No instance set. Run: /auth login --instance <host>")
ui.print_info("Example: /auth login --instance acme-be.glean.com")
ui.print_err("No instance set. Run: /login <hostname-or-instance-id>")
ui.print_info("Example: /login acme")
return
manager = AuthManager(s.config)
nb = bool(flags.get("no-browser") or flags.get("no_browser"))
Expand Down
Loading
Loading