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
56 changes: 56 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# GoClaw CLI

Go CLI for managing GoClaw AI agent gateway servers.

## Tech Stack

- **Language:** Go 1.25
- **CLI:** Cobra (commands) + Viper-style config
- **Transport:** HTTP REST + WebSocket RPC (gorilla/websocket)
- **Config:** `~/.goclaw/config.yaml` + env vars + flags

## Build & Test

```bash
go build ./... # Compile check
go vet ./... # Static analysis
go test ./... # Run all tests
go test -count=1 ./... # Skip test cache
make build # Build binary with ldflags
make install # Install to GOPATH/bin
```

## Project Structure

```
cmd/ # Cobra command files (1 per resource group)
internal/
├── client/ # HTTP + WebSocket + auth clients
├── config/ # Config loader (~/.goclaw/)
├── output/ # Table/JSON/YAML formatters
└── tui/ # Interactive prompts
```

## Conventions

- Go snake_case file naming
- Cobra command pattern: register in `init()`, implement as `RunE`
- Config precedence: flags > env vars > config file
- Token stored in credential store (not config.yaml)
- All destructive ops require `--yes` or interactive confirmation
- Dual mode: interactive (table output) + automation (JSON/YAML)

## Key Patterns

- `newHTTP()` / `newWS()` — create authenticated clients from global config
- `buildBody()` — construct request body from flag values, skip empty
- `readContent()` — read from `@filepath` or literal string
- `unmarshalMap()` / `unmarshalList()` — parse JSON responses
- `printer.Print()` — output in configured format

## Testing

- Unit tests in `*_test.go` alongside source
- Use `httptest.NewServer` for HTTP client tests
- Use gorilla/websocket upgrader for WS tests
- No CGO race detector on Windows (use Linux CI)
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

---

## [Unreleased] — Domain Coverage Expansion (P0–P5)
## [Unreleased] — Domain Coverage Expansion (P0–P6)

### Added

Expand Down Expand Up @@ -46,6 +46,19 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `goclaw agents evolution skill apply <agent-id> <suggestion-id> [--skill-draft @file]` — explicit wrapper for approving `skill_add` suggestions through the server evolution approval route.
- `goclaw agents evolution update` now maps `--action=accept|reject` to the server-compatible `status=approved|rejected` payload.

**P6 — Backend-unblocked surfaces (gateway `v3.12.0-beta.20`+)**
- `goclaw traces follow --session-key|--agent [--since RFC3339] [--limit N]` — one-shot incremental trace polling (`GET /v1/traces/follow`). Re-invoke with returned cursor to advance; no WS stream, no watch loop.
- `goclaw providers reconnect <provider-id>` — hot-reconnect a provider, bumping the registry without touching credentials (`POST /v1/providers/{id}/reconnect`).
- `goclaw sessions branch <session-key> --up-to-index N [--new-session-key K] [--label L] [--metadata k=v ...]` — branch a chat session at a 1-based message index into a new session (`POST /v1/chat/sessions/{key}/branch`). `--up-to-index=0` is preserved on the wire.
- `goclaw sessions follow <session-key> [--cursor N] [--limit N]` — one-shot cursor-based history poll (`GET /v1/chat/sessions/{key}/history/follow`). Not a stream; `--cursor=0` is preserved literally in the query string.
- `goclaw channels writers test <instance-id> --group-id G --user-id U` — probe a (group, user) pair against a channel's writer policy without mutating state (`POST /v1/channels/instances/{id}/writers/test`). Request body has exactly two keys.
- `goclaw activity aggregate --group-by {action|actor_type|entity_type|actor_id} [--from --to --limit --actor-type --actor-id --action --entity-type --entity-id]` — group audit-log activity by dimension with bucket counts (`GET /v1/activity/aggregate`). Attached as subcommand of existing `activity` parent.
- `goclaw logs aggregate [--group-by {level|source}] [--level --source --from]` — summarize the runtime log ring buffer (`GET /v1/logs/runtime/aggregate`, admin-only). Distinct from `logs tail`. Epoch-millis `last_seen` rendered as RFC3339, never scientific notation.

### Fixed

- `goclaw traces get <id>` — TTY mode now renders a human-readable summary (header card + span tree + events list) instead of dumping raw JSON. JSON-mode payload unchanged. Decode failures surface as wrapped errors instead of an empty `{}`. Trace ids are validated against `^[A-Za-z0-9._-]+$` and reserved tokens (`.`, `..`) are rejected before any HTTP call. Distinct exit codes per failure: not-found → 3, permission-denied → 2, malformed-id → 4, server-failure → 5. Latent retry-body bug in `internal/client/http.go` fixed: the final 5xx/429 response body is now preserved so the typed `APIError` reaches the caller (previously collapsed to exit 1). Closes #17.

### Notes
- All new commands honor the AI-first ergonomics contract: `--output=json` envelope, central error handler, `--yes` for destructive ops, `--quiet` for CI.
- P4/P5 backlog was re-swept against the current CLI surface; already-covered items were removed from residual scope before implementation.
Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,51 @@ echo "Analyze this log" | goclaw chat myagent
| `restore` | System/tenant restore from backup archive |
| `vault` | Knowledge Vault — documents, links, search, graph, enrichment |

### Backend-Unblocked Surfaces (P6)

Seven one-shot subcommands wired to backend PRs `#37` and `#44`:

```bash
# Incremental trace polling (one shot; rerun with returned cursor)
goclaw traces follow --session-key <key> [--since <RFC3339>] [--limit <n>]
goclaw traces follow --agent <id> [--since <RFC3339>] [--limit <n>]

# Provider hot-reconnect (bumps registry without recreating credentials)
goclaw providers reconnect <provider-id>

# Branch a chat session at a message index
goclaw sessions branch <session-key> --up-to-index <N> [--new-session-key <k>] \
[--label <l>] [--metadata k=v ...]

# One-shot session-history poll (cursor-based; not a stream)
goclaw sessions follow <session-key> [--cursor <n>] [--limit <n>]

# Probe a (group, user) pair against a channel's writer policy
goclaw channels writers test <instance-id> --group-id <g> --user-id <u>

# Aggregate audit-log activity by dimension
goclaw activity aggregate --group-by <action|actor_type|entity_type|actor_id> \
[--from <RFC3339>] [--to <RFC3339>] [--limit <n>] \
[--actor-type <v>] [--actor-id <v>] [--action <v>] [--entity-type <v>] [--entity-id <v>]

# Summarize the runtime log ring buffer (NOT a stream — see 'logs tail' for that)
goclaw logs aggregate [--group-by <level|source>] [--level <l>] [--source <s>] [--from <RFC3339>]
```

All are one-shot HTTP — no watch loops or WS streams. `logs aggregate` is admin-only on the server; `activity aggregate --group-by actor_id` is also admin-only (server-enforced).

### Reading a Trace by ID

```bash
# Human-readable: header + span tree + events
goclaw traces get <trace-id>

# Machine-readable JSON (also auto-selected when stdout is piped)
goclaw traces get <trace-id> -o json
```

Exit codes for `traces get`: `0` on success, `2` on permission denied, `3` on not-found, `4` on malformed id (rejected before any HTTP call — allowlist `^[A-Za-z0-9._-]+$`), `5` on upstream server failure, `6` on rate-limit / network-resource exhaustion.

## Backup & Restore

### System Backup
Expand Down
158 changes: 158 additions & 0 deletions cmd/activity_aggregate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package cmd

import (
"fmt"
"net/url"
"time"

"github.com/nextlevelbuilder/goclaw-cli/internal/output"
"github.com/spf13/cobra"
)

// validActivityGroupBy enumerates allowed --group-by values for the activity
// aggregate endpoint. Server enforces admin-only for actor_id; the CLI does
// not pre-check that — it only validates the enum.
var validActivityGroupBy = map[string]bool{
"action": true,
"actor_type": true,
"entity_type": true,
"actor_id": true,
}

// formatLastSeen renders an aggregate bucket's last_seen field as RFC3339.
//
// The activity aggregate endpoint returns last_seen as an RFC3339 string,
// but the logs runtime aggregate endpoint returns last_seen as epoch millis
// (a number). `unmarshalMap` decodes JSON numbers as float64, and the shared
// `str()` helper renders large float64 as scientific notation (e.g.
// "1.76e+12"). This helper type-switches so both endpoints render
// consistently as RFC3339 strings in the table view.
func formatLastSeen(v any) string {
switch t := v.(type) {
case nil:
return "-"
case string:
if t == "" {
return "-"
}
return t
case float64:
if t == 0 {
return "-"
}
return time.UnixMilli(int64(t)).UTC().Format(time.RFC3339)
case int64:
if t == 0 {
return "-"
}
return time.UnixMilli(t).UTC().Format(time.RFC3339)
case int:
if t == 0 {
return "-"
}
return time.UnixMilli(int64(t)).UTC().Format(time.RFC3339)
default:
return fmt.Sprintf("%v", v)
}
}

// activityAggregateCmd groups audit-log activity by a dimension and returns
// bucket counts. Attached as a subcommand of the existing activityCmd
// (declared in cmd/admin.go) so the top-level command surface is unchanged.
//
// Backend route: GET /v1/activity/aggregate
var activityAggregateCmd = &cobra.Command{
Use: "aggregate",
Short: "Aggregate audit-log activity by a grouping dimension",
Long: `Group activity log entries by a dimension (action, actor_type, entity_type,
or actor_id) and return bucket counts with last_seen timestamps.

Optional filters narrow the result set: --from/--to (RFC3339 window),
--actor-type, --actor-id, --action, --entity-type, --entity-id, --limit.

Backend route: GET /v1/activity/aggregate
Note: --group-by=actor_id requires admin privileges (enforced server-side).`,
RunE: func(cmd *cobra.Command, args []string) error {
groupBy, _ := cmd.Flags().GetString("group-by")
if groupBy == "" {
return fmt.Errorf("--group-by is required (one of action, actor_type, entity_type, actor_id)")
}
if !validActivityGroupBy[groupBy] {
return fmt.Errorf("--group-by must be one of action, actor_type, entity_type, actor_id (got %q)", groupBy)
}
from, _ := cmd.Flags().GetString("from")
if from != "" {
if _, err := time.Parse(time.RFC3339, from); err != nil {
return fmt.Errorf("--from must be RFC3339: %w", err)
}
}
to, _ := cmd.Flags().GetString("to")
if to != "" {
if _, err := time.Parse(time.RFC3339, to); err != nil {
return fmt.Errorf("--to must be RFC3339: %w", err)
}
}

q := url.Values{}
q.Set("group_by", groupBy)
if from != "" {
q.Set("from", from)
}
if to != "" {
q.Set("to", to)
}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
q.Set("limit", fmt.Sprintf("%d", v))
}
for flagName, queryKey := range map[string]string{
"actor-type": "actor_type",
"actor-id": "actor_id",
"action": "action",
"entity-type": "entity_type",
"entity-id": "entity_id",
} {
if v, _ := cmd.Flags().GetString(flagName); v != "" {
q.Set(queryKey, v)
}
}

c, err := newHTTP()
if err != nil {
return err
}
data, err := c.Get("/v1/activity/aggregate?" + q.Encode())
if err != nil {
return err
}
m := unmarshalMap(data)
if cfg.OutputFormat != "table" {
printer.Print(m)
return nil
}
buckets, _ := m["buckets"].([]any)
tbl := output.NewTable("KEY", "COUNT", "LAST_SEEN")
for _, raw := range buckets {
row, ok := raw.(map[string]any)
if !ok {
continue
}
tbl.AddRow(str(row, "key"), str(row, "count"), formatLastSeen(row["last_seen"]))
}
printer.Print(tbl)
return nil
},
}

func init() {
activityAggregateCmd.Flags().String("group-by", "", "Grouping dimension: action | actor_type | entity_type | actor_id (required)")
activityAggregateCmd.Flags().String("from", "", "RFC3339 start of time window")
activityAggregateCmd.Flags().String("to", "", "RFC3339 end of time window")
activityAggregateCmd.Flags().Int("limit", 0, "Maximum buckets to return (server default applied if 0)")
activityAggregateCmd.Flags().String("actor-type", "", "Filter by actor type")
activityAggregateCmd.Flags().String("actor-id", "", "Filter by actor id")
activityAggregateCmd.Flags().String("action", "", "Filter by action")
activityAggregateCmd.Flags().String("entity-type", "", "Filter by entity type")
activityAggregateCmd.Flags().String("entity-id", "", "Filter by entity id")
_ = activityAggregateCmd.MarkFlagRequired("group-by")
activityCmd.AddCommand(activityAggregateCmd)
}
Loading
Loading