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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,17 @@ ana auth login --endpoint https://app.textql.com
ana org show
ana connector list
ana chat send "show me last month's revenue"
ana update # replace the running binary with the latest release
```

Run `ana --help` or `ana <verb> --help` for command-specific flags.

`ana` checks GitHub for a newer release after each verb and prints a one-line
stderr nudge when one exists. The result is cached for 4 h by default; set
`updateCheckInterval` in `config.json` (any `time.ParseDuration`-compatible
value) to change the cadence, or `"0"` / `"disable"` to turn the check off.
`--json` suppresses the nudge so automation pipelines aren't broken.

## Configuration

`ana` stores tokens and per-profile endpoints at
Expand Down
5 changes: 3 additions & 2 deletions cmd/ana/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ The `ana` binary's main package. Pure wiring: reads global flags + config, const

## Files

- `main.go` — `main` + `run` (the testable entrypoint with injectable args/stdio/env) and the `buildVerbs`/`authDeps`/`profileDeps`/`chatDeps` adapters. Also owns `newUUID` (used for chat `cellId`s) and the projection `profileToAuthConfig` that keeps `internal/auth` from importing `internal/config`.
- `main.go` — `main` + `run` (the testable entrypoint with injectable args/stdio/env) and the `buildVerbs`/`authDeps`/`profileDeps`/`chatDeps` adapters. Also owns `newUUID` (used for chat `cellId`s), the projection `profileToAuthConfig` that keeps `internal/auth` from importing `internal/config`, and the `startNudge`/`drainNudge` helpers that run the passive update-check goroutine in parallel with `cli.Dispatch`.
- `version.go` — the `version` leaf command plus the `version`/`commit`/`date` package vars that goreleaser stamps via `-ldflags "-X main.version=..."`. `--version` / `-V` is rewritten to the `version` verb up front so flag and subcommand share one rendering path.
- `main_test.go` — exercises `run` end-to-end with fakes (no live server) and asserts the verb-map shape, version banner, and all adapter closures.
- `update.go` — the `update` leaf command (`ana update`) that delegates to `internal/update.SelfUpdate` to download, verify, and replace the running binary.
- `main_test.go` — exercises `run` end-to-end with fakes (no live server) and asserts the verb-map shape, version banner, adapter closures, `startNudge` skip predicates, `drainNudge` branches, and the `update` help short-circuit.
78 changes: 77 additions & 1 deletion cmd/ana/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"time"
Expand All @@ -27,6 +29,7 @@ import (
"github.com/highperformance-tech/ana-cli/internal/playbook"
"github.com/highperformance-tech/ana-cli/internal/profile"
"github.com/highperformance-tech/ana-cli/internal/transport"
"github.com/highperformance-tech/ana-cli/internal/update"
)

func main() {
Expand Down Expand Up @@ -120,7 +123,79 @@ func run(args []string, stdio cli.IO, env func(string) string) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

return cli.Dispatch(ctx, verbs, args, stdio)
// Kick the passive update-check goroutine BEFORE Dispatch so the HTTP
// round-trip overlaps the verb's work. drainNudge picks it up after
// Dispatch returns. nudgeCh is nil whenever we decide not to check at
// all (dev build, --json, disabled interval, or no cache path); that
// nil flows through drainNudge as a no-op.
nudgeCh := startNudge(env, loaded, global)
err = cli.Dispatch(ctx, verbs, args, stdio)
drainNudge(nudgeCh, 500*time.Millisecond, err, stdio.Stderr)
return err
}

// startNudge launches the passive update-check goroutine when enabled and
// returns a buffered channel that drainNudge reads. Returns nil whenever the
// check is skipped so drainNudge can short-circuit without touching the
// channel.
//
// Skip predicates (any true disables the check):
// - version == "dev" — source checkout, no corresponding GitHub release.
// - global.JSON — automation pipeline, extra stderr line would break parsers.
// - ParseInterval reports disabled (config value "0" / "disable").
// - CachePath fails — no XDG_CACHE_HOME and no HOME means we have nowhere
// to stash freshness state, and we refuse to re-hit the network on every
// run.
func startNudge(env func(string) string, loaded config.Config, global cli.Global) chan string {
if version == "dev" || global.JSON {
return nil
}
ttl, enabled := update.ParseInterval(loaded.UpdateCheckInterval)
if !enabled {
return nil
}
if _, err := update.CachePath(env); err != nil {
return nil
}
ch := make(chan string, 1)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
tag, notify, _ := update.CachedCheck(ctx, update.CacheDeps{
Env: env,
Now: time.Now,
HTTP: http.DefaultClient,
}, ttl, version)
if notify {
ch <- fmt.Sprintf("A new version of ana-cli is available: v%s → %s Run: ana update", version, tag)
} else {
ch <- ""
}
}()
return ch
}

// drainNudge waits up to timeout for startNudge's goroutine to report. When
// it produces a non-empty message and the verb did not return a help/usage
// error, the message is written to stderr — we intentionally suppress the
// nudge on help/usage paths so help text doesn't get crowded by an upgrade
// prompt. A nil ch (check was skipped) or a timeout is a clean no-op.
func drainNudge(ch chan string, timeout time.Duration, verbErr error, stderr io.Writer) {
if ch == nil {
return
}
if errors.Is(verbErr, cli.ErrHelp) || errors.Is(verbErr, cli.ErrUsage) {
return
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case msg := <-ch:
if msg != "" {
fmt.Fprintln(stderr, msg)
}
case <-timer.C:
}
}

// buildVerbs wires every verb package's Deps against the shared transport
Expand All @@ -144,6 +219,7 @@ func buildVerbs(client *transport.Client, env func(string) string, cfgPath, prof
"feed": feed.New(feed.Deps{Unary: client.Unary}),
"audit": audit.New(audit.Deps{Unary: client.Unary, Now: time.Now}),
"version": versionCmd{},
"update": updateCmd{deps: update.DefaultDeps()},
}
}

Expand Down
106 changes: 104 additions & 2 deletions cmd/ana/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ func TestBuildVerbs_Shape(t *testing.T) {
t.Parallel()
client := transport.New("https://example", func(context.Context) (string, error) { return "", nil })
verbs := buildVerbs(client, func(string) string { return "" }, "", "default", "https://example")
want := []string{"auth", "profile", "org", "connector", "chat", "dashboard", "playbook", "ontology", "feed", "audit", "version"}
want := []string{"auth", "profile", "org", "connector", "chat", "dashboard", "playbook", "ontology", "feed", "audit", "version", "update"}
for _, v := range want {
if _, ok := verbs[v]; !ok {
t.Errorf("missing verb: %q", v)
Expand Down Expand Up @@ -436,7 +436,7 @@ func TestVersionCmd_Help(t *testing.T) {
var out bytes.Buffer
stdio := cli.IO{Stdout: &out, Stderr: &bytes.Buffer{}}
err := (versionCmd{}).Run(context.Background(), []string{"--help"}, stdio)
if err != cli.ErrHelp {
if !errors.Is(err, cli.ErrHelp) {
t.Fatalf("err = %v, want ErrHelp", err)
}
if !strings.Contains(out.String(), "Print ana version") {
Expand Down Expand Up @@ -536,6 +536,108 @@ func TestRun_LeafUsageErrorReturned(t *testing.T) {
}
}

// TestStartNudge_SkipConditions covers every reason startNudge returns nil:
// dev version, --json, interval disabled, no HOME/XDG. Each skip must short-
// circuit before the goroutine spawns, which we assert by the returned ch
// being nil.
func TestStartNudge_SkipConditions(t *testing.T) {
// Mutates the package-level version var — must not run in parallel with
// TestVersionCmd_PrintsBanner or TestRun_VersionFlag, both of which read
// it concurrently under -race.
prev := version
t.Cleanup(func() { version = prev })
envNone := func(string) string { return "" }
envHome := func(k string) string {
if k == "HOME" {
return t.TempDir()
}
return ""
}
disable := "disable"
cases := []struct {
name string
version string
env func(string) string
cfg config.Config
global cli.Global
}{
{"dev build", "dev", envHome, config.Config{}, cli.Global{}},
{"json output", "1.0.0", envHome, config.Config{}, cli.Global{JSON: true}},
{"disabled", "1.0.0", envHome, config.Config{UpdateCheckInterval: &disable}, cli.Global{}},
{"no home", "1.0.0", envNone, config.Config{}, cli.Global{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
version = tc.version
if got := startNudge(tc.env, tc.cfg, tc.global); got != nil {
t.Fatalf("expected nil channel, got %v", got)
}
})
}
}

// TestDrainNudge covers the four branches: nil channel, help-err suppression,
// non-empty message printed, and empty message (no print).
func TestDrainNudge(t *testing.T) {
t.Parallel()
t.Run("nil channel is a no-op", func(t *testing.T) {
var buf bytes.Buffer
drainNudge(nil, time.Millisecond, nil, &buf)
if buf.Len() != 0 {
t.Fatalf("stderr: %q", buf.String())
}
})
t.Run("help err suppresses", func(t *testing.T) {
ch := make(chan string, 1)
ch <- "should not print"
var buf bytes.Buffer
drainNudge(ch, time.Millisecond, cli.ErrHelp, &buf)
if buf.Len() != 0 {
t.Fatalf("stderr: %q", buf.String())
}
})
t.Run("message printed", func(t *testing.T) {
ch := make(chan string, 1)
ch <- "hello"
var buf bytes.Buffer
drainNudge(ch, time.Millisecond, nil, &buf)
if !strings.Contains(buf.String(), "hello") {
t.Fatalf("stderr: %q", buf.String())
}
})
t.Run("empty message swallowed", func(t *testing.T) {
ch := make(chan string, 1)
ch <- ""
var buf bytes.Buffer
drainNudge(ch, time.Millisecond, nil, &buf)
if buf.Len() != 0 {
t.Fatalf("stderr: %q", buf.String())
}
})
t.Run("timeout", func(t *testing.T) {
ch := make(chan string) // no sender
var buf bytes.Buffer
drainNudge(ch, 10*time.Millisecond, nil, &buf)
if buf.Len() != 0 {
t.Fatalf("stderr: %q", buf.String())
}
})
}

// TestUpdateCmd_Help short-circuits on --help like every other leaf verb.
func TestUpdateCmd_Help(t *testing.T) {
t.Parallel()
var out bytes.Buffer
stdio := cli.IO{Stdout: &out, Stderr: &bytes.Buffer{}}
err := (updateCmd{}).Run(context.Background(), []string{"--help"}, stdio)
if !errors.Is(err, cli.ErrHelp) {
t.Fatalf("err = %v, want ErrHelp", err)
}
if !strings.Contains(out.String(), "latest ana release") {
t.Fatalf("help body missing: %q", out.String())
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// TestRun_UnknownProfile drives the ErrUnknownProfile branch in run: a
// --profile pointing at a slot that doesn't exist (and no env fallback)
// must print the canonical error to stderr and exit 1 via ErrUsage.
Expand Down
35 changes: 35 additions & 0 deletions cmd/ana/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package main

import (
"context"
"errors"
"fmt"

"github.com/highperformance-tech/ana-cli/internal/cli"
"github.com/highperformance-tech/ana-cli/internal/update"
)

// updateCmd implements `ana update`: fetches the matching release archive,
// verifies its sha256, and atomically replaces the running binary. Mirrors
// versionCmd's leaf shape — deps are pulled in via the package-level default
// so cmd/ana keeps its "pure wiring" posture.
type updateCmd struct {
deps update.Deps
}

func (updateCmd) Help() string {
return "Download and install the latest ana release."
}

func (c updateCmd) Run(ctx context.Context, args []string, stdio cli.IO) error {
if len(args) > 0 && cli.IsHelpArg(args[0]) {
fmt.Fprintln(stdio.Stdout, updateCmd{}.Help())
return cli.ErrHelp
}
jsonOut := cli.GlobalFrom(ctx).JSON
if err := update.SelfUpdate(ctx, c.deps, version, stdio.Stdout, jsonOut); err != nil {
fmt.Fprintln(stdio.Stderr, err)
return errors.Join(err, cli.ErrReported)
}
return nil
}
2 changes: 1 addition & 1 deletion cmd/ana/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func (versionCmd) Help() string {
}

func (versionCmd) Run(_ context.Context, args []string, stdio cli.IO) error {
if len(args) > 0 && (args[0] == "-h" || args[0] == "--help" || args[0] == "help") {
if len(args) > 0 && cli.IsHelpArg(args[0]) {
fmt.Fprintln(stdio.Stdout, versionCmd{}.Help())
return cli.ErrHelp
}
Expand Down
4 changes: 4 additions & 0 deletions docs/cli-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Confidence key: ✅ full CRUD verified · 🟡 partial / readonly verified ·
| Packages | 🟡 | List only; what "install/uninstall" looks like is unknown. |
| Notifications | 🟡 | Streaming envelope not captured (`StreamNotifications`). |
| Feed | 🟡 | Same — `StreamFeed` not captured. |
| Self-update | ✅ | Passive check after every verb (4h cache, `--json` suppresses); `ana update` downloads + sha256-verifies + atomically replaces the running binary from the matching GoReleaser archive. |

## Enum catalog (incomplete but useful)

Expand Down Expand Up @@ -135,6 +136,9 @@ ana dashboard list / get <id> / spawn <id> / health <id>
ana ontology list / get <id>

ana audit tail # poll ListAuditLogs

ana update # replace the running binary with latest release
ana version # banner + build metadata
```

Anything beyond this (`ana dashboard create`, `ana playbook schedule`, etc.) needs a fresh probe — the RPCs exist but their request shapes are not in the catalog yet.
Expand Down
2 changes: 1 addition & 1 deletion e2e/harness/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Per-test scaffolding for live smoke tests against a real TextQL endpoint. Duplic

## Files

- `harness.go` — `H`, `Begin`, `End`. Per-test lifecycle with temp config, auth env, verb map, and cleanup stack.
- `harness.go` — `H`, `Begin`, `End`. Per-test lifecycle with temp config, auth env, verb map, and cleanup stack. Exposes `ExpectOrgID()` and `Endpoint()` so tests that assert endpoint/org-referencing stdout (e.g. OAuth callback URLs) read validated values instead of re-querying the env.
- `client.go` — mirrors `cmd/ana/main.go`'s verb builder so harness and binary share the same wiring shape.
- `guard.go` — wraps mutating RPCs: records them on the ledger before invoking, aborts if the pre-flight guard fails (wrong org, missing env, etc.).
- `ledger.go` — `ManualRevertLog` + `Record`/`Close`. Writes any unreverted mutation using `e2e/testdata/manual-revert.template.md`.
Expand Down
1 change: 1 addition & 0 deletions internal/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ Multi-file verb packages use one `<source>_test.go` per source file (e.g. `list.
| `ontology/` | `ana ontology` — readonly list/get. |
| `feed/` | `ana feed` — show + stats. |
| `audit/` | `ana audit tail` — audit-log listing with `--since`. Injectable clock. |
| `update/` | Passive update-check nudge + `ana update` self-update verb. Stdlib-only; 100% covered. |
2 changes: 1 addition & 1 deletion internal/cli/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Argument-dispatch core shared by every verb. Defines the `Command` interface, th

## Files

- `cli.go` — `Command`, `IO`, `DefaultIO`, `Group` (nested-verb dispatcher with auto-generated help listing; optional `Flags` closure declares group-level flags that descend to every leaf via `WithAncestorFlags`), `dispatchChild` (the Group/Dispatch handoff that scans a resolved leaf's args for `--help`/`-h` and renders its `Help()` before `Run` is called — and if the leaf implements `Flagger`, appends a `Flags:` block enumerating own + ancestor flags via `renderFlagsAsText`; Groups are skipped so the flag reaches the deepest leaf, and bare positional `help` is left alone so leaves can receive it as an argument), `Flagger` (opt-in interface: leaves that implement `Flags(fs)` get the ancestor-aware `Flags:` block in `--help`), and `renderFlagsAsText` (sorted `--name <type> usage (default: X)` enumeration). Precedence when ancestor and leaf declare the same name: **leaf wins**, because leaves call `ApplyAncestorFlags` AFTER declaring their own flags, and ancestor registrars use `DeclareString` / `DeclareBool` Lookup-guards that skip already-declared names (stdlib `flag.FlagSet.StringVar` panics on duplicate names, verified empirically).
- `cli.go` — `Command`, `IO`, `DefaultIO`, `Group` (nested-verb dispatcher with auto-generated help listing; optional `Flags` closure declares group-level flags that descend to every leaf via `WithAncestorFlags`), `dispatchChild` (the Group/Dispatch handoff that scans a resolved leaf's args for `--help`/`-h` and renders its `Help()` before `Run` is called — and if the leaf implements `Flagger`, appends a `Flags:` block enumerating own + ancestor flags via `renderFlagsAsText`; Groups are skipped so the flag reaches the deepest leaf, and bare positional `help` is left alone so leaves can receive it as an argument), `Flagger` (opt-in interface: leaves that implement `Flags(fs)` get the ancestor-aware `Flags:` block in `--help`), `IsHelpArg` (exported helper `cmd/ana` leaves reuse so the `-h`/`--help`/`help` check lives in one place), and `renderFlagsAsText` (sorted `--name <type> usage (default: X)` enumeration). Precedence when ancestor and leaf declare the same name: **leaf wins**, because leaves call `ApplyAncestorFlags` AFTER declaring their own flags, and ancestor registrars use `DeclareString` / `DeclareBool` Lookup-guards that skip already-declared names (stdlib `flag.FlagSet.StringVar` panics on duplicate names, verified empirically).
- `dispatch.go` — `Dispatch` (root entry: short-circuits help, parses globals, routes to the matching verb via `dispatchChild`) and `RootHelp`.
- `root.go` — `Global` shape, `WithGlobal`/`GlobalFrom` context helpers (both require a non-nil ctx per stdlib `context.WithValue` convention — nil panics), `ParseGlobal` (stdlib-style front-anchored parse: stops at the first positional), and `StripGlobals` (position-tolerant: walks argv once, consumes known global flags wherever they appear, passes everything else through in order so the leaf's FlagSet reports unknown-flag errors). The two share the authoritative `globalFlagRegistry` list — `TestGlobalFlagsRegistrySync` enforces that the registry matches `ParseGlobal`'s FlagSet shape. `Dispatch` uses `StripGlobals`; `cmd/ana/main.go`'s early config-resolution pre-pass uses it too so `ana org show --profile prod` honours `--profile` even when it's placed after the verb. `globalFlagsHelp` renders the canonical `Global Flags:` block that both `RootHelp` and the leaf `--help` path append so `--json`/`--endpoint`/`--token-file`/`--profile` are discoverable from every help surface. Phase 2 flag-registrar stack: `WithAncestorFlags(ctx, reg)` / `ApplyAncestorFlags(ctx, fs)` (context-carried slice of `func(*flag.FlagSet)` closures that `Group.Run` appends to and leaves replay on their own `FlagSet`), plus `DeclareString` / `DeclareBool` / `DeclareInt` (Lookup-guarded wrappers ancestor closures use instead of raw `StringVar` / `BoolVar` / `IntVar` to avoid the stdlib redeclaration panic when a leaf already declared the same name).
- `flags.go` — `ParseFlags`, which tolerates positional args interleaved with flags (stdlib `FlagSet.Parse` stops at the first non-flag, silently dropping later flags); `FlagWasSet`, the `fs.Visit` wrapper partial-update verbs use to tell "user left this alone" from "user explicitly passed the zero value"; `RequireFlags`, which emits a single sorted `missing required flags: --a, --b` usage error for any name not explicitly set on fs; and three typed `flag.Value` constructors — `EnumFlag` (allow-list validation at parse time), `IntListFlag` (CSV → `[]int` with whitespace tolerance), `SinceFlag` (accepts non-negative `time.ParseDuration` or RFC3339, stored UTC via an injected clock). The stdlib `flag.Parse` re-wraps `Set` errors with `%v`, so the `ErrUsage` chain survives only through the outer `ParseFlags` wrap — tests that exercise these helpers must go through `ParseFlags`, not bare `fs.Parse`.
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ type Group struct {
// before delegating so every descendant leaf can ApplyAncestorFlags and pick
// up the group's declared flags.
func (g *Group) Run(ctx context.Context, args []string, stdio IO) error {
if len(args) == 0 || isHelpArg(args[0]) {
if len(args) == 0 || IsHelpArg(args[0]) {
fmt.Fprintln(stdio.Stdout, g.Help())
return ErrHelp
}
Expand Down Expand Up @@ -207,8 +207,8 @@ func (g *Group) Help() string {
return strings.TrimRight(b.String(), "\n")
}

// isHelpArg reports whether s is one of the recognized help tokens.
func isHelpArg(s string) bool {
// IsHelpArg reports whether s is one of the recognized help tokens.
func IsHelpArg(s string) bool {
return s == "-h" || s == "--help" || s == "help"
}

Expand Down
Loading
Loading