Skill: alexrudloff/caesar-research (version 0.0.1)

--- name: caesar-research description: Deep research using the Caesar API — run queries, follow up with chat, brainstorm, and manage collections. homepage: https://www.caesar.org/ metadata: { "openclaw": { "emoji": "🔬", "requires": { "bins": ["caesar"], "env": ["CAESAR_API_KEY"] } } } --- # Caesar Research CLI for [Caesar](https://www.caesar.org/) deep research. Runs multi-source research jobs with citations, follow-up chat, and brainstorming. ## Setup ```bash go install github.com/alexrudloff/caesar-cli@latest export CAESAR_API_KEY=your_key_here ``` ## Research Run a query (waits for completion by default, prints events as they happen): ```bash caesar research create "What are the latest advances in mRNA vaccines?" ``` Returns JSON with `content` (synthesized answer with `[n]` citations) and a `results` array of sources. Fire-and-forget: ```bash caesar research create "query" --no-wait # Returns: { "id": "uuid", "status": "queued" } ``` Then check on it: ```bash caesar research get <job-id> caesar research watch <job-id> caesar research events <job-id> ``` ### Research Options | Flag | Description | |------|-------------| | `--no-wait` | Return immediately with job ID | | `--model <name>` | `gpt-5.2`, `gemini-3-pro`, `gemini-3-flash`, `claude-opus-4.5` | | `--loops N` | Max reasoning loops (default 1, higher = deeper research) | | `--reasoning` | Enable advanced reasoning mode | | `--auto` | Let Caesar auto-configure based on query | | `--exclude-social` | Skip social media sources | | `--exclude-domain x.com` | Exclude specific domains (repeatable) | | `--system-prompt "..."` | Custom synthesis prompt | | `--brainstorm <id>` | Use a brainstorm session for context | ### Status Lifecycle `queued` → `searching` → `summarizing` → `analyzing` → `researching` → `completed` or `failed` ## Chat (Follow-Up Questions) Ask follow-up questions about a completed research job: ```bash caesar chat send <job-id> "How does this compare to traditional vaccines?" ``` Waits for the response by default. The answer includes inline `[n]` citations referencing the original research sources. ```bash caesar chat send <job-id> "question" --wait=false caesar chat history <job-id> ``` ## Brainstorm Get clarifying questions before research to improve results: ```bash caesar brainstorm "How does CRISPR gene editing work?" # Prints questions with multiple-choice options and a session ID ``` Then use the session ID: ```bash caesar research create --brainstorm <session-id> "How does CRISPR gene editing work?" ``` ## Collections Group files for research context: ```bash caesar collections create "Dataset Name" --description "Optional description" ``` ## Tips - For broad topics, use `--auto` to let Caesar pick optimal settings. - Use `--loops 3` or higher for complex multi-faceted questions. - Use `--reasoning` for questions requiring deep analysis. - Pipe output through `jq` to extract specific fields: `caesar research get <id> | jq '.content'` - Chain brainstorm → research for best results on ambiguous queries.

path: README.md
content: # caesar-cli

A command-line interface for the [Caesar](https://www.caesar.org/) research API.

## Install

Requires [Go](https://go.dev/dl/) 1.21 or later.

```bash
go install github.com/alexrudloff/caesar-cli@latest
```

Or build from source:

```bash
git clone https://github.com/alexrudloff/caesar-cli.git
cd caesar-cli
go build -o caesar .
```

This produces a `caesar` binary in the current directory. Move it somewhere on your `$PATH` to use it globally:

```bash
mv caesar /usr/local/bin/
```

## Configuration

Get an API key from [caesar.org](https://www.caesar.org/) and set it as an environment variable:

```bash
export CAESAR_API_KEY=your_key_here
```

To persist it across shell sessions, add the export line to your shell profile (`~/.zshrc`, `~/.bashrc`, etc.), or create a local `.env` file:

```bash
echo 'export CAESAR_API_KEY=your_key_here' > .env
source .env
```

The `.env` file is git-ignored and will not be committed.

## Usage

### Research

```bash
# Run a research query (waits for completion by default)
caesar research create "What is quantum computing?"

# Return immediately with job ID
caesar research create "query" --no-wait

# Check on a job
caesar research get <job-id>

# Watch a job until it completes, printing events
caesar research watch <job-id>

# View reasoning events for a job
caesar research events <job-id>
```

Research options:

| Flag | Description |
|------|-------------|
| `--no-wait` | Return immediately with job ID |
| `--model` | Model to use (`gpt-5.2`, `gemini-3-pro`, `gemini-3-flash`, `claude-opus-4.5`) |
| `--loops N` | Max reasoning loops (default 1) |
| `--reasoning` | Enable reasoning mode |
| `--auto` | Auto-configure based on query |
| `--exclude-social` | Exclude social media sources |
| `--exclude-domain` | Domains to exclude (repeatable) |
| `--system-prompt` | Custom system prompt |
| `--brainstorm` | Brainstorm session ID to use |

### Chat

Ask follow-up questions about a completed research job:

```bash
# Send a follow-up (waits for response by default)
caesar chat send <job-id> "Tell me more about X"

# Return immediately
caesar chat send <job-id> "question" --wait=false

# View chat history
caesar chat history <job-id>
```

### Brainstorm

Get clarifying questions before starting research:

```bash
caesar brainstorm "How does CRISPR work?"
```

Then use the returned session ID:

```bash
caesar research create --brainstorm <session-id> "How does CRISPR work?"
```

### Collections

```bash
caesar collections create "My Dataset" --description "Optional description"
```


path: main.go
content: package main

import "github.com/alexrudloff/caesar-cli/cmd"

func main() {
	cmd.Execute()
}


path: CLAUDE.md
content: # CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What This Is

A Go CLI (`caesar`) for the [Caesar](https://www.caesar.org/) research API. It wraps the `https://api.caesar.xyz` REST API to let users run research jobs, chat with results, brainstorm, and manage collections from the terminal.

## Build & Run

```bash
go build -o caesar .       # build the binary
go run . [command]          # run without building
go build ./...              # verify all packages compile
go vet ./...                # static analysis
```

There are no tests yet. When adding tests, use standard `go test ./...` and place test files alongside the code they test (`*_test.go`).

## Authentication

The API key is read from the `CAESAR_API_KEY` environment variable. Never hardcode keys or store them in files tracked by git. The `.gitignore` excludes `.env`.

## Architecture

```
main.go                       → entry point, calls cmd.Execute()
cmd/                          → cobra command definitions (one file per command group)
  root.go                     → root command, wires subcommands
  research.go                 → research create/get/events/watch + polling logic
  chat.go                     → chat send/history on research jobs
  brainstorm.go               → brainstorm session creation
  collections.go              → collection management
internal/
  client/
    client.go                 → HTTP client: auth, request/response, all API methods
    types.go                  → request/response structs for the Caesar API
  config/
    config.go                 → base URL constant and env var reading
  output/
    output.go                 → JSON printing and error helpers
```

**Key patterns:**
- Each `cmd/*.go` file registers its commands in an `init()` function via `rootCmd.AddCommand()`
- All API calls go through `internal/client.Client`, which handles auth headers and error checking in a single `do()` method
- The `client.Client` is created per-command via `client.New()` (reads the API key from env at that point)
- Output is always JSON via `output.JSON()` unless there's a human-friendly format (like brainstorm questions or chat responses)

## Caesar API Overview

Base URL: `https://api.caesar.xyz` — Bearer token auth via `Authorization: Bearer <token>`

**Research lifecycle:** `POST /research` → returns job ID with status `queued` → poll `GET /research/{id}` → status progresses through `searching`, `summarizing`, `analyzing`, `researching` → `completed` or `failed`. The completed response includes `content` (synthesized answer with `[n]` citations) and a `results` array mapping citation indices to source URLs.

**Key endpoints:**
- `POST /research` — create research job (many optional params: model, reasoning_loops, auto, exclude_social, etc.)
- `GET /research/{id}` — poll job status and get results
- `GET /research/{id}/events` — get reasoning step log
- `GET /research/{id}/results/{resultId}/content?format=raw|markdown` — fetch source content
- `POST /research/brainstorm` — get clarifying questions before research
- `POST /research/{id}/chat` — follow-up questions (response via polling or SSE at `/chat/{messageId}/stream`)
- `GET /research/{id}/chat` — chat history
- `POST /research/collections` — create file collections

**Models available:** `gpt-5.2`, `gemini-3-pro`, `gemini-3-flash`, `claude-opus-4.5`

## Adding New Commands

1. Create a new file in `cmd/` (e.g., `cmd/newfeature.go`)
2. Define the cobra command and register it in `init()` via `rootCmd.AddCommand()` or as a subcommand
3. Add any new API methods to `internal/client/client.go` and types to `internal/client/types.go`
4. Use `output.JSON()` for structured output, `output.Error()` for fatal errors


path: internal/client/types.go
content: package client

import "time"

type CreateResearchRequest struct {
	Query              string   `json:"query"`
	BrainstormSessionID string  `json:"brainstorm_session_id,omitempty"`
	Answers            []Answer `json:"answers,omitempty"`
	Files              []string `json:"files,omitempty"`
	CollectionIDs      []string `json:"collection_ids,omitempty"`
	ReasoningLoops     int      `json:"reasoning_loops,omitempty"`
	SourceTimeout      int      `json:"source_timeout,omitempty"`
	ReasoningMode      bool     `json:"reasoning_mode,omitempty"`
	AllowEarlyExit     bool     `json:"allow_early_exit,omitempty"`
	ExcludeSocial      bool     `json:"exclude_social,omitempty"`
	ExcludedDomains    []string `json:"excluded_domains,omitempty"`
	Auto               bool     `json:"auto,omitempty"`
	SystemPrompt       string   `json:"system_prompt,omitempty"`
	Model              string   `json:"model,omitempty"`
}

type Answer struct {
	QuestionID string `json:"question_id"`
	Answer     string `json:"answer"`
}

type CreateResearchResponse struct {
	ID     string `json:"id"`
	Status string `json:"status"`
}

type ResearchObject struct {
	ID                     string          `json:"id"`
	CreatedAt              time.Time       `json:"created_at"`
	Status                 string          `json:"status"`
	Query                  string          `json:"query"`
	Content                *string         `json:"content"`
	TransformedContent     *string         `json:"transformed_content"`
	ReasoningLoopsConsumed int             `json:"reasoning_loops_consumed"`
	RunningTime            int             `json:"running_time"`
	Results                []ResearchResult `json:"results"`
}

type ResearchResult struct {
	ID            string `json:"id"`
	Title         string `json:"title"`
	URL           string `json:"url"`
	CitationIndex int    `json:"citation_index"`
}

type ResearchEvent struct {
	Message   string    `json:"message"`
	CreatedAt time.Time `json:"created_at"`
}

type ResultContent struct {
	Content string `json:"content"`
}

type BrainstormSession struct {
	ID        string             `json:"id"`
	Query     string             `json:"query"`
	Questions []BrainstormQuestion `json:"questions"`
	Status    string             `json:"status"`
	CreatedAt time.Time          `json:"created_at"`
}

type BrainstormQuestion struct {
	ID       string            `json:"id"`
	Question string            `json:"question"`
	Options  map[string]string `json:"options"`
}

type ChatMessage struct {
	ID      string           `json:"id"`
	Status  string           `json:"status,omitempty"`
	Content string           `json:"content,omitempty"`
	Results []ResearchResult `json:"results,omitempty"`
}

type Collection struct {
	ID          string    `json:"id"`
	Name        string    `json:"name"`
	Description string    `json:"description"`
	CreatedAt   time.Time `json:"created_at"`
	UpdatedAt   time.Time `json:"updated_at"`
}


path: internal/client/client.go
content: package client

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"time"

	"github.com/alexrudloff/caesar-cli/internal/config"
)

type Client struct {
	httpClient *http.Client
	baseURL    string
	apiKey     string
}

// Options allows creating a Client with explicit dependencies (used in tests).
type Options struct {
	HTTPClient *http.Client
	BaseURL    string
	APIKey     string
}

func New() (*Client, error) {
	apiKey, err := config.GetAPIKey()
	if err != nil {
		return nil, err
	}
	return &Client{
		httpClient: &http.Client{Timeout: 120 * time.Second},
		baseURL:    config.BaseURL,
		[REDACTED_SECRET],
	}, nil
}

// NewWithOptions creates a Client with explicit dependencies.
func NewWithOptions(opts Options) *Client {
	httpClient := opts.HTTPClient
	if httpClient == nil {
		httpClient = &http.Client{Timeout: 120 * time.Second}
	}
	baseURL := opts.BaseURL
	if baseURL == "" {
		baseURL = config.BaseURL
	}
	return &Client{
		httpClient: httpClient,
		baseURL:    baseURL,
		[REDACTED_SECRET],
	}
}

func (c *Client) do(method, path string, body any) (*http.Response, error) {
	var reqBody io.Reader
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil {
			return nil, fmt.Errorf("marshal request: %w", err)
		}
		reqBody = bytes.NewReader(b)
	}

	req, err := http.NewRequest(method, c.baseURL+path, reqBody)
	if err != nil {
		return nil, fmt.Errorf("create request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("request failed: %w", err)
	}

	if resp.StatusCode >= 400 {
		defer resp.Body.Close()
		respBody, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
	}

	return resp, nil
}

// CreateResearch starts a new research job.
func (c *Client) CreateResearch(req *CreateResearchRequest) (*CreateResearchResponse, error) {
	resp, err := c.do("POST", "/research", req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var result CreateResearchResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}
	return &result, nil
}

// GetResearch retrieves a research job by ID.
func (c *Client) GetResearch(id string) (*ResearchObject, error) {
	resp, err := c.do("GET", "/research/"+id, nil)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var result ResearchObject
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}
	return &result, nil
}

// GetResearchEvents retrieves events/reasoning steps for a research job.
func (c *Client) GetResearchEvents(id string) ([]ResearchEvent, error) {
	resp, err := c.do("GET", "/research/"+id+"/events", nil)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var result []ResearchEvent
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}
	return result, nil
}

// GetResultContent retrieves content for a specific source result.
func (c *Client) GetResultContent(researchID, resultID, format string) (*ResultContent, error) {
	path := fmt.Sprintf("/research/%s/results/%s/content", researchID, resultID)
	if format != "" {
		path += "?format=" + format
	}
	resp, err := c.do("GET", path, nil)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var result ResultContent
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}
	return &result, nil
}

// CreateBrainstorm starts a brainstorm session.
func (c *Client) CreateBrainstorm(query string) (*BrainstormSession, error) {
	resp, err := c.do("POST", "/research/brainstorm", map[string]string{"query": query})
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var result BrainstormSession
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}
	return &result, nil
}

// CreateChatMessage sends a follow-up chat message on a research job.
func (c *Client) CreateChatMessage(researchID string, content string) (*ChatMessage, error) {
	resp, err := c.do("POST", "/research/"+researchID+"/chat", map[string]string{"content": content})
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var result ChatMessage
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}
	return &result, nil
}

// GetChatHistory retrieves chat messages for a research job.
func (c *Client) GetChatHistory(researchID string) ([]ChatMessage, error) {
	resp, err := c.do("GET", "/research/"+researchID+"/chat", nil)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var result []ChatMessage
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}
	return result, nil
}

// GetChatMessage polls a single chat message by ID.
func (c *Client) GetChatMessage(researchID, messageID string) (*ChatMessage, error) {
	path := fmt.Sprintf("/research/%s/chat/%s", researchID, messageID)
	resp, err := c.do("GET", path, nil)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var result ChatMessage
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}
	return &result, nil
}

// StreamChatMessage returns the raw SSE response for streaming a chat message.
func (c *Client) StreamChatMessage(researchID, messageID string) (*http.Response, error) {
	path := fmt.Sprintf("/research/%s/chat/%s/stream", researchID, messageID)
	return c.do("GET", path, nil)
}

// CreateCollection creates a new file collection.
func (c *Client) CreateCollection(name, description string) (*Collection, error) {
	body := map[string]string{"name": name}
	if description != "" {
		body["description"] = description
	}
	resp, err := c.do("POST", "/research/collections", body)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var result Collection
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}
	return &result, nil
}


path: internal/client/client_test.go
content: package client

import (
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"
)

func testClient(t *testing.T, handler http.Handler) (*Client, *httptest.Server) {
	t.Helper()
	srv := httptest.NewServer(handler)
	t.Cleanup(srv.Close)
	c := NewWithOptions(Options{
		BaseURL: srv.URL,
		[REDACTED_SECRET]",
	})
	return c, srv
}

func TestDo_SetsAuthHeader(t *testing.T) {
	var gotAuth string
	c, _ := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotAuth = r.Header.Get("Authorization")
		w.WriteHeader(200)
	}))

	c.do("GET", "/test", nil)

	want := "Bearer test-key-12345"
	if gotAuth != want {
		t.Errorf("Authorization = %q, want %q", gotAuth, want)
	}
}

func TestDo_SetsContentTypeForBody(t *testing.T) {
	var gotCT string
	c, _ := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotCT = r.Header.Get("Content-Type")
		w.WriteHeader(200)
	}))

	c.do("POST", "/test", map[string]string{"key": "val"})

	if gotCT != "application/json" {
		t.Errorf("Content-Type = %q, want %q", gotCT, "application/json")
	}
}

func TestDo_NoContentTypeForNilBody(t *testing.T) {
	var gotCT string
	c, _ := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotCT = r.Header.Get("Content-Type")
		w.WriteHeader(200)
	}))

	c.do("GET", "/test", nil)

	if gotCT != "" {
		t.Errorf("Content-Type = %q, want empty for nil body", gotCT)
	}
}

func TestDo_HTTPError(t *testing.T) {
	tests := []struct {
		name   string
		status int
		body   string
	}{
		{"bad request", 400, "invalid input"},
		{"unauthorized", 401, "bad key"},
		{"server error", 500, "internal error"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			c, _ := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				w.WriteHeader(tt.status)
				w.Write([]byte(tt.body))
			}))

			_, err := c.do("GET", "/test", nil)
			if err == nil {
				t.Fatal("expected error for status", tt.status)
			}
			if !strings.Contains(err.Error(), tt.body) {
				t.Errorf("error %q should contain body %q", err.Error(), tt.body)
			}
		})
	}
}

func TestCreateResearch(t *testing.T) {
	var gotReq CreateResearchRequest
	var gotMethod, gotPath string

	c, _ := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotMethod = r.Method
		gotPath = r.URL.Path
		json.NewDecoder(r.Body).Decode(&gotReq)
		json.NewEncoder(w).Encode(CreateResearchResponse{ID: "res-123", Status: "queued"})
	}))

	resp, err := c.CreateResearch(&CreateResearchRequest{
		Query:          "test query",
		ReasoningLoops: 3,
		Model:          "claude-opus-4.5",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if gotMethod != "POST" {
		t.Errorf("method = %s, want POST", gotMethod)
	}
	if gotPath != "/research" {
		t.Errorf("path = %s, want /research", gotPath)
	}
	if gotReq.Query != "test query" {
		t.Errorf("request query = %q, want %q", gotReq.Query, "test query")
	}
	if gotReq.ReasoningLoops != 3 {
		t.Errorf("request loops = %d, want 3", gotReq.ReasoningLoops)
	}
	if gotReq.Model != "claude-opus-4.5" {
		t.Errorf("request model = %q, want %q", gotReq.Model, "claude-opus-4.5")
	}
	if resp.ID != "res-123" {
		t.Errorf("response ID = %q, want %q", resp.ID, "res-123")
	}
	if resp.Status != "queued" {
		t.Errorf("response status = %q, want %q", resp.Status, "queued")
	}
}

func TestGetResearch(t *testing.T) {
	content := "Here is the answer with [1] citations."
	c, _ := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != "GET" {
			t.Errorf("method = %s, want GET", r.Method)
		}
		if r.URL.Path != "/research/abc-123" {
			t.Errorf("path = %s, want /research/abc-123", r.URL.Path)
		}
		json.NewEncoder(w).Encode(ResearchObject{
			ID:        "abc-123",
			Status:    "completed",
			Query:     "test",
			Content:   &content,
			CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
			Results: []ResearchResult{
				{ID: "r1", Title: "Source 1", URL: "https://example.com", CitationIndex: 1},
			},
		})
	}))

	res, err := c.GetResearch("abc-123")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if res.ID != "abc-123" {
		t.Errorf("ID = %q, want %q", res.ID, "abc-123")
	}
	if res.Status != "completed" {
		t.Errorf("status = %q, want %q", res.Status, "completed")
	}
	if res.Content == nil || *res.Content != content {
		t.Errorf("content = %v, want %q", res.Content, content)
	}
	if len(res.Results) != 1 {
		t.Fatalf("results count = %d, want 1", len(res.Results))
	}
	if res.Results[0].CitationIndex != 1 {
		t.Errorf("citation index = %d, want 1", res.Results[0].CitationIndex)
	}
}

func TestGetResearchEvents(t *testing.T) {
	c, _ := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/research/abc-123/events" {
			t.Errorf("path = %s, want /research/abc-123/events", r.URL.Path)
		}
		json.NewEncoder(w).Encode([]ResearchEvent{
			{Message: "Searching...", CreatedAt: time.Now()},
			{Message: "Analyzing...", CreatedAt: time.Now()},
		})
	}))

	events, err := c.GetResearchEvents("abc-123")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(events) != 2 {
		t.Fatalf("events count = %d, want 2", len(events))
	}
	if events[0].Message != "Searching..." {
		t.Errorf("event[0] message = %q, want %q", events[0].Message, "Searching...")
	}
}

func TestGetResultContent(t *testing.T) {
	t.Run("with format", func(t *testing.T) {
		c, _ := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			wantPath := "/research/r1/results/res1/content"
			if r.URL.Path != wantPath {
				t.Errorf("path = %s, want %s", r.URL.Path, wantPath)
			}
			if r.URL.Query().Get("format") != "markdown" {
				t.Errorf("format = %q, want %q", r.URL.Query().Get("format"), "markdown")
			}
			json.NewEncoder(w).Encode(ResultContent{Content: "# Hello"})
		}))

		rc, err := c.GetResultContent("r1", "res1", "markdown")
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if rc.Content != "# Hello" {
			t.Errorf("content = %q, want %q", rc.Content, "# Hello")
		}
	})

	t.Run("without format", func(t *testing.T) {
		c, _ := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if r.URL.RawQuery != "" {
				t.Errorf("query string = %q, want empty", r.URL.RawQuery)
			}
			json.NewEncoder(w).Encode(ResultContent{Content: "raw content"})
		}))

		rc, err := c.GetResultContent("r1", "res1", "")
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if rc.Content != "raw content" {
			t.Errorf("content = %q, want %q", rc.Content, "raw content")
		}
	})
}

func TestCreateBrainstorm(t *testing.T) {
	var gotBody map[string]string
	c, _ := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != "POST" {
			t.Errorf("method = %s, want POST", r.Method)
		}
		if r.URL.Path != "/research/brainstorm" {
			t.Errorf("path = %s, want /research/brainstorm", r.URL.Path)
		}
		json.NewDecoder(r.Body).Decode(&gotBody)
		json.NewEncoder(w).Encode(BrainstormSession{
			ID:     "bs-1",
			Query:  "test query",
			Status: "ready",
			Questions: []BrainstormQuestion{
				{
					ID:       "q1",
					Question: "What scope?",
					Options:  map[string]string{"1": "Narrow", "2": "Broad"},
				},
			},
		})
	}))

	session, err := c.CreateBrainstorm("test query")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if gotBody["query"] != "test query" {
		t.Errorf("sent query = %q, want %q", gotBody["query"], "test query")
	}
	if session.ID != "bs-1" {
		t.Errorf("session ID = %q, want %q", session.ID, "bs-1")
	}
	if len(session.Questions) != 1 {
		t.Fatalf("questions count = %d, want 1", len(session.Questions))
	}
	if session.Questions[0].Question != "What scope?" {
		t.Errorf("question = %q, want %q", session.Questions[0].Question, "What scope?")
	}
}

func TestCreateChatMessage(t *testing.T) {
	var gotBody map[string]string
	c, _ := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != "POST" {
			t.Errorf("method = %s, want POST", r.Method)
		}
		if r.URL.Path != "/research/r1/chat" {
			t.Errorf("path = %s, want /research/r1/chat", r.URL.Path)
		}
		json.NewDecoder(r.Body).Decode(&gotBody)
		json.NewEncoder(w).Encode(ChatMessage{ID: "msg-1", Status: "processing"})
	}))

	msg, err := c.CreateChatMessage("r1", "What about X?")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if gotBody["content"] != "What about X?" {
		t.Errorf("sent content = %q, want %q", gotBody["content"], "What about X?")
	}
	if msg.ID != "msg-1" {
		t.Errorf("message ID = %q, want %q", msg.ID, "msg-1")
	}
	if msg.Status != "processing" {
		t.Errorf("message status = %q, want %q", msg.Status, "processing")
	}
}

func TestGetChatMessage(t *testing.T) {
	c, _ := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/research/r1/chat/msg-1" {
			t.Errorf("path = %s, want /research/r1/chat/msg-1", r.URL.Path)
		}
		json.NewEncoder(w).Encode(ChatMessage{
			ID:      "msg-1",
			Status:  "completed",
			Content: "The answer is 42.",
			Results: []ResearchResult{{ID: "r1", Title: "Source", URL: "https://example.com", CitationIndex: 1}},
		})
	}))

	msg, err := c.GetChatMessage("r1", "msg-1")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if msg.Content != "The answer is 42." {
		t.Errorf("content = %q, want %q", msg.Content, "The answer is 42.")
	}
	if len(msg.Results) != 1 {
		t.Fatalf("results count = %d, want 1", len(msg.Results))
	}
}

func TestGetChatHistory(t *testing.T) {
	c, _ := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/research/r1/chat" {
			t.Errorf("path = %s, want /research/r1/chat", r.URL.Path)
		}
		if r.Method != "GET" {
			t.Errorf("method = %s, want GET", r.Method)
		}
		json.NewEncoder(w).Encode([]ChatMessage{
			{ID: "msg-1", Content: "first"},
			{ID: "msg-2", Content: "second"},
		})
	}))

	msgs, err := c.GetChatHistory("r1")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(msgs) != 2 {
		t.Fatalf("messages count = %d, want 2", len(msgs))
	}
	if msgs[0].ID != "msg-1" {
		t.Errorf("msgs[0].ID = %q, want %q", msgs[0].ID, "msg-1")
	}
}

func TestStreamChatMessage(t *testing.T) {
	c, _ := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/research/r1/chat/msg-1/stream" {
			t.Errorf("path = %s, want /research/r1/chat/msg-1/stream", r.URL.Path)
		}
		w.Header().Set("Content-Type", "text/event-stream")
		w.Write([]byte("data: hello\n\n"))
	}))

	resp, err := c.StreamChatMessage("r1", "msg-1")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	if !strings.Contains(string(body), "hello") {
		t.Errorf("stream body = %q, want to contain %q", string(body), "hello")
	}
}

func TestCreateCollection(t *testing.T) {
	t.Run("with description", func(t *testing.T) {
		var gotBody map[string]string
		c, _ := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if r.Method != "POST" {
				t.Errorf("method = %s, want POST", r.Method)
			}
			if r.URL.Path != "/research/collections" {
				t.Errorf("path = %s, want /research/collections", r.URL.Path)
			}
			json.NewDecoder(r.Body).Decode(&gotBody)
			json.NewEncoder(w).Encode(Collection{
				ID:   "col-1",
				Name: "My Collection",
			})
		}))

		coll, err := c.CreateCollection("My Collection", "A description")
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if gotBody["name"] != "My Collection" {
			t.Errorf("sent name = %q, want %q", gotBody["name"], "My Collection")
		}
		if gotBody["description"] != "A description" {
			t.Errorf("sent description = %q, want %q", gotBody["description"], "A description")
		}
		if coll.ID != "col-1" {
			t.Errorf("collection ID = %q, want %q", coll.ID, "col-1")
		}
	})

	t.Run("without description", func(t *testing.T) {
		var gotBody map[string]string
		c, _ := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			json.NewDecoder(r.Body).Decode(&gotBody)
			json.NewEncoder(w).Encode(Collection{ID: "col-2", Name: "Bare"})
		}))

		_, err := c.CreateCollection("Bare", "")
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if _, ok := gotBody["description"]; ok {
			t.Errorf("description should not be sent when empty, got %q", gotBody["description"])
		}
	})
}

func TestNewWithOptions_Defaults(t *testing.T) {
	c := NewWithOptions(Options{APIKey: "key"})
	if c.httpClient == nil {
		t.Error("httpClient should not be nil")
	}
	if c.baseURL == "" {
		t.Error("baseURL should default to config.BaseURL")
	}
	if c.apiKey != "key" {
		t.Errorf("apiKey = %q, want %q", c.apiKey, "key")
	}
}

func TestNew_MissingKey(t *testing.T) {
	t.Setenv("CAESAR_API_KEY", "")
	_, err := New()
	if err == nil {
		t.Fatal("expected error when CAESAR_API_KEY is not set")
	}
}

func TestNew_WithKey(t *testing.T) {
	t.Setenv("CAESAR_API_KEY", "real-key")
	c, err := New()
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if c.apiKey != "real-key" {
		t.Errorf("apiKey = %q, want %q", c.apiKey, "real-key")
	}
}


path: internal/output/output_test.go
content: package output

import (
	"bytes"
	"os"
	"runtime"
	"strings"
	"testing"
)

func restoreDefaults() func() {
	origOut, origErr, origExit := Stdout, Stderr, ExitFunc
	return func() {
		Stdout = origOut
		Stderr = origErr
		ExitFunc = origExit
	}
}

func TestJSON_Struct(t *testing.T) {
	defer restoreDefaults()()
	var buf bytes.Buffer
	Stdout = &buf

	JSON(struct {
		Name string `json:"name"`
	}{Name: "test"})

	got := buf.String()
	if !strings.Contains(got, `"name": "test"`) {
		t.Errorf("JSON output = %q, want to contain %q", got, `"name": "test"`)
	}
}

func TestJSON_Nil(t *testing.T) {
	defer restoreDefaults()()
	var buf bytes.Buffer
	Stdout = &buf

	JSON(nil)

	got := strings.TrimSpace(buf.String())
	if got != "null" {
		t.Errorf("JSON(nil) = %q, want %q", got, "null")
	}
}

func TestJSON_Indented(t *testing.T) {
	defer restoreDefaults()()
	var buf bytes.Buffer
	Stdout = &buf

	JSON(map[string]int{"a": 1})

	got := buf.String()
	if !strings.Contains(got, "  ") {
		t.Errorf("JSON output should be indented, got %q", got)
	}
}

func TestError_WritesToStderr(t *testing.T) {
	defer restoreDefaults()()
	var buf bytes.Buffer
	Stderr = &buf
	ExitFunc = func(int) {} // prevent exit

	Error("something went %s", "wrong")

	got := buf.String()
	want := "Error: something went wrong\n"
	if got != want {
		t.Errorf("Error() wrote %q to stderr, want %q", got, want)
	}
}

func TestError_CallsExitWithOne(t *testing.T) {
	defer restoreDefaults()()
	Stderr = &bytes.Buffer{}

	var gotCode int
	var didExit bool

	ExitFunc = func(code int) {
		gotCode = code
		didExit = true
		// Use runtime.Goexit to stop execution like os.Exit would,
		// but in a way that's safe for tests.
		runtime.Goexit()
	}

	done := make(chan struct{})
	go func() {
		defer close(done)
		Error("fail")
	}()
	<-done

	if !didExit {
		t.Error("ExitFunc was not called")
	}
	if gotCode != 1 {
		t.Errorf("exit code = %d, want 1", gotCode)
	}
}

func TestJSON_EncodingError(t *testing.T) {
	defer restoreDefaults()()
	var stdoutBuf, stderrBuf bytes.Buffer
	Stdout = &stdoutBuf
	Stderr = &stderrBuf

	// Channels cannot be JSON-encoded.
	JSON(make(chan int))

	if stderrBuf.Len() == 0 {
		t.Error("expected error on stderr for non-encodable type")
	}
}

// Verify defaults point to the real os.Stdout/os.Stderr.
func TestDefaults(t *testing.T) {
	if Stdout != os.Stdout {
		t.Error("default Stdout should be os.Stdout")
	}
	if Stderr != os.Stderr {
		t.Error("default Stderr should be os.Stderr")
	}
}


path: internal/output/output.go
content: package output

import (
	"encoding/json"
	"fmt"
	"io"
	"os"
)

// Stdout and Stderr are the writers used for output. Tests can replace these.
var (
	Stdout io.Writer = os.Stdout
	Stderr io.Writer = os.Stderr
)

// ExitFunc is called to exit the process. Tests can replace this.
var ExitFunc = os.Exit

// JSON prints v as indented JSON to Stdout.
func JSON(v any) {
	enc := json.NewEncoder(Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		fmt.Fprintf(Stderr, "Error encoding output: %v\n", err)
	}
}

// Error prints an error message to Stderr and exits.
func Error(format string, args ...any) {
	fmt.Fprintf(Stderr, "Error: "+format+"\n", args...)
	ExitFunc(1)
}


path: internal/config/config.go
content: package config

import (
	"fmt"
	"os"
)

const (
	BaseURL    = "https://api.caesar.xyz"
	EnvAPIKey  = "CAESAR_API_KEY"
)

func GetAPIKey() (string, error) {
	key := os.Getenv(EnvAPIKey)
	if key == "" {
		return "", fmt.Errorf("environment variable %s is not set", EnvAPIKey)
	}
	return key, nil
}


path: internal/config/config_test.go
content: package config

import (
	"testing"
)

func TestGetAPIKey_Set(t *testing.T) {
	t.Setenv("CAESAR_API_KEY", "my-secret-key")
	key, err := GetAPIKey()
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if key != "my-secret-key" {
		t.Errorf("key = %q, want %q", key, "my-secret-key")
	}
}

func TestGetAPIKey_Empty(t *testing.T) {
	t.Setenv("CAESAR_API_KEY", "")
	_, err := GetAPIKey()
	if err == nil {
		t.Fatal("expected error when CAESAR_API_KEY is empty")
	}
}

func TestGetAPIKey_Unset(t *testing.T) {
	t.Setenv("CAESAR_API_KEY", "")
	_, err := GetAPIKey()
	if err == nil {
		t.Fatal("expected error when CAESAR_API_KEY is not set")
	}
	want := "environment variable CAESAR_API_KEY is not set"
	if err.Error() != want {
		t.Errorf("error = %q, want %q", err.Error(), want)
	}
}


path: cmd/research.go
content: package cmd

import (
	"fmt"
	"io"
	"time"

	"github.com/alexrudloff/caesar-cli/internal/client"
	"github.com/alexrudloff/caesar-cli/internal/output"
	"github.com/spf13/cobra"
)

// pollInterval controls how often pollResearch polls. Tests can set this to 0.
var pollInterval = 3 * time.Second

func init() {
	rootCmd.AddCommand(researchCmd)
	researchCmd.AddCommand(researchCreateCmd)
	researchCmd.AddCommand(researchGetCmd)
	researchCmd.AddCommand(researchEventsCmd)
	researchCmd.AddCommand(researchWatchCmd)

	researchCreateCmd.Flags().Int("loops", 1, "Maximum reasoning loops")
	researchCreateCmd.Flags().Bool("reasoning", false, "Enable reasoning mode")
	researchCreateCmd.Flags().Bool("auto", false, "Auto-configure based on query")
	researchCreateCmd.Flags().Bool("exclude-social", false, "Exclude social media sources")
	researchCreateCmd.Flags().String("model", "", "Model to use (gpt-5.2, gemini-3-pro, gemini-3-flash, claude-opus-4.5)")
	researchCreateCmd.Flags().String("system-prompt", "", "Custom system prompt")
	researchCreateCmd.Flags().StringSlice("exclude-domain", nil, "Domains to exclude")
	researchCreateCmd.Flags().String("brainstorm", "", "Brainstorm session ID to use")
	researchCreateCmd.Flags().Bool("no-wait", false, "Return immediately without waiting for completion")
}

var researchCmd = &cobra.Command{
	Use:   "research",
	Short: "Manage research jobs",
}

var researchCreateCmd = &cobra.Command{
	Use:   "create [query]",
	Short: "Create a new research job",
	Args:  cobra.ExactArgs(1),
	Run: func(cmd *cobra.Command, args []string) {
		c, err := newClient()
		if err != nil {
			output.Error("%v", err)
		}

		req := &client.CreateResearchRequest{Query: args[0]}
		req.ReasoningLoops, _ = cmd.Flags().GetInt("loops")
		req.ReasoningMode, _ = cmd.Flags().GetBool("reasoning")
		req.Auto, _ = cmd.Flags().GetBool("auto")
		req.ExcludeSocial, _ = cmd.Flags().GetBool("exclude-social")
		req.Model, _ = cmd.Flags().GetString("model")
		req.SystemPrompt, _ = cmd.Flags().GetString("system-prompt")
		req.ExcludedDomains, _ = cmd.Flags().GetStringSlice("exclude-domain")
		req.BrainstormSessionID, _ = cmd.Flags().GetString("brainstorm")

		resp, err := c.CreateResearch(req)
		if err != nil {
			output.Error("%v", err)
		}

		noWait, _ := cmd.Flags().GetBool("no-wait")
		if noWait {
			output.JSON(resp)
			return
		}

		result, err := pollResearch(c, cmd.OutOrStdout(), resp.ID)
		if err != nil {
			output.Error("%v", err)
		}
		output.JSON(result)
	},
}

var researchGetCmd = &cobra.Command{
	Use:   "get [id]",
	Short: "Get a research job by ID",
	Args:  cobra.ExactArgs(1),
	Run: func(cmd *cobra.Command, args []string) {
		c, err := newClient()
		if err != nil {
			output.Error("%v", err)
		}

		res, err := c.GetResearch(args[0])
		if err != nil {
			output.Error("%v", err)
		}

		output.JSON(res)
	},
}

var researchEventsCmd = &cobra.Command{
	Use:   "events [id]",
	Short: "Get events for a research job",
	Args:  cobra.ExactArgs(1),
	Run: func(cmd *cobra.Command, args []string) {
		c, err := newClient()
		if err != nil {
			output.Error("%v", err)
		}

		events, err := c.GetResearchEvents(args[0])
		if err != nil {
			output.Error("%v", err)
		}

		output.JSON(events)
	},
}

var researchWatchCmd = &cobra.Command{
	Use:   "watch [id]",
	Short: "Poll a research job until completion, printing events",
	Args:  cobra.ExactArgs(1),
	Run: func(cmd *cobra.Command, args []string) {
		c, err := newClient()
		if err != nil {
			output.Error("%v", err)
		}

		result, err := pollResearch(c, cmd.OutOrStdout(), args[0])
		if err != nil {
			output.Error("%v", err)
		}

		output.JSON(result)
	},
}

func pollResearch(c *client.Client, w io.Writer, id string) (*client.ResearchObject, error) {
	seenEvents := 0
	for {
		res, err := c.GetResearch(id)
		if err != nil {
			return nil, err
		}

		events, err := c.GetResearchEvents(id)
		if err == nil && len(events) > seenEvents {
			for _, e := range events[seenEvents:] {
				fmt.Fprintf(w, "[%s] %s\n", e.CreatedAt.Format(time.TimeOnly), e.Message)
			}
			seenEvents = len(events)
		}

		switch res.Status {
		case "completed":
			return res, nil
		case "failed":
			return nil, fmt.Errorf("research job %s failed", id)
		}

		time.Sleep(pollInterval)
	}
}


path: cmd/collections_test.go
content: package cmd

import (
	"encoding/json"
	"net/http"
	"strings"
	"testing"

	"github.com/alexrudloff/caesar-cli/internal/client"
)

func TestCollectionsCreateCmd(t *testing.T) {
	stdout, _ := captureOutput(t)

	var gotBody map[string]string
	setupTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		json.NewDecoder(r.Body).Decode(&gotBody)
		json.NewEncoder(w).Encode(client.Collection{ID: "col-1", Name: "My Docs"})
	}))

	rootCmd.SetArgs([]string{"collections", "create", "My Docs"})
	rootCmd.Execute()

	if gotBody["name"] != "My Docs" {
		t.Errorf("sent name = %q, want %q", gotBody["name"], "My Docs")
	}

	out := stdout.String()
	if !strings.Contains(out, "col-1") {
		t.Errorf("output should contain collection ID, got %q", out)
	}
}

func TestCollectionsCreateCmd_WithDescription(t *testing.T) {
	stdout, _ := captureOutput(t)

	var gotBody map[string]string
	setupTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		json.NewDecoder(r.Body).Decode(&gotBody)
		json.NewEncoder(w).Encode(client.Collection{ID: "col-2", Name: "Named", Description: "A desc"})
	}))

	rootCmd.SetArgs([]string{"collections", "create", "Named", "--description", "A desc"})
	rootCmd.Execute()

	if gotBody["description"] != "A desc" {
		t.Errorf("sent description = %q, want %q", gotBody["description"], "A desc")
	}

	out := stdout.String()
	if !strings.Contains(out, "col-2") {
		t.Errorf("output should contain collection ID, got %q", out)
	}
}


path: cmd/testutil_test.go
content: package cmd

import (
	"bytes"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/alexrudloff/caesar-cli/internal/client"
	"github.com/alexrudloff/caesar-cli/internal/output"
)

// setupTestServer creates an httptest.Server and injects a test client into newClient.
// Returns the server (for handler access) and a restore function.
func setupTestServer(t *testing.T, handler http.Handler) *httptest.Server {
	t.Helper()
	srv := httptest.NewServer(handler)
	t.Cleanup(srv.Close)

	origNewClient := newClient
	newClient = func() (*client.Client, error) {
		return client.NewWithOptions(client.Options{
			BaseURL: srv.URL,
			[REDACTED_SECRET]",
		}), nil
	}
	t.Cleanup(func() { newClient = origNewClient })

	return srv
}

// captureOutput replaces output.Stdout and output.Stderr with buffers.
// Returns the buffers. Restores originals on test cleanup.
func captureOutput(t *testing.T) (stdout, stderr *bytes.Buffer) {
	t.Helper()
	var outBuf, errBuf bytes.Buffer
	origOut, origErr, origExit := output.Stdout, output.Stderr, output.ExitFunc
	output.Stdout = &outBuf
	output.Stderr = &errBuf
	output.ExitFunc = func(int) {} // prevent os.Exit in tests
	t.Cleanup(func() {
		output.Stdout = origOut
		output.Stderr = origErr
		output.ExitFunc = origExit
	})
	return &outBuf, &errBuf
}

// jsonHandler returns an http.HandlerFunc that responds with the given value as JSON.
func jsonHandler(t *testing.T, v any) http.HandlerFunc {
	t.Helper()
	return func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(v)
	}
}


path: cmd/brainstorm.go
content: package cmd

import (
	"fmt"

	"github.com/alexrudloff/caesar-cli/internal/output"
	"github.com/spf13/cobra"
)

func init() {
	rootCmd.AddCommand(brainstormCmd)
}

var brainstormCmd = &cobra.Command{
	Use:   "brainstorm [query]",
	Short: "Start a brainstorm session to get clarifying questions before research",
	Args:  cobra.ExactArgs(1),
	Run: func(cmd *cobra.Command, args []string) {
		c, err := newClient()
		if err != nil {
			output.Error("%v", err)
		}

		session, err := c.CreateBrainstorm(args[0])
		if err != nil {
			output.Error("%v", err)
		}

		w := cmd.OutOrStdout()
		fmt.Fprintf(w, "Brainstorm session: %s\n\n", session.ID)
		for i, q := range session.Questions {
			fmt.Fprintf(w, "%d. %s\n", i+1, q.Question)
			for k, v := range q.Options {
				fmt.Fprintf(w, "   %s) %s\n", k, v)
			}
			fmt.Fprintln(w)
		}
		fmt.Fprintln(w, "Use this session ID with: caesar research create --brainstorm", session.ID, "\"your query\"")
	},
}


path: cmd/research_test.go
content: package cmd

import (
	"bytes"
	"encoding/json"
	"net/http"
	"strings"
	"sync/atomic"
	"testing"
	"time"

	"github.com/alexrudloff/caesar-cli/internal/client"
)

func TestPollResearch_CompletesImmediately(t *testing.T) {
	origInterval := pollInterval
	pollInterval = 0
	t.Cleanup(func() { pollInterval = origInterval })

	content := "done"
	setupTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch {
		case strings.HasSuffix(r.URL.Path, "/events"):
			json.NewEncoder(w).Encode([]client.ResearchEvent{})
		default:
			json.NewEncoder(w).Encode(client.ResearchObject{
				ID:      "r1",
				Status:  "completed",
				Content: &content,
			})
		}
	}))

	c := client.NewWithOptions(client.Options{BaseURL: "", APIKey: "test"})
	// We need the real test server URL, so use newClient
	c2, _ := newClient()

	var buf bytes.Buffer
	res, err := pollResearch(c2, &buf, "r1")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if res.Status != "completed" {
		t.Errorf("status = %q, want %q", res.Status, "completed")
	}
	_ = c // suppress unused
}

func TestPollResearch_PollingWithEvents(t *testing.T) {
	origInterval := pollInterval
	pollInterval = 0
	t.Cleanup(func() { pollInterval = origInterval })

	var callCount atomic.Int32
	setupTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n := callCount.Add(1)
		switch {
		case strings.HasSuffix(r.URL.Path, "/events"):
			if n <= 2 {
				json.NewEncoder(w).Encode([]client.ResearchEvent{
					{Message: "Searching...", CreatedAt: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)},
				})
			} else {
				json.NewEncoder(w).Encode([]client.ResearchEvent{
					{Message: "Searching...", CreatedAt: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)},
					{Message: "Analyzing...", CreatedAt: time.Date(2025, 1, 1, 12, 0, 1, 0, time.UTC)},
				})
			}
		default:
			if n <= 2 {
				json.NewEncoder(w).Encode(client.ResearchObject{ID: "r1", Status: "searching"})
			} else {
				content := "result"
				json.NewEncoder(w).Encode(client.ResearchObject{ID: "r1", Status: "completed", Content: &content})
			}
		}
	}))

	c, _ := newClient()
	var buf bytes.Buffer
	res, err := pollResearch(c, &buf, "r1")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if res.Status != "completed" {
		t.Errorf("status = %q, want %q", res.Status, "completed")
	}

	out := buf.String()
	if !strings.Contains(out, "Searching...") {
		t.Errorf("output should contain 'Searching...', got %q", out)
	}
	if !strings.Contains(out, "Analyzing...") {
		t.Errorf("output should contain 'Analyzing...', got %q", out)
	}
	// "Searching..." should appear only once (not duplicated on re-poll)
	if strings.Count(out, "Searching...") != 1 {
		t.Errorf("'Searching...' should appear once, appeared %d times", strings.Count(out, "Searching..."))
	}
}

func TestPollResearch_FailedJob(t *testing.T) {
	origInterval := pollInterval
	pollInterval = 0
	t.Cleanup(func() { pollInterval = origInterval })

	setupTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch {
		case strings.HasSuffix(r.URL.Path, "/events"):
			json.NewEncoder(w).Encode([]client.ResearchEvent{})
		default:
			json.NewEncoder(w).Encode(client.ResearchObject{ID: "r1", Status: "failed"})
		}
	}))

	c, _ := newClient()
	var buf bytes.Buffer
	_, err := pollResearch(c, &buf, "r1")
	if err == nil {
		t.Fatal("expected error for failed job")
	}
	if !strings.Contains(err.Error(), "failed") {
		t.Errorf("error = %q, should contain 'failed'", err.Error())
	}
}

func TestResearchCreateCmd_NoWait(t *testing.T) {
	stdout, _ := captureOutput(t)

	var gotReq client.CreateResearchRequest
	setupTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		json.NewDecoder(r.Body).Decode(&gotReq)
		json.NewEncoder(w).Encode(client.CreateResearchResponse{ID: "res-99", Status: "queued"})
	}))

	rootCmd.SetArgs([]string{"research", "create", "my research question", "--no-wait"})
	rootCmd.Execute()

	if gotReq.Query != "my research question" {
		t.Errorf("sent query = %q, want %q", gotReq.Query, "my research question")
	}

	out := stdout.String()
	if !strings.Contains(out, "res-99") {
		t.Errorf("output should contain job ID, got %q", out)
	}
}

func TestResearchCreateCmd_DefaultWaits(t *testing.T) {
	origInterval := pollInterval
	pollInterval = 0
	t.Cleanup(func() { pollInterval = origInterval })

	// Reset sticky flag from prior tests.
	researchCreateCmd.Flags().Set("no-wait", "false")

	stdout, _ := captureOutput(t)

	var callCount atomic.Int32
	setupTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n := callCount.Add(1)
		if r.Method == "POST" {
			json.NewEncoder(w).Encode(client.CreateResearchResponse{ID: "res-99", Status: "queued"})
			return
		}
		switch {
		case strings.HasSuffix(r.URL.Path, "/events"):
			json.NewEncoder(w).Encode([]client.ResearchEvent{})
		default:
			if n <= 3 {
				json.NewEncoder(w).Encode(client.ResearchObject{ID: "res-99", Status: "searching"})
			} else {
				content := "final answer"
				json.NewEncoder(w).Encode(client.ResearchObject{ID: "res-99", Status: "completed", Content: &content})
			}
		}
	}))

	rootCmd.SetArgs([]string{"research", "create", "my question"})
	rootCmd.Execute()

	out := stdout.String()
	if !strings.Contains(out, "completed") {
		t.Errorf("default should wait for completion, got %q", out)
	}
}

func TestResearchCreateCmd_WithFlags(t *testing.T) {
	captureOutput(t)

	var gotReq client.CreateResearchRequest
	setupTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		json.NewDecoder(r.Body).Decode(&gotReq)
		json.NewEncoder(w).Encode(client.CreateResearchResponse{ID: "res-100", Status: "queued"})
	}))

	rootCmd.SetArgs([]string{"research", "create", "query", "--model", "gpt-5.2", "--loops", "5", "--reasoning", "--no-wait"})
	rootCmd.Execute()

	if gotReq.Model != "gpt-5.2" {
		t.Errorf("model = %q, want %q", gotReq.Model, "gpt-5.2")
	}
	if gotReq.ReasoningLoops != 5 {
		t.Errorf("loops = %d, want 5", gotReq.ReasoningLoops)
	}
	if !gotReq.ReasoningMode {
		t.Error("reasoning mode should be true")
	}
}

func TestResearchGetCmd(t *testing.T) {
	stdout, _ := captureOutput(t)

	content := "answer"
	setupTestServer(t, jsonHandler(t, client.ResearchObject{
		ID:      "r1",
		Status:  "completed",
		Content: &content,
	}))

	rootCmd.SetArgs([]string{"research", "get", "r1"})
	rootCmd.Execute()

	out := stdout.String()
	if !strings.Contains(out, "r1") {
		t.Errorf("output should contain research ID, got %q", out)
	}
	if !strings.Contains(out, "completed") {
		t.Errorf("output should contain status, got %q", out)
	}
}

func TestResearchEventsCmd(t *testing.T) {
	stdout, _ := captureOutput(t)

	setupTestServer(t, jsonHandler(t, []client.ResearchEvent{
		{Message: "Step 1", CreatedAt: time.Now()},
	}))

	rootCmd.SetArgs([]string{"research", "events", "r1"})
	rootCmd.Execute()

	out := stdout.String()
	if !strings.Contains(out, "Step 1") {
		t.Errorf("output should contain event message, got %q", out)
	}
}


path: cmd/collections.go
content: package cmd

import (
	"github.com/alexrudloff/caesar-cli/internal/output"
	"github.com/spf13/cobra"
)

func init() {
	rootCmd.AddCommand(collectionsCmd)
	collectionsCmd.AddCommand(collectionsCreateCmd)

	collectionsCreateCmd.Flags().String("description", "", "Collection description")
}

var collectionsCmd = &cobra.Command{
	Use:   "collections",
	Short: "Manage file collections",
}

var collectionsCreateCmd = &cobra.Command{
	Use:   "create [name]",
	Short: "Create a new collection",
	Args:  cobra.ExactArgs(1),
	Run: func(cmd *cobra.Command, args []string) {
		c, err := newClient()
		if err != nil {
			output.Error("%v", err)
		}

		desc, _ := cmd.Flags().GetString("description")
		coll, err := c.CreateCollection(args[0], desc)
		if err != nil {
			output.Error("%v", err)
		}

		output.JSON(coll)
	},
}


path: cmd/chat_test.go
content: package cmd

import (
	"encoding/json"
	"net/http"
	"strings"
	"sync/atomic"
	"testing"

	"github.com/alexrudloff/caesar-cli/internal/client"
)

func TestChatSendCmd_NoWait(t *testing.T) {
	stdout, _ := captureOutput(t)

	setupTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode(client.ChatMessage{ID: "msg-1", Status: "processing"})
	}))

	rootCmd.SetArgs([]string{"chat", "send", "r1", "What about X?", "--wait=false"})
	rootCmd.Execute()

	out := stdout.String()
	if !strings.Contains(out, "msg-1") {
		t.Errorf("output should contain message ID, got %q", out)
	}
}

func TestChatSendCmd_PollUntilComplete(t *testing.T) {
	origInterval := chatPollInterval
	chatPollInterval = 0
	t.Cleanup(func() { chatPollInterval = origInterval })

	var callCount atomic.Int32
	setupTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n := callCount.Add(1)
		if r.Method == "POST" {
			json.NewEncoder(w).Encode(client.ChatMessage{ID: "msg-1", Status: "processing"})
			return
		}
		// GET polls
		if n <= 3 {
			json.NewEncoder(w).Encode(client.ChatMessage{ID: "msg-1", Status: "processing"})
		} else {
			json.NewEncoder(w).Encode(client.ChatMessage{
				ID:      "msg-1",
				Status:  "completed",
				Content: "The answer is 42.",
				Results: []client.ResearchResult{
					{CitationIndex: 1, Title: "Source A", URL: "https://example.com/a"},
				},
			})
		}
	}))

	var cmdOut strings.Builder
	chatSendCmd.SetOut(&cmdOut)
	t.Cleanup(func() { chatSendCmd.SetOut(nil) })

	rootCmd.SetArgs([]string{"chat", "send", "r1", "question", "--wait"})
	rootCmd.Execute()

	out := cmdOut.String()
	if !strings.Contains(out, "The answer is 42.") {
		t.Errorf("output should contain answer, got %q", out)
	}
	if !strings.Contains(out, "Sources:") {
		t.Errorf("output should contain sources header, got %q", out)
	}
	if !strings.Contains(out, "Source A") {
		t.Errorf("output should contain source title, got %q", out)
	}
}

func TestChatSendCmd_NoSources(t *testing.T) {
	origInterval := chatPollInterval
	chatPollInterval = 0
	t.Cleanup(func() { chatPollInterval = origInterval })

	setupTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method == "POST" {
			json.NewEncoder(w).Encode(client.ChatMessage{ID: "msg-1", Status: "completed", Content: "No sources needed."})
			return
		}
		json.NewEncoder(w).Encode(client.ChatMessage{ID: "msg-1", Status: "completed", Content: "No sources needed."})
	}))

	var cmdOut strings.Builder
	chatSendCmd.SetOut(&cmdOut)
	t.Cleanup(func() { chatSendCmd.SetOut(nil) })

	rootCmd.SetArgs([]string{"chat", "send", "r1", "simple question", "--wait"})
	rootCmd.Execute()

	out := cmdOut.String()
	if strings.Contains(out, "Sources:") {
		t.Errorf("output should not contain sources when none exist, got %q", out)
	}
}

func TestChatHistoryCmd(t *testing.T) {
	stdout, _ := captureOutput(t)

	setupTestServer(t, jsonHandler(t, []client.ChatMessage{
		{ID: "msg-1", Content: "first"},
		{ID: "msg-2", Content: "second"},
	}))

	rootCmd.SetArgs([]string{"chat", "history", "r1"})
	rootCmd.Execute()

	out := stdout.String()
	if !strings.Contains(out, "msg-1") {
		t.Errorf("output should contain first message ID, got %q", out)
	}
	if !strings.Contains(out, "msg-2") {
		t.Errorf("output should contain second message ID, got %q", out)
	}
}


path: cmd/chat.go
content: package cmd

import (
	"fmt"
	"time"

	"github.com/alexrudloff/caesar-cli/internal/output"
	"github.com/spf13/cobra"
)

// chatPollInterval controls how often chat send polls. Tests can set this to 0.
var chatPollInterval = 2 * time.Second

func init() {
	rootCmd.AddCommand(chatCmd)
	chatCmd.AddCommand(chatSendCmd)
	chatCmd.AddCommand(chatHistoryCmd)

	chatSendCmd.Flags().Bool("wait", true, "Wait for response to complete")
}

var chatCmd = &cobra.Command{
	Use:   "chat",
	Short: "Chat with a research job",
}

var chatSendCmd = &cobra.Command{
	Use:   "send [research-id] [message]",
	Short: "Send a follow-up question about a research job",
	Args:  cobra.ExactArgs(2),
	Run: func(cmd *cobra.Command, args []string) {
		c, err := newClient()
		if err != nil {
			output.Error("%v", err)
		}

		msg, err := c.CreateChatMessage(args[0], args[1])
		if err != nil {
			output.Error("%v", err)
		}

		wait, _ := cmd.Flags().GetBool("wait")
		if !wait {
			output.JSON(msg)
			return
		}

		// Poll until the message is complete.
		w := cmd.OutOrStdout()
		for msg.Status == "processing" {
			time.Sleep(chatPollInterval)
			msg, err = c.GetChatMessage(args[0], msg.ID)
			if err != nil {
				output.Error("%v", err)
			}
		}

		fmt.Fprintln(w, msg.Content)
		if len(msg.Results) > 0 {
			fmt.Fprintln(w, "\nSources:")
			for _, r := range msg.Results {
				fmt.Fprintf(w, "  [%d] %s - %s\n", r.CitationIndex, r.Title, r.URL)
			}
		}
	},
}

var chatHistoryCmd = &cobra.Command{
	Use:   "history [research-id]",
	Short: "List chat history for a research job",
	Args:  cobra.ExactArgs(1),
	Run: func(cmd *cobra.Command, args []string) {
		c, err := newClient()
		if err != nil {
			output.Error("%v", err)
		}

		messages, err := c.GetChatHistory(args[0])
		if err != nil {
			output.Error("%v", err)
		}

		output.JSON(messages)
	},
}


path: cmd/brainstorm_test.go
content: package cmd

import (
	"strings"
	"testing"

	"github.com/alexrudloff/caesar-cli/internal/client"
)

func TestBrainstormCmd(t *testing.T) {
	captureOutput(t)

	setupTestServer(t, jsonHandler(t, client.BrainstormSession{
		ID:    "bs-1",
		Query: "test query",
		Questions: []client.BrainstormQuestion{
			{
				ID:       "q1",
				Question: "What scope?",
				Options:  map[string]string{"1": "Narrow", "2": "Broad"},
			},
		},
		Status: "ready",
	}))

	var cmdOut strings.Builder
	brainstormCmd.SetOut(&cmdOut)
	t.Cleanup(func() { brainstormCmd.SetOut(nil) })

	rootCmd.SetArgs([]string{"brainstorm", "test query"})
	rootCmd.Execute()

	out := cmdOut.String()
	if !strings.Contains(out, "bs-1") {
		t.Errorf("output should contain session ID, got %q", out)
	}
	if !strings.Contains(out, "What scope?") {
		t.Errorf("output should contain question, got %q", out)
	}
	if !strings.Contains(out, "--brainstorm") {
		t.Errorf("output should contain usage hint, got %q", out)
	}
}


path: cmd/root.go
content: package cmd

import (
	"fmt"
	"os"

	"github.com/alexrudloff/caesar-cli/internal/client"
	"github.com/spf13/cobra"
)

// newClient is the function used to create API clients. Tests can replace it.
var newClient = func() (*client.Client, error) {
	return client.New()
}

var rootCmd = &cobra.Command{
	Use:   "caesar",
	Short: "CLI for the Caesar research API",
	Long:  "A command-line interface for caesar.org's research API. Set CAESAR_API_KEY to authenticate.",
}

func Execute() {
	if err := rootCmd.Execute(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)