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
24 changes: 12 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ on:
workflow_dispatch:
inputs:
version:
description: Existing tag to release, for example v0.4.0
description: Existing tag to release, for example v0.7.0
required: true
type: string

Expand All @@ -21,6 +21,9 @@ permissions:
attestations: write
artifact-metadata: write

env:
RELEASE_TAG: ${{ inputs.version || github.ref_name }}

jobs:
build:
name: Build ${{ matrix.goos }}/${{ matrix.goarch }}
Expand All @@ -40,6 +43,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.version) || github.ref }}

- name: Setup Go
uses: actions/setup-go@v6
Expand All @@ -49,12 +54,9 @@ jobs:

- name: Resolve version metadata
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "VERSION=${{ inputs.version }}" >> "$GITHUB_ENV"
else
echo "VERSION=${GITHUB_REF_NAME}" >> "$GITHUB_ENV"
fi
echo "COMMIT=${GITHUB_SHA::12}" >> "$GITHUB_ENV"
[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]] || exit 2
echo "VERSION=$RELEASE_TAG" >> "$GITHUB_ENV"
echo "COMMIT=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_ENV"
echo "DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"

- name: Build archive
Expand Down Expand Up @@ -83,6 +85,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.version) || github.ref }}

- name: Download archives
uses: actions/download-artifact@v7
Expand All @@ -92,11 +96,7 @@ jobs:

- name: Resolve release tag
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "VERSION=${{ inputs.version }}" >> "$GITHUB_ENV"
else
echo "VERSION=${GITHUB_REF_NAME}" >> "$GITHUB_ENV"
fi
echo "VERSION=$RELEASE_TAG" >> "$GITHUB_ENV"

- name: Generate checksum manifest
run: scripts/build-checksums.sh
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ SPDX-License-Identifier: CC0-1.0

All notable changes to `odh-cli` are documented here.

## v0.7.0 - 2026-09-10

- Add fetched, matched, returned, and truncation coverage to traffic and normalized
latest-measurement results, including Content's upstream total when available.
- Return MCP structured JSON alongside text and advertise read-only tools.
- Manual releases check out the requested tag and derive commit metadata from
that checkout. Local archive builds use `dev` unless a version is supplied.
- `datasets list` and `datasets guide` accept `--json`, including when
overriding `--format table`.
- The traffic dataset guide and quickstart lead with the Content bulletin for
current provincial road notices.
- Traffic results from the Mobility Timeseries feed lead with the feed's date
warning, before table and Markdown rows. Bike results also link to the current
Content bulletin search and explain the stored `active` flag. Refs #11.
- Document omitted optional traffic fields, including `end` on open Content
announcements, and cover that JSON shape with a regression test. Refs #13.

## v0.6.2 - 2026-09-07

- `datasets guide` ranks catalogue matches by how many query terms hit instead
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@ Not included: authenticated write flows, live transit rerouting, historical GTFS
curl -fsSL https://raw.githubusercontent.com/galjos/odh-cli/main/scripts/install.sh | sh
```

The installer detects macOS/Linux and `amd64`/`arm64`, verifies the published SHA-256 checksum, and installs to `~/.local/bin`. Pass `--version v0.6.2 --dir "$HOME/bin"` to pin a version or directory.
The installer detects macOS/Linux and `amd64`/`arm64`, verifies the published SHA-256 checksum, and installs to `~/.local/bin`. Pass `--version v0.7.0 --dir "$HOME/bin"` to pin a version or directory.

Alternatives:

```bash
brew install galjos/odh/odh
sudo apt install ./odh_v0.6.2_linux_amd64.deb # from GitHub Releases
sudo apt install ./odh_v0.7.0_linux_amd64.deb # from GitHub Releases
go build -o odh ./cmd/odh # from source
```

Expand All @@ -56,15 +56,19 @@ odh call tourism /v1/ODHActivityPoi --param pagesize=1 --param fields=Detail.en.
odh mobility origins --station-type ParkingStation
odh mobility latest --station-type EChargingStation --data-type number-available --active --fresh-within 24h --sort newest --limit 5
odh diagnostics parking-forecasts --origin "Municipality Merano" --fresh-within 2h
odh traffic today --area ueberetsch-unterland --type roadworks --format table
odh traffic search "road closed badia" --today --json
odh traffic today --source content --area ueberetsch-unterland --type roadworks --format table
odh traffic search "road closed badia" --source content --today --json
odh a22 status --limit 10
odh transit stops search merano --limit 10
odh transit journey --from merano --to ora --time 16:40 --max-transfers 3 --with-realtime --json
```

Every command documents flags and examples in `odh <command> --help`.

For current provincial road notices, start with `--source content`. Its area
filters infer geography from coordinates; read the returned warnings. The default
`--source odh` accesses Mobility Timeseries events whose stored dates may be old.

## Automation And MCP

`odh` is script and agent friendly: stdout is data (JSON via `--json` or `--format json`), stderr is diagnostics, exit code `2` means bad invocation and `1` runtime failure, and commands are non-interactive. The contract, caveats, and stable JSON fields are documented in [docs/agent-usage.md](docs/agent-usage.md) and [docs/json-contracts.md](docs/json-contracts.md).
Expand Down
4 changes: 2 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ The generic HTTP cache is only for low-risk discovery surfaces such as OpenAPI s

## Build Metadata

Development builds default to `0.6.2-dev` with best-effort VCS metadata. Release builds can stamp metadata through Go linker flags:
Development builds default to `0.7.0-dev` with best-effort VCS metadata. Release builds can stamp metadata through Go linker flags:

```bash
go build \
-ldflags "-X github.com/galjos/odh-cli/internal/version.Version=0.6.2 -X github.com/galjos/odh-cli/internal/version.Commit=$(git rev-parse --short HEAD) -X github.com/galjos/odh-cli/internal/version.Date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-ldflags "-X github.com/galjos/odh-cli/internal/version.Version=0.7.0 -X github.com/galjos/odh-cli/internal/version.Commit=$(git rev-parse --short HEAD) -X github.com/galjos/odh-cli/internal/version.Date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o odh ./cmd/odh
```

Expand Down
24 changes: 22 additions & 2 deletions docs/json-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ semantics:

## `odh traffic today|events|search --json`

Traffic results and normalized `mobility latest` results include `coverage`:

| Field | Meaning |
| --- | --- |
| `fetched_count` | Upstream rows inspected |
| `matched_count` | Matching results within those rows, after filtering and traffic deduplication, before a local result limit |
| `returned_count` | Results returned, equal to `count` |
| `result_truncated` | A local result limit omitted known matches |
| `upstream_may_have_more` | The requested page filled its limit, or the source reports more rows |
| `upstream_total` | Total rows reported by the Content source, before local filtering; absent when unknown |

`matched_count` never estimates matches on unseen pages. A zero-result search
with `upstream_may_have_more: true` does not establish that no matching notice
exists. Existing `raw_count`, `count`, and warnings remain available. The raw
Mobility passthrough response, used without local processing, retains its upstream
shape.

Stable top-level fields:

- `source`
Expand Down Expand Up @@ -68,6 +85,9 @@ Stable event fields:

`raw` is present only with `--raw` and mirrors upstream data.

Empty optional event fields are omitted from JSON. Read them with an optional
lookup such as `event.get("end")` rather than assuming every listed key exists.

### `--source odh` and `--source content`

Both sources emit the same envelope and the same event fields. They differ in
Expand All @@ -85,8 +105,8 @@ With `--source content`:

- `subtype` holds the upstream `traffic-event:*` tags, comma-joined and sorted,
for example `hindrance,road-work`. `type` is derived from them.
- `end` is empty while the announcement is open. The provider sets an end time
only when the event ends, so an empty `end` means ongoing, not unknown.
- `end` is omitted while the announcement is open. The provider sets an end time
only when the event ends, so an absent `end` means ongoing, not unknown.
- `active` is true only when the announcement overlaps the requested date range
**and** has not ended yet. Upstream `Active` is not read: it is `true` on
every PROVINCE_BZ record, including ones closed a year ago.
Expand Down
5 changes: 4 additions & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ output is therefore byte-identical to the documented CLI behavior:
stable fields in [json-contracts.md](json-contracts.md),
- a second content block carries stderr diagnostics (for example
stale-GTFS-cache warnings) when the command emitted any,
- JSON objects are also returned as `structuredContent`, preserving their
fields and warnings for clients that consume structured results,
- a nonzero exit code becomes an MCP tool error (`isError: true`) with
the CLI's stderr message, so usage errors and runtime failures stay
visible to the agent.

Commands that default to compact table output are forced to `--json`
over MCP.
over MCP. All tools advertise read-only, non-destructive behavior and interaction
with public external data sources.

## Setup

Expand Down
22 changes: 11 additions & 11 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ SPDX-License-Identifier: CC0-1.0

# Release

Releases are built from Git tags named `v*`, for example `v0.6.2`.
Releases are built from Git tags named `v*`, for example `v0.7.0`.

Before tagging, update `CHANGELOG.md` with the release date and the user-facing milestone summary.

Expand All @@ -23,20 +23,20 @@ go build ./... && go vet ./... && go test ./... && gofmt -l . && golangci-lint r
Build a stamped archive for the current platform:

```bash
VERSION=v0.6.2 scripts/build-release.sh
VERSION=v0.7.0 scripts/build-release.sh
```

Cross-compile by setting `GOOS` and `GOARCH`:

```bash
VERSION=v0.6.2 GOOS=linux GOARCH=amd64 scripts/build-release.sh
VERSION=v0.7.0 GOOS=linux GOARCH=amd64 scripts/build-release.sh
```

Artifacts are written to `dist/` as archives plus per-asset SHA-256 checksum
files. Linux targets can also produce Debian packages:

```bash
VERSION=v0.6.2 GOOS=linux GOARCH=amd64 scripts/build-deb.sh
VERSION=v0.7.0 GOOS=linux GOARCH=amd64 scripts/build-deb.sh
```

Generate the aggregate release manifest locally when testing release assets:
Expand All @@ -50,8 +50,8 @@ scripts/build-checksums.sh
Push a version tag:

```bash
git tag v0.6.2
git push origin v0.6.2
git tag v0.7.0
git push origin v0.7.0
```

The release workflow builds Linux and macOS binaries for `amd64` and `arm64`,
Expand All @@ -73,11 +73,11 @@ The installer in `scripts/install.sh` depends on that asset naming and the adjac
## Verify A Published Release

```bash
gh release view v0.6.2 --repo galjos/odh-cli
gh release download v0.6.2 --repo galjos/odh-cli --pattern SHA256SUMS --pattern 'odh_v0.6.2_darwin_arm64.tar.gz'
grep 'odh_v0.6.2_darwin_arm64.tar.gz' SHA256SUMS
shasum -a 256 odh_v0.6.2_darwin_arm64.tar.gz
gh attestation verify odh_v0.6.2_darwin_arm64.tar.gz --repo galjos/odh-cli
gh release view v0.7.0 --repo galjos/odh-cli
gh release download v0.7.0 --repo galjos/odh-cli --pattern SHA256SUMS --pattern 'odh_v0.7.0_darwin_arm64.tar.gz'
grep 'odh_v0.7.0_darwin_arm64.tar.gz' SHA256SUMS
shasum -a 256 odh_v0.7.0_darwin_arm64.tar.gz
gh attestation verify odh_v0.7.0_darwin_arm64.tar.gz --repo galjos/odh-cli
```

The checksum printed by `shasum` must match `SHA256SUMS`. The attestation check
Expand Down
4 changes: 2 additions & 2 deletions docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ The skill frontmatter also declares the `odh` runtime binary through `metadata.o
ClawHub publishing uses the ClawHub CLI:

```bash
clawhub skill publish "$(pwd)/skills/open-data-hub-cli" --version 0.6.2 --clawscan-note "Uses network access only through the odh CLI to query public Open Data Hub endpoints. Declares odh as a required binary and provides an OpenClaw Go installer hint; manual setup uses Go or Homebrew."
clawhub skill publish "$(pwd)/skills/open-data-hub-cli" --version 0.7.0 --clawscan-note "Uses network access only through the odh CLI to query public Open Data Hub endpoints. Declares odh as a required binary and provides an OpenClaw Go installer hint; manual setup uses Go or Homebrew."
```

The skill carries its own version line, which has run ahead of the CLI's since 0.4.1 — check `clawhub inspect open-data-hub-cli --versions` for the last published version and increment from there, rather than assuming it matches the CLI release.
The skill has its own version history. Check `clawhub inspect open-data-hub-cli --versions` before choosing a new version; it may differ from the CLI release. Publish the required CLI version before publishing the skill that installs it.

Publishing to ClawHub makes the skill public under ClawHub's registry terms.
24 changes: 24 additions & 0 deletions internal/commands/coverage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: 2026 Josef Gallmetzer
//
// SPDX-License-Identifier: MPL-2.0

package commands

// resultCoverage separates the inspected upstream page from local matches and
// the returned subset. MatchedCount never estimates matches on unseen pages.
type resultCoverage struct {
FetchedCount int `json:"fetched_count"`
MatchedCount int `json:"matched_count"`
ReturnedCount int `json:"returned_count"`
ResultTruncated bool `json:"result_truncated"`
UpstreamMayHaveMore bool `json:"upstream_may_have_more"`
UpstreamTotal *int `json:"upstream_total,omitempty"`
}

func pageCoverage(fetched, matched, returned, requestLimit int) resultCoverage {
return resultCoverage{
FetchedCount: fetched, MatchedCount: matched, ReturnedCount: returned,
ResultTruncated: returned < matched,
UpstreamMayHaveMore: requestLimit > 0 && fetched >= requestLimit,
}
}
12 changes: 9 additions & 3 deletions internal/commands/datasets.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,20 @@ func (r *Runner) newDatasetsCmd() *cobra.Command {

var listDomain string
var listFormat string
var listJSON bool
listCmd := &cobra.Command{
Use: "list",
Short: "List known datasets",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
applyJSONShortcut(&listFormat, listJSON)
entries := filterDatasetsByDomain(datasetCatalog(), listDomain)
return writeDatasetEntries(cmd, entries, listFormat)
},
}
listCmd.Flags().StringVar(&listDomain, "domain", "", "optional domain filter, for example tourism or mobility")
listCmd.Flags().StringVar(&listFormat, "format", "json", "output format: json or table")
listCmd.Flags().BoolVar(&listJSON, "json", false, "shortcut for --format json")

var searchDomain string
var searchFormat string
Expand All @@ -73,6 +76,7 @@ func (r *Runner) newDatasetsCmd() *cobra.Command {

var guideDomain string
var guideFormat string
var guideJSON bool
var guideLimit int
guideCmd := &cobra.Command{
Use: "guide <query>",
Expand All @@ -88,6 +92,7 @@ into agent answers.`,
if guideLimit < 0 {
return usageErrorf("--limit must not be negative")
}
applyJSONShortcut(&guideFormat, guideJSON)
query := strings.Join(args, " ")
entries := filterDatasetsByDomain(datasetCatalog(), guideDomain)
entries = rankDatasetsByQuery(entries, query)
Expand All @@ -103,6 +108,7 @@ into agent answers.`,
}
guideCmd.Flags().StringVar(&guideDomain, "domain", "", "optional domain filter, for example tourism or mobility")
guideCmd.Flags().StringVar(&guideFormat, "format", "json", "output format: json or table")
guideCmd.Flags().BoolVar(&guideJSON, "json", false, "shortcut for --format json")
guideCmd.Flags().IntVar(&guideLimit, "limit", 3, "maximum number of matching datasets to guide; 0 means no limit")

cmd.AddCommand(listCmd)
Expand Down Expand Up @@ -316,12 +322,12 @@ func datasetGuideFor(entry datasetEntry) datasetGuideEntry {
"odh traffic categories --json",
}
guidance.Verify = []string{
"odh traffic today --area <area> --type <category> --json",
"odh traffic search <text> --today --json",
"odh traffic today --source content --json",
"odh traffic search <text> --source content --today --json",
"odh traffic today --source odh --area <area> --type <category> --json",
}
guidance.Caveats = []string{
"Open Data Hub PROVINCE_BZ is a public bulletin feed, not a complete live road bulletin.",
"Start current provincial road-notice queries with --source content; the default --source odh reads Mobility Timeseries events whose stored dates may be old.",
"Stale open-ended rows are hidden by default; carry warnings into answers.",
"--source content reads the Content API bulletin the province still updates; it rejects --road, and answers --zone-id and --area by inferring the zone from the announcement's coordinates rather than reading a zone field.",
}
Expand Down
23 changes: 23 additions & 0 deletions internal/commands/json_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,29 @@ func TestJSONContractTrafficContentSearch(t *testing.T) {
assertGoldenJSON(t, "traffic-content-search.json", normalizeAnnouncementRange(stdout), server.URL)
}

func TestJSONContractContentOpenEndOmitted(t *testing.T) {
server := newAnnouncementTestServer(t, announcementFixture)
defer server.Close()
stdout := runContractCommand(t, []apis.API{{Name: "tourism", BaseURL: server.URL, Public: true}}, []string{
"traffic", "events", "--source", "content", "--from", "2026-05-16", "--to", "2026-05-16", "--json",
})
var result struct {
Events []map[string]any `json:"events"`
}
if err := json.Unmarshal(stdout, &result); err != nil {
t.Fatal(err)
}
for _, event := range result.Events {
if event["id"] == "urn:announcements:provincebz:open-roadwork" {
if _, present := event["end"]; present {
t.Fatalf("open announcement must omit end: %v", event)
}
return
}
}
t.Fatal("open announcement missing from the response")
}

// normalizeAnnouncementRange pins the begin/end query parameters, which the
// local timezone shifts, so the golden endpoint stays comparable.
var announcementRangeParams = regexp.MustCompile(`(begin|end)=[^&"]*`)
Expand Down
Loading