Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
418c782
feat: add Ferro operator console
MitulShah1 Aug 14, 2026
22cdf29
docs: simplify v0.1.0 changelog for first release
MitulShah1 Aug 14, 2026
15b87b9
feat(tui): rework the wide home header
MitulShah1 Aug 14, 2026
55eacea
docs: re-record the console demo as docs/console.gif
MitulShah1 Aug 14, 2026
fa20b27
fix(tui): preallocate the header readout column, unexport the mark art
MitulShah1 Aug 14, 2026
3dd363b
fix(table): neutralize control characters in table cells
MitulShah1 Aug 14, 2026
d49c6cf
fix(command): honour "missing is not zero" for latency, stop escaping…
MitulShah1 Aug 14, 2026
a096c47
fix: attribute the stream idle timeout to ferro, not the gateway
MitulShah1 Aug 14, 2026
04d70cf
fix(api): fail loudly on oversized bodies, bound error text, sync tes…
MitulShah1 Aug 14, 2026
db2a76f
refactor(table): hold the model listing's shape in one place
MitulShah1 Aug 14, 2026
49854a2
fix(tui): keep a modal's actions when the pane is short
MitulShah1 Aug 14, 2026
855db28
ci: harden the module boundary check, the release job, and the fixtur…
MitulShah1 Aug 14, 2026
e53ed41
test: isolate config resolution, deflake the tail test, widen itest r…
MitulShah1 Aug 14, 2026
56b8e97
fix(table): neutralize control characters on every surface, not one
MitulShah1 Aug 16, 2026
61f880a
fix(api): charge every frame against the stream bound, free the heade…
MitulShah1 Aug 16, 2026
274d600
fix(api): keep paging when the gateway omits total_entries
MitulShah1 Aug 16, 2026
210e4b0
refactor(table): hold the provider listing's shape in one place
MitulShah1 Aug 16, 2026
806c330
fix(config): confine the MASTER_KEY fallback to a loopback gateway
MitulShah1 Aug 16, 2026
57beeaf
feat(command): gate keys rotate and revoke behind a typed confirmation
MitulShah1 Aug 16, 2026
fda8a7e
fix(version): fall back to the embedded build info
MitulShah1 Aug 16, 2026
ffc40b0
fix(tui): follow a selected log row by its identity, not its trace id
MitulShah1 Aug 16, 2026
18b5bc7
ci: unalias the concurrency group, make release builds reproducible
MitulShah1 Aug 16, 2026
0e2d3bc
test: isolate the chat tests from a developer's ferro config
MitulShah1 Aug 16, 2026
03b9fa3
docs: record the behaviour changes and declare the copyright
MitulShah1 Aug 16, 2026
b42f56e
docs: give the README the gateway's header and its own badges
MitulShah1 Aug 16, 2026
88ffdb3
docs: carry the gateway's logo.png verbatim
MitulShah1 Aug 16, 2026
0bf0e7b
fix(api): bound the envelope error message and the stream's header wait
MitulShah1 Aug 16, 2026
6dc82b6
refactor: write the 501 rule once, and start one fixture server per test
MitulShah1 Aug 16, 2026
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
114 changes: 114 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
name: CI

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
workflow_call:
schedule:
- cron: "0 4 * * 1"

concurrency:
# Literal "ci-", never ${{ github.workflow }}: inside a reusable workflow that
# expression resolves to the *caller's* name, so the `uses: ./.github/workflows/ci.yml`
# leg of release.yml would compute "Release-refs/tags/vX" while release.yml's own
# group is "release-refs/tags/vX" -- the same group, because GitHub matches
# concurrency names case-insensitively, but with the two halves disagreeing about
# cancel-in-progress (true here, false there). A literal keeps this group's meaning
# fixed no matter who calls the workflow. Do not "restore" the expression.
group: ci-${{ github.ref }}
# Pull requests and pushes to main only care about the newest commit; superseded
# runs are wasted minutes. Tagged releases are serialised by release.yml's own
# group instead, which is where cancel-in-progress: false belongs.
cancel-in-progress: true

permissions:
contents: read

jobs:
boundary:
name: HTTP boundary intact
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: go.mod
- run: ./scripts/check-module-boundary.sh

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: go.mod
- uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9
with:
version: v2.11.4

security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: go.mod
- run: go run golang.org/x/vuln/cmd/govulncheck@v1.3.0 ./...

test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: go.mod
- run: go mod verify
- run: go vet ./...
- run: go test -race ./...
- run: go build ./cmd/ferro

integration:
name: Contract vs AI Gateway ${{ matrix.gateway_ref }}
runs-on: ubuntu-latest
# The pinned v1.4.2 leg is the required signal; "main" moves upstream of
# this repo and release.yml depends on ci via `needs: ci`, so a failure
# there must not block merges or tagged releases — only report it.
continue-on-error: ${{ matrix.gateway_ref == 'main' }}
strategy:
fail-fast: false
matrix:
gateway_ref: ["v1.4.2", "main"]
steps:
- name: Check out gateway-cli
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
path: gateway-cli
persist-credentials: false
- name: Check out AI Gateway
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ferro-labs/ai-gateway
ref: ${{ matrix.gateway_ref }}
path: ai-gateway
persist-credentials: false
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: gateway-cli/go.mod
- run: ./scripts/with-gateway.sh
working-directory: gateway-cli
env:
FERRO_GATEWAY_SOURCE: ${{ github.workspace }}/ai-gateway
47 changes: 47 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Release

on:
push:
tags: ["v*"]

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

permissions: {}

jobs:
ci:
permissions:
contents: read
uses: ./.github/workflows/ci.yml

release:
name: GoReleaser
needs: ci
runs-on: ubuntu-latest
permissions:
# GoReleaser publishes the GitHub release and uploads archives.
contents: write
Comment thread
coderabbitai[bot] marked this conversation as resolved.
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: go.mod
# A restored cache can carry entries from other refs; this job
# publishes release artifacts, so a poisoned cache is user-facing.
cache: false
# .goreleaser.yaml's sboms block shells out to syft, which is not present on
# GitHub-hosted runners; without this step the first real tag fails at the
# SBOM stage, after the binaries are already built.
- uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
- uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
with:
distribution: goreleaser
version: "2.17.1"
args: release --clean
Comment thread
coderabbitai[bot] marked this conversation as resolved.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ go.work.sum
# Editor/IDE
# .idea/
# .vscode/

# Local builds
/ferro
/fakegw
/dist/
60 changes: 60 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
version: "2"

run:
timeout: 5m

linters:
enable:
- bodyclose
- copyloopvar
- depguard
- dupl
- errcheck
- errorlint
- goconst
- gocritic
- gocyclo
- gosec
- govet
- ineffassign
- maintidx
- misspell
- noctx
- nolintlint
- prealloc
- revive
- staticcheck
- unconvert
- unparam
- unused

settings:
depguard:
rules:
main:
deny:
- pkg: gopkg.in/yaml.v3
desc: use go.yaml.in/yaml/v3
# depguard prefix-matches, so the module root also denies every
# current and future subpackage.
- pkg: github.com/ferro-labs/ai-gateway
desc: gateway-cli communicates with AI Gateway over HTTP only
dupl:
threshold: 150
gocyclo:
min-complexity: 30
nolintlint:
require-explanation: true
require-specific: true
allow-unused: false

exclusions:
rules:
- path: _test\.go
linters: [dupl, errorlint, goconst, unparam]
- path: _test\.go
linters: [staticcheck]
text: SA5011
- path: internal/command/output\.go
linters: [gosec]
text: G115
66 changes: 66 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# GoReleaser for the standalone ferro operator console.
version: 2
project_name: ferro
dist: dist

builds:
- id: ferro
main: ./cmd/ferro
binary: ferro
env: [CGO_ENABLED=0]
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
# Reproducibility: rebuilding a tag must yield the same bytes, or a user has
# no way to check that a published binary was built from the source it names.
# mod_timestamp pins the file mtimes goreleaser stamps into archives, and
# .CommitDate replaces the build wall clock below -- both are needed, since
# either one alone still leaves the build time baked in.
mod_timestamp: "{{ .CommitTimestamp }}"
# -trimpath is the third leg: without it the binary embeds the absolute build
# directory, so a rebuild anywhere but the CI runner's own path differs even
# with the timestamps pinned -- and the published binary leaks that path.
flags: [-trimpath]
ldflags:
- -s -w
- -X github.com/ferro-labs/gateway-cli/internal/version.Version={{.Version}}
- -X github.com/ferro-labs/gateway-cli/internal/version.Commit={{.ShortCommit}}
- -X github.com/ferro-labs/gateway-cli/internal/version.Date={{.CommitDate}}

# cmd/fakegw is a development tool (a fake gateway for testing the CLI without
# a server). It is excluded by omission — builds are listed explicitly above.
# Do not add a build entry for it.

archives:
- formats: [tar.gz]
name_template: "ferro_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
files:
- src: README.md
dst: README.md
- src: CHANGELOG.md
dst: CHANGELOG.md
- src: LICENSE
dst: LICENSE

checksum:
name_template: checksums.txt

# One SBOM per archive (syft's default, SPDX JSON), so a consumer can answer
# "is this build affected by CVE-x" without unpacking and re-scanning it.
# Requires syft on the runner -- release.yml installs it, and goreleaser fails
# the release rather than skipping the SBOM if it is missing.
sboms:
- artifacts: archive

release:
name_template: "Ferro CLI v{{ .Version }}"

changelog:
use: git
filters:
include:
- "^feat"
- "^fix"
- "^perf"
74 changes: 74 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Changelog

All notable changes to **Ferro Operator Console**, the scriptable CLI and
interactive TUI for Ferro Labs AI Gateway. The binary it installs is `ferro`.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## v0.1.0 — 2026-08-14

First release. One binary, `ferro`, with two surfaces over a single HTTP client:
scriptable commands for automation, and a full-screen console for operators.
Requires AI Gateway v1.4.2 or later.

### Added

- **Commands** — `status`, `models`, `providers`, `mcp`, `plugins`, `services`,
`sessions`, `audit`, `keys list|get|create|rotate|revoke`,
`logs list|stats|tail`, `chat`, and `version`. Every one supports
`--format table|json|yaml`.
- **Console** — run bare `ferro` at a TTY for a full-screen operations view with
four screens: Home, Request logs, Keys, and Playground. Includes a command
composer with completion, history, and reverse search (`ctrl+r`).
- **Request-log tail** — follow live traffic in `logs tail` or the console, with
filters for time, model, provider, stage, and credential.
- **Key management** — list, create, rotate, and revoke API keys, with derived
`active` / `expired` / `revoked` state. New secrets are printed once, to
stdout, and never enter the console transcript or history. `rotate` and
`revoke` are irreversible, so at a terminal each asks for the key id typed
back before it runs; `--yes` skips the prompt, and is required when stdin is
not a terminal rather than the verb blocking or proceeding unasked.
- **Playground** — streaming chat against the gateway with `/model` and
`/clear`, showing token usage plus route and cost when available.
- **Connection profiles** at `os.UserConfigDir()/ferro/config.yaml`. URL
resolution: `--gateway-url` > `FERRO_URL` > profile > `http://localhost:8080`.
Key resolution: `FERRO_API_KEY` > profile `api_key_env` > `MASTER_KEY`, the
last of which applies only when the gateway URL is loopback. `MASTER_KEY` is
the gateway server's own variable and is not chosen for ferro by anyone, so
it is the one source that could otherwise be forwarded to a remote host the
operator named on the command line. Keys are never passed as flags, so they
stay out of shell history.
- **Global flags** `--gateway-url`, `--profile`, `--format`, `--ascii`, plus
`NO_COLOR`, TTY, and `TERM=dumb` detection.

### Output contract

- stdout carries data; narration, warnings, and errors go to stderr — so
`--format json` is always safe to pipe.
- `status` exits 1 only when the gateway is unreachable. A reachable but
degraded gateway exits 0 and reports its degraded state.
- Missing measurements render `-`, never `0`. Endpoints the gateway does not
serve disable that panel with a hint instead of failing the command.
- Gateway-supplied text is data, never layout or terminal control. Control
characters in a provider name, an upstream error message, or a model's answer
are neutralized on every surface, so a table keeps its columns, a pane keeps
its line count, and nothing upstream can drive the terminal.

### Distribution

- Release binaries are reproducible: `-trimpath`, and both the build stamp and
the archive timestamps come from the tagged commit rather than the build
clock, so two builds of one tag are byte-identical.
- An SPDX SBOM ships beside each archive.
- `go install` builds, which carry no linker stamp, now report their module
version and commit from the embedded build info instead of `dev`/`none`.

### Known limitations

- The gateway does not report its version over HTTP, so the console's version
slot renders `—`.
- The traffic panel and route attribution come from the request log, and show as
unavailable when no request-log store is configured.
- Not yet shipped: config viewer / history / rollback, provider capability
matrix, doctor checklist, and init wizard.
15 changes: 15 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Contributing

Ferro Operator Console is a standalone Go module. Keep the runtime boundary
with AI Gateway HTTP-only; do not import packages from the gateway module.

Before opening a change, run:

```bash
./scripts/check-module-boundary.sh
go vet ./...
go test -race ./...
golangci-lint run ./...
```

For contract changes, run `FERRO_GATEWAY_SOURCE=/path/to/ai-gateway make itest`.
Loading
Loading