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
58 changes: 58 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 <id> --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.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions cmd/quartr/main.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
10 changes: 10 additions & 0 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions internal/output/output.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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 == "" {
Expand Down Expand Up @@ -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))
Expand Down
28 changes: 27 additions & 1 deletion internal/quartr/client.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -15,24 +17,33 @@ 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
HTTP *http.Client
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
Body string
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 {
Expand All @@ -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
Expand All @@ -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 ...`")
Expand Down Expand Up @@ -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 {
Expand All @@ -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 == "" {
Expand Down Expand Up @@ -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")
Expand Down
15 changes: 15 additions & 0 deletions internal/quartr/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@ 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"`
Format string `json:"format,omitempty"`
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
Expand All @@ -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 == "" {
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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 == "" {
Expand Down
Loading