From 8576703566444f4be097971e654e6d9411a21e8e Mon Sep 17 00:00:00 2001 From: Bruno Clermont Date: Thu, 25 Jun 2026 18:44:39 -0400 Subject: [PATCH 1/3] (feat) move client out of internal. this create a gocrawl client library --- {internal/client => client}/auth.go | 0 {internal/client => client}/auth_test.go | 0 {internal/client => client}/errors.go | 0 {internal/client => client}/errors_test.go | 0 {internal/client => client}/follow.go | 0 {internal/client => client}/follow_test.go | 0 {internal/client => client}/http.go | 0 {internal/client => client}/http_headers.go | 0 {internal/client => client}/http_test.go | 0 {internal/client => client}/multipart_upload.go | 0 {internal/client => client}/signed_download.go | 0 .../client => client}/signed_download_test.go | 2 +- {internal/client => client}/websocket.go | 6 +++--- {internal/client => client}/websocket_test.go | 0 cmd/agents_export.go | 2 +- cmd/api_docs.go | 2 +- cmd/auth.go | 2 +- cmd/backup.go | 2 +- cmd/chat.go | 2 +- cmd/edition.go | 2 +- cmd/heartbeat.go | 2 +- cmd/helpers.go | 2 +- cmd/logs.go | 2 +- cmd/media_upload.go | 2 +- cmd/oauth.go | 8 ++++---- cmd/packages_updates.go | 2 +- cmd/status.go | 2 +- cmd/super_admin_parity_test.go | 2 +- cmd/teams_events.go | 2 +- cmd/teams_tasks_advanced.go | 2 +- cmd/traces.go | 2 +- cmd/traces_timeline.go | 2 +- cmd/vault_enrichment_test.go | 10 +++++----- cmd/vault_test.go | 4 ++-- cmd/vault_upload.go | 2 +- docs/code-standards.md | 8 ++++---- docs/codebase-summary.md | 6 +++--- docs/deployment-guide.md | 2 +- docs/project-roadmap.md | 14 +++++++------- docs/system-architecture.md | 4 ++-- go.mod | 2 +- internal/output/exit.go | 4 ++-- 42 files changed, 53 insertions(+), 53 deletions(-) rename {internal/client => client}/auth.go (100%) rename {internal/client => client}/auth_test.go (100%) rename {internal/client => client}/errors.go (100%) rename {internal/client => client}/errors_test.go (100%) rename {internal/client => client}/follow.go (100%) rename {internal/client => client}/follow_test.go (100%) rename {internal/client => client}/http.go (100%) rename {internal/client => client}/http_headers.go (100%) rename {internal/client => client}/http_test.go (100%) rename {internal/client => client}/multipart_upload.go (100%) rename {internal/client => client}/signed_download.go (100%) rename {internal/client => client}/signed_download_test.go (96%) rename {internal/client => client}/websocket.go (96%) rename {internal/client => client}/websocket_test.go (100%) diff --git a/internal/client/auth.go b/client/auth.go similarity index 100% rename from internal/client/auth.go rename to client/auth.go diff --git a/internal/client/auth_test.go b/client/auth_test.go similarity index 100% rename from internal/client/auth_test.go rename to client/auth_test.go diff --git a/internal/client/errors.go b/client/errors.go similarity index 100% rename from internal/client/errors.go rename to client/errors.go diff --git a/internal/client/errors_test.go b/client/errors_test.go similarity index 100% rename from internal/client/errors_test.go rename to client/errors_test.go diff --git a/internal/client/follow.go b/client/follow.go similarity index 100% rename from internal/client/follow.go rename to client/follow.go diff --git a/internal/client/follow_test.go b/client/follow_test.go similarity index 100% rename from internal/client/follow_test.go rename to client/follow_test.go diff --git a/internal/client/http.go b/client/http.go similarity index 100% rename from internal/client/http.go rename to client/http.go diff --git a/internal/client/http_headers.go b/client/http_headers.go similarity index 100% rename from internal/client/http_headers.go rename to client/http_headers.go diff --git a/internal/client/http_test.go b/client/http_test.go similarity index 100% rename from internal/client/http_test.go rename to client/http_test.go diff --git a/internal/client/multipart_upload.go b/client/multipart_upload.go similarity index 100% rename from internal/client/multipart_upload.go rename to client/multipart_upload.go diff --git a/internal/client/signed_download.go b/client/signed_download.go similarity index 100% rename from internal/client/signed_download.go rename to client/signed_download.go diff --git a/internal/client/signed_download_test.go b/client/signed_download_test.go similarity index 96% rename from internal/client/signed_download_test.go rename to client/signed_download_test.go index bfb2b68..ce4112c 100644 --- a/internal/client/signed_download_test.go +++ b/client/signed_download_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" ) func TestDownloadSigned_NoAuthHeader(t *testing.T) { diff --git a/internal/client/websocket.go b/client/websocket.go similarity index 96% rename from internal/client/websocket.go rename to client/websocket.go index 3000370..26b7a65 100644 --- a/internal/client/websocket.go +++ b/client/websocket.go @@ -23,9 +23,9 @@ type WSClient struct { insecure bool nextID atomic.Int64 - mu sync.Mutex // protects pending and listeners - writeMu sync.Mutex // protects concurrent writes (gorilla requirement) - closeOnce sync.Once // guards Close against concurrent readLoop + caller invocation + mu sync.Mutex // protects pending and listeners + writeMu sync.Mutex // protects concurrent writes (gorilla requirement) + closeOnce sync.Once // guards Close against concurrent readLoop + caller invocation pending map[string]chan *WSResponse listeners map[string][]func(*WSEvent) done chan struct{} diff --git a/internal/client/websocket_test.go b/client/websocket_test.go similarity index 100% rename from internal/client/websocket_test.go rename to client/websocket_test.go diff --git a/cmd/agents_export.go b/cmd/agents_export.go index 6538e5a..d918c57 100644 --- a/cmd/agents_export.go +++ b/cmd/agents_export.go @@ -26,7 +26,7 @@ Examples: } outFile, _ := cmd.Flags().GetString("file") - resp, err := c.GetRaw("/v1/agents/"+args[0]+"/export") + resp, err := c.GetRaw("/v1/agents/" + args[0] + "/export") if err != nil { return err } diff --git a/cmd/api_docs.go b/cmd/api_docs.go index ac19445..9e3a07a 100644 --- a/cmd/api_docs.go +++ b/cmd/api_docs.go @@ -6,7 +6,7 @@ import ( "os/exec" "runtime" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/spf13/cobra" ) diff --git a/cmd/auth.go b/cmd/auth.go index 3bf248e..cb76b6c 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/nextlevelbuilder/goclaw-cli/internal/config" "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/nextlevelbuilder/goclaw-cli/internal/tui" diff --git a/cmd/backup.go b/cmd/backup.go index 1b623a9..18b70ad 100644 --- a/cmd/backup.go +++ b/cmd/backup.go @@ -6,7 +6,7 @@ import ( "os" "path/filepath" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/spf13/cobra" ) diff --git a/cmd/chat.go b/cmd/chat.go index ca2a4d8..862f45d 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -8,7 +8,7 @@ import ( "os" "strings" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/nextlevelbuilder/goclaw-cli/internal/tui" "github.com/spf13/cobra" ) diff --git a/cmd/edition.go b/cmd/edition.go index bbafc84..8a51db8 100644 --- a/cmd/edition.go +++ b/cmd/edition.go @@ -1,7 +1,7 @@ package cmd import ( - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/spf13/cobra" ) diff --git a/cmd/heartbeat.go b/cmd/heartbeat.go index 32c9c09..d6669f6 100644 --- a/cmd/heartbeat.go +++ b/cmd/heartbeat.go @@ -8,7 +8,7 @@ import ( "os/signal" "syscall" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/spf13/cobra" ) diff --git a/cmd/helpers.go b/cmd/helpers.go index 7748178..9270031 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -8,7 +8,7 @@ import ( "os" "strings" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" ) // newHTTP creates an authenticated HTTP client from current config. diff --git a/cmd/logs.go b/cmd/logs.go index 5cf2ace..7a9652b 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -8,7 +8,7 @@ import ( "os/signal" "syscall" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/spf13/cobra" ) diff --git a/cmd/media_upload.go b/cmd/media_upload.go index 0e1ac6f..0fd07b3 100644 --- a/cmd/media_upload.go +++ b/cmd/media_upload.go @@ -6,7 +6,7 @@ import ( "net/http" "os" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" ) func uploadMediaFile(c *client.HTTPClient, filePath string) (*http.Response, error) { diff --git a/cmd/oauth.go b/cmd/oauth.go index 0e5623f..d18bb9e 100644 --- a/cmd/oauth.go +++ b/cmd/oauth.go @@ -15,10 +15,10 @@ var oauthCmd = &cobra.Command{Use: "oauth", Short: "Manage OAuth provider pool ( // validOAuthProviders lists accepted --provider values. // Extend when server adds new providers. var validOAuthProviders = map[string]bool{ - "openai": true, - "chatgpt": true, - "claude": true, // chatgpt sub-provider — keep if server supports - "gemini": true, + "openai": true, + "chatgpt": true, + "claude": true, // chatgpt sub-provider — keep if server supports + "gemini": true, } // validateProvider returns an error if provider is not supported. diff --git a/cmd/packages_updates.go b/cmd/packages_updates.go index 4982d37..30f13c4 100644 --- a/cmd/packages_updates.go +++ b/cmd/packages_updates.go @@ -3,7 +3,7 @@ package cmd import ( "strings" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/spf13/cobra" ) diff --git a/cmd/status.go b/cmd/status.go index 28e37e9..b03ae12 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/spf13/cobra" ) diff --git a/cmd/super_admin_parity_test.go b/cmd/super_admin_parity_test.go index 94f8ad7..522e10b 100644 --- a/cmd/super_admin_parity_test.go +++ b/cmd/super_admin_parity_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/gorilla/websocket" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" ) func TestAPIKeysRevokeUsesPostRevokeRoute(t *testing.T) { diff --git a/cmd/teams_events.go b/cmd/teams_events.go index dd4d0a8..e7fbe4d 100644 --- a/cmd/teams_events.go +++ b/cmd/teams_events.go @@ -8,7 +8,7 @@ import ( "os/signal" "syscall" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/spf13/cobra" ) diff --git a/cmd/teams_tasks_advanced.go b/cmd/teams_tasks_advanced.go index 038e6e7..2c9bc48 100644 --- a/cmd/teams_tasks_advanced.go +++ b/cmd/teams_tasks_advanced.go @@ -9,7 +9,7 @@ import ( "strings" "syscall" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/nextlevelbuilder/goclaw-cli/internal/tui" "github.com/spf13/cobra" diff --git a/cmd/traces.go b/cmd/traces.go index 7b7b5e5..29c45ee 100644 --- a/cmd/traces.go +++ b/cmd/traces.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/spf13/cobra" ) diff --git a/cmd/traces_timeline.go b/cmd/traces_timeline.go index 597ff94..14e655f 100644 --- a/cmd/traces_timeline.go +++ b/cmd/traces_timeline.go @@ -6,7 +6,7 @@ import ( "net/url" "strings" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/spf13/cobra" ) diff --git a/cmd/vault_enrichment_test.go b/cmd/vault_enrichment_test.go index 8fffad5..ba91e46 100644 --- a/cmd/vault_enrichment_test.go +++ b/cmd/vault_enrichment_test.go @@ -14,11 +14,11 @@ func TestVaultEnrichmentStatus_CallsEndpoint(t *testing.T) { } w.Header().Set("Content-Type", "application/json") w.Write(vaultEnvelope(map[string]any{ - "running": true, - "total": 10, - "done": 4, - "failed": 0, - "percent": 40, + "running": true, + "total": 10, + "done": 4, + "failed": 0, + "percent": 40, })) })) defer srv.Close() diff --git a/cmd/vault_test.go b/cmd/vault_test.go index 756a5f0..62af2ff 100644 --- a/cmd/vault_test.go +++ b/cmd/vault_test.go @@ -214,8 +214,8 @@ func TestGraphJSONToDOT_SkipsIncompleteEdges(t *testing.T) { input := map[string]any{ "nodes": []any{}, "edges": []map[string]any{ - {"from_doc_id": "", "to_doc_id": "x"}, // empty from — skip - {"from_doc_id": "y", "to_doc_id": ""}, // empty to — skip + {"from_doc_id": "", "to_doc_id": "x"}, // empty from — skip + {"from_doc_id": "y", "to_doc_id": ""}, // empty to — skip }, } raw, _ := json.Marshal(input) diff --git a/cmd/vault_upload.go b/cmd/vault_upload.go index 2ae3531..603e54d 100644 --- a/cmd/vault_upload.go +++ b/cmd/vault_upload.go @@ -8,7 +8,7 @@ import ( "os" "strings" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/spf13/cobra" ) diff --git a/docs/code-standards.md b/docs/code-standards.md index c5d1d4a..0f52801 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -54,7 +54,7 @@ goclaw-cli/ ### Naming Conventions - **Go Files:** `snake_case.go` (e.g., `config_cmd.go`, `websocket.go`) -- **Packages:** Lowercase, no underscores (e.g., `internal/client`, `internal/config`) +- **Packages:** Lowercase, no underscores (e.g., `client`, `internal/config`) - **Functions:** `PascalCase` (exported), `camelCase` (unexported) - **Variables:** `camelCase` (local), `CONSTANT_CASE` (constants) - **Interfaces:** `Reader`, `Writer`, `Handler` (noun-based) @@ -73,7 +73,7 @@ import ( "encoding/json" "fmt" - "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/nextlevelbuilder/goclaw-cli/internal/config" "github.com/spf13/cobra" ) @@ -118,7 +118,7 @@ The `output` package uses duck-typed interfaces (`apiErrorIface`, `apiErrorWithS ### API Client Pattern -**HTTP Client (internal/client/http.go):** +**HTTP Client (client/http.go):** ```go type HTTPClient struct { @@ -713,7 +713,7 @@ if cfg.Confirm != expectedValue { **FollowStream Pattern:** ```go -// internal/client/follow.go +// client/follow.go func FollowStream(ctx context.Context, serverURL, token, ..., handler FollowHandler) error { // Reconnect on drop with exponential backoff: 1s → 2s → 4s → 8s → 16s (max 5 retries) // If handler returns error, stop immediately (no retry) diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 18ac127..bc57216 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -89,7 +89,7 @@ All files follow Cobra pattern: root command + subcommands. #### client/ — HTTP + WebSocket Clients ``` -internal/client/ +client/ ├── http.go # REST API client ├── websocket.go # WebSocket streaming ├── auth.go # Auth helpers (keyring, device pairing) @@ -535,8 +535,8 @@ Each level overrides the previous. | File | Purpose | |------|---------| -| `internal/client/signed_download.go` | `DownloadSigned(url, dst, insecure, progress)` — GET with NO auth header | -| `internal/client/multipart_upload.go` | `UploadFile(path, field, file)` — streaming pipe-based multipart POST | +| `client/signed_download.go` | `DownloadSigned(url, dst, insecure, progress)` — GET with NO auth header | +| `client/multipart_upload.go` | `UploadFile(path, field, file)` — streaming pipe-based multipart POST | | `cmd/io_helpers.go` | `copyProgress`, `writeToFile`, `printProgress` shared helpers | --- diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index f4494d5..71d4754 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -715,7 +715,7 @@ Default HTTP client reuses TCP connections (connection pooling). No tuning neede Currently hardcoded to 30 seconds per request. Override in future versions if needed. ```go -// internal/client/http.go +// client/http.go HTTPClient: &http.Client{ Timeout: 30 * time.Second, // Can be made configurable } diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 94a22fe..a4f32c9 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -118,10 +118,10 @@ - [x] Output formatters (table, JSON, YAML) **Key Files:** -- `internal/client/http.go` -- `internal/client/websocket.go` -- `internal/client/auth.go` -- `internal/client/errors.go` +- `client/http.go` +- `client/websocket.go` +- `client/auth.go` +- `client/errors.go` - `internal/config/config.go` - `internal/output/output.go` - `internal/tui/prompt.go` @@ -355,7 +355,7 @@ **Key Files:** - `internal/output/exit.go`, `error.go`, `tty.go` -- `internal/client/follow.go` +- `client/follow.go` - `cmd/root.go` (error handler) - `CHANGELOG.md` (breaking change doc) @@ -391,7 +391,7 @@ - [x] Signed download flow (unauthenticated binary via token) - [x] Multipart streaming upload (no RAM buffering) -**Key Files:** `cmd/backup.go`, `cmd/backup_s3.go`, `cmd/restore.go`, `cmd/*_export.go`, `internal/client/signed_download.go`, `internal/client/multipart_upload.go` +**Key Files:** `cmd/backup.go`, `cmd/backup_s3.go`, `cmd/restore.go`, `cmd/*_export.go`, `client/signed_download.go`, `client/multipart_upload.go` **Status:** COMPLETE; 4 critical fixes applied (S3 masking, URL escaping, error propagation, MkdirAll) @@ -504,7 +504,7 @@ **Key Areas:** - `cmd/*_test.go` (command handlers) -- `internal/client/*_test.go` (HTTP, WebSocket) +- `client/*_test.go` (HTTP, WebSocket) - `internal/config/*_test.go` (configuration loading) - `internal/output/*_test.go` (formatters) diff --git a/docs/system-architecture.md b/docs/system-architecture.md index bb70027..55b367a 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -96,7 +96,7 @@ var myCmd = &cobra.Command{ --- -### 3. Client Layer (internal/client/) +### 3. Client Layer (client/) #### HTTP Client (http.go) @@ -601,7 +601,7 @@ TLS Encryption (HTTPS by default, --insecure only for testing) ### Custom Auth Methods -1. Extend `internal/client/auth.go` +1. Extend `client/auth.go` 2. Implement credential storage backend 3. Register in auth command 4. Update login flow diff --git a/go.mod b/go.mod index 912da04..3b64cab 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,12 @@ go 1.25.3 require ( github.com/gorilla/websocket v1.5.3 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 golang.org/x/term v0.41.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.9 // indirect golang.org/x/sys v0.42.0 // indirect ) diff --git a/internal/output/exit.go b/internal/output/exit.go index 20ccf89..7c4beee 100644 --- a/internal/output/exit.go +++ b/internal/output/exit.go @@ -25,9 +25,9 @@ var serverCodeMap = map[string]int{ "NOT_LINKED": ExitNotFound, // Validation (4) - "INVALID_REQUEST": ExitValidation, + "INVALID_REQUEST": ExitValidation, "FAILED_PRECONDITION": ExitValidation, - "ALREADY_EXISTS": ExitValidation, + "ALREADY_EXISTS": ExitValidation, // Server (5) "INTERNAL": ExitServer, From 9cdefc337b98d4b4fe6c23ef47a871c29378ac97 Mon Sep 17 00:00:00 2001 From: Bruno Clermont Date: Sat, 4 Jul 2026 13:18:05 -0400 Subject: [PATCH 2/3] feat(client): complete client package coverage for core goclaw API methods Extended the WS client to cover chat, sessions, agents, config, skills, and cron method groups with correct request/response shapes verified directly against the current goclaw server handlers, including several field-naming corrections beyond the agent_key/session_key rename from PR #30. --- client/agents.go | 75 ++++++++++++++ client/chat.go | 168 ++++++++++++++++++++++++++++++ client/config.go | 82 +++++++++++++++ client/cron.go | 241 ++++++++++++++++++++++++++++++++++++++++++++ client/sessions.go | 147 +++++++++++++++++++++++++++ client/skills.go | 94 +++++++++++++++++ client/websocket.go | 70 +++++++++++-- 7 files changed, 870 insertions(+), 7 deletions(-) create mode 100644 client/agents.go create mode 100644 client/chat.go create mode 100644 client/config.go create mode 100644 client/cron.go create mode 100644 client/sessions.go create mode 100644 client/skills.go diff --git a/client/agents.go b/client/agents.go new file mode 100644 index 0000000..7af9fac --- /dev/null +++ b/client/agents.go @@ -0,0 +1,75 @@ +package client + +import "encoding/json" + +// AgentInfo describes a single agent as returned by agents.list. +type AgentInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Model string `json:"model"` + Provider string `json:"provider"` + AgentType string `json:"agentType"` + Status string `json:"status"` + IsRunning bool `json:"isRunning"` +} + +// AgentsListResult is the response payload for agents.list. +type AgentsListResult struct { + Agents []AgentInfo `json:"agents"` +} + +// AgentsList lists agents visible to the caller. +func (ws *WSClient) AgentsList() (*AgentsListResult, error) { + payload, err := ws.Call("agents.list", nil) + if err != nil { + return nil, err + } + var result AgentsListResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// AgentParams are the params for agent and agent.wait. +type AgentParams struct { + AgentID string `json:"agentId"` +} + +// AgentResult is the response payload for the agent method. +type AgentResult struct { + ID string `json:"id"` + IsRunning bool `json:"isRunning"` +} + +// Agent returns the running state for a single agent. +func (ws *WSClient) Agent(params AgentParams) (*AgentResult, error) { + payload, err := ws.Call("agent", params) + if err != nil { + return nil, err + } + var result AgentResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// AgentWaitResult is the response payload for agent.wait. +type AgentWaitResult struct { + ID string `json:"id"` + Status string `json:"status"` +} + +// AgentWait waits for (or reports the current status of) an agent. +func (ws *WSClient) AgentWait(params AgentParams) (*AgentWaitResult, error) { + payload, err := ws.Call("agent.wait", params) + if err != nil { + return nil, err + } + var result AgentWaitResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/client/chat.go b/client/chat.go new file mode 100644 index 0000000..405930f --- /dev/null +++ b/client/chat.go @@ -0,0 +1,168 @@ +package client + +import "encoding/json" + +// ChatMediaItem represents a media file attached to a chat message. +type ChatMediaItem struct { + Path string `json:"path"` + Filename string `json:"filename,omitempty"` +} + +// ChatSendParams are the params for chat.send. +type ChatSendParams struct { + Message string `json:"message"` + AgentID string `json:"agentId"` + SessionKey string `json:"sessionKey,omitempty"` + Stream bool `json:"stream,omitempty"` + Media json.RawMessage `json:"media,omitempty"` // []string (legacy) or []ChatMediaItem +} + +// ChatSendResult is the response payload for chat.send. +type ChatSendResult struct { + RunID string `json:"runId"` + Content string `json:"content"` + Usage json.RawMessage `json:"usage,omitempty"` + Thinking string `json:"thinking,omitempty"` + Media json.RawMessage `json:"media,omitempty"` + Cancelled bool `json:"cancelled,omitempty"` + Injected bool `json:"injected,omitempty"` +} + +// ChatSend sends a chat message to an agent. +func (ws *WSClient) ChatSend(params ChatSendParams) (*ChatSendResult, error) { + payload, err := ws.Call("chat.send", params) + if err != nil { + return nil, err + } + var result ChatSendResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ChatSendStream sends a chat message and streams events until run completion. +func (ws *WSClient) ChatSendStream(params ChatSendParams, onEvent func(*WSEvent)) (*ChatSendResult, error) { + payload, err := ws.Stream("chat.send", params, onEvent) + if err != nil { + return nil, err + } + var result ChatSendResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ChatHistoryParams are the params for chat.history. +type ChatHistoryParams struct { + AgentID string `json:"agentId"` + SessionKey string `json:"sessionKey"` +} + +// ChatHistoryResult is the response payload for chat.history. +type ChatHistoryResult struct { + Messages json.RawMessage `json:"messages"` +} + +// ChatHistory fetches the message history for a session. +func (ws *WSClient) ChatHistory(params ChatHistoryParams) (*ChatHistoryResult, error) { + payload, err := ws.Call("chat.history", params) + if err != nil { + return nil, err + } + var result ChatHistoryResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ChatInjectParams are the params for chat.inject. +type ChatInjectParams struct { + SessionKey string `json:"sessionKey"` + Message string `json:"message"` + Label string `json:"label,omitempty"` +} + +// ChatInjectResult is the response payload for chat.inject. +type ChatInjectResult struct { + OK bool `json:"ok"` + MessageID string `json:"messageId"` +} + +// ChatInject injects a message into a session transcript without running the agent. +func (ws *WSClient) ChatInject(params ChatInjectParams) (*ChatInjectResult, error) { + payload, err := ws.Call("chat.inject", params) + if err != nil { + return nil, err + } + var result ChatInjectResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ChatAbortParams are the params for chat.abort. +type ChatAbortParams struct { + RunID string `json:"runId,omitempty"` + SessionKey string `json:"sessionKey,omitempty"` +} + +// ChatAbortResult is the response payload for chat.abort. +type ChatAbortResult struct { + OK bool `json:"ok"` + Aborted bool `json:"aborted"` + Stopped bool `json:"stopped"` + Forced bool `json:"forced"` + AlreadyAborting bool `json:"alreadyAborting"` + NotFound bool `json:"notFound"` + Unauthorized bool `json:"unauthorized"` + RunIDs []string `json:"runIds"` +} + +// ChatAbort cancels running agent invocations for a session or specific run. +func (ws *WSClient) ChatAbort(params ChatAbortParams) (*ChatAbortResult, error) { + payload, err := ws.Call("chat.abort", params) + if err != nil { + return nil, err + } + var result ChatAbortResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ChatSessionStatusParams are the params for chat.session.status. +type ChatSessionStatusParams struct { + SessionKey string `json:"sessionKey"` +} + +// ChatActivity describes the current in-flight agent activity for a session. +type ChatActivity struct { + Phase string `json:"phase"` + Tool string `json:"tool"` + Iteration int `json:"iteration"` +} + +// ChatSessionStatusResult is the response payload for chat.session.status. +type ChatSessionStatusResult struct { + IsRunning bool `json:"isRunning"` + RunID string `json:"runId"` + Activity *ChatActivity `json:"activity"` +} + +// ChatSessionStatus returns the running state and activity for a session. +func (ws *WSClient) ChatSessionStatus(params ChatSessionStatusParams) (*ChatSessionStatusResult, error) { + payload, err := ws.Call("chat.session.status", params) + if err != nil { + return nil, err + } + var result ChatSessionStatusResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/client/config.go b/client/config.go new file mode 100644 index 0000000..76123d7 --- /dev/null +++ b/client/config.go @@ -0,0 +1,82 @@ +package client + +import "encoding/json" + +// ConfigGetResult is the response payload for config.get. +type ConfigGetResult struct { + Config json.RawMessage `json:"config"` + Hash string `json:"hash"` + Path string `json:"path"` +} + +// ConfigGet returns the current masked config, its hash, and file path. +func (ws *WSClient) ConfigGet() (*ConfigGetResult, error) { + payload, err := ws.Call("config.get", nil) + if err != nil { + return nil, err + } + var result ConfigGetResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ConfigApplyParams are the params for config.apply and config.patch. +type ConfigApplyParams struct { + Raw string `json:"raw"` + BaseHash string `json:"baseHash,omitempty"` +} + +// ConfigApplyResult is the response payload for config.apply and config.patch. +type ConfigApplyResult struct { + OK bool `json:"ok"` + Path string `json:"path"` + Config json.RawMessage `json:"config"` + Hash string `json:"hash"` + Restart bool `json:"restart"` +} + +// ConfigApply replaces the entire config with raw JSON5 content. +func (ws *WSClient) ConfigApply(params ConfigApplyParams) (*ConfigApplyResult, error) { + payload, err := ws.Call("config.apply", params) + if err != nil { + return nil, err + } + var result ConfigApplyResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ConfigPatch merges a partial JSON5 config update into the current config. +func (ws *WSClient) ConfigPatch(params ConfigApplyParams) (*ConfigApplyResult, error) { + payload, err := ws.Call("config.patch", params) + if err != nil { + return nil, err + } + var result ConfigApplyResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ConfigSchemaResult is the response payload for config.schema. +type ConfigSchemaResult struct { + JSON json.RawMessage `json:"json"` +} + +// ConfigSchema returns the config JSON schema for UI form generation. +func (ws *WSClient) ConfigSchema() (*ConfigSchemaResult, error) { + payload, err := ws.Call("config.schema", nil) + if err != nil { + return nil, err + } + var result ConfigSchemaResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/client/cron.go b/client/cron.go new file mode 100644 index 0000000..5e5f819 --- /dev/null +++ b/client/cron.go @@ -0,0 +1,241 @@ +package client + +import "encoding/json" + +// CronSchedule describes a cron job's trigger (matches store.CronSchedule). +type CronSchedule struct { + Kind string `json:"kind"` // "at", "every", "cron" + AtMS *int64 `json:"atMs,omitempty"` + EveryMS *int64 `json:"everyMs,omitempty"` + Expr string `json:"expr,omitempty"` + TZ string `json:"tz,omitempty"` +} + +// CronCommandSpec is a deterministic shell command run by a cron job (matches +// store.CronCommandSpec). +type CronCommandSpec struct { + Argv []string `json:"argv,omitempty"` + Cwd string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` + Input string `json:"input,omitempty"` + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` + NoOutputTimeoutSeconds int `json:"noOutputTimeoutSeconds,omitempty"` + OutputMaxBytes int `json:"outputMaxBytes,omitempty"` +} + +// CronJobPatch is a partial update for cron.update (matches store.CronJobPatch). +type CronJobPatch struct { + Name string `json:"name,omitempty"` + AgentID *string `json:"agentId,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Schedule *CronSchedule `json:"schedule,omitempty"` + Message string `json:"message,omitempty"` + Command *CronCommandSpec `json:"command,omitempty"` + DeleteAfterRun *bool `json:"deleteAfterRun,omitempty"` + Stateless *bool `json:"stateless,omitempty"` + Deliver *bool `json:"deliver,omitempty"` + DeliverChannel *string `json:"deliverChannel,omitempty"` + DeliverTo *string `json:"deliverTo,omitempty"` + WakeHeartbeat *bool `json:"wakeHeartbeat,omitempty"` + ProviderID *string `json:"providerId,omitempty"` + Model *string `json:"model,omitempty"` +} + +// CronJob describes a cron job as returned by the server (fields loosely +// typed as json.RawMessage where the exact shape isn't load-bearing). +type CronJob struct { + ID string `json:"id"` + TenantID string `json:"tenantId,omitempty"` + Name string `json:"name"` + AgentID string `json:"agentId,omitempty"` + UserID string `json:"userId,omitempty"` + Enabled bool `json:"enabled"` + Schedule CronSchedule `json:"schedule"` + Payload json.RawMessage `json:"payload"` + State json.RawMessage `json:"state"` + CreatedAtMS int64 `json:"createdAtMs"` + UpdatedAtMS int64 `json:"updatedAtMs"` + DeleteAfterRun bool `json:"deleteAfterRun,omitempty"` + Stateless bool `json:"stateless"` + Deliver bool `json:"deliver"` + DeliverChannel string `json:"deliverChannel"` +} + +// CronListParams are the params for cron.list. +type CronListParams struct { + IncludeDisabled bool `json:"includeDisabled,omitempty"` +} + +// CronListResult is the response payload for cron.list. +type CronListResult struct { + Jobs []CronJob `json:"jobs"` + Status json.RawMessage `json:"status"` +} + +// CronList lists cron jobs. +func (ws *WSClient) CronList(params CronListParams) (*CronListResult, error) { + payload, err := ws.Call("cron.list", params) + if err != nil { + return nil, err + } + var result CronListResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// CronCreateParams are the params for cron.create. +type CronCreateParams struct { + Name string `json:"name"` + Schedule CronSchedule `json:"schedule"` + Message string `json:"message,omitempty"` + Command *CronCommandSpec `json:"command,omitempty"` + Deliver bool `json:"deliver,omitempty"` + DeliverChannel string `json:"deliverChannel,omitempty"` + DeliverTo string `json:"deliverTo,omitempty"` + WakeHeartbeat bool `json:"wakeHeartbeat,omitempty"` + Stateless *bool `json:"stateless,omitempty"` + AgentID string `json:"agentId"` +} + +// CronJobResult wraps a single cron job, as returned by cron.create and cron.update. +type CronJobResult struct { + Job CronJob `json:"job"` +} + +// CronCreate creates a new cron job. +func (ws *WSClient) CronCreate(params CronCreateParams) (*CronJobResult, error) { + payload, err := ws.Call("cron.create", params) + if err != nil { + return nil, err + } + var result CronJobResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// CronUpdateParams are the params for cron.update. +type CronUpdateParams struct { + JobID string `json:"jobId"` + Patch CronJobPatch `json:"patch"` +} + +// CronUpdate applies a partial update to a cron job. +func (ws *WSClient) CronUpdate(params CronUpdateParams) (*CronJobResult, error) { + payload, err := ws.Call("cron.update", params) + if err != nil { + return nil, err + } + var result CronJobResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// CronJobIDParams are the params shared by cron.delete, cron.run and cron.status(single-job variants). +type CronJobIDParams struct { + JobID string `json:"jobId"` +} + +// CronDeleteResult is the response payload for cron.delete. +type CronDeleteResult struct { + Deleted bool `json:"deleted"` +} + +// CronDelete deletes a cron job. +func (ws *WSClient) CronDelete(params CronJobIDParams) (*CronDeleteResult, error) { + payload, err := ws.Call("cron.delete", params) + if err != nil { + return nil, err + } + var result CronDeleteResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// CronToggleParams are the params for cron.toggle. +type CronToggleParams struct { + JobID string `json:"jobId"` + Enabled bool `json:"enabled"` +} + +// CronToggleResult is the response payload for cron.toggle. +type CronToggleResult struct { + JobID string `json:"jobId"` + Enabled bool `json:"enabled"` +} + +// CronToggle enables or disables a cron job. +func (ws *WSClient) CronToggle(params CronToggleParams) (*CronToggleResult, error) { + payload, err := ws.Call("cron.toggle", params) + if err != nil { + return nil, err + } + var result CronToggleResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// CronRunParams are the params for cron.run. +type CronRunParams struct { + JobID string `json:"jobId"` + Mode string `json:"mode,omitempty"` // "force" or "due" (default) +} + +// CronRunResult is the response payload for cron.run. +type CronRunResult struct { + OK bool `json:"ok"` + Ran bool `json:"ran"` +} + +// CronRun triggers an immediate (background) run of a cron job. +func (ws *WSClient) CronRun(params CronRunParams) (*CronRunResult, error) { + payload, err := ws.Call("cron.run", params) + if err != nil { + return nil, err + } + var result CronRunResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// CronRunsParams are the params for cron.runs. +type CronRunsParams struct { + JobID string `json:"jobId,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` +} + +// CronRunsResult is the response payload for cron.runs. +type CronRunsResult struct { + Entries json.RawMessage `json:"entries"` + Total int `json:"total"` +} + +// CronRuns returns the run log entries for a cron job. +func (ws *WSClient) CronRuns(params CronRunsParams) (*CronRunsResult, error) { + payload, err := ws.Call("cron.runs", params) + if err != nil { + return nil, err + } + var result CronRunsResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// CronStatus returns the scheduler's overall status object (raw, shape not stabilized). +func (ws *WSClient) CronStatus() (json.RawMessage, error) { + return ws.Call("cron.status", nil) +} diff --git a/client/sessions.go b/client/sessions.go new file mode 100644 index 0000000..5b02d23 --- /dev/null +++ b/client/sessions.go @@ -0,0 +1,147 @@ +package client + +import "encoding/json" + +// SessionsListParams are the params for sessions.list. +type SessionsListParams struct { + AgentID string `json:"agentId,omitempty"` + Channel string `json:"channel,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` +} + +// SessionsListResult is the response payload for sessions.list. +type SessionsListResult struct { + Sessions json.RawMessage `json:"sessions"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// SessionsList lists sessions with optional filtering. +func (ws *WSClient) SessionsList(params SessionsListParams) (*SessionsListResult, error) { + payload, err := ws.Call("sessions.list", params) + if err != nil { + return nil, err + } + var result SessionsListResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// SessionsPreviewParams are the params for sessions.preview. +type SessionsPreviewParams struct { + Key string `json:"key"` +} + +// SessionsPreviewResult is the response payload for sessions.preview. +type SessionsPreviewResult struct { + Key string `json:"key"` + Messages json.RawMessage `json:"messages"` + Summary json.RawMessage `json:"summary,omitempty"` +} + +// SessionsPreview returns the message history and summary for a session. +func (ws *WSClient) SessionsPreview(params SessionsPreviewParams) (*SessionsPreviewResult, error) { + payload, err := ws.Call("sessions.preview", params) + if err != nil { + return nil, err + } + var result SessionsPreviewResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// SessionsPatchParams are the params for sessions.patch. +type SessionsPatchParams struct { + Key string `json:"key"` + Label *string `json:"label,omitempty"` + Model *string `json:"model,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// SessionsPatchResult is the response payload for sessions.patch. +type SessionsPatchResult struct { + OK bool `json:"ok"` + Key string `json:"key"` +} + +// SessionsPatch updates label/model/metadata on a session. +func (ws *WSClient) SessionsPatch(params SessionsPatchParams) (*SessionsPatchResult, error) { + payload, err := ws.Call("sessions.patch", params) + if err != nil { + return nil, err + } + var result SessionsPatchResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// SessionsKeyParams are the params shared by sessions.delete and sessions.reset. +type SessionsKeyParams struct { + Key string `json:"key"` +} + +// SessionsOKResult is a generic {ok:true} response. +type SessionsOKResult struct { + OK bool `json:"ok"` +} + +// SessionsDelete deletes a session. +func (ws *WSClient) SessionsDelete(params SessionsKeyParams) (*SessionsOKResult, error) { + payload, err := ws.Call("sessions.delete", params) + if err != nil { + return nil, err + } + var result SessionsOKResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// SessionsReset resets a session's transcript. +func (ws *WSClient) SessionsReset(params SessionsKeyParams) (*SessionsOKResult, error) { + payload, err := ws.Call("sessions.reset", params) + if err != nil { + return nil, err + } + var result SessionsOKResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// SessionsCompactParams are the params for sessions.compact. +type SessionsCompactParams struct { + Key string `json:"key"` + KeepLast int `json:"keepLast,omitempty"` +} + +// SessionsCompactResult is the response payload for sessions.compact. +type SessionsCompactResult struct { + OK bool `json:"ok"` + Original int `json:"original,omitempty"` + Kept int `json:"kept"` + Message string `json:"message,omitempty"` +} + +// SessionsCompact compacts a session's history, keeping only the most recent messages. +func (ws *WSClient) SessionsCompact(params SessionsCompactParams) (*SessionsCompactResult, error) { + payload, err := ws.Call("sessions.compact", params) + if err != nil { + return nil, err + } + var result SessionsCompactResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/client/skills.go b/client/skills.go new file mode 100644 index 0000000..05973c5 --- /dev/null +++ b/client/skills.go @@ -0,0 +1,94 @@ +package client + +import "encoding/json" + +// SkillInfo describes a skill as returned by skills.list. Field names are +// snake_case, matching the server's skills.list response (unlike most other +// methods, which use camelCase). +type SkillInfo struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Source string `json:"source"` + Version string `json:"version"` + IsSystem bool `json:"is_system"` + Enabled bool `json:"enabled"` + Visibility string `json:"visibility,omitempty"` + Tags []string `json:"tags,omitempty"` + Status string `json:"status,omitempty"` + Author string `json:"author,omitempty"` + CreatorAgent *string `json:"creator_agent,omitempty"` + ManagerAgents []string `json:"manager_agents,omitempty"` + MissingDeps []string `json:"missing_deps,omitempty"` + TenantEnabled *bool `json:"tenant_enabled,omitempty"` +} + +// SkillsListResult is the response payload for skills.list. +type SkillsListResult struct { + Skills []SkillInfo `json:"skills"` +} + +// SkillsList lists skills visible to the caller. +func (ws *WSClient) SkillsList() (*SkillsListResult, error) { + payload, err := ws.Call("skills.list", nil) + if err != nil { + return nil, err + } + var result SkillsListResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// SkillsGetParams are the params for skills.get. +type SkillsGetParams struct { + Name string `json:"name"` +} + +// SkillsGetResult is the response payload for skills.get. Embeds SkillInfo's +// fields (snake_case) plus the skill's raw content. +type SkillsGetResult struct { + SkillInfo + Content string `json:"content"` +} + +// SkillsGet fetches a skill's metadata and content. +func (ws *WSClient) SkillsGet(params SkillsGetParams) (*SkillsGetResult, error) { + payload, err := ws.Call("skills.get", params) + if err != nil { + return nil, err + } + var result SkillsGetResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} + +// SkillsUpdateParams are the params for skills.update. +type SkillsUpdateParams struct { + Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` + Updates map[string]any `json:"updates"` +} + +// SkillsUpdateResult is the response payload for skills.update. Note: the +// server returns the string literal "true"/"false" for "ok", not a JSON bool. +type SkillsUpdateResult struct { + OK string `json:"ok"` +} + +// SkillsUpdate updates a skill's metadata by name or id. +func (ws *WSClient) SkillsUpdate(params SkillsUpdateParams) (*SkillsUpdateResult, error) { + payload, err := ws.Call("skills.update", params) + if err != nil { + return nil, err + } + var result SkillsUpdateResult + if err := json.Unmarshal(payload, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/client/websocket.go b/client/websocket.go index 26b7a65..3a6cf71 100644 --- a/client/websocket.go +++ b/client/websocket.go @@ -15,12 +15,16 @@ import ( // WSClient is a WebSocket RPC client implementing GoClaw protocol v3. type WSClient struct { - conn *websocket.Conn - serverURL string - token string - userID string - senderID string - insecure bool + conn *websocket.Conn + serverURL string + token string + userID string + senderID string + locale string + tenantHint string + tenantID string + tenantScope string + insecure bool nextID atomic.Int64 mu sync.Mutex // protects pending and listeners @@ -55,6 +59,26 @@ type WSEvent struct { Payload json.RawMessage `json:"payload,omitempty"` } +// ConnectServerInfo describes the server identity returned by the connect handshake. +type ConnectServerInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// ConnectResult is the response payload for the connect handshake. +type ConnectResult struct { + Protocol int `json:"protocol"` + Role string `json:"role"` + UserID string `json:"user_id"` + TenantID string `json:"tenant_id"` + IsOwner bool `json:"is_owner"` + IsMasterScope bool `json:"is_master_scope"` + Edition string `json:"edition"` + Server ConnectServerInfo `json:"server"` + TenantName string `json:"tenant_name,omitempty"` + TenantSlug string `json:"tenant_slug,omitempty"` +} + // NewWSClient creates a WebSocket client. func NewWSClient(serverURL, token, userID string, insecure bool) *WSClient { return &WSClient{ @@ -68,6 +92,26 @@ func NewWSClient(serverURL, token, userID string, insecure bool) *WSClient { } } +// SetLocale sets the user's preferred locale (en, vi, zh) sent on connect. +func (ws *WSClient) SetLocale(locale string) { + ws.locale = locale +} + +// SetTenantHint sets an optional tenant slug hint sent on connect (browser pairing multi-tenant). +func (ws *WSClient) SetTenantHint(hint string) { + ws.tenantHint = hint +} + +// SetTenantID sets the tenant scope (UUID or slug) sent on connect for cross-tenant admin callers. +func (ws *WSClient) SetTenantID(tenantID string) { + ws.tenantID = tenantID +} + +// SetTenantScope sets the deprecated tenant_scope alias for tenant_id, sent on connect. +func (ws *WSClient) SetTenantScope(scope string) { + ws.tenantScope = scope +} + // Connect establishes WebSocket connection and performs handshake. func (ws *WSClient) Connect() (*json.RawMessage, error) { wsURL := strings.Replace(ws.serverURL, "http://", "ws://", 1) @@ -91,7 +135,7 @@ func (ws *WSClient) Connect() (*json.RawMessage, error) { // Start read loop go ws.readLoop() - // Send connect handshake + // Send connect handshake. Params are snake_case per protocol v3. params := map[string]any{ "user_id": ws.userID, } @@ -101,6 +145,18 @@ func (ws *WSClient) Connect() (*json.RawMessage, error) { if ws.senderID != "" { params["sender_id"] = ws.senderID } + if ws.locale != "" { + params["locale"] = ws.locale + } + if ws.tenantHint != "" { + params["tenant_hint"] = ws.tenantHint + } + if ws.tenantID != "" { + params["tenant_id"] = ws.tenantID + } + if ws.tenantScope != "" { + params["tenant_scope"] = ws.tenantScope + } resp, err := ws.Call("connect", params) if err != nil { From 1bdaace97d1d34f63aa25ceb50d06e1049471eec Mon Sep 17 00:00:00 2001 From: Bruno Clermont Date: Sat, 4 Jul 2026 13:32:02 -0400 Subject: [PATCH 3/3] fix(cmd): use typed client wrappers, fix remaining stale field names and config.patch shape WS RPC calls in cmd/*.go still sent legacy snake_case fields (agent_key, session_key) that the gateway no longer accepts (it expects agentId/ sessionKey), and config.patch/apply sent {key,value} instead of the real {raw,baseHash} JSON5-diff contract. Migrated call sites to the typed client wrappers (ChatSend/ChatHistory/ChatInject/ChatSessionStatus, SessionsCompact/SessionsReset, AgentWait, Cron*, Config*) so the wire shape matches internal/gateway/methods on the server, and updated tests to match the corrected contracts. --- cmd/agents_lifecycle.go | 30 +++++------ cmd/agents_lifecycle_test.go | 12 ++--- cmd/chat.go | 40 ++++++--------- cmd/chat_ai_commands.go | 78 +++++++++++++--------------- cmd/chat_extensions_test.go | 40 ++++++++------- cmd/chat_replay.go | 23 ++++----- cmd/config_cmd.go | 57 ++++++++++----------- cmd/config_cmd_test.go | 23 ++++++--- cmd/cron.go | 98 ++++++++++++++++++------------------ cmd/p4_ux_polish_test.go | 13 +++-- cmd/sessions.go | 5 +- cmd/teams_tasks_advanced.go | 2 +- 12 files changed, 202 insertions(+), 219 deletions(-) diff --git a/cmd/agents_lifecycle.go b/cmd/agents_lifecycle.go index fa34bf3..ad3fa40 100644 --- a/cmd/agents_lifecycle.go +++ b/cmd/agents_lifecycle.go @@ -8,6 +8,7 @@ import ( "syscall" "time" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/spf13/cobra" ) @@ -44,9 +45,8 @@ WS method: agent.wait Response schema: { - "agent_key": "string", - "state": "online|running|idle|offline", - "reached_at": "RFC3339 timestamp" + "id": "string", + "status": "idle" } Examples: @@ -84,20 +84,17 @@ Examples: } defer ws.Close() - params := map[string]any{"agent_key": args[0]} - if state != "" { - params["state"] = state - } + params := client.AgentParams{AgentID: args[0]} // Call is blocking on the server side until state matches or server times out. // We wrap with our own context deadline. type result struct { - data []byte + data *client.AgentWaitResult err error } done := make(chan result, 1) go func() { - data, err := ws.Call("agent.wait", params) + data, err := ws.AgentWait(params) done <- result{data, err} }() @@ -120,7 +117,7 @@ Examples: if r.err != nil { return r.err } - printer.Print(unmarshalMap(r.data)) + printer.Print(r.data) return nil } }, @@ -135,12 +132,11 @@ WS method: agent.identity.get Response schema: { - "agent_key": "string", - "display_name": "string", - "persona": "string", - "traits": ["string", ...], - "goals": ["string", ...], - "constraints": ["string", ...] + "agentId": "string", + "name": "string", + "emoji": "string", + "avatar": "string", + "description": "string" } Examples: @@ -157,7 +153,7 @@ Examples: } defer ws.Close() - data, err := ws.Call("agent.identity.get", map[string]any{"agent_key": args[0]}) + data, err := ws.Call("agent.identity.get", map[string]any{"agentId": args[0]}) if err != nil { return err } diff --git a/cmd/agents_lifecycle_test.go b/cmd/agents_lifecycle_test.go index 0fa0852..4328fbf 100644 --- a/cmd/agents_lifecycle_test.go +++ b/cmd/agents_lifecycle_test.go @@ -100,10 +100,9 @@ func TestAgentsWake(t *testing.T) { func TestAgentsIdentity_ReturnsIdentityJSON(t *testing.T) { srv := mockAgentsWSServer(t, map[string]any{ "agent.identity.get": map[string]any{ - "agent_key": "my-agent", - "display_name": "My Agent", - "persona": "helpful assistant", - "traits": []string{"curious", "precise"}, + "agentId": "my-agent", + "name": "My Agent", + "description": "helpful assistant", }, }) defer srv.Close() @@ -119,9 +118,8 @@ func TestAgentsIdentity_ReturnsIdentityJSON(t *testing.T) { func TestAgentsWait_SuccessfulStateMatch(t *testing.T) { srv := mockAgentsWSServer(t, map[string]any{ "agent.wait": map[string]any{ - "agent_key": "my-agent", - "state": "idle", - "reached_at": "2024-01-01T00:00:00Z", + "id": "my-agent", + "status": "idle", }, }) defer srv.Close() diff --git a/cmd/chat.go b/cmd/chat.go index 862f45d..3d8db7c 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -63,34 +63,28 @@ func chatSingleShot(agentKey, message, session string, noStream bool) error { } defer ws.Close() - params := map[string]any{ - "agent_key": agentKey, - "message": message, - } - if session != "" { - params["session_key"] = session + params := client.ChatSendParams{ + AgentID: agentKey, + Message: message, + SessionKey: session, } if noStream || cfg.OutputFormat == "json" { // Non-streaming: collect full response - resp, err := ws.Call("chat.send", params) + result, err := ws.ChatSend(params) if err != nil { return err } if cfg.OutputFormat == "json" { - printer.Print(unmarshalMap(resp)) + printer.Print(result) } else { - var result struct { - Content string `json:"content"` - } - _ = json.Unmarshal(resp, &result) fmt.Println(result.Content) } return nil } // Streaming mode - _, err = ws.Stream("chat.send", params, func(e *client.WSEvent) { + _, err = ws.ChatSendStream(params, func(e *client.WSEvent) { if cfg.OutputFormat == "json" { // NDJSON output line := map[string]any{"event": e.Event, "data": unmarshalMap(e.Payload)} @@ -151,34 +145,32 @@ func chatInteractive(agentKey, session string) error { fmt.Println("Goodbye!") return nil case "/abort": - _, _ = ws.Call("chat.abort", map[string]any{"agent_key": agentKey}) + _, _ = ws.ChatAbort(client.ChatAbortParams{SessionKey: session}) fmt.Println("[aborted]") continue case "/sessions": - resp, err := ws.Call("sessions.list", map[string]any{"agent_key": agentKey}) + result, err := ws.SessionsList(client.SessionsListParams{AgentID: agentKey}) if err != nil { fmt.Printf("Error: %s\n", err) continue } - fmt.Println(string(resp)) + fmt.Println(string(result.Sessions)) continue case "/clear": if session != "" { - _, _ = ws.Call("sessions.reset", map[string]any{"session_key": session}) + _, _ = ws.SessionsReset(client.SessionsKeyParams{Key: session}) fmt.Println("[session cleared]") } continue } - params := map[string]any{ - "agent_key": agentKey, - "message": input, - } - if session != "" { - params["session_key"] = session + params := client.ChatSendParams{ + AgentID: agentKey, + Message: input, + SessionKey: session, } - _, err := ws.Stream("chat.send", params, func(e *client.WSEvent) { + _, err := ws.ChatSendStream(params, func(e *client.WSEvent) { switch e.Event { case "chunk": var chunk struct { diff --git a/cmd/chat_ai_commands.go b/cmd/chat_ai_commands.go index 1cced47..e176887 100644 --- a/cmd/chat_ai_commands.go +++ b/cmd/chat_ai_commands.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/spf13/cobra" ) @@ -17,16 +18,13 @@ var chatHistoryCmd = &cobra.Command{ WS method: chat.history -Response schema (array of message objects): - [ - { - "role": "user|assistant|system", - "content": "string", - "created_at": "RFC3339 timestamp", - "session_key": "string" - }, - ... - ] +Response schema: + { + "messages": [ + {"role": "user|assistant|system", "content": "string", ...}, + ... + ] + } Flags: --limit N Maximum messages to return (default: 50) @@ -60,16 +58,14 @@ This endpoint requires admin-level permissions on the server. WS method: chat.inject Request fields: - agent_key string Agent key (required) - role string "user", "assistant", or "system" (required) - content string Message content (required) - session_key string Target session key (optional) + sessionKey string Target session key (required) + message string Message content (required) + label string Optional label (role is used as label) Response schema: { - "injected": true, - "message_id": "string", - "session_key": "string" + "ok": true, + "messageId": "string" } Examples: @@ -93,6 +89,9 @@ Examples: if content == "" { return fmt.Errorf("--content is required and must not be empty") } + if session == "" { + return fmt.Errorf("--session is required") + } ws, err := newWS("cli") if err != nil { @@ -103,20 +102,15 @@ Examples: } defer ws.Close() - params := map[string]any{ - "agent_key": args[0], - "role": role, - "content": content, - } - if session != "" { - params["session_key"] = session - } - - data, err := ws.Call("chat.inject", params) + result, err := ws.ChatInject(client.ChatInjectParams{ + SessionKey: session, + Message: content, + Label: role, + }) if err != nil { return err } - printer.Print(unmarshalMap(data)) + printer.Print(result) return nil }, } @@ -130,21 +124,20 @@ WS method: chat.session.status Response schema: { - "agent_key": "string", - "session_key": "string", - "state": "idle|running|waiting|error", - "turn_count": 42, - "last_active": "RFC3339 timestamp", - "model": "string" + "isRunning": true, + "runId": "string", + "activity": {"phase": "string", "tool": "string", "iteration": 1} } Examples: - goclaw chat session-status my-agent - goclaw chat session-status my-agent --output=json | jq '.state' - goclaw chat session-status my-agent --session=sess-key-1`, + goclaw chat session-status my-agent --session=sess-key-1 + goclaw chat session-status my-agent --output=json | jq '.isRunning'`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { session, _ := cmd.Flags().GetString("session") + if session == "" { + return fmt.Errorf("--session is required") + } ws, err := newWS("cli") if err != nil { @@ -155,16 +148,11 @@ Examples: } defer ws.Close() - params := map[string]any{"agent_key": args[0]} - if session != "" { - params["session_key"] = session - } - - data, err := ws.Call("chat.session.status", params) + result, err := ws.ChatSessionStatus(client.ChatSessionStatusParams{SessionKey: session}) if err != nil { return err } - printer.Print(unmarshalMap(data)) + printer.Print(result) return nil }, } @@ -181,9 +169,11 @@ func init() { chatInjectCmd.Flags().String("content", "", "Message content (or @filepath)") _ = chatInjectCmd.MarkFlagRequired("content") chatInjectCmd.Flags().String("session", "", "Target session key") + _ = chatInjectCmd.MarkFlagRequired("session") // chat session-status flags chatSessionStatusCmd.Flags().String("session", "", "Session key to query") + _ = chatSessionStatusCmd.MarkFlagRequired("session") chatCmd.AddCommand(chatHistoryCmd, chatInjectCmd, chatSessionStatusCmd) } diff --git a/cmd/chat_extensions_test.go b/cmd/chat_extensions_test.go index c03bc95..4de6fca 100644 --- a/cmd/chat_extensions_test.go +++ b/cmd/chat_extensions_test.go @@ -67,10 +67,12 @@ func setupChatTest(serverURL string) { func TestChatHistory_ReturnsMessageArray(t *testing.T) { messages := []any{ - map[string]any{"role": "user", "content": "Hello", "created_at": "2024-01-01T00:00:00Z"}, - map[string]any{"role": "assistant", "content": "Hi there!", "created_at": "2024-01-01T00:00:01Z"}, + map[string]any{"role": "user", "content": "Hello", "createdAt": "2024-01-01T00:00:00Z"}, + map[string]any{"role": "assistant", "content": "Hi there!", "createdAt": "2024-01-01T00:00:01Z"}, } - srv := mockChatServer(t, map[string]any{"chat.history": messages}) + srv := mockChatServer(t, map[string]any{ + "chat.history": map[string]any{"messages": messages}, + }) defer srv.Close() setupChatTest(srv.URL) @@ -85,7 +87,9 @@ func TestChatHistory_ReturnsMessageArray(t *testing.T) { } func TestChatHistory_WithLimitAndBefore(t *testing.T) { - srv := mockChatServer(t, map[string]any{"chat.history": []any{}}) + srv := mockChatServer(t, map[string]any{ + "chat.history": map[string]any{"messages": []any{}}, + }) defer srv.Close() setupChatTest(srv.URL) @@ -99,7 +103,9 @@ func TestChatHistory_WithLimitAndBefore(t *testing.T) { } func TestChatHistory_WithSession(t *testing.T) { - srv := mockChatServer(t, map[string]any{"chat.history": []any{}}) + srv := mockChatServer(t, map[string]any{ + "chat.history": map[string]any{"messages": []any{}}, + }) defer srv.Close() setupChatTest(srv.URL) @@ -116,14 +122,14 @@ func TestChatHistory_WithSession(t *testing.T) { func TestChatInject_UserRole(t *testing.T) { srv := mockChatServer(t, map[string]any{ - "chat.inject": map[string]any{"injected": true, "message_id": "msg-1"}, + "chat.inject": map[string]any{"ok": true, "messageId": "msg-1"}, }) defer srv.Close() setupChatTest(srv.URL) chatInjectCmd.Flags().Set("role", "user") chatInjectCmd.Flags().Set("content", "Test message") - chatInjectCmd.Flags().Set("session", "") + chatInjectCmd.Flags().Set("session", "sess-1") if err := chatInjectCmd.RunE(chatInjectCmd, []string{"my-agent"}); err != nil { t.Fatalf("unexpected error: %v", err) @@ -132,14 +138,14 @@ func TestChatInject_UserRole(t *testing.T) { func TestChatInject_SystemRole(t *testing.T) { srv := mockChatServer(t, map[string]any{ - "chat.inject": map[string]any{"injected": true, "message_id": "msg-2"}, + "chat.inject": map[string]any{"ok": true, "messageId": "msg-2"}, }) defer srv.Close() setupChatTest(srv.URL) chatInjectCmd.Flags().Set("role", "system") chatInjectCmd.Flags().Set("content", "You are a helpful assistant.") - chatInjectCmd.Flags().Set("session", "") + chatInjectCmd.Flags().Set("session", "sess-2") if err := chatInjectCmd.RunE(chatInjectCmd, []string{"my-agent"}); err != nil { t.Fatalf("unexpected error: %v", err) @@ -153,7 +159,7 @@ func TestChatInject_InvalidRole(t *testing.T) { chatInjectCmd.Flags().Set("role", "bot") // invalid chatInjectCmd.Flags().Set("content", "hello") - chatInjectCmd.Flags().Set("session", "") + chatInjectCmd.Flags().Set("session", "sess-1") err := chatInjectCmd.RunE(chatInjectCmd, []string{"my-agent"}) if err == nil { @@ -171,7 +177,7 @@ func TestChatInject_EmptyContent(t *testing.T) { chatInjectCmd.Flags().Set("role", "user") chatInjectCmd.Flags().Set("content", "") // empty — invalid after readContent - chatInjectCmd.Flags().Set("session", "") + chatInjectCmd.Flags().Set("session", "sess-1") err := chatInjectCmd.RunE(chatInjectCmd, []string{"my-agent"}) if err == nil { @@ -181,7 +187,7 @@ func TestChatInject_EmptyContent(t *testing.T) { func TestChatInject_WithSession(t *testing.T) { srv := mockChatServer(t, map[string]any{ - "chat.inject": map[string]any{"injected": true, "session_key": "sess-1"}, + "chat.inject": map[string]any{"ok": true, "messageId": "msg-3"}, }) defer srv.Close() setupChatTest(srv.URL) @@ -200,16 +206,14 @@ func TestChatInject_WithSession(t *testing.T) { func TestChatSessionStatus_ReturnsState(t *testing.T) { srv := mockChatServer(t, map[string]any{ "chat.session.status": map[string]any{ - "agent_key": "my-agent", - "state": "idle", - "turn_count": 5, - "last_active": "2024-01-01T12:00:00Z", + "isRunning": false, + "runId": "", }, }) defer srv.Close() setupChatTest(srv.URL) - chatSessionStatusCmd.Flags().Set("session", "") + chatSessionStatusCmd.Flags().Set("session", "sess-1") if err := chatSessionStatusCmd.RunE(chatSessionStatusCmd, []string{"my-agent"}); err != nil { t.Fatalf("unexpected error: %v", err) @@ -219,7 +223,7 @@ func TestChatSessionStatus_ReturnsState(t *testing.T) { func TestChatSessionStatus_WithSession(t *testing.T) { srv := mockChatServer(t, map[string]any{ "chat.session.status": map[string]any{ - "state": "running", "session_key": "sess-42", + "isRunning": true, "runId": "run-42", }, }) defer srv.Close() diff --git a/cmd/chat_replay.go b/cmd/chat_replay.go index 4d988fb..0d12211 100644 --- a/cmd/chat_replay.go +++ b/cmd/chat_replay.go @@ -1,6 +1,9 @@ package cmd -import "github.com/spf13/cobra" +import ( + "github.com/nextlevelbuilder/goclaw-cli/client" + "github.com/spf13/cobra" +) var chatReplayCmd = &cobra.Command{ Use: "replay ", @@ -24,22 +27,14 @@ func runChatHistory(agent, session, before string, limit int) error { } 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) + result, err := ws.ChatHistory(client.ChatHistoryParams{ + AgentID: agent, + SessionKey: session, + }) if err != nil { return err } - printer.Print(unmarshalList(data)) + printer.Print(unmarshalList(result.Messages)) return nil } diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index 25a4729..82f3294 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -1,10 +1,10 @@ package cmd import ( - "encoding/json" "fmt" "os" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/nextlevelbuilder/goclaw-cli/internal/tui" "github.com/spf13/cobra" ) @@ -22,15 +22,11 @@ var configGetCmd = &cobra.Command{ return err } defer ws.Close() - params := map[string]any{} - if v, _ := cmd.Flags().GetString("key"); v != "" { - params["key"] = v - } - data, err := ws.Call("config.get", params) + result, err := ws.ConfigGet() if err != nil { return err } - printer.Print(unmarshalMap(data)) + printer.Print(result) return nil }, } @@ -51,11 +47,11 @@ var configApplyCmd = &cobra.Command{ if err != nil { return fmt.Errorf("read config file: %w", err) } - var cfg map[string]any - if err := json.Unmarshal(data, &cfg); err != nil { - return fmt.Errorf("parse config: %w", err) - } - _, err = ws.Call("config.apply", cfg) + baseHash, _ := cmd.Flags().GetString("base-hash") + _, err = ws.ConfigApply(client.ConfigApplyParams{ + Raw: string(data), + BaseHash: baseHash, + }) if err != nil { return err } @@ -65,7 +61,7 @@ var configApplyCmd = &cobra.Command{ } var configPatchCmd = &cobra.Command{ - Use: "patch", Short: "Patch a config key", + Use: "patch", Short: "Patch config with a partial JSON5 file", RunE: func(cmd *cobra.Command, args []string) error { ws, err := newWS("cli") if err != nil { @@ -75,20 +71,20 @@ var configPatchCmd = &cobra.Command{ return err } defer ws.Close() - key, _ := cmd.Flags().GetString("key") - value, _ := cmd.Flags().GetString("value") - - // Try to parse value as JSON, fall back to string - var parsedVal any - if err := json.Unmarshal([]byte(value), &parsedVal); err != nil { - parsedVal = value + filePath, _ := cmd.Flags().GetString("file") + data, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("read patch file: %w", err) } - - _, err = ws.Call("config.patch", map[string]any{"key": key, "value": parsedVal}) + baseHash, _ := cmd.Flags().GetString("base-hash") + _, err = ws.ConfigPatch(client.ConfigApplyParams{ + Raw: string(data), + BaseHash: baseHash, + }) if err != nil { return err } - printer.Success(fmt.Sprintf("Config %s updated", key)) + printer.Success("Configuration patched") return nil }, } @@ -104,11 +100,11 @@ var configSchemaCmd = &cobra.Command{ return err } defer ws.Close() - data, err := ws.Call("config.schema", nil) + result, err := ws.ConfigSchema() if err != nil { return err } - printer.Print(unmarshalMap(data)) + printer.Print(result) return nil }, } @@ -233,13 +229,12 @@ Example: } func init() { - configGetCmd.Flags().String("key", "", "Config key path") - configApplyCmd.Flags().String("file", "", "Config JSON file") + configApplyCmd.Flags().String("file", "", "Config JSON5 file (full config)") _ = configApplyCmd.MarkFlagRequired("file") - configPatchCmd.Flags().String("key", "", "Config key") - configPatchCmd.Flags().String("value", "", "Config value") - _ = configPatchCmd.MarkFlagRequired("key") - _ = configPatchCmd.MarkFlagRequired("value") + configApplyCmd.Flags().String("base-hash", "", "Base config hash for optimistic concurrency (from config get)") + configPatchCmd.Flags().String("file", "", "Partial config JSON5 file to merge") + _ = configPatchCmd.MarkFlagRequired("file") + configPatchCmd.Flags().String("base-hash", "", "Base config hash for optimistic concurrency (from config get)") // Permissions list configPermissionsListCmd.Flags().String("agent", "", "Agent key or ID") diff --git a/cmd/config_cmd_test.go b/cmd/config_cmd_test.go index c0aa786..7bd79ae 100644 --- a/cmd/config_cmd_test.go +++ b/cmd/config_cmd_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "github.com/gorilla/websocket" @@ -44,18 +46,21 @@ func setupConfigTest(serverURL string) { } func TestConfigGet(t *testing.T) { - srv := mockConfigWSServer(t, map[string]any{"key": "agent.model", "value": "gpt-4o"}) + srv := mockConfigWSServer(t, map[string]any{ + "config": map[string]any{"agent": map[string]any{"model": "gpt-4o"}}, + "hash": "h1", + "path": "/config.json5", + }) defer srv.Close() setupConfigTest(srv.URL) - configGetCmd.Flags().Set("key", "agent.model") if err := configGetCmd.RunE(configGetCmd, nil); err != nil { t.Fatalf("unexpected error: %v", err) } } func TestConfigSchema(t *testing.T) { - srv := mockConfigWSServer(t, map[string]any{"schema": map[string]any{}}) + srv := mockConfigWSServer(t, map[string]any{"json": map[string]any{}}) defer srv.Close() setupConfigTest(srv.URL) @@ -65,12 +70,18 @@ func TestConfigSchema(t *testing.T) { } func TestConfigPatch(t *testing.T) { - srv := mockConfigWSServer(t, map[string]any{"ok": true}) + srv := mockConfigWSServer(t, map[string]any{ + "ok": true, "path": "/config.json5", "config": map[string]any{}, "hash": "h2", "restart": false, + }) defer srv.Close() setupConfigTest(srv.URL) - configPatchCmd.Flags().Set("key", "agent.model") - configPatchCmd.Flags().Set("value", "gpt-4o") + patchFile := filepath.Join(t.TempDir(), "patch.json5") + if err := os.WriteFile(patchFile, []byte(`{agent: {model: "gpt-4o"}}`), 0o600); err != nil { + t.Fatalf("write patch file: %v", err) + } + configPatchCmd.Flags().Set("file", patchFile) + configPatchCmd.Flags().Set("base-hash", "") if err := configPatchCmd.RunE(configPatchCmd, nil); err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/cmd/cron.go b/cmd/cron.go index b2414cb..761d995 100644 --- a/cmd/cron.go +++ b/cmd/cron.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/nextlevelbuilder/goclaw-cli/internal/tui" "github.com/spf13/cobra" @@ -13,36 +14,29 @@ var cronCmd = &cobra.Command{Use: "cron", Short: "Manage scheduled jobs"} var cronListCmd = &cobra.Command{ Use: "list", Short: "List cron jobs", RunE: func(cmd *cobra.Command, args []string) error { - c, err := newHTTP() + ws, err := newWS("cli") if err != nil { return err } - ws, wsErr := newWS("cli") - if wsErr != nil { - return wsErr - } if _, err := ws.Connect(); err != nil { return err } defer ws.Close() - data, err := ws.Call("cron.list", nil) + result, err := ws.CronList(client.CronListParams{}) if err != nil { - // Fallback to HTTP if WS method unavailable - _ = c return err } if cfg.OutputFormat != "table" { - printer.Print(unmarshalList(data)) + printer.Print(result.Jobs) return nil } - tbl := output.NewTable("ID", "NAME", "AGENT", "SCHEDULE", "ENABLED", "LAST_STATUS", "NEXT_RUN") - for _, j := range unmarshalList(data) { - schedule := str(j, "cron_expression") - if schedule == "" { - schedule = str(j, "interval_ms") + "ms" + tbl := output.NewTable("ID", "NAME", "AGENT", "SCHEDULE", "ENABLED", "DELIVER_CHANNEL") + for _, j := range result.Jobs { + schedule := j.Schedule.Expr + if schedule == "" && j.Schedule.EveryMS != nil { + schedule = fmt.Sprintf("%dms", *j.Schedule.EveryMS) } - tbl.AddRow(str(j, "id"), str(j, "name"), str(j, "agent_id"), - schedule, str(j, "enabled"), str(j, "last_status"), str(j, "next_run_at")) + tbl.AddRow(j.ID, j.Name, j.AgentID, schedule, fmt.Sprintf("%v", j.Enabled), j.DeliverChannel) } printer.Print(tbl) return nil @@ -60,12 +54,17 @@ var cronGetCmd = &cobra.Command{ return err } defer ws.Close() - data, err := ws.Call("cron.status", map[string]any{"id": args[0]}) + result, err := ws.CronList(client.CronListParams{IncludeDisabled: true}) if err != nil { return err } - printer.Print(unmarshalMap(data)) - return nil + for _, j := range result.Jobs { + if j.ID == args[0] { + printer.Print(j) + return nil + } + } + return fmt.Errorf("cron job %q not found", args[0]) }, } @@ -87,17 +86,22 @@ var cronCreateCmd = &cobra.Command{ message, _ := cmd.Flags().GetString("message") timezone, _ := cmd.Flags().GetString("timezone") - params := buildBody("agent_id", agent, "name", name, - "cron_expression", schedule, "timezone", timezone) - if message != "" { - params["payload"] = map[string]any{"message": message} + params := client.CronCreateParams{ + Name: name, + AgentID: agent, + Message: message, + Schedule: client.CronSchedule{ + Kind: "cron", + Expr: schedule, + TZ: timezone, + }, } - data, err := ws.Call("cron.create", params) + result, err := ws.CronCreate(params) if err != nil { return err } - printer.Success(fmt.Sprintf("Cron job created: %s", str(unmarshalMap(data), "id"))) + printer.Success(fmt.Sprintf("Cron job created: %s", result.Job.ID)) return nil }, } @@ -113,16 +117,16 @@ var cronUpdateCmd = &cobra.Command{ return err } defer ws.Close() - params := map[string]any{"id": args[0]} + patch := client.CronJobPatch{} if cmd.Flags().Changed("name") { v, _ := cmd.Flags().GetString("name") - params["name"] = v + patch.Name = v } if cmd.Flags().Changed("schedule") { v, _ := cmd.Flags().GetString("schedule") - params["cron_expression"] = v + patch.Schedule = &client.CronSchedule{Kind: "cron", Expr: v} } - _, err = ws.Call("cron.update", params) + _, err = ws.CronUpdate(client.CronUpdateParams{JobID: args[0], Patch: patch}) if err != nil { return err } @@ -145,7 +149,7 @@ var cronDeleteCmd = &cobra.Command{ return err } defer ws.Close() - _, err = ws.Call("cron.delete", map[string]any{"id": args[0]}) + _, err = ws.CronDelete(client.CronJobIDParams{JobID: args[0]}) if err != nil { return err } @@ -157,6 +161,7 @@ var cronDeleteCmd = &cobra.Command{ var cronToggleCmd = &cobra.Command{ Use: "toggle ", Short: "Enable/disable cron job", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + enabled, _ := cmd.Flags().GetBool("enabled") ws, err := newWS("cli") if err != nil { return err @@ -165,7 +170,7 @@ var cronToggleCmd = &cobra.Command{ return err } defer ws.Close() - _, err = ws.Call("cron.toggle", map[string]any{"id": args[0]}) + _, err = ws.CronToggle(client.CronToggleParams{JobID: args[0], Enabled: enabled}) if err != nil { return err } @@ -185,7 +190,7 @@ var cronRunCmd = &cobra.Command{ return err } defer ws.Close() - _, err = ws.Call("cron.run", map[string]any{"id": args[0]}) + _, err = ws.CronRun(client.CronRunParams{JobID: args[0], Mode: "force"}) if err != nil { return err } @@ -205,12 +210,17 @@ var cronStatusCmd = &cobra.Command{ return err } defer ws.Close() - data, err := ws.Call("cron.status", map[string]any{"id": args[0]}) + result, err := ws.CronList(client.CronListParams{IncludeDisabled: true}) if err != nil { return err } - printer.Print(unmarshalMap(data)) - return nil + for _, j := range result.Jobs { + if j.ID == args[0] { + printer.Print(j.State) + return nil + } + } + return fmt.Errorf("cron job %q not found", args[0]) }, } @@ -226,24 +236,11 @@ var cronRunsCmd = &cobra.Command{ } defer ws.Close() limit, _ := cmd.Flags().GetInt("limit") - params := map[string]any{"id": args[0]} - if limit > 0 { - params["limit"] = limit - } - data, err := ws.Call("cron.runs", params) + result, err := ws.CronRuns(client.CronRunsParams{JobID: args[0], Limit: limit}) if err != nil { return err } - if cfg.OutputFormat != "table" { - printer.Print(unmarshalList(data)) - return nil - } - tbl := output.NewTable("RUN_ID", "STATUS", "STARTED", "DURATION_MS", "TOKENS") - for _, r := range unmarshalList(data) { - tbl.AddRow(str(r, "id"), str(r, "status"), str(r, "started_at"), - str(r, "duration_ms"), str(r, "total_tokens")) - } - printer.Print(tbl) + printer.Print(result) return nil }, } @@ -260,6 +257,7 @@ func init() { cronUpdateCmd.Flags().String("name", "", "Job name") cronUpdateCmd.Flags().String("schedule", "", "Cron expression") + cronToggleCmd.Flags().Bool("enabled", true, "Enable or disable the job") cronRunsCmd.Flags().Int("limit", 20, "Max results") cronCmd.AddCommand(cronListCmd, cronGetCmd, cronCreateCmd, cronUpdateCmd, diff --git a/cmd/p4_ux_polish_test.go b/cmd/p4_ux_polish_test.go index 9e4e500..b494294 100644 --- a/cmd/p4_ux_polish_test.go +++ b/cmd/p4_ux_polish_test.go @@ -386,8 +386,8 @@ func TestChatReplayAndResumeUseExistingWSContracts(t *testing.T) { } <-seen // connect history := <-seen - if history.Method != "chat.history" || history.Params["agent_key"] != "agent-1" || - history.Params["session_key"] != "sess-1" { + if history.Method != "chat.history" || history.Params["agentId"] != "agent-1" || + history.Params["sessionKey"] != "sess-1" { t.Fatalf("history call = %#v", history) } @@ -399,8 +399,8 @@ func TestChatReplayAndResumeUseExistingWSContracts(t *testing.T) { } <-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" { + if send.Method != "chat.send" || send.Params["agentId"] != "agent-1" || + send.Params["sessionKey"] != "sess-2" || send.Params["message"] != "hi" { t.Fatalf("send call = %#v", send) } } @@ -458,8 +458,11 @@ func mockCaptureRPCServer(t *testing.T, seen chan<- wsCall) *httptest.Server { } seen <- wsCall{Method: req.Method, Params: req.Params} payload, _ := json.Marshal([]map[string]any{{"content": "ok"}}) - if req.Method == "chat.send" || req.Method == "connect" { + switch req.Method { + case "chat.send", "connect": payload, _ = json.Marshal(map[string]any{"content": "ok"}) + case "chat.history": + payload, _ = json.Marshal(map[string]any{"messages": []map[string]any{{"content": "ok"}}}) } _ = conn.WriteJSON(map[string]any{ "type": "res", diff --git a/cmd/sessions.go b/cmd/sessions.go index 000297b..f27d7fe 100644 --- a/cmd/sessions.go +++ b/cmd/sessions.go @@ -4,6 +4,7 @@ import ( "fmt" "net/url" + "github.com/nextlevelbuilder/goclaw-cli/client" "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/nextlevelbuilder/goclaw-cli/internal/tui" "github.com/spf13/cobra" @@ -150,11 +151,11 @@ var sessionsCompactCmd = &cobra.Command{ return fmt.Errorf("connect: %w", err) } defer ws.Close() - resp, err := ws.Call("sessions.compact", map[string]any{"session_key": args[0]}) + result, err := ws.SessionsCompact(client.SessionsCompactParams{Key: args[0]}) if err != nil { return err } - printer.Print(jsonToMap(resp)) + printer.Print(result) return nil }, } diff --git a/cmd/teams_tasks_advanced.go b/cmd/teams_tasks_advanced.go index 2c9bc48..049dca0 100644 --- a/cmd/teams_tasks_advanced.go +++ b/cmd/teams_tasks_advanced.go @@ -176,7 +176,7 @@ Example: } defer ws.Close() data, err := ws.Call("teams.tasks.active-by-session", map[string]any{ - "session_key": session, + "sessionKey": session, }) if err != nil { return err