diff --git a/.claude/skills/quartr/SKILL.md b/.claude/skills/quartr/SKILL.md index e37c6b0..a9a8a2a 100644 --- a/.claude/skills/quartr/SKILL.md +++ b/.claude/skills/quartr/SKILL.md @@ -10,16 +10,29 @@ companies, events, transcripts, reports, slides, audio, and live events. ## Binary -This skill ships inside the `quartr-cli` repo. Build the binary from the repo -root: +Check for an installed binary first — `command -v quartr` — and use it if it is +on `PATH`. `quartr --version` prints the release it came from. + +If it is not installed, install a prebuilt one: + +```bash +go install github.com/TJC-LP/quartr-cli/cmd/quartr@latest +``` + +Or download an archive for the platform from + (macOS, Linux, and Windows; +amd64 and arm64), each published with a `SHA256SUMS` file. + +This skill also ships inside the `quartr-cli` repo, so when working from a +clone, build from the repo root instead: ```bash -go build -o bin/quartr ./cmd/quartr +make build # ./bin/quartr, with the version baked in ``` -Then invoke it as `./bin/quartr` from the repo root, or `$(git rev-parse --show-toplevel)/bin/quartr` -from anywhere inside the worktree. Use `go install ./cmd/quartr` instead to -install it on `$GOBIN/quartr` for PATH-style invocation. +Then invoke it as `./bin/quartr` from the repo root, or +`$(git rev-parse --show-toplevel)/bin/quartr` from anywhere inside the +worktree. `make install` puts it on `$GOBIN/quartr` for PATH-style invocation. ## Auth diff --git a/.claude/skills/quartr/references/commands.md b/.claude/skills/quartr/references/commands.md index 92e9bd5..cfd8f31 100644 --- a/.claude/skills/quartr/references/commands.md +++ b/.claude/skills/quartr/references/commands.md @@ -17,7 +17,7 @@ specific flag or endpoint at hand. | `audio` | `list`, `get`, `chapters`, `download` | `/audio` | `list` may be tier-restricted; `fileUrl` | | `live` | `list`, `get` | `/live` | Honors `transcriptVersion` | | `live audio` | `list`, `get`, `download` | `/live/audio` | Download field is `audio` | -| `live transcripts` | `list`, `get`, `stream` | `/live/transcripts` | `list` may be tier-restricted; stream field is `transcript` | +| `live transcripts` | `list`, `get`, `download`, `stream` | `/live/transcripts` | `list` may be tier-restricted; download and stream both read the `transcript` field | | `event-types` | `list`, `get` | `/event-types` | Lookup table; `list` returns the whole catalog | | `document-types` | `list`, `get` | `/document-types` | Lookup table; `list` returns the whole catalog | | `request` | `get` | (any path) | Escape hatch; `--query k=v --paginate` | @@ -62,12 +62,18 @@ Auth precedence: flags > env > config file > defaults. --end-date 2024-12-31 ISO 8601 --updated-after 2024-01-01 incremental sync lower bound --updated-before 2024-12-31 ---expand event,company event is expanded by the API; company is joined client-side ---type-ids 1,2,3 events / documents* / transcripts* / reports* / slides* / audio* ---event-ids 128301 documents* / transcripts* / reports* / slides* / audio* / live* +--expand event API-side; documents / reports / slides / transcripts / audio. + Dropped on events (a self-expansion) and companies. +--expand company client-side join; works on any resource whose rows carry a + companyId, including events. Only `companies` rejects it, + as redundant. +--type-ids 1,2,3 events / documents / transcripts / reports / slides. + NOT audio — /audio has no typeIds param and drops it. +--event-ids 128301 documents / transcripts / reports / slides / audio / live* --document-group-ids foo documents* / transcripts* / reports* / slides* --states live,willBeLive live, live-transcripts, live-audio ---transcript-version 1.7 live, live-transcripts, transcripts (get only), audio (get only) +--transcript-version 1.7 live, live-transcripts (list and get) only; silently + dropped on transcripts/audio, which have no such param --sort-by id|date events list ONLY; rejected with exit 2 everywhere else --levels 1,2 chapters subcommand on reports/slides/transcripts/audio ``` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1060c54..0d06ed4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,28 @@ jobs: - uses: golangci/golangci-lint-action@v7 with: version: v2.12.0 + + # 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. + dist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build release artifacts + run: make dist VERSION=ci + + - name: Smoke test the linux binary + run: | + test "$(./dist/bin/quartr-linux-amd64 --version)" = "quartr ci" + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/* + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9e1eae9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Test + run: go test ./... + + # Same recipe a maintainer runs locally, so the published archives are + # never a CI-only code path. + - name: Build release artifacts + run: make dist VERSION="${GITHUB_REF_NAME#v}" + + # A tag that ships a binary reporting a different version is worse than + # a failed release, so fail here instead. + - name: Verify the tag version was baked in + run: | + expected="quartr ${GITHUB_REF_NAME#v}" + actual="$(./dist/bin/quartr-linux-amd64 --version)" + if [ "$actual" != "$expected" ]; then + echo "version mismatch: binary says '$actual', tag implies '$expected'" >&2 + exit 1 + fi + echo "$actual" + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + run: | + # A tag like v0.2.0-rc1 publishes as a prerelease. + prerelease="" + case "$GITHUB_REF_NAME" in + *-*) prerelease="--prerelease" ;; + esac + gh release create "$GITHUB_REF_NAME" \ + --title "$GITHUB_REF_NAME" \ + --generate-notes \ + $prerelease \ + dist/*.tar.gz dist/*.zip dist/*SHA256SUMS diff --git a/CLAUDE.md b/CLAUDE.md index a4074e6..400f4e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,12 +5,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Commands ```bash -make build # build ./bin/quartr +make build # build ./bin/quartr, version baked in via -ldflags 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 +make dist # cross-compile release archives + checksums into ./dist +make clean # remove ./bin and ./dist go test ./internal/cli -run TestBuildConfigPrecedence # run a single test @@ -55,9 +56,40 @@ Three internal packages, no external deps (Go stdlib only): ## Repo / branch hygiene -- Remote: `git@github.com:TJC-LP/quartr-cli.git` (private). +- Remote: `git@github.com:TJC-LP/quartr-cli.git` (public). +- Module path is `github.com/TJC-LP/quartr-cli`, which must keep matching the + repo URL — that is what makes `go install github.com/TJC-LP/quartr-cli/cmd/quartr@latest` + resolve. Renaming the repo means renaming the module. - `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. +- `.env`, `bin/`, and `dist/` are gitignored; the `.env.example` exception is preserved. + +## Releases + +Cutting a release is one push: + +```bash +git tag -a v0.2.0 -m "v0.2.0" +git push origin v0.2.0 +``` + +`.github/workflows/release.yml` fires on `v*` tags, runs the tests, calls +`make dist VERSION=${tag#v}`, and publishes the archives with `gh release +create --generate-notes`. A tag containing a hyphen (`v0.2.0-rc1`) publishes as +a prerelease. + +- **Tags carry the `v`; the reported version does not.** `v0.1.0` produces + `quartr 0.1.0`. The workflow asserts this before publishing, so a mismatch + fails the release rather than shipping a mislabeled binary. +- **`make dist` is the single build recipe** — CI calls it rather than + reimplementing the matrix, and the `dist` job in `ci.yml` runs it on every PR + so a broken cross-compile surfaces before a tag exists. +- **Version resolution lives in `internal/quartr/version.go`**, not in `cli`, + so `--version` and the `User-Agent` header cannot drift. Order: `-ldflags + -X ...quartr.buildVersion`, then `debug.ReadBuildInfo` (module version for + `go install pkg@version`, VCS revision for a checkout build), then `dev`. + If you move or rename `buildVersion`, update `VERSION_LDFLAGS` in the Makefile. +- Release archives bundle `README.md` and `LICENSE` alongside the binary and + ship with a `SHA256SUMS` file. ## Skill diff --git a/Makefile b/Makefile index e4028dd..ef24c59 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,63 @@ -.PHONY: build install test clean +.PHONY: build install test clean dist GOBIN := $(shell go env GOBIN) ifeq ($(GOBIN),) GOBIN := $(shell go env GOPATH)/bin endif +# Release string baked into the binary. Defaults to the current git +# description with any leading "v" stripped, so tag v0.1.0 reports 0.1.0. +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//') +ifeq ($(VERSION),) +VERSION := dev +endif + +VERSION_LDFLAGS := -X github.com/TJC-LP/quartr-cli/internal/quartr.buildVersion=$(VERSION) +RELEASE_LDFLAGS := -s -w $(VERSION_LDFLAGS) + +# os/arch pairs published on every release. +PLATFORMS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 + build: - go build -o bin/quartr ./cmd/quartr + go build -ldflags "$(VERSION_LDFLAGS)" -o bin/quartr ./cmd/quartr install: - go install ./cmd/quartr + go install -ldflags "$(VERSION_LDFLAGS)" ./cmd/quartr @if [ -n "$$QUARTR_API_KEY" ]; then printf '%s' "$$QUARTR_API_KEY" | $(GOBIN)/quartr auth login --api-key-stdin; fi test: go test ./... +# dist is what the release workflow runs, so a local `make dist` and the +# published artifacts come from the same recipe. Raw binaries land in +# dist/bin for smoke tests; the archives and checksum file get uploaded. +dist: + rm -rf dist + mkdir -p dist/bin + @set -e; for platform in $(PLATFORMS); do \ + os=$${platform%/*}; arch=$${platform#*/}; ext=""; \ + if [ "$$os" = "windows" ]; then ext=".exe"; fi; \ + echo "==> quartr $(VERSION) $$os/$$arch"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \ + go build -trimpath -ldflags "$(RELEASE_LDFLAGS)" \ + -o dist/bin/quartr-$$os-$$arch$$ext ./cmd/quartr; \ + stage=dist/stage/quartr_$(VERSION)_$${os}_$${arch}; \ + mkdir -p $$stage; \ + cp dist/bin/quartr-$$os-$$arch$$ext $$stage/quartr$$ext; \ + cp README.md LICENSE $$stage/; \ + if [ "$$os" = "windows" ]; then \ + (cd dist/stage && zip -qr ../quartr_$(VERSION)_$${os}_$${arch}.zip quartr_$(VERSION)_$${os}_$${arch}); \ + else \ + tar -czf dist/quartr_$(VERSION)_$${os}_$${arch}.tar.gz -C dist/stage quartr_$(VERSION)_$${os}_$${arch}; \ + fi; \ + done + @rm -rf dist/stage + @cd dist && if command -v sha256sum >/dev/null 2>&1; then \ + sha256sum quartr_$(VERSION)_*.tar.gz quartr_$(VERSION)_*.zip > quartr_$(VERSION)_SHA256SUMS; \ + else \ + shasum -a 256 quartr_$(VERSION)_*.tar.gz quartr_$(VERSION)_*.zip > quartr_$(VERSION)_SHA256SUMS; \ + fi + @echo "artifacts:"; ls -1 dist/*.tar.gz dist/*.zip dist/*SHA256SUMS + clean: - rm -rf bin + rm -rf bin dist diff --git a/README.md b/README.md index e5e9ec2..8d72a4c 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,52 @@ It is designed for API subscribers who want a terminal-friendly interface for co - Retries transient `429` and `5xx` responses with short backoff and honors `Retry-After` when present. - Uses only the Go standard library. -## Build +## Install + +### From a release + +Prebuilt binaries for macOS, Linux, and Windows (amd64 and arm64) are attached +to every [release](https://github.com/TJC-LP/quartr-cli/releases). Download the +archive for your platform, verify it, and put `quartr` on your `PATH`: ```bash -go build -o bin/quartr ./cmd/quartr +VERSION=0.1.0 +OS=$(uname -s | tr '[:upper:]' '[:lower:]') # darwin | linux +ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') +BASE="https://github.com/TJC-LP/quartr-cli/releases/download/v$VERSION" + +curl -fsSLO "$BASE/quartr_${VERSION}_${OS}_${ARCH}.tar.gz" +curl -fsSLO "$BASE/quartr_${VERSION}_SHA256SUMS" +shasum -a 256 -c quartr_${VERSION}_SHA256SUMS --ignore-missing + +tar -xzf "quartr_${VERSION}_${OS}_${ARCH}.tar.gz" +sudo install "quartr_${VERSION}_${OS}_${ARCH}/quartr" /usr/local/bin/quartr +quartr --version ``` -Or install from the project directory: +macOS Gatekeeper quarantines binaries downloaded with a browser. If you get +"cannot be opened because the developer cannot be verified", clear the +attribute: `xattr -d com.apple.quarantine /usr/local/bin/quartr`. + +### From source ```bash -go install ./cmd/quartr +go install github.com/TJC-LP/quartr-cli/cmd/quartr@latest ``` +Or from a clone of this repo: + +```bash +make build # ./bin/quartr +make install # $GOBIN/quartr +``` + +`make` bakes the version in via `-ldflags`, so `quartr --version` reports the +tag. A plain `go build ./cmd/quartr` works too and falls back to whatever the +go tool stamped: the module version for `go install ...@v0.1.0`, a +`0.0.0--` pseudo-version when building from a checkout, or +`dev` when no build information is available at all. + ## Configure ```bash diff --git a/cmd/quartr/main.go b/cmd/quartr/main.go index 2a3bd93..4ef55f7 100644 --- a/cmd/quartr/main.go +++ b/cmd/quartr/main.go @@ -6,7 +6,7 @@ package main import ( "os" - "quartr-cli/internal/cli" + "github.com/TJC-LP/quartr-cli/internal/cli" ) func main() { diff --git a/go.mod b/go.mod index 43fc608..924fa0b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module quartr-cli +module github.com/TJC-LP/quartr-cli go 1.26.2 diff --git a/internal/cli/app.go b/internal/cli/app.go index a7a09fc..5136dc9 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -11,12 +11,9 @@ import ( "fmt" "io" - "quartr-cli/internal/quartr" + "github.com/TJC-LP/quartr-cli/internal/quartr" ) -// Version is the CLI release string, surfaced via `quartr --version`. -const Version = "0.1.0" - type app struct { out io.Writer errOut io.Writer @@ -44,7 +41,7 @@ func Run(args []string, out, errOut io.Writer) int { return 2 } if globals.Version { - fmt.Fprintf(out, "quartr %s\n", Version) + fmt.Fprintf(out, "quartr %s\n", quartr.Version) return 0 } diff --git a/internal/cli/config.go b/internal/cli/config.go index bcdaeae..6f93258 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -4,7 +4,7 @@ import ( "os" "strings" - "quartr-cli/internal/quartr" + "github.com/TJC-LP/quartr-cli/internal/quartr" ) type globalOverrides struct { diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index cb23030..695ed22 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - "quartr-cli/internal/quartr" + "github.com/TJC-LP/quartr-cli/internal/quartr" ) func TestBuildConfigPrecedence(t *testing.T) { diff --git a/internal/cli/errors.go b/internal/cli/errors.go index 8334fe4..8941226 100644 --- a/internal/cli/errors.go +++ b/internal/cli/errors.go @@ -5,7 +5,7 @@ import ( "fmt" "net/http" - "quartr-cli/internal/quartr" + "github.com/TJC-LP/quartr-cli/internal/quartr" ) // usageError marks a failure caused by the command line the user typed diff --git a/internal/cli/fetch.go b/internal/cli/fetch.go index 1d15147..644d7ff 100644 --- a/internal/cli/fetch.go +++ b/internal/cli/fetch.go @@ -9,7 +9,7 @@ import ( "regexp" "strings" - "quartr-cli/internal/output" + "github.com/TJC-LP/quartr-cli/internal/output" ) // listRequest describes one list-or-paginate call. It exists so callers can diff --git a/internal/cli/handlers.go b/internal/cli/handlers.go index d7fe436..2813c1a 100644 --- a/internal/cli/handlers.go +++ b/internal/cli/handlers.go @@ -11,8 +11,8 @@ import ( "path/filepath" "strings" - "quartr-cli/internal/output" - "quartr-cli/internal/quartr" + "github.com/TJC-LP/quartr-cli/internal/output" + "github.com/TJC-LP/quartr-cli/internal/quartr" ) func (a *app) handleAuth(args []string) error { diff --git a/internal/cli/help.go b/internal/cli/help.go index ca8d479..a659d1e 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "quartr-cli/internal/quartr" + "github.com/TJC-LP/quartr-cli/internal/quartr" ) func (a *app) printRootHelp() { @@ -37,7 +37,7 @@ Examples: quartr transcripts download 432907 --output transcript.json quartr live transcripts stream 127537 --transcript-version 1.7 quartr request get /events --query tickers=AAPL --query limit=3 --format json -`, Version, quartr.DefaultBaseURL, quartr.DefaultConfigPath(), strings.Join(cmds, ", ")) +`, quartr.Version, quartr.DefaultBaseURL, quartr.DefaultConfigPath(), strings.Join(cmds, ", ")) } func (a *app) printAuthHelp() { diff --git a/internal/quartr/client.go b/internal/quartr/client.go index 62f03c9..26fc3d0 100644 --- a/internal/quartr/client.go +++ b/internal/quartr/client.go @@ -16,12 +16,9 @@ import ( "time" ) -const ( - // DefaultBaseURL is the production API endpoint used when no override is configured. - DefaultBaseURL = "https://api.quartr.com/public/v3" - // UserAgent is sent on every outbound request. - UserAgent = "quartr-cli/0.1.0" -) +// DefaultBaseURL is the production API endpoint used when no override is configured. +// Version and UserAgent live in version.go. +const DefaultBaseURL = "https://api.quartr.com/public/v3" // Client performs authenticated GET requests against the Quartr Public API. // It retries 429 and 5xx responses with backoff and honors Retry-After. diff --git a/internal/quartr/version.go b/internal/quartr/version.go new file mode 100644 index 0000000..a10e8a6 --- /dev/null +++ b/internal/quartr/version.go @@ -0,0 +1,66 @@ +package quartr + +import ( + "runtime/debug" + "strings" +) + +// buildVersion is overridden at link time by the release build: +// +// go build -ldflags "-X github.com/TJC-LP/quartr-cli/internal/quartr.buildVersion=1.2.3" +// +// It is deliberately empty by default. Everything else falls back to the build +// information the go tool stamps into the binary on its own, so a user who runs +// `go install github.com/TJC-LP/quartr-cli/cmd/quartr@v0.1.0` still gets a +// binary that reports 0.1.0 without any linker flags involved. +var buildVersion = "" + +// Version is the release string reported by `quartr --version`. It is resolved +// once, here, rather than in cli, so the version the CLI prints and the one it +// puts on the wire in User-Agent cannot drift apart. +var Version = resolveVersion() + +// UserAgent is sent on every outbound request. +var UserAgent = "quartr-cli/" + Version + +// resolveVersion reports, in order of preference: the linker-injected release +// string, the module version recorded by `go install module@version`, or the +// VCS revision stamped into a `go build` from a checkout. "dev" is the answer +// only when none of the three is available. +func resolveVersion() string { + if v := strings.TrimSpace(buildVersion); v != "" { + return strings.TrimPrefix(v, "v") + } + + bi, ok := debug.ReadBuildInfo() + if !ok { + return "dev" + } + + // Set when the binary came from `go install module@version`. A plain + // `go build` inside the module reports "" or "(devel)" instead. + if v := bi.Main.Version; v != "" && v != "(devel)" { + return strings.TrimPrefix(v, "v") + } + + var revision string + var modified bool + for _, setting := range bi.Settings { + switch setting.Key { + case "vcs.revision": + revision = setting.Value + case "vcs.modified": + modified = setting.Value == "true" + } + } + if revision == "" { + return "dev" + } + if len(revision) > 12 { + revision = revision[:12] + } + if modified { + revision += "-dirty" + } + return revision +} diff --git a/internal/quartr/version_test.go b/internal/quartr/version_test.go new file mode 100644 index 0000000..25711b7 --- /dev/null +++ b/internal/quartr/version_test.go @@ -0,0 +1,46 @@ +package quartr + +import ( + "strings" + "testing" +) + +func TestResolveVersionPrefersInjectedBuildVersion(t *testing.T) { + original := buildVersion + t.Cleanup(func() { buildVersion = original }) + + // The release build injects a bare "1.2.3", but a tag-shaped "v1.2.3" + // must not produce a doubled prefix in `quartr vv1.2.3`. + for _, injected := range []string{"1.2.3", "v1.2.3", " 1.2.3 "} { + buildVersion = injected + if got := resolveVersion(); got != "1.2.3" { + t.Errorf("resolveVersion() with buildVersion=%q = %q, want %q", injected, got, "1.2.3") + } + } +} + +// Without an injected version the value comes from the build info the go tool +// stamps in. Under `go test` that is the VCS revision, so the only invariant +// worth asserting is that the fallback chain always yields something usable. +func TestResolveVersionFallsBackToBuildInfo(t *testing.T) { + original := buildVersion + t.Cleanup(func() { buildVersion = original }) + + buildVersion = "" + got := resolveVersion() + if got == "" { + t.Fatal("resolveVersion() returned an empty string; want a revision or \"dev\"") + } + if strings.HasPrefix(got, "v") { + t.Errorf("resolveVersion() = %q; the leading v should be stripped", got) + } +} + +func TestUserAgentTracksVersion(t *testing.T) { + if want := "quartr-cli/" + Version; UserAgent != want { + t.Errorf("UserAgent = %q, want %q", UserAgent, want) + } + if strings.Contains(UserAgent, "0.1.0") && Version != "0.1.0" { + t.Error("UserAgent carries a hardcoded version that Version does not agree with") + } +}