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
70 changes: 32 additions & 38 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Searchlight CLI — Project Contract

A thin Go client over the Searchlight MCP server (eva-web). The tool surface is
fully derived at runtime from the MCP server's `tools/list`, so adding new
tools server-side requires zero changes here. Audience is internal Headline
users + portfolio CEOs (today) and sophisticated external investors (after the
`is_internal?` gate is relaxed server-side).
A thin Go client over the Searchlight MCP server. The tool surface is fully
derived at runtime from the MCP server's `tools/list`, so adding new tools
server-side requires zero changes here. Audience is internal Headline users +
portfolio CEOs (today) and sophisticated external investors (after the
internal-user gate is relaxed server-side).

## What this CLI is for

Expand All @@ -22,7 +22,7 @@ Linear ticket: [EVA-9938](https://linear.app/headline/issue/EVA-9938/create-a-se

```
┌─────────────────────────┐ OAuth PKCE ┌──────────────────────┐
│ searchlight CLI (Go) │ ◄─────────────────────────► │ eva-web /oauth/*
│ searchlight CLI (Go) │ ◄─────────────────────────► │ searchlight /oauth/*│
│ ───────────────────── │ │ /.well-known/* │
│ cobra root │ JSON-RPC POST │ │
│ ├─ auth login/logout │ ──────────────────────────► │ /mcp │
Expand Down Expand Up @@ -92,11 +92,10 @@ Every invocation:

### Auth flow

PKCE + browser loopback against eva-web's custom OAuth implementation. All the
relevant server endpoints already exist (`POST /oauth/token`,
`/.well-known/oauth-authorization-server`, `OauthApplication` with wildcard
redirect URI support, PKCE S256 enforced in
`app/services/mcp/oauth/authorization_service.rb`).
PKCE + browser loopback against the Searchlight server's custom OAuth
implementation. The server exposes the standard endpoints (`POST /oauth/token`,
`/.well-known/oauth-authorization-server`) and supports OAuth applications
with wildcard loopback redirect URIs and PKCE S256 challenges.

The CLI:

Expand Down Expand Up @@ -157,7 +156,7 @@ CGO_ENABLED=0 go build \
for internal/test code where exit code doesn't matter.
- **Comments**: explain WHY in domain terms, not WHAT. Never reference Linear
ticket numbers in source (they go in commit messages, PR titles, branch names
only — same rule as eva-web).
only).
- **No `panic`** in CLI paths. Return errors. Tests can `t.Fatalf` freely.
- **HTTP clients**: always set a timeout. The MCP client uses 60s, OAuth
endpoints use 5min for the browser-loopback window.
Expand All @@ -168,7 +167,7 @@ CGO_ENABLED=0 go build \

## Testing Philosophy

Inherited from the eva-web project — applies here verbatim:
Inherited from our other internal projects — applies here verbatim:

- **Never re-implement logic in tests.** Hardcode expected values from
manually-verified examples. If the production code computes
Expand All @@ -189,23 +188,17 @@ Inherited from the eva-web project — applies here verbatim:
## OAuth client setup (one-time, manual)

The CLI is a public OAuth client (no client_secret; PKCE proves possession).
Two `OauthApplication` rows must exist in eva-web — one in production, one in
staging. Run this in the **eva-web** Rails console for each environment:

```ruby
OauthApplication.create!(
name: "Searchlight CLI",
client_id: SecureRandom.uuid, # record this → GitHub secret
client_secret: nil,
redirect_uris: ["http://localhost:*", "http://127.0.0.1:*"],
grant_types: %w[authorization_code refresh_token],
response_types: %w[code],
)
```
A Searchlight admin must register two OAuth applications on the server — one
in production, one in staging — each with:

- name: `Searchlight CLI`
- client_id: a freshly minted UUID (record it — this becomes a GitHub secret)
- client_secret: empty (PKCE-only)
- redirect_uris: `http://localhost:*`, `http://127.0.0.1:*` (loopback only)
- grant_types: `authorization_code`, `refresh_token`
- response_types: `code`

The wildcard redirect URI support is already implemented in
`app/models/oauth_application.rb:13-21`. Record both `client_id`s and stash
them as GitHub secrets in this repo:
Record both `client_id`s and stash them as GitHub secrets in this repo:

- `SEARCHLIGHT_PROD_CLIENT_ID`
- `SEARCHLIGHT_STAGING_CLIENT_ID`
Expand Down Expand Up @@ -281,8 +274,9 @@ Before the first release can succeed:
1. Create `headlinevc/homebrew-tap` repo (public, can be empty).
2. Create a PAT with `contents:write` on `homebrew-tap`, store as
`HOMEBREW_TAP_TOKEN` secret on this repo.
3. Create the `OauthApplication` rows (see above) and stash
`SEARCHLIGHT_PROD_CLIENT_ID` + `SEARCHLIGHT_STAGING_CLIENT_ID` secrets.
3. Register the production + staging OAuth applications on the server (see
above) and stash `SEARCHLIGHT_PROD_CLIENT_ID` + `SEARCHLIGHT_STAGING_CLIENT_ID`
secrets.

## Safety Rails

Expand Down Expand Up @@ -322,11 +316,11 @@ Before the first release can succeed:

## Known Constraints / Open follow-ups

1. **`is_internal?` gate.** Every MCP request hits
`lib/mcp/auth/authenticator.rb` which requires `user.is_internal? == true`.
The CLI's intended investor audience is currently blocked by this. Needs a
server-side change to scope access per `OauthApplication` type before
external rollout. Tracked as a follow-up on EVA-9938 (separate ticket).
1. **Internal-user gate.** The Searchlight server currently requires the
authenticated user to be a Headline internal user on every MCP request.
The CLI's intended external-investor audience is blocked by this until
the server scopes access by OAuth application type. Tracked as a
follow-up Linear issue.
2. **`go install` UX.** Lacks the ldflags-injected client_id; the user falls
back to the `SEARCHLIGHT_CLIENT_ID` env var. README + docs/agents.md
explain this. Acceptable for dev workflows, not for investor onboarding.
Expand Down Expand Up @@ -381,8 +375,8 @@ Before declaring work complete, confirm:

When context compaction triggers, preserve in priority order:

1. Architecture decisions (NEVER summarize away — `is_internal?` constraint,
dynamic registration design, conventional-commit requirement)
1. Architecture decisions (NEVER summarize away — internal-user gate
constraint, dynamic registration design, conventional-commit requirement)
2. Modified files and their key changes
3. Current test pass/fail + coverage status
4. Open follow-ups (1–5 in "Known Constraints" above)
Expand Down
15 changes: 7 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ auth, same payloads — but driven from the shell, optimized for agents and powe
users.

> v0 audience: internal Headline users + portfolio CEOs already in the
> `is_internal?` allowlist on the eva-web server. External investors are
> internal-user allowlist on the Searchlight server. External investors are
> blocked until that gate is relaxed; tracked as a follow-up Linear issue.

## Install
Expand Down Expand Up @@ -42,7 +42,7 @@ searchlight lookup_company --domain openai.com --pretty

## Authentication

The CLI uses OAuth 2.0 authorization_code + PKCE (S256) against the eva-web
The CLI uses OAuth 2.0 authorization_code + PKCE (S256) against the Searchlight
MCP server, with a loopback redirect on an ephemeral port. Tokens are stored
in your OS keyring (macOS Keychain / Windows Credential Manager / libsecret),
falling back to a 0600 file at `$XDG_CONFIG_HOME/searchlight/credentials.json`
Expand Down Expand Up @@ -115,9 +115,10 @@ CGO_ENABLED=0 go build \
SEARCHLIGHT_URL=https://staging.searchlight.io ./searchlight auth login
```

The two `client_id` values are minted by a one-time `OauthApplication.create!`
in the eva-web Rails console (production and staging) — see `CLAUDE.md` for
the exact snippet and ask an admin for the values.
The two `client_id` values are minted once by a Searchlight admin when
registering the production and staging OAuth applications on the server —
see `CLAUDE.md` for the required application config, and ask an admin for
the values.

## Contributing & Releases

Expand Down Expand Up @@ -153,7 +154,5 @@ owns both.

## Related

- Eva-web MCP tool source: `lib/mcp/tools/*.rb` (124 tools as of server v4.2.0)
- Eva-web MCP transport: `app/controllers/api/mcp_controller.rb`
- Linear ticket: EVA-9938
- Full project contract: `CLAUDE.md`
- Agent invariants: `docs/agents.md`
6 changes: 3 additions & 3 deletions cmd/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func newIntegrationFixture(t *testing.T) *integrationFixture {
handler := &integrationHandler{
respond: map[string]func(map[string]any) (any, bool){
"get_current_user": func(_ map[string]any) (any, bool) {
return map[string]any{"email": "diogo@example.com", "is_internal": true}, false
return map[string]any{"email": "user@example.com", "is_internal": true}, false
},
},
}
Expand Down Expand Up @@ -212,8 +212,8 @@ func TestAuthWhoami_Integration(t *testing.T) {
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("output is not JSON: %q", out)
}
if got["email"] != "diogo@example.com" {
t.Errorf("email = %v, want diogo@example.com", got["email"])
if got["email"] != "user@example.com" {
t.Errorf("email = %v, want user@example.com", got["email"])
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const (
)

// ClientIDs are baked at build time via -ldflags="-X ...prodClientID=..." after
// an admin creates the OauthApplication rows on staging and prod Rails consoles.
// an admin registers the staging and prod OAuth applications on the server.
// See CLAUDE.md, "OAuth client setup (one-time, manual)".
var (
prodClientID = ""
Expand Down
4 changes: 2 additions & 2 deletions internal/oauth/pkce.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ type PKCE struct {
}

// NewPKCE returns a 43-byte verifier (256 bits of entropy) and its S256 challenge.
// Length is intentional: the eva-web authorization_service.rb requires the S256
// challenge to be exactly 43 base64url chars (i.e. 32 raw bytes), matching RFC 7636.
// Length is intentional: the server enforces an exact-length S256 challenge of
// 43 base64url chars (i.e. 32 raw bytes), matching RFC 7636.
func NewPKCE() (*PKCE, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
Expand Down
Loading