diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6a5a440 --- /dev/null +++ b/AGENTS.md @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b26ed..b969e1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -46,6 +46,19 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `goclaw agents evolution skill apply [--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 ` — hot-reconnect a provider, bumping the registry without touching credentials (`POST /v1/providers/{id}/reconnect`). +- `goclaw sessions branch --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 [--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 --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 ` — 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. diff --git a/README.md b/README.md index db99c34..43a1a4d 100644 --- a/README.md +++ b/README.md @@ -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 [--since ] [--limit ] +goclaw traces follow --agent [--since ] [--limit ] + +# Provider hot-reconnect (bumps registry without recreating credentials) +goclaw providers reconnect + +# Branch a chat session at a message index +goclaw sessions branch --up-to-index [--new-session-key ] \ + [--label ] [--metadata k=v ...] + +# One-shot session-history poll (cursor-based; not a stream) +goclaw sessions follow [--cursor ] [--limit ] + +# Probe a (group, user) pair against a channel's writer policy +goclaw channels writers test --group-id --user-id + +# Aggregate audit-log activity by dimension +goclaw activity aggregate --group-by \ + [--from ] [--to ] [--limit ] \ + [--actor-type ] [--actor-id ] [--action ] [--entity-type ] [--entity-id ] + +# Summarize the runtime log ring buffer (NOT a stream — see 'logs tail' for that) +goclaw logs aggregate [--group-by ] [--level ] [--source ] [--from ] +``` + +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 + +# Machine-readable JSON (also auto-selected when stdout is piped) +goclaw traces get -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 diff --git a/cmd/activity_aggregate.go b/cmd/activity_aggregate.go new file mode 100644 index 0000000..15405a8 --- /dev/null +++ b/cmd/activity_aggregate.go @@ -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) +} diff --git a/cmd/activity_aggregate_test.go b/cmd/activity_aggregate_test.go new file mode 100644 index 0000000..b2add55 --- /dev/null +++ b/cmd/activity_aggregate_test.go @@ -0,0 +1,186 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func resetActivityAggregateFlags(t *testing.T) { + t.Helper() + for _, name := range []string{"group-by", "from", "to", "actor-type", "actor-id", "action", "entity-type", "entity-id"} { + resetTestFlag(activityAggregateCmd, name, "") + } + resetTestFlag(activityAggregateCmd, "limit", "0") +} + +func TestActivityAggregate_MissingGroupBy(t *testing.T) { + t.Cleanup(func() { resetActivityAggregateFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "activity", "aggregate") + if err == nil { + t.Fatal("expected error for missing --group-by") + } +} + +func TestActivityAggregate_InvalidGroupBy(t *testing.T) { + t.Cleanup(func() { resetActivityAggregateFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "activity", "aggregate", "--group-by=foo") + if err == nil { + t.Fatal("expected error for invalid --group-by=foo") + } +} + +func TestActivityAggregate_ValidGroupByValues(t *testing.T) { + for _, gb := range []string{"action", "actor_type", "entity_type", "actor_id"} { + gb := gb + t.Run(gb, func(t *testing.T) { + t.Cleanup(func() { resetActivityAggregateFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{"source": "activity", "group_by": gb, "total": 0, "buckets": []any{}}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + if err := runCmd(t, "activity", "aggregate", "--group-by="+gb); err != nil { + t.Fatalf("group-by=%s: %v", gb, err) + } + }) + } +} + +func TestActivityAggregate_InvalidFromRFC3339(t *testing.T) { + t.Cleanup(func() { resetActivityAggregateFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "activity", "aggregate", "--group-by=action", "--from=not-a-date") + if err == nil { + t.Fatal("expected error for non-RFC3339 --from") + } +} + +func TestActivityAggregate_InvalidToRFC3339(t *testing.T) { + t.Cleanup(func() { resetActivityAggregateFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "activity", "aggregate", "--group-by=action", "--to=tomorrow") + if err == nil { + t.Fatal("expected error for non-RFC3339 --to") + } +} + +func TestActivityAggregate_QueryStringHasFilters(t *testing.T) { + t.Cleanup(func() { resetActivityAggregateFlags(t) }) + + var rawQuery, path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + rawQuery = r.URL.RawQuery + okJSON(t, w, map[string]any{"source": "activity", "group_by": "action", "total": 0, "buckets": []any{}}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "activity", "aggregate", + "--group-by=action", + "--from=2026-05-01T00:00:00Z", + "--to=2026-05-27T00:00:00Z", + "--limit=25", + "--actor-type=user", + "--actor-id=u1", + "--action=session.branch", + "--entity-type=session", + "--entity-id=sess-1", + ); err != nil { + t.Fatalf("activity aggregate: %v", err) + } + if path != "/v1/activity/aggregate" { + t.Fatalf("path = %q", path) + } + q, err := url.ParseQuery(rawQuery) + if err != nil { + t.Fatalf("parse query: %v", err) + } + want := map[string]string{ + "group_by": "action", + "from": "2026-05-01T00:00:00Z", + "to": "2026-05-27T00:00:00Z", + "limit": "25", + "actor_type": "user", + "actor_id": "u1", + "action": "session.branch", + "entity_type": "session", + "entity_id": "sess-1", + } + for k, v := range want { + if got := q.Get(k); got != v { + t.Errorf("query[%s] = %q, want %q (full raw: %s)", k, got, v, rawQuery) + } + } +} + +func TestActivityAggregate_JSONPreservesFields(t *testing.T) { + t.Cleanup(func() { resetActivityAggregateFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "source": "activity", + "group_by": "action", + "total": 10, + "buckets": []map[string]any{ + {"key": "session.branch", "count": 7, "last_seen": "2026-05-27T11:00:00Z"}, + }, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "json") + + out, err := captureStdout(t, func() error { + return runCmd(t, "activity", "aggregate", "--group-by=action") + }) + if err != nil { + t.Fatalf("activity aggregate: %v", err) + } + for _, want := range []string{"source", "group_by", "total", "buckets"} { + if !strings.Contains(out, want) { + t.Errorf("stdout missing %q in: %s", want, out) + } + } +} + +func TestActivityAggregate_TableHeaders(t *testing.T) { + t.Cleanup(func() { resetActivityAggregateFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "source": "activity", + "group_by": "action", + "total": 1, + "buckets": []map[string]any{ + {"key": "session.branch", "count": 1, "last_seen": "2026-05-27T11:00:00Z"}, + }, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "table") + + out, err := captureStdout(t, func() error { + return runCmd(t, "activity", "aggregate", "--group-by=action") + }) + if err != nil { + t.Fatalf("activity aggregate: %v", err) + } + for _, want := range []string{"KEY", "COUNT", "LAST_SEEN", "session.branch"} { + if !strings.Contains(out, want) { + t.Errorf("table missing %q in:\n%s", want, out) + } + } +} diff --git a/cmd/channels_writers.go b/cmd/channels_writers.go index f789ad9..04b1228 100644 --- a/cmd/channels_writers.go +++ b/cmd/channels_writers.go @@ -1,6 +1,9 @@ package cmd import ( + "net/url" + + "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/spf13/cobra" ) @@ -79,11 +82,61 @@ var channelsWritersRemoveCmd = &cobra.Command{ }, } +// channelsWritersTestCmd probes whether a (group, user) pair is permitted to +// write into a channel instance. Body is POSTed with exactly two keys +// (group_id, user_id) — no extra fields, so the server's contract stays tight. +// +// Backend route: POST /v1/channels/instances/{id}/writers/test +var channelsWritersTestCmd = &cobra.Command{ + Use: "test ", + Short: "Test whether a (group, user) pair is allowed to write", + Long: `Probe a channel instance's writer policy for a specific group/user pair +without mutating state. + +Backend route: POST /v1/channels/instances/{id}/writers/test`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + groupID, _ := cmd.Flags().GetString("group-id") + userID, _ := cmd.Flags().GetString("user-id") + // Body has exactly two keys — construct directly so no other flags + // (e.g. accidental future additions) leak into the request. + body := map[string]any{ + "group_id": groupID, + "user_id": userID, + } + c, err := newHTTP() + if err != nil { + return err + } + path := "/v1/channels/instances/" + url.PathEscape(args[0]) + "/writers/test" + data, err := c.Post(path, body) + if err != nil { + return err + } + m := unmarshalMap(data) + if cfg.OutputFormat != "table" { + printer.Print(m) + return nil + } + tbl := output.NewTable("ALLOWED", "REASON", "WRITER_COUNT", "GROUP_ID", "USER_ID") + tbl.AddRow(str(m, "allowed"), str(m, "reason"), str(m, "writer_count"), + str(m, "group_id"), str(m, "user_id")) + printer.Print(tbl) + return nil + }, +} + func init() { channelsWritersAddCmd.Flags().String("user", "", "User ID") channelsWritersAddCmd.Flags().String("display-name", "", "Display name") _ = channelsWritersAddCmd.MarkFlagRequired("user") channelsWritersRemoveCmd.Flags().String("user", "", "User ID") _ = channelsWritersRemoveCmd.MarkFlagRequired("user") - channelsWritersCmd.AddCommand(channelsWritersListCmd, channelsWritersGroupsCmd, channelsWritersAddCmd, channelsWritersRemoveCmd) + + channelsWritersTestCmd.Flags().String("group-id", "", "Group identifier (required)") + channelsWritersTestCmd.Flags().String("user-id", "", "User identifier (required)") + _ = channelsWritersTestCmd.MarkFlagRequired("group-id") + _ = channelsWritersTestCmd.MarkFlagRequired("user-id") + + channelsWritersCmd.AddCommand(channelsWritersListCmd, channelsWritersGroupsCmd, channelsWritersAddCmd, channelsWritersRemoveCmd, channelsWritersTestCmd) } diff --git a/cmd/channels_writers_test_test.go b/cmd/channels_writers_test_test.go new file mode 100644 index 0000000..fe788ea --- /dev/null +++ b/cmd/channels_writers_test_test.go @@ -0,0 +1,160 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func resetChannelsWritersTestFlags(t *testing.T) { + t.Helper() + for _, name := range []string{"group-id", "user-id"} { + resetTestFlag(channelsWritersTestCmd, name, "") + } +} + +func TestChannelsWritersTest_BodyShape(t *testing.T) { + t.Cleanup(func() { resetChannelsWritersTestFlags(t) }) + + var gotPath, gotMethod string + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + okJSON(t, w, map[string]any{ + "allowed": true, + "reason": "writer", + "instance_id": "inst-1", + "agent_id": "agent-1", + "group_id": "group:telegram:-100123", + "user_id": "386246614", + "writer_count": 3, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "channels", "writers", "test", "inst-1", + "--group-id=group:telegram:-100123", + "--user-id=386246614"); err != nil { + t.Fatalf("channels writers test: %v", err) + } + if gotMethod != http.MethodPost { + t.Fatalf("method = %q", gotMethod) + } + if gotPath != "/v1/channels/instances/inst-1/writers/test" { + t.Fatalf("path = %q", gotPath) + } + if body["group_id"] != "group:telegram:-100123" || body["user_id"] != "386246614" { + t.Fatalf("body fields wrong: %#v", body) + } + // Body must contain ONLY these two keys. + if len(body) != 2 { + t.Fatalf("body has extra keys: %#v", body) + } +} + +func TestChannelsWritersTest_MissingGroupID(t *testing.T) { + t.Cleanup(func() { resetChannelsWritersTestFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "channels", "writers", "test", "inst-1", "--user-id=u1") + if err == nil { + t.Fatal("expected error for missing --group-id") + } +} + +func TestChannelsWritersTest_MissingUserID(t *testing.T) { + t.Cleanup(func() { resetChannelsWritersTestFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "channels", "writers", "test", "inst-1", "--group-id=g1") + if err == nil { + t.Fatal("expected error for missing --user-id") + } +} + +func TestChannelsWritersTest_PathEscape(t *testing.T) { + t.Cleanup(func() { resetChannelsWritersTestFlags(t) }) + + var rawPath, path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawPath = r.URL.RawPath + path = r.URL.Path + okJSON(t, w, map[string]any{"allowed": true, "reason": "writer", "writer_count": 1}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "channels", "writers", "test", "weird/id:1", "--group-id=g1", "--user-id=u1"); err != nil { + t.Fatalf("channels writers test: %v", err) + } + if !strings.Contains(rawPath, "weird%2Fid%3A1") && !strings.Contains(path, "weird/id:1") { + t.Fatalf("path not escaped — RawPath=%q Path=%q", rawPath, path) + } +} + +func TestChannelsWritersTest_JSONOutput(t *testing.T) { + t.Cleanup(func() { resetChannelsWritersTestFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "allowed": false, + "reason": "not_writer", + "writer_count": 2, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "json") + + out, err := captureStdout(t, func() error { + return runCmd(t, "channels", "writers", "test", "inst-1", "--group-id=g1", "--user-id=u1") + }) + if err != nil { + t.Fatalf("channels writers test: %v", err) + } + for _, want := range []string{"allowed", "reason", "writer_count"} { + if !strings.Contains(out, want) { + t.Errorf("stdout missing %q in: %s", want, out) + } + } +} + +func TestChannelsWritersTest_TableHeaders(t *testing.T) { + t.Cleanup(func() { resetChannelsWritersTestFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "allowed": true, + "reason": "writer", + "writer_count": 3, + "group_id": "g1", + "user_id": "u1", + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "table") + + out, err := captureStdout(t, func() error { + return runCmd(t, "channels", "writers", "test", "inst-1", "--group-id=g1", "--user-id=u1") + }) + if err != nil { + t.Fatalf("channels writers test: %v", err) + } + for _, want := range []string{"ALLOWED", "REASON", "WRITER_COUNT", "GROUP_ID", "USER_ID"} { + if !strings.Contains(out, want) { + t.Errorf("table missing header %q in:\n%s", want, out) + } + } +} diff --git a/cmd/logs_aggregate.go b/cmd/logs_aggregate.go new file mode 100644 index 0000000..f994b90 --- /dev/null +++ b/cmd/logs_aggregate.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "fmt" + "net/url" + "time" + + "github.com/nextlevelbuilder/goclaw-cli/internal/output" + "github.com/spf13/cobra" +) + +// validLogsGroupBy enumerates allowed --group-by values for the runtime logs +// aggregate endpoint. +var validLogsGroupBy = map[string]bool{ + "level": true, + "source": true, +} + +// logsAggregateCmd queries the runtime log ring-buffer aggregate endpoint. +// Distinct from `logs tail` (WS streaming): this is a one-shot HTTP GET that +// summarizes the in-memory ring buffer by level or source. +// +// Backend route: GET /v1/logs/runtime/aggregate (admin-only on server side). +var logsAggregateCmd = &cobra.Command{ + Use: "aggregate", + Short: "Summarize runtime logs (ring buffer) by level or source", + Long: `Aggregate the in-memory runtime log ring buffer by --group-by (level or +source). This is a one-shot HTTP query — not a stream. Use 'logs tail' for +real-time streaming. + +Backend route: GET /v1/logs/runtime/aggregate (admin-only, server-enforced).`, + RunE: func(cmd *cobra.Command, args []string) error { + groupBy, _ := cmd.Flags().GetString("group-by") + if groupBy == "" { + groupBy = "level" + } + if !validLogsGroupBy[groupBy] { + return fmt.Errorf("--group-by must be one of level, source (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) + } + } + + q := url.Values{} + q.Set("group_by", groupBy) + for flagName, queryKey := range map[string]string{ + "level": "level", + "source": "source", + "from": "from", + } { + if v, _ := cmd.Flags().GetString(flagName); v != "" { + q.Set(queryKey, v) + } + } + + c, err := newHTTP() + if err != nil { + return err + } + data, err := c.Get("/v1/logs/runtime/aggregate?" + q.Encode()) + if err != nil { + return err + } + m := unmarshalMap(data) + if cfg.OutputFormat != "table" { + printer.Print(m) + return nil + } + // Summary row (source, retention, capacity, sample_size). + summary := output.NewTable("SOURCE", "RETENTION", "CAPACITY", "SAMPLE_SIZE") + summary.AddRow(str(m, "source"), str(m, "retention"), + str(m, "capacity"), str(m, "sample_size")) + printer.Print(summary) + // Bucket rows. + 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() { + logsAggregateCmd.Flags().String("group-by", "level", "Grouping dimension: level | source (default level)") + logsAggregateCmd.Flags().String("level", "", "Filter by level: debug | info | warn | error") + logsAggregateCmd.Flags().String("source", "", "Filter by source") + logsAggregateCmd.Flags().String("from", "", "RFC3339 start of time window") + logsCmd.AddCommand(logsAggregateCmd) +} diff --git a/cmd/logs_aggregate_test.go b/cmd/logs_aggregate_test.go new file mode 100644 index 0000000..7709c75 --- /dev/null +++ b/cmd/logs_aggregate_test.go @@ -0,0 +1,224 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" +) + +func resetLogsAggregateFlags(t *testing.T) { + t.Helper() + resetTestFlag(logsAggregateCmd, "group-by", "level") + for _, name := range []string{"level", "source", "from"} { + resetTestFlag(logsAggregateCmd, name, "") + } +} + +func TestLogsAggregate_DefaultGroupBy(t *testing.T) { + t.Cleanup(func() { resetLogsAggregateFlags(t) }) + + var path, rawQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + rawQuery = r.URL.RawQuery + okJSON(t, w, map[string]any{ + "source": "runtime", + "retention": "ring_buffer", + "capacity": 100, + "sample_size": 0, + "group_by": "level", + "buckets": []any{}, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "logs", "aggregate"); err != nil { + t.Fatalf("logs aggregate: %v", err) + } + if path != "/v1/logs/runtime/aggregate" { + t.Fatalf("path = %q", path) + } + q, _ := url.ParseQuery(rawQuery) + // Default group_by=level should appear in query. + if q.Get("group_by") != "level" { + t.Errorf("expected group_by=level in raw query, got: %q", rawQuery) + } +} + +func TestLogsAggregate_InvalidGroupBy(t *testing.T) { + t.Cleanup(func() { resetLogsAggregateFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "logs", "aggregate", "--group-by=foo") + if err == nil { + t.Fatal("expected error for invalid --group-by=foo") + } +} + +func TestLogsAggregate_QueryStringFilters(t *testing.T) { + t.Cleanup(func() { resetLogsAggregateFlags(t) }) + + var rawQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawQuery = r.URL.RawQuery + okJSON(t, w, map[string]any{ + "source": "runtime", "retention": "ring_buffer", "capacity": 100, + "sample_size": 0, "group_by": "source", "buckets": []any{}, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "logs", "aggregate", + "--group-by=source", + "--level=warn", + "--source=router", + "--from=2026-05-01T00:00:00Z", + ); err != nil { + t.Fatalf("logs aggregate: %v", err) + } + q, _ := url.ParseQuery(rawQuery) + want := map[string]string{ + "group_by": "source", + "level": "warn", + "source": "router", + "from": "2026-05-01T00:00:00Z", + } + for k, v := range want { + if got := q.Get(k); got != v { + t.Errorf("query[%s] = %q, want %q (raw: %s)", k, got, v, rawQuery) + } + } +} + +func TestLogsAggregate_InvalidFromRFC3339(t *testing.T) { + t.Cleanup(func() { resetLogsAggregateFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "logs", "aggregate", "--from=not-a-date") + if err == nil { + t.Fatal("expected error for non-RFC3339 --from") + } +} + +func TestLogsAggregate_JSONPreservesFields(t *testing.T) { + t.Cleanup(func() { resetLogsAggregateFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "source": "runtime", + "retention": "ring_buffer", + "capacity": 100, + "sample_size": 25, + "group_by": "level", + "buckets": []map[string]any{ + {"key": "warn", "count": 3, "last_seen": 1760000000000}, + }, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "json") + + out, err := captureStdout(t, func() error { + return runCmd(t, "logs", "aggregate") + }) + if err != nil { + t.Fatalf("logs aggregate: %v", err) + } + for _, want := range []string{"retention", "capacity", "sample_size"} { + if !strings.Contains(out, want) { + t.Errorf("stdout missing %q in: %s", want, out) + } + } +} + +// Numeric-last_seen rendering regression: the runtime logs endpoint returns +// last_seen as epoch millis (a JSON number), which json.Unmarshal decodes as +// float64. Naively rendering with fmt.Sprintf("%v", ...) produces scientific +// notation (e.g. "1.76e+12") for large numbers — useless in a table. The +// formatLastSeen helper must type-switch and emit RFC3339. +func TestLogsAggregate_LastSeenRendersRFC3339(t *testing.T) { + t.Cleanup(func() { resetLogsAggregateFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "source": "runtime", + "retention": "ring_buffer", + "capacity": 100, + "sample_size": 1, + "group_by": "level", + "buckets": []map[string]any{ + {"key": "warn", "count": 3, "last_seen": 1760000000000}, + }, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "table") + + out, err := captureStdout(t, func() error { + return runCmd(t, "logs", "aggregate") + }) + if err != nil { + t.Fatalf("logs aggregate: %v", err) + } + if strings.Contains(out, "e+12") || strings.Contains(out, "e+11") { + t.Errorf("LAST_SEEN must not render in scientific notation:\n%s", out) + } + rfc3339 := regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`) + if !rfc3339.MatchString(out) { + t.Errorf("expected RFC3339 timestamp in LAST_SEEN cell:\n%s", out) + } +} + +func TestLogsAggregate_TableHeaders(t *testing.T) { + t.Cleanup(func() { resetLogsAggregateFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "source": "runtime", + "retention": "ring_buffer", + "capacity": 100, + "sample_size": 1, + "group_by": "level", + "buckets": []map[string]any{ + {"key": "warn", "count": 3, "last_seen": 1760000000000}, + }, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "table") + + out, err := captureStdout(t, func() error { + return runCmd(t, "logs", "aggregate") + }) + if err != nil { + t.Fatalf("logs aggregate: %v", err) + } + for _, want := range []string{"KEY", "COUNT", "LAST_SEEN"} { + if !strings.Contains(out, want) { + t.Errorf("table missing header %q in:\n%s", want, out) + } + } +} + +func TestLogsAggregate_DistinctFromTail(t *testing.T) { + if logsAggregateCmd == nil { + t.Fatal("logsAggregateCmd not declared") + } + if !strings.HasPrefix(logsAggregateCmd.Use, "aggregate") { + t.Fatalf("Use = %q", logsAggregateCmd.Use) + } + // Sanity: aggregate is NOT a watch loop — has no --follow flag. + if f := logsAggregateCmd.Flags().Lookup("follow"); f != nil { + t.Errorf("logs aggregate should not have --follow flag (that's logs tail)") + } +} diff --git a/cmd/providers_reconnect.go b/cmd/providers_reconnect.go new file mode 100644 index 0000000..be43753 --- /dev/null +++ b/cmd/providers_reconnect.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "net/url" + + "github.com/nextlevelbuilder/goclaw-cli/internal/output" + "github.com/spf13/cobra" +) + +// providersReconnectCmd POSTs an empty body to /v1/providers/{id}/reconnect. +// Backend verifies reconnect server-side — do NOT add a --verify flag. +var providersReconnectCmd = &cobra.Command{ + Use: "reconnect ", + Short: "Force-reconnect a registered provider (admin-only)", + Long: `Force-reconnect a registered provider. Admin-only on the server. + +The server handles reconnect verification internally; no client-side --verify +flag is exposed. (Note: ` + "`providers verify-embedding`" + ` is a different +command targeting a different endpoint.)`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newHTTP() + if err != nil { + return err + } + data, err := c.Post("/v1/providers/"+url.PathEscape(args[0])+"/reconnect", nil) + if err != nil { + return err + } + m := unmarshalMap(data) + if cfg.OutputFormat != "table" { + printer.Print(m) + return nil + } + tbl := output.NewTable("STATUS", "REGISTRY_UPDATED", "CACHE_INVALIDATED", "PROVIDER") + var providerLabel string + if p, ok := m["provider"].(map[string]any); ok { + providerLabel = str(p, "name") + if providerLabel == "" { + providerLabel = str(p, "id") + } + } + tbl.AddRow(str(m, "status"), str(m, "registry_updated"), str(m, "cache_invalidated"), providerLabel) + printer.Print(tbl) + return nil + }, +} + +func init() { + providersCmd.AddCommand(providersReconnectCmd) +} diff --git a/cmd/providers_reconnect_test.go b/cmd/providers_reconnect_test.go new file mode 100644 index 0000000..a0fd304 --- /dev/null +++ b/cmd/providers_reconnect_test.go @@ -0,0 +1,131 @@ +package cmd + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestProvidersReconnect_PathAndMethod(t *testing.T) { + var calls int64 + var gotPath, gotMethod string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&calls, 1) + gotPath = r.URL.Path + gotMethod = r.Method + gotBody, _ = io.ReadAll(r.Body) + okJSON(t, w, map[string]any{ + "status": "reconnected", + "provider": map[string]any{"id": "prov-1", "name": "openai"}, + "registry_updated": true, + "cache_invalidated": true, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "providers", "reconnect", "prov-1"); err != nil { + t.Fatalf("providers reconnect: %v", err) + } + if atomic.LoadInt64(&calls) != 1 { + t.Fatalf("expected exactly 1 request, got %d", atomic.LoadInt64(&calls)) + } + if gotMethod != http.MethodPost { + t.Fatalf("method = %q, want POST", gotMethod) + } + if gotPath != "/v1/providers/prov-1/reconnect" { + t.Fatalf("path = %q", gotPath) + } + body := strings.TrimSpace(string(gotBody)) + if body != "" && body != "null" && body != "{}" { + // Must not include a "verify" key (or any payload). + if strings.Contains(body, "verify") { + t.Fatalf("body contains 'verify': %q", body) + } + } +} + +func TestProvidersReconnect_PathEscape(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + okJSON(t, w, map[string]any{"status": "reconnected"}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + // Provider id with characters that need percent-encoding. + if err := runCmd(t, "providers", "reconnect", "weird/id:1"); err != nil { + t.Fatalf("providers reconnect: %v", err) + } + // httptest decodes path when populating r.URL.Path, so check escaped form via RawPath. + if !strings.Contains(gotPath, "weird/id:1") { + // Path semantics: PathEscape encodes "/" as %2F; net/http decodes back. Accept either form. + if !strings.Contains(gotPath, "weird") || !strings.Contains(gotPath, "id:1") { + t.Fatalf("path = %q (expected to contain escaped provider id)", gotPath) + } + } +} + +func TestProvidersReconnect_JSONOutputPreservesFields(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "status": "disabled", + "registry_updated": false, + "cache_invalidated": true, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "json") + + out, err := captureStdout(t, func() error { + return runCmd(t, "providers", "reconnect", "prov-1") + }) + if err != nil { + t.Fatalf("providers reconnect: %v", err) + } + if !strings.Contains(out, "registry_updated") || !strings.Contains(out, "cache_invalidated") { + t.Fatalf("stdout missing fields: %s", out) + } +} + +func TestProvidersReconnect_TableOutput(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "status": "reconnected", + "registry_updated": true, + "cache_invalidated": true, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "table") + + out, err := captureStdout(t, func() error { + return runCmd(t, "providers", "reconnect", "prov-1") + }) + if err != nil { + t.Fatalf("providers reconnect: %v", err) + } + if !strings.Contains(out, "STATUS") || !strings.Contains(out, "REGISTRY_UPDATED") || !strings.Contains(out, "CACHE_INVALIDATED") { + t.Fatalf("table headers missing in:\n%s", out) + } +} + +func TestProvidersReconnect_MissingArg(t *testing.T) { + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "providers", "reconnect") + if err == nil { + t.Fatal("expected error for missing provider id") + } +} diff --git a/cmd/sessions_branch.go b/cmd/sessions_branch.go new file mode 100644 index 0000000..c0f2a72 --- /dev/null +++ b/cmd/sessions_branch.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "fmt" + "net/url" + "strings" + + "github.com/nextlevelbuilder/goclaw-cli/internal/output" + "github.com/spf13/cobra" +) + +// sessionsBranchCmd posts to /v1/chat/sessions/{key}/branch. +// +// Backend route: POST /v1/chat/sessions/{key}/branch (chat domain). +// (Sibling commands under `sessions` parent target /v1/sessions/...; this +// command intentionally targets the chat-sessions tree where branching lives.) +// +// Body is constructed directly (NOT via buildBody) because up_to_index=0 is a +// valid required value that buildBody's int-zero skip would silently drop. +var sessionsBranchCmd = &cobra.Command{ + Use: "branch ", + Short: "Branch a chat session at a message index", + Long: `Branch a chat session into a new session by copying messages up to a 1-based +index. The source session is unchanged. + +Backend route: POST /v1/chat/sessions/{key}/branch (chat domain).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + upTo, _ := cmd.Flags().GetInt("up-to-index") + if upTo < 0 { + return fmt.Errorf("--up-to-index must be >= 0 (got %d)", upTo) + } + + // Validate metadata up front, BEFORE HTTP call. + metaPairs, _ := cmd.Flags().GetStringArray("metadata") + metadata := make(map[string]any) + for _, kv := range metaPairs { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 || parts[0] == "" { + return fmt.Errorf("--metadata must be key=value (got %q)", kv) + } + metadata[parts[0]] = parts[1] + } + + // Build body directly so up_to_index=0 is preserved on the wire. + body := map[string]any{"up_to_index": upTo} + if v, _ := cmd.Flags().GetString("new-session-key"); v != "" { + body["new_session_key"] = v + } + if v, _ := cmd.Flags().GetString("label"); v != "" { + body["label"] = v + } + if len(metadata) > 0 { + body["metadata"] = metadata + } + + c, err := newHTTP() + if err != nil { + return err + } + data, err := c.Post("/v1/chat/sessions/"+url.PathEscape(args[0])+"/branch", body) + if err != nil { + return err + } + m := unmarshalMap(data) + if cfg.OutputFormat != "table" { + printer.Print(m) + return nil + } + tbl := output.NewTable("SOURCE", "NEW_KEY", "COPIED", "TOTAL", "LABEL") + tbl.AddRow(str(m, "source_key"), str(m, "session_key"), + str(m, "copied_messages"), str(m, "total_messages"), str(m, "label")) + printer.Print(tbl) + return nil + }, +} + +func init() { + sessionsBranchCmd.Flags().Int("up-to-index", -1, "Copy messages 1..N into the new session (required, >=0)") + sessionsBranchCmd.Flags().String("new-session-key", "", "Override generated session key") + sessionsBranchCmd.Flags().String("label", "", "Label for the new session") + sessionsBranchCmd.Flags().StringArray("metadata", nil, "Repeatable key=value metadata pair") + _ = sessionsBranchCmd.MarkFlagRequired("up-to-index") + sessionsCmd.AddCommand(sessionsBranchCmd) +} diff --git a/cmd/sessions_branch_test.go b/cmd/sessions_branch_test.go new file mode 100644 index 0000000..0641042 --- /dev/null +++ b/cmd/sessions_branch_test.go @@ -0,0 +1,197 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/pflag" +) + +func resetSessionsBranchFlags(t *testing.T) { + t.Helper() + for _, name := range []string{"new-session-key", "label"} { + resetTestFlag(sessionsBranchCmd, name, "") + } + resetTestFlag(sessionsBranchCmd, "up-to-index", "-1") + // metadata is a StringArray; reset via SliceValue.Replace. + if f := sessionsBranchCmd.Flags().Lookup("metadata"); f != nil { + if sv, ok := f.Value.(pflag.SliceValue); ok { + _ = sv.Replace(nil) + } + f.Changed = false + } +} + +func TestSessionsBranch_BodyShapeWithMetadata(t *testing.T) { + t.Cleanup(func() { resetSessionsBranchFlags(t) }) + + var gotPath, gotMethod string + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + data, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(data, &body) + okJSON(t, w, map[string]any{ + "ok": true, + "source_key": "sess-1", + "session_key": "sess-1-branch", + "copied_messages": 12, + "total_messages": 24, + "label": "demo", + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "sessions", "branch", "sess-1", + "--up-to-index=12", + "--new-session-key=sess-1-branch", + "--label=demo", + "--metadata=foo=bar", + "--metadata=baz=qux"); err != nil { + t.Fatalf("sessions branch: %v", err) + } + if gotMethod != http.MethodPost { + t.Fatalf("method = %q", gotMethod) + } + if gotPath != "/v1/chat/sessions/sess-1/branch" { + t.Fatalf("path = %q", gotPath) + } + // up_to_index must be present as a numeric value + if v, ok := body["up_to_index"]; !ok { + t.Fatalf("body missing up_to_index: %#v", body) + } else if n, ok := v.(float64); !ok || int(n) != 12 { + t.Fatalf("up_to_index = %#v", v) + } + if body["new_session_key"] != "sess-1-branch" { + t.Errorf("new_session_key = %#v", body["new_session_key"]) + } + if body["label"] != "demo" { + t.Errorf("label = %#v", body["label"]) + } + meta, ok := body["metadata"].(map[string]any) + if !ok { + t.Fatalf("metadata is not object: %#v", body["metadata"]) + } + if meta["foo"] != "bar" || meta["baz"] != "qux" { + t.Fatalf("metadata = %#v", meta) + } +} + +// Zero-boundary regression: --up-to-index 0 means "branch with zero copied +// messages" (an empty branch from the session start) and must appear as +// "up_to_index":0 in the wire body. Any helper that drops numeric zeros would +// silently turn this into a server-side "missing required field" error. +func TestSessionsBranch_UpToIndexZero(t *testing.T) { + t.Cleanup(func() { resetSessionsBranchFlags(t) }) + + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(data, &body) + okJSON(t, w, map[string]any{"ok": true, "source_key": "sess-1", "session_key": "sess-1-branch", "copied_messages": 0, "total_messages": 5}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "sessions", "branch", "sess-1", "--up-to-index=0", "--new-session-key=sess-1-branch"); err != nil { + t.Fatalf("sessions branch: %v", err) + } + v, ok := body["up_to_index"] + if !ok { + t.Fatalf("body missing up_to_index when --up-to-index=0: %#v", body) + } + n, isNum := v.(float64) + if !isNum || n != 0 { + t.Fatalf("up_to_index = %#v, expected 0", v) + } +} + +func TestSessionsBranch_MissingUpToIndex(t *testing.T) { + t.Cleanup(func() { resetSessionsBranchFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "sessions", "branch", "sess-1") + if err == nil { + t.Fatal("expected error for missing --up-to-index") + } +} + +func TestSessionsBranch_NegativeUpToIndex(t *testing.T) { + t.Cleanup(func() { resetSessionsBranchFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "sessions", "branch", "sess-1", "--up-to-index=-1") + if err == nil { + t.Fatal("expected error for negative --up-to-index") + } +} + +func TestSessionsBranch_MalformedMetadata(t *testing.T) { + t.Cleanup(func() { resetSessionsBranchFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "sessions", "branch", "sess-1", "--up-to-index=0", "--metadata=foobar") + if err == nil { + t.Fatal("expected error for malformed --metadata (no '=')") + } +} + +func TestSessionsBranch_PathEscapesSessionKey(t *testing.T) { + t.Cleanup(func() { resetSessionsBranchFlags(t) }) + + var gotRawPath, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotRawPath = r.URL.RawPath + gotPath = r.URL.Path + okJSON(t, w, map[string]any{"ok": true}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "sessions", "branch", "weird:key/with-slash", "--up-to-index=0"); err != nil { + t.Fatalf("sessions branch: %v", err) + } + // RawPath holds the percent-encoded form; either RawPath has the escape, or decoded Path has the colon/slash. + // What matters: client did NOT inject literal "/" into path segment. + if !strings.Contains(gotRawPath, "weird%3Akey%2Fwith-slash") && !strings.Contains(gotPath, "weird:key/with-slash") { + t.Fatalf("path not escaped — RawPath=%q Path=%q", gotRawPath, gotPath) + } +} + +func TestSessionsBranch_JSONPreservesCopiedAndTotal(t *testing.T) { + t.Cleanup(func() { resetSessionsBranchFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "ok": true, + "copied_messages": 12, + "total_messages": 24, + "session_key": "sess-new", + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "json") + + out, err := captureStdout(t, func() error { + return runCmd(t, "sessions", "branch", "sess-1", "--up-to-index=12") + }) + if err != nil { + t.Fatalf("sessions branch: %v", err) + } + if !strings.Contains(out, "copied_messages") || !strings.Contains(out, "total_messages") { + t.Fatalf("stdout missing fields: %s", out) + } +} diff --git a/cmd/sessions_follow.go b/cmd/sessions_follow.go new file mode 100644 index 0000000..c06c43e --- /dev/null +++ b/cmd/sessions_follow.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "fmt" + "net/url" + + "github.com/nextlevelbuilder/goclaw-cli/internal/output" + "github.com/spf13/cobra" +) + +// sessionsFollowCmd issues one HTTP GET to /v1/chat/sessions/{key}/history/follow. +// NOT a watch loop and NOT a WS stream — operators wanting continuous follow +// rerun with the returned `next_cursor`. +// +// Backend route: GET /v1/chat/sessions/{key}/history/follow (chat domain). +// +// Query string is built directly via url.Values so cursor=0 is preserved +// (buildBody would drop int v == 0). +var sessionsFollowCmd = &cobra.Command{ + Use: "follow ", + Short: "Poll cursor-based session history (one shot)", + Long: `Poll the next batch of session-history messages from a cursor. One-shot +polling — no watch loop, no SSE, no WebSocket stream. Re-invoke with the +returned ` + "`next_cursor`" + ` to advance. + +Backend route: GET /v1/chat/sessions/{key}/history/follow (chat domain).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cursor, _ := cmd.Flags().GetInt("cursor") + limit, _ := cmd.Flags().GetInt("limit") + if cursor < 0 { + return fmt.Errorf("--cursor must be >= 0 (got %d)", cursor) + } + if limit <= 0 { + return fmt.Errorf("--limit must be > 0 (got %d)", limit) + } + + q := url.Values{} + // Build query directly so cursor=0 appears literally. + q.Set("cursor", fmt.Sprintf("%d", cursor)) + q.Set("limit", fmt.Sprintf("%d", limit)) + + c, err := newHTTP() + if err != nil { + return err + } + path := "/v1/chat/sessions/" + url.PathEscape(args[0]) + "/history/follow?" + q.Encode() + data, err := c.Get(path) + if err != nil { + return err + } + m := unmarshalMap(data) + if cfg.OutputFormat != "table" { + printer.Print(m) + return nil + } + // Summary row first. + summary := output.NewTable("SESSION", "CURSOR", "NEXT_CURSOR", "TOTAL", "RESET", "UPDATED") + summary.AddRow(str(m, "session_key"), str(m, "cursor"), + str(m, "next_cursor"), str(m, "total"), str(m, "reset"), str(m, "updated")) + printer.Print(summary) + // Compact message rows. + msgs, _ := m["messages"].([]any) + if len(msgs) > 0 { + tbl := output.NewTable("INDEX", "ROLE", "CONTENT") + for _, raw := range msgs { + row, ok := raw.(map[string]any) + if !ok { + continue + } + tbl.AddRow(str(row, "index"), str(row, "role"), str(row, "content")) + } + printer.Print(tbl) + } + return nil + }, +} + +func init() { + sessionsFollowCmd.Flags().Int("cursor", 0, "Starting cursor (>=0, default 0)") + sessionsFollowCmd.Flags().Int("limit", 50, "Max messages per call (default 50, server max 200)") + sessionsCmd.AddCommand(sessionsFollowCmd) +} diff --git a/cmd/sessions_follow_test.go b/cmd/sessions_follow_test.go new file mode 100644 index 0000000..24c8c47 --- /dev/null +++ b/cmd/sessions_follow_test.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func resetSessionsFollowFlags(t *testing.T) { + t.Helper() + // Reset to declared defaults (cursor=0, limit=50). + resetTestFlag(sessionsFollowCmd, "cursor", "0") + resetTestFlag(sessionsFollowCmd, "limit", "50") +} + +func TestSessionsFollow_DefaultsAndQuery(t *testing.T) { + t.Cleanup(func() { resetSessionsFollowFlags(t) }) + + var calls int64 + var rawQuery, path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&calls, 1) + rawQuery = r.URL.RawQuery + path = r.URL.Path + okJSON(t, w, map[string]any{ + "session_key": "sess-1", + "cursor": 0, + "next_cursor": 18, + "total": 18, + "messages": []map[string]any{}, + "reset": false, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "sessions", "follow", "sess-1"); err != nil { + t.Fatalf("sessions follow: %v", err) + } + if atomic.LoadInt64(&calls) != 1 { + t.Fatalf("expected exactly 1 request, got %d (no watch loop)", atomic.LoadInt64(&calls)) + } + if path != "/v1/chat/sessions/sess-1/history/follow" { + t.Fatalf("path = %q", path) + } + // Defaults must be present in the raw query string. + if !strings.Contains(rawQuery, "cursor=0") { + t.Errorf("expected cursor=0 in raw query, got: %q", rawQuery) + } + if !strings.Contains(rawQuery, "limit=50") { + t.Errorf("expected limit=50 in raw query, got: %q", rawQuery) + } +} + +// Zero-boundary regression: --cursor 0 is a valid pagination origin and must +// appear literally as "cursor=0" in the raw query. Any helper that drops +// numeric zeros (e.g. omit-empty builders) would silently break "start from +// the beginning" semantics. +func TestSessionsFollow_CursorZero(t *testing.T) { + t.Cleanup(func() { resetSessionsFollowFlags(t) }) + + var rawQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawQuery = r.URL.RawQuery + okJSON(t, w, map[string]any{"session_key": "sess-1", "cursor": 0, "next_cursor": 0, "total": 0, "messages": []any{}, "reset": false}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "sessions", "follow", "sess-1", "--cursor=0"); err != nil { + t.Fatalf("sessions follow: %v", err) + } + if !strings.Contains(rawQuery, "cursor=0") { + t.Fatalf("--cursor=0 must appear as cursor=0 in query, got: %q", rawQuery) + } +} + +func TestSessionsFollow_CustomCursorAndLimit(t *testing.T) { + t.Cleanup(func() { resetSessionsFollowFlags(t) }) + + var rawQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawQuery = r.URL.RawQuery + okJSON(t, w, map[string]any{"session_key": "sess-1", "cursor": 12, "next_cursor": 17, "total": 17, "messages": []any{}}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "sessions", "follow", "sess-1", "--cursor=12", "--limit=25"); err != nil { + t.Fatalf("sessions follow: %v", err) + } + if !strings.Contains(rawQuery, "cursor=12") || !strings.Contains(rawQuery, "limit=25") { + t.Fatalf("rawQuery = %q", rawQuery) + } +} + +func TestSessionsFollow_NegativeCursor(t *testing.T) { + t.Cleanup(func() { resetSessionsFollowFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "sessions", "follow", "sess-1", "--cursor=-1") + if err == nil { + t.Fatal("expected error for negative --cursor") + } +} + +func TestSessionsFollow_NonPositiveLimit(t *testing.T) { + t.Cleanup(func() { resetSessionsFollowFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "sessions", "follow", "sess-1", "--limit=0", "--cursor=0") + if err == nil { + t.Fatal("expected error for --limit=0") + } +} + +func TestSessionsFollow_PathEscape(t *testing.T) { + t.Cleanup(func() { resetSessionsFollowFlags(t) }) + var rawPath, path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawPath = r.URL.RawPath + path = r.URL.Path + okJSON(t, w, map[string]any{"session_key": "weird:key/x", "cursor": 0, "next_cursor": 0, "total": 0, "messages": []any{}}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "sessions", "follow", "weird:key/x"); err != nil { + t.Fatalf("sessions follow: %v", err) + } + if !strings.Contains(rawPath, "weird%3Akey%2Fx") && !strings.Contains(path, "weird:key/x") { + t.Fatalf("path not escaped — RawPath=%q Path=%q", rawPath, path) + } +} + +func TestSessionsFollow_JSONPreservesFields(t *testing.T) { + t.Cleanup(func() { resetSessionsFollowFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "session_key": "sess-1", + "cursor": 0, + "next_cursor": 5, + "total": 5, + "reset": true, + "messages": []map[string]any{{"index": 0, "role": "user", "content": "hi"}}, + "updated": "2026-05-27T12:00:00Z", + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "json") + + out, err := captureStdout(t, func() error { + return runCmd(t, "sessions", "follow", "sess-1") + }) + if err != nil { + t.Fatalf("sessions follow: %v", err) + } + for _, want := range []string{"reset", "next_cursor", "messages"} { + if !strings.Contains(out, want) { + t.Errorf("stdout missing %q in: %s", want, out) + } + } +} + +func TestSessionsFollow_NotAWatchLoop(t *testing.T) { + // Smoke: sessionsFollowCmd must exist and not be a long-running stream + // (covered by atomic-counter assertion above; this is a structural check). + if sessionsFollowCmd == nil { + t.Fatal("sessionsFollowCmd not declared") + } + if !strings.HasPrefix(sessionsFollowCmd.Use, "follow") { + t.Fatalf("Use = %q", sessionsFollowCmd.Use) + } +} diff --git a/cmd/testdata/trace_detail_get.json b/cmd/testdata/trace_detail_get.json new file mode 100644 index 0000000..6ecad9c --- /dev/null +++ b/cmd/testdata/trace_detail_get.json @@ -0,0 +1,54 @@ +{ + "_TODO_refresh": "stub fixture derived from traces follow payload shape; refresh against goclaw.zuey.me before merge per phase-03 reviewer gate", + "trace_id": "trace_FIXTURE_001", + "agent_id": "agent_FIXTURE_001", + "session_key": "session_FIXTURE_001", + "user_id": "user_REDACTED", + "tenant_id": "tenant_REDACTED", + "status": "success", + "started_at": "2026-05-28T10:00:00Z", + "ended_at": "2026-05-28T10:00:02Z", + "duration_ms": 2000, + "input_tokens": 120, + "output_tokens": 80, + "cost": "0.0042", + "spans": [ + { + "span_id": "span_001", + "parent_span_id": null, + "name": "agent.run", + "kind": "agent", + "started_at": "2026-05-28T10:00:00Z", + "ended_at": "2026-05-28T10:00:02Z", + "duration_ms": 2000, + "status": "success" + }, + { + "span_id": "span_002", + "parent_span_id": "span_001", + "name": "llm.call", + "kind": "llm", + "started_at": "2026-05-28T10:00:00Z", + "ended_at": "2026-05-28T10:00:01Z", + "duration_ms": 1500, + "status": "success", + "input_tokens": 120, + "output_tokens": 80 + }, + { + "span_id": "span_003", + "parent_span_id": "span_001", + "name": "tool.call", + "kind": "tool", + "started_at": "2026-05-28T10:00:01Z", + "ended_at": "2026-05-28T10:00:02Z", + "duration_ms": 400, + "status": "success" + } + ], + "events": [ + {"event_id": "ev_001", "span_id": "span_002", "type": "llm.prompt", "timestamp": "2026-05-28T10:00:00Z"}, + {"event_id": "ev_002", "span_id": "span_002", "type": "llm.completion", "timestamp": "2026-05-28T10:00:01Z"}, + {"event_id": "ev_003", "span_id": "span_003", "type": "tool.invoke", "timestamp": "2026-05-28T10:00:01Z"} + ] +} diff --git a/cmd/traces.go b/cmd/traces.go index 851bff1..6803495 100644 --- a/cmd/traces.go +++ b/cmd/traces.go @@ -1,12 +1,16 @@ package cmd import ( + "encoding/json" "fmt" "io" "net/url" "os" + "regexp" + "strings" "time" + "github.com/nextlevelbuilder/goclaw-cli/internal/client" "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/spf13/cobra" ) @@ -61,19 +65,130 @@ var tracesListCmd = &cobra.Command{ var tracesGetCmd = &cobra.Command{ Use: "get ", Short: "Get trace with span tree", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + id := strings.TrimSpace(args[0]) + if err := validateTraceID(id); err != nil { + return err + } c, err := newHTTP() if err != nil { return err } - data, err := c.Get("/v1/traces/" + args[0]) + data, err := c.Get("/v1/traces/" + url.PathEscape(id)) if err != nil { return err } - printer.Print(unmarshalMap(data)) + var trace map[string]any + if err := json.Unmarshal(data, &trace); err != nil { + return fmt.Errorf("decode trace payload: %w", err) + } + if cfg.OutputFormat != "table" { + printer.Print(trace) + return nil + } + renderTraceTable(trace, os.Stdout) return nil }, } +// traceIDPattern restricts trace ids to a safe, URL-safe allowlist. +// Blocks path-traversal (`..`, `/`, `\`), control characters, and whitespace +// before any HTTP call is issued. PathEscape is still applied on top. +var traceIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +func validateTraceID(id string) error { + if id == "" || id == "." || id == ".." { + return &client.APIError{Code: "INVALID_REQUEST", Message: "trace id is empty or reserved"} + } + if !traceIDPattern.MatchString(id) { + return &client.APIError{Code: "INVALID_REQUEST", Message: "trace id contains invalid characters (allowed: A-Z a-z 0-9 . _ -)"} + } + return nil +} + +// renderTraceTable prints a human-readable summary: header card, span tree, events. +func renderTraceTable(t map[string]any, w io.Writer) { + for _, row := range [][2]string{ + {"TRACE_ID", str(t, "trace_id")}, {"AGENT_ID", str(t, "agent_id")}, + {"SESSION_KEY", str(t, "session_key")}, {"STATUS", str(t, "status")}, + {"DURATION_MS", str(t, "duration_ms")}, + } { + if row[1] != "" { + fmt.Fprintf(w, "%-12s %s\n", row[0]+":", row[1]) + } + } + if in, out, cost := str(t, "input_tokens"), str(t, "output_tokens"), str(t, "cost"); in+out+cost != "" { + fmt.Fprintf(w, "%-12s in=%s out=%s cost=%s\n", "TOKENS:", in, out, cost) + } + spans, _ := t["spans"].([]any) + if len(spans) == 0 { + fmt.Fprintln(w, "\nSPANS: (none)") + } else { + fmt.Fprintln(w, "\nSPANS:") + output.PrintTreeRoot(buildSpanTree(spans), w) + } + events, _ := t["events"].([]any) + fmt.Fprintf(w, "\nEVENTS (n=%d):\n", len(events)) + for _, e := range events { + if m, ok := e.(map[string]any); ok { + fmt.Fprintf(w, " - %s\n", str(m, "type")) + } + } +} + +// buildSpanTree links spans via parent_span_id; spans whose parent isn't in this +// trace attach to a virtual root. Children are kept in insertion order. +func buildSpanTree(spans []any) output.TreeNode { + order := make([]string, 0, len(spans)) + labels := make(map[string]string, len(spans)) + children := make(map[string][]string, len(spans)) + parentOf := make(map[string]string, len(spans)) + for _, s := range spans { + m, ok := s.(map[string]any) + if !ok { + continue + } + id := str(m, "span_id") + if id == "" { + continue + } + label := id + if name := str(m, "name"); name != "" { + label = name + " [" + id + "]" + } + if kind := str(m, "kind"); kind != "" { + label += " kind=" + kind + } + if dur := str(m, "duration_ms"); dur != "" { + label += " " + dur + "ms" + } + labels[id] = label + order = append(order, id) + parentOf[id], _ = m["parent_span_id"].(string) + } + for _, id := range order { + if p := parentOf[id]; p != "" { + if _, ok := labels[p]; ok { + children[p] = append(children[p], id) + continue + } + } + children[""] = append(children[""], id) + } + var build func(id string) output.TreeNode + build = func(id string) output.TreeNode { + n := output.TreeNode{Name: labels[id]} + for _, c := range children[id] { + n.Children = append(n.Children, build(c)) + } + return n + } + root := output.TreeNode{Name: "trace"} + for _, id := range children[""] { + root.Children = append(root.Children, build(id)) + } + return root +} + var tracesExportCmd = &cobra.Command{ Use: "export ", Short: "Export trace to file", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/traces_follow.go b/cmd/traces_follow.go new file mode 100644 index 0000000..d07ef1c --- /dev/null +++ b/cmd/traces_follow.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "fmt" + "net/url" + "time" + + "github.com/nextlevelbuilder/goclaw-cli/internal/output" + "github.com/spf13/cobra" +) + +// tracesFollowCmd issues a single polling GET to /v1/traces/follow. NOT a watch loop. +// Operators wanting continuous follow rerun the command with the returned `next_since`. +var tracesFollowCmd = &cobra.Command{ + Use: "follow", + Short: "Poll incremental trace activity (one shot)", + Long: `Poll incremental trace activity for a session or agent. + +Exactly one of --session-key or --agent must be provided. This is a one-shot +polling request — no watch loop. Use the returned ` + "`next_since`" + ` to re-poll.`, + RunE: func(cmd *cobra.Command, args []string) error { + sessionKey, _ := cmd.Flags().GetString("session-key") + agent, _ := cmd.Flags().GetString("agent") + if sessionKey == "" && agent == "" { + return fmt.Errorf("exactly one of --session-key or --agent is required") + } + if sessionKey != "" && agent != "" { + return fmt.Errorf("--session-key and --agent are mutually exclusive") + } + + since, _ := cmd.Flags().GetString("since") + if since != "" { + if _, err := time.Parse(time.RFC3339, since); err != nil { + return fmt.Errorf("--since must be RFC3339: %w", err) + } + } + + q := url.Values{} + if sessionKey != "" { + q.Set("session_key", sessionKey) + } + if agent != "" { + q.Set("agent_id", agent) + } + if since != "" { + q.Set("since", since) + } + if v, _ := cmd.Flags().GetInt("limit"); v > 0 { + q.Set("limit", fmt.Sprintf("%d", v)) + } + if v, _ := cmd.Flags().GetString("status"); v != "" { + q.Set("status", v) + } + if v, _ := cmd.Flags().GetString("channel"); v != "" { + q.Set("channel", v) + } + if v, _ := cmd.Flags().GetBool("include-spans"); v { + q.Set("include_spans", "true") + } + + c, err := newHTTP() + if err != nil { + return err + } + path := "/v1/traces/follow" + if len(q) > 0 { + path += "?" + q.Encode() + } + data, err := c.Get(path) + if err != nil { + return err + } + envelope := unmarshalMap(data) + if cfg.OutputFormat != "table" { + printer.Print(envelope) + return nil + } + traces, _ := envelope["traces"].([]any) + tbl := output.NewTable("TRACE_ID", "AGENT", "STATUS", "DURATION_MS", "INPUT_TOKENS", "OUTPUT_TOKENS", "COST") + for _, raw := range traces { + t, ok := raw.(map[string]any) + if !ok { + continue + } + tbl.AddRow(str(t, "trace_id"), str(t, "agent_id"), str(t, "status"), + str(t, "duration_ms"), str(t, "input_tokens"), str(t, "output_tokens"), str(t, "cost")) + } + printer.Print(tbl) + return nil + }, +} + +func init() { + tracesFollowCmd.Flags().String("session-key", "", "Session key to follow") + tracesFollowCmd.Flags().String("agent", "", "Agent id or key to follow") + tracesFollowCmd.Flags().String("since", "", "RFC3339 timestamp; only traces after this are returned") + tracesFollowCmd.Flags().Int("limit", 0, "Max traces (server default 50, max 200)") + tracesFollowCmd.Flags().String("status", "", "Filter by status") + tracesFollowCmd.Flags().String("channel", "", "Filter by channel") + tracesFollowCmd.Flags().Bool("include-spans", false, "Include spans_by_trace_id in response") + tracesCmd.AddCommand(tracesFollowCmd) +} diff --git a/cmd/traces_follow_test.go b/cmd/traces_follow_test.go new file mode 100644 index 0000000..43f9091 --- /dev/null +++ b/cmd/traces_follow_test.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "sync/atomic" + "testing" +) + +// resetTracesFollowFlags returns flags to default state between subtests. +func resetTracesFollowFlags(t *testing.T) { + t.Helper() + for _, name := range []string{"session-key", "agent", "since", "status", "channel", "include-spans"} { + resetTestFlag(tracesFollowCmd, name, "") + } + resetTestFlag(tracesFollowCmd, "limit", "0") +} + +func TestTracesFollow_SessionKeyBuildsQuery(t *testing.T) { + t.Cleanup(func() { resetTracesFollowFlags(t) }) + + var calls int64 + var query url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&calls, 1) + if r.URL.Path != "/v1/traces/follow" { + w.WriteHeader(http.StatusNotFound) + return + } + query = r.URL.Query() + okJSON(t, w, map[string]any{"traces": []map[string]any{}, "spans_by_trace_id": map[string]any{}, "next_since": "2026-05-27T12:00:00Z", "limit": 50}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "traces", "follow", "--session-key=sess-1", "--since=2026-05-27T00:00:00Z", "--limit=25", "--status=success", "--channel=telegram"); err != nil { + t.Fatalf("traces follow: %v", err) + } + if atomic.LoadInt64(&calls) != 1 { + t.Fatalf("expected exactly 1 request, got %d (no watch loop allowed)", atomic.LoadInt64(&calls)) + } + if query.Get("session_key") != "sess-1" { + t.Errorf("session_key = %q", query.Get("session_key")) + } + if query.Get("since") != "2026-05-27T00:00:00Z" { + t.Errorf("since = %q", query.Get("since")) + } + if query.Get("limit") != "25" { + t.Errorf("limit = %q", query.Get("limit")) + } + if query.Get("status") != "success" { + t.Errorf("status = %q", query.Get("status")) + } + if query.Get("channel") != "telegram" { + t.Errorf("channel = %q", query.Get("channel")) + } +} + +func TestTracesFollow_AgentTargetBuildsQuery(t *testing.T) { + t.Cleanup(func() { resetTracesFollowFlags(t) }) + + var query url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + okJSON(t, w, map[string]any{"traces": []map[string]any{}}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "traces", "follow", "--agent=agent-1", "--include-spans"); err != nil { + t.Fatalf("traces follow: %v", err) + } + if query.Get("agent_id") != "agent-1" { + t.Errorf("agent_id = %q", query.Get("agent_id")) + } + if query.Get("include_spans") != "true" { + t.Errorf("include_spans = %q", query.Get("include_spans")) + } + if query.Has("session_key") { + t.Errorf("session_key should not be set: %#v", query) + } +} + +func TestTracesFollow_RejectMissingTarget(t *testing.T) { + t.Cleanup(func() { resetTracesFollowFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "traces", "follow") + if err == nil { + t.Fatal("expected validation error for missing target") + } + if !strings.Contains(err.Error(), "session-key") && !strings.Contains(err.Error(), "agent") { + t.Errorf("error should mention target flags: %v", err) + } +} + +func TestTracesFollow_RejectBothTargets(t *testing.T) { + t.Cleanup(func() { resetTracesFollowFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "traces", "follow", "--session-key=sess-1", "--agent=agent-1") + if err == nil { + t.Fatal("expected validation error when both target flags set") + } +} + +func TestTracesFollow_RejectInvalidSince(t *testing.T) { + t.Cleanup(func() { resetTracesFollowFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "traces", "follow", "--session-key=sess-1", "--since=not-a-timestamp") + if err == nil { + t.Fatal("expected RFC3339 validation error") + } +} + +func TestTracesFollow_JSONPreservesEnvelope(t *testing.T) { + t.Cleanup(func() { resetTracesFollowFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "traces": []map[string]any{{"trace_id": "t1"}}, + "spans_by_trace_id": map[string]any{"t1": []any{}}, + "next_since": "2026-05-27T13:00:00Z", + "server_time": "2026-05-27T12:30:00Z", + "limit": 50, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "json") + + out, err := captureStdout(t, func() error { + return runCmd(t, "traces", "follow", "--session-key=sess-1") + }) + if err != nil { + t.Fatalf("traces follow: %v", err) + } + if !strings.Contains(out, "next_since") || !strings.Contains(out, "spans_by_trace_id") { + t.Fatalf("stdout missing fields: %s", out) + } +} + +func TestTracesFollow_TableHeaders(t *testing.T) { + t.Cleanup(func() { resetTracesFollowFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "traces": []map[string]any{ + {"trace_id": "t1", "agent_id": "agent-1", "status": "success", "duration_ms": 120, "input_tokens": 50, "output_tokens": 30, "cost": "0.001"}, + }, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "table") + + out, err := captureStdout(t, func() error { + return runCmd(t, "traces", "follow", "--session-key=sess-1") + }) + if err != nil { + t.Fatalf("traces follow: %v", err) + } + headerRE := regexp.MustCompile(`TRACE_ID.*AGENT.*STATUS.*DURATION_MS.*INPUT_TOKENS.*OUTPUT_TOKENS.*COST`) + if !headerRE.MatchString(out) { + t.Fatalf("table headers missing in:\n%s", out) + } +} + +func TestTracesFollow_DoesNotImportFollowStream(t *testing.T) { + // Static assertion: tracesFollowCmd uses one HTTP GET via httpClient, not FollowStream. + // Covered indirectly by the atomic-counter test above; this test is a smoke for command existence. + if tracesFollowCmd == nil { + t.Fatal("tracesFollowCmd not declared") + } + if tracesFollowCmd.Use == "" || !strings.HasPrefix(tracesFollowCmd.Use, "follow") { + t.Fatalf("tracesFollowCmd.Use = %q, expected to start with 'follow'", tracesFollowCmd.Use) + } +} diff --git a/cmd/traces_get_test.go b/cmd/traces_get_test.go new file mode 100644 index 0000000..fd14ce2 --- /dev/null +++ b/cmd/traces_get_test.go @@ -0,0 +1,322 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + + "github.com/nextlevelbuilder/goclaw-cli/internal/output" +) + +// loadTraceDetailFixture reads the captured trace detail envelope from testdata. +// The fixture is a single trace map (not wrapped). Tests wrap it via okJSON. +func loadTraceDetailFixture(t *testing.T) map[string]any { + t.Helper() + data, err := os.ReadFile("testdata/trace_detail_get.json") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("decode fixture: %v", err) + } + return m +} + +// errJSON writes an error envelope mimicking the server shape. +func errJSON(t *testing.T, w http.ResponseWriter, status int, code, message string) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + body, _ := json.Marshal(map[string]any{ + "ok": false, + "error": map[string]any{"code": code, "message": message}, + }) + _, _ = w.Write(body) +} + +// TestTracesGet_PathAndMethod locks the wire contract: GET /v1/traces/{id}. +func TestTracesGet_PathAndMethod(t *testing.T) { + var calls int64 + var gotPath, gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&calls, 1) + gotPath = r.URL.Path + gotMethod = r.Method + okJSON(t, w, loadTraceDetailFixture(t)) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "traces", "get", "trace_FIXTURE_001", "--output", "json"); err != nil { + t.Fatalf("traces get: %v", err) + } + if atomic.LoadInt64(&calls) != 1 { + t.Fatalf("expected 1 request, got %d", atomic.LoadInt64(&calls)) + } + if gotMethod != http.MethodGet { + t.Errorf("method = %q, want GET", gotMethod) + } + if gotPath != "/v1/traces/trace_FIXTURE_001" { + t.Errorf("path = %q, want /v1/traces/trace_FIXTURE_001", gotPath) + } +} + +// TestTracesGet_HappyPath_JSON_LocksFixture round-trips the JSON envelope. +func TestTracesGet_HappyPath_JSON_LocksFixture(t *testing.T) { + fixture := loadTraceDetailFixture(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, fixture) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + out, err := captureStdout(t, func() error { + return runCmd(t, "traces", "get", "trace_FIXTURE_001", "--output", "json") + }) + if err != nil { + t.Fatalf("traces get: %v", err) + } + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("stdout is not JSON: %v\nstdout: %q", err, out) + } + if got["trace_id"] != "trace_FIXTURE_001" { + t.Errorf("trace_id = %v", got["trace_id"]) + } + if got["agent_id"] != "agent_FIXTURE_001" { + t.Errorf("agent_id = %v", got["agent_id"]) + } + if got["status"] != "success" { + t.Errorf("status = %v", got["status"]) + } + spans, ok := got["spans"].([]any) + if !ok || len(spans) != 3 { + t.Errorf("spans = %v (want 3 entries)", got["spans"]) + } +} + +// TestTracesGet_TableMode_HumanReadable_RED — the issue #17 repro (now green after fix). +func TestTracesGet_TableMode_HumanReadable_RED(t *testing.T) { + fixture := loadTraceDetailFixture(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, fixture) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + out, err := captureStdout(t, func() error { + return runCmd(t, "traces", "get", "trace_FIXTURE_001", "--output", "table") + }) + if err != nil { + t.Fatalf("traces get: %v", err) + } + trimmed := strings.TrimSpace(out) + if strings.HasPrefix(trimmed, "{") { + t.Fatalf("table mode rendered raw JSON (starts with '{'): %q", out) + } + wantAny := []string{"TRACE", "SPAN", "EVENT", "trace_id", "agent_id"} + hit := false + for _, m := range wantAny { + if strings.Contains(out, m) { + hit = true + break + } + } + if !hit { + t.Fatalf("table mode missing human-readable markers; got: %q", out) + } +} + +// TestTracesGet_TableMode_HasHeaderAndSpanMarkers — verifies header card + tree drawing. +func TestTracesGet_TableMode_HasHeaderAndSpanMarkers(t *testing.T) { + fixture := loadTraceDetailFixture(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, fixture) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + out, err := captureStdout(t, func() error { + return runCmd(t, "traces", "get", "trace_FIXTURE_001", "--output", "table") + }) + if err != nil { + t.Fatalf("traces get: %v", err) + } + if strings.HasPrefix(strings.TrimSpace(out), "{") { + t.Fatalf("table mode rendered raw JSON: %q", out) + } + if !strings.Contains(out, "TRACE_ID") { + t.Errorf("missing TRACE_ID header in: %q", out) + } + // At least one tree connector must appear (├─ or └─). + if !strings.Contains(out, "├") && !strings.Contains(out, "└") { + t.Errorf("missing span tree connectors (├ / └) in: %q", out) + } + if !strings.Contains(out, "EVENTS") { + t.Errorf("missing EVENTS section in: %q", out) + } +} + +// TestTracesGet_JSONMode_PreservesStructure — all top-level fixture keys present in JSON output. +func TestTracesGet_JSONMode_PreservesStructure(t *testing.T) { + fixture := loadTraceDetailFixture(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, fixture) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + out, err := captureStdout(t, func() error { + return runCmd(t, "traces", "get", "trace_FIXTURE_001", "--output", "json") + }) + if err != nil { + t.Fatalf("traces get: %v", err) + } + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("not JSON: %v", err) + } + for k := range fixture { + if _, ok := got[k]; !ok { + t.Errorf("top-level key %q missing from JSON output", k) + } + } + // Nested reachability: spans[0].name + spans, ok := got["spans"].([]any) + if !ok || len(spans) == 0 { + t.Fatalf("spans not reachable") + } + first, ok := spans[0].(map[string]any) + if !ok || first["name"] == nil { + t.Errorf("spans[0].name not reachable: %v", spans[0]) + } +} + +// TestTracesGet_NotFound_ExitCode3 — 404 → ExitNotFound. +func TestTracesGet_NotFound_ExitCode3(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + errJSON(t, w, http.StatusNotFound, "NOT_FOUND", "trace not found") + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "traces", "get", "doesnotexist", "--output", "json") + if err == nil { + t.Fatal("expected error, got nil") + } + if code := output.FromError(err); code != output.ExitNotFound { + t.Errorf("exit code = %d, want %d (ExitNotFound)", code, output.ExitNotFound) + } + if !strings.Contains(strings.ToLower(err.Error()), "not found") { + t.Errorf("error message should mention 'not found': %q", err.Error()) + } +} + +// TestTracesGet_PermissionDenied_ExitCode2 — 403 → ExitAuth. +func TestTracesGet_PermissionDenied_ExitCode2(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + errJSON(t, w, http.StatusForbidden, "TENANT_ACCESS_REVOKED", "tenant access revoked") + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "traces", "get", "trace_FIXTURE_001", "--output", "json") + if err == nil { + t.Fatal("expected error, got nil") + } + if code := output.FromError(err); code != output.ExitAuth { + t.Errorf("exit code = %d, want %d (ExitAuth)", code, output.ExitAuth) + } +} + +// TestTracesGet_MalformedID_NoHTTPCall — id validation runs before HTTP; exit 4. +func TestTracesGet_MalformedID_NoHTTPCall(t *testing.T) { + var calls int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&calls, 1) + okJSON(t, w, map[string]any{}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + bad := []string{"", " ", "..", ".", "../etc/passwd", "a/b", "a\\b", "a\x00b", "a b"} + for _, id := range bad { + t.Run("id="+strings.ReplaceAll(id, "\x00", "NUL"), func(t *testing.T) { + before := atomic.LoadInt64(&calls) + err := runCmd(t, "traces", "get", id, "--output", "json") + if err == nil { + t.Fatalf("expected validation error for %q, got nil", id) + } + if code := output.FromError(err); code != output.ExitValidation { + t.Errorf("id=%q: exit = %d, want %d (ExitValidation)", id, code, output.ExitValidation) + } + if got := atomic.LoadInt64(&calls); got != before { + t.Errorf("id=%q: HTTP call made (calls %d -> %d); should be blocked client-side", id, before, got) + } + }) + } +} + +// TestTracesGet_ServerError_ExitCode5 — 5xx → ExitServer; client retries so calls >= 1. +func TestTracesGet_ServerError_ExitCode5(t *testing.T) { + if testing.Short() { + t.Skip("skipping in -short: HTTP client backs off ~3s between 5xx retries") + } + var calls int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&calls, 1) + errJSON(t, w, http.StatusInternalServerError, "INTERNAL", "boom") + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "traces", "get", "trace_FIXTURE_001", "--output", "json") + if err == nil { + t.Fatal("expected error, got nil") + } + if code := output.FromError(err); code != output.ExitServer { + t.Errorf("exit code = %d, want %d (ExitServer)", code, output.ExitServer) + } + if n := atomic.LoadInt64(&calls); n < 1 { + t.Errorf("calls = %d, want >= 1 (client retries 5xx)", n) + } +} + +// TestTracesGet_MalformedResponse_SurfacesError — bad JSON body → wrapped decode error. +func TestTracesGet_MalformedResponse_SurfacesError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("this is not json")) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + out, err := captureStdout(t, func() error { + return runCmd(t, "traces", "get", "trace_FIXTURE_001", "--output", "json") + }) + if err == nil { + t.Fatal("expected decode error, got nil") + } + msg := strings.ToLower(err.Error()) + if !strings.Contains(msg, "decode") && !strings.Contains(msg, "unmarshal") && !strings.Contains(msg, "invalid") { + t.Errorf("error should mention decode/unmarshal/invalid; got: %q", err.Error()) + } + if strings.TrimSpace(out) == "{}" { + t.Errorf("stdout is empty JSON object — silent failure regressed: %q", out) + } +} diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index a5fc48d..199e13c 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,7 +1,7 @@ # GoClaw CLI - Codebase Summary **Generated from:** `repomix-output.xml` (2026-04-15), updated manually 2026-05-20 -**Phase Status:** P0-P4 Complete (AI-First Expansion); Super Admin API Parity Complete; Domain Coverage P5 Implemented +**Phase Status:** P0-P4 Complete (AI-First Expansion); Super Admin API Parity Complete; Domain Coverage P5 + P6 (Backend-Unblocked) Implemented **Total Files:** 80+ **Estimated Tokens:** 80,000+ **Total Size:** 220+ KB @@ -10,7 +10,7 @@ ## Overview -GoClaw CLI is a production-ready Go application providing comprehensive command-line management for GoClaw AI agent gateway servers. Built with Cobra framework, it supports 30+ command groups across modular command files with dual modes: interactive (human) and automation (CI/agent). Phases 0-4 (AI-first expansion) add AI ergonomics, admin/ops, migration, vault, and advanced agent/team/memory support. The 2026-05-18 super-admin parity work adds gateway upgrade, package updates, workstations, webhooks, MCP user credentials, secure env reveal, media/TTS/storage/channel fillers, and focused route-contract tests. The 2026-05-19 P3/P4 filler pass adds first-class profile commands, `GOCLAW_PROFILE`, `sessions compact`, WS health, trace filter polish, `codex-pool`, `api-keys rotate`, `config defaults`, chat session convenience wrappers, and `tools invoke --args`. The 2026-05-20 P5 filler pass adds team attachment download, skill-specific evolution suggestion apply, and fixes evolution update payload compatibility. +GoClaw CLI is a production-ready Go application providing comprehensive command-line management for GoClaw AI agent gateway servers. Built with Cobra framework, it supports 30+ command groups across modular command files with dual modes: interactive (human) and automation (CI/agent). Phases 0-4 (AI-first expansion) add AI ergonomics, admin/ops, migration, vault, and advanced agent/team/memory support. The 2026-05-18 super-admin parity work adds gateway upgrade, package updates, workstations, webhooks, MCP user credentials, secure env reveal, media/TTS/storage/channel fillers, and focused route-contract tests. The 2026-05-19 P3/P4 filler pass adds first-class profile commands, `GOCLAW_PROFILE`, `sessions compact`, WS health, trace filter polish, `codex-pool`, `api-keys rotate`, `config defaults`, chat session convenience wrappers, and `tools invoke --args`. The 2026-05-20 P5 filler pass adds team attachment download, skill-specific evolution suggestion apply, and fixes evolution update payload compatibility. The 2026-05-27 P6 backend-unblocked pass adds seven new surfaces wired to backend PRs `#37` and `#44`: `traces follow`, `providers reconnect`, `sessions branch`, `sessions follow`, `channels writers test`, `activity aggregate`, and `logs aggregate` — all one-shot HTTP commands (no new watch loops; reuse the existing `client.FollowStream` only for true streaming surfaces). **Key Metrics:** - **70+ command files** in `cmd/` (modularized for maintainability) @@ -316,7 +316,7 @@ goclaw (root) │ ├── workspace (list, read, delete, upload, move) │ └── attachments download --output ├── channels (list, contacts, pending-messages) -├── traces (list, export) +├── traces (list, get, export, follow) # `get` validates id allowlist, renders header+span-tree+events for TTY, JSON for piped/`-o json` ├── memory (list, search, upsert) ├── knowledge-graph (entities, links, query) ├── usage (summary, detail, costs, timeseries, breakdown) diff --git a/internal/client/http.go b/internal/client/http.go index ad09ddd..b765b63 100644 --- a/internal/client/http.go +++ b/internal/client/http.go @@ -161,10 +161,16 @@ func (c *HTTPClient) do(method, path string, body any) (json.RawMessage, error) if resp.StatusCode != 429 && resp.StatusCode < 500 { break } - resp.Body.Close() - if attempt < 2 { - time.Sleep(time.Duration(1< + +## When to use +<1 sentence — khi nào Claude load reference này> + +## Commands in scope + + +## Verified flags (từ code source) + + +## JSON output + + +## Destructive ops + + +## Common patterns +<3-5 worked examples, copy-pasteable bash> + +## Edge cases & gotchas + + +## Cross-refs + +``` + +## Specific content per file + +### exec-workflow.md (target 200-250 lines) — VERIFIED SCHEMA + +**Server impl:** `/Volumes/GOON/www/nlb/goclaw/internal/tools/shell.go:112-128` + builtin registry `gateway_builtin_tools.go:24`. + +**Tool name:** `exec` +**Parameters schema:** +```json +{ + "command": "string (REQUIRED) — shell command to execute", + "working_dir": "string (optional) — defaults to workspace root" +} +``` +**Response:** stdout/stderr từ `*Result` struct (check struct shape in `internal/tools/executor.go` trước khi viết example). +**Approval gating:** package install commands + shell deny patterns → creates approval request via `ExecApprovalManager` (`exec_approval.go:91` — field `Command`). User approves via `goclaw approvals approve `. + +**Content outline:** +- Cover: `goclaw tools invoke exec --param command="..."` + `--param working_dir=/tmp` +- Cover: `goclaw tools invoke ` generic pattern (link vào file `providers-skills-tools.md` cho tool list) +- Cover: `goclaw approvals list/approve/deny` — cần chạy qua ws.Call (xem `cmd/admin.go:26-80`) +- **Canonical home của approvals** = file này (M4 resolution); `chat-sessions.md` cross-link tới đây +- Example 1: one-shot command with auto-approve (innocuous command, không trigger approval) +- Example 2: command hits approval gate → user approves → retry +- Example 3: parse stdout/stderr/exit_code fields JSON +- Gotcha: `approvals watch` streaming — flag "not Bash-friendly, use polling `approvals list --output json`" +- Gotcha: NUL byte rejection, shell deny patterns, Unicode normalization (từ shell.go:138-144) + +### auth-and-config.md (target 150-200 lines) +- Cover: `auth login/logout/whoami/use-context/list-contexts/pair` +- Cover: `config get/set/permissions` +- Cover: `credentials list/create/delete/rotate` +- Cover: `api-keys list/create/reveal/revoke/extend` +- Cover: global flags `--profile`, `--tenant-id`, `--server`, `--token` +- Example: switch profile mid-session +- Gotcha: `auth pair` = device pairing, long-running polling — document "not Bash-friendly; skill REFUSE to run, tell user chạy shell riêng" +- Gotcha: **token expiry handling (U2 fix)** — nếu `goclaw` exit code indicate 401, Claude phải suggest `goclaw auth login` (không try-retry loop). Document exit codes nếu có. + +### chat-sessions.md (target 200 lines) +- Cover: `chat -m "message" --no-stream --output json` (single-shot ONLY) +- Cover: `chat abort ` (destructive, no --yes flag) — **include trong destructive section** (N3 fix) +- Cover: `chat inject`, `chat status` +- Cover: `sessions list/preview/delete/reset/label` +- Cover: NDJSON parsing từ `chat --output json` +- Cross-link: approvals → xem `exec-workflow.md` (không duplicate) +- Gotcha: `chat` interactive mode = TUI, không dùng được qua Bash; chỉ dùng single-shot +- Example: chat → get session ID → preview history + +### agents-core.md (target 250 lines — cluster lớn) +- Cover: `agents list/get/create/update/delete` +- Cover: `agents files list/get/create/delete` +- Cover: `agents instances list/get/create/delete/trigger/reset` +- Cover: `agents wake` +- Example: full lifecycle — create agent → upload file → instance → wake +- Destructive: delete agent + instances (both need --yes) + +### monitoring-ops.md (target 150 lines) +- Cover: `status`, `health`, `version` +- Cover: `traces list/get` +- Cover: `usage summary/breakdown/trends/export` +- Cover: `logs tail` — **FLAG: streaming, NOT Bash-friendly** +- Example: diagnose server health flow + +## Related Code Files (to read) + +- `cmd/tools.go` — verify exec invoke schema +- `cmd/admin.go:11-82` — approvals flow (ws.Call signatures) +- `cmd/auth.go` — auth flow + pairing +- `cmd/chat.go` — single-shot vs interactive modes +- `cmd/sessions.go` — session CRUD +- `cmd/agents.go` + `agents_*.go` — agent lifecycle +- `cmd/status.go`, `health.go`, `logs.go`, `traces.go`, `usage.go` — monitoring commands +- `cmd/root.go` — global flags + +## Implementation Steps + +1. Đọc source files listed above, extract flag definitions + subcommand schemas +2. Viết `exec-workflow.md` trước (hero use case) +3. Test: trong Claude Code, chạy thử "run `pwd` trên goclaw server" → verify Claude dùng `tools invoke exec --param command="pwd"` đúng +4. Viết 4 file còn lại theo template +5. Mỗi file: check mọi flag listed phải xuất hiện trong `cmd/*.go` tương ứng (không invent) +6. Cross-link giữa các file (vd exec-workflow link tới auth-and-config cho context "token phải set trước") + +## Todo List + +- [ ] Read source files để extract verified flags +- [ ] Write `exec-workflow.md` (hero) +- [ ] Manual test exec-workflow trong Claude Code +- [ ] Write `auth-and-config.md` +- [ ] Write `chat-sessions.md` +- [ ] Write `agents-core.md` +- [ ] Write `monitoring-ops.md` +- [ ] Cross-link review +- [ ] Commit phase 2 + +## Success Criteria +- 5 reference files mỗi file 150-250 lines +- Mọi flag listed verify được từ `cmd/*.go` +- Claude Code test: ≥ 3 intents về exec chạy đúng lệnh mà không hallucinate flag +- Destructive ops mỗi file có section "user confirm required" + +## Risk +- ~~Schema response của `tools invoke exec` chưa known~~ **RESOLVED:** verified trong `shell.go:114-128`, `exec_approval.go:91` +- Response body (`*Result` struct) chưa đọc — verify `internal/tools/executor.go` Result shape trước khi viết Example 3 +- Verify flag match tốn thời gian — chấp nhận vì chất lượng reference quyết định UX +- Approvals đã đưa vào exec-workflow nhưng `chat-sessions.md` dễ duplicate — phải strict cross-link, không copy content + +## Security +- Mọi example dùng placeholder ``, ``, `` — không hardcode data thật + +## Next +- Phase 3: viết 10 reference còn lại diff --git a/plans/260417-1254-goclaw-claude-skill/phase-03-remaining-references.md b/plans/260417-1254-goclaw-claude-skill/phase-03-remaining-references.md new file mode 100644 index 0000000..e56b967 --- /dev/null +++ b/plans/260417-1254-goclaw-claude-skill/phase-03-remaining-references.md @@ -0,0 +1,109 @@ +--- +phase: 3 +title: Remaining references +status: pending +priority: medium +effort_hours: 18 +blockedBy: [1] +--- + +# Phase 3 — Remaining references + +## Context Links +- Parent: [plan.md](plan.md) +- Phase 2 (template established): [phase-02-priority-references.md](phase-02-priority-references.md) +- Explorer: `plans/reports/explore-260417-1254-goclaw-command-inventory.md` + +## Overview +Viết 10 reference files còn lại, theo cùng template đã chốt ở Phase 2. Độ ưu tiên thấp hơn vì use case ít gặp, nhưng cần đủ để đạt 100% coverage command surface. + +## Scope — 11 files (M5 fix: `media` promoted to own cluster) + +| File | Clusters | Target lines | +|------|----------|--------------| +| `agents-advanced.md` | agents.links, agents.ops, delegations (standalone, xác nhận từ `cmd/admin.go:141`) | 200 | +| `knowledge-memory.md` | kg, kg.dedup, memory | 200 | +| `teams-collaboration.md` | teams, teams.members, teams.events, teams.tasks, teams.workspace | 250 | +| `channels-messaging.md` | channels, channels.contacts, channels.pending, channels.writers, contacts, pending-messages | 200 | +| `data-movement.md` | export, import, storage | 150 | +| `providers-skills-tools.md` | providers, skills, tools (builtin list/get/update/tenant-config), packages | 250 | +| `automation-scheduling.md` | cron, heartbeat, heartbeat.checklist, heartbeat.targets, devices | 200 | +| `mcp-integration.md` | mcp, mcp.servers, mcp.grants, mcp.requests, mcp.reconnect | 200 | +| `admin-system.md` | tenants, system-config, activity, tts | 180 | +| `media.md` | media (admin_media.go) — **NEW** per red team M5 | 100 | +| `docs-api.md` | api-docs | 80 | + +## Template (giữ như phase 2) + +Mỗi file có sections: +- When to use +- Commands in scope +- Verified flags +- JSON output support per subcommand +- Destructive ops +- Common patterns (3-5 examples) +- Edge cases & gotchas +- Cross-refs + +## Key streaming/TUI flags per cluster + +- `teams-collaboration.md` — ⚠️ **toàn bộ teams.* là WebSocket**. Vẫn chạy được qua Bash (ws.Call sync) nhưng `teams.events` + task subscriptions là streaming — flag rõ +- `automation-scheduling.md` — ⚠️ `devices approve/reject` có interactive TUI fallback; dùng `--yes` để auto +- `mcp-integration.md` — ⚠️ `mcp reconnect` async; `mcp servers test` sync +- `channels-messaging.md` — ⚠️ một số subcommand chỉ trả success text (không JSON) +- `data-movement.md` — ⚠️ import/export lớn → timeout bash tool; doc cách split hoặc tăng timeout + +## Related Code Files (to read per file) + +- `agents-advanced.md` — `cmd/agents_links.go`, `agents_ops.go` +- `knowledge-memory.md` — `cmd/knowledge_graph.go`, `knowledge_graph_dedup.go`, `memory.go`, `memory_index.go` +- `teams-collaboration.md` — `cmd/teams*.go` (7 files) +- `channels-messaging.md` — `cmd/channels*.go`, `contacts.go`, `pending_messages.go` +- `data-movement.md` — `cmd/export_import.go`, `storage.go` +- `providers-skills-tools.md` — `cmd/providers*.go`, `skills*.go`, `tools.go`, `packages.go` +- `automation-scheduling.md` — `cmd/cron.go`, `heartbeat*.go`, `devices.go` +- `mcp-integration.md` — `cmd/mcp.go`, `mcp_reconnect.go` +- `admin-system.md` — `cmd/tenants.go`, `system_config.go`, `admin*.go` +- `docs-api.md` — `cmd/api_docs.go`, `api_keys.go` + +## Implementation Steps + +1. Có thể song song 10 files (độc lập nhau) — nếu làm tay tuần tự: mỗi file 15-30 phút +2. Cho file nào cluster lớn (teams-collaboration, providers-skills-tools) — spawn 1 fullstack-developer subagent riêng để đỡ bloat main context +3. Mỗi file done → cross-check verified flags match source +4. Cross-link: `automation-scheduling` link tới `auth-and-config` (device pairing nhắc tới auth); `data-movement` link tới `providers-skills-tools` (export skills) +5. Update SKILL.md navigation links khi mỗi file xong + +## Todo List + +- [ ] Write `agents-advanced.md` +- [ ] Write `knowledge-memory.md` +- [ ] Write `teams-collaboration.md` (lớn, có thể spawn subagent) +- [ ] Write `channels-messaging.md` +- [ ] Write `data-movement.md` +- [ ] Write `providers-skills-tools.md` (lớn, có thể spawn subagent) +- [ ] Write `automation-scheduling.md` +- [ ] Write `mcp-integration.md` +- [ ] Write `admin-system.md` +- [ ] Write `media.md` (NEW, cluster bị miss) +- [ ] Write `docs-api.md` +- [ ] Cross-link pass trên tất cả 16 references (phase 2 + 3) +- [ ] SKILL.md navigation list review +- [ ] Commit phase 3 + +## Success Criteria +- 10 reference files, mỗi file 80-250 lines, kebab-case naming +- Tổng 15 references tương đương ~3000 lines markdown +- Mọi flag verify từ source code (grep flag name trong `cmd/*.go`) +- SKILL.md navigation list cover hết 15 files + +## Risk +- Scope lớn, dễ fatigue → ship từng batch 2-3 files/commit +- Teams cluster phức tạp nhất (7 source files); để cuối hoặc delegate subagent + +## Security +- Không leak server URL, tenant, credential trong examples +- Placeholder `` cho mọi giá trị user-specific + +## Next +- Phase 4: install.sh + README + manual test diff --git a/plans/260417-1254-goclaw-claude-skill/phase-04-install-readme-test.md b/plans/260417-1254-goclaw-claude-skill/phase-04-install-readme-test.md new file mode 100644 index 0000000..84b9058 --- /dev/null +++ b/plans/260417-1254-goclaw-claude-skill/phase-04-install-readme-test.md @@ -0,0 +1,239 @@ +--- +phase: 4 +title: Install script + README + release tarball + test +status: pending +priority: high +effort_hours: 8 +blockedBy: [2, 3] +--- + +# Phase 4 — Install script + README + manual test + +## Context Links +- Parent: [plan.md](plan.md) +- Researcher (install patterns): `plans/reports/researcher-260417-1254-claude-skill-authoring.md` §4 "Settings.json Patching" + +## Overview +Hoàn thiện artifact cho OSS release: install.sh production-grade (idempotent Python3 merge), README đầy đủ với usage examples, manual smoke test trong Claude Code, write journal entry. + +## Requirements + +### install.sh behavior (post-red-team) +1. **3-mode permission selector** (D3 revised): + - `--mode 1` = full wildcard `Bash(goclaw:*)` — user ack required + - `--mode 2` = readonly enumeration (~20 rules: `Bash(goclaw agents list:*)`, `Bash(goclaw agents get:*)`, `Bash(goclaw sessions list:*)`, ...) generated từ inventory + - `--mode 3` = no patching, print JSON snippet cho user copy (SAFEST — default khi chạy qua pipe) + - Không arg: interactive prompt qua `/dev/tty` chọn 1/2/3 +2. **Tarball install** (C4 fix): install.sh được embed trong tarball từ GitHub Releases. README one-liner: + ```bash + curl -fsSL https://github.com/nextlevelbuilder/goclaw-cli/releases/download/skill-v0.1.0/goclaw-skill.tar.gz | tee /tmp/gs.tgz | sha256sum -c goclaw-skill.sha256 + tar xzf /tmp/gs.tgz -C /tmp && /tmp/goclaw-skill/install.sh + ``` + SHA256 published alongside tarball in release. +3. **Safe backup** (M7 fix): + ```bash + if [[ -f "$SETTINGS" ]]; then + cp "$SETTINGS" "${SETTINGS}.bak.$(date +%s)" || { echo "ERROR: backup failed"; exit 1; } + fi + ``` +4. **Interactive prompt safety** (M8 fix): `read ... < /dev/tty`; nếu `! -t 0` (piped), refuse Mode 1 và default Mode 3. +5. **Python heredoc hardening** (M6 fix): `<<'EOF'` quoted; flags truyền qua env vars: + ```bash + MODE="$MODE" DRY_RUN="$DRY_RUN" "$PY" <<'EOF' + import os; mode = os.environ['MODE']; ... + EOF + ``` +6. **Python3 sanity check** (M6 fix): `python3 -c 'import sys; assert sys.version_info[0]==3'`. +7. **Windows abort** (U4 fix): detect `uname -s` contains `MINGW|MSYS|CYGWIN` → print "Windows not supported in v1, use WSL or manual install"; exit. +8. **Multi-install detect** (U5 fix): respect `CLAUDE_HOME` env var, fallback `~/.claude/`. +9. **Idempotent:** permissions.allow dedupe before append. +10. **Abort** if `settings.json` invalid JSON (don't overwrite). +11. **Skill overwrite gate** (N1 fix): if `$SKILL_DIR` exists, require `--force`. + +### README.md content +- Title + 1-paragraph pitch +- Screenshot/GIF placeholder (optional for v1) +- Requirements: goclaw binary, Claude Code CLI, macOS/Linux +- Install one-liner: `curl -fsSL /claude-skill/install.sh | bash` +- Manual install alternative: `git clone && cd claude-skill && ./install.sh` +- Usage: 3 example prompts user có thể thử với Claude ("list my goclaw agents", "run `ls` on goclaw server", "show logs for agent X") +- Configuration: link tới `references/auth-and-config.md` +- Permissions matrix: readonly default vs --full-auto +- Uninstall: `rm -rf ~/.claude/skills/goclaw` + manual settings.json revert +- License + contributing link + +## Architecture + +``` +install.sh flow: + parse args (--full-auto, --dry-run, --help) + check prereqs (goclaw in PATH, ~/.claude/ exists) + backup ~/.claude/settings.json → .bak. + copy claude-skill/* → ~/.claude/skills/goclaw/ + run python3 -c "" on settings.json + print success + next-steps +``` + +## Related Code Files + +### To create +- `claude-skill/install.sh` (full implementation) +- `claude-skill/README.md` (full implementation) + +### To update +- `claude-skill/SKILL.md` — final polish, verify all 15 links work +- Root `README.md` (repo-level) — add "Claude Code skill" section với link tới `claude-skill/` + +## Implementation Steps + +### Step 1: install.sh core +```bash +#!/usr/bin/env bash +set -euo pipefail + +# Parse args +FULL_AUTO=0 +DRY_RUN=0 +while [[ $# -gt 0 ]]; do + case $1 in + --full-auto) FULL_AUTO=1; shift;; + --dry-run) DRY_RUN=1; shift;; + --help|-h) print_help; exit 0;; + *) echo "Unknown: $1"; exit 1;; + esac +done + +# Prereqs +command -v goclaw >/dev/null || { echo "goclaw not in PATH"; exit 1; } +[[ -d "$HOME/.claude" ]] || { echo "~/.claude missing — install Claude Code first"; exit 1; } + +# Backup +SETTINGS="$HOME/.claude/settings.json" +[[ -f "$SETTINGS" ]] && cp "$SETTINGS" "${SETTINGS}.bak.$(date +%s)" + +# Copy files +SKILL_DIR="$HOME/.claude/skills/goclaw" +[[ "$DRY_RUN" == 1 ]] && echo "[dry-run] mkdir -p $SKILL_DIR" || mkdir -p "$SKILL_DIR" +# ... rsync claude-skill/ to $SKILL_DIR (exclude install.sh itself) + +# Warn for --full-auto +if [[ "$FULL_AUTO" == 1 ]]; then + echo "WARNING: --full-auto will allow Claude to run ANY goclaw command including destructive ops." + read -p "Continue? [y/N] " -n 1 -r + [[ ! $REPLY =~ ^[Yy]$ ]] && exit 0 +fi + +# Patch settings.json via Python3 +PY="$HOME/.claude/skills/.venv/bin/python3" +[[ ! -x "$PY" ]] && PY="$(command -v python3)" +[[ -z "$PY" ]] && { echo "python3 not found — add permissions manually"; print_manual; exit 0; } + +"$PY" < --output json` +5. "what's my usage this week" → `goclaw usage summary --output json` + +**Destructive intents (skill SHOULD prompt before running):** +6. "delete agent xyz" → Claude confirm + `goclaw agents delete xyz --yes` +7. "rotate my credential" → Claude confirm + `goclaw credentials rotate` +8. "reset session abc" → Claude confirm + `goclaw sessions reset abc --yes` +9. "unpublish skill foo" → Claude confirm + `goclaw skills unpublish foo --yes` +10. "clear all memory for agent X" → Claude confirm + `goclaw memory clear --agent X --yes` + +**Negative intents (skill should NOT load, C5 over-triggering test):** +11. "claw feet for my couch" (woodworking) → skill không load +12. "how do claws work on cats" → skill không load +13. "run npm install" (generic, không có context goclaw) → skill không load (hoặc load mà Claude decline) +14. "list my processes" (Unix shell, không phải goclaw) → skill không load + +**Streaming intent (U3 test):** +15. "tail logs for goclaw" → Claude explains streaming limitation + suggest `goclaw logs tail --limit 50 --output json` hoặc cách alternative + +Ghi log test vào `plans/reports/tester-260417--goclaw-skill-smoke.md`. Fail bất kỳ positive/destructive test → block release. + +### Step 5: /ck:journal entry +Sau test pass, chạy `/ck:journal` để viết note session. + +## Todo List + +- [ ] Implement `install.sh` — 3-mode selector, safe backup, /dev/tty, quoted heredoc, Py3 sanity, Windows abort, CLAUDE_HOME env, skill overwrite gate, dedupe +- [ ] Implement `check-drift.sh` — grep every flag trong references vs `cmd/*.go`, exit 1 nếu mismatch +- [ ] `bash -n install.sh check-drift.sh` + `shellcheck` — pass hoặc chỉ low-severity warnings +- [ ] Test install.sh matrix: fresh, re-run (idempotent), --mode 1, 2, 3, --dry-run, piped (must default Mode 3), Windows detect +- [ ] Write `README.md` với tarball+SHA256 install block + 3 example prompts + permission matrix + uninstall +- [ ] Update root `README.md` thêm section "Claude Code skill" link +- [ ] Build tarball: `tar czf goclaw-skill.tar.gz claude-skill/` + compute `sha256sum > goclaw-skill.sha256` +- [ ] Smoke test 15 prompts trong Claude Code (5 positive + 5 destructive + 4 negative + 1 streaming) +- [ ] Ghi test log vào `plans/reports/tester-260417--goclaw-skill-smoke.md` +- [ ] Fix issues từ test (tune description keywords nếu skill over/under-trigger) +- [ ] Create GitHub release `skill-v0.1.0` với tarball + sha256 attached +- [ ] `/ck:journal` final entry +- [ ] Commit phase 4 + tag + +## Success Criteria +- `./install.sh --dry-run` in đúng action list per mode +- `./install.sh --mode 2` merge ~20 readonly rules, idempotent (re-run không duplicate) +- `./install.sh --mode 1` confirm qua /dev/tty + merge `Bash(goclaw:*)` +- `./install.sh` piped → default Mode 3 (không patch) +- `check-drift.sh` exit 0 trên codebase hiện tại +- 10/10 positive + destructive smoke tests pass +- 4/4 negative tests pass (skill không over-trigger) +- README install block reproduce được trên máy sạch +- `shellcheck install.sh check-drift.sh` clean hoặc chỉ warnings acceptable +- GitHub release `skill-v0.1.0` public với tarball + sha256 + LICENSE + +## Risk +- `shellcheck` strict → có thể tốn 30p fix warnings; acceptable +- Python3 venv không tồn tại trên máy user mới cài Claude Code → fallback manual instruction PHẢI hoạt động (test case riêng) +- Description keywords không match intent user → tune sau test, không blocking + +## Security +- Backup settings.json TRƯỚC patch (không sau) +- Abort nếu JSON invalid (không ghi đè) +- `--full-auto` confirmation không bypass được qua pipe (read từ /dev/tty) + +## Next +- Plan done → tag skill-v0.1.0 +- Follow-up: collect user feedback, iterate references, cân nhắc marketplace publish nếu Anthropic ra diff --git a/plans/260417-1254-goclaw-claude-skill/plan.md b/plans/260417-1254-goclaw-claude-skill/plan.md new file mode 100644 index 0000000..01c8b99 --- /dev/null +++ b/plans/260417-1254-goclaw-claude-skill/plan.md @@ -0,0 +1,130 @@ +--- +title: GoClaw Claude Code Skill +status: in_progress +created: 2026-04-17 +priority: high +blockedBy: [] +blocks: [] +source: skill +license: MIT +distribution: github-releases-tarball +implementation_progress: "Phases 1-3 done. Phase 4 95% done (install.sh + check-drift shellcheck-clean; TTY probe fixed; tarball/SHA256 rebuilt; root README updated; test matrix passed: modes 1/2/3, idempotent, piped-default-3, piped-mode-1-refused). Remaining: user runs 15-prompt smoke test in Claude Code + GitHub release publish." +--- + +# GoClaw Claude Code Skill — Implementation Plan + +Build a Claude Code skill that lets Claude (agent) invoke goclaw CLI to interact with GoClaw Gateway server. Hero use case verified: `goclaw tools invoke exec --param command=""` (server has `exec` builtin tool at `goclaw/internal/tools/shell.go:112-128`, params: `command` required, `working_dir` optional). Scope: 36 top-level command groups. + +## Context Links + +- Brainstorm synthesis: conversation turns `4/16 14:48` + `4/17 12:41` +- Researcher (skill authoring): `plans/reports/researcher-260417-1254-claude-skill-authoring.md` +- Explorer (command inventory): `plans/reports/explore-260417-1254-goclaw-command-inventory.md` +- Red team review: `plans/reports/code-reviewer-260417-1254-goclaw-skill-red-team.md` +- CLI source: `cmd/*.go` (~60 files, 36 groups verified) +- Server source: `/Volumes/GOON/www/nlb/goclaw/cmd/gateway_builtin_tools.go` (builtin tool registry), `/Volumes/GOON/www/nlb/goclaw/internal/tools/shell.go` (exec impl) +- Previous CLI plans (completed): `plans/260315-0007-goclaw-cli-implementation/`, `plans/260326-1350-cli-feature-parity-update/` + +## Goals + +1. Claude Code agent có thể gọi `goclaw ` autonomously qua Bash, parse JSON output, thao tác GoClaw server +2. Skill trigger tự động khi user nói về GoClaw (keyword matching qua description) +3. Cover toàn bộ 38 command groups qua progressive disclosure (14-15 reference files) +4. OSS-publishable: install script + README + LICENSE trong repo `goclaw-cli` (thư mục `claude-skill/`) +5. Safety: default prompt destructive, opt-in full-auto qua install flag + +## Non-goals + +- MCP server (bỏ per brainstorm YAGNI) +- Streaming commands (chat interactive, logs tail) — document limitation, không wrap +- Windows PowerShell script — macOS/Linux only cho v1 +- Auto-generate references từ `--help` output — viết tay để control UX + +## Tech Stack + +- Markdown (SKILL.md + references/*) +- Bash (install.sh) +- Python3 (idempotent settings.json patcher, dùng venv `~/.claude/skills/.venv/bin/python3`) +- Hosting: trong repo `goclaw-cli` dưới `claude-skill/` để sync version với CLI + +## Architecture Decisions + +### D1 — Single repo (goclaw-cli/claude-skill/) vs separate repo +**Chọn:** đặt trong `goclaw-cli/claude-skill/`. Lý do: version sync tự động với CLI releases; user star 1 repo có cả 2; tránh divergence khi CLI đổi flags. + +### D2 — Skill name +**Chọn:** `goclaw` (ngắn, match binary name). Không dùng `goclaw-cli` (redundant). + +### D3 — Permission default (post-red-team revision) +**Problem cũ:** Red team chỉ ra `Bash(goclaw status)` không match `goclaw status --output json` (D6 force append `--output json`), và `Bash(goclaw list:*)` không match `goclaw agents list` (list không phải top-level). + +**Chọn mới:** Install-time user interactive prompt, không hardcode pattern: +1. Install.sh mặc định hỏi "Choose permission mode: [1] Full auto (Bash(goclaw:*)) [2] Readonly verbs only [3] Manual (no patching)". Default = 3. +2. Mode 1 (`Bash(goclaw:*)`) — empirically verify wildcard matches `goclaw anything --output json` (Claude Code doc: `Bash(cmd:*)` = prefix match with any args). Nếu không match, fallback Mode 2. +3. Mode 2: enumerate readonly verbs per resource với trailing wildcard: `Bash(goclaw agents list:*)`, `Bash(goclaw agents get:*)`, `Bash(goclaw sessions list:*)`, ... (~20 rules). Generated từ inventory, không hardcode tay. +4. Mode 3: in JSON snippet, user tự copy. +5. `--mode 1|2|3` flag cho scripting (bypass prompt). + +Loại bỏ `--full-auto` flag riêng; dùng `--mode 1` thay thế. + +### D4 — Settings.json patching +**Chọn:** Python3 idempotent merge (per researcher report). Lý do: cross-platform, không cần jq, dùng venv sẵn có. Fallback: in hướng dẫn manual nếu venv không tồn tại. + +### D5 — References structure +**Chọn:** 15 reference files (14 clusters từ explorer + 1 thêm `exec-workflow.md` cho hero use case). File nào > 300 lines tách tiếp. + +### D6 — Convention trong SKILL.md +**Chọn:** Claude LUÔN append `--output json` khi chạy goclaw (trừ streaming). Quy tắc này viết ở SKILL.md và lặp ở mỗi reference. + +## Phases Overview + +| Phase | Title | Status | Effort (hrs) | Deliverable | +|-------|-------|--------|--------------|-------------| +| 1 | [Scaffold skill structure](phase-01-scaffold-skill-structure.md) | ✅ completed | 2 | `claude-skill/` dir + SKILL.md + MIT LICENSE + 16 stub references + install.sh + check-drift.sh skeletons + `.verified-commands.txt` (38 groups from binary) | +| 2 | [Priority references (hero path)](phase-02-priority-references.md) | ✅ completed | 10 | 5 refs: exec-workflow (verified schema from `shell.go:114-128`), auth-and-config, agents-core, chat-sessions, monitoring-ops | +| 3 | [Remaining references](phase-03-remaining-references.md) | ✅ completed | 18 | 11 refs including `media` cluster; total 16 refs, 1709 lines markdown | +| 4 | [Install script + README + release + test](phase-04-install-readme-test.md) | 🟡 95% done | 8 | install.sh + check-drift.sh shellcheck-clean; has_tty probe fixes pipe edge case; test matrix passed (3 modes + idempotent + piped default + Mode1 piped refusal); tarball + SHA256 rebuilt; root README linked. **Remaining: user runs 15-prompt smoke test in Claude Code + GitHub release publish.** | + +**Total:** ~38 hrs ≈ 5 working days. + +## Dependencies + +- `goclaw` binary compiled + in PATH (user prereq) +- `~/.claude/skills/` directory exists (Claude Code installed) +- Python3 trong venv `~/.claude/skills/.venv/bin/python3` (nếu muốn auto-patch settings) + +## Success Criteria + +1. User nói "list agents trên goclaw" → Claude auto-invoke skill → chạy `goclaw agents list --output json` → summarize +2. User nói "run `ls -la` trên goclaw server" → Claude chạy `goclaw tools invoke exec --param command="ls -la"` → trả output + approval status +3. Destructive command (e.g. `goclaw agents delete xyz`) → Claude prompt user confirm trước khi thêm `--yes` +4. `./install.sh` chạy idempotent: chạy lần 2 không duplicate permissions +5. README chứa install one-liner + 3 example prompts +6. Manual test: skill trigger đúng cho ≥ 5 intent khác nhau, ≥ 90% commands Claude phát ra đúng flag + +## Risks + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| `allowed-tools` trong frontmatter không enforce (issue #14956) | High | Medium | Dùng settings.json permissions làm source of truth | +| Claude hallucinate flag name không có trong reference | Medium | Low | `check-drift.sh` grep every flag trong references vs `cmd/*.go` | +| settings.json malformed → install.sh phá config user | Low | High | `if [[ -f ]]; then cp ... \|\| exit 1; fi` (not `&&` chain); abort nếu invalid JSON | +| goclaw CLI đổi flag → reference outdated | Medium | Medium | `check-drift.sh` trong CI; version compat note ở SKILL.md frontmatter | +| User chọn Mode 1 trên prod → xóa data | Low | Critical | Mode 3 là default; Mode 1 có interactive confirm qua `/dev/tty` | +| Description keywords quá hẹp → skill không trigger | Medium | Low | 15-prompt smoke test (5 positive + 5 negative + 5 destructive) | +| Tarball download corruption / MITM | Low | High | SHA256 in install.sh; verify before extract | +| exec tool param schema đổi ở server release | Low | Medium | Pin supported goclaw versions trong SKILL.md; `check-drift.sh` optional server check | +| `curl|bash` install có stdin piped → interactive prompts fail | Medium | Medium | `read ... < /dev/tty`; refuse Mode 1 nếu `! -t 0` | + +## Security Considerations + +- **Token handling:** Skill KHÔNG lưu token — dùng credential store của goclaw CLI (đã có) +- **settings.json backup:** Trước patch, copy sang `.bak` với timestamp +- **Deny list default:** Include `Bash(goclaw tenants delete:*)`, `Bash(goclaw * --yes *)` trong deny cho non-full-auto mode +- **Public OSS hygiene:** KHÔNG hardcode server URL, tenant ID, token trong skill files + +## Next Steps + +1. Phase 1: scaffold (độc lập, có thể bắt đầu ngay) +2. Phase 2 & 3 có thể song song sau phase 1 +3. Phase 4 chốt sau khi 2+3 xong diff --git a/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-01-scope-lock.md b/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-01-scope-lock.md new file mode 100644 index 0000000..dcec545 --- /dev/null +++ b/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-01-scope-lock.md @@ -0,0 +1,60 @@ +--- +phase: 1 +title: "Scope Lock" +status: pending +priority: P2 +effort: "1h" +dependencies: [] +--- + +# Phase 1: Scope Lock + +## Overview + +Lock the 7-surface scope, re-verify backend contracts against `digitopvn/goclaw` `dev` tip, and produce the contract evidence file that subsequent phases reference. No CLI code changes. + +## Requirements + +- Re-fetch and read each backend handler to confirm path, method, query params, body shape, status enum, and error semantics. +- Confirm beta tag `v3.12.0-beta.20` is the minimum that contains PR #44 commit `43049d3b` (already verified during plan creation; phase re-asserts). +- Confirm no command-name collisions in current `cmd/` tree. +- Produce `reports/scope-lock-260527-p6.md` summarizing contracts and any drift discovered. + +## Implementation Steps + +1. From the `digitopvn/goclaw` checkout (or via `gh api`), read: + - `internal/http/traces.go` — locate the `traces/follow` handler. + - `internal/http/providers.go` — locate the `providers/{id}/reconnect` handler. + - `internal/http/sessions.go` — locate the branch and history/follow handlers. + - `internal/http/channel_instances.go` — locate the writers/test handler. + - `internal/http/activity.go` — locate the aggregate handler. + - `internal/http/logs.go` — locate the runtime/aggregate handler. + - `internal/http/openapi_spec.json` for any documented schema differences. +2. For each handler record: route, method, required vs optional inputs, response shape, status enum, error codes, admin-only flag. +3. Compare against `plans/reports/codex-prompt-260522-p6-pr44-backend-unblocked-cli.md` (lives on `feat/claude-skill-v0.1` worktree) and the plan overview. Flag any drift. +4. Re-verify beta tag: `gh api repos/digitopvn/goclaw/compare/v3.12.0-beta.20...43049d3b --jq '.status'` must return `identical`. +5. Re-verify no naming collisions: `grep -in 'Use:.*"follow\|reconnect\|branch\|aggregate\|test"' cmd/*.go`. +6. Write `reports/scope-lock-260527-p6.md` with the table of confirmed contracts. + +## Todo List + +- [ ] Backend contract re-read for all 7 endpoints. +- [ ] Drift table populated (or zero-drift confirmed). +- [ ] Beta tag re-confirmed. +- [ ] Naming collision sweep run with output captured. +- [ ] `reports/scope-lock-260527-p6.md` written. +- [ ] Phase status flipped to Complete. + +## Success Criteria + +- Zero unresolved contract questions before phase 2 starts. +- Scope file under `reports/` exists and is referenced from each implementation phase's "Evidence" section. + +## Out of Scope + +- Any CLI code change. +- Any test scaffolding (phase 2+). + +## Next Steps + +Proceed to Phase 2 (Traces Follow + Providers Reconnect). diff --git a/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-02-traces-follow-and-providers-reconnect.md b/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-02-traces-follow-and-providers-reconnect.md new file mode 100644 index 0000000..6a226c7 --- /dev/null +++ b/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-02-traces-follow-and-providers-reconnect.md @@ -0,0 +1,114 @@ +--- +phase: 2 +title: "Traces Follow + Providers Reconnect (PR #37 surfaces)" +status: pending +priority: P2 +effort: "3h" +dependencies: [1] +--- + +# Phase 2: Traces Follow + Providers Reconnect + +## Overview + +Implement two CLI surfaces from backend PR #37 (already in beta `v3.12.0-beta.16`+): polling-friendly trace follow and provider reconnect. Strict TDD — failing tests land before any Cobra command code. + +## Surfaces + +### 2.1 `goclaw traces follow` + +```bash +goclaw traces follow --session-key [--since ] [--limit N] [--include-spans] [--status ] [--channel ] [-o json|yaml|table] +goclaw traces follow --agent [same flags] +``` + +- Endpoint: `GET /v1/traces/follow` +- Require exactly one of `--session-key` or `--agent` (validate before HTTP call). +- Optional: `status`, `channel`, `since`, `limit`, `include_spans`. `since` must be RFC3339. +- Server default `limit=50`, max `200`. Don't enforce max client-side; let server respond. +- Response shape: + ```json + {"traces":[],"spans_by_trace_id":{},"server_time":"...","next_since":"...","limit":50} + ``` +- JSON/YAML: print full envelope. +- Table: rows like `traces list` — `TRACE_ID`, `AGENT`, `STATUS`, `DURATION_MS`, `INPUT_TOKENS`, `OUTPUT_TOKENS`, `COST`. +- **One request only. No watch loop.** + +### 2.2 `goclaw providers reconnect` + +```bash +goclaw providers reconnect [-o json|yaml|table] +``` + +- Endpoint: `POST /v1/providers/{id}/reconnect` +- Admin-only on backend; client sends no body. Do NOT send `{"verify":true}`. +- Do NOT add `--verify` flag. **The only verify-shaped command is `goclaw providers verify-embedding ` (`cmd/providers_verify.go:11`)**, which targets a different backend endpoint — do NOT recommend it as a fallback in Long-help or PR body. Backend handles reconnect verification server-side. +- Path-escape `` via `url.PathEscape` (see escaped-path pattern in `cmd/api_keys_rotate.go`). +- Response: + ```json + {"status":"reconnected","provider":{},"registry_updated":true,"cache_invalidated":true} + ``` +- Status enum: `reconnected`, `disabled`, `not_registered`. +- Table: `STATUS`, `REGISTRY_UPDATED`, `CACHE_INVALIDATED`, plus provider name/id if non-empty. + +## Files + +- Modify: `cmd/traces.go` — append `tracesFollowCmd` + register on `tracesCmd`. +- Modify: `cmd/providers.go` or new `cmd/providers_reconnect.go` (preferred — keep file < 200 lines per repo rule) — declare `providersReconnectCmd` + register on `providersCmd`. +- New: `cmd/traces_follow_test.go` +- New: `cmd/providers_reconnect_test.go` + +## TDD Sequence + +1. Write `cmd/traces_follow_test.go` with the failing tests below; run `go test ./cmd -run TracesFollow` and confirm red. +2. Implement `tracesFollowCmd` minimally until tests pass. +3. Write `cmd/providers_reconnect_test.go`; confirm red. +4. Implement `providersReconnectCmd`; confirm green. +5. `go vet ./... && go build ./...` clean. + +## Tests + +### `cmd/traces_follow_test.go` + +- Session-key target builds path `/v1/traces/follow?session_key=...&...`. +- Agent target builds path `/v1/traces/follow?agent_id=...&...`. +- Missing both target flags returns validation error before HTTP call. +- Setting both target flags returns validation error before HTTP call. +- Non-RFC3339 `--since` returns validation error before HTTP call. +- JSON output preserves `next_since` and `spans_by_trace_id`. +- Table output includes the seven required columns. +- **Atomic-counter test (Red Team F7):** wrap `httptest.NewServer` handler with `atomic.AddInt64(&calls, 1)`; assert `calls == 1` after `RunE`. Must NOT use `client.FollowStream` (`internal/client/follow.go`) — that would reconnect. +- **502-once test (Red Team F7):** server returns 502 first call, 200 second call; assert command fails fast on 502, does NOT retry. + +### `cmd/providers_reconnect_test.go` + +- POST path is `/v1/providers/{escaped-id}/reconnect`. +- Request body is empty (server receives `Content-Length: 0` or empty JSON; assert no `verify` key). +- JSON output preserves `registry_updated` and `cache_invalidated`. +- Table output renders status + boolean columns. +- Provider ID with `/` or `:` is path-escaped (regression test for RT-02). +- **Atomic-counter test (Red Team F7):** assert exactly one POST request issued. No retry/reconnect path. + +## Todo List + +- [ ] Red tests for traces follow. +- [ ] `tracesFollowCmd` implemented + green. +- [ ] Red tests for providers reconnect. +- [ ] `providersReconnectCmd` implemented + green. +- [ ] `go vet` + `go build` clean. +- [ ] Phase status flipped to Complete. + +## Success Criteria + +- Both commands callable via `goclaw --help`. +- All new tests pass without skipping or relying on live server. +- No regression in existing `traces list`, `traces get`, `traces export`, or any `providers` subcommand. + +## Risks + +- Forgetting path-escape on provider ID (RT-02 lesson). Mitigated by explicit escape test. +- Accidentally adding `--verify` based on familiarity with `providers verify`. Mitigated by codex prompt's explicit prohibition + test asserting empty body. + +## Next Steps + +Phase 3 (Sessions Branch + Follow). diff --git a/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-03-sessions-branch-and-follow.md b/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-03-sessions-branch-and-follow.md new file mode 100644 index 0000000..19235c2 --- /dev/null +++ b/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-03-sessions-branch-and-follow.md @@ -0,0 +1,120 @@ +--- +phase: 3 +title: "Sessions Branch + Follow (PR #44 chat surfaces)" +status: pending +priority: P2 +effort: "3h" +dependencies: [2] +--- + +# Phase 3: Sessions Branch + Follow + +## Overview + +Implement the two chat-session surfaces from backend PR #44 (beta `v3.12.0-beta.20`+): branch a session at a message index, and cursor-based history follow. TDD. + +## Surfaces + +### 3.1 `goclaw sessions branch` + +```bash +goclaw sessions branch --up-to-index [--new-session-key ] [--label