From 373ba07e7c5e3bbea77c0228547d4e0576f65e60 Mon Sep 17 00:00:00 2001 From: "Josef M. Gallmetzer" <64498081+galjos@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:38:38 +0200 Subject: [PATCH] Prepare v0.7.0 --- .github/workflows/release.yml | 24 +++--- CHANGELOG.md | 17 +++++ README.md | 12 ++- docs/development.md | 4 +- docs/json-contracts.md | 24 +++++- docs/mcp.md | 5 +- docs/release.md | 22 +++--- docs/skills.md | 4 +- internal/commands/coverage.go | 24 ++++++ internal/commands/datasets.go | 12 ++- internal/commands/json_contract_test.go | 23 ++++++ internal/commands/mobility.go | 2 + internal/commands/reliability_test.go | 74 +++++++++++++++++++ .../testdata/golden/mobility-latest.json | 9 ++- .../golden/traffic-content-search.json | 10 ++- .../testdata/golden/traffic-search.json | 9 ++- internal/commands/traffic.go | 23 +++++- internal/commands/traffic_content.go | 9 +++ internal/commands/traffic_content_test.go | 16 ++-- internal/mcpserver/server.go | 17 ++++- internal/mcpserver/server_test.go | 8 ++ internal/version/version.go | 2 +- scripts/build-release.sh | 2 +- skills/open-data-hub-cli/SKILL.md | 11 +-- 24 files changed, 306 insertions(+), 57 deletions(-) create mode 100644 internal/commands/coverage.go create mode 100644 internal/commands/reliability_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f7b8ab..aeb41ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 }} @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8534fe0..79a4409 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4c40dee..3719981 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -56,8 +56,8 @@ 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 @@ -65,6 +65,10 @@ odh transit journey --from merano --to ora --time 16:40 --max-transfers 3 --with Every command documents flags and examples in `odh --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). diff --git a/docs/development.md b/docs/development.md index 563c268..2297c0d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 ``` diff --git a/docs/json-contracts.md b/docs/json-contracts.md index ba0fa26..53fcfae 100644 --- a/docs/json-contracts.md +++ b/docs/json-contracts.md @@ -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` @@ -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 @@ -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. diff --git a/docs/mcp.md b/docs/mcp.md index 6b38ba7..d8b8952 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 diff --git a/docs/release.md b/docs/release.md index 062d162..cf54559 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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. @@ -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: @@ -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`, @@ -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 diff --git a/docs/skills.md b/docs/skills.md index 2c1bae5..452424e 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -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. diff --git a/internal/commands/coverage.go b/internal/commands/coverage.go new file mode 100644 index 0000000..7cabb43 --- /dev/null +++ b/internal/commands/coverage.go @@ -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, + } +} diff --git a/internal/commands/datasets.go b/internal/commands/datasets.go index 1377da7..a96868c 100644 --- a/internal/commands/datasets.go +++ b/internal/commands/datasets.go @@ -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 @@ -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 ", @@ -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) @@ -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) @@ -316,12 +322,12 @@ func datasetGuideFor(entry datasetEntry) datasetGuideEntry { "odh traffic categories --json", } guidance.Verify = []string{ - "odh traffic today --area --type --json", - "odh traffic search --today --json", "odh traffic today --source content --json", + "odh traffic search --source content --today --json", + "odh traffic today --source odh --area --type --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.", } diff --git a/internal/commands/json_contract_test.go b/internal/commands/json_contract_test.go index 95cfa77..8675aad 100644 --- a/internal/commands/json_contract_test.go +++ b/internal/commands/json_contract_test.go @@ -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)=[^&"]*`) diff --git a/internal/commands/mobility.go b/internal/commands/mobility.go index 7776336..9ee1060 100644 --- a/internal/commands/mobility.go +++ b/internal/commands/mobility.go @@ -643,6 +643,7 @@ type mobilityLatestResult struct { RawCount int `json:"raw_count"` Count int `json:"count"` Measurements []map[string]any `json:"measurements"` + Coverage resultCoverage `json:"coverage"` Warnings []string `json:"warnings,omitempty"` } @@ -1031,6 +1032,7 @@ func filterMobilityLatest(records []map[string]any, filter mobilityLatestFilter) RawCount: len(records), Count: len(matched), Measurements: matched, + Coverage: pageCoverage(len(records), matchedBeforeLimit, len(matched), filter.RequestLimit), Warnings: warnings, } } diff --git a/internal/commands/reliability_test.go b/internal/commands/reliability_test.go new file mode 100644 index 0000000..b35bd61 --- /dev/null +++ b/internal/commands/reliability_test.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 Josef Gallmetzer +// +// SPDX-License-Identifier: MPL-2.0 + +package commands + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + "time" +) + +func TestDatasetJSONShortcutOverridesTable(t *testing.T) { + for _, args := range [][]string{ + {"datasets", "list"}, + {"datasets", "search", "parking"}, + {"datasets", "guide", "parking"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := NewDefaultRunner().Run(context.Background(), append(args, "--format", "table", "--json"), &stdout, &stderr) + if code != 0 || !json.Valid(stdout.Bytes()) { + t.Fatalf("exit=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + }) + } +} + +func TestBikeFeedWarningPrecedesResults(t *testing.T) { + day := time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC) + events, warnings := normalizeTrafficEvents([]map[string]any{{ + "evuuid": "old-bike-notice", + "evstart": "2019-04-05T00:00:00Z", + "evtransactiontime": "2025-09-15T08:00:00Z", + "evmetadata": map[string]any{ + "subTycodeValue": "RADWEG_SPERRE", + "placeDe": "Radroute Gadertal", + }, + }}, trafficQuery{Type: "bike", IncludeStale: true, Limit: 1000}, trafficArea{}, day, day) + if len(events) != 1 || len(warnings) == 0 { + t.Fatalf("expected the historical notice with warnings: %+v, %v", events, warnings) + } + if !strings.Contains(warnings[0], "2025-09-15") || + !strings.Contains(warnings[0], "not a live bulletin") || + !strings.Contains(warnings[0], "odh traffic search radroute --today --source content --json") { + t.Fatalf("first warning must explain age and the current cycle search: %v", warnings) + } + for _, format := range []string{"table", "markdown"} { + var stdout bytes.Buffer + if err := writeTrafficOutput(&stdout, trafficResult{ + Source: "odh", Events: events, Warnings: warnings, OutputFormat: format, + }); err != nil { + t.Fatal(err) + } + text := stdout.String() + if strings.Index(text, warnings[0]) > strings.Index(text, "Radroute Gadertal") || + strings.Count(text, warnings[0]) != 1 { + t.Fatalf("%s must show the feed warning once, before the rows: %s", format, text) + } + } +} + +func TestMobilityCoverageSeparatesPageMatchesAndResults(t *testing.T) { + rows := []map[string]any{{"sactive": true}, {"sactive": false}, {"sactive": true}} + result := filterMobilityLatest(rows, mobilityLatestFilter{ActiveOnly: true, Limit: 1, RequestLimit: 3}) + coverage := result.Coverage + if coverage.FetchedCount != 3 || coverage.MatchedCount != 2 || coverage.ReturnedCount != 1 || + !coverage.ResultTruncated || !coverage.UpstreamMayHaveMore || coverage.UpstreamTotal != nil { + t.Fatalf("unexpected coverage: %+v", coverage) + } +} diff --git a/internal/commands/testdata/golden/mobility-latest.json b/internal/commands/testdata/golden/mobility-latest.json index b782c6c..cfb0e45 100644 --- a/internal/commands/testdata/golden/mobility-latest.json +++ b/internal/commands/testdata/golden/mobility-latest.json @@ -19,5 +19,12 @@ "sname": "Park One", "sorigin": "Municipality Merano" } - ] + ], + "coverage": { + "fetched_count": 1, + "matched_count": 1, + "returned_count": 1, + "result_truncated": false, + "upstream_may_have_more": false + } } diff --git a/internal/commands/testdata/golden/traffic-content-search.json b/internal/commands/testdata/golden/traffic-content-search.json index 3f6c137..7952cd1 100644 --- a/internal/commands/testdata/golden/traffic-content-search.json +++ b/internal/commands/testdata/golden/traffic-content-search.json @@ -32,5 +32,13 @@ "warnings": [ "this source cannot populate the event fields zone_id, zone, zone_it, road, road_name, severity, or series_id; an empty value there means the Content API does not carry the field, not that the event has no zone, road, or severity. The top-level zone_id and area echo the filter you passed; they are not read from the records", "source is the Open Data Hub Content API /v1/Announcement feed for PROVINCE_BZ; compare with the official traffic service before presenting this as a complete live road bulletin" - ] + ], + "coverage": { + "fetched_count": 4, + "matched_count": 1, + "returned_count": 1, + "result_truncated": false, + "upstream_may_have_more": false, + "upstream_total": 4 + } } diff --git a/internal/commands/testdata/golden/traffic-search.json b/internal/commands/testdata/golden/traffic-search.json index d99a79e..9542353 100644 --- a/internal/commands/testdata/golden/traffic-search.json +++ b/internal/commands/testdata/golden/traffic-search.json @@ -39,5 +39,12 @@ "warnings": [ "the newest row in this Mobility Timeseries event response is dated 2999-05-16; this feed is not a live bulletin, and neither a stale nor an empty result is evidence that roads are clear, so cross-check current notices with: odh call tourism /v1/Announcement --param source=PROVINCE_BZ --param rawsort=-LastChange", "source is Open Data Hub PROVINCE_BZ; compare with the official traffic service before presenting this as a complete live road bulletin" - ] + ], + "coverage": { + "fetched_count": 1, + "matched_count": 1, + "returned_count": 1, + "result_truncated": false, + "upstream_may_have_more": false + } } diff --git a/internal/commands/traffic.go b/internal/commands/traffic.go index 5fb5b47..ff592d6 100644 --- a/internal/commands/traffic.go +++ b/internal/commands/traffic.go @@ -389,6 +389,7 @@ func (r *Runner) runODHTrafficQueryCobra(ctx context.Context, query trafficQuery Search: strings.TrimSpace(query.Search), RawCount: len(rawEvents), Count: len(events), + Coverage: pageCoverage(len(rawEvents), len(events), len(events), query.Limit), Events: events, Warnings: warnings, OutputFormat: query.Format, @@ -408,6 +409,7 @@ type trafficResult struct { Search string `json:"search,omitempty"` RawCount int `json:"raw_count"` Count int `json:"count"` + Coverage resultCoverage `json:"coverage"` Events []trafficEvent `json:"events"` Warnings []string `json:"warnings,omitempty"` OutputFormat string `json:"-"` @@ -471,7 +473,11 @@ func normalizeTrafficEvents(raw []map[string]any, query trafficQuery, area traff return deduped[i].Place < deduped[j].Place }) - warnings := make([]string, 0) + feedWarning := timeseriesEventFeedWarning(newestTrafficEventTimestamp(deduped), "PROVINCE_BZ") + if normalizeTrafficTypeName(query.Type) == "bike" { + feedWarning += "; active reflects the stored date range, not verified current status. For current cycle-route notices, run: odh traffic search radroute --today --source content --json" + } + warnings := []string{feedWarning} if len(events) != len(deduped) { warnings = append(warnings, fmt.Sprintf("deduplicated %d raw matching rows to %d events", len(events), len(deduped))) } @@ -495,7 +501,6 @@ func normalizeTrafficEvents(raw []map[string]any, query trafficQuery, area traff if warning := mobilityTruncationWarning("returned", "raw event rows", query.Limit, len(raw), "traffic completeness"); warning != "" { warnings = append(warnings, warning) } - warnings = append(warnings, timeseriesEventFeedWarning(newestTrafficEventTimestamp(deduped), "PROVINCE_BZ")) warnings = append(warnings, "source is Open Data Hub PROVINCE_BZ; compare with the official traffic service before presenting this as a complete live road bulletin") return deduped, warnings } @@ -607,6 +612,11 @@ func normalizeTrafficFormat(value string) (string, error) { } func writeTrafficTable(stdout io.Writer, result trafficResult) error { + warnings := result.Warnings + if result.Source == trafficSourceODH && len(warnings) > 0 { + fmt.Fprintf(stdout, "warning: %s\n\n", warnings[0]) + warnings = warnings[1:] + } tw := tabwriter.NewWriter(stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(tw, "TYPE\tROAD\tPLACE\tTIME\tACTIVE\tSTALE") for _, event := range result.Events { @@ -622,13 +632,18 @@ func writeTrafficTable(stdout io.Writer, result trafficResult) error { if err := tw.Flush(); err != nil { return err } - for _, warning := range result.Warnings { + for _, warning := range warnings { fmt.Fprintf(stdout, "warning: %s\n", warning) } return nil } func writeTrafficMarkdown(stdout io.Writer, result trafficResult) error { + warnings := result.Warnings + if result.Source == trafficSourceODH && len(warnings) > 0 { + fmt.Fprintf(stdout, "> warning: %s\n\n", warnings[0]) + warnings = warnings[1:] + } fmt.Fprintln(stdout, "| type | road | place | time | active | stale |") fmt.Fprintln(stdout, "| --- | --- | --- | --- | --- | --- |") for _, event := range result.Events { @@ -641,7 +656,7 @@ func writeTrafficMarkdown(stdout io.Writer, result trafficResult) error { event.Stale, ) } - for _, warning := range result.Warnings { + for _, warning := range warnings { fmt.Fprintf(stdout, "\n> warning: %s\n", warning) } return nil diff --git a/internal/commands/traffic_content.go b/internal/commands/traffic_content.go index e4a9c87..808b081 100644 --- a/internal/commands/traffic_content.go +++ b/internal/commands/traffic_content.go @@ -68,6 +68,14 @@ func (r *Runner) runContentTrafficQueryCobra(ctx context.Context, query trafficQ } records := mapsFromList(extractItemsList(value)) events, warnings := normalizeContentTrafficEvents(records, announcementTotalResults(value), query, area, fromDay, toDay) + coverage := pageCoverage(len(records), len(events), len(events), query.Limit) + if object, ok := value.(map[string]any); ok { + if total, valid := numberValue(object["TotalResults"]); valid && total >= 0 { + count := int(total) + coverage.UpstreamTotal = &count + coverage.UpstreamMayHaveMore = count > len(records) + } + } return writeTrafficOutput(stdout, trafficResult{ Source: trafficSourceContent, SourceDetail: "Open Data Hub Tourism Content API /v1/Announcement " + announcementTrafficSource + " road bulletin", @@ -80,6 +88,7 @@ func (r *Runner) runContentTrafficQueryCobra(ctx context.Context, query trafficQ Search: strings.TrimSpace(query.Search), RawCount: len(records), Count: len(events), + Coverage: coverage, Events: events, Warnings: warnings, OutputFormat: query.Format, diff --git a/internal/commands/traffic_content_test.go b/internal/commands/traffic_content_test.go index 4978917..2e9a32e 100644 --- a/internal/commands/traffic_content_test.go +++ b/internal/commands/traffic_content_test.go @@ -118,11 +118,12 @@ func newAnnouncementTestServer(t *testing.T, body string) *httptest.Server { } type contentTrafficResponse struct { - Source string `json:"source"` - SourceDetail string `json:"source_detail"` - Endpoint string `json:"endpoint"` - RawCount int `json:"raw_count"` - Count int `json:"count"` + Coverage resultCoverage `json:"coverage"` + Source string `json:"source"` + SourceDetail string `json:"source_detail"` + Endpoint string `json:"endpoint"` + RawCount int `json:"raw_count"` + Count int `json:"count"` Events []struct { ID string `json:"id"` MessageID string `json:"message_id"` @@ -314,6 +315,11 @@ func TestRunTrafficTodayContentWarnsWhenLimitTruncates(t *testing.T) { if !containsWarning(decoded.Warnings, "the Content API reports 40 announcements in this date range but --limit=4 fetched only 4") { t.Fatalf("expected truncation warning, got %#v", decoded.Warnings) } + if decoded.Coverage.UpstreamTotal == nil || *decoded.Coverage.UpstreamTotal != 40 || + !decoded.Coverage.UpstreamMayHaveMore || decoded.Coverage.FetchedCount != 4 || + decoded.Coverage.MatchedCount != len(decoded.Events) || decoded.Coverage.ResultTruncated { + t.Fatalf("unexpected coverage: %+v", decoded.Coverage) + } } func TestRunTrafficContentWarnsThatIncludeStaleDoesNothing(t *testing.T) { diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index dd66739..18da9e6 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -7,6 +7,7 @@ package mcpserver import ( "bytes" "context" + "encoding/json" "fmt" "io" "strings" @@ -53,6 +54,7 @@ func New(version string, exec ExecFunc) *mcp.Server { Name: spec.name, Description: spec.desc, InputSchema: spec.inputSchema(), + Annotations: readOnlyAnnotations(), } server.AddTool(tool, newToolHandler(spec, exec)) } @@ -89,7 +91,20 @@ func newToolHandler(spec toolSpec, exec ExecFunc) mcp.ToolHandler { if diagnostics != "" { content = append(content, &mcp.TextContent{Text: "stderr diagnostics:\n" + diagnostics}) } - return &mcp.CallToolResult{Content: content}, nil + result := &mcp.CallToolResult{Content: content} + var structured map[string]any + if json.Unmarshal(stdout.Bytes(), &structured) == nil && structured != nil { + result.StructuredContent = structured + } + return result, nil + } +} + +func readOnlyAnnotations() *mcp.ToolAnnotations { + destructive, openWorld := false, true + return &mcp.ToolAnnotations{ + ReadOnlyHint: true, DestructiveHint: &destructive, + IdempotentHint: true, OpenWorldHint: &openWorld, } } diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index e3f3420..ef1f7c0 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -177,6 +177,10 @@ func TestServerListsAllTools(t *testing.T) { t.Fatalf("list tools: %v", err) } listed[tool.Name] = true + if tool.Annotations == nil || !tool.Annotations.ReadOnlyHint || + tool.Annotations.DestructiveHint == nil || *tool.Annotations.DestructiveHint { + t.Fatalf("tool %q must advertise read-only behavior", tool.Name) + } } if len(listed) != len(toolSpecs) { t.Fatalf("listed %d tools, want %d", len(listed), len(toolSpecs)) @@ -213,6 +217,10 @@ func TestServerCallToolSuccessWithDiagnostics(t *testing.T) { if len(result.Content) != 2 { t.Fatalf("want 2 content blocks, got %d", len(result.Content)) } + structured, ok := result.StructuredContent.(map[string]any) + if !ok || !reflect.DeepEqual(structured["zones"], []any{}) { + t.Fatalf("unexpected structured result: %#v", result.StructuredContent) + } first, ok := result.Content[0].(*mcp.TextContent) if !ok || first.Text != `{"zones": []}` { t.Fatalf("unexpected first content: %#v", result.Content[0]) diff --git a/internal/version/version.go b/internal/version/version.go index bc84f30..0059799 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -12,7 +12,7 @@ import ( // These variables can be overridden at build time with -ldflags. var ( - Version = "0.6.2-dev" + Version = "0.7.0-dev" Commit = "unknown" Date = "unknown" ) diff --git a/scripts/build-release.sh b/scripts/build-release.sh index a0f3ad5..f2caa7f 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -5,7 +5,7 @@ set -euo pipefail -VERSION="${VERSION:-0.4.0-dev}" +VERSION="${VERSION:-dev}" COMMIT="${COMMIT:-$(git rev-parse --short HEAD)}" DATE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" TARGET_OS="${GOOS:-$(go env GOOS)}" diff --git a/skills/open-data-hub-cli/SKILL.md b/skills/open-data-hub-cli/SKILL.md index 60ad651..744623a 100644 --- a/skills/open-data-hub-cli/SKILL.md +++ b/skills/open-data-hub-cli/SKILL.md @@ -13,7 +13,7 @@ metadata: { "id": "go", "kind": "go", - "module": "github.com/galjos/odh-cli/cmd/odh@v0.6.2", + "module": "github.com/galjos/odh-cli/cmd/odh@v0.7.0", "bins": ["odh"], "label": "Install odh CLI (go)", }, @@ -33,18 +33,18 @@ odh version odh doctor --timeout 10s ``` -Need `odh v0.6.2+` for the current command contracts, dataset guidance, source/provenance fields, traffic helpers, GTFS/transit, filtered latest measurements, comma-safe `--param`, `transit journey --with-realtime`, and MCP server mode. +Need `odh v0.7.0+` for the current command contracts, dataset guidance, source/provenance fields, traffic helpers, GTFS/transit, filtered latest measurements, comma-safe `--param`, `transit journey --with-realtime`, and MCP server mode. Preferred manual install options: ```bash -go install github.com/galjos/odh-cli/cmd/odh@v0.6.2 +go install github.com/galjos/odh-cli/cmd/odh@v0.7.0 brew install galjos/odh/odh ``` Agent hosts can also use the declared OpenClaw Go installer metadata in this skill. If running from the source repo, use `./odh`. -Agent hosts that prefer MCP over shell commands can run the same curated surface as Model Context Protocol tools with `odh mcp serve`; tool outputs follow the same JSON contracts and warnings as the CLI. +Agent hosts that prefer MCP over shell commands can run the same curated surface as Model Context Protocol tools with `odh mcp serve`; tools advertise read-only behavior and return structured JSON alongside text, with the same contracts and warnings as the CLI. ## Output Rules @@ -57,6 +57,7 @@ Agent hosts that prefer MCP over shell commands can run the same curated surface - Prefer discovery commands before guessing provider names, data types, stop IDs, or zone IDs. - Prefer returned `source`, `source_detail`, `endpoint`, `archive`, `realtime`, and `warnings` fields over inferred provenance. - Stable curated JSON fields are documented in `docs/json-contracts.md` in the repo. +- Traffic and normalized `mobility latest` results include `coverage` with fetched, matched, and returned counts. Check `result_truncated` and `upstream_may_have_more` before claiming a complete list; matched counts cover only the fetched rows. Content traffic also reports `upstream_total` when supplied by the source. ## Discovery @@ -96,7 +97,7 @@ odh traffic search radroute --today --source content --json Prefer `traffic` over raw `mobility events --origin PROVINCE_BZ`. Surface stale/source warnings. Do not present stale open-ended rows as confirmed current closures. The default `--source odh` is a Mobility Timeseries event feed, not a live bulletin: an empty result is not evidence that roads are clear. Report the newest row date the command returns. -`--source content` runs the same commands against the Content API `/v1/Announcement` bulletin, which is where the province still publishes. It supports `--from`/`--to`/`--today`, `--near`/`--radius`, `--search`, `--type`, `--limit`, `--include-expired` and `--zone-id`/`--area`, and rejects `--road` and `--type bike` with exit code 2 rather than returning a partial list. Its results leave `zone_id`, `zone`, `zone_it`, `road`, `road_name`, `severity` and `series_id` empty; that means the field is unavailable, not absent. An empty `end` means the announcement is still open; already-ended ones are hidden unless `--include-expired` is passed, and `stale` there only means "unchanged for 30 days", which is normal for long-running restrictions. +`--source content` runs the same commands against the Content API `/v1/Announcement` bulletin, which is where the province still publishes. It supports `--from`/`--to`/`--today`, `--near`/`--radius`, `--search`, `--type`, `--limit`, `--include-expired` and `--zone-id`/`--area`, and rejects `--road` and `--type bike` with exit code 2 rather than returning a partial list. Its results omit `zone_id`, `zone`, `zone_it`, `road`, `road_name`, `severity` and `series_id` because this source cannot populate them. An absent `end` means the announcement is still open; use `event.get("end")` to read it. Already-ended ones are hidden unless `--include-expired` is passed, and `stale` there only means "unchanged for 30 days", which is normal for long-running restrictions. `--zone-id` and `--area` are geographic inference on this source, not a field read: the announcement's coordinates are matched against a committed table of ~1100 coordinates whose zone the Mobility feed recorded, matching when the nearest is within 2.0 km. Announcements beyond that, or without coordinates, are excluded as unassignable and counted in a warning. No inferred zone is written into the output. Surface that warning and phrase the answer as "in that area", not "filed under that zone". Municipality aliases such as `--area kaltern` narrow only to the zone here — the response warns — so use `--search` when the answer must be about the village.