From 174b5ba356ca84ec6da0b52acaba32c34e79ae02 Mon Sep 17 00:00:00 2001 From: Duy /zuey/ Date: Tue, 19 May 2026 16:15:55 +0700 Subject: [PATCH 1/4] feat(cli): add domain coverage p3 fillers Add Domain Coverage P3 CLI fillers, harden beta release workflow, and sync roadmap/docs. --- .github/workflows/release.yaml | 11 +- CHANGELOG.md | 10 +- CLAUDE.md | 4 +- README.md | 17 +- cmd/cmd_test.go | 2 + cmd/p3_commands_test.go | 114 +++++++++++ cmd/profile.go | 146 +++++++++++++ cmd/profile_test.go | 77 +++++++ cmd/root.go | 10 +- cmd/sessions.go | 27 ++- cmd/status.go | 17 ++ cmd/traces.go | 8 + docs/code-standards.md | 10 +- docs/codebase-summary.md | 31 +-- docs/deployment-guide.md | 82 ++++++-- docs/project-overview-pdr.md | 5 +- docs/project-roadmap.md | 25 ++- docs/system-architecture.md | 32 +-- internal/config/config.go | 119 ++++------- internal/config/config_test.go | 107 ++++++++++ internal/config/profile.go | 191 ++++++++++++++++++ internal/output/error.go | 16 +- internal/output/error_test.go | 24 ++- internal/output/tty.go | 12 ++ internal/output/tty_test.go | 16 ++ .../phase-03-ai-critical-fillers.md | 22 +- .../phase-04-ux-polish-batch-1.md | 44 ++-- .../phase-05-fillers-verification-batch-2.md | 45 ++--- .../phase-06-server-fr-backlog.md | 4 +- .../plan.md | 18 +- 30 files changed, 1011 insertions(+), 235 deletions(-) create mode 100644 cmd/p3_commands_test.go create mode 100644 cmd/profile.go create mode 100644 cmd/profile_test.go create mode 100644 internal/config/profile.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 44e0588..2798392 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -7,6 +7,10 @@ on: permissions: contents: write +concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest @@ -29,6 +33,8 @@ jobs: if: github.ref_name == 'dev' shell: bash run: | + # dev publishes prereleases for the next minor after the latest stable tag. + # Example: latest stable v0.5.0 => dev releases v0.6.0-beta.N. latest_stable="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1)" if [ -z "$latest_stable" ]; then major=0 @@ -45,6 +51,8 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} hooks: goreleaser + changelog-file: CHANGELOG.md + prepend: true allow-initial-development-versions: true prerelease: ${{ github.ref_name == 'dev' }} env: @@ -55,7 +63,6 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_VERSION: ${{ steps.semrel.outputs.version }} - RELEASE_CHANGELOG: ${{ steps.semrel.outputs.changelog }} run: | - printf '%s\n' "$RELEASE_CHANGELOG" > CHANGELOG.md + test -s CHANGELOG.md gh release upload "v${RELEASE_VERSION}" CHANGELOG.md --clobber diff --git a/CHANGELOG.md b/CHANGELOG.md index a8a1963..20254c7 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–P2) +## [Unreleased] — Domain Coverage Expansion (P0–P3) ### Added @@ -27,8 +27,16 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `goclaw teams workspace upload` and `goclaw teams workspace move` — multipart upload + rename for team workspace. - `goclaw packages github-releases` — list GitHub releases for tracked packages. +**P3 — AI-critical fillers** +- `goclaw profile` (list, current, create, use, delete) — first-class CLI profile management with safe profile names. +- `GOCLAW_PROFILE` — per-command profile selection precedence between `--profile` and active config. +- `goclaw sessions compact ` — invokes WS RPC `sessions.compact` behind destructive confirmation. +- `goclaw health` — uses WS RPC `health` when authenticated, retaining unauthenticated HTTP `/health` fallback. +- `goclaw traces list --since --agent --status --root-only --limit` — expanded filters for automation-friendly trace search. + ### 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 the next implementation pass. - Out of scope: OpenAI-compatible `/chat/completions` and `/v1/responses` endpoints (client APIs, not admin CLI surface). --- diff --git a/CLAUDE.md b/CLAUDE.md index e048937..df5d9eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ internal/ These patterns are **locked** — do not change without updating CHANGELOG.md. ### Output format auto-detection -Precedence: `--output` flag > `GOCLAW_OUTPUT` env > TTY detection +Precedence: `--output` flag > `GOCLAW_OUTPUT` env > active profile output default > TTY detection - stdout is TTY → `"table"` (human) - stdout is piped/redirected → `"json"` (machine) @@ -84,7 +84,7 @@ All command errors bubble via `return err` to `cmd.Execute()` → `output.PrintE - `readContent()` — read from `@filepath` or literal string - `unmarshalMap()` / `unmarshalList()` — parse JSON responses - `printer.Print()` — output in configured format -- `output.ResolveFormat(flagVal)` — resolve format with TTY fallback +- `output.ResolveFormatWithDefault(flagVal, profileDefault)` — resolve format with profile default + TTY fallback - `output.FromError(err)` — map error to exit code - `output.PrintError(err, format)` — format-aware error output - `client.FollowStream(ctx, ...)` — persistent WS streaming with reconnect diff --git a/README.md b/README.md index 0f46eed..155bd29 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,10 @@ echo "Analyze this log" | goclaw chat myagent | Command | Description | |---------|-------------| | `auth` | Login, logout, device pairing, profile management | +| `profile` | List, create, switch, inspect, and delete CLI profiles | | `agents` | CRUD, shares, delegation links, per-user instances | | `chat` | Interactive or single-shot messaging with streaming | -| `sessions` | List, preview, delete, reset, label | +| `sessions` | List, preview, delete, reset, label, compact | | `skills` | Upload, manage, grant/revoke access | | `mcp` | MCP server management, grants, access requests | | `providers` | LLM provider CRUD, model listing, verification | @@ -63,7 +64,7 @@ echo "Analyze this log" | goclaw chat myagent | `cron` | Scheduled jobs CRUD, trigger, run history | | `teams` | Team management, task board, workspace | | `channels` | Channel instances, contacts, pending messages | -| `traces` | LLM trace viewer, export | +| `traces` | LLM trace viewer, filters, export | | `memory` | Memory documents, semantic search | | `knowledge-graph` | Entity extraction, linking, querying | | `usage` | Usage analytics and cost breakdown | @@ -321,10 +322,11 @@ GoClaw CLI auto-detects the appropriate output format: |-----------|---------------| | stdout is a terminal (TTY) | `table` — human-readable aligned columns | | stdout is piped / redirected | `json` — machine-readable, one object per response | +| active profile has an output default | configured profile default — unless env/flag overrides it | | `GOCLAW_OUTPUT=yaml` env set | `yaml` — regardless of TTY | | `--output` / `-o` flag set | exact value — overrides everything | -**Precedence (highest → lowest):** `--output` flag > `GOCLAW_OUTPUT` env > TTY detection +**Precedence (highest → lowest):** `--output` flag > `GOCLAW_OUTPUT` env > active profile output default > TTY detection ```bash # Explicit format (always wins) @@ -391,9 +393,18 @@ profiles: Switch profiles: ```bash +goclaw profile list +goclaw profile use staging goclaw auth use-context staging ``` +One-shot profile override: + +```bash +goclaw --profile staging agents list +GOCLAW_PROFILE=staging goclaw traces list --since=1h --root-only -o json +``` + ## Claude Code Skill A Claude Code skill wrapping this CLI lives in [`claude-skill/`](./claude-skill/). diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index fe77109..d432a91 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -39,6 +39,7 @@ func TestAllCommandsRegistered(t *testing.T) { "memory", "packages", "pending-messages", + "profile", "providers", "sessions", "skills", @@ -113,6 +114,7 @@ func TestCommandUseFields(t *testing.T) { {"memory"}, {"packages"}, {"pending-messages"}, + {"profile"}, {"providers"}, {"sessions"}, {"skills"}, diff --git a/cmd/p3_commands_test.go b/cmd/p3_commands_test.go new file mode 100644 index 0000000..b157cc9 --- /dev/null +++ b/cmd/p3_commands_test.go @@ -0,0 +1,114 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/websocket" + "github.com/nextlevelbuilder/goclaw-cli/internal/config" + "github.com/nextlevelbuilder/goclaw-cli/internal/output" +) + +func setupP3CommandTest(serverURL string) { + cfg = &config.Config{Server: serverURL, Token: "test-token", OutputFormat: "json", Yes: true} + printer = output.NewPrinter("json") +} + +func mockRPCServer(t *testing.T, seen chan<- string) *httptest.Server { + t.Helper() + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("ws upgrade: %v", err) + } + defer conn.Close() + for { + var req struct { + Type string `json:"type"` + ID string `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if err := conn.ReadJSON(&req); err != nil { + return + } + if seen != nil { + seen <- req.Method + } + payload, _ := json.Marshal(map[string]any{"ok": true, "method": req.Method}) + _ = conn.WriteJSON(map[string]any{ + "type": "res", + "id": req.ID, + "ok": true, + "payload": json.RawMessage(payload), + }) + } + })) +} + +func TestSessionsCompactUsesWSMethod(t *testing.T) { + seen := make(chan string, 2) + srv := mockRPCServer(t, seen) + defer srv.Close() + setupP3CommandTest(srv.URL) + + if err := sessionsCompactCmd.RunE(sessionsCompactCmd, []string{"session-1"}); err != nil { + t.Fatalf("sessions compact: %v", err) + } + <-seen // connect + if method := <-seen; method != "sessions.compact" { + t.Fatalf("expected sessions.compact, got %s", method) + } +} + +func TestHealthUsesWSMethodWhenAuthenticated(t *testing.T) { + seen := make(chan string, 2) + srv := mockRPCServer(t, seen) + defer srv.Close() + setupP3CommandTest(srv.URL) + + if err := healthCmd.RunE(healthCmd, nil); err != nil { + t.Fatalf("health: %v", err) + } + <-seen // connect + if method := <-seen; method != "health" { + t.Fatalf("expected health, got %s", method) + } +} + +func TestTracesListAddsP3Filters(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/traces" { + w.WriteHeader(http.StatusNotFound) + return + } + q := r.URL.Query() + if q.Get("agent_id") != "agent-1" || q.Get("status") != "error" || + q.Get("since") != "1h" || q.Get("root_only") != "true" || q.Get("limit") != "5" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + okJSON(t, w, []map[string]any{{"trace_id": "trace-1"}}) + })) + defer srv.Close() + setupP3CommandTest(srv.URL) + + _ = tracesListCmd.Flags().Set("agent", "agent-1") + _ = tracesListCmd.Flags().Set("status", "error") + _ = tracesListCmd.Flags().Set("since", "1h") + _ = tracesListCmd.Flags().Set("root-only", "true") + _ = tracesListCmd.Flags().Set("limit", "5") + t.Cleanup(func() { + _ = tracesListCmd.Flags().Set("agent", "") + _ = tracesListCmd.Flags().Set("status", "") + _ = tracesListCmd.Flags().Set("since", "") + _ = tracesListCmd.Flags().Set("root-only", "false") + _ = tracesListCmd.Flags().Set("limit", "20") + }) + + if err := tracesListCmd.RunE(tracesListCmd, nil); err != nil { + t.Fatalf("traces list: %v", err) + } +} diff --git a/cmd/profile.go b/cmd/profile.go new file mode 100644 index 0000000..45a642f --- /dev/null +++ b/cmd/profile.go @@ -0,0 +1,146 @@ +package cmd + +import ( + "fmt" + + "github.com/nextlevelbuilder/goclaw-cli/internal/config" + "github.com/nextlevelbuilder/goclaw-cli/internal/output" + "github.com/nextlevelbuilder/goclaw-cli/internal/tui" + "github.com/spf13/cobra" +) + +var profileCmd = &cobra.Command{ + Use: "profile", + Short: "Manage CLI connection profiles", +} + +var profileListCmd = &cobra.Command{ + Use: "list", + Short: "List configured profiles", + RunE: func(cmd *cobra.Command, args []string) error { + profiles, active, err := config.ListProfiles() + if err != nil { + return err + } + if cfg.OutputFormat != "table" { + printer.Print(map[string]any{"active": active, "profiles": profiles}) + return nil + } + tbl := output.NewTable("ACTIVE", "NAME", "SERVER", "OUTPUT") + for _, p := range profiles { + marker := "" + if p.Name == active { + marker = "*" + } + tbl.AddRow(marker, p.Name, p.Server, p.OutputFormat) + } + printer.Print(tbl) + return nil + }, +} + +var profileCurrentCmd = &cobra.Command{ + Use: "current", + Short: "Print the active profile name", + RunE: func(cmd *cobra.Command, args []string) error { + if cfg.OutputFormat != "table" { + printer.Print(map[string]any{"profile": cfg.Profile}) + return nil + } + fmt.Fprintln(cmd.OutOrStdout(), cfg.Profile) + return nil + }, +} + +var profileUseCmd = &cobra.Command{ + Use: "use ", + Short: "Switch active profile", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if err := config.ValidateProfileName(name); err != nil { + return err + } + if err := config.SetActiveProfile(name); err != nil { + return err + } + printer.Success(fmt.Sprintf("Switched to profile %q", name)) + return nil + }, +} + +var profileCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a profile", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if err := config.ValidateProfileName(name); err != nil { + return err + } + server := cfg.Server + outputFormat, _ := cmd.Flags().GetString("profile-output") + copyFrom, _ := cmd.Flags().GetString("copy-from") + + if copyFrom != "" { + if err := config.ValidateProfileName(copyFrom); err != nil { + return err + } + profiles, _, err := config.ListProfiles() + if err != nil { + return err + } + found := false + for _, p := range profiles { + if p.Name == copyFrom { + found = true + if server == "" { + server = p.Server + } + if outputFormat == "" { + outputFormat = p.OutputFormat + } + break + } + } + if !found { + return &config.ProfileNotFoundError{Name: copyFrom} + } + } + + profile := config.Profile{Name: name, Server: server, OutputFormat: outputFormat} + if err := config.Save(profile, false); err != nil { + return err + } + printer.Success(fmt.Sprintf("Created profile %q", name)) + return nil + }, +} + +var profileDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a profile", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if err := config.ValidateProfileName(name); err != nil { + return err + } + if name == cfg.Profile && !tui.Confirm(fmt.Sprintf("Delete active profile %s?", name), cfg.Yes) { + return nil + } + if err := config.RemoveProfile(name); err != nil { + return err + } + printer.Success(fmt.Sprintf("Deleted profile %q", name)) + return nil + }, +} + +func init() { + profileCreateCmd.Flags().String("copy-from", "", "Copy server and output settings from an existing profile") + profileCreateCmd.Flags().String("profile-output", "", "Default output format for this profile") + + profileCmd.AddCommand(profileListCmd, profileCurrentCmd, profileUseCmd, profileCreateCmd, profileDeleteCmd) + rootCmd.AddCommand(profileCmd) +} diff --git a/cmd/profile_test.go b/cmd/profile_test.go new file mode 100644 index 0000000..a1f58e9 --- /dev/null +++ b/cmd/profile_test.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "errors" + "testing" + + "github.com/nextlevelbuilder/goclaw-cli/internal/config" + "github.com/nextlevelbuilder/goclaw-cli/internal/output" +) + +func setupProfileCommandTest(t *testing.T) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + cfg = &config.Config{Profile: config.DefaultProfileName, OutputFormat: "json", Yes: true} + printer = output.NewPrinter("json") +} + +func TestProfileCreateUseAndDelete(t *testing.T) { + setupProfileCommandTest(t) + cfg.Server = "https://staging.example.com" + + if err := profileCreateCmd.RunE(profileCreateCmd, []string{"staging"}); err != nil { + t.Fatalf("profile create: %v", err) + } + profiles, _, err := config.ListProfiles() + if err != nil { + t.Fatalf("list profiles: %v", err) + } + if len(profiles) != 1 || profiles[0].Name != "staging" || profiles[0].Server != cfg.Server { + t.Fatalf("unexpected profiles after create: %#v", profiles) + } + + if err := profileUseCmd.RunE(profileUseCmd, []string{"staging"}); err != nil { + t.Fatalf("profile use: %v", err) + } + _, active, err := config.ListProfiles() + if err != nil { + t.Fatalf("list active: %v", err) + } + if active != "staging" { + t.Fatalf("expected active staging, got %q", active) + } + + cfg.Profile = "default" + if err := profileDeleteCmd.RunE(profileDeleteCmd, []string{"staging"}); err != nil { + t.Fatalf("profile delete: %v", err) + } + profiles, _, err = config.ListProfiles() + if err != nil { + t.Fatalf("list after delete: %v", err) + } + if len(profiles) != 0 { + t.Fatalf("expected no profiles after delete, got %#v", profiles) + } +} + +func TestProfileRejectsUnsafeName(t *testing.T) { + setupProfileCommandTest(t) + err := profileCreateCmd.RunE(profileCreateCmd, []string{"../prod"}) + if err == nil { + t.Fatal("expected unsafe profile name error") + } +} + +func TestProfileCreateCopyFromMissingErrors(t *testing.T) { + setupProfileCommandTest(t) + _ = profileCreateCmd.Flags().Set("copy-from", "missing") + t.Cleanup(func() { _ = profileCreateCmd.Flags().Set("copy-from", "") }) + + err := profileCreateCmd.RunE(profileCreateCmd, []string{"staging"}) + var notFound *config.ProfileNotFoundError + if !errors.As(err, ¬Found) || notFound.Name != "missing" { + t.Fatalf("expected missing copy-from profile error, got %T %v", err, err) + } +} diff --git a/cmd/root.go b/cmd/root.go index a6098c0..68ae70b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,17 +26,15 @@ var rootCmd = &cobra.Command{ return fmt.Errorf("config load: %w", err) } - // Resolve output format: flag > GOCLAW_OUTPUT env > TTY detect + // Resolve output format: flag > GOCLAW_OUTPUT env > profile default > TTY detect // config.Load already applied env + flag precedence into cfg.OutputFormat, - // but only when the flag was explicitly Changed. Re-resolve here so that - // the TTY fallback kicks in when neither flag nor env is set. + // but re-resolve here so TTY fallback kicks in when neither flag nor env + // is set while preserving a profile-level output default. flagVal := "" if cmd.Flags().Changed("output") { flagVal, _ = cmd.Flags().GetString("output") - } else if v := os.Getenv("GOCLAW_OUTPUT"); v != "" { - flagVal = v } - cfg.OutputFormat = output.ResolveFormat(flagVal) + cfg.OutputFormat = output.ResolveFormatWithDefault(flagVal, cfg.ProfileOutputFormat) printer = output.NewPrinter(cfg.OutputFormat) return nil diff --git a/cmd/sessions.go b/cmd/sessions.go index 3b4b016..714a281 100644 --- a/cmd/sessions.go +++ b/cmd/sessions.go @@ -133,6 +133,31 @@ var sessionsLabelCmd = &cobra.Command{ }, } +var sessionsCompactCmd = &cobra.Command{ + Use: "compact ", + Short: "Compact a session context window", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !tui.Confirm(fmt.Sprintf("Compact session %s?", args[0]), cfg.Yes) { + return nil + } + ws, err := newWS("cli") + if err != nil { + return err + } + if _, err := ws.Connect(); err != nil { + return fmt.Errorf("connect: %w", err) + } + defer ws.Close() + resp, err := ws.Call("sessions.compact", map[string]any{"session_key": args[0]}) + if err != nil { + return err + } + printer.Print(jsonToMap(resp)) + return nil + }, +} + func init() { sessionsListCmd.Flags().String("agent", "", "Filter by agent ID") sessionsListCmd.Flags().String("user", "", "Filter by user ID") @@ -140,6 +165,6 @@ func init() { sessionsLabelCmd.Flags().String("label", "", "Session label") _ = sessionsLabelCmd.MarkFlagRequired("label") - sessionsCmd.AddCommand(sessionsListCmd, sessionsPreviewCmd, sessionsDeleteCmd, sessionsResetCmd, sessionsLabelCmd) + sessionsCmd.AddCommand(sessionsListCmd, sessionsPreviewCmd, sessionsDeleteCmd, sessionsResetCmd, sessionsLabelCmd, sessionsCompactCmd) rootCmd.AddCommand(sessionsCmd) } diff --git a/cmd/status.go b/cmd/status.go index 0208c75..28e37e9 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -16,6 +16,23 @@ var healthCmd = &cobra.Command{ if cfg.Server == "" { return client.ErrServerRequired } + if cfg.Token != "" { + ws, err := newWS("cli") + if err != nil { + return err + } + if _, err := ws.Connect(); err != nil { + return fmt.Errorf("connect: %w", err) + } + defer ws.Close() + healthResp, err := ws.Call("health", nil) + if err != nil { + return fmt.Errorf("health: %w", err) + } + printer.Print(jsonToMap(healthResp)) + return nil + } + c := client.NewHTTPClient(cfg.Server, cfg.Token, cfg.Insecure) if err := c.HealthCheck(); err != nil { return err diff --git a/cmd/traces.go b/cmd/traces.go index e21fefa..5372cfd 100644 --- a/cmd/traces.go +++ b/cmd/traces.go @@ -26,6 +26,12 @@ var tracesListCmd = &cobra.Command{ if v, _ := cmd.Flags().GetString("status"); v != "" { q.Set("status", v) } + if v, _ := cmd.Flags().GetString("since"); v != "" { + q.Set("since", v) + } + if v, _ := cmd.Flags().GetBool("root-only"); v { + q.Set("root_only", "true") + } if v, _ := cmd.Flags().GetInt("limit"); v > 0 { q.Set("limit", fmt.Sprintf("%d", v)) } @@ -232,6 +238,8 @@ var usageBreakdownCmd = &cobra.Command{ func init() { tracesListCmd.Flags().String("agent", "", "Filter by agent ID") tracesListCmd.Flags().String("status", "", "Filter: running, success, error") + tracesListCmd.Flags().String("since", "", "Filter by relative or ISO timestamp, e.g. 1h or 2026-05-19T00:00:00Z") + tracesListCmd.Flags().Bool("root-only", false, "Only show root traces") tracesListCmd.Flags().Int("limit", 20, "Max results") tracesExportCmd.Flags().StringP("output", "f", "", "Output file (default: .json.gz)") diff --git a/docs/code-standards.md b/docs/code-standards.md index cd67065..c5d1d4a 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -565,19 +565,23 @@ Before submitting PR: **Format Auto-Detection (precedence):** 1. `--output` flag (explicit) 2. `GOCLAW_OUTPUT` environment variable -3. `stdout` is a TTY → `"table"` -4. else (piped/CI) → `"json"` +3. Active profile output default +4. `stdout` is a TTY → `"table"` +5. else (piped/CI) → `"json"` **Implementation:** ```go // internal/output/tty.go -func ResolveFormat(flagValue string) string { +func ResolveFormatWithDefault(flagValue, profileDefault string) string { if flagValue != "" { return flagValue // --output flag wins } if env := os.Getenv("GOCLAW_OUTPUT"); env != "" { return env // env override } + if profileDefault != "" { + return profileDefault + } if IsTTY(os.Stdout) { return "table" // Human-friendly } diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 58f8680..8ec5939 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-18 -**Phase Status:** P0-P4 Complete (AI-First Expansion); Super Admin API Parity Complete +**Generated from:** `repomix-output.xml` (2026-04-15), updated manually 2026-05-19 +**Phase Status:** P0-P4 Complete (AI-First Expansion); Super Admin API Parity Complete; Domain Coverage P3 Complete **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. +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 filler pass adds first-class profile commands, `GOCLAW_PROFILE`, `sessions compact`, WS health, and trace filter polish. **Key Metrics:** - **70+ command files** in `cmd/` (modularized for maintainability) @@ -42,10 +42,11 @@ All files follow Cobra pattern: root command + subcommands. | File | Commands | LOC | Purpose | |------|----------|-----|---------| | `root.go` | `goclaw` (root), global flags | 52 | Root command + persistent flags | -| `auth.go` | `auth`, `credentials` | 180+ | Login, logout, profile mgmt | +| `auth.go` | `auth`, `credentials` | 180+ | Login, logout, legacy profile aliases | +| `profile.go` | `profile` | 120+ | Profile list/current/create/use/delete | | `agents.go` | `agents` (list/get/create/update/delete) | 250+ | Agent CRUD operations | | `chat.go` | `chat` | 300+ | Interactive + streaming chat | -| `sessions.go` | `sessions` (list/get/delete/reset/label) | 200+ | Session management | +| `sessions.go` | `sessions` (list/get/delete/reset/label/compact) | 200+ | Session management + WS compaction | | `skills.go` | `skills` (list/upload/delete) | 200+ | Skill management | | `mcp.go` | `mcp` (list/add/remove/grants) | 250+ | MCP server management | | `providers.go` | `providers` (list/create/update/delete) | 200+ | LLM provider mgmt | @@ -53,13 +54,13 @@ All files follow Cobra pattern: root command + subcommands. | `cron.go` | `cron` (list/create/delete/trigger) | 220+ | Scheduled job management | | `teams.go` | `teams` (list/create/members) | 270+ | Team management (largest file) | | `channels.go` | `channels` (list/contacts) | 200+ | Channel management | -| `traces.go` | `traces` (list/export) | 180+ | LLM trace viewing | +| `traces.go` | `traces` (list/export + filters) | 180+ | LLM trace viewing | | `memory.go` | `memory` (list/search/upsert) | 180+ | Memory document management | | `config_cmd.go` | `config` (get/apply/patch/permissions) | 230+ | Server config + permissions | | `logs.go` | `logs` | 120+ | Real-time log streaming | | `storage.go` | `storage` (list/download) | 150+ | Workspace file browser | | `admin.go` | Admin operations | 250+ | Admin commands | -| `status.go` | `status` | 80+ | Server health check | +| `status.go` | `status`, `health` | 100+ | Server status + HTTP/WS health check | | `version.go` | `version` | 60+ | Version display | | `api_keys.go` | `api-keys` (list/create/revoke) | 135 | API key management | | `api_docs.go` | `api-docs` (open/spec) | 82 | API documentation viewer | @@ -130,7 +131,8 @@ internal/client/ ``` internal/config/ -└── config.go +├── config.go +└── profile.go ``` **Features:** @@ -140,8 +142,9 @@ internal/config/ - `Load()` function: Implements precedence: flags > env > file > defaults - `Dir()`: Returns ~/.goclaw/ - `FilePath()`: Returns ~/.goclaw/config.yaml -- Multi-profile support with `FindProfile()` -- Environment variables: GOCLAW_SERVER, GOCLAW_TOKEN, GOCLAW_OUTPUT +- Multi-profile support with `FindProfile()`, `SetActiveProfile()`, and safe profile-name validation +- Legacy single-profile config migration removes token from config.yaml and writes `credentials_` +- Environment variables: GOCLAW_SERVER, GOCLAW_TOKEN, GOCLAW_OUTPUT, GOCLAW_PROFILE #### output/ — Output Formatting + Error Handling @@ -161,8 +164,9 @@ internal/output/ **TTY-aware format resolution (precedence):** 1. `--output` flag (explicit) 2. `GOCLAW_OUTPUT` environment variable -3. stdout is TTY → `"table"` -4. else → `"json"` +3. active profile output default +4. stdout is TTY → `"table"` +5. else → `"json"` **Printer struct:** - Methods: `Print()`, `Error()`, `Success()` @@ -465,8 +469,9 @@ Each level overrides the previous. ### Profile Management - Multiple profiles in `~/.goclaw/config.yaml` -- Set active via `goclaw auth use-context ` +- Set active via `goclaw profile use ` or legacy `goclaw auth use-context ` - Override per-command: `goclaw --profile staging agents list` +- Env override: `GOCLAW_PROFILE=staging goclaw traces list --since=1h` ### Automation Mode - Flags: `--yes` (skip prompts), `--output json` (machine output), `--verbose` (debug) diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index 3a086e0..66d8bba 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -195,20 +195,45 @@ make clean ### Create Release (Automated via GitHub Actions) -**Trigger Release:** +Releases are branch-driven and generated from conventional commits through +`go-semantic-release` + GoReleaser. + +**Beta release from `dev`:** + +```bash +# Merge or push conventional commits to dev +git push origin dev + +# GitHub Actions automatically: +# 1. Runs build, vet, tests, race detector +# 2. Calculates the next beta version from commits +# 3. Creates a prerelease tag like v0.6.0-beta.1 +# 4. Builds all platform archives with GoReleaser +# 5. Generates changelog from commits and uploads CHANGELOG.md +``` + +**Stable release from `main`:** ```bash -# Create and push tag -git tag v1.0.0 -git push origin v1.0.0 +# Merge or push conventional commits to main +git push origin main # GitHub Actions automatically: -# 1. Runs go vet and go test -# 2. Builds for all platforms (GoReleaser) -# 3. Creates checksums -# 4. Publishes release to GitHub +# 1. Runs the same validation gate +# 2. Calculates next stable semver from commits +# 3. Creates a stable tag like v0.6.0 +# 4. Publishes binaries, checksums, release notes, and changelog ``` +**Version bump rules:** + +| Commit type | Release impact | +|-------------|----------------| +| `fix:` | patch | +| `feat:` | minor | +| `feat!:` or `BREAKING CHANGE:` | major | +| `docs:`, `test:`, `ci:` | included/excluded by release tooling depending on changelog filters; no feature bump by default | + **Verify Release:** ```bash @@ -320,14 +345,14 @@ jobs: 4. Run `go vet` (linting) 5. Run tests with race detector -#### release.yaml (Build & Release on Tag) +#### release.yaml (Semantic Release + GoReleaser) ```yaml name: Release on: push: - tags: ['v*'] + branches: [main, dev] permissions: contents: write @@ -342,22 +367,33 @@ jobs: - uses: actions/setup-go@v5 with: go-version: '1.25' - - uses: goreleaser/goreleaser-action@v6 + - name: Build + run: go build ./... + - name: Vet + run: go vet ./... + - name: Test + run: go test -count=1 ./... + - uses: go-semantic-release/action@v1 with: - version: '~> v2' - args: release --clean + github-token: ${{ secrets.GITHUB_TOKEN }} + hooks: goreleaser + changelog-file: CHANGELOG.md + prepend: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` **Triggers:** -- On push of tags matching `v*` (e.g., v1.0.0) +- Push to `dev`: prerelease beta stream. +- Push to `main`: stable release stream. **Steps:** 1. Checkout full history 2. Setup Go 1.25 -3. Run GoReleaser v2 -4. Build and publish to GitHub Releases +3. Run build, vet, test, race detector +4. Compute next SemVer from conventional commits +5. Run GoReleaser through the semantic-release hook +6. Publish GitHub Release assets and generated changelog --- @@ -425,7 +461,8 @@ docker run -it \ |----------|---------|---------|----------| | `GOCLAW_SERVER` | GoClaw server URL | `https://goclaw.example.com` | Yes | | `GOCLAW_TOKEN` | Authentication token | `sk_prod_abc123xyz` | Yes* | -| `GOCLAW_OUTPUT` | Output format | `json`, `table`, `yaml` | No (default: table) | +| `GOCLAW_OUTPUT` | Output format | `json`, `table`, `yaml` | No (overrides profile/default output) | +| `GOCLAW_PROFILE` | Config profile name | `staging` | No | *Token stored in OS keyring if using `auth login` interactively. @@ -436,7 +473,7 @@ docker run -it \ | `--server` | `GOCLAW_SERVER` | Override server URL | `--server https://staging.example.com` | | `--token` | `GOCLAW_TOKEN` | Override token | `--token new-token` | | `--output, -o` | `GOCLAW_OUTPUT` | Output format | `--output json` | -| `--profile` | — | Select config profile | `--profile staging` | +| `--profile` | `GOCLAW_PROFILE` | Select config profile | `--profile staging` | | `--yes, -y` | — | Skip confirmation prompts | `--yes` | | `--verbose, -v` | — | Enable debug logging | `--verbose` | | `--insecure` | — | Skip TLS verification | `--insecure` | @@ -455,17 +492,18 @@ docker run -it \ export GOCLAW_SERVER=https://staging.com goclaw agents list -3. Config File (~/.goclaw/config.yaml) +3. Config Profile (~/.goclaw/config.yaml) active_profile: production profiles: - name: production server: https://goclaw.example.com + output: table -4. Profile Defaults - If --profile staging specified and exists in config +4. Profile Output Default + Used when --output and GOCLAW_OUTPUT are absent 5. Built-in Defaults - OutputFormat: "table" + stdout TTY => table, piped/CI => json Insecure: false ``` diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index fd26661..e852e45 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -121,9 +121,10 @@ goclaw status # Server health check ### Configuration Hierarchy 1. **CLI Flags** (highest priority) — `goclaw agents list -o json` -2. **Environment Variables** — `GOCLAW_SERVER`, `GOCLAW_TOKEN`, `GOCLAW_OUTPUT` +2. **Environment Variables** — `GOCLAW_SERVER`, `GOCLAW_TOKEN`, `GOCLAW_OUTPUT`, `GOCLAW_PROFILE` 3. **Config File** — `~/.goclaw/config.yaml` -4. **Defaults** — Built-in defaults +4. **Profile Defaults** — active profile server/output settings +5. **Defaults** — Built-in defaults ### Command Structure - Root command: `goclaw` diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 1acfbc7..b220ecb 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,9 +1,28 @@ # GoClaw CLI - Project Roadmap -**Last Updated:** 2026-05-18 +**Last Updated:** 2026-05-19 **Phase Structure:** Legacy Phases 1-9 (bootstrap → CI/CD) + AI-First Expansion Phases 0-5 (2026-04-15) -**Current Status:** Legacy Phases 1-9 ✓ COMPLETE; P0-P4 ✓ COMPLETE; Super Admin API Parity ✓ COMPLETE -**Next Phase:** Route-drift monitoring and any deferred low-priority endpoint parity. +**Current Status:** Legacy Phases 1-9 ✓ COMPLETE; P0-P4 ✓ COMPLETE; Super Admin API Parity ✓ COMPLETE; Domain Coverage P3 ✓ COMPLETE +**Next Phase:** Domain Coverage residuals: P4 UX polish leftovers, then P5 team attachment download + evolution skill apply. + +--- + +## 2026-05-19: Domain Coverage P3 ✓ COMPLETE + +**Objective:** Close AI-critical filler gaps for profile selection, session compaction, health probing, and trace filtering. + +**Deliverables:** +- [x] Added `profile` command group with list/current/create/use/delete. +- [x] Added `GOCLAW_PROFILE` precedence between `--profile` and active config. +- [x] Added legacy single-profile config migration that removes token from config.yaml. +- [x] Added `sessions compact ` via WS RPC `sessions.compact`. +- [x] Updated `health` to use WS RPC `health` when authenticated, with HTTP fallback. +- [x] Added `traces list --since --root-only` on top of existing agent/status/limit filters. +- [x] Added focused tests for profile, migration, session compact, health, and traces filters. + +**Validation:** `go test ./...`. + +**Backlog Sweep:** P4/P5 verification on 2026-05-19 removed covered items from future scope: `agents prompt-preview`, `storage size`, `channels writers groups`, `contacts unmerge`, `agents instances`, `mcp servers tools`, `agents evolution update`, and `tts synthesize`. --- diff --git a/docs/system-architecture.md b/docs/system-architecture.md index d8b2776..ef5e561 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -225,13 +225,14 @@ goclaw auth login --pair ```go type Config struct { - Server string - Token string - OutputFormat string // "table", "json", "yaml" - Profile string - Insecure bool // Skip TLS cert check (testing only) - Verbose bool // Debug logging - Yes bool // Skip confirmation prompts + Server string + Token string + OutputFormat string // resolved "table", "json", "yaml" + ProfileOutputFormat string // profile default, before TTY fallback + Profile string + Insecure bool // Skip TLS cert check (testing only) + Verbose bool // Debug logging + Yes bool // Skip confirmation prompts } type Profile struct { @@ -294,8 +295,9 @@ Maps 12 known server error codes to exit codes; HTTP status fallback for envelop **TTY-Aware Format Resolution (precedence):** 1. `--output` flag (explicit) 2. `GOCLAW_OUTPUT` env var -3. stdout is TTY → `"table"` -4. else → `"json"` +3. active profile output default +4. stdout is TTY → `"table"` +5. else → `"json"` **Printer Interface:** @@ -696,12 +698,18 @@ GoClaw CLI v1.0.0 (commit: abc1234, built: 2026-03-15T10:00:00Z) Yes (env set) → Use env value │ No → ┌─────────────────────┐ - │ Check stdout is TTY │ + │ Check profile output│ └────────┬────────────┘ │ - Yes → "table" (human) + Yes → Use profile default │ - No → "json" (machine/CI) + No → ┌─────────────────────┐ + │ Check stdout is TTY │ + └────────┬────────────┘ + │ + Yes → "table" (human) + │ + No → "json" (machine/CI) ``` ### Error Mapping for AI Agents diff --git a/internal/config/config.go b/internal/config/config.go index f581c81..414b17e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,14 +11,15 @@ import ( // Config holds CLI configuration loaded from file, env, and flags. type Config struct { - Server string `yaml:"server"` - Token string `yaml:"token"` - OutputFormat string `yaml:"output"` - Profile string `yaml:"profile"` - TenantID string `yaml:"-"` // never persisted — flag/env only - Insecure bool `yaml:"insecure"` - Verbose bool `yaml:"verbose"` - Yes bool `yaml:"-"` // never persisted + Server string `yaml:"server"` + Token string `yaml:"token"` + OutputFormat string `yaml:"output"` + ProfileOutputFormat string `yaml:"-"` + Profile string `yaml:"profile"` + TenantID string `yaml:"-"` // never persisted — flag/env only + Insecure bool `yaml:"insecure"` + Verbose bool `yaml:"verbose"` + Yes bool `yaml:"-"` // never persisted } // Profile represents a named server connection profile. @@ -35,6 +36,14 @@ type Profile struct { type FileConfig struct { ActiveProfile string `yaml:"active_profile"` Profiles []Profile `yaml:"profiles"` + + // Legacy single-profile fields. They are migrated into Profiles on load and + // omitted on save so tokens do not remain in config.yaml. + Server string `yaml:"server,omitempty"` + Token string `yaml:"token,omitempty"` + OutputFormat string `yaml:"output,omitempty"` + Insecure bool `yaml:"insecure,omitempty"` + Verbose bool `yaml:"verbose,omitempty"` } // Dir returns the config directory path (~/.goclaw/). @@ -54,21 +63,23 @@ func Load(cmd *cobra.Command) (*Config, error) { cfg := &Config{OutputFormat: "table"} // 1. Load from file - if fc, err := loadFile(); err == nil { - profileName, _ := cmd.Flags().GetString("profile") - if profileName == "" { - profileName = fc.ActiveProfile - } + fc, err := loadFile() + if err != nil && !os.IsNotExist(err) { + return nil, err + } + profileName := resolveProfileName(cmd, fc) + cfg.Profile = profileName + if fc != nil { if p := fc.FindProfile(profileName); p != nil { cfg.Server = p.Server cfg.Profile = p.Name if p.OutputFormat != "" { cfg.OutputFormat = p.OutputFormat + cfg.ProfileOutputFormat = p.OutputFormat } - // Token loaded from credential store, not config file - if tokenData, err := os.ReadFile(filepath.Join(Dir(), "credentials_"+p.Name)); err == nil { - cfg.Token = string(tokenData) - } + cfg.Token = loadStoredToken(p.Name) + } else if shouldRequireProfile(cmd, profileName) { + return nil, &ProfileNotFoundError{Name: profileName} } } @@ -112,77 +123,19 @@ func Load(cmd *cobra.Command) (*Config, error) { return cfg, nil } -// Save persists a profile to the config file. -func Save(profile Profile, setActive bool) error { - fc, _ := loadFile() - if fc == nil { - fc = &FileConfig{} - } - - // Upsert profile - found := false - for i, p := range fc.Profiles { - if p.Name == profile.Name { - fc.Profiles[i] = profile - found = true - break - } - } - if !found { - fc.Profiles = append(fc.Profiles, profile) - } - if setActive || fc.ActiveProfile == "" { - fc.ActiveProfile = profile.Name - } - - return saveFile(fc) -} - -// RemoveProfile deletes a profile from config. -func RemoveProfile(name string) error { - fc, _ := loadFile() - if fc == nil { - return nil - } - for i, p := range fc.Profiles { - if p.Name == name { - fc.Profiles = append(fc.Profiles[:i], fc.Profiles[i+1:]...) - break - } - } - if fc.ActiveProfile == name { - fc.ActiveProfile = "" - if len(fc.Profiles) > 0 { - fc.ActiveProfile = fc.Profiles[0].Name - } - } - return saveFile(fc) -} - -// ListProfiles returns all configured profiles and the active one. -func ListProfiles() ([]Profile, string, error) { - fc, err := loadFile() - if err != nil { - return nil, "", err - } - return fc.Profiles, fc.ActiveProfile, nil -} - -func (fc *FileConfig) FindProfile(name string) *Profile { - for _, p := range fc.Profiles { - if p.Name == name { - return &p - } - } - return nil -} - func loadFile() (*FileConfig, error) { data, err := os.ReadFile(FilePath()) if err != nil { return nil, err } - return parseConfig(data) + fc, err := parseConfig(data) + if err != nil { + return nil, err + } + if err := migrateLegacyConfig(fc, data); err != nil { + return nil, err + } + return fc, nil } // parseConfig parses YAML bytes into FileConfig (exported for testing). diff --git a/internal/config/config_test.go b/internal/config/config_test.go index fc90fe0..f9b4cb5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/spf13/cobra" ) func TestFileConfig_FindProfile(t *testing.T) { @@ -104,3 +106,108 @@ func containsString(haystack, needle string) bool { } return false } + +func testLoadCommand() *cobra.Command { + cmd := &cobra.Command{Use: "agents"} + flags := cmd.Flags() + flags.String("server", "", "") + flags.String("token", "", "") + flags.String("output", "", "") + flags.String("profile", "", "") + flags.String("tenant-id", "", "") + flags.Bool("insecure", false, "") + flags.Bool("verbose", false, "") + flags.Bool("yes", false, "") + return cmd +} + +func setTestHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + return home +} + +func TestLoadUsesGOCLAWProfile(t *testing.T) { + setTestHome(t) + t.Setenv("GOCLAW_PROFILE", "staging") + if err := Save(Profile{Name: "default", Server: "https://default.example.com"}, true); err != nil { + t.Fatalf("save default: %v", err) + } + if err := Save(Profile{Name: "staging", Server: "https://staging.example.com"}, false); err != nil { + t.Fatalf("save staging: %v", err) + } + + cfg, err := Load(testLoadCommand()) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Profile != "staging" || cfg.Server != "https://staging.example.com" { + t.Fatalf("expected staging profile, got profile=%q server=%q", cfg.Profile, cfg.Server) + } +} + +func TestLoadTracksProfileOutputDefault(t *testing.T) { + setTestHome(t) + if err := Save(Profile{Name: "default", Server: "https://default.example.com", OutputFormat: "yaml"}, true); err != nil { + t.Fatalf("save: %v", err) + } + + cfg, err := Load(testLoadCommand()) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.ProfileOutputFormat != "yaml" { + t.Fatalf("expected profile output yaml, got %q", cfg.ProfileOutputFormat) + } +} + +func TestLoadMissingExplicitProfileErrors(t *testing.T) { + setTestHome(t) + if err := Save(Profile{Name: "default", Server: "https://default.example.com"}, true); err != nil { + t.Fatalf("save: %v", err) + } + cmd := testLoadCommand() + if err := cmd.Flags().Set("profile", "missing"); err != nil { + t.Fatalf("set profile: %v", err) + } + + _, err := Load(cmd) + if err == nil { + t.Fatal("expected missing profile error") + } + if _, ok := err.(*ProfileNotFoundError); !ok { + t.Fatalf("expected ProfileNotFoundError, got %T %v", err, err) + } +} + +func TestLegacyConfigMigratesToProfileAndCredential(t *testing.T) { + home := setTestHome(t) + dir := filepath.Join(home, ".goclaw") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + legacy := []byte("server: https://legacy.example.com\ntoken: secret-token\noutput: json\n") + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), legacy, 0600); err != nil { + t.Fatalf("write legacy: %v", err) + } + + cfg, err := Load(testLoadCommand()) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Profile != DefaultProfileName || cfg.Server != "https://legacy.example.com" { + t.Fatalf("unexpected migrated cfg: profile=%q server=%q", cfg.Profile, cfg.Server) + } + if cfg.Token != "secret-token" { + t.Fatalf("expected token from migrated credential, got %q", cfg.Token) + } + data, err := os.ReadFile(filepath.Join(dir, "config.yaml")) + if err != nil { + t.Fatalf("read migrated: %v", err) + } + if containsString(string(data), "secret-token") { + t.Fatal("migrated config must not contain token") + } +} diff --git a/internal/config/profile.go b/internal/config/profile.go new file mode 100644 index 0000000..5e0f705 --- /dev/null +++ b/internal/config/profile.go @@ -0,0 +1,191 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/spf13/cobra" +) + +const DefaultProfileName = "default" + +var profileNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,32}$`) + +type ProfileNotFoundError struct { + Name string +} + +func (e *ProfileNotFoundError) Error() string { + return fmt.Sprintf("profile %q not found", e.Name) +} + +func (e *ProfileNotFoundError) ErrorCode() string { return "NOT_FOUND" } + +func (e *ProfileNotFoundError) ErrorMessage() string { return e.Error() } + +func (e *ProfileNotFoundError) ErrorDetails() any { return nil } + +func (e *ProfileNotFoundError) IsRetryable() bool { return false } + +func (e *ProfileNotFoundError) RetryAfter() int { return 0 } + +// Save persists a profile to the config file. +func Save(profile Profile, setActive bool) error { + fc, _ := loadFile() + if fc == nil { + fc = &FileConfig{} + } + + found := false + for i, p := range fc.Profiles { + if p.Name == profile.Name { + fc.Profiles[i] = profile + found = true + break + } + } + if !found { + fc.Profiles = append(fc.Profiles, profile) + } + if setActive || fc.ActiveProfile == "" { + fc.ActiveProfile = profile.Name + } + + return saveFile(fc) +} + +// RemoveProfile deletes a profile from config. +func RemoveProfile(name string) error { + fc, _ := loadFile() + if fc == nil { + return nil + } + for i, p := range fc.Profiles { + if p.Name == name { + fc.Profiles = append(fc.Profiles[:i], fc.Profiles[i+1:]...) + break + } + } + if fc.ActiveProfile == name { + fc.ActiveProfile = "" + if len(fc.Profiles) > 0 { + fc.ActiveProfile = fc.Profiles[0].Name + } + } + return saveFile(fc) +} + +// ListProfiles returns all configured profiles and the active one. +func ListProfiles() ([]Profile, string, error) { + fc, err := loadFile() + if err != nil { + if os.IsNotExist(err) { + return nil, "", nil + } + return nil, "", err + } + return fc.Profiles, fc.ActiveProfile, nil +} + +func (fc *FileConfig) FindProfile(name string) *Profile { + if name == "" { + name = fc.ActiveProfile + } + for _, p := range fc.Profiles { + if p.Name == name { + return &p + } + } + return nil +} + +// SetActiveProfile switches the active profile without changing its content. +func SetActiveProfile(name string) error { + fc, err := loadFile() + if err != nil { + return err + } + if fc.FindProfile(name) == nil { + return &ProfileNotFoundError{Name: name} + } + fc.ActiveProfile = name + return saveFile(fc) +} + +// ValidateProfileName rejects path traversal and shell-unfriendly names. +func ValidateProfileName(name string) error { + if !profileNamePattern.MatchString(name) { + return fmt.Errorf("profile name must match %s", profileNamePattern.String()) + } + return nil +} + +func resolveProfileName(cmd *cobra.Command, fc *FileConfig) string { + if profileName, _ := cmd.Flags().GetString("profile"); profileName != "" { + return profileName + } + if profileName := os.Getenv("GOCLAW_PROFILE"); profileName != "" { + return profileName + } + if fc != nil && fc.ActiveProfile != "" { + return fc.ActiveProfile + } + return DefaultProfileName +} + +func shouldRequireProfile(cmd *cobra.Command, profileName string) bool { + if profileName == "" { + return false + } + if os.Getenv("GOCLAW_SERVER") != "" || os.Getenv("GOCLAW_TOKEN") != "" { + return false + } + for c := cmd; c != nil; c = c.Parent() { + switch c.Name() { + case "profile": + return false + case "login", "list-contexts", "use-context": + if c.Parent() != nil && c.Parent().Name() == "auth" { + return false + } + } + } + return true +} + +func loadStoredToken(profile string) string { + data, err := os.ReadFile(filepath.Join(Dir(), "credentials_"+profile)) + if err != nil { + return "" + } + return string(data) +} + +func migrateLegacyConfig(fc *FileConfig, original []byte) error { + if len(fc.Profiles) > 0 || fc.Server == "" { + return nil + } + name := fc.ActiveProfile + if name == "" { + name = DefaultProfileName + } + if err := ValidateProfileName(name); err != nil { + return err + } + fc.ActiveProfile = name + fc.Profiles = []Profile{{ + Name: name, + Server: fc.Server, + OutputFormat: fc.OutputFormat, + }} + if fc.Token != "" { + if err := os.WriteFile(filepath.Join(Dir(), "credentials_"+name), []byte(fc.Token), 0600); err != nil { + return err + } + } + backup := filepath.Join(Dir(), "config.yaml.bak") + _ = os.WriteFile(backup, original, 0600) + return saveFile(&FileConfig{ActiveProfile: fc.ActiveProfile, Profiles: fc.Profiles}) +} diff --git a/internal/output/error.go b/internal/output/error.go index ec519cc..93171df 100644 --- a/internal/output/error.go +++ b/internal/output/error.go @@ -2,6 +2,7 @@ package output import ( "encoding/json" + "errors" "fmt" "os" ) @@ -107,7 +108,8 @@ func toErrorDetail(err error) *ErrorDetail { return &ErrorDetail{Code: "UNKNOWN", Message: "unknown error"} } // Prefer the rich interface if available - if ae, ok := err.(apiErrorIface); ok { + var ae apiErrorIface + if errors.As(err, &ae) { return &ErrorDetail{ Code: ae.ErrorCode(), Message: ae.ErrorMessage(), @@ -117,7 +119,8 @@ func toErrorDetail(err error) *ErrorDetail { } } // Check for *ErrorDetail itself - if d, ok := err.(*ErrorDetail); ok { + var d *ErrorDetail + if errors.As(err, &d) { return d } // Plain error @@ -136,20 +139,23 @@ func FromError(err error) int { if err == nil { return ExitSuccess } - if ae, ok := err.(apiErrorIface); ok { + var ae apiErrorIface + if errors.As(err, &ae) { code := ae.ErrorCode() if c := MapServerCode(code); c != ExitGeneric { return c } // Try HTTP status fallback - if aws, ok := err.(apiErrorWithStatus); ok { + var aws apiErrorWithStatus + if errors.As(err, &aws) { if s := aws.HTTPStatus(); s > 0 { return MapHTTPStatus(s) } } } // Try ErrorDetail - if d, ok := err.(*ErrorDetail); ok { + var d *ErrorDetail + if errors.As(err, &d) { if c := MapServerCode(d.Code); c != ExitGeneric { return c } diff --git a/internal/output/error_test.go b/internal/output/error_test.go index 86502f2..9683406 100644 --- a/internal/output/error_test.go +++ b/internal/output/error_test.go @@ -3,6 +3,7 @@ package output import ( "encoding/json" "errors" + "fmt" "os" "testing" ) @@ -106,13 +107,13 @@ type fakeAPIError struct { statusCode int } -func (f *fakeAPIError) Error() string { return f.msg } -func (f *fakeAPIError) ErrorCode() string { return f.code } +func (f *fakeAPIError) Error() string { return f.msg } +func (f *fakeAPIError) ErrorCode() string { return f.code } func (f *fakeAPIError) ErrorMessage() string { return f.msg } -func (f *fakeAPIError) ErrorDetails() any { return nil } -func (f *fakeAPIError) IsRetryable() bool { return false } -func (f *fakeAPIError) RetryAfter() int { return 0 } -func (f *fakeAPIError) HTTPStatus() int { return f.statusCode } +func (f *fakeAPIError) ErrorDetails() any { return nil } +func (f *fakeAPIError) IsRetryable() bool { return false } +func (f *fakeAPIError) RetryAfter() int { return 0 } +func (f *fakeAPIError) HTTPStatus() int { return f.statusCode } func TestFromError_KnownServerCode(t *testing.T) { cases := []struct { @@ -148,6 +149,17 @@ func TestFromError_HTTPStatusFallback(t *testing.T) { } } +func TestFromError_WrappedStructuredError(t *testing.T) { + err := fmt.Errorf("config load: %w", &fakeAPIError{code: "NOT_FOUND", msg: "profile missing"}) + if got := FromError(err); got != ExitNotFound { + t.Errorf("FromError(wrapped NOT_FOUND) = %d, want %d", got, ExitNotFound) + } + detail := toErrorDetail(err) + if detail.Code != "NOT_FOUND" || detail.Message != "profile missing" { + t.Fatalf("unexpected wrapped detail: %#v", detail) + } +} + func TestFromError_NilError(t *testing.T) { if got := FromError(nil); got != ExitSuccess { t.Errorf("FromError(nil) = %d, want %d", got, ExitSuccess) diff --git a/internal/output/tty.go b/internal/output/tty.go index 1941ed1..02983b1 100644 --- a/internal/output/tty.go +++ b/internal/output/tty.go @@ -29,6 +29,12 @@ func isValidFormat(s string) bool { return validFormats[s] } // Invalid GOCLAW_OUTPUT values emit a warning on stderr and fall through to // TTY detection, to avoid silently mis-formatting AI-consumer output. func ResolveFormat(flagVal string) string { + return ResolveFormatWithDefault(flagVal, "") +} + +// ResolveFormatWithDefault resolves output format with a config/profile default +// between GOCLAW_OUTPUT and TTY detection. +func ResolveFormatWithDefault(flagVal, defaultVal string) string { if flagVal != "" { return flagVal } @@ -38,6 +44,12 @@ func ResolveFormat(flagVal string) string { } fmt.Fprintf(os.Stderr, "goclaw: warning: invalid GOCLAW_OUTPUT=%q, falling back to auto-detect (valid: table|json|yaml)\n", env) } + if defaultVal != "" { + if isValidFormat(defaultVal) { + return defaultVal + } + fmt.Fprintf(os.Stderr, "goclaw: warning: invalid profile output=%q, falling back to auto-detect (valid: table|json|yaml)\n", defaultVal) + } if IsTTY(int(os.Stdout.Fd())) { return "table" } diff --git a/internal/output/tty_test.go b/internal/output/tty_test.go index 401fadf..e4fba86 100644 --- a/internal/output/tty_test.go +++ b/internal/output/tty_test.go @@ -72,6 +72,22 @@ func TestResolveFormat_InvalidEnvFallsThrough(t *testing.T) { } } +func TestResolveFormatWithDefault_ProfileDefault(t *testing.T) { + t.Setenv("GOCLAW_OUTPUT", "") + got := ResolveFormatWithDefault("", "yaml") + if got != "yaml" { + t.Errorf("profile default should win before TTY fallback: got %q, want yaml", got) + } +} + +func TestResolveFormatWithDefault_EnvBeatsProfileDefault(t *testing.T) { + t.Setenv("GOCLAW_OUTPUT", "json") + got := ResolveFormatWithDefault("", "yaml") + if got != "json" { + t.Errorf("env should win over profile default: got %q, want json", got) + } +} + func TestResolveFormat_InvalidFlagPassedThrough(t *testing.T) { // Invalid flag values pass through — cobra validation catches them. got := ResolveFormat("xml") diff --git a/plans/260503-1907-domain-coverage-p3-plus/phase-03-ai-critical-fillers.md b/plans/260503-1907-domain-coverage-p3-plus/phase-03-ai-critical-fillers.md index 509fa28..17990df 100644 --- a/plans/260503-1907-domain-coverage-p3-plus/phase-03-ai-critical-fillers.md +++ b/plans/260503-1907-domain-coverage-p3-plus/phase-03-ai-critical-fillers.md @@ -1,7 +1,7 @@ # Phase 3 — AI-Critical Fillers **Priority:** 🔥 critical -**Status:** not-started +**Status:** complete **Estimated LoC:** ~250 (excl. tests) **Estimated PR size:** ≤ 500 LoC incl. tests @@ -103,16 +103,16 @@ cmd/ ## Todo List -- [ ] config: profile struct + List/Create/Delete/Use/Current -- [ ] config: legacy migration (config.yaml → profiles/default/config.yaml) -- [ ] root: --profile flag + GOCLAW_PROFILE env + precedence -- [ ] keyring: profile-namespaced token key -- [ ] cmd/profile.go: 5 subcommands -- [ ] cmd/sessions.go: compact subcommand -- [ ] cmd/health.go: WS health probe -- [ ] cmd/traces.go: --since/--agent/--status/--root-only/--limit flags -- [ ] tests: profile, migrate, sessions_compact, health, traces_filters -- [ ] docs sync: codebase-summary + CHANGELOG +- [x] config: profile struct + List/Create/Delete/Use/Current +- [x] config: legacy migration from flat config.yaml into profile list format +- [x] root: --profile flag + GOCLAW_PROFILE env + precedence +- [x] keyring: profile-namespaced token key +- [x] cmd/profile.go: 5 subcommands +- [x] cmd/sessions.go: compact subcommand +- [x] cmd/health.go: WS health probe with HTTP fallback +- [x] cmd/traces.go: --since/--agent/--status/--root-only/--limit flags +- [x] tests: profile, migrate, sessions_compact, health, traces_filters +- [x] docs sync: codebase-summary + CHANGELOG ## Success Criteria diff --git a/plans/260503-1907-domain-coverage-p3-plus/phase-04-ux-polish-batch-1.md b/plans/260503-1907-domain-coverage-p3-plus/phase-04-ux-polish-batch-1.md index 978e9bf..357af36 100644 --- a/plans/260503-1907-domain-coverage-p3-plus/phase-04-ux-polish-batch-1.md +++ b/plans/260503-1907-domain-coverage-p3-plus/phase-04-ux-polish-batch-1.md @@ -1,8 +1,8 @@ # Phase 4 — UX Polish Batch 1 **Priority:** 🟡 medium -**Status:** not-started -**Estimated LoC:** ~400 (excl. tests) +**Status:** verified residuals — not-started +**Estimated LoC:** ~250 (excl. tests) **Estimated PR size:** ≤ 500 LoC incl. tests **Depends on:** P3 (multi-profile stable) @@ -12,20 +12,20 @@ ## Overview -Composite/UX commands wrapping endpoints already shipped server-side. No server FRs needed. Most are 1-file each. +Composite/UX commands wrapping endpoints already shipped server-side. No server FRs needed. Sweep on 2026-05-19 found several planned items already covered. ## Scope | # | Command | Source | Type | |---|---|---|---| -| P2 | `codex-pool activity --agent=… \| --provider=…` | unify `agents codex-pool-activity` + `providers codex-pool-activity` | umbrella alias | -| D3 | `api-keys rotate ` | composite: create-new + emit raw + revoke-old | composite | -| D6 | `config defaults` | WS `config.defaults` (`pkg/protocol/methods.go` ConfigDefaults) | direct | -| E3 | `chat replay ` | composite: `sessions preview` + `chat history` | composite | -| E1 | `chat sessions resume ` | UX wrapper around `chat send --session-key=` | alias | -| X1 | `agents prompt-preview ` | `GET /v1/agents/{id}/system-prompt-preview` | direct | -| X6 | `tools invoke [--args=…]` | `internal/http/tools_invoke.go` | direct | -| X7 | `storage size` | `GET /v1/storage/size` | direct | +| P2 | `codex-pool activity --agent=… \| --provider=…` | unify `agents codex-pool-activity` + `providers codex-pool-activity` | residual | +| D3 | `api-keys rotate ` | composite: create-new + emit raw + revoke-old | residual | +| D6 | `config defaults` | WS `config.defaults` (`pkg/protocol/methods.go` ConfigDefaults) | residual | +| E3 | `chat replay ` | composite: `sessions preview` + `chat history` | residual | +| E1 | `chat sessions resume ` | UX wrapper around `chat send --session-key=` | residual | +| X1 | `agents prompt-preview ` | `cmd/agents_admin.go` | covered | +| X6 | `tools invoke ` | `cmd/tools_custom.go`; residual is `--args=@file.json` alias only | partial | +| X7 | `storage size` | `cmd/storage.go` | covered | ## Related Code Files @@ -33,15 +33,13 @@ Composite/UX commands wrapping endpoints already shipped server-side. No server - `cmd/api_keys.go` — add `rotate` - `cmd/chat.go` — add `replay` + `sessions resume` -- `cmd/agents.go` or `cmd/agents_misc.go` — add `prompt-preview` -- `cmd/tools.go` — add `invoke` (read-write surface, not config-only) +- `cmd/tools_custom.go` — add `--args` alias / `@file` support for existing `tools invoke` - `CHANGELOG.md`, `docs/codebase-summary.md` ### Create - `cmd/codex_pool.go` — umbrella group - `cmd/config_defaults.go` -- `cmd/storage.go` - companion `_test.go` per file ### Delete @@ -53,24 +51,22 @@ Composite/UX commands wrapping endpoints already shipped server-side. No server 1. `cmd/codex_pool.go`: register top-level `codex-pool activity` + flag dispatch (`--agent` vs `--provider`). Mark old commands deprecated in Long help. 2. `cmd/api_keys.go::rotate`: orchestrate create → emit raw → revoke-old in single command, JSON output of new key. 3. `cmd/config_defaults.go`: WS `config.defaults`, raw passthrough. -4. `cmd/storage.go`: HTTP GET `/v1/storage/size`, table + JSON. -5. Extend `cmd/chat.go` with `replay ` (composite preview+history, stream JSONL to stdout). -6. Add `cmd/chat.go::sessions resume ` as alias for `chat send --session-key=` (read stdin/--message body). -7. Extend agents group with `prompt-preview ` — GET endpoint, `--format=raw|markdown`. -8. Extend tools group with `invoke ` — POST body via `--args=@file.json` or literal JSON. -9. Tests for each. Composites: assert sequence of HTTP calls via httptest. -10. Docs sync. +4. Extend `cmd/chat.go` with `replay ` (composite preview+history, stream JSONL to stdout). +5. Add `cmd/chat.go::sessions resume ` as alias for `chat send --session-key=` (read stdin/--message body). +6. Extend existing `tools invoke ` with `--args=@file.json` or literal JSON alias. +7. Tests for each. Composites: assert sequence of HTTP/WS calls via httptest. +8. Docs sync. ## Todo List - [ ] cmd/codex_pool.go umbrella + alias deprecation - [ ] cmd/api_keys.go rotate composite - [ ] cmd/config_defaults.go -- [ ] cmd/storage.go (size) - [ ] cmd/chat.go replay composite - [ ] cmd/chat.go sessions resume alias -- [ ] cmd/agents prompt-preview -- [ ] cmd/tools invoke (with @file + literal JSON args) +- [x] cmd/agents prompt-preview +- [ ] cmd/tools invoke `--args` alias (existing `--params` + `--param` already covered) +- [x] cmd/storage.go (size) - [ ] tests per command - [ ] CHANGELOG + docs diff --git a/plans/260503-1907-domain-coverage-p3-plus/phase-05-fillers-verification-batch-2.md b/plans/260503-1907-domain-coverage-p3-plus/phase-05-fillers-verification-batch-2.md index cfe9009..e8deea1 100644 --- a/plans/260503-1907-domain-coverage-p3-plus/phase-05-fillers-verification-batch-2.md +++ b/plans/260503-1907-domain-coverage-p3-plus/phase-05-fillers-verification-batch-2.md @@ -1,8 +1,8 @@ # Phase 5 — Fillers & Verification Batch 2 **Priority:** 🟡 medium -**Status:** not-started -**Estimated LoC:** ~250 (excl. tests; may shrink after verify sweep) +**Status:** verified residuals — not-started +**Estimated LoC:** ~150 (excl. tests) **Depends on:** P3 + P4 merged ## Context Links @@ -11,7 +11,7 @@ ## Overview -Final fillers. **Begins with a 30-min verification sweep** — several "missing" items are flagged "verify" because grep matched server file but not CLI registration. Some may already exist under different command paths. +Final fillers. Verification sweep completed 2026-05-19; most "missing" items were already present under different command paths. ## Verification Sweep (do FIRST) @@ -19,13 +19,13 @@ For each item below, grep both repos and confirm gap before scoping LoC: | ID | Server source | Verify CLI | Likely outcome | |---|---|---|---| -| C1 | `GET /v1/channels/instances/{id}/writers/groups` | `cmd/channels_writers.go` Use list | confirmed gap | -| C4 | `POST /v1/contacts/unmerge` | `cmd/channels_contacts.go` | likely gap | -| X3 | `GET /v1/agents/{id}/instances` + files | `cmd/agents_instances.go` | partial — verify sub-routes | -| X4 | `GET /v1/mcp/servers/{id}/tools` | `cmd/mcp_servers.go` | likely covered | -| X8 | `PATCH /v1/agents/{id}/evolution/suggestions/{sid}` | `cmd/agents_evolution.go` | likely missing | -| X11 | `GET /v1/teams/{teamId}/attachments/{aid}/download` | `cmd/teams.go` / `cmd/teams_*.go` | likely missing | -| X12 | `internal/http/evolution_skill_apply.go` | `cmd/agents_evolution.go` | likely missing | +| C1 | `GET /v1/channels/instances/{id}/writers/groups` | `cmd/channels_writers.go` | covered: `channels writers groups` | +| C4 | `POST /v1/contacts/unmerge` | `cmd/channels_contacts.go`, `cmd/contacts.go` | covered: `channels contacts unmerge`, `contacts unmerge` | +| X3 | `GET /v1/agents/{id}/instances` + files | `cmd/agents_instances.go` | covered: list/get-file/set-file/metadata | +| X4 | `GET /v1/mcp/servers/{id}/tools` | `cmd/mcp.go` | covered: `mcp servers tools` | +| X8 | `PATCH /v1/agents/{id}/evolution/suggestions/{sid}` | `cmd/agents_evolution.go` | covered: `agents evolution update` | +| X11 | `GET /v1/teams/{teamId}/attachments/{aid}/download` | `cmd/teams.go` / `cmd/teams_*.go` | residual | +| X12 | `internal/http/evolution_skill_apply.go` | `cmd/agents_evolution.go` | residual | After sweep, drop covered items, finalize scope. Report sweep results in PR description. @@ -33,31 +33,26 @@ After sweep, drop covered items, finalize scope. Report sweep results in PR desc | # | Command | Server route | File | |---|---|---|---| -| C1 | `channels writers groups ` | `GET /v1/channels/instances/{id}/writers/groups` | `cmd/channels_writers.go` | -| C4 | `contacts unmerge ` | `POST /v1/contacts/unmerge` | `cmd/channels_contacts.go` | -| X3 | `agents instances list ` + `agents instances files ` | server | `cmd/agents_instances.go` | -| X4 | `mcp servers tools ` | `GET /v1/mcp/servers/{id}/tools` | `cmd/mcp_servers.go` | -| X8 | `agents evolution suggestions update ` | `PATCH …/suggestions/{sid}` | `cmd/agents_evolution.go` | | X11 | `teams attachments download [--out=…]` | `GET …/attachments/{aid}/download` | `cmd/teams_workspace.go` or new `cmd/teams_attachments.go` | | X12 | `agents evolution skill apply ` | `internal/http/evolution_skill_apply.go` | `cmd/agents_evolution.go` | ## Implementation Steps -1. **Sweep first.** Use Grep on `cmd/*.go` for each route literal + each `Use:` line. Document results in PR description. -2. For each confirmed gap, extend the existing module file (no new files unless >200 LoC pushes existing over budget). -3. `teams attachments download` — binary file save via signed-URL pattern (`internal/client/signed_download.go`); reuse helper. +1. Extend the existing module file for each confirmed residual (no new files unless >200 LoC pushes existing over budget). +2. `teams attachments download` — binary file save via signed-URL pattern (`internal/client/signed_download.go`); reuse helper. +3. `agents evolution skill apply` — wire the server route and expose structured output. 4. Add `_test.go` cases for each. 5. CHANGELOG + docs sync. ## Todo List -- [ ] verify sweep on 7 items (grep both repos) -- [ ] drop confirmed-covered items, finalize scope -- [ ] cmd/channels_writers.go: groups subcommand -- [ ] cmd/channels_contacts.go: unmerge -- [ ] cmd/agents_instances.go: list + files (if missing) -- [ ] cmd/mcp_servers.go: tools subcommand -- [ ] cmd/agents_evolution.go: suggestions update + skill apply +- [x] verify sweep on 7 items (grep CLI command surface) +- [x] drop confirmed-covered items, finalize scope +- [x] cmd/channels_writers.go: groups subcommand +- [x] cmd/channels_contacts.go: unmerge +- [x] cmd/agents_instances.go: list + files +- [x] cmd/mcp_servers.go: tools subcommand +- [ ] cmd/agents_evolution.go: skill apply - [ ] teams attachments download (reuse signed_download) - [ ] tests per command - [ ] CHANGELOG + docs diff --git a/plans/260503-1907-domain-coverage-p3-plus/phase-06-server-fr-backlog.md b/plans/260503-1907-domain-coverage-p3-plus/phase-06-server-fr-backlog.md index 8d7dc21..69c6b0a 100644 --- a/plans/260503-1907-domain-coverage-p3-plus/phase-06-server-fr-backlog.md +++ b/plans/260503-1907-domain-coverage-p3-plus/phase-06-server-fr-backlog.md @@ -23,7 +23,7 @@ Per YAGNI: shipping CLI stubs for endpoints that don't exist server-side = broke | C2 | `goclaw channels writers test ` | `POST /v1/channels/instances/{id}/writers/test` | 🟢 | | E2 | `goclaw chat sessions branch ` | `POST /v1/chat/sessions/{key}/branch` (fork/copy session state) | 🟡 | | E5 | `goclaw chat history --follow` | WS `chat.history.delta` push or SSE | 🟢 | -| X5 | `goclaw tts synthesize --text=… --voice=…` | already exists `POST /v1/tts/synthesize` — verify; if exists, demote to P5 | 🟢 | +| X5 | `goclaw tts synthesize --text=… --voice=…` | covered in `cmd/tts_http.go`; no server FR | closed | ## Recommended Issue Template (per item) @@ -48,7 +48,7 @@ After endpoint ships, CLI will add `goclaw ` mapping 1:1. ## Todo List -- [ ] File 7 issues in goclaw repo (skip X5 if `tts synthesize` already exists) +- [ ] File 7 issues in goclaw repo (X5 closed; `tts synthesize` already exists) - [ ] Track issue numbers in this file - [ ] When server ships endpoint, demote item to a future CLI phase diff --git a/plans/260503-1907-domain-coverage-p3-plus/plan.md b/plans/260503-1907-domain-coverage-p3-plus/plan.md index db8bb21..5dae50e 100644 --- a/plans/260503-1907-domain-coverage-p3-plus/plan.md +++ b/plans/260503-1907-domain-coverage-p3-plus/plan.md @@ -3,7 +3,7 @@ **Date:** 2026-05-03 **Branch:** feat/ai-first-cli-expansion **Reference report:** `plans/reports/brainstorm-260503-1907-gap-analysis-round2.md` -**Status:** Drafted — awaiting impl approval. +**Status:** P3 complete — P4/P5 sweep complete; residual implementation not-started. ## Summary @@ -11,9 +11,9 @@ Sau R1 (P0–P5) + R2 expansion (P0–P2), CLI đạt ~95% server coverage. R2 r | Phase | Scope | LoC | Tier | Status | |---|---|---|---|---| -| P3 | AI-critical fillers (multi-profile, sessions compact, health, traces filter polish) | ~250 | 🔥 | not-started | -| P4 | UX polish batch 1 (codex-pool umbrella, api-keys rotate, config defaults, chat replay/resume, agents prompt-preview, tools invoke, storage size) | ~400 | 🟡 | not-started | -| P5 | Fillers + verification batch 2 (writers groups, contacts unmerge, agents instances, mcp tools, evolution patch/apply, team attachments dl) | ~250 | 🟡 | not-started | +| P3 | AI-critical fillers (multi-profile, sessions compact, health, traces filter polish) | ~250 | 🔥 | complete | +| P4 | UX polish batch 1 residuals (codex-pool umbrella, api-keys rotate, config defaults, chat replay/resume, tools invoke `--args` alias) | ~250 | 🟡 | not-started | +| P5 | Fillers residuals after sweep (team attachments download, evolution skill apply) | ~150 | 🟡 | not-started | | P6 | Deferred — blocked on server FRs (traces follow, logs aggregate, providers reconnect, …) | n/a | 🟢 | server-blocked | ## Phase Files @@ -27,7 +27,7 @@ Sau R1 (P0–P5) + R2 expansion (P0–P2), CLI đạt ~95% server coverage. R2 r - Superseded/blocked by `plans/260518-1936-super-admin-api-parity/` for the next implementation slice. The newer plan narrows the backlog to super-admin operational parity after server `v3.12.0-beta.5`. - P3 multi-profile may refactor `internal/config` singleton — finish before P4/P5. -- P5 begins with **30-min verify sweep** (grep CLI for X1..X12 items) — likely shrinks scope. +- P5 verify sweep completed 2026-05-19; most suspected gaps already exist under current command paths. - P6 = upstream goclaw issues, not CLI work. ## Success Criteria @@ -39,10 +39,10 @@ Sau R1 (P0–P5) + R2 expansion (P0–P2), CLI đạt ~95% server coverage. R2 r ## Open Questions (consolidated from report) -1. Profile naming: `profile` vs `context`? +1. Profile naming: resolved as `profile`; legacy `auth use-context` remains. 2. Codex-pool alias sunset version? -3. Chat replay output: stdout JSONL vs file? +3. Chat replay output: default stdout JSONL; add `--file` only if user workflow demands it. 4. Health schema: raw passthrough vs normalized? 5. Server FR ownership for P6 items? -6. `tts synthesize` AI use case? -7. `--profile` vs `GOCLAW_OUTPUT` precedence? +6. `tts synthesize` AI use case: already covered by `cmd/tts_http.go`; not a server FR. +7. `--profile` vs `GOCLAW_OUTPUT` precedence: `--profile`/`GOCLAW_PROFILE` selects config profile; `GOCLAW_OUTPUT` still wins output format. From 3ea088b74bd2cbfdeb30908d61a819fecd2d0145 Mon Sep 17 00:00:00 2001 From: Duy /zuey/ Date: Tue, 19 May 2026 22:50:59 +0700 Subject: [PATCH 2/4] feat(cli): add domain coverage P4 UX polish --- CHANGELOG.md | 9 +- README.md | 24 +- cmd/agents_misc.go | 5 +- cmd/api_keys.go | 13 +- cmd/api_keys_helpers.go | 20 ++ cmd/api_keys_rotate.go | 85 ++++++ cmd/chat_ai_commands.go | 27 +- cmd/chat_replay.go | 52 ++++ cmd/chat_sessions.go | 29 ++ cmd/cmd_test.go | 2 + cmd/codex_pool.go | 45 +++ cmd/config_defaults.go | 28 ++ cmd/p4_ux_polish_test.go | 287 ++++++++++++++++++ cmd/providers_codex_pool.go | 7 +- cmd/tools_custom.go | 18 +- cmd/tools_invoke_args.go | 45 +++ docs/codebase-summary.md | 10 +- docs/project-roadmap.md | 20 +- .../phase-04-ux-polish-batch-1.md | 53 ++-- .../plan.md | 11 +- .../reports/validation-red-team-260519-p4.md | 35 +++ 21 files changed, 738 insertions(+), 87 deletions(-) create mode 100644 cmd/api_keys_helpers.go create mode 100644 cmd/api_keys_rotate.go create mode 100644 cmd/chat_replay.go create mode 100644 cmd/chat_sessions.go create mode 100644 cmd/codex_pool.go create mode 100644 cmd/config_defaults.go create mode 100644 cmd/p4_ux_polish_test.go create mode 100644 cmd/tools_invoke_args.go create mode 100644 plans/260503-1907-domain-coverage-p3-plus/reports/validation-red-team-260519-p4.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 20254c7..af4e11c 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–P3) +## [Unreleased] — Domain Coverage Expansion (P0–P4) ### Added @@ -34,6 +34,13 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `goclaw health` — uses WS RPC `health` when authenticated, retaining unauthenticated HTTP `/health` fallback. - `goclaw traces list --since --agent --status --root-only --limit` — expanded filters for automation-friendly trace search. +**P4 — UX polish** +- `goclaw codex-pool activity --agent=|--provider=` — unified Codex pool activity lookup; legacy agent/provider commands remain as deprecated aliases. +- `goclaw api-keys rotate ` — create replacement key, show raw key once, then revoke old key with structured partial-failure reporting. +- `goclaw config defaults` — read-only WS passthrough for server default config values. +- `goclaw chat replay --session=` and `goclaw chat sessions resume --session=` — discoverability wrappers over existing chat session contracts. +- `goclaw tools invoke --args=` — alias for `--params` with file-backed JSON support. + ### 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 the next implementation pass. diff --git a/README.md b/README.md index 155bd29..709fb7a 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ echo "Analyze this log" | goclaw chat myagent | `agents` | CRUD, shares, delegation links, per-user instances | | `chat` | Interactive or single-shot messaging with streaming | | `sessions` | List, preview, delete, reset, label, compact | +| `codex-pool` | Unified Codex pool activity lookup for agents/providers | | `skills` | Upload, manage, grant/revoke access | | `mcp` | MCP server management, grants, access requests | | `providers` | LLM provider CRUD, model listing, verification | @@ -81,7 +82,7 @@ echo "Analyze this log" | goclaw chat myagent | `tts` | Text-to-speech operations | | `media` | Media upload/download | | `activity` | Audit log | -| `api-keys` | API key management (create, list, revoke) | +| `api-keys` | API key management (create, list, revoke, rotate) | | `system upgrade` | Gateway release upgrade status and trigger controls | | `workstations` | Coding-agent workstation CRUD, permissions, activity, and agent links | | `webhooks` | Webhook admin CRUD, secret rotation, and deletion | @@ -300,10 +301,31 @@ goclaw api-keys list # Revoke a key goclaw api-keys revoke + +# Rotate a key by creating a replacement and revoking the old key +goclaw api-keys rotate --name "ci-deploy-v2" --scopes "operator.read,operator.write" --yes ``` Available scopes: `operator.admin`, `operator.read`, `operator.write`, `operator.approvals`, `operator.pairing` +## UX Convenience Commands + +```bash +# Unified Codex pool activity +goclaw codex-pool activity --agent=agent-123 +goclaw codex-pool activity --provider=provider-123 + +# Resolved server defaults +goclaw config defaults -o json + +# Replay or resume a known chat session +goclaw chat replay myagent --session=sess-123 -o json +goclaw chat sessions resume myagent --session=sess-123 -m "Continue" --no-stream + +# Invoke a custom tool with JSON args from file +goclaw tools invoke weather --args=@payload.json +``` + ## API Docs ```bash diff --git a/cmd/agents_misc.go b/cmd/agents_misc.go index f6b7fc2..fdb45ba 100644 --- a/cmd/agents_misc.go +++ b/cmd/agents_misc.go @@ -35,8 +35,9 @@ Example: } var agentsCodexPoolActivityCmd = &cobra.Command{ - Use: "codex-pool-activity ", - Short: "Get codex pool activity for an agent", + Use: "codex-pool-activity ", + Short: "Get codex pool activity for an agent", + Deprecated: "use 'goclaw codex-pool activity --agent=' instead", Long: `Retrieve recent codex (context pool) activity for an agent. GET /v1/agents/{id}/codex-pool-activity diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 9a8caef..45e7d54 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -59,16 +59,9 @@ var apiKeysCreateCmd = &cobra.Command{ scopesRaw, _ := cmd.Flags().GetString("scopes") expiresIn, _ := cmd.Flags().GetInt("expires-in") - // Parse comma-separated scopes into slice - var scopes []string - for _, s := range strings.Split(scopesRaw, ",") { - s = strings.TrimSpace(s) - if s != "" { - scopes = append(scopes, s) - } - } - if len(scopes) == 0 { - return fmt.Errorf("at least one scope is required") + scopes, err := parseAPIKeyScopes(scopesRaw) + if err != nil { + return err } body := buildBody("name", name, "scopes", scopes) diff --git a/cmd/api_keys_helpers.go b/cmd/api_keys_helpers.go new file mode 100644 index 0000000..24641bd --- /dev/null +++ b/cmd/api_keys_helpers.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "fmt" + "strings" +) + +func parseAPIKeyScopes(scopesRaw string) ([]string, error) { + var scopes []string + for _, s := range strings.Split(scopesRaw, ",") { + s = strings.TrimSpace(s) + if s != "" { + scopes = append(scopes, s) + } + } + if len(scopes) == 0 { + return nil, fmt.Errorf("at least one scope is required") + } + return scopes, nil +} diff --git a/cmd/api_keys_rotate.go b/cmd/api_keys_rotate.go new file mode 100644 index 0000000..6d2d281 --- /dev/null +++ b/cmd/api_keys_rotate.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "fmt" + "net/url" + + "github.com/nextlevelbuilder/goclaw-cli/internal/output" + "github.com/nextlevelbuilder/goclaw-cli/internal/tui" + "github.com/spf13/cobra" +) + +var apiKeysRotateCmd = &cobra.Command{ + Use: "rotate ", + Short: "Create a replacement API key and revoke the old key", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !tui.Confirm("Rotate this API key?", cfg.Yes) { + return nil + } + c, err := newHTTP() + if err != nil { + return err + } + name, _ := cmd.Flags().GetString("name") + scopesRaw, _ := cmd.Flags().GetString("scopes") + expiresIn, _ := cmd.Flags().GetInt("expires-in") + scopes, err := parseAPIKeyScopes(scopesRaw) + if err != nil { + return err + } + + body := buildBody("name", name, "scopes", scopes) + if expiresIn > 0 { + body["expires_in"] = expiresIn + } + + data, err := c.Post("/v1/api-keys", body) + if err != nil { + return err + } + result := unmarshalMap(data) + printAPIKeyRotateResult(args[0], result) + + _, err = c.Post("/v1/api-keys/"+url.PathEscape(args[0])+"/revoke", nil) + if err != nil { + return apiKeyRotatePartialError(args[0], result, err) + } + return nil + }, +} + +func printAPIKeyRotateResult(oldKeyID string, result map[string]any) { + if cfg.OutputFormat == "table" { + fmt.Printf("API key rotated: %s\n", str(result, "id")) + fmt.Println("--- IMPORTANT: Copy your replacement API key now. It will not be shown again. ---") + fmt.Printf("Key: %s\n", str(result, "key")) + return + } + result["old_key_id"] = oldKeyID + result["old_revoke_status"] = "pending" + printer.Print(result) +} + +func apiKeyRotatePartialError(oldKeyID string, result map[string]any, revokeErr error) error { + details := map[string]any{ + "new_key_id": str(result, "id"), + "old_key_id": oldKeyID, + "old_revoke_status": "failed", + "revoke_error": revokeErr.Error(), + } + return &output.ErrorDetail{ + Code: "INTERNAL", + Message: "replacement API key was created, but revoking the old key failed", + Details: details, + } +} + +func init() { + apiKeysRotateCmd.Flags().String("name", "", "Human-readable replacement key name") + _ = apiKeysRotateCmd.MarkFlagRequired("name") + apiKeysRotateCmd.Flags().String("scopes", "", "Comma-separated replacement key scopes") + _ = apiKeysRotateCmd.MarkFlagRequired("scopes") + apiKeysRotateCmd.Flags().Int("expires-in", 0, "TTL in seconds (0 = no expiry)") + apiKeysCmd.AddCommand(apiKeysRotateCmd) +} diff --git a/cmd/chat_ai_commands.go b/cmd/chat_ai_commands.go index 145e62f..1cced47 100644 --- a/cmd/chat_ai_commands.go +++ b/cmd/chat_ai_commands.go @@ -43,32 +43,7 @@ Examples: before, _ := cmd.Flags().GetString("before") session, _ := cmd.Flags().GetString("session") - ws, err := newWS("cli") - if err != nil { - return err - } - if _, err := ws.Connect(); err != nil { - return err - } - defer ws.Close() - - params := map[string]any{ - "agent_key": args[0], - "limit": limit, - } - if before != "" { - params["before"] = before - } - if session != "" { - params["session_key"] = session - } - - data, err := ws.Call("chat.history", params) - if err != nil { - return err - } - printer.Print(unmarshalList(data)) - return nil + return runChatHistory(args[0], session, before, limit) }, } diff --git a/cmd/chat_replay.go b/cmd/chat_replay.go new file mode 100644 index 0000000..4d988fb --- /dev/null +++ b/cmd/chat_replay.go @@ -0,0 +1,52 @@ +package cmd + +import "github.com/spf13/cobra" + +var chatReplayCmd = &cobra.Command{ + Use: "replay ", + Short: "Replay history for a specific chat session", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + session, _ := cmd.Flags().GetString("session") + before, _ := cmd.Flags().GetString("before") + limit, _ := cmd.Flags().GetInt("limit") + return runChatHistory(args[0], session, before, limit) + }, +} + +func runChatHistory(agent, session, before string, limit int) error { + ws, err := newWS("cli") + if err != nil { + return err + } + if _, err := ws.Connect(); err != nil { + return err + } + defer ws.Close() + + params := map[string]any{ + "agent_key": agent, + "limit": limit, + } + if before != "" { + params["before"] = before + } + if session != "" { + params["session_key"] = session + } + + data, err := ws.Call("chat.history", params) + if err != nil { + return err + } + printer.Print(unmarshalList(data)) + return nil +} + +func init() { + chatReplayCmd.Flags().String("session", "", "Session key to replay") + _ = chatReplayCmd.MarkFlagRequired("session") + chatReplayCmd.Flags().String("before", "", "Return messages before this RFC3339 timestamp") + chatReplayCmd.Flags().Int("limit", 50, "Maximum number of messages to return") + chatCmd.AddCommand(chatReplayCmd) +} diff --git a/cmd/chat_sessions.go b/cmd/chat_sessions.go new file mode 100644 index 0000000..efb92fc --- /dev/null +++ b/cmd/chat_sessions.go @@ -0,0 +1,29 @@ +package cmd + +import "github.com/spf13/cobra" + +var chatSessionsCmd = &cobra.Command{Use: "sessions", Short: "Chat session convenience commands"} + +var chatSessionsResumeCmd = &cobra.Command{ + Use: "resume ", + Short: "Resume a chat session", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + session, _ := cmd.Flags().GetString("session") + message, _ := cmd.Flags().GetString("message") + noStream, _ := cmd.Flags().GetBool("no-stream") + if message != "" { + return chatSingleShot(args[0], message, session, noStream) + } + return chatInteractive(args[0], session) + }, +} + +func init() { + chatSessionsResumeCmd.Flags().String("session", "", "Session key to resume") + _ = chatSessionsResumeCmd.MarkFlagRequired("session") + chatSessionsResumeCmd.Flags().StringP("message", "m", "", "Message to send") + chatSessionsResumeCmd.Flags().Bool("no-stream", false, "Disable streaming, wait for full response") + chatSessionsCmd.AddCommand(chatSessionsResumeCmd) + chatCmd.AddCommand(chatSessionsCmd) +} diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index d432a91..914e96f 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -25,6 +25,7 @@ func TestAllCommandsRegistered(t *testing.T) { "chat", "config", "contacts", + "codex-pool", "credentials", "cron", "delegations", @@ -100,6 +101,7 @@ func TestCommandUseFields(t *testing.T) { {"chat"}, {"config"}, {"contacts"}, + {"codex-pool"}, {"credentials"}, {"cron"}, {"delegations"}, diff --git a/cmd/codex_pool.go b/cmd/codex_pool.go new file mode 100644 index 0000000..4e48a2d --- /dev/null +++ b/cmd/codex_pool.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" +) + +var codexPoolCmd = &cobra.Command{Use: "codex-pool", Short: "Inspect Codex pool activity"} + +var codexPoolActivityCmd = &cobra.Command{ + Use: "activity", + Short: "Show Codex pool activity for an agent or provider", + RunE: func(cmd *cobra.Command, args []string) error { + agentID, _ := cmd.Flags().GetString("agent") + providerID, _ := cmd.Flags().GetString("provider") + if (agentID == "") == (providerID == "") { + return fmt.Errorf("provide exactly one of --agent or --provider") + } + c, err := newHTTP() + if err != nil { + return err + } + path := "" + if agentID != "" { + path = "/v1/agents/" + url.PathEscape(agentID) + "/codex-pool-activity" + } else { + path = "/v1/providers/" + url.PathEscape(providerID) + "/codex-pool-activity" + } + data, err := c.Get(path) + if err != nil { + return err + } + printer.Print(unmarshalMap(data)) + return nil + }, +} + +func init() { + codexPoolActivityCmd.Flags().String("agent", "", "Agent ID") + codexPoolActivityCmd.Flags().String("provider", "", "Provider ID") + codexPoolCmd.AddCommand(codexPoolActivityCmd) + rootCmd.AddCommand(codexPoolCmd) +} diff --git a/cmd/config_defaults.go b/cmd/config_defaults.go new file mode 100644 index 0000000..084b3ad --- /dev/null +++ b/cmd/config_defaults.go @@ -0,0 +1,28 @@ +package cmd + +import "github.com/spf13/cobra" + +var configDefaultsCmd = &cobra.Command{ + Use: "defaults", + Short: "Show resolved server configuration defaults", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := newWS("cli") + if err != nil { + return err + } + if _, err := ws.Connect(); err != nil { + return err + } + defer ws.Close() + data, err := ws.Call("config.defaults", nil) + if err != nil { + return err + } + printer.Print(unmarshalMap(data)) + return nil + }, +} + +func init() { + configCmd.AddCommand(configDefaultsCmd) +} diff --git a/cmd/p4_ux_polish_test.go b/cmd/p4_ux_polish_test.go new file mode 100644 index 0000000..dbca00b --- /dev/null +++ b/cmd/p4_ux_polish_test.go @@ -0,0 +1,287 @@ +package cmd + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gorilla/websocket" + "github.com/nextlevelbuilder/goclaw-cli/internal/output" + "github.com/spf13/cobra" +) + +func TestCodexPoolActivityRoutes(t *testing.T) { + defer resetTestFlag(codexPoolActivityCmd, "agent", "") + defer resetTestFlag(codexPoolActivityCmd, "provider", "") + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, 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, "codex-pool", "activity", "--agent=agent-1"); err != nil { + t.Fatalf("codex-pool agent: %v", err) + } + _ = codexPoolActivityCmd.Flags().Set("agent", "") + if err := runCmd(t, "codex-pool", "activity", "--provider=provider-1"); err != nil { + t.Fatalf("codex-pool provider: %v", err) + } + want := []string{ + "/v1/agents/agent-1/codex-pool-activity", + "/v1/providers/provider-1/codex-pool-activity", + } + for i := range want { + if paths[i] != want[i] { + t.Fatalf("path[%d] = %q, want %q", i, paths[i], want[i]) + } + } +} + +func TestAPIKeysRotatePartialFailureMapsExitFive(t *testing.T) { + defer resetTestFlag(apiKeysRotateCmd, "name", "") + defer resetTestFlag(apiKeysRotateCmd, "scopes", "") + defer resetTestFlag(apiKeysRotateCmd, "expires-in", "0") + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + switch r.URL.Path { + case "/v1/api-keys": + okJSON(t, w, map[string]any{"id": "new-key", "key": "goclaw_new_raw", "name": "rotated"}) + case "/v1/api-keys/old-key/revoke": + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"ok":false,"error":{"code":"INTERNAL","message":"boom"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "json") + + stdout, err := captureStdout(t, func() error { + return runCmd(t, "api-keys", "rotate", "old-key", "--name=rotated", "--scopes=operator.read", "--yes") + }) + if err == nil { + t.Fatal("expected partial failure") + } + if strings.Count(stdout, "goclaw_new_raw") != 1 { + t.Fatalf("raw key count in stdout = %d, stdout = %s", strings.Count(stdout, "goclaw_new_raw"), stdout) + } + if got := output.FromError(err); got != output.ExitServer { + t.Fatalf("exit = %d, want %d", got, output.ExitServer) + } + var detail *output.ErrorDetail + if !errors.As(err, &detail) { + t.Fatalf("expected ErrorDetail, got %T", err) + } + details, ok := detail.Details.(map[string]any) + if !ok { + t.Fatalf("details = %#v", detail.Details) + } + if _, ok := details["new_key"]; ok { + t.Fatalf("partial failure details must not repeat raw key: %#v", details) + } + if details["new_key_id"] != "new-key" || details["old_key_id"] != "old-key" || + details["old_revoke_status"] != "failed" { + t.Fatalf("details = %#v", details) + } + if len(paths) != 2 || paths[0] != "/v1/api-keys" || paths[1] != "/v1/api-keys/old-key/revoke" { + t.Fatalf("paths = %#v", paths) + } +} + +func TestAPIKeysCreateUsesSharedScopeParser(t *testing.T) { + defer resetTestFlag(apiKeysCreateCmd, "name", "") + defer resetTestFlag(apiKeysCreateCmd, "scopes", "") + defer resetTestFlag(apiKeysCreateCmd, "expires-in", "0") + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/api-keys" { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewDecoder(r.Body).Decode(&body) + okJSON(t, w, map[string]any{"id": "new-key", "key": "goclaw_new_raw"}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "json") + + err := runCmd(t, "api-keys", "create", "--name=ci", "--scopes= operator.read, , operator.write ") + if err != nil { + t.Fatalf("api-keys create: %v", err) + } + scopes := body["scopes"].([]any) + if len(scopes) != 2 || scopes[0] != "operator.read" || scopes[1] != "operator.write" { + t.Fatalf("scopes = %#v", scopes) + } +} + +func TestLegacyCodexPoolAliasesUseCobraDeprecationMetadata(t *testing.T) { + if agentsCodexPoolActivityCmd.Deprecated == "" { + t.Fatal("agents codex-pool-activity should be marked deprecated") + } + if providersCodexPoolActivityCmd.Deprecated == "" { + t.Fatal("providers codex-pool-activity should be marked deprecated") + } +} + +func TestConfigDefaultsUsesWSMethod(t *testing.T) { + seen := make(chan string, 2) + srv := mockRPCServer(t, seen) + defer srv.Close() + setupP3CommandTest(srv.URL) + + if err := configDefaultsCmd.RunE(configDefaultsCmd, nil); err != nil { + t.Fatalf("config defaults: %v", err) + } + <-seen // connect + if method := <-seen; method != "config.defaults" { + t.Fatalf("method = %q", method) + } +} + +func TestToolsInvokeArgsReadsFile(t *testing.T) { + defer resetTestFlag(toolsInvokeCmd, "args", "") + defer resetTestFlag(toolsInvokeCmd, "param", "") + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/tools/invoke" { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewDecoder(r.Body).Decode(&body) + okJSON(t, w, map[string]any{"ok": true}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + path := filepath.Join(t.TempDir(), "args.json") + if err := os.WriteFile(path, []byte(`{"city":"Saigon"}`), 0o600); err != nil { + t.Fatalf("write args: %v", err) + } + if err := runCmd(t, "tools", "invoke", "weather", "--args=@"+path, "--param=unit=c"); err != nil { + t.Fatalf("tools invoke: %v", err) + } + params := body["parameters"].(map[string]any) + if params["city"] != "Saigon" || params["unit"] != "c" { + t.Fatalf("params = %#v", params) + } +} + +func TestChatReplayAndResumeUseExistingWSContracts(t *testing.T) { + defer resetTestFlag(chatReplayCmd, "session", "") + defer resetTestFlag(chatReplayCmd, "before", "") + defer resetTestFlag(chatReplayCmd, "limit", "50") + defer resetTestFlag(chatSessionsResumeCmd, "session", "") + defer resetTestFlag(chatSessionsResumeCmd, "message", "") + defer resetTestFlag(chatSessionsResumeCmd, "no-stream", "false") + seen := make(chan wsCall, 4) + srv := mockCaptureRPCServer(t, seen) + defer srv.Close() + setupP3CommandTest(srv.URL) + + _ = chatReplayCmd.Flags().Set("session", "sess-1") + _ = chatReplayCmd.Flags().Set("before", "") + _ = chatReplayCmd.Flags().Set("limit", "50") + if err := chatReplayCmd.RunE(chatReplayCmd, []string{"agent-1"}); err != nil { + t.Fatalf("chat replay: %v", err) + } + <-seen // connect + history := <-seen + if history.Method != "chat.history" || history.Params["agent_key"] != "agent-1" || + history.Params["session_key"] != "sess-1" { + t.Fatalf("history call = %#v", history) + } + + _ = chatSessionsResumeCmd.Flags().Set("session", "sess-2") + _ = chatSessionsResumeCmd.Flags().Set("message", "hi") + _ = chatSessionsResumeCmd.Flags().Set("no-stream", "true") + if err := chatSessionsResumeCmd.RunE(chatSessionsResumeCmd, []string{"agent-1"}); err != nil { + t.Fatalf("chat sessions resume: %v", err) + } + <-seen // connect + send := <-seen + if send.Method != "chat.send" || send.Params["agent_key"] != "agent-1" || + send.Params["session_key"] != "sess-2" || send.Params["message"] != "hi" { + t.Fatalf("send call = %#v", send) + } +} + +func resetTestFlag(cmd *cobra.Command, name, value string) { + flag := cmd.Flags().Lookup(name) + if flag == nil { + return + } + _ = flag.Value.Set(value) + flag.Changed = false +} + +func captureStdout(t *testing.T, fn func() error) (string, error) { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + fnErr := fn() + _ = w.Close() + os.Stdout = old + data, readErr := io.ReadAll(r) + _ = r.Close() + if readErr != nil { + t.Fatalf("read stdout: %v", readErr) + } + return string(data), fnErr +} + +type wsCall struct { + Method string + Params map[string]any +} + +func mockCaptureRPCServer(t *testing.T, seen chan<- wsCall) *httptest.Server { + t.Helper() + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("ws upgrade: %v", err) + } + defer conn.Close() + for { + var req struct { + ID string `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` + } + if err := conn.ReadJSON(&req); err != nil { + return + } + seen <- wsCall{Method: req.Method, Params: req.Params} + payload, _ := json.Marshal([]map[string]any{{"content": "ok"}}) + if req.Method == "chat.send" || req.Method == "connect" { + payload, _ = json.Marshal(map[string]any{"content": "ok"}) + } + _ = conn.WriteJSON(map[string]any{ + "type": "res", + "id": req.ID, + "ok": true, + "payload": json.RawMessage(payload), + }) + } + })) +} diff --git a/cmd/providers_codex_pool.go b/cmd/providers_codex_pool.go index ee93649..23ee979 100644 --- a/cmd/providers_codex_pool.go +++ b/cmd/providers_codex_pool.go @@ -8,9 +8,10 @@ import ( // Returns activity data for a provider's Codex pool (code execution sessions). var providersCodexPoolActivityCmd = &cobra.Command{ - Use: "codex-pool-activity ", - Short: "Show Codex pool activity for a provider", - Args: cobra.ExactArgs(1), + Use: "codex-pool-activity ", + Short: "Show Codex pool activity for a provider", + Deprecated: "use 'goclaw codex-pool activity --provider=' instead", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newHTTP() if err != nil { diff --git a/cmd/tools_custom.go b/cmd/tools_custom.go index 06e3879..ba8bfd2 100644 --- a/cmd/tools_custom.go +++ b/cmd/tools_custom.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "net/url" - "strings" "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/nextlevelbuilder/goclaw-cli/internal/tui" @@ -153,19 +152,9 @@ var toolsInvokeCmd = &cobra.Command{ if err != nil { return err } - paramPairs, _ := cmd.Flags().GetStringSlice("param") - paramsJSON, _ := cmd.Flags().GetString("params") - params := make(map[string]any) - if paramsJSON != "" { - if err := json.Unmarshal([]byte(paramsJSON), ¶ms); err != nil { - return fmt.Errorf("invalid --params JSON: %w", err) - } - } - for _, pair := range paramPairs { - parts := strings.SplitN(pair, "=", 2) - if len(parts) == 2 { - params[parts[0]] = parts[1] - } + params, err := parseToolInvokeParams(cmd) + if err != nil { + return err } body := map[string]any{"name": args[0], "parameters": params} data, err := c.Post("/v1/tools/invoke", body) @@ -190,6 +179,7 @@ func init() { } toolsInvokeCmd.Flags().StringSlice("param", nil, "Parameter key=value pairs") toolsInvokeCmd.Flags().String("params", "", "Parameters as JSON object") + toolsInvokeCmd.Flags().String("args", "", "Alias for --params; accepts literal JSON or @filepath") toolsCustomCmd.AddCommand(toolsCustomListCmd, toolsCustomGetCmd, toolsCustomCreateCmd, toolsCustomUpdateCmd, toolsCustomDeleteCmd) diff --git a/cmd/tools_invoke_args.go b/cmd/tools_invoke_args.go new file mode 100644 index 0000000..f045763 --- /dev/null +++ b/cmd/tools_invoke_args.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +func parseToolInvokeParams(cmd *cobra.Command) (map[string]any, error) { + paramPairs, _ := cmd.Flags().GetStringSlice("param") + paramsJSON, _ := cmd.Flags().GetString("params") + argsJSON, _ := cmd.Flags().GetString("args") + paramsChanged := cmd.Flags().Changed("params") + argsChanged := cmd.Flags().Changed("args") + if paramsChanged && argsChanged && paramsJSON != argsJSON { + return nil, fmt.Errorf("use only one of --params or --args") + } + + rawJSON := paramsJSON + flagName := "--params" + if rawJSON == "" && argsJSON != "" { + rawJSON = argsJSON + flagName = "--args" + } + + params := make(map[string]any) + if rawJSON != "" { + content, err := readContent(rawJSON) + if err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(content), ¶ms); err != nil { + return nil, fmt.Errorf("invalid %s JSON: %w", flagName, err) + } + } + for _, pair := range paramPairs { + parts := strings.SplitN(pair, "=", 2) + if len(parts) == 2 { + params[parts[0]] = parts[1] + } + } + return params, nil +} diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 8ec5939..32557b8 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-19 -**Phase Status:** P0-P4 Complete (AI-First Expansion); Super Admin API Parity Complete; Domain Coverage P3 Complete +**Phase Status:** P0-P4 Complete (AI-First Expansion); Super Admin API Parity Complete; Domain Coverage P4 Complete **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 filler pass adds first-class profile commands, `GOCLAW_PROFILE`, `sessions compact`, WS health, and trace filter polish. +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`. **Key Metrics:** - **70+ command files** in `cmd/` (modularized for maintainability) @@ -633,6 +633,12 @@ goclaw vault | `agents_v3_flags.go` | `agents v3-flags get/toggle` | Experimental feature flags | | `agents_misc.go` | `agents orchestration/codex-pool-activity` | Orchestration + pool status | | `chat_ai_commands.go` | `chat history/inject/session-status` | AI-critical MAX POLISH chat ops | +| `chat_replay.go` | `chat replay` | Session replay wrapper over `chat.history` | +| `chat_sessions.go` | `chat sessions resume` | Resume wrapper over existing chat session flow | +| `codex_pool.go` | `codex-pool activity` | Unified agent/provider Codex pool activity lookup | +| `config_defaults.go` | `config defaults` | Read-only WS config defaults passthrough | +| `api_keys_rotate.go` | `api-keys rotate` | Replacement key creation + old key revoke composite | +| `tools_invoke_args.go` | `tools invoke --args` | JSON arg alias/file parsing for tool invocation | | `teams_members.go` | `teams members list/add/remove` | Team membership | | `teams_tasks.go` | `teams tasks list/get/get-light/create/assign` | Core task CRUD | | `teams_tasks_review.go` | `teams tasks approve/reject/comment/comments` | Task review workflow | diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index b220ecb..6d30054 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -2,8 +2,24 @@ **Last Updated:** 2026-05-19 **Phase Structure:** Legacy Phases 1-9 (bootstrap → CI/CD) + AI-First Expansion Phases 0-5 (2026-04-15) -**Current Status:** Legacy Phases 1-9 ✓ COMPLETE; P0-P4 ✓ COMPLETE; Super Admin API Parity ✓ COMPLETE; Domain Coverage P3 ✓ COMPLETE -**Next Phase:** Domain Coverage residuals: P4 UX polish leftovers, then P5 team attachment download + evolution skill apply. +**Current Status:** Legacy Phases 1-9 ✓ COMPLETE; P0-P4 ✓ COMPLETE; Super Admin API Parity ✓ COMPLETE; Domain Coverage P4 ✓ COMPLETE +**Next Phase:** Domain Coverage residuals: P5 team attachment download + evolution skill apply. + +--- + +## 2026-05-19: Domain Coverage P4 ✓ COMPLETE + +**Objective:** Close UX polish residuals after the P4/P5 sweep without adding new server contracts. + +**Deliverables:** +- [x] Added `codex-pool activity --agent|--provider` as the unified Codex pool activity command. +- [x] Added `api-keys rotate ` as a non-atomic create-and-revoke composite with emit-before-revoke partial failure handling. +- [x] Added `config defaults` via WS RPC `config.defaults`. +- [x] Added `chat replay --session=` and `chat sessions resume --session=` convenience wrappers. +- [x] Added `tools invoke --args=` alias support. +- [x] Added focused P4 route/contract tests and synced README, changelog, codebase summary, and plan docs. + +**Validation:** `/usr/local/go/bin/go build ./...`; `/usr/local/go/bin/go test ./...`; `/usr/local/go/bin/go vet ./...`. --- diff --git a/plans/260503-1907-domain-coverage-p3-plus/phase-04-ux-polish-batch-1.md b/plans/260503-1907-domain-coverage-p3-plus/phase-04-ux-polish-batch-1.md index 357af36..4090186 100644 --- a/plans/260503-1907-domain-coverage-p3-plus/phase-04-ux-polish-batch-1.md +++ b/plans/260503-1907-domain-coverage-p3-plus/phase-04-ux-polish-batch-1.md @@ -1,7 +1,7 @@ # Phase 4 — UX Polish Batch 1 **Priority:** 🟡 medium -**Status:** verified residuals — not-started +**Status:** complete **Estimated LoC:** ~250 (excl. tests) **Estimated PR size:** ≤ 500 LoC incl. tests **Depends on:** P3 (multi-profile stable) @@ -9,6 +9,7 @@ ## Context Links - Gap analysis: `plans/reports/brainstorm-260503-1907-gap-analysis-round2.md` § 5 (P4) +- Validation/red-team: `reports/validation-red-team-260519-p4.md` ## Overview @@ -21,8 +22,8 @@ Composite/UX commands wrapping endpoints already shipped server-side. No server | P2 | `codex-pool activity --agent=… \| --provider=…` | unify `agents codex-pool-activity` + `providers codex-pool-activity` | residual | | D3 | `api-keys rotate ` | composite: create-new + emit raw + revoke-old | residual | | D6 | `config defaults` | WS `config.defaults` (`pkg/protocol/methods.go` ConfigDefaults) | residual | -| E3 | `chat replay ` | composite: `sessions preview` + `chat history` | residual | -| E1 | `chat sessions resume ` | UX wrapper around `chat send --session-key=` | residual | +| E3 | `chat replay --session=` | convenience wrapper over existing `chat history --session` | residual | +| E1 | `chat sessions resume --session=` | discoverability wrapper over existing `chat --session` | residual | | X1 | `agents prompt-preview ` | `cmd/agents_admin.go` | covered | | X6 | `tools invoke ` | `cmd/tools_custom.go`; residual is `--args=@file.json` alias only | partial | | X7 | `storage size` | `cmd/storage.go` | covered | @@ -32,7 +33,7 @@ Composite/UX commands wrapping endpoints already shipped server-side. No server ### Modify - `cmd/api_keys.go` — add `rotate` -- `cmd/chat.go` — add `replay` + `sessions resume` +- `cmd/chat.go` / `cmd/chat_ai_commands.go` — add replay/resume convenience wrappers without duplicating existing session logic - `cmd/tools_custom.go` — add `--args` alias / `@file` support for existing `tools invoke` - `CHANGELOG.md`, `docs/codebase-summary.md` @@ -49,32 +50,34 @@ Composite/UX commands wrapping endpoints already shipped server-side. No server ## Implementation Steps 1. `cmd/codex_pool.go`: register top-level `codex-pool activity` + flag dispatch (`--agent` vs `--provider`). Mark old commands deprecated in Long help. -2. `cmd/api_keys.go::rotate`: orchestrate create → emit raw → revoke-old in single command, JSON output of new key. +2. `cmd/api_keys.go::rotate`: orchestrate create → emit raw → revoke-old in single command, JSON output of new key. Treat as non-atomic composite; preserve machine-readable partial failure output. 3. `cmd/config_defaults.go`: WS `config.defaults`, raw passthrough. -4. Extend `cmd/chat.go` with `replay ` (composite preview+history, stream JSONL to stdout). -5. Add `cmd/chat.go::sessions resume ` as alias for `chat send --session-key=` (read stdin/--message body). -6. Extend existing `tools invoke ` with `--args=@file.json` or literal JSON alias. +4. Add chat wrappers only: `replay` calls existing `chat.history` with `agent_key` + `session_key`; `resume` dispatches to existing chat flow with `--session`. +5. Extend existing `tools invoke ` with `--args=@file.json` or literal JSON alias. Reject simultaneous conflicting `--params` and `--args`. +6. Keep deprecated codex-pool aliases stdout-clean; runtime notices must not break JSON output. 7. Tests for each. Composites: assert sequence of HTTP/WS calls via httptest. 8. Docs sync. ## Todo List -- [ ] cmd/codex_pool.go umbrella + alias deprecation -- [ ] cmd/api_keys.go rotate composite -- [ ] cmd/config_defaults.go -- [ ] cmd/chat.go replay composite -- [ ] cmd/chat.go sessions resume alias +- [x] cmd/codex_pool.go umbrella + alias deprecation +- [x] cmd/api_keys.go rotate composite +- [x] cmd/config_defaults.go +- [x] chat replay convenience wrapper +- [x] chat sessions resume convenience wrapper - [x] cmd/agents prompt-preview -- [ ] cmd/tools invoke `--args` alias (existing `--params` + `--param` already covered) +- [x] cmd/tools invoke `--args` alias + `@file` (existing `--params` + `--param` already covered) - [x] cmd/storage.go (size) -- [ ] tests per command -- [ ] CHANGELOG + docs +- [x] tests per command +- [x] CHANGELOG + docs ## Success Criteria - `goclaw codex-pool activity` works for both --agent and --provider; legacy commands print deprecation notice (stderr only, doesn't break JSON pipe on stdout). -- `goclaw api-keys rotate ` returns new key once + revokes old atomically; if revoke fails, output indicates partial state with exit 5. -- `goclaw chat replay ` outputs JSONL transcript suitable for `| jq` pipeline. +- `goclaw api-keys rotate ` returns new key once + attempts old revoke; if revoke fails, output indicates partial state with exit 5. +- `goclaw config defaults` calls WS `config.defaults` and returns existing printer output. +- `goclaw chat replay --session=` outputs transcript suitable for `| jq` pipeline. +- `goclaw chat sessions resume --session=` reuses existing chat send/interactive path. - `goclaw tools invoke --args=@payload.json` returns server response, exit 0 on success. - All new commands respect `--output`, `--quiet`, `--yes` contracts. - ≥ 60% line coverage on new code. @@ -83,16 +86,24 @@ Composite/UX commands wrapping endpoints already shipped server-side. No server | Risk | Mitigation | |---|---| -| `api-keys rotate` partial failure (new created, old revoke failed) | Output structured JSON `{"new_key":..., "old_revoke_status":"failed", "old_key_id":...}` + exit 5; user can manually revoke | +| `api-keys rotate` partial failure (new created, old revoke failed) | Emit raw new key immediately after create, then return structured partial-failure error with `new_key_id`, `old_key_id`, `old_revoke_status`, and exit 5 if revoke fails | | `tools invoke` argument injection | Server enforces auth + validates payload; CLI only passes through | -| `chat replay` large transcript blows memory | Stream JSONL line-by-line, do not buffer | +| `chat replay` large transcript blows memory | Current wrapper reuses `chat.history`; add pagination/streaming later only if transcript size becomes a real workflow issue | | Deprecated codex-pool aliases confuse users | Help text + CHANGELOG note + sunset version (open question — see plan.md Q2) | +| Runtime deprecation notice breaks JSON pipelines | Prefer Cobra `Deprecated` metadata or stderr-only notices; never write notices to stdout | ## Security Considerations - `api-keys rotate` raw key visible only once via stdout; suggest `--output=json` and `jq -r .key` for capture. - `tools invoke` requires authenticated session; CLI does not bypass. +## Validation Log + +- 2026-05-19 validate/red-team complete. Evidence and decisions recorded in `reports/validation-red-team-260519-p4.md`. +- `config.defaults` contract verified in sibling server: `protocol.MethodConfigDefaults = "config.defaults"` and handler registered as read-only, master-scope gated. +- Existing CLI session support verified; P4 must add discoverability wrappers, not a second session implementation. +- 2026-05-19 implementation complete. Verified with `/usr/local/go/bin/go build ./...`, `/usr/local/go/bin/go test ./...`, and `/usr/local/go/bin/go vet ./...`. + ## Next Steps -P5 = verify sweep + final fillers. +P5 = final fillers: team attachment download + evolution skill apply. diff --git a/plans/260503-1907-domain-coverage-p3-plus/plan.md b/plans/260503-1907-domain-coverage-p3-plus/plan.md index 5dae50e..145293a 100644 --- a/plans/260503-1907-domain-coverage-p3-plus/plan.md +++ b/plans/260503-1907-domain-coverage-p3-plus/plan.md @@ -3,7 +3,7 @@ **Date:** 2026-05-03 **Branch:** feat/ai-first-cli-expansion **Reference report:** `plans/reports/brainstorm-260503-1907-gap-analysis-round2.md` -**Status:** P3 complete — P4/P5 sweep complete; residual implementation not-started. +**Status:** P3/P4 complete — P5 next; P6 remains server-blocked. ## Summary @@ -12,7 +12,7 @@ Sau R1 (P0–P5) + R2 expansion (P0–P2), CLI đạt ~95% server coverage. R2 r | Phase | Scope | LoC | Tier | Status | |---|---|---|---|---| | P3 | AI-critical fillers (multi-profile, sessions compact, health, traces filter polish) | ~250 | 🔥 | complete | -| P4 | UX polish batch 1 residuals (codex-pool umbrella, api-keys rotate, config defaults, chat replay/resume, tools invoke `--args` alias) | ~250 | 🟡 | not-started | +| P4 | UX polish batch 1 residuals (codex-pool umbrella, api-keys rotate, config defaults, chat replay convenience, tools invoke `--args` alias) | ~250 | 🟡 | complete | | P5 | Fillers residuals after sweep (team attachments download, evolution skill apply) | ~150 | 🟡 | not-started | | P6 | Deferred — blocked on server FRs (traces follow, logs aggregate, providers reconnect, …) | n/a | 🟢 | server-blocked | @@ -25,10 +25,11 @@ Sau R1 (P0–P5) + R2 expansion (P0–P2), CLI đạt ~95% server coverage. R2 r ## Key Dependencies -- Superseded/blocked by `plans/260518-1936-super-admin-api-parity/` for the next implementation slice. The newer plan narrows the backlog to super-admin operational parity after server `v3.12.0-beta.5`. -- P3 multi-profile may refactor `internal/config` singleton — finish before P4/P5. +- Super-admin API parity is already merged; P4 should proceed from current `dev`. +- P3 multi-profile is complete; P4 can build on stable profile/default output behavior. - P5 verify sweep completed 2026-05-19; most suspected gaps already exist under current command paths. - P6 = upstream goclaw issues, not CLI work. +- P4 validation/red-team evidence: `reports/validation-red-team-260519-p4.md`; implementation validated with `go build ./...`, `go test ./...`, and `go vet ./...`. ## Success Criteria @@ -41,7 +42,7 @@ Sau R1 (P0–P5) + R2 expansion (P0–P2), CLI đạt ~95% server coverage. R2 r 1. Profile naming: resolved as `profile`; legacy `auth use-context` remains. 2. Codex-pool alias sunset version? -3. Chat replay output: default stdout JSONL; add `--file` only if user workflow demands it. +3. Chat replay output: resolved as existing printer output from `chat.history` (JSON array in JSON mode); add JSONL/streaming only if a real workflow demands it. 4. Health schema: raw passthrough vs normalized? 5. Server FR ownership for P6 items? 6. `tts synthesize` AI use case: already covered by `cmd/tts_http.go`; not a server FR. diff --git a/plans/260503-1907-domain-coverage-p3-plus/reports/validation-red-team-260519-p4.md b/plans/260503-1907-domain-coverage-p3-plus/reports/validation-red-team-260519-p4.md new file mode 100644 index 0000000..8297a62 --- /dev/null +++ b/plans/260503-1907-domain-coverage-p3-plus/reports/validation-red-team-260519-p4.md @@ -0,0 +1,35 @@ +# P4 Validation + Red-Team Report + +**Date:** 2026-05-19 +**Scope:** `phase-04-ux-polish-batch-1.md` +**Verdict:** ready for implementation after tightening scope below. + +## Validation Findings + +| ID | Severity | Finding | Evidence | Decision | +|---|---:|---|---|---| +| V-01 | High | `chat replay/resume` must not duplicate existing session support. CLI already supports `chat history --session=` and `chat --session=`. | `cmd/chat_ai_commands.go:13-70`, `cmd/chat.go:18-52`, `cmd/chat.go:208-211` | Narrow to convenience wrappers only. | +| V-02 | High | `api-keys rotate` cannot be truly atomic with current HTTP contracts; it is a composite create + revoke. Plan must define partial-failure output and tests. | `cmd/api_keys.go:51-103`, `cmd/api_keys.go:106-122` | Keep feature, call it composite, not atomic. | +| V-03 | Medium | `tools invoke` already supports structured JSON through `--params`; residual is only `--args` alias and `@file` support for JSON. | `cmd/tools_custom.go:147-175`, `cmd/tools_custom.go:191-192`, `cmd/helpers.go:63-72` | Keep tiny alias/file change. | +| V-04 | Medium | `config.defaults` exists in server and is read-only/secret-free, but CLI has no command. | `/Volumes/GOON/www/digitop/goclaw/pkg/protocol/methods.go:29-34`, `/Volumes/GOON/www/digitop/goclaw/internal/gateway/methods/config.go:38-47`, `cmd/config_cmd.go:12-113` | Add CLI passthrough. | +| V-05 | Low | `codex-pool` umbrella is an alias/UX improvement; existing agent/provider commands work. | `cmd/agents_misc.go:37-59`, `cmd/providers_codex_pool.go:10-25` | Implement top-level group, keep aliases. | + +## Red-Team Notes + +1. `api-keys rotate` failure mode is riskiest. If create succeeds, stdout must include the raw new key exactly once before the command attempts old-key revoke. If revoke fails, stderr/central error output should explain the old key remains active and the command should exit with code 5. +2. Avoid deprecation text on stdout for legacy `agents codex-pool-activity` and `providers codex-pool-activity`; it would break JSON pipelines. Use command `Deprecated` help metadata or stderr-only notices. +3. `chat replay` should be explicit about the agent key requirement. A session key alone is not enough for current `chat.history`, which requires `agent_key`. +4. `chat sessions resume ` should be framed as discoverability around existing `chat --session `, not a new stateful launcher unless the command accepts/resolves an agent. +5. `config defaults` is master-scope gated server-side. Tests should assert method dispatch, not assume owner-only auth. + +## Required Plan Adjustments + +- Update phase text from `chat replay/resume` to `chat replay/resume convenience wrappers`. +- State that `api-keys rotate` is non-atomic composite with emit-before-revoke partial failure handling. +- State `tools invoke --args` is an alias for `--params` and supports `@file`. +- Add tests for exact command registration and JSON/stdout safety. + +## Unresolved Questions + +1. Should `chat replay --session ` be the command shape instead of `chat replay `? +2. Should legacy codex-pool commands show deprecation notices now, or only mark `Deprecated` in Cobra help without runtime stderr? From ce74e5e49d4c5d65270d5ead869044552aa18ce0 Mon Sep 17 00:00:00 2001 From: Duy /zuey/ Date: Wed, 20 May 2026 14:32:29 +0700 Subject: [PATCH 3/4] feat(cli): add domain coverage p5 fillers --- CHANGELOG.md | 9 +- README.md | 10 +- cmd/agents_evolution.go | 86 +++++- cmd/p5_fillers_test.go | 283 ++++++++++++++++++ cmd/root.go | 4 +- cmd/teams.go | 2 +- cmd/teams_attachments.go | 94 ++++++ docs/codebase-summary.md | 15 +- docs/project-roadmap.md | 24 +- internal/config/config.go | 41 ++- .../phase-05-fillers-verification-batch-2.md | 33 +- .../plan.md | 5 +- .../phase-01-scope-lock.md | 72 +++++ .../phase-02-team-attachment-download.md | 98 ++++++ .../phase-03-evolution-skill-apply.md | 102 +++++++ .../phase-04-tests-and-docs.md | 78 +++++ .../phase-05-ship-readiness.md | 73 +++++ .../plan.md | 155 ++++++++++ .../reports/red-team-260520-p5.md | 32 ++ .../reports/validation-260520-p5.md | 53 ++++ 20 files changed, 1220 insertions(+), 49 deletions(-) create mode 100644 cmd/p5_fillers_test.go create mode 100644 cmd/teams_attachments.go create mode 100644 plans/260520-1050-domain-coverage-p5-fillers/phase-01-scope-lock.md create mode 100644 plans/260520-1050-domain-coverage-p5-fillers/phase-02-team-attachment-download.md create mode 100644 plans/260520-1050-domain-coverage-p5-fillers/phase-03-evolution-skill-apply.md create mode 100644 plans/260520-1050-domain-coverage-p5-fillers/phase-04-tests-and-docs.md create mode 100644 plans/260520-1050-domain-coverage-p5-fillers/phase-05-ship-readiness.md create mode 100644 plans/260520-1050-domain-coverage-p5-fillers/plan.md create mode 100644 plans/260520-1050-domain-coverage-p5-fillers/reports/red-team-260520-p5.md create mode 100644 plans/260520-1050-domain-coverage-p5-fillers/reports/validation-260520-p5.md diff --git a/CHANGELOG.md b/CHANGELOG.md index af4e11c..30b26ed 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–P4) +## [Unreleased] — Domain Coverage Expansion (P0–P5) ### Added @@ -41,9 +41,14 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `goclaw chat replay --session=` and `goclaw chat sessions resume --session=` — discoverability wrappers over existing chat session contracts. - `goclaw tools invoke --args=` — alias for `--params` with file-backed JSON support. +**P5 — Residual command fillers** +- `goclaw teams attachments download --output ` — authenticated attachment download with required output path and no-overwrite default. +- `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. + ### 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 the next implementation pass. +- P4/P5 backlog was re-swept against the current CLI surface; already-covered items were removed from residual scope before implementation. - Out of scope: OpenAI-compatible `/chat/completions` and `/v1/responses` endpoints (client APIs, not admin CLI surface). --- diff --git a/README.md b/README.md index 709fb7a..db99c34 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ echo "Analyze this log" | goclaw chat myagent |---------|-------------| | `auth` | Login, logout, device pairing, profile management | | `profile` | List, create, switch, inspect, and delete CLI profiles | -| `agents` | CRUD, shares, delegation links, per-user instances | +| `agents` | CRUD, shares, delegation links, per-user instances, evolution | | `chat` | Interactive or single-shot messaging with streaming | | `sessions` | List, preview, delete, reset, label, compact | | `codex-pool` | Unified Codex pool activity lookup for agents/providers | @@ -63,7 +63,7 @@ echo "Analyze this log" | goclaw chat myagent | `providers` | LLM provider CRUD, model listing, verification | | `tools` | Custom + built-in tool management, invocation | | `cron` | Scheduled jobs CRUD, trigger, run history | -| `teams` | Team management, task board, workspace | +| `teams` | Team management, task board, workspace, attachments | | `channels` | Channel instances, contacts, pending messages | | `traces` | LLM trace viewer, filters, export | | `memory` | Memory documents, semantic search | @@ -324,6 +324,12 @@ goclaw chat sessions resume myagent --session=sess-123 -m "Continue" --no-stream # Invoke a custom tool with JSON args from file goclaw tools invoke weather --args=@payload.json + +# Download a team task attachment to an explicit file +goclaw teams attachments download team-123 attachment-456 --output ./artifact.bin + +# Approve a skill_add evolution suggestion, optionally overriding the draft +goclaw agents evolution skill apply agent-123 suggestion-456 --skill-draft @./SKILL.md ``` ## API Docs diff --git a/cmd/agents_evolution.go b/cmd/agents_evolution.go index b1e942a..9a128df 100644 --- a/cmd/agents_evolution.go +++ b/cmd/agents_evolution.go @@ -1,7 +1,9 @@ package cmd import ( + "encoding/json" "fmt" + "net/url" "github.com/spf13/cobra" ) @@ -85,26 +87,104 @@ Example: if err != nil { return err } + status := map[string]string{ + "accept": "approved", + "reject": "rejected", + }[action] _, err = c.Patch( - fmt.Sprintf("/v1/agents/%s/evolution/suggestions/%s", args[0], args[1]), - map[string]any{"action": action}, + fmt.Sprintf( + "/v1/agents/%s/evolution/suggestions/%s", + url.PathEscape(args[0]), + url.PathEscape(args[1]), + ), + map[string]any{"status": status}, ) if err != nil { return err } - printer.Success(fmt.Sprintf("Suggestion %s: %sd", args[1], action)) + printer.Success(fmt.Sprintf("Suggestion %s %s", args[1], status)) return nil }, } +var agentsEvolutionSkillCmd = &cobra.Command{ + Use: "skill", + Short: "Apply skill evolution suggestions", +} + +var agentsEvolutionSkillApplyCmd = &cobra.Command{ + Use: "apply ", + Short: "Approve a skill_add evolution suggestion", + Long: `Approve a skill_add evolution suggestion for an agent. + +PATCH /v1/agents/{id}/evolution/suggestions/{suggestionID} + +Example: + goclaw agents evolution skill apply agent-1 sugg-42 + goclaw agents evolution skill apply agent-1 sugg-42 --skill-draft @./SKILL.md`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + body := map[string]any{"status": "approved"} + if cmd.Flags().Changed("skill-draft") { + draft, _ := cmd.Flags().GetString("skill-draft") + content, err := readContent(draft) + if err != nil { + return err + } + body["skill_draft"] = content + } + c, err := newHTTP() + if err != nil { + return err + } + if err := requireSkillAddSuggestion(c, args[0], args[1]); err != nil { + return err + } + data, err := c.Patch( + fmt.Sprintf( + "/v1/agents/%s/evolution/suggestions/%s", + url.PathEscape(args[0]), + url.PathEscape(args[1]), + ), + body, + ) + if err != nil { + return err + } + printer.Print(unmarshalMap(data)) + return nil + }, +} + +func requireSkillAddSuggestion(c interface { + Get(path string) (json.RawMessage, error) +}, agentID, suggestionID string) error { + data, err := c.Get("/v1/agents/" + url.PathEscape(agentID) + "/evolution/suggestions?status=pending&limit=500") + if err != nil { + return err + } + for _, suggestion := range unmarshalList(data) { + if str(suggestion, "id") == suggestionID { + if str(suggestion, "suggestion_type") != "skill_add" { + return fmt.Errorf("suggestion %s is %q, not skill_add", suggestionID, str(suggestion, "suggestion_type")) + } + return nil + } + } + return fmt.Errorf("suggestion %s not found in agent evolution suggestions", suggestionID) +} + func init() { agentsEvolutionUpdateCmd.Flags().String("action", "", "Action: accept or reject") _ = agentsEvolutionUpdateCmd.MarkFlagRequired("action") + agentsEvolutionSkillApplyCmd.Flags().String("skill-draft", "", "Skill draft content or @file") + agentsEvolutionSkillCmd.AddCommand(agentsEvolutionSkillApplyCmd) agentsEvolutionCmd.AddCommand( agentsEvolutionMetricsCmd, agentsEvolutionSuggestionsCmd, agentsEvolutionUpdateCmd, + agentsEvolutionSkillCmd, ) agentsCmd.AddCommand(agentsEvolutionCmd) } diff --git a/cmd/p5_fillers_test.go b/cmd/p5_fillers_test.go new file mode 100644 index 0000000..d5f9ff3 --- /dev/null +++ b/cmd/p5_fillers_test.go @@ -0,0 +1,283 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/nextlevelbuilder/goclaw-cli/internal/config" + "github.com/nextlevelbuilder/goclaw-cli/internal/output" + "github.com/spf13/cobra" +) + +func setupP5HTTPTest(serverURL string) { + cfg = &config.Config{Server: serverURL, Token: "test-token", OutputFormat: "json"} + printer = output.NewPrinter("json") +} + +func resetFlag(t *testing.T, cmd *cobra.Command, name string) { + t.Helper() + flag := cmd.Flags().Lookup(name) + if flag == nil { + t.Fatalf("missing flag %s", name) + } + _ = flag.Value.Set(flag.DefValue) + flag.Changed = false +} + +func TestTeamsAttachmentsDownloadWritesOutputAndSendsAuth(t *testing.T) { + body := []byte("attachment-data") + outFile := filepath.Join(t.TempDir(), "nested", "artifact.bin") + var gotPath, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.EscapedPath() + gotAuth = r.Header.Get("Authorization") + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + _, _ = w.Write(body) + })) + defer srv.Close() + setupP5HTTPTest(srv.URL) + resetFlag(t, teamsAttachmentsDownloadCmd, "output") + resetFlag(t, teamsAttachmentsDownloadCmd, "force") + _ = teamsAttachmentsDownloadCmd.Flags().Set("output", outFile) + + if err := teamsAttachmentsDownloadCmd.RunE(teamsAttachmentsDownloadCmd, []string{"team alpha", "att/42"}); err != nil { + t.Fatalf("download: %v", err) + } + if gotPath != "/v1/teams/team%20alpha/attachments/att%2F42/download" { + t.Fatalf("path = %q", gotPath) + } + if gotAuth != "Bearer test-token" { + t.Fatalf("authorization = %q", gotAuth) + } + got, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("read output: %v", err) + } + if string(got) != string(body) { + t.Fatalf("output = %q", got) + } +} + +func TestTeamsAttachmentsDownloadRequiresOutputBeforeNetwork(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })) + defer srv.Close() + setupP5HTTPTest(srv.URL) + resetFlag(t, teamsAttachmentsDownloadCmd, "output") + resetFlag(t, teamsAttachmentsDownloadCmd, "force") + + err := teamsAttachmentsDownloadCmd.RunE(teamsAttachmentsDownloadCmd, []string{"team-1", "att-1"}) + if err == nil || !strings.Contains(err.Error(), "--output is required") { + t.Fatalf("expected output validation error, got %v", err) + } + if called { + t.Fatal("server was called before output validation") + } +} + +func TestTeamsAttachmentsDownloadRefusesExistingFileUnlessForce(t *testing.T) { + outFile := filepath.Join(t.TempDir(), "artifact.bin") + if err := os.WriteFile(outFile, []byte("old"), 0644); err != nil { + t.Fatalf("seed file: %v", err) + } + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + _, _ = w.Write([]byte("new")) + })) + defer srv.Close() + setupP5HTTPTest(srv.URL) + resetFlag(t, teamsAttachmentsDownloadCmd, "output") + resetFlag(t, teamsAttachmentsDownloadCmd, "force") + _ = teamsAttachmentsDownloadCmd.Flags().Set("output", outFile) + + err := teamsAttachmentsDownloadCmd.RunE(teamsAttachmentsDownloadCmd, []string{"team-1", "att-1"}) + if err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("expected overwrite guard, got %v", err) + } + if called { + t.Fatal("server was called before overwrite guard") + } + + _ = teamsAttachmentsDownloadCmd.Flags().Set("force", "true") + if err := teamsAttachmentsDownloadCmd.RunE(teamsAttachmentsDownloadCmd, []string{"team-1", "att-1"}); err != nil { + t.Fatalf("force download: %v", err) + } + got, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("read output: %v", err) + } + if string(got) != "new" { + t.Fatalf("output = %q", got) + } +} + +func TestTeamsAttachmentsDownloadLocalOutputDoesNotOverrideFormat(t *testing.T) { + outFile := filepath.Join(t.TempDir(), "artifact.bin") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("attachment")) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "json") + resetFlag(t, teamsAttachmentsDownloadCmd, "output") + resetFlag(t, teamsAttachmentsDownloadCmd, "force") + + if err := runCmd(t, "teams", "attachments", "download", "team-1", "att-1", "--output", outFile); err != nil { + t.Fatalf("download: %v", err) + } + if cfg.OutputFormat != "json" { + t.Fatalf("cfg.OutputFormat = %q, want json", cfg.OutputFormat) + } +} + +func TestAgentsEvolutionUpdateMapsActionToStatusAndEscapesRoute(t *testing.T) { + var gotPath string + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.EscapedPath() + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Errorf("decode body: %v", err) + } + okJSON(t, w, map[string]any{"status": "approved"}) + })) + defer srv.Close() + setupP5HTTPTest(srv.URL) + resetFlag(t, agentsEvolutionUpdateCmd, "action") + _ = agentsEvolutionUpdateCmd.Flags().Set("action", "accept") + + if err := agentsEvolutionUpdateCmd.RunE(agentsEvolutionUpdateCmd, []string{"agent/1", "sugg 1"}); err != nil { + t.Fatalf("update: %v", err) + } + if gotPath != "/v1/agents/agent%2F1/evolution/suggestions/sugg%201" { + t.Fatalf("path = %q", gotPath) + } + if gotBody["status"] != "approved" || gotBody["action"] != nil { + t.Fatalf("body = %#v", gotBody) + } +} + +func TestAgentsEvolutionUpdateRejectMapsToRejected(t *testing.T) { + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Errorf("decode body: %v", err) + } + okJSON(t, w, map[string]any{"status": "rejected"}) + })) + defer srv.Close() + setupP5HTTPTest(srv.URL) + resetFlag(t, agentsEvolutionUpdateCmd, "action") + _ = agentsEvolutionUpdateCmd.Flags().Set("action", "reject") + + if err := agentsEvolutionUpdateCmd.RunE(agentsEvolutionUpdateCmd, []string{"agent-1", "sugg-1"}); err != nil { + t.Fatalf("update: %v", err) + } + if gotBody["status"] != "rejected" { + t.Fatalf("body = %#v", gotBody) + } +} + +func TestAgentsEvolutionSkillApplySendsApprovedWithDraftFile(t *testing.T) { + draftPath := filepath.Join(t.TempDir(), "SKILL.md") + if err := os.WriteFile(draftPath, []byte("skill draft\n"), 0644); err != nil { + t.Fatalf("write draft: %v", err) + } + var gotPath string + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + if r.URL.EscapedPath() != "/v1/agents/agent%2F1/evolution/suggestions" { + t.Errorf("unexpected preflight path: %s", r.URL.EscapedPath()) + } + okJSON(t, w, []map[string]any{{"id": "sugg 1", "suggestion_type": "skill_add"}}) + return + case http.MethodPatch: + gotPath = r.URL.EscapedPath() + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Errorf("decode body: %v", err) + } + okJSON(t, w, map[string]any{"applied": true}) + return + default: + t.Errorf("unexpected method: %s", r.Method) + } + })) + defer srv.Close() + setupP5HTTPTest(srv.URL) + resetFlag(t, agentsEvolutionSkillApplyCmd, "skill-draft") + _ = agentsEvolutionSkillApplyCmd.Flags().Set("skill-draft", "@"+draftPath) + + if err := agentsEvolutionSkillApplyCmd.RunE(agentsEvolutionSkillApplyCmd, []string{"agent/1", "sugg 1"}); err != nil { + t.Fatalf("skill apply: %v", err) + } + if gotPath != "/v1/agents/agent%2F1/evolution/suggestions/sugg%201" { + t.Fatalf("path = %q", gotPath) + } + if gotBody["status"] != "approved" || gotBody["skill_draft"] != "skill draft\n" { + t.Fatalf("body = %#v", gotBody) + } +} + +func TestAgentsEvolutionSkillApplyWithoutDraftSendsApprovedOnly(t *testing.T) { + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + okJSON(t, w, []map[string]any{{"id": "sugg-1", "suggestion_type": "skill_add"}}) + return + } + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } else if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Errorf("decode body: %v", err) + } + okJSON(t, w, map[string]any{"applied": true}) + })) + defer srv.Close() + setupP5HTTPTest(srv.URL) + resetFlag(t, agentsEvolutionSkillApplyCmd, "skill-draft") + + if err := agentsEvolutionSkillApplyCmd.RunE(agentsEvolutionSkillApplyCmd, []string{"agent-1", "sugg-1"}); err != nil { + t.Fatalf("skill apply: %v", err) + } + if gotBody["status"] != "approved" || gotBody["skill_draft"] != nil { + t.Fatalf("body = %#v", gotBody) + } +} + +func TestAgentsEvolutionSkillApplyRejectsNonSkillSuggestionBeforePatch(t *testing.T) { + patchCalled := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPatch { + patchCalled = true + t.Error("PATCH should not be called for non-skill suggestion") + return + } + okJSON(t, w, []map[string]any{{"id": "sugg-1", "suggestion_type": "tool_order"}}) + })) + defer srv.Close() + setupP5HTTPTest(srv.URL) + resetFlag(t, agentsEvolutionSkillApplyCmd, "skill-draft") + + err := agentsEvolutionSkillApplyCmd.RunE(agentsEvolutionSkillApplyCmd, []string{"agent-1", "sugg-1"}) + if err == nil || !strings.Contains(err.Error(), "not skill_add") { + t.Fatalf("expected skill_add validation error, got %v", err) + } + if patchCalled { + t.Fatal("PATCH was called") + } +} diff --git a/cmd/root.go b/cmd/root.go index 68ae70b..2008108 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -31,8 +31,8 @@ var rootCmd = &cobra.Command{ // but re-resolve here so TTY fallback kicks in when neither flag nor env // is set while preserving a profile-level output default. flagVal := "" - if cmd.Flags().Changed("output") { - flagVal, _ = cmd.Flags().GetString("output") + if flag := cmd.Root().PersistentFlags().Lookup("output"); flag != nil && flag.Changed { + flagVal = flag.Value.String() } cfg.OutputFormat = output.ResolveFormatWithDefault(flagVal, cfg.ProfileOutputFormat) diff --git a/cmd/teams.go b/cmd/teams.go index 8137d64..5f86f57 100644 --- a/cmd/teams.go +++ b/cmd/teams.go @@ -144,7 +144,7 @@ func init() { teamsCmd.AddCommand( teamsListCmd, teamsGetCmd, teamsCreateCmd, teamsUpdateCmd, teamsDeleteCmd, teamsMembersCmd, teamsTasksCmd, teamsWorkspaceCmd, - teamsEventsCmd, teamsScopesCmd, + teamsEventsCmd, teamsScopesCmd, teamsAttachmentsCmd, ) rootCmd.AddCommand(teamsCmd) } diff --git a/cmd/teams_attachments.go b/cmd/teams_attachments.go new file mode 100644 index 0000000..d4d9cd2 --- /dev/null +++ b/cmd/teams_attachments.go @@ -0,0 +1,94 @@ +package cmd + +import ( + "fmt" + "io" + "net/url" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +// teams_attachments.go — team task attachment downloads. +// HTTP endpoint: GET /v1/teams/{teamId}/attachments/{attachmentId}/download + +var teamsAttachmentsCmd = &cobra.Command{ + Use: "attachments", + Short: "Manage team task attachments", +} + +var teamsAttachmentsDownloadCmd = &cobra.Command{ + Use: "download ", + Short: "Download a team task attachment", + Long: `Download a team task attachment to an explicit local output file. + +GET /v1/teams/{teamId}/attachments/{attachmentId}/download + +Example: + goclaw teams attachments download team-1 att-42 --output ./artifact.bin + goclaw teams attachments download team-1 att-42 -o ./artifact.bin --force`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + outFile, _ := cmd.Flags().GetString("output") + force, _ := cmd.Flags().GetBool("force") + if outFile == "" { + return fmt.Errorf("--output is required") + } + if err := os.MkdirAll(filepath.Dir(outFile), 0755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + if !force { + if _, err := os.Stat(outFile); err == nil { + return fmt.Errorf("output file already exists: %s (use --force to overwrite)", outFile) + } else if !os.IsNotExist(err) { + return fmt.Errorf("check output file: %w", err) + } + } + + flags := os.O_WRONLY | os.O_CREATE | os.O_EXCL + if force { + flags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC + } + + c, err := newHTTP() + if err != nil { + return err + } + path := fmt.Sprintf( + "/v1/teams/%s/attachments/%s/download", + url.PathEscape(args[0]), + url.PathEscape(args[1]), + ) + resp, err := c.GetRaw(path) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + return rawResponseError(resp) + } + defer resp.Body.Close() + + f, err := os.OpenFile(outFile, flags, 0644) + if err != nil { + if os.IsExist(err) { + return fmt.Errorf("output file already exists: %s (use --force to overwrite)", outFile) + } + return fmt.Errorf("open output file: %w", err) + } + defer f.Close() + + n, err := io.Copy(f, resp.Body) + if err != nil { + return fmt.Errorf("write output file: %w", err) + } + printer.Success(fmt.Sprintf("Downloaded %d bytes to %s", n, outFile)) + return nil + }, +} + +func init() { + teamsAttachmentsDownloadCmd.Flags().StringP("output", "o", "", "Output file path") + teamsAttachmentsDownloadCmd.Flags().Bool("force", false, "Overwrite output file if it exists") + teamsAttachmentsCmd.AddCommand(teamsAttachmentsDownloadCmd) +} diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 32557b8..a5fc48d 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-19 -**Phase Status:** P0-P4 Complete (AI-First Expansion); Super Admin API Parity Complete; Domain Coverage P4 Complete +**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 **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`. +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. **Key Metrics:** - **70+ command files** in `cmd/` (modularized for maintainability) @@ -281,7 +281,7 @@ goclaw (root) │ ├── files (list, get, set) # global AGENTS.md, SOUL.md, IDENTITY.md, ... │ ├── instances (list, get-file, set-file, metadata, update-metadata) │ ├── episodic (list, search) -│ ├── evolution (metrics, suggestions, update) +│ ├── evolution (metrics, suggestions, update, skill apply) │ ├── orchestration / codex-pool-activity │ ├── skills list # skills granted to agent │ ├── v3-flags (get, toggle) @@ -313,7 +313,8 @@ goclaw (root) ├── tools (list, invoke, delete) ├── cron (list, create, update, delete, trigger, history) ├── teams (list, create, members, task-board, export, import [--apply]) -│ └── workspace (list, read, delete, upload, move) +│ ├── workspace (list, read, delete, upload, move) +│ └── attachments download --output ├── channels (list, contacts, pending-messages) ├── traces (list, export) ├── memory (list, search, upsert) @@ -628,7 +629,7 @@ goclaw vault | `agents_sharing.go` | `agents share/unshare/regenerate/resummon` | Agent sharing lifecycle | | `agents_instances.go` | `agents instances list/get-file/set-file/update-metadata/metadata` | Per-user instance management | | `agents_links.go` | `agents links list/create/update/delete` | Delegation link management | -| `agents_evolution.go` | `agents evolution metrics/suggestions/update` | Evolution feedback loop | +| `agents_evolution.go` | `agents evolution metrics/suggestions/update/skill apply` | Evolution feedback loop and skill suggestion approval | | `agents_episodic.go` | `agents episodic list/search` | Episodic memory (semantic search) | | `agents_v3_flags.go` | `agents v3-flags get/toggle` | Experimental feature flags | | `agents_misc.go` | `agents orchestration/codex-pool-activity` | Orchestration + pool status | @@ -644,6 +645,7 @@ goclaw vault | `teams_tasks_review.go` | `teams tasks approve/reject/comment/comments` | Task review workflow | | `teams_tasks_advanced.go` | `teams tasks delete/delete-bulk/events/active` | Advanced task ops + follow stream | | `teams_workspace.go` | `teams workspace list/read/delete` | Team workspace files | +| `teams_attachments.go` | `teams attachments download` | Authenticated team task attachment downloads | | `teams_events.go` | `teams events list [--follow]` | Team event stream | | `teams_scopes.go` | `teams scopes ` | Permission scopes | | `memory_kg.go` | `memory kg entities list/get/upsert/delete` | KG entity CRUD | @@ -671,6 +673,7 @@ All `cmd/` files now ≤200 LoC (chat files are 214 lines — overage is entirel | File | Tests | Coverage | |------|-------|---------| | `agents_lifecycle_test.go` | 18 tests | wake, identity, wait (success+timeout+invalid), sync, preview, evolution, episodic, v3-flags, orchestration, codex, instances | +| `p5_fillers_test.go` | 9 tests | team attachment download, output-format guard, evolution payload mapping, skill apply type guard and draft override | | `chat_extensions_test.go` | 11 tests | history (3), inject (5 inc. validation), session-status (2) | | `teams_tasks_test.go` | 16 tests | list, get, get-light, create, assign, delete (yes+declined), delete-bulk (ids+missing), events, active (success+missing), scopes, events-list | | `memory_kg_test.go` | 15 tests | entities (list/get/delete/delete-with-yes), traverse (from-required+success), stats, graph (full+compact), dedup (scan/list/merge/dismiss), chunks, index, index-all, documents-global | diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 6d30054..4cb6b20 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,9 +1,24 @@ # GoClaw CLI - Project Roadmap -**Last Updated:** 2026-05-19 +**Last Updated:** 2026-05-20 **Phase Structure:** Legacy Phases 1-9 (bootstrap → CI/CD) + AI-First Expansion Phases 0-5 (2026-04-15) -**Current Status:** Legacy Phases 1-9 ✓ COMPLETE; P0-P4 ✓ COMPLETE; Super Admin API Parity ✓ COMPLETE; Domain Coverage P4 ✓ COMPLETE -**Next Phase:** Domain Coverage residuals: P5 team attachment download + evolution skill apply. +**Current Status:** Legacy Phases 1-9 ✓ COMPLETE; P0-P4 ✓ COMPLETE; Super Admin API Parity ✓ COMPLETE; Domain Coverage P5 implemented pending release. +**Next Phase:** Ship Domain Coverage P5 PR to `dev` and verify beta release. + +--- + +## 2026-05-20: Domain Coverage P5 IMPLEMENTED + +**Objective:** Close final CLI-only residuals after the P5 sweep without adding server routes. + +**Deliverables:** +- [x] Added `teams attachments download --output ` with required output, parent directory creation, no-overwrite default, and `--force`. +- [x] Added `agents evolution skill apply [--skill-draft @file]` as a skill-specific approval wrapper. +- [x] Fixed `agents evolution update --action=accept|reject` to send server-compatible `status=approved|rejected`. +- [x] Added focused regression tests for route escaping, auth header, request bodies, file writes, missing output, overwrite guard, output-format preservation, suggestion type guard, and draft override. +- [x] Synced parent P5 plan and dedicated execution plan. + +**Validation:** `/usr/local/go/bin/go build ./...`; `/usr/local/go/bin/go test ./...`; `/usr/local/go/bin/go vet ./...`. --- @@ -38,7 +53,7 @@ **Validation:** `go test ./...`. -**Backlog Sweep:** P4/P5 verification on 2026-05-19 removed covered items from future scope: `agents prompt-preview`, `storage size`, `channels writers groups`, `contacts unmerge`, `agents instances`, `mcp servers tools`, `agents evolution update`, and `tts synthesize`. +**Backlog Sweep:** P4/P5 verification on 2026-05-19 removed covered items from future scope: `agents prompt-preview`, `storage size`, `channels writers groups`, `contacts unmerge`, `agents instances`, `mcp servers tools`, and `tts synthesize`. Follow-up P5 validation on 2026-05-20 found `agents evolution update` existed as a command surface but needed payload compatibility repair. --- @@ -460,7 +475,6 @@ **Deferred / out of scope:** - OpenAI-compatible `/chat/completions` and `/v1/responses` (client APIs) -- `evolution_skill_apply` (no REST route registered server-side) - `hooks history` pagination (server stub returns empty list pending Phase 4) --- diff --git a/internal/config/config.go b/internal/config/config.go index 414b17e..64919d3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -95,22 +95,22 @@ func Load(cmd *cobra.Command) (*Config, error) { } // 3. Overlay flags (only if explicitly set) - if cmd.Flags().Changed("server") { - cfg.Server, _ = cmd.Flags().GetString("server") + if changed, value := rootOrLocalStringFlag(cmd, "server"); changed { + cfg.Server = value } - if cmd.Flags().Changed("token") { - cfg.Token, _ = cmd.Flags().GetString("token") + if changed, value := rootOrLocalStringFlag(cmd, "token"); changed { + cfg.Token = value } - if cmd.Flags().Changed("output") { - cfg.OutputFormat, _ = cmd.Flags().GetString("output") + if changed, value := rootOrLocalStringFlag(cmd, "output"); changed { + cfg.OutputFormat = value } - if cmd.Flags().Changed("insecure") { - cfg.Insecure, _ = cmd.Flags().GetBool("insecure") + if changed, value := rootOrLocalBoolFlag(cmd, "insecure"); changed { + cfg.Insecure = value } - if cmd.Flags().Changed("verbose") { - cfg.Verbose, _ = cmd.Flags().GetBool("verbose") + if changed, value := rootOrLocalBoolFlag(cmd, "verbose"); changed { + cfg.Verbose = value } - cfg.Yes, _ = cmd.Flags().GetBool("yes") + _, cfg.Yes = rootOrLocalBoolFlag(cmd, "yes") // Tenant ID: env then flag override if v := os.Getenv("GOCLAW_TENANT_ID"); v != "" { @@ -123,6 +123,25 @@ func Load(cmd *cobra.Command) (*Config, error) { return cfg, nil } +func rootOrLocalStringFlag(cmd *cobra.Command, name string) (bool, string) { + if cmd != nil && cmd.Root() != nil { + if flag := cmd.Root().PersistentFlags().Lookup(name); flag != nil { + return flag.Changed, flag.Value.String() + } + } + if cmd != nil { + if flag := cmd.Flags().Lookup(name); flag != nil { + return flag.Changed, flag.Value.String() + } + } + return false, "" +} + +func rootOrLocalBoolFlag(cmd *cobra.Command, name string) (bool, bool) { + changed, raw := rootOrLocalStringFlag(cmd, name) + return changed, raw == "true" +} + func loadFile() (*FileConfig, error) { data, err := os.ReadFile(FilePath()) if err != nil { diff --git a/plans/260503-1907-domain-coverage-p3-plus/phase-05-fillers-verification-batch-2.md b/plans/260503-1907-domain-coverage-p3-plus/phase-05-fillers-verification-batch-2.md index e8deea1..ec742f5 100644 --- a/plans/260503-1907-domain-coverage-p3-plus/phase-05-fillers-verification-batch-2.md +++ b/plans/260503-1907-domain-coverage-p3-plus/phase-05-fillers-verification-batch-2.md @@ -1,13 +1,14 @@ # Phase 5 — Fillers & Verification Batch 2 **Priority:** 🟡 medium -**Status:** verified residuals — not-started +**Status:** implemented pending ship **Estimated LoC:** ~150 (excl. tests) **Depends on:** P3 + P4 merged ## Context Links - Gap analysis: `plans/reports/brainstorm-260503-1907-gap-analysis-round2.md` § 3.C, 3.F, § 5 (P5) +- Detailed execution plan: `../260520-1050-domain-coverage-p5-fillers/plan.md` ## Overview @@ -23,7 +24,7 @@ For each item below, grep both repos and confirm gap before scoping LoC: | C4 | `POST /v1/contacts/unmerge` | `cmd/channels_contacts.go`, `cmd/contacts.go` | covered: `channels contacts unmerge`, `contacts unmerge` | | X3 | `GET /v1/agents/{id}/instances` + files | `cmd/agents_instances.go` | covered: list/get-file/set-file/metadata | | X4 | `GET /v1/mcp/servers/{id}/tools` | `cmd/mcp.go` | covered: `mcp servers tools` | -| X8 | `PATCH /v1/agents/{id}/evolution/suggestions/{sid}` | `cmd/agents_evolution.go` | covered: `agents evolution update` | +| X8 | `PATCH /v1/agents/{id}/evolution/suggestions/{sid}` | `cmd/agents_evolution.go` | covered as command surface, but payload compatibility fix required in P5 | | X11 | `GET /v1/teams/{teamId}/attachments/{aid}/download` | `cmd/teams.go` / `cmd/teams_*.go` | residual | | X12 | `internal/http/evolution_skill_apply.go` | `cmd/agents_evolution.go` | residual | @@ -33,16 +34,17 @@ After sweep, drop covered items, finalize scope. Report sweep results in PR desc | # | Command | Server route | File | |---|---|---|---| -| X11 | `teams attachments download [--out=…]` | `GET …/attachments/{aid}/download` | `cmd/teams_workspace.go` or new `cmd/teams_attachments.go` | -| X12 | `agents evolution skill apply ` | `internal/http/evolution_skill_apply.go` | `cmd/agents_evolution.go` | +| X11 | `teams attachments download --output ` | `GET …/attachments/{aid}/download` | new `cmd/teams_attachments.go` | +| X12 | `agents evolution skill apply [--skill-draft @file]` | `PATCH /v1/agents/{id}/evolution/suggestions/{sid}` with `status=approved` | `cmd/agents_evolution.go` | ## Implementation Steps -1. Extend the existing module file for each confirmed residual (no new files unless >200 LoC pushes existing over budget). -2. `teams attachments download` — binary file save via signed-URL pattern (`internal/client/signed_download.go`); reuse helper. -3. `agents evolution skill apply` — wire the server route and expose structured output. -4. Add `_test.go` cases for each. -5. CHANGELOG + docs sync. +1. Follow detailed plan in `../260520-1050-domain-coverage-p5-fillers/`. +2. `teams attachments download` — authenticated binary file save via `GetRaw`; require `--output/-o`; add `--force` for overwrite. +3. `agents evolution skill apply` — approve `skill_add` suggestions via existing PATCH route and expose structured output. +4. Fix existing `agents evolution update` payload mapping: `accept -> approved`, `reject -> rejected`. +5. Add `_test.go` cases for each. +6. CHANGELOG + docs sync. ## Todo List @@ -52,10 +54,11 @@ After sweep, drop covered items, finalize scope. Report sweep results in PR desc - [x] cmd/channels_contacts.go: unmerge - [x] cmd/agents_instances.go: list + files - [x] cmd/mcp_servers.go: tools subcommand -- [ ] cmd/agents_evolution.go: skill apply -- [ ] teams attachments download (reuse signed_download) -- [ ] tests per command -- [ ] CHANGELOG + docs +- [x] cmd/agents_evolution.go: update payload compatibility +- [x] cmd/agents_evolution.go: skill apply +- [x] teams attachments download (use authenticated GetRaw) +- [x] tests per command +- [x] CHANGELOG + docs ## Success Criteria @@ -69,13 +72,13 @@ After sweep, drop covered items, finalize scope. Report sweep results in PR desc | Risk | Mitigation | |---|---| | Sweep reveals all items already covered | Phase becomes verification-only — close as docs PR | -| Binary download path collisions | Default `--out=./`; respect existing file with --force flag | +| Binary download path collisions | Require `--output/-o`; refuse existing file unless `--force` | | `unmerge` destructive without --yes | Require --yes for unmerge | | Evolution suggestion updates race | Server handles concurrency; CLI passes ETag if available | ## Security Considerations -- Attachment download — write file mode 0644. +- Attachment download — write file mode 0644; no binary stdout mode. - Unmerge requires --yes. ## Next Steps diff --git a/plans/260503-1907-domain-coverage-p3-plus/plan.md b/plans/260503-1907-domain-coverage-p3-plus/plan.md index 145293a..4494861 100644 --- a/plans/260503-1907-domain-coverage-p3-plus/plan.md +++ b/plans/260503-1907-domain-coverage-p3-plus/plan.md @@ -3,7 +3,7 @@ **Date:** 2026-05-03 **Branch:** feat/ai-first-cli-expansion **Reference report:** `plans/reports/brainstorm-260503-1907-gap-analysis-round2.md` -**Status:** P3/P4 complete — P5 next; P6 remains server-blocked. +**Status:** P3/P4 complete — P5 implemented pending ship; P6 remains server-blocked. ## Summary @@ -13,7 +13,7 @@ Sau R1 (P0–P5) + R2 expansion (P0–P2), CLI đạt ~95% server coverage. R2 r |---|---|---|---|---| | P3 | AI-critical fillers (multi-profile, sessions compact, health, traces filter polish) | ~250 | 🔥 | complete | | P4 | UX polish batch 1 residuals (codex-pool umbrella, api-keys rotate, config defaults, chat replay convenience, tools invoke `--args` alias) | ~250 | 🟡 | complete | -| P5 | Fillers residuals after sweep (team attachments download, evolution skill apply) | ~150 | 🟡 | not-started | +| P5 | Fillers residuals after sweep (team attachments download, evolution skill apply) | ~150 | 🟡 | implemented pending ship | | P6 | Deferred — blocked on server FRs (traces follow, logs aggregate, providers reconnect, …) | n/a | 🟢 | server-blocked | ## Phase Files @@ -28,6 +28,7 @@ Sau R1 (P0–P5) + R2 expansion (P0–P2), CLI đạt ~95% server coverage. R2 r - Super-admin API parity is already merged; P4 should proceed from current `dev`. - P3 multi-profile is complete; P4 can build on stable profile/default output behavior. - P5 verify sweep completed 2026-05-19; most suspected gaps already exist under current command paths. +- P5 detailed execution plan: `../260520-1050-domain-coverage-p5-fillers/plan.md`. - P6 = upstream goclaw issues, not CLI work. - P4 validation/red-team evidence: `reports/validation-red-team-260519-p4.md`; implementation validated with `go build ./...`, `go test ./...`, and `go vet ./...`. diff --git a/plans/260520-1050-domain-coverage-p5-fillers/phase-01-scope-lock.md b/plans/260520-1050-domain-coverage-p5-fillers/phase-01-scope-lock.md new file mode 100644 index 0000000..8950169 --- /dev/null +++ b/plans/260520-1050-domain-coverage-p5-fillers/phase-01-scope-lock.md @@ -0,0 +1,72 @@ +--- +phase: 1 +title: "Scope Lock" +status: complete +priority: P1 +effort: "45m" +dependencies: [] +--- + +# Phase 1: Scope Lock + +## Overview + +Lock exact P5 contract before implementation. This phase prevents reintroducing already-covered backlog items or implementing against stale server assumptions. + +## Requirements + +- Functional: confirm only two residual commands are implemented. +- Functional: verify server route, HTTP method, request body, and auth mode for each command. +- Non-functional: keep implementation CLI-only and small. +- Non-functional: preserve existing command behavior unless a contract mismatch is verified. + +## Architecture + +Inputs: +- Parent plan: `../260503-1907-domain-coverage-p3-plus/phase-05-fillers-verification-batch-2.md` +- Server route: `/Volumes/GOON/www/nlb/goclaw/internal/http/team_attachments.go` +- Server route: `/Volumes/GOON/www/nlb/goclaw/internal/http/evolution_handlers.go` +- Server helper: `/Volumes/GOON/www/nlb/goclaw/internal/http/evolution_skill_apply.go` + +Decision points: +- Use authenticated `GetRaw()` for attachment binary download because the route accepts Bearer auth. +- Use `PATCH` with `status=approved` for skill apply because server dispatches skill creation from suggestion approval. +- Fix existing `agents evolution update` payload. It currently sends stale `action=accept|reject`, while the server requires `status=approved|rejected`. + +## Related Code Files + +- Read: `cmd/agents_evolution.go` +- Read: `cmd/teams.go` +- Read: `cmd/teams_workspace.go` +- Read: `cmd/agents_lifecycle_test.go` +- Read: `cmd/storage.go` +- Read: `cmd/media.go` +- Read: `internal/client/http.go` + +## Implementation Steps + +1. Confirm `dev` is clean and synced. +2. Re-run grep on current CLI for the seven P5 sweep items. +3. Reconfirm final residual list is only X11 + X12. +4. Verify server route details: + - `GET /v1/teams/{teamId}/attachments/{attachmentId}/download` + - `PATCH /v1/agents/{agentID}/evolution/suggestions/{suggestionID}` +5. Decide file boundaries: + - Create `cmd/teams_attachments.go`; `cmd/teams_workspace.go` is already near 200 LoC. + - Extend `cmd/agents_evolution.go`; expected to remain under 200 LoC. +6. Record the verified evolution update mismatch in parent P5 phase before implementation. + +## Success Criteria + +- [x] Final scope is exactly two new commands plus required evolution update compatibility fix. +- [x] Parent P5 phase links to this dedicated plan. +- [x] No server route changes are planned. +- [x] File ownership is clear before code edits. + +## Risk Assessment + +| Risk | Mitigation | +|---|---| +| Duplicate planning sources | Make this plan the execution plan and link it from the parent P5 phase. | +| Stale server assumptions | Use server source as source of truth before coding. | +| Hidden scope creep | Reject covered backlog items unless current code proves broken. | diff --git a/plans/260520-1050-domain-coverage-p5-fillers/phase-02-team-attachment-download.md b/plans/260520-1050-domain-coverage-p5-fillers/phase-02-team-attachment-download.md new file mode 100644 index 0000000..a97b861 --- /dev/null +++ b/plans/260520-1050-domain-coverage-p5-fillers/phase-02-team-attachment-download.md @@ -0,0 +1,98 @@ +--- +phase: 2 +title: "Team Attachment Download" +status: complete +priority: P1 +effort: "2h" +dependencies: [1] +--- + +# Phase 2: Team Attachment Download + +## Overview + +Add a direct CLI command for authenticated team task attachment downloads. The command writes binary content to an explicit output file only. + +## Requirements + +- Functional: `goclaw teams attachments download --output ` downloads the response body. +- Functional: `--output/-o` is mandatory. +- Functional: existing output file is refused unless `--force` is set. +- Functional: parent directories for output path are created. +- Functional: HTTP errors use existing raw response error handling. +- Non-functional: no stdout binary output; safer for automation and logs. +- Non-functional: use Bearer auth via `newHTTP().GetRaw()`. + +## Architecture + +Add a new teams subgroup: + +```text +teams + attachments + download --output [--force] +``` + +Data flow: + +```text +Cobra args -> validate output path -> newHTTP() + -> GET /v1/teams/{teamId}/attachments/{attachmentId}/download + -> status check -> open output file -> io.Copy(response.Body, file) + -> printer.Success("Downloaded N bytes to path") +``` + +Implementation details: +- Use `url.PathEscape` for both IDs. +- Use `os.OpenFile(path, O_WRONLY|O_CREATE|O_EXCL, 0644)` by default. +- With `--force`, use `O_WRONLY|O_CREATE|O_TRUNC`. +- Use `os.MkdirAll(filepath.Dir(outFile), 0755)`. +- Use `rawResponseError(resp)` for `resp.StatusCode >= 400`. +- After successful status check, `defer resp.Body.Close()` before opening/copying the output file. +- Do not parse `Content-Disposition` in this phase. + +## Related Code Files + +- Create: `cmd/teams_attachments.go` +- Modify: `cmd/teams.go` +- Read: `cmd/storage.go` +- Read: `cmd/media.go` +- Read: `cmd/backup.go` +- Read: `internal/client/http.go` +- Test: add coverage in a focused `cmd/p5_fillers_test.go` or existing teams test file. + +## Implementation Steps + +1. Create `teamsAttachmentsCmd` and `teamsAttachmentsDownloadCmd`. +2. Add flags: + - `StringP("output", "o", "", "Output file path")` + - `Bool("force", false, "Overwrite output file if it exists")` +3. Validate `--output` before creating HTTP client. +4. Build escaped route path. +5. Stream response to file after status check; close the response body on every success path. +6. Register `teamsAttachmentsCmd` under `teamsCmd`. +7. Add tests: + - success writes file and hits exact route. + - Authorization header is present through `newHTTP`. + - missing `--output` returns validation error before network. + - existing file without `--force` is refused. + - existing file with `--force` overwrites. + - response body closes on success and error paths where practical. + - local file `--output` does not override the root output-format contract. + +## Success Criteria + +- [x] `teams attachments download` appears in command tree. +- [x] Binary content is written exactly to requested output path. +- [x] No accidental overwrite without `--force`. +- [x] Parent directory creation works. +- [x] Tests pass with httptest. + +## Risk Assessment + +| Risk | Mitigation | +|---|---| +| Path traversal in output path | This writes only local user-selected output. Do not sanitize beyond parent dir creation; user controls destination. | +| Route ID special chars | `url.PathEscape` both path segments. | +| Large file memory use | Stream with `io.Copy`; no buffering entire body. | +| Wrong auth mode | Use Bearer `GetRaw`; server accepts Bearer and signed token. | diff --git a/plans/260520-1050-domain-coverage-p5-fillers/phase-03-evolution-skill-apply.md b/plans/260520-1050-domain-coverage-p5-fillers/phase-03-evolution-skill-apply.md new file mode 100644 index 0000000..c80e99f --- /dev/null +++ b/plans/260520-1050-domain-coverage-p5-fillers/phase-03-evolution-skill-apply.md @@ -0,0 +1,102 @@ +--- +phase: 3 +title: "Evolution Skill Apply" +status: complete +priority: P1 +effort: "2h" +dependencies: [1] +--- + +# Phase 3: Evolution Skill Apply + +## Overview + +Add a clear CLI wrapper for approving `skill_add` evolution suggestions. The server creates the managed skill when the suggestion is approved. + +## Requirements + +- Functional: `goclaw agents evolution skill apply ` sends `status=approved`. +- Functional: command refuses suggestions whose `suggestion_type` is not `skill_add`. +- Functional: optional `--skill-draft ` sends `skill_draft` override. +- Functional: response is printed as structured output. +- Functional: missing/invalid IDs are left to server validation; CLI only enforces arg count. +- Non-functional: no new generic evolution apply surface. +- Non-functional: preserve existing `agents evolution update` UX while fixing stale payload mapping. + +## Architecture + +Command tree: + +```text +agents + evolution + metrics + suggestions + update --action accept|reject + skill + apply [--skill-draft ] +``` + +Skill apply payload: + +```json +{ + "status": "approved", + "skill_draft": "optional override content" +} +``` + +Existing update compatibility: +- Current CLI command should map `--action=accept` to `status=approved`. +- Current CLI command should map `--action=reject` to `status=rejected`. +- This is required: current CLI sends `{"action": ...}` but server reads `status`. +- Fix it in the same file with tests because it shares the exact server route. +- Use `url.PathEscape` for agent and suggestion ID path segments in both the new skill command and the existing update command. + +## Related Code Files + +- Modify: `cmd/agents_evolution.go` +- Test: extend `cmd/agents_lifecycle_test.go` or add `cmd/p5_fillers_test.go` +- Read: `/Volumes/GOON/www/nlb/goclaw/internal/http/evolution_handlers.go` +- Read: `/Volumes/GOON/www/nlb/goclaw/internal/http/evolution_skill_apply.go` + +## Implementation Steps + +1. Add `agentsEvolutionSkillCmd` subgroup. +2. Add `agentsEvolutionSkillApplyCmd`. +3. Add `--skill-draft` flag. +4. Use existing `readContent()` for literal or `@file` draft content. +5. PATCH `/v1/agents/{id}/evolution/suggestions/{suggestionID}` with escaped path segments and `status=approved`. +6. Preflight pending suggestions and verify the selected suggestion has `suggestion_type=skill_add`. +7. Print `unmarshalMap(data)` so JSON/YAML users get server action fields. +8. Change `agentsEvolutionUpdateCmd` body from `{"action": action}` to `{"status": mappedStatus}`. + - `accept` -> `approved` + - `reject` -> `rejected` + - Keep CLI flag as `--action` for backward compatibility. + - Update success text to avoid `acceptd`; use approved/rejected wording. +9. Add tests: + - skill apply sends `status=approved`. + - `--skill-draft=@file` includes exact content. + - non-`skill_add` suggestion is refused before approval PATCH. + - update accept maps to `status=approved`. + - update reject maps to `status=rejected`. + - evolution PATCH routes escape IDs. + - invalid update action still rejects before network. + +## Success Criteria + +- [x] `agents evolution skill apply` appears in command tree. +- [x] PATCH payload matches server contract. +- [x] Non-`skill_add` suggestions are refused before approval. +- [x] Optional draft override works from `@file`. +- [x] Existing update command remains backward-compatible at CLI flag level and sends server-compatible payload. +- [x] Tests cover both new wrapper and any payload mapping fix. + +## Risk Assessment + +| Risk | Mitigation | +|---|---| +| Applying non-skill suggestions | Server enforces suggestion type; CLI command name makes intent explicit. | +| Stale payload in existing update | Validate and fix mapping in same module if needed. | +| Draft content leaking in process list | `--skill-draft @file` documented as preferred for large/sensitive drafts. | +| Over-abstraction | Keep command wrapper direct; no generic suggestion apply framework. | diff --git a/plans/260520-1050-domain-coverage-p5-fillers/phase-04-tests-and-docs.md b/plans/260520-1050-domain-coverage-p5-fillers/phase-04-tests-and-docs.md new file mode 100644 index 0000000..bbb1522 --- /dev/null +++ b/plans/260520-1050-domain-coverage-p5-fillers/phase-04-tests-and-docs.md @@ -0,0 +1,78 @@ +--- +phase: 4 +title: "Tests and Docs" +status: complete +priority: P2 +effort: "1.5h" +dependencies: [2, 3] +--- + +# Phase 4: Tests and Docs + +## Overview + +Add focused regression tests and sync documentation so P5 is discoverable and the parent roadmap reflects the final residual closure. + +## Requirements + +- Functional: tests cover new commands and failure modes. +- Functional: docs list exact command shapes. +- Non-functional: no broad docs rewrite. +- Non-functional: tests must use real httptest routes, no fake command-only assertions for HTTP contracts. + +## Architecture + +Test strategy: +- HTTP `httptest.NewServer` for binary download and evolution PATCH route. +- Environment-driven auth via existing test helpers where possible. +- File output tests use `t.TempDir()`. +- Avoid touching real filesystem outside temp dirs. + +Docs strategy: +- `README.md`: add concise examples under UX/API/team/evolution sections. +- `CHANGELOG.md`: add P5 entries under Unreleased. +- `docs/codebase-summary.md`: add new files/commands. +- `docs/project-roadmap.md`: mark P5 as planned/in progress only after implementation starts; plan creation can note "P5 execution plan created". +- Parent phase: link this plan and update stale output decisions. +- Parent phase: document verified `agents evolution update` payload mismatch and that P5 fixes it. + +## Related Code Files + +- Modify: `README.md` +- Modify: `CHANGELOG.md` +- Modify: `docs/codebase-summary.md` +- Modify: `docs/project-roadmap.md` +- Modify: `plans/260503-1907-domain-coverage-p3-plus/plan.md` +- Modify: `plans/260503-1907-domain-coverage-p3-plus/phase-05-fillers-verification-batch-2.md` +- Test: `cmd/p5_fillers_test.go` or existing adjacent test files. + +## Implementation Steps + +1. Add tests immediately after code changes for each command. +2. Run targeted tests first: + - `/usr/local/go/bin/go test ./cmd -run 'P5|Attachment|Evolution' -count=1` +3. Run full validation: + - `/usr/local/go/bin/go build ./...` + - `/usr/local/go/bin/go test ./...` + - `/usr/local/go/bin/go vet ./...` +4. Update README and CHANGELOG with exact examples. +5. Update `docs/codebase-summary.md` command table. +6. Update `docs/project-roadmap.md` after implementation status is known. +7. Update parent P5 phase to point at this dedicated execution plan. +8. Document red-team/validation outcomes in plan reports. + +## Success Criteria + +- [x] Tests prove route, method, request body, file output, and overwrite behavior. +- [x] Full build/test/vet pass. +- [x] Docs mention both P5 commands. +- [x] Parent P5 plan no longer contradicts required `--output`. +- [x] Parent P5 plan notes the required evolution update compatibility fix. + +## Risk Assessment + +| Risk | Mitigation | +|---|---| +| Tests pass without checking body | Decode request body and assert exact fields. | +| Docs drift from command shape | Copy command examples from tests/help strings. | +| Parent plan stale | Update parent phase in same PR. | diff --git a/plans/260520-1050-domain-coverage-p5-fillers/phase-05-ship-readiness.md b/plans/260520-1050-domain-coverage-p5-fillers/phase-05-ship-readiness.md new file mode 100644 index 0000000..3bcc9e5 --- /dev/null +++ b/plans/260520-1050-domain-coverage-p5-fillers/phase-05-ship-readiness.md @@ -0,0 +1,73 @@ +--- +phase: 5 +title: "Ship Readiness" +status: pending +priority: P2 +effort: "1h" +dependencies: [4] +--- + +# Phase 5: Ship Readiness + +## Overview + +Prepare the P5 implementation branch for beta shipping. This phase is verification and PR hygiene only. + +## Requirements + +- Functional: final diff is one cohesive P5 PR. +- Functional: PR description lists sweep results and validation commands. +- Non-functional: no direct push to `dev`; use PR to `dev`. +- Non-functional: no untracked generated artifacts in final status. + +## Architecture + +Expected flow: + +```text +dev synced + -> feature branch/worktree + -> implement phases 2-4 + -> local validation + -> ck:git cp + -> ck:ship beta + -> ck:review-pr +``` + +Release expectation: +- Merge to `dev` triggers CI + Release workflows. +- Dev release should publish prerelease through existing semantic-release setup. + +## Implementation Steps + +1. Confirm `git status --short --branch` is clean. +2. Review diff for accidental generated files. +3. Run secret scan on staged diff. +4. Commit with conventional message, recommended: + - `feat(cli): add domain coverage P5 fillers` +5. Push branch and open PR to `dev`. +6. PR body must include: + - Commands added. + - Server contracts used. + - P5 sweep summary. + - Validation commands and results. +7. Run review and fix findings before merge. +8. After merge, watch CI + Release until complete. +9. Confirm beta release appears. + +## Success Criteria + +- [ ] Branch pushed. +- [ ] PR to `dev` opened. +- [ ] Review has no critical/important findings. +- [ ] CI pass. +- [ ] Release pass after merge. +- [ ] Beta prerelease published. + +## Risk Assessment + +| Risk | Mitigation | +|---|---| +| Release workflow fails despite local pass | Watch CI/Release and fix in follow-up branch if needed. | +| PR contains parent-plan-only noise | Keep docs updates scoped to P5 status and command examples. | +| Generated local docs/journals appear ignored | Check `git status --ignored` only if needed; do not force-add ignored journals. | diff --git a/plans/260520-1050-domain-coverage-p5-fillers/plan.md b/plans/260520-1050-domain-coverage-p5-fillers/plan.md new file mode 100644 index 0000000..47fb24f --- /dev/null +++ b/plans/260520-1050-domain-coverage-p5-fillers/plan.md @@ -0,0 +1,155 @@ +--- +title: "Domain Coverage P5 Fillers" +description: "Finish the final CLI-only P5 residuals: team attachment download and evolution skill apply." +status: in-progress +priority: P2 +branch: "dev" +tags: [domain-coverage, p5, cli] +blockedBy: [] +blocks: [260503-1907-domain-coverage-p3-plus] +created: "2026-05-20T03:50:47.956Z" +createdBy: "ck:plan" +source: skill +--- + +# Domain Coverage P5 Fillers + +## Overview + +Implement the final post-sweep CLI residuals from Domain Coverage P5 in one PR from `dev`. +Scope is intentionally small: add a direct team attachment download command and a clear evolution skill-apply wrapper over the existing server approval contract. + +## Decisions + +- Scope: one PR with both P5 commands. +- Attachment download: `--output/-o` is required. No implicit filename guessing in this round. +- Evolution skill apply: wrapper only for approving `skill_add` suggestions; optional `--skill-draft` override. +- Do not add new server routes. Server already exposes the required contracts. +- Repair existing evolution update payload: current CLI sends stale `action`, while server requires `status`. + +## Phases + +| Phase | Name | Status | +|-------|------|--------| +| 1 | [Scope Lock](./phase-01-scope-lock.md) | Complete | +| 2 | [Team Attachment Download](./phase-02-team-attachment-download.md) | Complete | +| 3 | [Evolution Skill Apply](./phase-03-evolution-skill-apply.md) | Complete | +| 4 | [Tests and Docs](./phase-04-tests-and-docs.md) | Complete | +| 5 | [Ship Readiness](./phase-05-ship-readiness.md) | Pending | + +## Dependencies + +- Parent backlog: `../260503-1907-domain-coverage-p3-plus/plan.md` +- P5 legacy phase: `../260503-1907-domain-coverage-p3-plus/phase-05-fillers-verification-batch-2.md` +- Server evidence: + - `/Volumes/GOON/www/nlb/goclaw/internal/http/team_attachments.go` + - `/Volumes/GOON/www/nlb/goclaw/internal/http/evolution_handlers.go` + - `/Volumes/GOON/www/nlb/goclaw/internal/http/evolution_skill_apply.go` + +## Command Contract + +### Team Attachment Download + +```bash +goclaw teams attachments download --output ./artifact.bin +goclaw teams attachments download -o ./artifact.bin --force +``` + +Behavior: +- Calls `GET /v1/teams/{teamId}/attachments/{attachmentId}/download` using normal Bearer auth. +- Requires `--output/-o`; refuses missing output before network call. +- Refuses overwrite unless `--force` is set. +- Creates parent directory when needed. +- Streams binary response to disk. + +### Evolution Skill Apply + +```bash +goclaw agents evolution skill apply +goclaw agents evolution skill apply --skill-draft @./SKILL.md +``` + +Behavior: +- Calls `PATCH /v1/agents/{agentID}/evolution/suggestions/{suggestionID}`. +- Preflights pending suggestions and refuses non-`skill_add` suggestion IDs. +- Sends `{"status":"approved"}` by default. +- Adds `skill_draft` when `--skill-draft` is provided; supports literal content or `@file` via existing `readContent()`. +- Prints structured server response with `printer.Print(unmarshalMap(data))`. +- Uses escaped path segments for agent and suggestion IDs. + +## Scope Boundary + +In scope: +- Two new CLI surfaces above. +- Tests for route, body, file writing, required output, overwrite guard, and draft override. +- Docs sync in `CHANGELOG.md`, `README.md`, `docs/codebase-summary.md`, `docs/project-roadmap.md`, and parent P5 phase. + +Out of scope: +- New server routes. +- Generic "apply any evolution suggestion" command. +- Signed URL discovery from team task detail. +- Auto filename extraction from `Content-Disposition`. +- Large refactors of teams or evolution command groups. + +## Validation Gates + +- `/usr/local/go/bin/go build ./...` — pass 2026-05-20 +- `/usr/local/go/bin/go test ./...` — pass 2026-05-20 +- `/usr/local/go/bin/go vet ./...` — pass 2026-05-20 +- Manual PR review against server source contracts. + +## Red Team Review + +### Findings + +| ID | Severity | Finding | Evidence | Disposition | +|---|---|---|---|---| +| RT-01 | High | Existing `agents evolution update` is not just "maybe stale"; it is verified stale and must be fixed with P5. | `cmd/agents_evolution.go:88-90`; `/Volumes/GOON/www/nlb/goclaw/internal/http/evolution_handlers.go:199-213` | Accepted | +| RT-02 | Medium | Evolution PATCH paths should escape IDs. New code must not copy the current unescaped `fmt.Sprintf` path pattern. | `cmd/agents_evolution.go:88-89`; `cmd/api_keys_rotate.go:44`; `cmd/storage.go:55` | Accepted | +| RT-03 | Medium | Binary download plan needs explicit `resp.Body.Close()` after successful status check, before file-copy returns. | `cmd/storage.go:62`; `cmd/media.go:55`; `cmd/helpers.go:120-126` | Accepted | +| RT-04 | Low | Optional `--force` was not explicitly in the user decision, but it is low-risk and bounded because default remains no-overwrite. | `plans/260520-1050-domain-coverage-p5-fillers/phase-02-team-attachment-download.md:19-20` | Accepted | +| CR-01 | Medium | Local attachment `--output` flag can shadow the root output-format flag unless config resolution reads the persistent root flag. | `cmd/root.go`; `internal/config/config.go`; `cmd/teams_attachments.go:91` | Accepted | +| CR-02 | Medium | Skill-specific wrapper must not approve non-`skill_add` suggestions through the generic server route. | `/Volumes/GOON/www/digitop/goclaw/internal/http/evolution_handlers.go:223-245` | Accepted | + +### Whole-Plan Consistency Sweep + +- Files reread: `plan.md`, `phase-01-scope-lock.md`, `phase-02-team-attachment-download.md`, `phase-03-evolution-skill-apply.md`, `phase-04-tests-and-docs.md`, `phase-05-ship-readiness.md`. +- Decision deltas checked: stale `action` payload, escaped IDs, response close, `--force` no-overwrite default. +- Reconciled stale references: optional update fix converted to required update fix. +- Unresolved contradictions: 0 + +## Validation Log + +### Verification Results + +- **Tier:** Full +- **Claims checked:** 24 +- **Verified:** 24 | **Failed:** 0 | **Unverified:** 0 + +Verified examples: +- Team attachment route exists and accepts Bearer auth: `/Volumes/GOON/www/nlb/goclaw/internal/http/team_attachments.go:25-53`. +- Team attachment route validates team and attachment IDs: `/Volumes/GOON/www/nlb/goclaw/internal/http/team_attachments.go:64-84`. +- Server exposes `Content-Disposition`, but user decision requires explicit `--output`: `/Volumes/GOON/www/nlb/goclaw/internal/http/team_attachments.go:104-112`. +- Evolution PATCH route exists: `/Volumes/GOON/www/nlb/goclaw/internal/http/evolution_handlers.go:65-69`. +- Evolution PATCH body requires `status`, not `action`: `/Volumes/GOON/www/nlb/goclaw/internal/http/evolution_handlers.go:199-213`. +- Skill creation dispatches when `status=approved` and suggestion type is `SuggestSkillAdd`: `/Volumes/GOON/www/nlb/goclaw/internal/http/evolution_handlers.go:223-232`. + +### Critical Questions + +| Question | Decision | +|---|---| +| Should P5 ship both residual commands in one PR? | Yes, user confirmed both commands in one PR. | +| Should attachment download infer filename from `Content-Disposition`? | No, user confirmed `--output/-o` is required. | +| Should evolution skill apply be generic or a skill-specific wrapper? | Skill-specific wrapper only, user confirmed recommended approach. | +| Should stale `agents evolution update` payload be handled? | Yes, validation found it is a verified contract mismatch, so fix with P5. | + +### Whole-Plan Consistency Sweep + +- Files reread: `plan.md`, all five phase files. +- Decision deltas checked: required output, skill-specific apply, status payload, path escaping, response close. +- Reconciled stale references: 5. +- Unresolved contradictions: 0 + +## Handoff + +Recommended next gate after implementation: `/ck:git cp` then `/ck:ship beta`. diff --git a/plans/260520-1050-domain-coverage-p5-fillers/reports/red-team-260520-p5.md b/plans/260520-1050-domain-coverage-p5-fillers/reports/red-team-260520-p5.md new file mode 100644 index 0000000..f8f1704 --- /dev/null +++ b/plans/260520-1050-domain-coverage-p5-fillers/reports/red-team-260520-p5.md @@ -0,0 +1,32 @@ +# Red Team Report: Domain Coverage P5 Fillers + +## Summary + +Adversarial review found one high-severity contract bug already present in the planned touchpoint and two medium implementation hazards. All accepted findings were propagated into plan files. + +## Findings + +| ID | Severity | Finding | Evidence | Disposition | +|---|---|---|---|---| +| RT-01 | High | Existing `agents evolution update` sends `action`, but server requires `status`; P5 must fix it, not leave it conditional. | `cmd/agents_evolution.go:88-90`; `/Volumes/GOON/www/nlb/goclaw/internal/http/evolution_handlers.go:199-213` | Accepted | +| RT-02 | Medium | New evolution PATCH code must escape path segments; current update command does not. | `cmd/agents_evolution.go:88-89`; `cmd/api_keys_rotate.go:44`; `cmd/storage.go:55` | Accepted | +| RT-03 | Medium | Binary download plan must explicitly close response body after successful status check. | `cmd/storage.go:62`; `cmd/media.go:55`; `cmd/helpers.go:120-126` | Accepted | +| RT-04 | Low | `--force` was not part of explicit user decision, but default no-overwrite keeps it safe and practical. | `plans/260520-1050-domain-coverage-p5-fillers/phase-02-team-attachment-download.md:19-20` | Accepted | + +## Plan Changes + +- Phase 3 now requires the update payload compatibility fix. +- Phase 3 now requires escaped PATCH path segments. +- Phase 2 now requires response body close on successful download path. +- Parent P5 plan now states X8 is surface-covered but payload compatibility needs P5 fix. + +## Whole-Plan Consistency Sweep + +- Files reread: `plan.md`, `phase-01-scope-lock.md`, `phase-02-team-attachment-download.md`, `phase-03-evolution-skill-apply.md`, `phase-04-tests-and-docs.md`, `phase-05-ship-readiness.md`. +- Decision deltas checked: stale `action` payload, escaped IDs, response close, no-overwrite default. +- Reconciled stale references: 5. +- Unresolved contradictions: 0 + +## Unresolved Questions + +None. diff --git a/plans/260520-1050-domain-coverage-p5-fillers/reports/validation-260520-p5.md b/plans/260520-1050-domain-coverage-p5-fillers/reports/validation-260520-p5.md new file mode 100644 index 0000000..63d8911 --- /dev/null +++ b/plans/260520-1050-domain-coverage-p5-fillers/reports/validation-260520-p5.md @@ -0,0 +1,53 @@ +# Validation Report: Domain Coverage P5 Fillers + +## Summary + +Validation confirmed the plan is implementable from current `dev` with no server changes. User decisions already resolve the main product questions: both commands in one PR, required `--output/-o`, and skill-specific apply wrapper. + +## Verification Results + +- **Tier:** Full +- **Claims checked:** 24 +- **Verified:** 24 +- **Failed:** 0 +- **Unverified:** 0 + +## Verified Claims + +| Claim | Result | Evidence | +|---|---|---| +| Team attachment route exists. | VERIFIED | `/Volumes/GOON/www/nlb/goclaw/internal/http/team_attachments.go:25-28` | +| Team attachment route accepts Bearer auth. | VERIFIED | `/Volumes/GOON/www/nlb/goclaw/internal/http/team_attachments.go:42-52` | +| Team attachment route validates team ownership. | VERIFIED | `/Volumes/GOON/www/nlb/goclaw/internal/http/team_attachments.go:76-85` | +| Team attachment route serves binary file response. | VERIFIED | `/Volumes/GOON/www/nlb/goclaw/internal/http/team_attachments.go:104-117` | +| Existing CLI raw downloads use `GetRaw`. | VERIFIED | `cmd/storage.go:55`; `cmd/media.go:48`; `cmd/backup.go:172` | +| Evolution PATCH route exists. | VERIFIED | `/Volumes/GOON/www/nlb/goclaw/internal/http/evolution_handlers.go:65-69` | +| Evolution PATCH body uses `status`. | VERIFIED | `/Volumes/GOON/www/nlb/goclaw/internal/http/evolution_handlers.go:199-213` | +| Skill draft override field is `skill_draft`. | VERIFIED | `/Volumes/GOON/www/nlb/goclaw/internal/http/evolution_handlers.go:199-203` | +| Skill add is applied on `status=approved`. | VERIFIED | `/Volumes/GOON/www/nlb/goclaw/internal/http/evolution_handlers.go:223-232` | +| Current CLI update payload is stale. | VERIFIED | `cmd/agents_evolution.go:88-90` | + +## Critical Questions + +| Question | Answer | +|---|---| +| What artifact should P5 produce? | One PR implementing `teams attachments download` and `agents evolution skill apply`, plus update payload compatibility fix. | +| Acceptance criteria? | Commands hit verified server routes, tests prove payload/file behavior, build/test/vet pass. | +| Scope boundary? | No server changes, no generic evolution apply, no filename inference. | +| Non-negotiable constraints? | Required `--output/-o`; skill apply wrapper only; PR to `dev`. | +| Touchpoints? | `cmd/teams.go`, new `cmd/teams_attachments.go`, `cmd/agents_evolution.go`, focused tests, README/CHANGELOG/docs/plans. | + +## Whole-Plan Consistency Sweep + +- Files reread: `plan.md`, all five phase files, parent P5 phase. +- Decision deltas checked: required output, skill-specific apply, status payload, escaped IDs, response close. +- Reconciled stale references: 5. +- Unresolved contradictions: 0 + +## Recommendation + +Proceed to implementation after user approval. Suggested command: + +```bash +/ck:cook /Users/duynguyen/.codex/worktrees/a69b/goclaw-cli/plans/260520-1050-domain-coverage-p5-fillers/plan.md +``` From 143624d1c1e0f34d91d6b2801ed3e1a1eab267f1 Mon Sep 17 00:00:00 2001 From: Duy /zuey/ Date: Thu, 21 May 2026 12:47:01 +0700 Subject: [PATCH 4/4] fix(cli): align live API command contracts (#15) --- cmd/agents.go | 4 +- cmd/list_response_helpers.go | 41 ++++++++ cmd/memory.go | 20 ++-- cmd/p4_ux_polish_test.go | 189 ++++++++++++++++++++++++++++++++++- cmd/sessions.go | 9 +- cmd/tools.go | 2 +- cmd/tools_custom.go | 130 ++++++------------------ cmd/traces.go | 47 ++++++--- 8 files changed, 310 insertions(+), 132 deletions(-) create mode 100644 cmd/list_response_helpers.go diff --git a/cmd/agents.go b/cmd/agents.go index e1cb6ae..51584ab 100644 --- a/cmd/agents.go +++ b/cmd/agents.go @@ -42,11 +42,11 @@ var agentsListCmd = &cobra.Command{ return err } if cfg.OutputFormat != "table" { - printer.Print(unmarshalList(data)) + printer.Print(unmarshalNamedList(data, "agents")) return nil } tbl := output.NewTable("ID", "KEY", "NAME", "PROVIDER", "MODEL", "STATUS", "TYPE") - for _, a := range unmarshalList(data) { + for _, a := range unmarshalNamedList(data, "agents") { tbl.AddRow(str(a, "id"), str(a, "agent_key"), str(a, "display_name"), str(a, "provider"), str(a, "model"), str(a, "status"), str(a, "agent_type")) } diff --git a/cmd/list_response_helpers.go b/cmd/list_response_helpers.go new file mode 100644 index 0000000..a8491bb --- /dev/null +++ b/cmd/list_response_helpers.go @@ -0,0 +1,41 @@ +package cmd + +import "encoding/json" + +// unmarshalNamedList handles endpoints that wrap arrays in an object envelope. +func unmarshalNamedList(data json.RawMessage, key string) []map[string]any { + if list := unmarshalList(data); list != nil { + return list + } + var envelope map[string]any + if err := json.Unmarshal(data, &envelope); err == nil { + return mapsFromAnyList(envelope[key]) + } + return nil +} + +func mapsFromAnyList(value any) []map[string]any { + switch list := value.(type) { + case []map[string]any: + return list + case []any: + out := make([]map[string]any, 0, len(list)) + for _, item := range list { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + return out + default: + return nil + } +} + +func strFirst(m map[string]any, keys ...string) string { + for _, key := range keys { + if v := str(m, key); v != "" { + return v + } + } + return "" +} diff --git a/cmd/memory.go b/cmd/memory.go index 3b6eb99..0ce8b90 100644 --- a/cmd/memory.go +++ b/cmd/memory.go @@ -24,9 +24,11 @@ var memoryListCmd = &cobra.Command{ if err != nil { return err } - path := "/v1/memory/" + args[0] + path := "/v1/agents/" + url.PathEscape(args[0]) + "/memory/documents" if v, _ := cmd.Flags().GetString("user"); v != "" { - path += "?user_id=" + v + q := url.Values{} + q.Set("user_id", v) + path += "?" + q.Encode() } data, err := c.Get(path) if err != nil { @@ -54,7 +56,7 @@ var memoryGetCmd = &cobra.Command{ if err != nil { return err } - data, err := c.Get("/v1/memory/" + url.PathEscape(args[0]) + "/" + url.PathEscape(args[1])) + data, err := c.Get(memoryDocumentPath(args[0], args[1])) if err != nil { return err } @@ -77,7 +79,7 @@ var memoryStoreCmd = &cobra.Command{ if err != nil { return err } - _, err = c.Put("/v1/memory/"+url.PathEscape(args[0])+"/"+url.PathEscape(args[1]), + _, err = c.Put(memoryDocumentPath(args[0], args[1]), map[string]any{"content": content}) if err != nil { return err @@ -99,7 +101,7 @@ var memoryDeleteCmd = &cobra.Command{ if err != nil { return err } - _, err = c.Delete("/v1/memory/" + url.PathEscape(args[0]) + "/" + url.PathEscape(args[1])) + _, err = c.Delete(memoryDocumentPath(args[0], args[1])) if err != nil { return err } @@ -120,15 +122,19 @@ var memorySearchCmd = &cobra.Command{ query, _ := cmd.Flags().GetString("query") user, _ := cmd.Flags().GetString("user") body := buildBody("query", query, "user_id", user) - data, err := c.Post("/v1/memory/"+args[0]+"/search", body) + data, err := c.Post("/v1/agents/"+url.PathEscape(args[0])+"/memory/search", body) if err != nil { return err } - printer.Print(unmarshalList(data)) + printer.Print(unmarshalMap(data)) return nil }, } +func memoryDocumentPath(agentID, path string) string { + return "/v1/agents/" + url.PathEscape(agentID) + "/memory/documents/" + url.PathEscape(path) +} + func init() { memoryListCmd.Flags().String("user", "", "Filter by user ID") memoryStoreCmd.Flags().String("content", "", "Content (or @filepath)") diff --git a/cmd/p4_ux_polish_test.go b/cmd/p4_ux_polish_test.go index dbca00b..9e4e500 100644 --- a/cmd/p4_ux_polish_test.go +++ b/cmd/p4_ux_polish_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "strings" @@ -155,6 +156,10 @@ func TestConfigDefaultsUsesWSMethod(t *testing.T) { func TestToolsInvokeArgsReadsFile(t *testing.T) { defer resetTestFlag(toolsInvokeCmd, "args", "") defer resetTestFlag(toolsInvokeCmd, "param", "") + defer resetTestFlag(toolsInvokeCmd, "agent", "") + defer resetTestFlag(toolsInvokeCmd, "action", "") + defer resetTestFlag(toolsInvokeCmd, "session", "") + defer resetTestFlag(toolsInvokeCmd, "dry-run", "false") var body map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/tools/invoke" { @@ -172,15 +177,195 @@ func TestToolsInvokeArgsReadsFile(t *testing.T) { if err := os.WriteFile(path, []byte(`{"city":"Saigon"}`), 0o600); err != nil { t.Fatalf("write args: %v", err) } - if err := runCmd(t, "tools", "invoke", "weather", "--args=@"+path, "--param=unit=c"); err != nil { + if err := runCmd(t, "tools", "invoke", "weather", "--args=@"+path, "--param=unit=c", + "--agent=goclaw", "--action=forecast", "--session=sess-1", "--dry-run"); err != nil { t.Fatalf("tools invoke: %v", err) } - params := body["parameters"].(map[string]any) + if body["tool"] != "weather" { + t.Fatalf("tool = %#v", body["tool"]) + } + if body["agentId"] != "goclaw" || body["action"] != "forecast" || + body["sessionKey"] != "sess-1" || body["dryRun"] != true { + t.Fatalf("context fields = %#v", body) + } + params := body["args"].(map[string]any) if params["city"] != "Saigon" || params["unit"] != "c" { t.Fatalf("params = %#v", params) } } +func TestToolsCustomUnsupportedDoesNotCallServer(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "tools", "custom", "list") + if err == nil { + t.Fatal("expected unsupported custom tools error") + } + var detail *output.ErrorDetail + if !errors.As(err, &detail) || detail.Code != "INVALID_REQUEST" { + t.Fatalf("error = %#v, want INVALID_REQUEST detail", err) + } + if requests != 0 { + t.Fatalf("requests = %d, want 0", requests) + } +} + +func TestUsageTimeseriesMapsFlagsToServerContract(t *testing.T) { + defer resetTestFlag(usageTimeseriesCmd, "start", "") + defer resetTestFlag(usageTimeseriesCmd, "end", "") + defer resetTestFlag(usageTimeseriesCmd, "granularity", "day") + defer resetTestFlag(usageTimeseriesCmd, "agent", "") + var query url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/usage/timeseries" { + w.WriteHeader(http.StatusNotFound) + return + } + query = r.URL.Query() + okJSON(t, w, map[string]any{"series": []map[string]any{}}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "usage", "timeseries", "--start=2026-05-20", "--end=2026-05-21", "--granularity=day", "--agent=agent-1"); err != nil { + t.Fatalf("usage timeseries: %v", err) + } + if query.Get("from") != "2026-05-20T00:00:00Z" || query.Get("to") != "2026-05-21T00:00:00Z" || + query.Get("group_by") != "day" || query.Get("agent_id") != "agent-1" { + t.Fatalf("query = %#v", query) + } + if query.Has("start") || query.Has("end") || query.Has("granularity") || query.Has("agent") { + t.Fatalf("query contains legacy keys: %#v", query) + } +} + +func TestUsageBreakdownMapsFlagsToServerContract(t *testing.T) { + defer resetTestFlag(usageBreakdownCmd, "start", "") + defer resetTestFlag(usageBreakdownCmd, "end", "") + defer resetTestFlag(usageBreakdownCmd, "by", "agent") + var query url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/usage/breakdown" { + w.WriteHeader(http.StatusNotFound) + return + } + query = r.URL.Query() + okJSON(t, w, map[string]any{"groups": []map[string]any{}}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "usage", "breakdown", "--start=2026-05-20", "--end=2026-05-21", "--by=provider"); err != nil { + t.Fatalf("usage breakdown: %v", err) + } + if query.Get("from") != "2026-05-20T00:00:00Z" || query.Get("to") != "2026-05-21T00:00:00Z" || + query.Get("group_by") != "provider" { + t.Fatalf("query = %#v", query) + } + if query.Has("start") || query.Has("end") || query.Has("by") { + t.Fatalf("query contains legacy keys: %#v", query) + } +} + +func TestMemoryCommandsUseAgentScopedHTTPRoutes(t *testing.T) { + defer resetTestFlag(memoryListCmd, "user", "") + defer resetTestFlag(memorySearchCmd, "query", "") + defer resetTestFlag(memorySearchCmd, "user", "") + var paths []string + var searchBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.String()) + switch r.URL.Path { + case "/v1/agents/goclaw/memory/documents": + okJSON(t, w, []map[string]any{{"path": "notes.md"}}) + case "/v1/agents/goclaw/memory/search": + _ = json.NewDecoder(r.Body).Decode(&searchBody) + okJSON(t, w, map[string]any{"results": []map[string]any{}, "count": 0}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "memory", "list", "goclaw", "--user=system"); err != nil { + t.Fatalf("memory list: %v", err) + } + if err := runCmd(t, "memory", "search", "goclaw", "--query=test"); err != nil { + t.Fatalf("memory search: %v", err) + } + if len(paths) != 2 || paths[0] != "/v1/agents/goclaw/memory/documents?user_id=system" || + paths[1] != "/v1/agents/goclaw/memory/search" { + t.Fatalf("paths = %#v", paths) + } + if searchBody["query"] != "test" { + t.Fatalf("search body = %#v", searchBody) + } +} + +func TestListCommandsAcceptObjectWrappedLists(t *testing.T) { + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + switch r.URL.Path { + case "/v1/agents": + okJSON(t, w, map[string]any{"agents": []map[string]any{{"id": "agent-1"}}}) + case "/v1/sessions": + okJSON(t, w, map[string]any{"sessions": []map[string]any{{"session_key": "sess-1"}}}) + case "/v1/tools/builtin": + okJSON(t, w, map[string]any{"tools": []map[string]any{{"name": "sessions_list"}}}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "json") + + agentsOut, err := captureStdout(t, func() error { + return runCmd(t, "agents", "list") + }) + if err != nil { + t.Fatalf("agents list: %v", err) + } + sessionsOut, err := captureStdout(t, func() error { + return runCmd(t, "sessions", "list") + }) + if err != nil { + t.Fatalf("sessions list: %v", err) + } + toolsOut, err := captureStdout(t, func() error { + return runCmd(t, "tools", "builtin", "list") + }) + if err != nil { + t.Fatalf("tools builtin list: %v", err) + } + if strings.Contains(agentsOut, "null") || !strings.Contains(agentsOut, "agent-1") { + t.Fatalf("agents stdout = %q", agentsOut) + } + if strings.Contains(sessionsOut, "null") || !strings.Contains(sessionsOut, "sess-1") { + t.Fatalf("sessions stdout = %q", sessionsOut) + } + if strings.Contains(toolsOut, "null") || !strings.Contains(toolsOut, "sessions_list") { + t.Fatalf("tools stdout = %q", toolsOut) + } + if len(paths) != 3 || paths[0] != "/v1/agents" || paths[1] != "/v1/sessions" || + paths[2] != "/v1/tools/builtin" { + t.Fatalf("paths = %#v", paths) + } +} + func TestChatReplayAndResumeUseExistingWSContracts(t *testing.T) { defer resetTestFlag(chatReplayCmd, "session", "") defer resetTestFlag(chatReplayCmd, "before", "") diff --git a/cmd/sessions.go b/cmd/sessions.go index 714a281..000297b 100644 --- a/cmd/sessions.go +++ b/cmd/sessions.go @@ -41,13 +41,14 @@ var sessionsListCmd = &cobra.Command{ return err } if cfg.OutputFormat != "table" { - printer.Print(unmarshalList(data)) + printer.Print(unmarshalNamedList(data, "sessions")) return nil } tbl := output.NewTable("KEY", "AGENT", "USER", "LABEL", "INPUT_TOKENS", "OUTPUT_TOKENS") - for _, s := range unmarshalList(data) { - tbl.AddRow(str(s, "session_key"), str(s, "agent_id"), str(s, "user_id"), - str(s, "label"), str(s, "input_tokens"), str(s, "output_tokens")) + for _, s := range unmarshalNamedList(data, "sessions") { + tbl.AddRow(strFirst(s, "session_key", "key"), strFirst(s, "agent_id", "agentID", "agentName"), + strFirst(s, "user_id", "userID"), str(s, "label"), + strFirst(s, "input_tokens", "inputTokens"), strFirst(s, "output_tokens", "outputTokens")) } printer.Print(tbl) return nil diff --git a/cmd/tools.go b/cmd/tools.go index 8364f50..3d04196 100644 --- a/cmd/tools.go +++ b/cmd/tools.go @@ -23,7 +23,7 @@ var toolsBuiltinListCmd = &cobra.Command{ if err != nil { return err } - printer.Print(unmarshalList(data)) + printer.Print(unmarshalNamedList(data, "tools")) return nil }, } diff --git a/cmd/tools_custom.go b/cmd/tools_custom.go index ba8bfd2..261729c 100644 --- a/cmd/tools_custom.go +++ b/cmd/tools_custom.go @@ -1,12 +1,7 @@ package cmd import ( - "encoding/json" - "fmt" - "net/url" - "github.com/nextlevelbuilder/goclaw-cli/internal/output" - "github.com/nextlevelbuilder/goclaw-cli/internal/tui" "github.com/spf13/cobra" ) @@ -18,126 +13,35 @@ var toolsCustomCmd = &cobra.Command{Use: "custom", Short: "Manage custom tools"} var toolsCustomListCmd = &cobra.Command{ Use: "list", Short: "List custom tools", RunE: func(cmd *cobra.Command, args []string) error { - c, err := newHTTP() - if err != nil { - return err - } - path := "/v1/tools/custom" - if v, _ := cmd.Flags().GetString("agent"); v != "" { - path += "?agent_id=" + url.QueryEscape(v) - } - data, err := c.Get(path) - if err != nil { - return err - } - if cfg.OutputFormat != "table" { - printer.Print(unmarshalList(data)) - return nil - } - tbl := output.NewTable("ID", "NAME", "DESCRIPTION", "ENABLED", "TIMEOUT") - for _, t := range unmarshalList(data) { - tbl.AddRow(str(t, "id"), str(t, "name"), str(t, "description"), - str(t, "enabled"), str(t, "timeout_seconds")) - } - printer.Print(tbl) - return nil + return customToolsUnsupported() }, } var toolsCustomGetCmd = &cobra.Command{ Use: "get ", Short: "Get custom tool details", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - c, err := newHTTP() - if err != nil { - return err - } - data, err := c.Get("/v1/tools/custom/" + args[0]) - if err != nil { - return err - } - printer.Print(unmarshalMap(data)) - return nil + return customToolsUnsupported() }, } var toolsCustomCreateCmd = &cobra.Command{ Use: "create", Short: "Create a custom tool", RunE: func(cmd *cobra.Command, args []string) error { - c, err := newHTTP() - if err != nil { - return err - } - name, _ := cmd.Flags().GetString("name") - desc, _ := cmd.Flags().GetString("description") - command, _ := cmd.Flags().GetString("command") - timeout, _ := cmd.Flags().GetInt("timeout") - agent, _ := cmd.Flags().GetString("agent") - paramsJSON, _ := cmd.Flags().GetString("parameters") - body := buildBody("name", name, "description", desc, - "command", command, "timeout_seconds", timeout, "agent_id", agent, "enabled", true) - if paramsJSON != "" { - var params any - if err := json.Unmarshal([]byte(paramsJSON), ¶ms); err != nil { - return fmt.Errorf("invalid parameters JSON: %w", err) - } - body["parameters"] = params - } - data, err := c.Post("/v1/tools/custom", body) - if err != nil { - return err - } - printer.Success(fmt.Sprintf("Tool created: %s", str(unmarshalMap(data), "id"))) - return nil + return customToolsUnsupported() }, } var toolsCustomUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update custom tool", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - c, err := newHTTP() - if err != nil { - return err - } - body := make(map[string]any) - for _, f := range []string{"name", "description", "command"} { - if cmd.Flags().Changed(f) { - v, _ := cmd.Flags().GetString(f) - body[f] = v - } - } - if cmd.Flags().Changed("timeout") { - v, _ := cmd.Flags().GetInt("timeout") - body["timeout_seconds"] = v - } - if cmd.Flags().Changed("enabled") { - v, _ := cmd.Flags().GetBool("enabled") - body["enabled"] = v - } - _, err = c.Put("/v1/tools/custom/"+args[0], body) - if err != nil { - return err - } - printer.Success("Tool updated") - return nil + return customToolsUnsupported() }, } var toolsCustomDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete custom tool", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if !tui.Confirm("Delete this tool?", cfg.Yes) { - return nil - } - c, err := newHTTP() - if err != nil { - return err - } - _, err = c.Delete("/v1/tools/custom/" + args[0]) - if err != nil { - return err - } - printer.Success("Tool deleted") - return nil + return customToolsUnsupported() }, } @@ -156,7 +60,18 @@ var toolsInvokeCmd = &cobra.Command{ if err != nil { return err } - body := map[string]any{"name": args[0], "parameters": params} + agentID, _ := cmd.Flags().GetString("agent") + action, _ := cmd.Flags().GetString("action") + sessionKey, _ := cmd.Flags().GetString("session") + dryRun, _ := cmd.Flags().GetBool("dry-run") + body := buildBody( + "tool", args[0], + "args", params, + "agentId", agentID, + "action", action, + "sessionKey", sessionKey, + "dryRun", dryRun, + ) data, err := c.Post("/v1/tools/invoke", body) if err != nil { return err @@ -166,6 +81,13 @@ var toolsInvokeCmd = &cobra.Command{ }, } +func customToolsUnsupported() error { + return &output.ErrorDetail{ + Code: "INVALID_REQUEST", + Message: "custom tool management is not supported by this GoClaw server; use `tools builtin` or `tools invoke`", + } +} + func init() { toolsCustomListCmd.Flags().String("agent", "", "Filter by agent ID") for _, c := range []*cobra.Command{toolsCustomCreateCmd, toolsCustomUpdateCmd} { @@ -180,6 +102,10 @@ func init() { toolsInvokeCmd.Flags().StringSlice("param", nil, "Parameter key=value pairs") toolsInvokeCmd.Flags().String("params", "", "Parameters as JSON object") toolsInvokeCmd.Flags().String("args", "", "Alias for --params; accepts literal JSON or @filepath") + toolsInvokeCmd.Flags().String("agent", "", "Agent key or ID for tool context") + toolsInvokeCmd.Flags().String("action", "", "Optional action to pass to the tool") + toolsInvokeCmd.Flags().String("session", "", "Optional session key for tool context") + toolsInvokeCmd.Flags().Bool("dry-run", false, "Validate tool and return schema without executing it") toolsCustomCmd.AddCommand(toolsCustomListCmd, toolsCustomGetCmd, toolsCustomCreateCmd, toolsCustomUpdateCmd, toolsCustomDeleteCmd) diff --git a/cmd/traces.go b/cmd/traces.go index 5372cfd..851bff1 100644 --- a/cmd/traces.go +++ b/cmd/traces.go @@ -5,6 +5,7 @@ import ( "io" "net/url" "os" + "time" "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/spf13/cobra" @@ -191,10 +192,17 @@ var usageTimeseriesCmd = &cobra.Command{ return err } q := url.Values{} - for _, k := range []string{"start", "end", "granularity", "agent", "user", "tenant"} { - if v, _ := cmd.Flags().GetString(k); v != "" { - q.Set(k, v) - } + if v, _ := cmd.Flags().GetString("start"); v != "" { + q.Set("from", normalizeUsageTimestamp(v)) + } + if v, _ := cmd.Flags().GetString("end"); v != "" { + q.Set("to", normalizeUsageTimestamp(v)) + } + if v, _ := cmd.Flags().GetString("granularity"); v != "" { + q.Set("group_by", v) + } + if v, _ := cmd.Flags().GetString("agent"); v != "" { + q.Set("agent_id", v) } path := "/v1/usage/timeseries" if len(q) > 0 { @@ -217,10 +225,14 @@ var usageBreakdownCmd = &cobra.Command{ return err } q := url.Values{} - for _, k := range []string{"by", "start", "end"} { - if v, _ := cmd.Flags().GetString(k); v != "" { - q.Set(k, v) - } + if v, _ := cmd.Flags().GetString("by"); v != "" { + q.Set("group_by", v) + } + if v, _ := cmd.Flags().GetString("start"); v != "" { + q.Set("from", normalizeUsageTimestamp(v)) + } + if v, _ := cmd.Flags().GetString("end"); v != "" { + q.Set("to", normalizeUsageTimestamp(v)) } path := "/v1/usage/breakdown" if len(q) > 0 { @@ -235,6 +247,13 @@ var usageBreakdownCmd = &cobra.Command{ }, } +func normalizeUsageTimestamp(v string) string { + if t, err := time.Parse("2006-01-02", v); err == nil { + return t.Format(time.RFC3339) + } + return v +} + func init() { tracesListCmd.Flags().String("agent", "", "Filter by agent ID") tracesListCmd.Flags().String("status", "", "Filter: running, success, error") @@ -250,15 +269,15 @@ func init() { usageDetailCmd.Flags().String("from", "", "Start date") usageDetailCmd.Flags().String("to", "", "End date") - usageTimeseriesCmd.Flags().String("start", "", "Start ISO timestamp") - usageTimeseriesCmd.Flags().String("end", "", "End ISO timestamp") - usageTimeseriesCmd.Flags().String("granularity", "day", "Bucket size: hour|day") + usageTimeseriesCmd.Flags().String("start", "", "Start date or RFC3339 timestamp") + usageTimeseriesCmd.Flags().String("end", "", "End date or RFC3339 timestamp") + usageTimeseriesCmd.Flags().String("granularity", "day", "Group by: provider|model|channel|agent|day") usageTimeseriesCmd.Flags().String("agent", "", "Filter by agent") usageTimeseriesCmd.Flags().String("user", "", "Filter by user") usageTimeseriesCmd.Flags().String("tenant", "", "Filter by tenant") - usageBreakdownCmd.Flags().String("by", "agent", "Dimension: agent|user|tenant") - usageBreakdownCmd.Flags().String("start", "", "Start ISO timestamp") - usageBreakdownCmd.Flags().String("end", "", "End ISO timestamp") + usageBreakdownCmd.Flags().String("by", "agent", "Dimension: provider|model|channel|agent|day") + usageBreakdownCmd.Flags().String("start", "", "Start date or RFC3339 timestamp") + usageBreakdownCmd.Flags().String("end", "", "End date or RFC3339 timestamp") tracesCmd.AddCommand(tracesListCmd, tracesGetCmd, tracesExportCmd) usageCmd.AddCommand(usageSummaryCmd, usageDetailCmd, usageCostsCmd,