From 3454f70ea46b754561351caaa22248e90b502d2e Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Thu, 10 Sep 2026 14:24:55 -0400 Subject: [PATCH 1/4] Move the toolchain to Go 1.27.1 The linter on PATH was built with Go 1.26.2 while GOROOT is 1.27.1, and golangci-lint panics type-checking the standard library in that state. Bump go.mod and the lint target to 1.27, and document that the linter has to be rebuilt with the same toolchain as the `go` on PATH. CI reads the version from go.mod so it follows along. Co-Authored-By: Claude Fable 5.1 --- .golangci.yml | 2 +- .pre-commit-config.yaml | 6 ++++-- CLAUDE.md | 4 ++-- go.mod | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 7b09b27..1ee28d2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,6 @@ version: "2" run: - go: "1.26" + go: "1.27" linters: default: none enable: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc5e0c4..4401b33 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,9 +3,11 @@ repos: hooks: # Uses the golangci-lint on PATH rather than the upstream pre-commit # repo, which builds the linter from source with whatever Go it finds. - # A linter built with Go < 1.26 refuses to load this config ("the Go + # A linter built with Go < 1.27 refuses to load this config ("the Go # language version used to build golangci-lint is lower than the - # targeted Go version"). Install the matching binary with: + # targeted Go version"), and one built with an older toolchain than + # the `go` on PATH panics loading the standard library. Rebuild it + # with the current toolchain: # go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.0 - id: golangci-lint name: golangci-lint diff --git a/CLAUDE.md b/CLAUDE.md index 400f4e7..371fa35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ 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 lint hook shells out to the `golangci-lint` on `PATH` instead of the upstream pre-commit repo, which builds the linter from source with whatever Go it finds; a linter built with Go < 1.26 refuses to load this config. Install the matching binary once: +`pre-commit install` was already run in this clone — every commit runs golangci-lint (with `--fix`) and `go test ./...`. The lint hook shells out to the `golangci-lint` on `PATH` instead of the upstream pre-commit repo, which builds the linter from source with whatever Go it finds; a linter built with Go < 1.27 refuses to load this config, and one built with an older patch release than the `go` on `PATH` panics while type-checking the standard library (`file requires newer Go version`). Whenever the toolchain moves, rebuild the linter with it: ```bash go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.0 @@ -47,7 +47,7 @@ Three internal packages, no external deps (Go stdlib only): ## 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. +- golangci-lint v2 syntax (config has `version: "2"` at top). v1 is built with Go 1.24 and rejects this repo's Go 1.27 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. diff --git a/go.mod b/go.mod index 924fa0b..862f143 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/TJC-LP/quartr-cli -go 1.26.2 +go 1.27.1 From d2a21a2c0ab2e98108947a3110cfded53c4e154b Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Thu, 10 Sep 2026 14:24:55 -0400 Subject: [PATCH 2/4] Add parsed document text, company segments, and OpenFIGI lookups Quartr turned on the parsed slides and reports datasets for our key, and the live OpenAPI spec carries surface the CLI did not wrap: - `quartr reports text ` / `quartr slides text ` fetch the parsed Markdown. The endpoint answers with a CDN `textUrl`, so the command follows it and prints the Markdown on stdout by default (`--output PATH` to write a file, `--metadata` to see the envelope). `download` keeps writing a file by default; both share `saveURL`. - `quartr companies segments ` lists the segments child endpoint. Quartr marks the dataset legacy and it 403s on our tier, so the hint names it. - `--openfigis` on `companies list`, and `companies resolve` recognises an OpenFIGI by shape and queries `openfigis` instead of `tickers`. - The 403 hint now lists `companies segments` and `reports text` / `slides text`, the latter being a separately licensed package. Docs: README, CLAUDE.md, the quartr skill (SKILL.md, commands.md, and a new recipe for reading a filing as Markdown). Co-Authored-By: Claude Fable 5.1 --- .claude/skills/quartr/SKILL.md | 22 +- .claude/skills/quartr/references/commands.md | 15 +- .claude/skills/quartr/references/recipes.md | 61 +++++- CLAUDE.md | 4 +- README.md | 37 +++- internal/cli/cli_test.go | 215 +++++++++++++++++++ internal/cli/errors.go | 3 +- internal/cli/flags.go | 5 +- internal/cli/handlers.go | 83 +++++-- internal/cli/help.go | 61 +++--- internal/cli/resources.go | 32 ++- internal/cli/tickers.go | 15 ++ 12 files changed, 485 insertions(+), 68 deletions(-) diff --git a/.claude/skills/quartr/SKILL.md b/.claude/skills/quartr/SKILL.md index a9a8a2a..7c1d1db 100644 --- a/.claude/skills/quartr/SKILL.md +++ b/.claude/skills/quartr/SKILL.md @@ -1,6 +1,6 @@ --- name: quartr -description: Query Quartr Public API v3 via the local `quartr` CLI — companies, events, earnings calls, transcripts, reports, slides, audio, live events. Use when the user asks about a ticker's earnings or fiscal periods, SEC filings (10-K / 10-Q / 8-K / 20-F / proxy) via Quartr, downloading transcripts or reports, streaming live calls, or anything sourced from api.quartr.com / quartr.com. +description: Query Quartr Public API v3 via the local `quartr` CLI — companies, events, earnings calls, transcripts, reports, slides, parsed Markdown of reports and slides, audio, live events. Use when the user asks about a ticker's earnings or fiscal periods, SEC filings (10-K / 10-Q / 8-K / 20-F / proxy) via Quartr, reading or downloading transcripts, reports or slides as text, streaming live calls, or anything sourced from api.quartr.com / quartr.com. --- # quartr @@ -92,8 +92,11 @@ explicitly passed, `--all` raises it to 500 to minimize round-trips. ```bash # Companies quartr companies list --tickers AAPL,MSFT --fields id,name,country +quartr companies list --openfigis BBG000B9XRY4 quartr companies resolve CE # every company using that ticker +quartr companies resolve BBG000B9XRY4 # a CIK or OpenFIGI works too quartr companies get 4742 --format json +quartr companies segments 4742 # legacy dataset; 403 on most plans # Events (earnings calls, AGMs, etc.) quartr events list --tickers AAPL --sort-by date --direction desc --limit 10 @@ -109,12 +112,15 @@ quartr transcripts chapters --levels 1,2 # Reports (10-K, 10-Q, 8-K, etc.) quartr reports list --tickers AAPL --type-ids 11 --limit 5 +quartr reports text # parsed Markdown on stdout — read this, not the PDF +quartr reports text --output 10k.md quartr reports download --output annual-report.pdf quartr reports pages --format csv quartr reports summary --length long --plain # Slides quartr slides list --tickers AAPL --limit 5 +quartr slides text # parsed Markdown of the deck quartr slides download quartr slides pages @@ -158,13 +164,22 @@ quartr request get /events --query tickers=AAPL --query limit=3 --format json Credito Emiliano and Cortus Energy. Run `quartr companies resolve ` when a symbol might be shared, then either use `--company-ids` or qualify the ticker as `NYSE:BLD` (the CLI resolves it to a companyId before querying). - There is no name search in the API — tickers and CIKs only. + There is no name search in the API — tickers, CIKs and OpenFIGIs only + (`--openfigis` on `companies list`; `resolve` recognises a FIGI by shape). - **`--expand company` is a client-side join.** The API rejects `expand=company`; the CLI strips it and batch-fetches `/companies` instead. Use it whenever rows need to be attributable — otherwise they carry only a bare `companyId` and a collision is invisible. +- **Read reports and slides with `text`, not `download`.** `reports text ` + and `slides text ` print Quartr's parsed Markdown (headings and tables + preserved) on stdout — pipe it to `head`, redirect it, or feed it to a model. + `download` fetches the PDF and is only right when the user wants the file. + `text` is a separate paid package: without it the endpoint returns 403. + Transcripts have no `text`; `transcripts download --output -` is already JSON. - **Tier-restricted endpoints** return `403 Forbidden` on the user's API tier. - Observed restrictions: `events summary`, `audio list`, `live transcripts list`. + Observed restrictions: `events summary`, `audio list`, `live transcripts list`, + `companies segments`; `reports text` / `slides text` without the parsed + documents package. The CLI prints a `hint:` line clarifying that 403 is entitlement, not authentication. Surface the error verbatim — do not retry, hide, silently fall back, or start debugging the API key. A rejected key returns 401. @@ -186,6 +201,7 @@ For a worked example of each, see `references/recipes.md`: - Find a company by ticker and grab its ID - Pull the last N earnings calls for a ticker - Download the latest annual report (10-K) +- Read a report or slide deck as Markdown - Fetch all transcripts for a ticker, paginated, with parent event metadata - Stream a live earnings transcript - Use the raw `request get` for an unwrapped endpoint diff --git a/.claude/skills/quartr/references/commands.md b/.claude/skills/quartr/references/commands.md index cfd8f31..fc45b4d 100644 --- a/.claude/skills/quartr/references/commands.md +++ b/.claude/skills/quartr/references/commands.md @@ -8,11 +8,11 @@ specific flag or endpoint at hand. | Command | Operations | Base path | Notes | |--------------------|---------------------------------------------|----------------------------|--------------------------------| | `auth` | `login`, `show`, `logout` | (local) | Manages `~/.config/quartr/config.json` | -| `companies` | `list`, `get`, `resolve` | `/companies` | Uses `ids` API param, not `companyIds`; `resolve ` lists collision candidates | +| `companies` | `list`, `get`, `resolve`, `segments` | `/companies` | Uses `ids` API param, not `companyIds`; `resolve ` lists collision candidates; `segments` is a legacy dataset (403 on most plans) | | `events` | `list`, `get`, `summary` | `/events` | `summary` is tier-restricted | | `documents` | `list`, `get`, `download` | `/documents` | Generic parent; prefer typed resources | -| `reports` | `list`, `get`, `summary`, `pages`, `download` | `/documents/reports` | `fileUrl` is the download field | -| `slides` | `list`, `get`, `summary`, `pages`, `download` | `/documents/slides` | `fileUrl` is the download field | +| `reports` | `list`, `get`, `summary`, `pages`, `text`, `download` | `/documents/reports` | `fileUrl` is the download field; `text` streams parsed Markdown (paid package) | +| `slides` | `list`, `get`, `summary`, `pages`, `text`, `download` | `/documents/slides` | `fileUrl` is the download field; `text` streams parsed Markdown (paid package) | | `transcripts` | `list`, `get`, `summary`, `chapters`, `download` | `/documents/transcripts` | `fileUrl` is the download field | | `audio` | `list`, `get`, `chapters`, `download` | `/audio` | `list` may be tier-restricted; `fileUrl` | | `live` | `list`, `get` | `/live` | Honors `transcriptVersion` | @@ -57,6 +57,7 @@ Auth precedence: flags > env > config file > defaults. --exchanges NYSE,NASDAQ exchange symbols --isins US0378331005 ISINs --ciks 0000320193 SEC CIKs +--openfigis BBG000B9XRY4 OpenFIGI codes (figi, compositeFigi or shareClassFigi); companies only --ids foo,bar alias used by companies-only consumers --start-date 2024-01-01 ISO 8601 --end-date 2024-12-31 ISO 8601 @@ -96,6 +97,12 @@ company` on rows that carry no companyId. Both exit 2. | ` summary` | `--plain` | Strip embedded document sources | | ` summary` | `--fields` | Output columns | | ` pages` | list flags | reports, slides only | +| ` text` | (none) | reports, slides only; prints parsed Markdown on **stdout** | +| ` text` | `--output PATH` | Write the Markdown to a file instead; `Saved ` on stderr | +| ` text` | `--metadata` | Print the envelope (`documentId`, `textUrl`, `updatedAt`) instead of fetching it | +| ` text` | `--fields` | Output columns, only with `--metadata` | +| ` text` | `--with-api-key` | Send `x-api-key` when fetching `textUrl` | +| ` segments` | list flags | companies only (`limit`, `cursor`, `direction`) | | ` chapters` | list flags + `--levels` | transcripts, audio only | | ` download` | `--output PATH` | Defaults to `-.` in cwd; `-` streams to stdout | | ` download` | (status line) | `Saved ` goes to **stderr**, never stdout | @@ -210,7 +217,7 @@ If the CLI gets new commands, refresh this reference from: - `internal/cli/app.go` — top-level command dispatch - `internal/cli/resources.go` — resource map, paths, allowed param sets - `internal/cli/flags.go` — global flags, listFlags, `toParams` -- `internal/cli/handlers.go` — list/get/summary/pages/chapters/download/stream/request +- `internal/cli/handlers.go` — list/get/summary/pages/text/chapters/segments/download/stream/request - `internal/output/output.go` — format implementations, dotted-path lookup - `internal/quartr/client.go` — retry, backoff, BuildURL - `internal/quartr/config.go` — config file precedence and shape diff --git a/.claude/skills/quartr/references/recipes.md b/.claude/skills/quartr/references/recipes.md index 8408e2c..b64cce5 100644 --- a/.claude/skills/quartr/references/recipes.md +++ b/.claude/skills/quartr/references/recipes.md @@ -41,10 +41,11 @@ quartr companies resolve CE # 16930 Cortus Energy SE OM:CE ``` -`resolve` takes a ticker, an `EXCHANGE:TICKER` pair, or a CIK, and lists every -candidate with the exchange pairs that matched. There is no name search — the -API has no `search`/`query`/`name` parameter — so never try to look a company -up by name. +`resolve` takes a ticker, an `EXCHANGE:TICKER` pair, a CIK, or an OpenFIGI +(`BBG000B9XRY4`), and lists every candidate with the exchange pairs that +matched. There is no name search — the API has no `search`/`query`/`name` +parameter — so never try to look a company up by name. When the user hands you +a FIGI, `quartr companies list --openfigis ` is exact and collision-free. Once the exchange is known, qualify the ticker anywhere `--tickers` is accepted and the CLI resolves it to a companyId before querying: @@ -145,6 +146,58 @@ country first. `fileUrl` is publicly fetchable. Only add it if the file URL itself returns 401/403. +**If the user wants to read the filing rather than have the PDF**, skip the +download and use recipe 3a. + +--- + +## 3a. Read a report or slide deck as Markdown + +**Intent:** "What does Apple's latest 10-K say about services margins?" / +"Summarize this deck" / anything where the content matters and the PDF is a +detour. + +Quartr's parsed documents package renders each report and slide deck to one +Markdown file with headings and tables preserved. `text` fetches it: + +```bash +quartr reports text # Markdown on stdout +quartr reports text --output 10k.md # or to a file +quartr slides text | head -80 # first slides of a deck +``` + +Find the id the same way as recipe 3 (pull wide, sort locally on `createdAt`), +then: + +```bash +quartr reports text 105446 > apple-q4-2019.md +``` + +Tables come through as pipe tables, so financial statements are greppable: + +```bash +quartr reports text 105446 | grep -i 'total net sales' +# |Total net sales (1)|64,040|62,900|260,174|265,595| +``` + +Response shape of `--metadata` (for provenance or freshness checks): + +```json +{"data":{"documentId":105446,"textUrl":"https://files.quartr.com/document-artifacts/…/….markdown?ref=…","updatedAt":"2026-08-05T22:36:36.000Z","createdAt":"…"}} +``` + +**Pitfalls:** + +- `text` prints to stdout by default; `download` writes a file by default. Do + not expect a `Saved` line from `text` unless you passed `--output`. +- Parsed text is a separately licensed package. A `403` here with a working + key elsewhere means the plan lacks it — say so, do not debug auth. +- Coverage is per document: a recent filing may have `text` before an old one + does. If `text` fails for one document, fall back to `download` and say the + parsed rendering is not available for it. +- Transcripts have no `text` endpoint: `quartr transcripts download + --output -` already returns structured JSON with the speaker turns. + --- ## 4. Fetch all transcripts for a ticker, paginated, with parent event metadata diff --git a/CLAUDE.md b/CLAUDE.md index 371fa35..81d8a35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ CI pins the same version through `golangci/golangci-lint-action@v7`. 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/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/text/chapters/segments/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`. @@ -44,6 +44,8 @@ Three internal packages, no external deps (Go stdlib only): - **`--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. +- **`text` streams to stdout by default; `download` writes a file by default.** Both share `saveURL`. The asymmetry is deliberate: parsed Markdown is meant to be piped or redirected, while PDFs and audio are not. Don't unify them. +- **`/text` returns a link, not text.** `DocumentTextDto` is `{documentId, textUrl, updatedAt, createdAt}`; the Markdown lives at `textUrl` on the CDN. `--metadata` exposes the envelope. ## Lint config notes diff --git a/README.md b/README.md index 8d72a4c..3220e94 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ It is designed for API subscribers who want a terminal-friendly interface for co - Supports `table`, `json`, `csv`, and `raw` output. - Supports cursor pagination with `--all`. - Supports downloads from metadata URL fields. +- Fetches Quartr's parsed Markdown for reports and slide decks with `reports text` / `slides text`. - Includes a raw `request get` escape hatch for endpoints or parameters not wrapped yet. - Retries transient `429` and `5xx` responses with short backoff and honors `Retry-After` when present. - Uses only the Go standard library. @@ -188,6 +189,20 @@ List report pages: quartr reports pages 12345 --format csv ``` +Print the parsed Markdown of a report or slide deck (see [Parsed documents](#parsed-documents)): + +```bash +quartr reports text 105446 | head -50 +quartr slides text 152141 --output deck.md +``` + +Look a company up by OpenFIGI: + +```bash +quartr companies list --openfigis BBG000B9XRY4 +quartr companies resolve BBG000B9XRY4 +``` + Stream a live transcript JSONL URL to stdout: ```bash @@ -222,6 +237,7 @@ Most list commands support a shared set of filters where Quartr exposes them: --exchanges --isins --ciks +--openfigis companies only --start-date --end-date --updated-after @@ -354,7 +370,26 @@ hint: 403 means this endpoint is not included in your API tier, not that your ke (a rejected key returns 401). ... ``` -Endpoints observed gated this way: `events summary`, `audio list`, `live transcripts list`. A rejected key returns `401` and gets a hint pointing at `quartr auth show` instead. +Endpoints observed gated this way: `events summary`, `audio list`, `live transcripts list`, `companies segments` (a legacy dataset Quartr no longer opens to new partners), and `reports text` / `slides text` when the parsed documents package is not on the plan. A rejected key returns `401` and gets a hint pointing at `quartr auth show` instead. + +## Parsed documents + +Quartr sells the extracted text of reports and slide decks as a separate package: one Markdown file per document, with headings and tables preserved, meant for search indexes and LLM context windows. The API answers `/documents/reports/{id}/text` and `/documents/slides/{id}/text` with a CDN link rather than the text itself; `text` follows the link for you. + +```bash +quartr reports text 105446 # Markdown on stdout +quartr reports text 105446 --output apple.md # or to a file; `Saved apple.md` goes to stderr +quartr reports text 105446 --metadata # the envelope: documentId, textUrl, updatedAt +quartr slides text 152141 | head -40 +``` + +Unlike `download`, `text` writes to **stdout** by default: the content is text, and the file name Quartr gives it is a content hash. The markdown fetch does not send `x-api-key` unless you pass `--with-api-key`, the same as downloads. + +Without the package the endpoint returns `403`; the CLI's hint says so. Transcripts have no `text` endpoint because `transcripts download` already returns structured JSON. + +## Company segments + +`quartr companies segments ` lists segment breakdowns (revenue or operating income by business line or geography) from S&P 500 annual reports. Quartr marks the dataset legacy and does not open it to new partners, so expect `403` unless your plan predates that. ## Downloads diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index eae758f..09bcfe4 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -589,3 +589,218 @@ func TestListAllFollowsPagination(t *testing.T) { t.Fatalf("expected both rows, got %s", out.String()) } } + +// textServer serves a parsed-text envelope for reports/1 whose textUrl points +// back at the same server, which answers with body. +func textServer(t *testing.T, body string) (*httptest.Server, *[]string) { + t.Helper() + paths := &[]string{} + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *paths = append(*paths, r.URL.Path) + switch r.URL.Path { + case "/documents/reports/1/text": + if got := r.Header.Get("x-api-key"); got != "secret" { + t.Errorf("expected x-api-key on the metadata request, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"documentId":1,"textUrl":"` + srv.URL + `/artifacts/abc.markdown?ref=x","updatedAt":"2026-08-05T22:36:36.000Z"}}`)) + case "/artifacts/abc.markdown": + if got := r.Header.Get("x-api-key"); got != "" { + t.Errorf("expected no x-api-key on the CDN fetch, got %q", got) + } + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write([]byte(body)) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + })) + return srv, paths +} + +func TestTextStreamsMarkdownToStdout(t *testing.T) { + const body = "# Apple Inc.\n\n|Net sales|64,040|\n" + srv, _ := textServer(t, body) + defer srv.Close() + + dir := t.TempDir() + t.Chdir(dir) + + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, + "reports", "text", "1"}, &out, &errOut) + if code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + // stdout is the Markdown, byte for byte, so `> report.md` captures it. + if out.String() != body { + t.Fatalf("expected the Markdown on stdout, got %q", out.String()) + } + if errOut.Len() != 0 { + t.Fatalf("expected nothing on stderr, got %q", errOut.String()) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("expected no files written, got %v", entries) + } +} + +func TestTextWritesFileWithOutput(t *testing.T) { + const body = "# Apple Inc.\n" + srv, _ := textServer(t, body) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "nested", "apple.md") + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, + "reports", "text", "1", "--output", dest}, &out, &errOut) + if code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + b, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if string(b) != body { + t.Fatalf("unexpected file body: %q", string(b)) + } + if out.Len() != 0 { + t.Fatalf("expected clean stdout, got %q", out.String()) + } + if !strings.Contains(errOut.String(), "Saved "+dest) { + t.Fatalf("expected the saved path on stderr, got %q", errOut.String()) + } +} + +func TestTextMetadataPrintsEnvelopeWithoutFetching(t *testing.T) { + srv, paths := textServer(t, "unused") + defer srv.Close() + + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, "--format", "json", + "reports", "text", "1", "--metadata"}, &out, &errOut) + if code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + if !strings.Contains(out.String(), "textUrl") || !strings.Contains(out.String(), "abc.markdown") { + t.Fatalf("expected the envelope on stdout, got %s", out.String()) + } + if len(*paths) != 1 { + t.Fatalf("expected only the metadata request, got %v", *paths) + } +} + +func TestTextUnavailableOnTranscripts(t *testing.T) { + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", "http://127.0.0.1:0", + "transcripts", "text", "1"}, &out, &errOut) + if code != 1 { + t.Fatalf("expected code 1, got %d; stderr=%s", code, errOut.String()) + } + if !strings.Contains(errOut.String(), "does not have a text endpoint") { + t.Fatalf("expected the missing-endpoint message, got %s", errOut.String()) + } +} + +func TestCompaniesSegmentsIsAChildList(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/companies/4742/segments" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("limit"); got != "5" { + t.Fatalf("expected limit=5, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"companyId":4742,"fiscalYear":2024,"segmentItem":"Revenue","total":187442}],"pagination":{"nextCursor":null}}`)) + })) + defer srv.Close() + + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, "--format", "json", + "companies", "segments", "4742", "--limit", "5"}, &out, &errOut) + if code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + if !strings.Contains(out.String(), "Revenue") { + t.Fatalf("expected segment rows, got %s", out.String()) + } +} + +func TestOpenfigisForwardedOnlyToCompanies(t *testing.T) { + var gotCompanies, gotEvents url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/companies": + gotCompanies = r.URL.Query() + case "/events": + gotEvents = r.URL.Query() + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"data":[],"pagination":{"nextCursor":null}}`)) + })) + defer srv.Close() + + var out, errOut bytes.Buffer + if code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, + "companies", "list", "--openfigis", "BBG000B9XRY4,bbg000b9xry4,BBG001S5N8V8"}, &out, &errOut); code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + if got := gotCompanies.Get("openfigis"); got != "BBG000B9XRY4,BBG001S5N8V8" { + t.Fatalf("expected deduped openfigis, got %q", got) + } + if code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, + "events", "list", "--openfigis", "BBG000B9XRY4"}, &out, &errOut); code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + if _, ok := gotEvents["openfigis"]; ok { + t.Fatalf("expected openfigis to be dropped on /events, got %v", gotEvents) + } +} + +func TestCompaniesResolveAcceptsFIGI(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/companies" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("openfigis"); got != "BBG000B9XRY4" { + t.Fatalf("expected openfigis=BBG000B9XRY4, got %q", got) + } + if _, ok := r.URL.Query()["tickers"]; ok { + t.Fatalf("expected no tickers filter, got %v", r.URL.Query()) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":4742,"name":"Apple Inc","country":"US","tickers":[{"exchange":"NasdaqGS","ticker":"AAPL"}]}],"pagination":{"nextCursor":null}}`)) + })) + defer srv.Close() + + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, + "companies", "resolve", "BBG000B9XRY4"}, &out, &errOut) + if code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + if !strings.Contains(out.String(), "Apple Inc") || !strings.Contains(out.String(), "NasdaqGS:AAPL") { + t.Fatalf("expected the resolved company, got %s", out.String()) + } +} + +func TestLooksLikeFIGI(t *testing.T) { + for s, want := range map[string]bool{ + "BBG000B9XRY4": true, + "BBG001S5N8V8": true, + "AAPL": false, + "0000320193": false, + "BBG000B9XRY": false, // 11 chars + "BBg000B9XRY4": false, // lower case + "BBQ000B9XRY4": false, // no G in third position + } { + if got := looksLikeFIGI(s); got != want { + t.Errorf("looksLikeFIGI(%q) = %v, want %v", s, got, want) + } + } +} diff --git a/internal/cli/errors.go b/internal/cli/errors.go index 8941226..fdcddbc 100644 --- a/internal/cli/errors.go +++ b/internal/cli/errors.go @@ -36,7 +36,8 @@ func errorHint(err error) string { case http.StatusForbidden: return "hint: 403 means this endpoint is not included in your API tier, not that your key is wrong " + "(a rejected key returns 401). Every other endpoint keeps working with the same key. " + - "Endpoints seen gated this way: `events summary`, `audio list`, `live transcripts list`." + "Endpoints seen gated this way: `events summary`, `audio list`, `live transcripts list`, " + + "`companies segments`, and `reports text` / `slides text` (the parsed documents package)." case http.StatusUnauthorized: return "hint: 401 means the API key was rejected. Check `quartr auth show`, QUARTR_API_KEY, " + "and any --api-key flag." diff --git a/internal/cli/flags.go b/internal/cli/flags.go index a0ef1eb..c97ff19 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -80,6 +80,7 @@ type listFlags struct { tickers string isins string ciks string + openfigis string companyIDs string ids string startDate string @@ -107,6 +108,7 @@ func addListFlags(fs *flag.FlagSet, lf *listFlags) { fs.StringVar(&lf.tickers, "tickers", "", "comma-separated tickers; qualify with an exchange to avoid collisions, e.g. AAPL,NYSE:BLD") fs.StringVar(&lf.isins, "isins", "", "comma-separated ISINs") fs.StringVar(&lf.ciks, "ciks", "", "comma-separated SEC CIKs") + fs.StringVar(&lf.openfigis, "openfigis", "", "comma-separated OpenFIGI codes (figi, compositeFigi, or shareClassFigi); companies only") fs.StringVar(&lf.companyIDs, "company-ids", "", "comma-separated Quartr company IDs") fs.StringVar(&lf.ids, "ids", "", "comma-separated IDs for resources that support ids") fs.StringVar(&lf.startDate, "start-date", "", "ISO 8601 start date") @@ -127,7 +129,7 @@ func addListFlags(fs *flag.FlagSet, lf *listFlags) { // lists, and so the ones worth deduplicating. Scalars are left alone — // a cursor is an opaque token that may legitimately contain a comma. var listValuedParams = params( - "countries", "exchanges", "tickers", "isins", "ciks", "companyIds", "ids", + "countries", "exchanges", "tickers", "isins", "ciks", "openfigis", "companyIds", "ids", "typeIds", "eventIds", "documentGroupIds", "states", "levels", "expand", ) @@ -155,6 +157,7 @@ func (lf listFlags) toParams(allowed paramSet, companyEndpoint bool) url.Values add("tickers", lf.tickers) add("isins", lf.isins) add("ciks", lf.ciks) + add("openfigis", lf.openfigis) if companyEndpoint { // Both flags feed the same parameter here, so merge them; setting // them one after the other would silently drop --company-ids. diff --git a/internal/cli/handlers.go b/internal/cli/handlers.go index 2813c1a..a81d380 100644 --- a/internal/cli/handlers.go +++ b/internal/cli/handlers.go @@ -100,9 +100,13 @@ func (a *app) handleResource(r resource, args []string) error { case "summary", "summarize": return a.summaryResource(r, rest) case "pages": - return a.childListResource(r, r.pagesPath, params("limit", "cursor", "direction"), rest, "pages") + return a.childListResource(r, r.pagesPath, childListParams, rest, "pages") case "chapters": - return a.childListResource(r, r.chaptersPath, params("limit", "cursor", "direction", "levels"), rest, "chapters") + return a.childListResource(r, r.chaptersPath, mergeParams(childListParams, params("levels")), rest, "chapters") + case "segments": + return a.childListResource(r, r.segmentsPath, childListParams, rest, "segments") + case "text", "markdown": + return a.textResource(r, rest) case "download", "dl": return a.downloadResource(r, rest) case "stream": @@ -211,20 +215,23 @@ func (a *app) resolveResource(r resource, args []string) error { return err } if fs.NArg() != 1 { - return usagef("usage: quartr companies resolve (e.g. BLD, NYSE:BLD, 0001739445)") + return usagef("usage: quartr companies resolve (e.g. BLD, NYSE:BLD, 0001739445, BBG000B9XRY4)") } query := strings.TrimSpace(fs.Arg(0)) if query == "" || strings.ContainsAny(query, " \t") { - return usagef("the Quartr API has no company name search; pass a ticker (BLD or NYSE:BLD) or a CIK") + return usagef("the Quartr API has no company name search; pass a ticker (BLD or NYSE:BLD), a CIK, or an OpenFIGI") } ctx := context.Background() var companies []map[string]any var err error - if looksLikeCIK(query) { + switch { + case looksLikeCIK(query): companies, err = a.lookupCompanies(ctx, "ciks", query, nil) - } else { + case looksLikeFIGI(query): + companies, err = a.lookupCompanies(ctx, "openfigis", query, nil) + default: specs := parseTickerSpecs(query) companies, err = a.lookupCompanies(ctx, "tickers", bareTickerCSV(specs), specs) } @@ -335,24 +342,66 @@ func (a *app) downloadResource(r resource, args []string) error { return err } + dest := *outPath + if dest == "" { + dest = defaultFileName(r.name, id, downloadURL) + } + return a.saveURL(downloadURL, *withAPIKey, dest) +} + +// textResource implements `quartr reports text ` and `quartr slides text +// `. The endpoint returns a CDN link to the parsed Markdown rather than +// the text itself, so the command follows the link by default and prints the +// Markdown on stdout — that is what a `| head`, `> report.md`, or an LLM +// context wants. Unlike `download`, stdout is the default: the content is +// text, and the file name Quartr gives it is a content hash nobody wants. +// Pass --metadata to see the envelope (textUrl, updatedAt) instead. +func (a *app) textResource(r resource, args []string) error { + if r.textPath == "" { + return fmt.Errorf("%s does not have a text endpoint; parsed text exists for reports and slides only", r.name) + } + fs := newFlagSet(r.name+" text", a.errOut) + outPath := fs.String("output", "-", "write the Markdown to this path instead of stdout") + metadata := fs.Bool("metadata", false, "print the text metadata (textUrl, updatedAt) instead of the Markdown") + fields := fs.String("fields", "", "comma-separated output fields; only with --metadata") + withAPIKey := fs.Bool("with-api-key", false, "include x-api-key when fetching the text URL") + if err := parseInterspersed(fs, args); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("usage: quartr %s text [--output file] [--metadata]", r.name) + } + + path := strings.ReplaceAll(r.textPath, "{id}", url.PathEscape(fs.Arg(0))) + obj, _, err := a.client.GetJSON(context.Background(), path, nil) + if err != nil { + return err + } + if *metadata { + return output.Write(a.out, obj, output.Options{Format: a.cfg.Format(), Fields: parseCSV(*fields)}) + } + textURL, err := extractStringField(obj, "textUrl") + if err != nil { + return err + } + return a.saveURL(textURL, *withAPIKey, *outPath) +} + +// saveURL fetches downloadURL into dest. dest "-" streams the body to stdout +// so it can be piped or redirected; everything else this command prints goes +// to stderr, so `quartr transcripts download --output - > f.json` writes +// the document and nothing else. Any other dest is created (with parents) +// and confirmed with a `Saved ` line on stderr. +func (a *app) saveURL(downloadURL string, withAPIKey bool, dest string) error { apiKey := "" - if *withAPIKey { + if withAPIKey { apiKey = a.cfg.APIKey() } - - // `--output -` streams the document itself to stdout so it can be piped - // or redirected. Everything else this command prints goes to stderr, so - // `quartr transcripts download --output - > f.json` writes the - // document and nothing else. - if *outPath == "-" { + if dest == "-" { _, err := a.client.Download(context.Background(), downloadURL, apiKey, a.out) return err } - dest := *outPath - if dest == "" { - dest = defaultFileName(r.name, id, downloadURL) - } if dir := filepath.Dir(dest); dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return err diff --git a/internal/cli/help.go b/internal/cli/help.go index a659d1e..8f869a0 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -35,6 +35,7 @@ Examples: quartr events list --tickers AAPL --sort-by date --direction desc --limit 5 quartr transcripts list --tickers MSFT --expand event --limit 10 quartr transcripts download 432907 --output transcript.json + quartr reports text 105446 --output apple-10k.md quartr live transcripts stream 127537 --transcript-version 1.7 quartr request get /events --query tickers=AAPL --query limit=3 --format json `, quartr.Version, quartr.DefaultBaseURL, quartr.DefaultConfigPath(), strings.Join(cmds, ", ")) @@ -60,33 +61,31 @@ Examples: `) } -func (a *app) printResourceHelp(r resource) { +// resourceOps lists the operations a resource supports, in the order help +// shows them: the ones the resource map enables plus the companies-only +// resolve. +func resourceOps(r resource) []string { ops := []string{} - if r.listPath != "" { - ops = append(ops, "list") - } - if r.getPath != "" { - ops = append(ops, "get ") - } - if r.name == "companies" { - ops = append(ops, "resolve ") - } - if r.summaryPath != "" { - ops = append(ops, "summary ") - } - if r.pagesPath != "" { - ops = append(ops, "pages ") - } - if r.chaptersPath != "" { - ops = append(ops, "chapters ") - } - if r.downloadField != "" { - ops = append(ops, "download ") - } - if r.streamField != "" { - ops = append(ops, "stream ") + add := func(enabled bool, op string) { + if enabled { + ops = append(ops, op) + } } + add(r.listPath != "", "list") + add(r.getPath != "", "get ") + add(r.name == "companies", "resolve ") + add(r.summaryPath != "", "summary ") + add(r.pagesPath != "", "pages ") + add(r.textPath != "", "text ") + add(r.chaptersPath != "", "chapters ") + add(r.segmentsPath != "", "segments ") + add(r.downloadField != "", "download ") + add(r.streamField != "", "stream ") + return ops +} +func (a *app) printResourceHelp(r resource) { + ops := resourceOps(r) fmt.Fprintf(a.out, "Usage:\n quartr %s <%s> [flags]\n\nOperations:\n", r.name, strings.Join(ops, " | ")) for _, op := range ops { fmt.Fprintf(a.out, " %s\n", op) @@ -128,13 +127,25 @@ Downloads: download --output P writes P download --output - streams the document to stdout, nothing else `, r.name) + } + if r.textPath != "" { + fmt.Fprint(a.out, ` +Parsed text (Markdown, separate Quartr package; 403 without it): + text prints the parsed Markdown on stdout + text --output P writes P instead + text --metadata prints the envelope (textUrl, updatedAt) instead +`) } fmt.Fprint(a.out, ` Examples: `) switch r.name { case "companies": - fmt.Fprint(a.out, " quartr companies list --tickers AAPL\n quartr companies resolve BLD # every company using that ticker\n quartr companies get 4742 --format json\n") + fmt.Fprint(a.out, " quartr companies list --tickers AAPL\n quartr companies list --openfigis BBG000B9XRY4\n quartr companies resolve BLD # every company using that ticker\n quartr companies get 4742 --format json\n quartr companies segments 4742 --format json\n") + case "reports": + fmt.Fprint(a.out, " quartr reports list --tickers AAPL --type-ids 11 --limit 5\n quartr reports text 105446 | head -50\n quartr reports text 105446 --output apple-10k.md\n quartr reports download 105446 --output apple-10k.pdf\n") + case "slides": + fmt.Fprint(a.out, " quartr slides list --tickers AAPL --limit 5\n quartr slides text 152141 > deck.md\n quartr slides pages 152141 --format csv\n") case "events": fmt.Fprint(a.out, " quartr events list --tickers AAPL --sort-by date --direction desc\n quartr events summary 128301 --length long --plain\n") case "transcripts": diff --git a/internal/cli/resources.go b/internal/cli/resources.go index 6cae964..04a05f0 100644 --- a/internal/cli/resources.go +++ b/internal/cli/resources.go @@ -36,12 +36,18 @@ func (s paramSet) allows(name string) bool { } type resource struct { - name string - listPath string - getPath string - summaryPath string - pagesPath string - chaptersPath string + name string + listPath string + getPath string + summaryPath string + pagesPath string + chaptersPath string + // textPath is the parsed-document endpoint: it answers with a CDN link + // to the Markdown rendering of a report or slide deck, not the text + // itself. Only reports and slides have one. + textPath string + // segmentsPath is the company segments child list. + segmentsPath string downloadField string streamField string listParams paramSet @@ -78,7 +84,8 @@ var ( liveListParams = mergeParams(params("countries", "exchanges", "tickers", "isins", "ciks", "companyIds", "eventIds", "states", "startDate", "endDate", "updatedAfter", "updatedBefore", "limit", "cursor", "direction"), params("transcriptVersion")) liveAudioListParams = params("countries", "exchanges", "tickers", "isins", "ciks", "companyIds", "eventIds", "states", "startDate", "endDate", "updatedAfter", "updatedBefore", "limit", "cursor", "direction") simpleListParams = params("limit", "cursor", "direction") - companyListParams = params("countries", "exchanges", "tickers", "isins", "ciks", "ids", "updatedAfter", "updatedBefore", "limit", "cursor", "direction") + companyListParams = params("countries", "exchanges", "tickers", "isins", "ciks", "openfigis", "ids", "updatedAfter", "updatedBefore", "limit", "cursor", "direction") + childListParams = params("limit", "cursor", "direction") summaryParams = params("length", "plain") getExpandParams = params("expand") getLiveParams = params("transcriptVersion") @@ -91,10 +98,11 @@ var ( var resources = map[string]resource{ "companies": { - name: "companies", - listPath: "/companies", - getPath: "/companies/{id}", - listParams: companyListParams, + name: "companies", + listPath: "/companies", + getPath: "/companies/{id}", + segmentsPath: "/companies/{id}/segments", + listParams: companyListParams, }, "events": { name: "events", @@ -119,6 +127,7 @@ var resources = map[string]resource{ getPath: "/documents/reports/{id}", pagesPath: "/documents/reports/{id}/pages", summaryPath: "/documents/reports/{id}/summary", + textPath: "/documents/reports/{id}/text", downloadField: "fileUrl", listParams: docListParams, getParams: getExpandParams, @@ -130,6 +139,7 @@ var resources = map[string]resource{ getPath: "/documents/slides/{id}", pagesPath: "/documents/slides/{id}/pages", summaryPath: "/documents/slides/{id}/summary", + textPath: "/documents/slides/{id}/text", downloadField: "fileUrl", listParams: docListParams, getParams: getExpandParams, diff --git a/internal/cli/tickers.go b/internal/cli/tickers.go index f4f4ff8..77f12f3 100644 --- a/internal/cli/tickers.go +++ b/internal/cli/tickers.go @@ -224,6 +224,21 @@ func looksLikeCIK(s string) bool { return true } +// looksLikeFIGI reports whether a `companies resolve` argument is an OpenFIGI +// identifier: twelve upper-case alphanumerics with "G" in the third position +// (BBG000B9XRY4). No listed ticker has that shape. +func looksLikeFIGI(s string) bool { + if len(s) != 12 || s[2] != 'G' { + return false + } + for _, r := range s { + if (r < '0' || r > '9') && (r < 'A' || r > 'Z') { + return false + } + } + return true +} + // dedupeCSV removes repeated entries from a comma-separated filter value, // ignoring case. Quartr accepts duplicates, but they inflate the URL and make // `--tickers "$LIST"` fragile when the caller builds the list by hand. From 006d6c85e5703ef2b439d9f33e1cd831ffc8e97c Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Thu, 10 Sep 2026 14:25:24 -0400 Subject: [PATCH 3/4] Point the skill at Quartr's OpenAPI spec and docs index Co-Authored-By: Claude Fable 5.1 --- .claude/skills/quartr/references/commands.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.claude/skills/quartr/references/commands.md b/.claude/skills/quartr/references/commands.md index fc45b4d..5e9170b 100644 --- a/.claude/skills/quartr/references/commands.md +++ b/.claude/skills/quartr/references/commands.md @@ -212,6 +212,12 @@ and pass it via `--type-ids`. ## Key code locations (for skill maintenance) +Quartr publishes the live spec at +and a docs index at (datasets, changelog at +`/docs/changelogs/api-updates.md`). When Quartr announces a new dataset, diff +the spec's `paths` against the `resources` map first — that is how `text`, +`segments`, and `openfigis` were found. + If the CLI gets new commands, refresh this reference from: - `internal/cli/app.go` — top-level command dispatch From 7671ee59085015074292373a043e8ced419a8028 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Thu, 10 Sep 2026 14:27:57 -0400 Subject: [PATCH 4/4] Pin golangci-lint v2.13.2, the first release built with Go 1.27 The v2.12.0 release binary CI downloads was built with Go 1.26 and refuses a Go 1.27 target. v2.13.2 is built with go1.27.0 and loads the config unchanged. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 +- .pre-commit-config.yaml | 2 +- CLAUDE.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d06ed4..725fdc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: - uses: golangci/golangci-lint-action@v7 with: - version: v2.12.0 + version: v2.13.2 # Exercises the release recipe on every PR. Without this, a broken cross # compile only shows up after a tag is pushed, which is the worst time. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4401b33..f199245 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ repos: # targeted Go version"), and one built with an older toolchain than # the `go` on PATH panics loading the standard library. Rebuild it # with the current toolchain: - # go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.0 + # go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 - id: golangci-lint name: golangci-lint entry: golangci-lint run --fix diff --git a/CLAUDE.md b/CLAUDE.md index 81d8a35..062ac36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ pre-commit run --all-files # lint+test against the whole tree `pre-commit install` was already run in this clone — every commit runs golangci-lint (with `--fix`) and `go test ./...`. The lint hook shells out to the `golangci-lint` on `PATH` instead of the upstream pre-commit repo, which builds the linter from source with whatever Go it finds; a linter built with Go < 1.27 refuses to load this config, and one built with an older patch release than the `go` on `PATH` panics while type-checking the standard library (`file requires newer Go version`). Whenever the toolchain moves, rebuild the linter with it: ```bash -go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.0 +go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 ``` CI pins the same version through `golangci/golangci-lint-action@v7`.