diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..83db91b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,58 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +make build # build ./bin/quartr +make test # go test ./... +make install # go install ./cmd/quartr; if QUARTR_API_KEY is in env, + # also runs `quartr auth login` to persist the key + # to ~/.config/quartr/config.json (mode 0600) +make clean # remove ./bin + +go test ./internal/cli -run TestBuildConfigPrecedence # run a single test + +pre-commit run --all-files # lint+test against the whole tree +~/go/bin/golangci-lint run # lint without pre-commit (needs v2.12+) +``` + +`pre-commit install` was already run in this clone — every commit runs golangci-lint (with `--fix`) and `go test ./...`. The hooks pin golangci-lint v2.12.0; CI pins the same version through `golangci/golangci-lint-action@v7`. + +## Architecture + +Three internal packages, no external deps (Go stdlib only): + +- **`internal/quartr`** — HTTP client and config persistence. `Client.GetBytes` retries 429/5xx with backoff (honors `Retry-After`). `LoadConfig`/`SaveConfig` handle the JSON file at `~/.config/quartr/config.json`. +- **`internal/cli`** — command dispatch and request shaping. The whole CLI surface is driven by a single `resources` map in `resources.go` keyed by command name; each entry describes the API path templates, allowed query params (`paramSet`), and download/stream URL fields. `handlers.go` dispatches the operations (list/get/summary/pages/chapters/download/stream) against any resource by reading from that map. Adding a new resource = one map entry, no per-command handler code. +- **`internal/output`** — formats results as `table` / `json` / `csv` / `raw`. `--fields` supports dotted paths (`event.title`) via `getPath` recursive traversal. + +`cmd/quartr/main.go` is a 3-line entry point that calls `cli.Run`. + +### Cross-cutting design choices to preserve + +- **Auth precedence is `flag > env > file > defaults`** — implemented in `internal/cli/config.go` `buildConfig`. Tests must `t.Setenv` to clear `QUARTR_*` env vars before asserting defaults; otherwise the user's shell env leaks in. +- **Companies API uses `ids`, not `companyIds`** — handled by `listFlags.toParams` taking a `companyEndpoint` flag. Don't generalize this away; Quartr's API is genuinely asymmetric. +- **`--all` auto-bumps `--limit` to 500** unless the user passed `--limit` explicitly. Detection lives in `flagWasPassed` (string-scan over the raw args, since the `flag` package can't distinguish "default" from "explicitly default"). +- **`parseInterspersed`** in `flags.go` lets users write `cmd --flag value`. The stdlib `flag` package stops at the first positional, so we shuffle flags before positionals before delegating. +- **Downloads do NOT send `x-api-key` by default** — the Quartr `fileUrl` is publicly fetchable. `--with-api-key` is the opt-in. + +## Lint config notes + +- golangci-lint v2 syntax (config has `version: "2"` at top). v1 is built with Go 1.24 and rejects this repo's Go 1.26 target — never downgrade. +- `gomodguard` is referenced as `gomodguard_v2` after the v2.12 deprecation rename. +- `gocritic.hugeParam` is intentionally disabled — passing `resource` (200B) by value is the design, not a perf bug. +- `gosec G304/G602` excluded globally — file paths from CLI args and bounds-checked slice indexes are inherent to the tool. +- `gosec G117` is suppressed via `//nolint:gosec` on the single line where it fires (`json.MarshalIndent(cfg, ...)` in `internal/quartr/config.go`) — that file IS the credential storage, by design. +- `varnamelen` / `goconst` / `tagliatelle` / `canonicalheader` are disabled because they fire heavily on idiomatic short-scope vars, output-format string literals, Quartr's camelCase JSON tags, and Quartr's lowercase headers (`x-api-key`). + +## Repo / branch hygiene + +- Remote: `git@github.com:TJC-LP/quartr-cli.git` (private). +- `main` is protected — push to a feature branch and open a PR with `gh pr create`. Direct pushes to `main` are rejected. +- `.env` and `bin/` are gitignored; the `.env.example` exception is preserved. + +## Skill + +The `.claude/skills/quartr/` skill (loaded automatically by the harness when working in this repo) documents how to drive the `quartr` binary at runtime — commands, flags, recipes, and the type-id lookup tables for events and document forms (10-K = 11, etc.). Reference it instead of re-deriving CLI usage from the source. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e1f96b6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TJC-LP + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cmd/quartr/main.go b/cmd/quartr/main.go index adfcb38..2a3bd93 100644 --- a/cmd/quartr/main.go +++ b/cmd/quartr/main.go @@ -1,3 +1,6 @@ +// Command quartr is a dependency-free Go CLI for the Quartr Public API v3. +// +// See README.md and `quartr --help` for usage. package main import ( diff --git a/internal/cli/app.go b/internal/cli/app.go index 2b91871..2260f59 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -1,3 +1,8 @@ +// Package cli implements the quartr command-line interface: argument +// parsing, command dispatch, request shaping, and pagination. +// +// All commands are driven from the resources map in resources.go. Adding +// a new resource is a single map entry — no per-command handler code. package cli import ( @@ -9,6 +14,7 @@ import ( "quartr-cli/internal/quartr" ) +// Version is the CLI release string, surfaced via `quartr --version`. const Version = "0.1.0" type app struct { @@ -27,6 +33,10 @@ func newApp(out, errOut io.Writer, cfg effectiveConfig) *app { } } +// Run is the package entry point. It parses args (without the leading +// program name), builds the effective config from flags/env/file, and +// dispatches the command. Returns the exit code: 0 for success, 1 for a +// runtime error, 2 for a usage error. func Run(args []string, out, errOut io.Writer) int { globals, commandArgs, err := extractGlobalFlags(args) if err != nil { diff --git a/internal/output/output.go b/internal/output/output.go index 1be2331..5597b48 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -1,3 +1,6 @@ +// Package output formats CLI responses as table, json, csv, or raw. +// Table and CSV output respect Options.Fields, including dotted paths +// like "event.title" that traverse nested maps. package output import ( @@ -12,11 +15,18 @@ import ( "text/tabwriter" ) +// Options controls how Write renders an object. +// +// Format is one of "table" (default), "json", "csv", or "raw". +// Fields is the explicit column list for table/csv output; empty means +// auto-select preferred columns from the response. type Options struct { Format string Fields []string } +// Write renders obj to w according to opts.Format. Returns an error for +// unknown formats or write failures. func Write(w io.Writer, obj any, opts Options) error { format := strings.ToLower(strings.TrimSpace(opts.Format)) if format == "" { @@ -285,6 +295,9 @@ func renderValue(v any, maxLen int) string { return s } +// PrettyJSONBytes reformats raw JSON with 2-space indentation. Invalid +// JSON is returned unchanged so callers can use it as a best-effort +// formatter on untrusted input. func PrettyJSONBytes(b []byte) []byte { var obj any dec := json.NewDecoder(bytes.NewReader(b)) diff --git a/internal/quartr/client.go b/internal/quartr/client.go index 36a2b4a..62f03c9 100644 --- a/internal/quartr/client.go +++ b/internal/quartr/client.go @@ -1,3 +1,5 @@ +// Package quartr is the HTTP client and on-disk config layer for the Quartr +// Public API v3. It depends only on the Go standard library. package quartr import ( @@ -15,10 +17,14 @@ import ( ) const ( + // DefaultBaseURL is the production API endpoint used when no override is configured. DefaultBaseURL = "https://api.quartr.com/public/v3" - UserAgent = "quartr-cli/0.1.0" + // UserAgent is sent on every outbound request. + UserAgent = "quartr-cli/0.1.0" ) +// Client performs authenticated GET requests against the Quartr Public API. +// It retries 429 and 5xx responses with backoff and honors Retry-After. type Client struct { BaseURL string APIKey string @@ -26,6 +32,9 @@ type Client struct { Debug bool } +// APIError is returned when the API responds with a non-2xx status. +// The full response Body is preserved (truncated at 800 chars in Error()) +// so callers can inspect the structured error payload. type APIError struct { StatusCode int Status string @@ -33,6 +42,8 @@ type APIError struct { Headers http.Header } +// Error implements the error interface, returning a one-line summary +// suitable for printing to a terminal. func (e *APIError) Error() string { body := strings.TrimSpace(e.Body) if len(body) > 800 { @@ -44,6 +55,8 @@ func (e *APIError) Error() string { return fmt.Sprintf("quartr api error: %s: %s", e.Status, body) } +// NewClient constructs a Client. An empty baseURL falls back to DefaultBaseURL. +// A non-positive timeout is replaced with 30 seconds. func NewClient(baseURL, apiKey string, timeout time.Duration, debug bool) *Client { if strings.TrimSpace(baseURL) == "" { baseURL = DefaultBaseURL @@ -59,6 +72,10 @@ func NewClient(baseURL, apiKey string, timeout time.Duration, debug bool) *Clien } } +// GetBytes performs a GET against BaseURL+path with params as the query +// string. The API key is sent in the x-api-key header. Retries on 429 and +// 5xx with exponential backoff (250ms, 500ms) and Retry-After honoring. +// Returns the raw body, response headers, and any error. func (c *Client) GetBytes(ctx context.Context, path string, params url.Values) ([]byte, http.Header, error) { if c.APIKey == "" { return nil, nil, errors.New("missing API key; set QUARTR_API_KEY or run `quartr auth login --api-key ...`") @@ -156,6 +173,9 @@ func sleep(ctx context.Context, d time.Duration) error { } } +// GetJSON wraps GetBytes and decodes the response into a generic +// map[string]any. Numbers are preserved as json.Number to avoid float +// coercion of int64-shaped IDs. func (c *Client) GetJSON(ctx context.Context, path string, params url.Values) (map[string]any, http.Header, error) { body, hdr, err := c.GetBytes(ctx, path, params) if err != nil { @@ -170,6 +190,9 @@ func (c *Client) GetJSON(ctx context.Context, path string, params url.Values) (m return out, hdr, nil } +// BuildURL composes the request URL from BaseURL + path + params. If path +// is itself a fully qualified URL it is used verbatim, allowing callers to +// pass redirect targets returned by the API. func (c *Client) BuildURL(path string, params url.Values) (string, error) { path = strings.TrimSpace(path) if path == "" { @@ -201,6 +224,9 @@ func (c *Client) BuildURL(path string, params url.Values) (string, error) { return u.String(), nil } +// Download fetches rawURL and streams the body to w. apiKey is sent in the +// x-api-key header only when non-empty; most Quartr file URLs are public, +// so callers typically pass "". func (c *Client) Download(ctx context.Context, rawURL, apiKey string, w io.Writer) (http.Header, error) { if strings.TrimSpace(rawURL) == "" { return nil, errors.New("empty download url") diff --git a/internal/quartr/config.go b/internal/quartr/config.go index f92c7c6..3fb65fc 100644 --- a/internal/quartr/config.go +++ b/internal/quartr/config.go @@ -11,6 +11,9 @@ import ( "time" ) +// Config is the on-disk shape of the persistent CLI config file. +// Empty fields are omitted from the serialized JSON so partial updates +// don't clobber values written by earlier `auth login` runs. type Config struct { APIKey string `json:"api_key,omitempty"` BaseURL string `json:"base_url,omitempty"` @@ -18,6 +21,9 @@ type Config struct { Timeout string `json:"timeout,omitempty"` } +// DefaultConfigPath returns the platform-appropriate config file path. +// QUARTR_CONFIG overrides; otherwise uses %APPDATA% on Windows, +// $XDG_CONFIG_HOME if set, or $HOME/.config/quartr/config.json. func DefaultConfigPath() string { if p := os.Getenv("QUARTR_CONFIG"); strings.TrimSpace(p) != "" { return p @@ -37,6 +43,8 @@ func DefaultConfigPath() string { return filepath.Join(home, ".config", "quartr", "config.json") } +// LoadConfig reads a JSON config file from path. A missing file returns +// the zero Config without error so first-run usage works seamlessly. func LoadConfig(path string) (Config, error) { var cfg Config if path == "" { @@ -58,6 +66,8 @@ func LoadConfig(path string) (Config, error) { return cfg, nil } +// SaveConfig writes cfg as pretty JSON to path with file mode 0600 and +// directory mode 0700, since the file holds the API key. func SaveConfig(path string, cfg Config) error { if path == "" { path = DefaultConfigPath() @@ -73,6 +83,8 @@ func SaveConfig(path string, cfg Config) error { return os.WriteFile(path, b, 0o600) } +// EffectiveTimeout parses timeout as a Go duration (e.g. "30s", "2m"). +// Empty or invalid input returns the 30-second default. func EffectiveTimeout(timeout string) time.Duration { if strings.TrimSpace(timeout) == "" { return 30 * time.Second @@ -84,6 +96,9 @@ func EffectiveTimeout(timeout string) time.Duration { return d } +// MaskKey returns key with all but the first and last 4 characters replaced +// by asterisks. Suitable for echoing the configured key back to the user +// without disclosing it. func MaskKey(key string) string { key = strings.TrimSpace(key) if key == "" {